Skip to content

feat(aws): expand resource surface area + add VpcOrigin, ListenerRule, TrustStore, HostedZone, HealthCheck#631

Merged
sam-goodwin merged 9 commits into
mainfrom
sam/aws-coverage
Jun 17, 2026
Merged

feat(aws): expand resource surface area + add VpcOrigin, ListenerRule, TrustStore, HostedZone, HealthCheck#631
sam-goodwin merged 9 commits into
mainfrom
sam/aws-coverage

Conversation

@sam-goodwin

Copy link
Copy Markdown
Contributor

Many AWS resources were narrow / opinionated and couldn't express the full config AWS supports. This expands them to the full surface area and adds the missing lower-level resources. All non-gated tests pass and tsgo -b is clean.

Depends on alchemy-run/distilled#351 (Route53 list-response schema fix); the submodule pointer is bumped to that commit.

New resources

CloudFront.VpcOrigin — front a private ALB/NLB/EC2 without exposing it publicly.

const origin = yield* CloudFront.VpcOrigin("api-origin", {
  arn: loadBalancer.arn,
  httpPort: 80,
  originProtocolPolicy: "https-only",
});

ELBv2.ListenerRule — ALB path/host/header/query/method/source-IP routing.

yield* ELBv2.ListenerRule("api-route", {
  listenerArn: listener.listenerArn,
  priority: 10,
  conditions: [{ pathPattern: { values: ["/api/*"] } }],
  actions: [{ type: "forward", targetGroups: [{ targetGroupArn: tg.targetGroupArn }] }],
});

ELBv2.TrustStore — CA bundle for mTLS verify mode.

yield* ELBv2.TrustStore("client-ca", {
  caCertificatesBundleS3Bucket: "my-ca-bundles",
  caCertificatesBundleS3Key: "ca-bundle.pem",
});

Route53.HostedZone + Route53.HealthCheck — Records no longer need a hand-supplied zone id, and failover routing gets a health-check source.

const zone = yield* Route53.HostedZone("example.com", { name: "example.com" });

const health = yield* Route53.HealthCheck("primary", {
  type: "HTTPS",
  fqdn: "primary.example.com",
  resourcePath: "/health",
  failureThreshold: 3,
});

Expanded resources

S3.Bucket — versioning, encryption, public-access-block, CORS, lifecycle, ownership/ACL, logging, acceleration, request-payer, website, replication, intelligent-tiering, object-lock, explicit policy.

yield* Bucket("assets", {
  versioning: "Enabled",
  encryption: { sseAlgorithm: "aws:kms", bucketKeyEnabled: true },
  publicAccessBlock: { blockPublicAcls: true, blockPublicPolicy: true },
  cors: [{ allowedMethods: ["GET"], allowedOrigins: ["https://example.com"] }],
  lifecycleRules: [{ id: "expire-old", status: "Enabled", expiration: { days: 90 } }],
});

SQS.Queue — redrive/DLQ, redrive-allow, KMS/SSE, policy, full FIFO surface.

yield* SQS.Queue("work", {
  redrivePolicy: { deadLetterTargetArn: dlq.queueArn, maxReceiveCount: 5 },
  kmsMasterKeyId: "alias/aws/sqs",
});

Route53.Record — weighted / latency / failover / geolocation / multivalue / alias routing.

yield* Route53.Record("blue", {
  hostedZoneId: zone.id,
  name: "app.example.com",
  type: "A",
  setIdentifier: "blue",
  weight: 90,
  aliasTarget: { dnsName: blueLb.dnsName, hostedZoneId: blueLb.canonicalHostedZoneId, evaluateTargetHealth: true },
});

ELBv2.Listener — full action union (redirect, fixed-response, weighted forward, OIDC/Cognito auth), mTLS, ALPN, SSL policy, SNI certs. LoadBalancer + TargetGroup gaps filled.

yield* ELBv2.Listener("https", {
  // ...
  defaultActions: [{ type: "redirect", redirectConfig: { protocol: "HTTPS", port: "443", statusCode: "HTTP_301" } }],
  certificates: [primaryCertArn, sniCertArn],
  sslPolicy: "ELBSecurityPolicy-TLS13-1-2-2021-06",
  mutualAuthentication: { mode: "verify", trustStoreArn: trustStore.trustStoreArn },
});

ECS.Task / ECS.Service — multi-container task definitions (sidecars + task-level config); de-opinionated Service: ALB is optional, plus capacity-provider strategy, deployment circuit breaker, placement, exec, with corrected replace-vs-in-place diff.

