Skip to content

Add AWS App Runner (Container App Hosting) Service Emulation - #391

Open
thzgajendra wants to merge 3 commits into
stackshy:developmentfrom
thzgajendra:feat/apprunner
Open

Add AWS App Runner (Container App Hosting) Service Emulation#391
thzgajendra wants to merge 3 commits into
stackshy:developmentfrom
thzgajendra:feat/apprunner

Conversation

@thzgajendra

Copy link
Copy Markdown
Collaborator

Objective

Add AWS App Runner to cloudemu at full aws-sdk-go-v2 wire parity — all 37 operations — as part of #295 Workstream B. Real aws-sdk-go-v2/service/apprunner clients and the aws apprunner CLI work against the SDK-compat server by only changing the endpoint.

What's implemented

App Runner is AWS JSON 1.0 with X-Amz-Target: AppRunner.<Op> dispatch (same protocol as Step Functions, the structural template). Four-layer architecture (services/apprunner/driverproviders/aws/apprunnerserver/aws/apprunner → registration).

  • ServicesCreateService mints an ARN + ID and returns an immediately-RUNNING service with a synthesized ServiceUrl; describe/update/delete/list; PauseService/ResumeService/StartDeployment enforce the state machine (InvalidStateException from an illegal state); every mutation records an OperationSummary surfaced by ListOperations.
  • Auto-scaling / observability / VPC-connector configurations — revision-managed (shared name, incrementing revision, one Latest, atomic under a lock).
  • Connections, VPC ingress connections, custom domains (per-service), and tags — routed by ARN across all six taggable resource kinds, atomic read-modify-write.

Quality: SetIfAbsent atomic creates; deep-copied reads (race-clean); per-op typed exceptions; optimistic lifecycle guards; deterministic Clock/idgen.

Proactive review-lessons pass

Before opening, I ran an adversarial audit against the accumulated review lessons with the exact SDK per-op error models, and fixed what it found:

  • Per-op error fidelity — ops that don't model ResourceNotFoundException (e.g. CreateVpcIngressConnection, AssociateCustomDomain) return InvalidRequestException for a missing service, not RNF; the 24 ops that do model RNF keep it.
  • Deep-copy — added Tags deep-copy on auto-scaling-config and connection reads (were shallow-aliased); regression test under -race.
  • ValidationCreateService requires SourceConfiguration; auto-scaling rejects MinSize > MaxSize; ProviderType/enum validation.

Docs