const task = yield* ECS.Task("web", {
  main: "nginx:latest",
  sidecars: [{ name: "log-router", image: "fluent/fluent-bit:latest", essential: false }],
  networkMode: "awsvpc",
  volumes: [{ name: "tmp" }],
});

yield* ECS.Service("web", {
  // ...
  desiredCount: 3,
  capacityProviderStrategy: [{ capacityProvider: "FARGATE_SPOT", weight: 1 }],
  loadBalancers: [/* optional — no longer force-created */],
});

RDS.DBInstance / RDS.DBCluster (passed through Aurora) — storage/IOPS, engine version, multi-AZ, performance insights, enhanced monitoring, backup/maintenance windows, parameter/option groups, encryption/KMS, IAM auth, log exports, serverless-v2 scaling. Diff replacement-sets corrected so in-place-updatable knobs no longer force replacement.

yield* RDS.DBInstance("db", {
  // ...
  allocatedStorage: 100,
  storageType: "gp3",
  multiAZ: true,
  performanceInsightsEnabled: true,
  deletionProtection: true,
  enabledCloudwatchLogsExports: ["postgresql"],
});

Testing

Non-gated live tests pass for every resource and tsgo -b is clean. Lifecycles that need slow provisioning or external prerequisites are implemented and skipIf-gated behind env vars (RDS_TEST_LIFECYCLE, CLOUDFRONT_TEST_VPC_ORIGIN, ELBV2_TEST_MTLS, S3_TEST_REPLICATION); making those green is in progress, which is why this is a draft.

…, TrustStore, HostedZone, HealthCheck

Many AWS resources were narrow/opinionated and could not express the full
config AWS supports. Expand them to the full surface and add the missing
lower-level resources.

New resources:
- CloudFront.VpcOrigin   — front a private ALB/NLB/EC2 without public exposure
- ELBv2.ListenerRule     — ALB path/host/header/query/method/source-IP routing
- ELBv2.TrustStore       — CA bundle for mTLS verify mode
- Route53.HostedZone     — Records no longer need a hand-supplied zone id
- Route53.HealthCheck    — health-check source for failover routing

Expanded:
- S3.Bucket    — versioning, encryption/SSE-KMS, public-access-block, CORS,
                 lifecycle, ownership/ACL, logging, acceleration, request-payer,
                 website, replication, intelligent-tiering, object-lock, policy
- SQS.Queue    — redrive/DLQ, redrive-allow, KMS/SSE, policy, full FIFO surface
- Route53.Record — weighted/latency/failover/geolocation/multivalue/alias routing
- ELBv2.Listener — full action union (redirect, fixed-response, weighted forward,
                   OIDC/Cognito auth), mTLS, ALPN, SSL policy, SNI certs;
                   LoadBalancer + TargetGroup gaps filled
- ECS.Task/Service — multi-container task definitions; de-opinionated Service
                     (optional ALB, capacity providers, deployment circuit
                     breaker, placement, exec) with corrected replace-vs-in-place
- RDS.DBInstance/DBCluster (+ Aurora) — storage/IOPS, engine version, multi-AZ,
                 performance insights, enhanced monitoring, backup/maintenance
                 windows, parameter/option groups, encryption/KMS, IAM auth,
                 log exports, serverless-v2 scaling; diff replacement-set fixes

Requires alchemy-run/distilled#351 (route-53 list-response schema fix); the
submodule pointer is bumped to that commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alchemy-version-bot

alchemy-version-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Install the packages built from this commit:

alchemy

bun add alchemy@https://pkg.ing/alchemy/c6d67f0

@alchemy.run/better-auth

bun add @alchemy.run/better-auth@https://pkg.ing/@alchemy.run/better-auth/c6d67f0

@alchemy.run/pr-package

bun add @alchemy.run/pr-package@https://pkg.ing/@alchemy.run/pr-package/c6d67f0

sam-goodwin and others added 2 commits June 16, 2026 23:35
- ELBv2 TrustStore: regenerate the checked-in CA fixture as X.509 v3 with
  CA:TRUE (v1 certs are rejected with "certificate version is not supported");
  the full mTLS lifecycle now passes.
- S3 replication: provision the IAM replication role + versioned destination
  bucket in-test so it is self-contained (drop the S3_TEST_REPLICATION gate and
  the S3_REPLICATION_ROLE_ARN env dependency).
- RDS DBInstance/DBCluster: the testing account has no default VPC/subnets, so
  provision an EC2.Network (VPC + subnets across 2 AZs) + DBSubnetGroup and pass
  dbSubnetGroupName.
- CloudFront VpcOrigin: use EC2.Network (attaches the IGW CloudFront requires),
  deploy networking before the origin, and raise the test timeout to 30m
  (CloudFront VPC-origin global propagation is slow).

Bumps the distilled submodule to include the CloudFront VpcOriginList
list-response schema fix (alchemy-run/distilled#351).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DBInstance/DBCluster delete returned as soon as deletion was *initiated*. RDS
deletion is async, so a dependent (DBSubnetGroup, VPC) was torn down next while
the DB was still `deleting`, and AWS rejected it with
`InvalidDBSubnetGroupStateFault: ... still using it`. Poll describeDB* after
delete until NotFound (bounded ~10 min) so teardown ordering is correct.

Tests: the RDS lifecycle tests now let the engine generate a unique
DBSubnetGroup name (a leftover group from an interrupted run otherwise forces a
cross-VPC ModifyDBSubnetGroup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* proxies (Envoy/App Mesh), metric agents (otel/cloudwatch), or any
* companion process that shares the task's network namespace.
*/
sidecars?: ecs.ContainerDefinition[];

@bbarks15 bbarks15 Jun 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nice work on this. one thing on the Task container shape, feel free to ignore if you've already thought about it.

the main/container/sidecars setup feels a bit different from how task defs normally work everywhere else (aws api, terraform, cfn, cdk all just take a flat list of containers). ecs doesn't really have a "primary" container either, it's just the essential flag plus the service picking one by name, so the primary vs sidecar thing is kind of us inventing it.

wonder if it'd be cleaner to keep it as a flat array and have the bundling just be an image source, something like:

containers: [
    { name: "app", image: Bundle({ main: import.meta.filename }), essential: true },
    { name: "otel", image: "public.ecr.aws/.../otel:latest" },
]

then a prebuilt image task is just image: "..." with no main needed. There might be better DX then this just what came to mind. But anyway just a thought, not a blocker

sam-goodwin and others added 3 commits June 16, 2026 23:51
CloudFront VPC-origin deployment is slow (global propagation) and routinely
exceeds 10 min. The reconcile's wait bound was 600s (60 * 10s), so a real
deploy would fail spuriously with the origin still `Deploying`. Raise to
~20 min (120 * 10s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…no orphan

delete used the stale `output.etag` and could not remove an origin stuck in
`Deploying` (e.g. after an interrupted create) — CloudFront then left the
origin and its managed ENIs behind, which blocked the fronted ALB/VPC from
being torn down. Now: observe current state, wait for `Deployed` so it is
deletable, delete with the current ETag, then block until the record is gone
so dependents can be reclaimed. Returns early if already gone (idempotent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…get to 45m

The full VPC-origin lifecycle (create -> Deployed -> delete -> gone) runs
~35-40 min because CloudFront VPC-origin propagation is very slow. Keep it
gated behind CLOUDFRONT_TEST_VPC_ORIGIN with a 45-min budget; the probe + list
cover the wiring + typed-error surface in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sam-goodwin sam-goodwin marked this pull request as ready for review June 17, 2026 08:00
sam-goodwin and others added 3 commits June 17, 2026 01:48
…ions

ENIs from a just-deleted ALB or CloudFront VPC origin can take several minutes
to detach after the owning resource is gone. The subnet delete retried
DependencyViolation for only ~2 min (exponential x 10), so a VPC-origin/ALB
teardown could fail and orphan the subnet/VPC. Cap the backoff at 30s and
extend the budget to ~12 min.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deleteBucketLifecycle is eventually consistent — the rule can linger on reads
for a few seconds, so the immediate post-delete assertion flaked. Retry until
the config clears (mirrors the syncBucketCors path, which happened not to flake).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lizers

The ECS Task/Service tests register task definitions (and, for the manual-LB
service test, create an ALB / target group / listener) out of band. The trailing
deregister/delete ran only on success, so a failed or interrupted run orphaned
them (ACTIVE task-def revisions, a stray internal ALB). Register them as
Effect.addFinalizer cleanups so they are reclaimed on scope close regardless of
outcome (LIFO: listener -> target group -> load balancer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sam-goodwin sam-goodwin merged commit 6f9528a into main Jun 17, 2026
9 checks passed
@sam-goodwin sam-goodwin deleted the sam/aws-coverage branch June 17, 2026 19:41
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