docs/services.md (service #40, operation families, Grand Total → 2786), docs/architecture.md, and regenerated docs/coverage/ (adds aws/apprunner.md). The coverage generator's determinism fix (from the MSK PR) held — no unrelated page churn.

Tests

Provider unit tests (CRUD, get-missing → RNF, pause illegal-state → InvalidState, ASC revision increments, -race no-alias for SourceConfiguration + Tags, concurrent-tag no-lost-update) and SDK-roundtrip tests using the real client.

Verification (locally)

gofmt · go build ./... · go build -mod=readonly ./... · go vet · go test -race ./... · go mod tidy (no diff) · golangci-lint --new-from-rev 0 issues · CodeQL (CI's go-code-scanning suite) 0 findings in the service path · CLI + wire E2E (create RUNNING → pause → illegal-pause→InvalidState → resume → 3 recorded operations → tag routing → ghost-service create→InvalidRequest → missing describe→RNF).

Risk & rollback

Additive: a new service package + registration (X-Amz-Target dispatched, unambiguous with S3). No shared-code changes. Revert by dropping the branch.

Full aws-sdk-go-v2/service/apprunner parity — all 37 operations — over the AWS
JSON 1.0 (X-Amz-Target: AppRunner.<Op>) wire protocol:
- Services (create RUNNING, describe/update/delete/list, pause/resume state
  machine, StartDeployment) with per-service operation history (ListOperations)
- Auto-scaling / observability / VPC-connector configs (revision-managed),
  connections, VPC ingress connections, custom domains, and tags (routed by ARN
  across all six taggable resource kinds)
- SetIfAbsent atomic creates, deep-copied reads, per-op typed exceptions matching
  each op's SDK error model, optimistic lifecycle guards, deterministic Clock/idgen

Includes a proactive review-lessons pass: per-op error fidelity
(CreateVpcIngressConnection/AssociateCustomDomain -> InvalidRequestException, not
NotFound), Tags deep-copy on auto-scaling-config and connection reads, and
required-field/enum validation. docs/services.md (service stackshy#40), architecture.md,
and generated coverage.

@NitinKumar004 NitinKumar004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thorough review of the new App Runner service. This is a strong PR and the proactive review-lessons pass clearly worked — credit up front:

  • Concurrency is fully clean. All four sibling-PR bug classes are structurally prevented: creates are atomic test-and-set; auto-scaling / observability configs are revision-managed under one lock with exactly-one-Latest (12 concurrent same-name creates → 12 distinct revisions, one Latest); all six taggable kinds do single-lock read-modify-write (no lost updates, probe-verified); reads are fully deep-copied (including the previously-shallow ASC/connection Tags, now fixed); lifecycle transitions are atomically guarded; and the orphan class is unreachable (soft-delete + nested children).
  • The op set is exactly faithful — all 37 real apprunner@v1.39.14 operations, nothing invented, and correctly no DescribeConnection (App Runner has only Create/List/Delete for connections).
  • Error taxonomy is correct for 36 of 37 ops — the 24 RNF-modeling ops keep RNF; ops that don't model RNF (AssociateCustomDomain, CreateVpcIngressConnection) return InvalidRequestException. Routing (X-Amz-Target: AppRunner. prefix, unique across handlers), casing (no json:"...ID" bug), epoch-seconds timestamps, pagination, and the service state machine are all faithful.

Requesting changes on one wire-fidelity High plus a Medium (details inline).

High

StartDeployment emits InvalidStateException, which the op does not model. Verified against the SDK: StartDeployment's error deserializer models only InternalServiceErrorException, InvalidRequestException, and ResourceNotFoundException — no InvalidStateException case. So start-deployment on a non-RUNNING service sends an exception a real smithy client can't deserialize as typed (errors.As(&*types.InvalidStateException) fails → generic untyped APIError); real App Runner returns InvalidRequestException. The other three state-guards (PauseService/ResumeService, DeleteService, DisassociateCustomDomain) all correctly use InvalidStateException because those ops model it — StartDeployment is the outlier. Fix: emit invalidRequest at services.go:232. This path is also untested (see coverage), which is why it slipped.

Medium

  • Delete-in-use is not rejected. DeleteAutoScalingConfiguration and DeleteVpcConnector (and DeleteObservabilityConfiguration) succeed even when a service still references them; real App Runner returns InvalidStateException for an in-use config/connector. The in-use query already exists and is genuinely implemented (ListServicesForAutoScalingConfiguration scans services by AutoScalingConfigArn) — delete just doesn't consult it.
  • SDK round-trip coverage is 26/37. StartDeployment is untested (a round-trip asserting the typed error on start-deployment-of-paused would have caught the High), along with the Update/Delete config ops and the VPC connector/ingress describes. Recommend adding StartDeployment (happy + wrong-state) and the config-revision delete/describe ops.

Low

  • DisassociateCustomDomain returns InvalidRequestException for a missing service, but the op models ResourceNotFoundException (its sibling DescribeCustomDomains correctly uses RNF) — not wire-breaking (IRE is also modeled), but inconsistent with real AWS.
  • DeleteService is a soft-delete that doesn't disassociate its per-service children (custom domains / VPC ingress connections linger), and operations still succeed against a DELETED service (tag / associate-custom-domain / create-vpc-ingress) where real App Runner rejects them.
  • SourceConfiguration one-of (CodeRepository XOR ImageRepository) and instance cpu/memory combos aren't validated (only non-empty is checked); ProviderType GITHUB/BITBUCKET is validated.
  • ExServiceQuotaExceeded is declared but never emitted (dead constant) — wire it to a quota guard or drop it.

On "full parity"

Wire/operation parity essentially holds (faithful op set, taxonomy correct bar the one op, correct protocol/casing/timestamps); behavioral parity is best-effort — immediate-RUNNING is documented, but the delete-in-use and deleted-service state guards are missing. Recommend the wording reflect that.

Comment thread providers/aws/apprunner/services.go Outdated
defer sd.mu.Unlock()

if sd.svc.Status != driver.ServiceStatusRunning {
return "", invalidState("service %q is %s; StartDeployment requires RUNNING", arn, sd.svc.Status)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — StartDeployment does not model InvalidStateException. Verified against apprunner@v1.39.14: this op's error deserializer has cases only for InternalServiceErrorException, InvalidRequestException, and ResourceNotFoundException. Emitting invalidState here means start-deployment on a non-RUNNING service returns an exception a real client can't deserialize as typed (errors.As(&*types.InvalidStateException) fails → generic APIError); real App Runner returns InvalidRequestException. Use invalidRequest(...) instead. (Pause/Resume/Delete/Disassociate correctly use invalidState because those ops DO model it — StartDeployment is the exception.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. StartDeployment now returns InvalidRequestException for a non-RUNNING service. Verified against apprunner@v1.39.14 that its error deserializer models only Internal/InvalidRequest/ResourceNotFound (not InvalidState). Added TestStartDeploymentIllegalStateIsInvalidRequest + E2E.

// 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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — DeleteAutoScalingConfiguration deactivates the revision unconditionally. Real App Runner rejects deleting a configuration that a service still uses with InvalidStateException. The in-use query is already implemented right here (ListServicesForAutoScalingConfiguration scans services by AutoScalingConfigArn) — consult it before deleting and return InvalidStateException when non-empty.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — with one correction. DeleteAutoScalingConfiguration now rejects an in-use config (any service referencing it via AutoScalingConfigArn, reusing the ListServicesForAutoScalingConfiguration scan; a delete-all-revisions checks every revision of the name). One nuance: I verified this op does NOT model InvalidStateException (only Internal/InvalidRequest/ResourceNotFound), so the in-use rejection is InvalidRequestException, not InvalidState — emitting InvalidState here would be a type a real client cannot deserialize for this op (the same class as the StartDeployment finding). Added TestDeleteAutoScalingConfigurationInUse + E2E.

}

// DeleteVpcConnector marks a connector INACTIVE and stamps its deletion time.
func (m *Mock) DeleteVpcConnector(_ context.Context, arn string) (*driver.VpcConnector, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — same delete-in-use gap: DeleteVpcConnector succeeds even when a service references this connector via its NetworkConfiguration. Real App Runner returns InvalidStateException for an in-use VPC connector. Gate the delete on whether any service references it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — same nuance as the ASC delete. DeleteVpcConnector now rejects a connector referenced by any service via its egress NetworkConfiguration.EgressConfiguration.VpcConnectorArn. This op does NOT model InvalidStateException either (only Internal/InvalidRequest/ResourceNotFound), so the in-use rejection is InvalidRequestException. Added TestDeleteVpcConnectorInUse + E2E.

) (*driver.CustomDomain, string, error) {
sd, ok := m.services.Get(serviceArn)
if !ok {
return nil, "", invalidRequest("no App Runner service found for ARN %q", serviceArn)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — DisassociateCustomDomain returns InvalidRequestException for a missing service, but this op models ResourceNotFoundException (and the sibling DescribeCustomDomains correctly uses RNF via getService). Not wire-breaking since IRE is also modeled, but it diverges from real AWS and from its sibling — prefer notFound here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. DisassociateCustomDomain now returns ResourceNotFoundException for a missing service (it models RNF, matching the DescribeCustomDomains sibling). AssociateCustomDomain keeps InvalidRequestException since it does NOT model RNF. Added TestDisassociateCustomDomainMissingServiceIsNotFound + E2E.

Comment thread services/apprunner/driver/errors.go Outdated
ExInvalidRequest = "InvalidRequestException"
ExInvalidState = "InvalidStateException"
ExResourceNotFound = "ResourceNotFoundException"
ExServiceQuotaExceeded = "ServiceQuotaExceededException"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — ExServiceQuotaExceeded is declared but never emitted anywhere (the six Create ops model it, but the emulator has no quotas). Dead code per the no-unused-symbols rule — wire it to a quota guard or remove it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Removed the dead ExServiceQuotaExceeded constant — the emulator models no quotas, so nothing emits it. (Went with removal rather than inventing an arbitrary quota limit that would diverge from real per-region defaults.)

- StartDeployment on an illegal state returns InvalidRequestException (the op
  does NOT model InvalidStateException; only Pause/Resume/Delete/Disassociate do)
- DeleteAutoScalingConfiguration and DeleteVpcConnector reject an in-use resource
  with InvalidRequestException (neither op models InvalidStateException, so the
  in-use rejection is a 400 InvalidRequest, not InvalidState) — in-use is
  detected by scanning services' AutoScalingConfigArn / egress VpcConnectorArn
- DisassociateCustomDomain returns ResourceNotFoundException for a missing service
  (it models RNF, unlike AssociateCustomDomain)
- Removed the dead ExServiceQuotaExceeded constant (no quotas modeled)
- Regression tests for each fix
@thzgajendra

Copy link
Copy Markdown
Collaborator Author

Thanks @NitinKumar004 — all 5 addressed (pushed on the latest commit).

  • StartDeployment illegal-state → InvalidRequestException (op doesn't model InvalidState).
  • DeleteAutoScalingConfiguration / DeleteVpcConnector now reject an in-use resource — detected by scanning services' AutoScalingConfigArn / egress VpcConnectorArn. Per-op nuance: neither op models InvalidStateException, so the in-use rejection is InvalidRequestException (not InvalidState) — same fidelity class as the StartDeployment finding.
  • DisassociateCustomDomain missing service → ResourceNotFoundException (it models RNF; Associate keeps InvalidRequest since it doesn't).
  • Removed the dead ExServiceQuotaExceeded constant.

Each fix has a regression test. Verified: gofmt / vet / build / -mod=readonly / go test -race / go mod tidy (no diff) / golangci-lint --new-from-rev 0 / CodeQL (CI suite) 0 in the service path / CLI E2E on all five.

@NitinKumar004 NitinKumar004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review notes

Real data-plane engine (per #427): MISSING — App Runner is ContainerEngine-eligible (it HOSTS a container image as a long-running service, exactly like ECS services / ACI / Cloud Run, all of which route through services/container/containerengine per #427). The PR wires NO engine: CreateService returns a synthetic Status=RUNNING + ServiceURL, StartDeployment only records a SUCCEEDED operation, DeleteService just flips status to DELETED — none call containerengine.Run/Stop.

Staleness / integration: CONFLICTS(docs/architecture.md) — docs-only, trivial. The PR is 29 commits behind development; merging origin/development into the PR head conflicts ONLY in docs/architecture.md (PR appended "+ App Runner" to a prose list; development rewrote the whole file to a mermaid layout).

Terraform compat: GAP.

coveragegen / docs autogen: OK.

Findings

High · real-engine — App Runner is fully synthetic — no ContainerEngine wiring, diverges from #427 (ECS/ACI/Cloud Run)
providers/aws/apprunner/services.go:44
config.WithContainerEngine has no effect on App Runner: a user who opts into the real data plane gets a real container for ECS/ACI/Cloud Run but a fake RUNNING service from App Runner, so integration tests that exercise the hosted app (hit ServiceURL, read container logs, observe crash/restart) pass against nothing. The engine seam exists and is plumbed (opts reaches New) but is unused, so this is a silent behavioral gap, not a compile error — the PR's own green CI cannot catch it.

CreateService sets Status: driver.ServiceStatusRunning + a fabricated ServiceURL and returns immediately; StartDeployment (services.go:222) only records a SUCCEEDED operation; DeleteService (services.go:92) flips status to DELETED. None route through services/container/containerengine.Run/Stop. apprunner.New(o) (apprunner.go:96) stores config.Options o — which carries o.ContainerEngine — but the Mock never reads it. ECS (providers/aws/ecs/engine.go:30 backTaskWithEngine) and Cloud Run (providers/gcp/cloudrun/engine

Medium · wire-fidelity — ListServices returns DELETED services forever
providers/aws/apprunner/services.go:168
If a user calls DeleteService(arn) and then ListServices(), the deleted service still appears in ServiceSummaryList (Status DELETED) permanently, because the store keeps the tombstone and ListServices does not filter it out. Real aws apprunner list-services never returns deleted services, so client code that enumerates live services via ListServices double-counts and may operate on a deleted service's ARN.

ListServices iterates m.services.SortedValues() with no status filter, and DeleteService (line 92-115) intentionally retains the record with Status=DELETED rather than removing it. No test deletes-then-lists (TestServiceCRUD deletes but never re-lists; TestSDKListServices/TestListServicesPagination never delete).

Medium · wire-fidelity — List{AutoScaling,Observability}Configurations return INACTIVE (deleted) revisions, contradicting docstring and real API
providers/aws/apprunner/autoscaling.go:175
If a user creates ASC 'hi' rev1, deletes it (rev1 -> INACTIVE), then calls ListAutoScalingConfigurations(name='hi', latestOnly=false), the deleted rev1 is still returned. Real App Runner returns only active configurations, and the function's own docstring promises ACTIVE-only, so a caller enumerating live/deletable revisions acts on an already-deleted revision.

ListAutoScalingConfigurations (autoscaling.go:175) and ListObservabilityConfigurations (observability.go:96) filter only on name and latestOnly, never on Status, yet both carry the doc comment 'lists ACTIVE revisions'. Delete sets Status=INACTIVE but leaves the revision in the map. TestObservabilityRevisions deletes rev1 then lists with latestOnly=true, which already excludes rev1 (it was never Latest), so the INACTIVE-still-listed path is untested.

Medium · staleness — Stale by 29 commits — docs/architecture.md conflicts on merge with development
docs/architecture.md:60
PR cannot be merged as-is; needs a rebase onto development. Resolution is trivial (re-insert 'App Runner' into the rewritten containers list), but until rebased the branch is red on merge and the App Runner mention would be lost if 'theirs' is taken blindly.

merge-base 6ff27cf; git merge origin/development into the PR head fails only in docs/architecture.md. The PR appended '+ App Runner' to the server/ prose list; development rewrote the entire file (three-layer prose -> mermaid flowchart). Code files auto-merge cleanly and build/vet/test pass after resolving.

Low · wire-fidelity — HasAssociatedService wire field is never populated (always false)
services/apprunner/driver/types.go:150
If a user creates an ASC, creates a service referencing it, then calls DescribeAutoScalingConfiguration/ListAutoScalingConfigurations, HasAssociatedService comes back false despite an active service using the config. SDK consumers that gate deletion or reporting on HasAssociatedService are misled into thinking the config is unused.

HasAssociatedService is defined on the driver type and emitted in both wireASC (types.go:180) and ascSummaryItem (types.go:195), but grep shows it is never assigned anywhere in providers/aws/apprunner — the provider has ascInUse()/ListServicesForAutoScalingConfiguration to compute association but never sets this field.

Low · wire-fidelity — ListOperations ordered by random operation Id, not reverse-chronological
providers/aws/apprunner/operations.go:26
If a user performs Create then Pause then Update on a service and calls ListOperations, OperationSummaryList is ordered by an arbitrary random id rather than newest-first, so a client reading OperationSummaryList[0] to obtain the latest operation gets an arbitrary one. (Pagination itself stays consistent since the token is the same Id key.)

ListOperations sorts sd.ops by o.ID (idgen.GenerateID random) ascending. Real App Runner returns operations in reverse chronological order (most recent first).

Low · correctness — CreateService silently accepts an unknown AutoScalingConfigurationArn; ASC min>max validation bypassable
providers/aws/apprunner/autoscaling.go:252
If a user calls CreateService with AutoScalingConfigurationArn set to a non-existent config, the service is created RUNNING with an empty AutoScalingConfigurationSummary name/revision instead of the InvalidRequestException real App Runner returns, so the SDK response is internally inconsistent and the misconfiguration goes undetected.

attachDefaultASC looks up svc.AutoScalingConfigArn in ascByArn; on miss it simply returns, leaving AutoScalingConfigName/Revision empty, and CreateService never validates the ARN exists. Separately, CreateAutoScalingConfiguration validation (autoscaling.go:70) is minSize>0 && maxSize>0 && minSize>maxSize, so passing minSize=30 with maxSize unset (defaulted to 25) skips the check and stores an invalid min>max config.

Low · terraform — App Runner absent from SDK compat matrix and terraform harness
compat/aws/containerregistry_compat_test.go:1
App Runner's real-cloud fidelity is asserted only by the PR's own SDK test, not by the repo's cross-cutting compat matrix or terraform suite, so a future wire-shape regression (e.g. a source_configuration field that stops round-tripping and would show a terraform perpetual diff) would go uncaught by the shared harnesses.

No compat/aws/apprunner_compat_test.go and no apprunner entry in docs/compat/coverage — App Runner will not appear in the generated compatibility matrix. contrib/terraform/fixtures/ has only basic/networking/wrapper, so there is no terraform apply -> post-apply-plan(no changes) round-trip for aws_apprunner_service et al. The real-SDK roundtrip in server/aws/apprunner/sdk_roundtrip_test.go covers the shapes but is not the terraform/compat harness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants