Skip to content

feat(aws)!: unified container platforms — ECS Task/Service, EKS Deployment/Job/Manifest, Dockerfile.inline image composition - #867

Merged
sam-goodwin merged 25 commits into
mainfrom
claude/container-platform-redesign
Jul 21, 2026
Merged

feat(aws)!: unified container platforms — ECS Task/Service, EKS Deployment/Job/Manifest, Dockerfile.inline image composition#867
sam-goodwin merged 25 commits into
mainfrom
claude/container-platform-redesign

Conversation

@sam-goodwin

@sam-goodwin sam-goodwin commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

One consistent container interface across ECS, EKS, and Cloudflare, modeled on the Worker authoring triad (external / inline effect / tagged effect). Design doc: processes/AWS/design/container-platform-redesign.md (local).

Every platform takes one image source, flat on props — image composes with main (there is no baseImage):

{ main }                                   // bundle this Effect program (default bun base)
{ main, image }                            // bundle FROM your base image
{ main, dockerfile: Dockerfile.inline`…` } // bundle in an inline-Dockerfile environment
{ context, dockerfile? }                   // build your Dockerfile (a string is always a PATH)
{ dockerfile: Dockerfile.inline`…` }       // inline content as the whole Dockerfile
{ image }                                  // registry ref deployed verbatim, mirrored into ECR / CF registry

Servers return { fetch, ...rpc }; one-shots return { run, ...rpc }. Tagged forms are always P<Self, Shape>().

AWS.ECS.Task — one-shot

// external (remote image) — no impl, no Effect runtime. Note: no `cluster` —
// a Task maps to AWS::ECS::TaskDefinition, which is cluster-independent; the
// cluster is declared where the launch happens (RunTask / Schedule / Service).
const migrate = yield* AWS.ECS.Task("DbMigrate", {
  image: "public.ecr.aws/docker/library/busybox:stable",
  command: ["sh", "-c", "echo done"],
  cpu: 256,
  memory: 512,
});

// external (build your Dockerfile)
const render = yield* AWS.ECS.Task("RenderJob", {
  context: "./render",
  dockerfile: "./render/Dockerfile.gpu",  // a PATH
  cpu: 1024,
  memory: 4096,
});

// inline effect
const drainer = yield* AWS.ECS.Task(
  "QueueDrainer",
  { main: import.meta.url, image: "oven/bun:1", cpu: 256, memory: 512 },
  Effect.gen(function* () {
    const receive = yield* AWS.SQS.ReceiveMessage(queue);
    return { run: Effect.gen(function* () { /* runs to completion, container exits */ }) };
  }),
);

// tagged effect
export class Reindexer extends AWS.ECS.Task<Reindexer, {
  status: () => Effect.Effect<ReindexStatus>;
}>()("Reindexer") {}

export default Reindexer.make(
  { main: import.meta.url, cpu: 512, memory: 1024 },
  Effect.gen(function* () {
    const scan = yield* AWS.DynamoDB.Scan(table);
    return { run: Effect.gen(function* () { /* ... */ }), status: () => Effect.succeed({ phase: "idle" }) };
  }).pipe(Effect.provide(AWS.DynamoDB.ScanHttp)),
);

// invoke / schedule a Task from other compute — the cluster is explicit at
// the launch site, never on the Task
const runTask = yield* AWS.ECS.RunTask(cluster, thumbnailer);
yield* runTask({ overrides: { environment: { KEY: objectKey } } });
yield* AWS.ECS.Schedule("Nightly", { cluster, task: thumbnailer, schedule: "cron(0 3 * * ? *)" });

AWS.ECS.Service — long-running server (new platform)

// external (remote image) behind an ALB
const nginx = yield* AWS.ECS.Service("Edge", {
  cluster,
  image: "public.ecr.aws/nginx/nginx:1.27",
  port: 80,
  desiredCount: 2,
  loadBalancer: true,
});
nginx.url; // string | undefined

// external (run an existing Task's definition)
const apiFromTask = yield* AWS.ECS.Service("Api", {
  cluster,
  task: apiTask,
  desiredCount: 2,
  loadBalancer: true,
});

// inline effect
const api = yield* AWS.ECS.Service(
  "Api",
  { cluster, main: import.meta.url, port: 3000, desiredCount: 2, cpu: 256, memory: 512 },
  Effect.gen(function* () {
    const putItem = yield* AWS.DynamoDB.PutItem(table);
    return { fetch: Effect.gen(function* () { /* ... */ }) };
  }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)),
);

// tagged effect
export class Api extends AWS.ECS.Service<Api, {
  health: () => Effect.Effect<string>;
}>()("Api") {}

export default Api.make(
  { cluster, main: import.meta.url, port: 3000, desiredCount: 2 },
  Effect.gen(function* () {
    return { fetch: Effect.gen(function* () { /* ... */ }), health: () => Effect.succeed("ok") };
  }),
);

AWS.EKS.Deployment — replicated K8s server (was ServerHost)

// external (remote image)
const nginx = yield* AWS.EKS.Deployment("Nginx", {
  cluster,
  image: "nginx:1.27",
  replicas: 3,
  port: 80,
  serviceType: "LoadBalancer",
});
nginx.url; nginx.deploymentName; nginx.serviceAccountName;

// external (build your Dockerfile)
const legacy = yield* AWS.EKS.Deployment("LegacyApp", { cluster, context: "./legacy", replicas: 2, port: 8080 });

// inline effect
const api = yield* AWS.EKS.Deployment(
  "Api",
  { cluster, main: import.meta.url, port: 3000, replicas: 2, serviceType: "LoadBalancer" },
  Effect.gen(function* () {
    const putItem = yield* AWS.DynamoDB.PutItem(table);   // → pod-identity IAM policy
    return { fetch: Effect.gen(function* () { /* ... */ }) };
  }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)),
);

// tagged effect
export class Api extends AWS.EKS.Deployment<Api, {
  health: () => Effect.Effect<string>;
}>()("Api") {}

export default Api.make(
  Effect.gen(function* () {
    const cluster = yield* AWS.EKS.Cluster.ref("Primary");
    return { cluster, main: import.meta.url, port: 3000 };
  }),
  Effect.gen(function* () {
    return { fetch: Effect.gen(function* () { /* ... */ }), health: () => Effect.succeed("ok") };
  }),
);

// escape hatch: literal deep-partial Pod template merged onto the synthesized one
const tuned = yield* AWS.EKS.Deployment("Api", {
  cluster, main: import.meta.url, port: 3000,
  podTemplate: {
    spec: { tolerations: [{ key: "gpu", operator: "Exists" }], nodeSelector: { pool: "arm" } },
  },
});

AWS.EKS.Job — run-to-completion (new)

// external
const migrate = yield* AWS.EKS.Job("DbMigrate", { cluster, image: "ghcr.io/acme/migrator:v3", backoffLimit: 2 });

// inline effect
const seed = yield* AWS.EKS.Job(
  "SeedData",
  { cluster, main: import.meta.url },
  Effect.gen(function* () {
    const putItem = yield* AWS.DynamoDB.PutItem(table);
    return { run: Effect.gen(function* () { /* runs to completion */ }) };
  }),
);

// tagged effect
export class Backfill extends AWS.EKS.Job<Backfill, {
  progress: () => Effect.Effect<number>;
}>()("Backfill") {}

export default Backfill.make(
  { cluster, main: import.meta.url, backoffLimit: 1 },
  Effect.gen(function* () {
    const scan = yield* AWS.DynamoDB.Scan(table);
    return { run: Effect.gen(function* () { /* ... */ }), progress: () => Effect.succeed(0) };
  }).pipe(Effect.provide(AWS.DynamoDB.ScanHttp)),
);

// scheduled — synthesizes a K8s CronJob
const nightly = yield* AWS.EKS.Job("NightlyBackfill", { cluster, main: import.meta.url, schedule: "0 3 * * *" });

AWS.EKS.Manifest — raw manifests (new)

The manifest is a literal object, exactly as you would write it in YAML (CRDs resolve via API discovery):

const sts = yield* AWS.EKS.Manifest("Cache", {
  cluster,
  manifest: {
    apiVersion: "apps/v1",
    kind: "StatefulSet",
    metadata: { name: "cache", namespace: "apps" },
    spec: { replicas: 3, /* ... */ },
  },
});

const crd = yield* AWS.EKS.Manifest("Custom", {
  cluster,
  manifest: { apiVersion: "acme.io/v1", kind: "Widget", metadata: { name: "w" }, spec: { /* CRDs ok */ } },
});

Cloudflare.Container

// external (remote image)
class Alpine extends Cloudflare.Container<Alpine>()("Alpine", {
  image: "ghcr.io/alpine/alpine:latest",
  instanceType: "lite",
}) {}

// external (build your Dockerfile)
class Sandbox extends Cloudflare.Container<Sandbox>()("Sandbox", {
  context: `${import.meta.dirname}/context`,
}) {}

// tagged effect (the only effectful form — the impl ships to a different bundle than the DO)
export class Runner extends Cloudflare.Container<Runner, {
  ping: () => Effect.Effect<string>;
}>()("Runner") {}

export default Runner.make(
  { main: import.meta.url, image: "oven/bun:latest", instanceType: "standard" },
  Effect.gen(function* () {
    const bucket = yield* Cloudflare.R2.ReadWriteBucket(store);
    return { ping: () => Effect.succeed("pong"), fetch: /* ... */ };
  }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketHttp)),
);

Shared load balancers (ECS.Service)

Rules attach to listeners; ALBs own listeners. String listen ⇒ owned; Listener ref ⇒ shared (the service owns only its TargetGroups + ListenerRules); mixing is a typed error.

// one ALB + listener owned at the stack level
const alb   = yield* AWS.ELBv2.LoadBalancer("Shared", { subnets, securityGroups });
const https = yield* AWS.ELBv2.Listener("Https", { loadBalancer: alb, port: 443, certificates: [cert] });

// services share it — each owns only its rules
const api = yield* AWS.ECS.Service("Api", {
  cluster, main: import.meta.url, port: 3000,
  loadBalancer: {
    listener: https,
    rules: [
      { path: "/api/*" },
      { host: "api.acme.com" },
      { forward: "9090/http", container: "metrics", path: "/metrics" },
    ],
  },
}, impl);

// simplest sharing: bare ref, default rule
const web = yield* AWS.ECS.Service("Web", { cluster, image: "ghcr.io/acme/web", port: 8080, loadBalancer: https });

// owned, many rules incl. redirect
loadBalancer: { rules: [{ listen: "443/https" }, { listen: "80/http", redirect: "443/https" }] }
  • Priorities auto-derive (pinned FNV-1a of the rule's logical id, 1–50000); live collisions are a typed ListenerRulePriorityInUse suggesting an explicit priority.
  • LB wiring is now composed resources (SecurityGroup/LoadBalancer/Listener/TargetGroup/ListenerRule under the service's namespace) instead of inline API calls; destroying a service removes exactly its rules/TGs and never touches shared infra. Legacy inline-created ingress is reaped on first reconcile (live migration test).
  • Verified live: two services sharing one listener with distinct path rules; partial destroy removes one service's rule+TG while the sibling keeps routing and the ALB survives; zero-orphan scan clean.

ECS.Service Phase 2 (SST parity) + live-example hardening

const api = yield* AWS.ECS.Service("Api", {
  cluster, main: import.meta.url, port: 3000,
  loadBalancer: { domain: "api.acme.com", rules: [{ listen: "443/https" }, { listen: "80/http", redirect: "443/https" }],
                  health: { "3000/http": { path: "/health", interval: "10 seconds" } } },
  scaling: { min: 1, max: 8, cpuUtilization: 70, requestCount: 500 },
  capacity: "spot",
  secrets: { DB_PASSWORD: dbSecret.arn },
  logging: { retention: "3 days" },
  serviceRegistry: { namespace, port: 3000 },
  healthCheck: { command: ["CMD-SHELL", "curl -f localhost:3000/health || exit 1"] },
  volumes: [{ efs: files, path: "/mnt/data" }],
}, impl);

NLB via tcp/udp/tls rule protocols; ACM gains region; scaling composes ApplicationAutoScaling resources via a new onCreate hook. All features have live fixture tests (DNS-validated domain path env-gated).

examples/aws-ecs is rewritten as an orders app exercising every authoring form and image source (shared ALB, tagged-form Api + external Web with path rules, { run } seed Task launched via the RunTask binding, cron Schedule, context: build) — deployed/curled/destroyed with a zero-orphan sweep. Deploying it live surfaced and fixed six real bugs the suites missed: serve self-recursion OOM on { fetch } impls, { fetch } containers exiting into a crash-loop, Fargate credential-chain resolution, an invalid Scheduler trust policy, multi-arch image mirroring (docker push --platform), and tagged-form (X.make Layer) boot on ECS + main:-source drift never invalidating images.

Breaking changes & how to migrate

Scan your code for these; each has a detailed diff below.

If you have Migrate to
docker: { base: "..." } on ECS/EKS props image: "..." next to main
docker: { dockerfile: "<content>" } (ECS/EKS) dockerfile: Dockerfile.inline content — or the external { image, command } form when the bundled program was a placeholder
Cloudflare Container dockerfile: "FROM ..." (content string) image: "..." for a bare base, or dockerfile: Dockerfile.inline for content
One-shot ECS Task impls returning { fetch } return { run }
AWS.EKS.ServerHost AWS.EKS.Deployment (tagged form takes two type args)
AWS.EKS.Workload / LoadBalancedWorkload / PodIdentityWorkload AWS.EKS.Deployment (or AWS.EKS.Manifest for raw specs)
AWS.EKS.AutoCluster AWS.EKS.Cluster("...", { compute: "auto" })
Anything from alchemy/Kubernetes (resource providers, types, builders) removed entirely — AWS.EKS.Manifest with a literal object
cluster on AWS.ECS.Task props remove it — bind launches with RunTask(cluster, task) / StartTask(cluster, task); Schedule takes its own cluster

Redeploy impact (state, not code):

  • ECS.Service ingress: previously deployed loadBalancer: true services redeploy onto composed resources — new ALB/TG/listener/SG physical names and a new URL; the old inline-created ingress is reaped automatically on first reconcile. The managed SG now admits the container port from 0.0.0.0/0 (tasks sit in public subnets); composed TargetGroups default to fast health checks (10s interval / threshold 2) and 30s deregistration delay.
  • EKS renames change resource Type strings (ServerHostDeployment): existing state rows no longer match, so the next deploy replaces those resources (new physical names; NLB/ELB URLs change). Plan a maintenance window if the URL is consumed externally.
  • Image-source changes are hash-stable where semantics are unchanged: docker: { base } → image and CF dockerfile-content → image produce the same generated Dockerfile, so no image rebuild or task-definition churn on redeploy.

ECS.Task: docker: {} removed; external forms replace the empty-program hack

-class OneShotTask extends AWS.ECS.Task<OneShotTask>()(
-  "OneShotTask",
-  {
-    main: import.meta.filename,
-    docker: { dockerfile: 'FROM busybox:stable\nCMD ["sh","-c","echo done"]' },
-  },
-  Effect.gen(function* () {}),   // bundled program never executed
-) {}
+const oneShot = yield* AWS.ECS.Task("OneShotTask", {
+  image: "public.ecr.aws/docker/library/busybox:stable",
+  command: ["sh", "-c", "echo done"],
+});
 AWS.ECS.Task("Api", {
   main: import.meta.url,
-  docker: { base: "oven/bun:1" },
+  image: "oven/bun:1",
 }, impl)

For environments that need extra build steps, compose inline content instead of a bare base:

AWS.ECS.Task("Transcoder", {
  main: import.meta.url,
  dockerfile: Dockerfile.inline`
    FROM oven/bun:1
    RUN apt-get update && apt-get install -y ffmpeg
  `,
}, impl)

Dockerfile.inline produces { content } — a plain state-serializable object (structural discrimination, no symbol brand). Interpolations ride Output.interpolate, so FROM ${base.imageUri} is a real dependency edge; unresolved content defers plan-time hashing to reconcile. On AWS, a path dockerfile also composes with main (the env Dockerfile builds as a local stage; the bundle layers FROM it). Exclusivity is enforced with typed defects: image+dockerfile, image+context, inline+context.

ECS.Task does not accept cluster; RunTask/StartTask take it explicitly

A Task maps to AWS::ECS::TaskDefinition, which is cluster-independent — the cluster belongs to the launch, not the definition:

 const seedTask = yield* AWS.ECS.Task("SeedTask", {
-  cluster,
   main: import.meta.url,
   image: "oven/bun:1",
 }, impl);

-const runSeedTask = yield* AWS.ECS.RunTask(seedTask).pipe(Effect.orDie);
+const runSeedTask = yield* AWS.ECS.RunTask(cluster, seedTask);

The two-argument form is the only form (no bind-time error channel, no Effect.orDie); AWS.ECS.Schedule/every already take their own cluster.

ECS.Task one-shot impls return { run } (and the container exits when it completes)

 Effect.gen(function* () {
-  return { fetch: Effect.gen(function* () { /* pseudo-handler */ }) };
+  return { run: Effect.gen(function* () { /* runs to completion */ }) };
 })

{ fetch } remains for server tasks referenced by Service({ task }).

EKS: ServerHostDeployment (no alias); resource Type string changes

-class Api extends AWS.EKS.ServerHost<Api>()(
+class Api extends AWS.EKS.Deployment<Api, {}>()(
   "Api",
-  { cluster, main: import.meta.url, port: 3000, docker: { base: "oven/bun:1" } },
+  { cluster, main: import.meta.url, port: 3000, image: "oven/bun:1" },
   impl,
 ) {}

EKS: Workload family deleted

-const web = yield* AWS.EKS.LoadBalancedWorkload("Web", {
-  cluster, namespace,
-  containers: [{ name: "web", image: "nginx:1.27", ports: [{ containerPort: 80 }] }],
-});
+const web = yield* AWS.EKS.Deployment("Web", {
+  cluster, namespace,
+  image: "nginx:1.27",
+  port: 80,
+  serviceType: "LoadBalancer",
+});

EKS: AutoClusterCluster({ compute: "auto" }); roleArn optional under auto

-const cluster = yield* AWS.EKS.AutoCluster("Primary", { ... });
+const cluster = yield* AWS.EKS.Cluster("Primary", { compute: "auto" });

alchemy/Kubernetes removed entirely — EKS is the only Kubernetes surface

The module (resource providers, hand-bridged types, zero-runtime builders) and the website's /kubernetes section are gone. AWS.EKS.Manifest takes a literal object; podTemplate on Deployment/Job is a literal deep-partial object. The apply machinery (server-side apply, discovery, ordering) is internal to AWS.EKS.

-import * as Kubernetes from "alchemy/Kubernetes";
-
-const cm = yield* Kubernetes.ConfigMap("Cfg", { cluster, data: { ... } });
+const cm = yield* AWS.EKS.Manifest("Cfg", {
+  cluster,
+  manifest: { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "cfg" }, data: { ... } },
+});

Cloudflare.Container: dockerfile-as-content string → image (or Dockerfile.inline); hash-stable, no rebuilds

 Runner.make(
   {
     main: import.meta.url,
-    dockerfile: "FROM oven/bun:latest",
+    image: "oven/bun:latest",
     instanceType: "standard",
   },
   impl,
 );

A dockerfile string is now always a path (external context variant). Inline content is explicit: dockerfile: Dockerfile.inline\FROM oven/bun:latest\nRUN apk add ffmpeg`— withmainit becomes the environment preamble the bundle layers onto; withoutmain` it is the whole Dockerfile. The variant-strict props union makes the old string-content misuse a type error.

Verification

Live suites green with ALL slow paths enabled: test/AWS/ECS 32/0 (AWS_TEST_SLOW=1; Fargate smoke, tagged bootstrap, shared-ALB, Phase 2) and test/AWS/EKS 19/0 (AWS_TEST_SLOW=1; full cluster-create → serialized config → image → Deployment → NLB → in-pod DynamoDB write → destroy, 19 min). test/Cloudflare/Container 12/12. Zero-orphan censuses clean after every round. The gated EKS path surfaced and fixed six more real bugs on its first true exercise: updateClusterConfig category batching, uncapped exponential cluster polls, hook-output invisibility in alchemy-test, NLB url missing the Service port, missing kubernetes.io/role/elb subnet tags, and a nonexistent default base-image ref.

Image-source finalization re-verified: ECS 33/0 fast sweep post-rework; a live Fargate one-shot whose inline environment bakes a marker via RUN and whose bundled { run } reads it back exited 0 (InlineDockerfileTask.test.ts); 12 unit tests cover the composition/exclusivity matrix (test/Docker/Dockerfile.test.ts). Also fixed in passing: { main, dockerfile: <path> } without context crashed on path.resolve(undefined) — context defaults to ".".

🤖 Generated with Claude Code

sam-goodwin and others added 4 commits July 18, 2026 00:14
…e platform

- New internal ECR/ImageSource: presence-discriminated { main, baseImage? } |
  { context, dockerfile-as-path } | { image } (remote refs mirrored into ECR,
  content-addressed tags, crash-safe resolve, bounded pulls, progress notes)
- ECS.Task: docker:{base,dockerfile} removed; main optional; external forms
  take no impl (busybox fixtures are now one-line externals); one-shot impls
  return { run } and exit on completion
- ECS.Service becomes a platform: image-owning or task:-referencing;
  loadBalancer:true wires ALB/TG/listener with owned ingress SG and
  default-VPC fallback; isBindingHost includes AWS.ECS.Service

Live: test/AWS/ECS 22 passed / 0 failed, zero-orphan census clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… types+builders

- EKS.Deployment replaces ServerHost (no alias): image triad via shared
  ImageSource, podTemplate DeepPartial merge, content-drift diffing; same
  Deployment+Service+SA+pod-identity semantics
- New EKS.Job: { run } impls, full triad, backoffLimit, content-addressed
  one-shot Jobs (immutable-template updates apply new + reap old);
  schedule: synthesizes a CronJob
- New EKS.Manifest: server-side apply of any manifest incl. CRDs (API
  discovery resolves arbitrary kinds); absorbs the former src/Kubernetes
  apply machinery as un-exported EKS internals
- src/Kubernetes is now 1:1 K8s types + zero-runtime builders only
- Deleted dead surface: Workload/LoadBalancedWorkload/PodIdentityWorkload;
  PodIdentityServiceAccount internal; AutoCluster folds into
  Cluster({ compute: 'auto' }) with optional self-managed IAM roles

Live: test/AWS/EKS 18 passed / 0 failed / skip-clean gated E2E.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The effectful (main) variant's dockerfile prop was inline Dockerfile
content used as the base image; it is now baseImage: a plain registry
ref (the provider synthesizes FROM). dockerfile remains only on the
external variant as a path. The props union is now strictly typed per
variant (the old misuse no longer typechecks); a runtime guard turns
accidental Dockerfile content in baseImage into a clear error.
Migration is hash-stable: FROM x -> baseImage x produces an identical
generated Dockerfile, so no rebuilds.

Live: test/Cloudflare/Container 12/12 standalone green (one pre-existing
cross-file concurrency flake with LocalContainer noted separately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four hand-written guide pages documented the deleted surface
(ServerHost, Workload family, AutoCluster, Kubernetes resource
providers). Rewritten against the shipped code: Cluster compute:'auto',
Deployment/Job/Manifest platforms, types+builders Kubernetes package,
server-side-apply mechanics. docs:check clean (4097 pages).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alchemy-version-bot

alchemy-version-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Install the packages built from this commit:

alchemy

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

@alchemy.run/better-auth

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

@alchemy.run/pr-package

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

…tes sidebar group

ecs.mdx now documents the Task one-shot triad (image/context/main,
{ run } impls, RunTask + Schedule) and the Service platform
(image-owning + task:-referencing, loadBalancer → ALB + url) instead of
docker.base/dockerfile-as-content and public:true. The
providerResourcesEntry("Kubernetes") sidebar entry pointed at a
now-deleted generated dir. docs:check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sam-goodwin
sam-goodwin marked this pull request as ready for review July 18, 2026 07:40
sam-goodwin and others added 8 commits July 18, 2026 01:58
Services can now attach routing rules to a shared listener instead of
owning an ALB:

  loadBalancer: true | ELBv2.Listener | { listener?, rules?, public? }

Rules are flat ({ listen, forward, redirect, container, path, host,
header, query, priority }); string listen = owned (service creates the
listener), Listener ref = shared (service owns only its TargetGroups +
ListenerRules); mixing is a typed error. Priorities auto-derive via a
pinned FNV-1a hash of the rule's logical id (1..50000); a live
collision is a typed ListenerRulePriorityInUse suggesting an explicit
override.

LB wiring is now COMPOSED resources (SecurityGroup/LoadBalancer/
Listener/TargetGroup/ListenerRule under Namespace.push) instead of
inline elbv2 calls — enabled by an additive transformProps hook on the
Platform factory that preserves all three authoring forms. Destroying
a service removes exactly its rules/TGs; shared infra is never touched.
Legacy inline-created ingress is reaped on first reconcile under the
new shape (proven by a live migration test).

Live: shared-ALB e2e (2 services, path routing, partial destroy leaves
sibling + ALB intact) + migration + priority determinism green; full
ECS suite green; zero-orphan account scan clean.

Note: the managed SG now admits the container port from 0.0.0.0/0
(tasks sit in public subnets; self-referencing rules can't precede
group creation); composed TGs default to fast health checks (10s/2)
and 30s deregistration delay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…example

Found by deploying examples/aws-ecs end-to-end; each fix verified live
(full deploy -> curl -> seed -> destroy -> zero-orphan) from a worktree.

- ECS createContainerRuntimeContext: capture base.serve before
  Object.assign overwrites it — the wrapper called itself at call time,
  recursing unboundedly (JSC MemoryExhaustion) the moment any Task/
  Service impl declared { fetch }
- Http.serve: park behind Effect.never after registering the server —
  HttpServer.serve registers on the ambient Scope and returns, so a
  pure { fetch } host program completed, closed its scope, tore down
  the server, and the container exited 0 in a crash-loop
- ECS bootstrap: Credentials.fromChain() instead of fromEnv() — Fargate
  task-role creds come from the container-credentials endpoint, not env
  vars; every Http binding inside a container failed
- Scheduler builders: remove the prohibited Resource field from the
  scheduler trust policy (MalformedPolicyDocumentException on every
  AWS.ECS.every / Scheduler.every target)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shared stack-level ALB + listener; Api (effectful Service, main:
bundled, DynamoDB Scan/GetItem + RunTask bindings, /api/* rule) and Web
(external image:, catch-all rule) sharing it; SeedTask (inline { run }
one-shot, PutItem); ReportTask (context: Dockerfile); HeartbeatTask
(image: busybox) on an AWS.ECS.every cron. All three image sources and
authoring forms appear.

Verified live: deploy, GET / and /api/orders (0 -> 3 after RunTask-
launched seed), GetItem + 404 paths, schedule ENABLED; destroy +
zero-orphan census clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ets, logging, serviceRegistry

SST-parity features, all additive props with live fixture tests:

- domain (owned LB): composes ACM Certificate (DNS-validated via zone
  lookup; explicit cert: skips) + Route53 alias records; url prefers
  the domain. ACM gains a region prop (in-region ALB certs); reads/
  deletes derive region from the ARN.
- NLB: tcp/udp/tls/tcp_udp rule protocols derive a network LB; typed
  errors for mixed layers/conditions/redirects on network rules.
- health: per-'{port}/{proto}' target-group overrides (Duration.Input).
- scaling: composes ScalableTarget + per-metric ScalingPolicies via a
  new onCreate hook (resourceId/ResourceLabel derive from the service's
  own outputs); reconcile stops pinning desiredCount while set.
- capacity: 'spot' | fargate/spot base+weight -> capacityProviderStrategy.
- secrets: SSM/SecretsManager valueFrom + exact-ARN execution-role
  policy. logging.retention on the managed log group. serviceRegistry
  composes CloudMap.Service into serviceRegistries. healthCheck +
  volumes ({ efs, path }) sugar.
- Service diff adopts imageSource.hash so main: source drift updates
  (same fix as Task).

Also: Docker push pins --platform (containerd store shipped whatever
arch was cached — arm64 busybox crashed x86 Fargate); HostedZone delete
retries HostedZoneNotEmpty (name-looked-up records race the zone).

Live: full ECS suite green; zero-orphan sweep clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…edule Input types

- ECS bun bootstrap now folds the entrypoint via makeEntrypointLayer
  (the Lambda/Container bridge pattern): a tagged X.make Layer default
  export boots identically to an inline Effect export. Proven live by
  a tagged { run } one-shot (RunTask -> exit 0) and by converting
  examples/aws-ecs Api to the tagged form and redeploying end-to-end.
- Task diff hashes main: sources at plan time via the new
  imageSource.hash (bundle covers the bootstrap entry) — bootstrap or
  code-only changes no longer silently no-op until --force.
- Scheduler builder args take Input<> (they are function args, not
  resource Props): a yielded Task and Output-valued subnets now
  typecheck; example casts removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The inline-class-with-Effect-props OOM report was the (already fixed)
createContainerRuntimeContext serve self-recursion — props were a red
herring; pre-fix, plain-object props OOM'd identically. Proven by
cherry-picking the serveBase capture into a pre-fix worktree and
watching the hermetic repro flip. New offline tests fail fast (2s
timeout guard, no cloud, bounded memory) instead of an 8GB
MemoryExhaustion: unit serve guard, plan/apply of the inline class
form with Effect-valued props, and repeated-yield plan stability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two compounding bugs made EKS cluster creation look like a harness
deadlock:

- Cluster.ts/Addon.ts waited on an UNCAPPED exponential
  (Schedule.exponential('1 second') x recurs(120)) — late attempts
  sleep 4/8/17/34 minutes, parking the process at 0% CPU mid-create
  and overshooting hook budgets. Now flat bounded polls
  (spaced 10s x 180 / 5s x 120), matching Nodegroup/FargateProfile.
- alchemy-test buffered ALL file-hook output until FileEnd and the 10s
  stall report ignored hooks, so a beforeAll deploy was invisible by
  design. Hook log lines now stream to the run log as they happen and
  hooks appear in the stall report; hermetic regression test asserts
  mid-hook visibility.

Fallout of the illusion: timeout-killed runs had silently issued
createCluster during the invisible sleeps, orphaning 4 ACTIVE clusters
+ 4 VPCs (account hit the 5-VPC quota).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tags

The EKS Deployment E2E's readiness poll failed with transport errors
for five consecutive runs because the provider returned
http://<nlb-hostname> while the NLB listener is the K8s Service port
(3000) — every GET hit :80 where nothing ever listened. The pods were
healthy the whole time. url is now http://<hostname>[:port] (port
elided when 80) and the fixture uses it verbatim.

Supporting fixes proven in the same green runs:
- EC2.Network tags public/private subnets with kubernetes.io/role/elb
  and /internal-elb so EKS Auto Mode's LB controller can discover them
- Deployment detects Auto Mode LB capability (loadBalancerClass
  eks.amazonaws.com/nlb) and defaults the NLB to internet-facing; user
  serviceAnnotations win; delete path tolerates a gone/DELETING cluster
- ImageSource default baseImage oven/bun:1 (docker/library/bun does
  not exist); Job.ts aligned with the shared machinery
- E2E budget 45 min with the measured timeline documented; NO_DESTROY
  gate on afterAll for live debugging

Full EKS suite with AWS_TEST_SLOW=1: 0 failed | 19 passed | 7 todo
(19 min) — including the complete cluster-create -> serialized-config
-> image -> Deployment -> NLB -> in-pod DynamoDB write -> destroy
cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/alchemy-test/src/FileLog.ts Outdated
Comment on lines +137 to +148
const appendHookLine: FileLog["appendHookLine"] = (file, entry) => {
try {
const prefixed = entry.message
.split("\n")
.map((line) => `[hook ${file}] ${line}`)
.join("\n");
NodeFs.appendFileSync(logFile, `${prefixed}\n`);
} catch {
// Never let log streaming break a hook.
}
};
return { append, appendHookLine } satisfies FileLog;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Never do synchronous I/O. appendHookLike should probably return an Effect

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a180ede: the capture site now only does Queue.offerUnsafe (in-memory enqueue, no I/O — it's called from the synchronous array-push interception so it can't yield an Effect itself); a forked writer fiber drains the queue with Effect FileSystem appends, and fileLog.close ends + awaits it at RunEnd so tail lines flush. The mid-hook streaming regression test still passes.


const scan = yield* AWS.DynamoDB.Scan(table);
const getItem = yield* AWS.DynamoDB.GetItem(table);
const runSeedTask = yield* AWS.ECS.RunTask(cluster, seedTask);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why do we need to pass cluster here? Don't we know the cluster from the task? Or is it because we need the LogicalId of the cluster?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great catch — two answers: (1) ECS task definitions are cluster-independent (any taskdef can run on any cluster), which is why the AWS API — and our binding — takes a cluster. (2) But since this Task declares its cluster, making callers repeat it was redundant. Fixed in 79a7b1b: Task attributes now record clusterArn when declared, and RunTask(task) / StartTask(task) infer it (explicit RunTask(cluster, task) remains as override; a cluster-less Task fails the single-arg form at bind time with typed RunTaskRequiresCluster). The example now reads RunTask(seedTask). One DX note: the task-only form carries the typed error in its channel, so inside a Platform init (which constrains errors to ConfigError) it needs .pipe(Effect.orDie) — happy to switch to a defect-carrying clean channel if you prefer bare yield* RunTask(task).

Comment thread packages/alchemy-test/src/FileLog.ts Outdated
Comment on lines +86 to +90
* entry as it is captured keeps the log tail-able mid-hook. Sync (node:fs
* append) because it is called from a plain array-push interception;
* append-mode writes interleave safely with the Effect-based appends.
*/
readonly appendHookLine: (file: string, entry: LogEntry) => void;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can we take another look at this and see if we can solve the problem without a synchronous call?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same fix — no synchronous calls remain in FileLog (NodeFs import is gone entirely); all disk I/O moved to the async writer fiber.

sam-goodwin and others added 5 commits July 20, 2026 18:21
Review feedback on #867: appendHookLine used NodeFs.appendFileSync
because it is called from the synchronous array-push interception that
captures hook output. It now performs NO I/O at the capture site —
Queue.offerUnsafe onto an unbounded queue (plain in-memory enqueue) —
and a forked writer fiber drains the queue with Effect FileSystem
appends. fileLog.close ends the queue and awaits the writer at RunEnd
so tail lines are flushed. Hermetic mid-hook streaming test still
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback on #867: RunTask(cluster, task) forced callers to
repeat a cluster the Task already declared. Task attributes now record
clusterArn when props.cluster is set, and RunTask/StartTask accept the
task-only form — RunTask(task) resolves the declared cluster (works
for Task.ref too); RunTask(cluster, task) stays as the explicit
override with an unchanged error channel. A task with no declared
cluster fails the single-arg form at BIND time with typed
RunTaskRequiresCluster/StartTaskRequiresCluster. ECS task definitions
remain cluster-independent — this is inference from the declaration,
not a change to the ECS model.

examples/aws-ecs: SeedTask declares its cluster; Api uses
RunTask(seedTask).

Live: ECS suite 33/0; example deployed, task-only seed ran on Fargate,
destroyed, zero-orphan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src/infra.ts (Network, Cluster compute:'auto', table, namespace via
EKS.Manifest + Kubernetes.namespace builder), src/Api.ts (tagged
effectful EKS.Deployment with DynamoDB Http bindings, LoadBalancer,
podTemplate escape hatch), src/SeedJob.ts (inline-effect EKS.Job
{ run } seeding via PutItem), thin alchemy.run.ts adding an external
nginx Deployment. Verified live: 38 resources, namespace applied,
seed Job pod ran to completion with pod-identity credentials (3 items
scanned), external nginx NLB served 200, destroy + zero-orphan sweep
clean. The tagged Api pods crash-loop on a platform bug fixed
separately (EKS bootstraps missing Layer-entrypoint normalization).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The EKS Deployment/Job bootstraps assumed the module's default export
is an Effect (inline-class form); the tagged form (X.make(props, impl))
exports a Layer, so tagged EKS workloads crash-looped with
'Not a valid effect'. Both bootstraps now fold the entrypoint through
makeEntrypointLayer(tag, entrypoint) — the same normalization the
ECS/Lambda/Container bridges use.

Proven live via examples/aws-eks: tagged Api Deployment served the
guestbook over its NLB (GET /entries returned the seed Job's items
through the Scan binding; POST wrote), external nginx 200, full
destroy after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n, Dockerfile.inline

- Remove `baseImage` everywhere: `{ main, image }` now means "bundle the
  Effect program FROM <image>"; `{ image }` alone stays the verbatim
  pre-built source
- `dockerfile?: string | Dockerfile.inline` — a string is always a PATH;
  `Dockerfile.inline` is inline content ({ content: Input<string> },
  structural discrimination, state-serializable, Output interpolation =
  dependency edge, unresolved content defers plan-time hashing)
- With `main`: inline content replaces the generated FROM preamble; on AWS a
  path dockerfile builds a local env stage the bundle layers on top of
- Without `main`: inline content is the whole Dockerfile, built in a stable
  generated context (live + local Cloudflare providers share it)
- Exclusivity defects: image+dockerfile, image+context, inline+context
- Fix: `{ main, dockerfile: path }` without `context` crashed on
  path.resolve(undefined); context now defaults to "."
- Live-verified: Fargate one-shot with inline-env RUN artifact exits 0;
  ECS suite 33/0; CF Container suite green modulo the known pre-existing
  RemoteContainer cross-file race; 12 new unit tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sam-goodwin sam-goodwin changed the title feat!: unified container platforms — ECS Task/Service, EKS Deployment/Job/Manifest, Cloudflare baseImage feat!: unified container platforms — ECS Task/Service, EKS Deployment/Job/Manifest, Dockerfile.inline image composition Jul 21, 2026
sam-goodwin and others added 7 commits July 20, 2026 22:47
…itly

A Task maps to AWS::ECS::TaskDefinition, which is cluster-independent —
the cluster belongs to the launch, not the definition. The task-only
RunTask(task) inference forced a RunTaskRequiresCluster error channel
(and .pipe(Effect.orDie) at every bind site) to cover tasks with no
declared cluster.

- Task props lose `cluster`; attributes lose `clusterArn`
- RunTask/StartTask restored to the single two-arg form
  RunTask(cluster, task) with a clean error channel
- examples/aws-ecs: SeedTask props are a plain object again; Api binds
  RunTask(cluster, seedTask)
- ecs.mdx: stale baseImage copy updated to image/Dockerfile.inline

Live: ECS suite 33/0 post-revert (Bindings suite exercises the two-arg
form through a deployed Lambda).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ECS intro now says what ECS is (managed container orchestrator,
  Fargate = no servers to manage) and introduces the four primitives
  (cluster, task definition, task, service) in AWS's own terminology
  before mapping them to Alchemy resources — no more defining ECS
  relative to Lambda
- "one-shot" replaced with plain "runs to completion" phrasing across
  ecs.mdx, eks.mdx, and the user-facing Task/TaskDefinition/RunTask/
  EKS.Job JSDoc (matching Kubernetes' own run-to-completion language
  for Jobs)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…; test(ecs): cover every()

- The Apple Silicon tip wrongly implied the host machine dictates the
  image architecture. Corrected: runtimePlatform.cpuArchitecture picks
  the Fargate architecture and Alchemy builds/mirrors for whatever the
  task definition declares; ARM64 = Graviton (cheaper), which happens
  to build natively on Apple Silicon
- RunTask/StartTask examples (ecs.mdx + JSDoc) now show the binding
  yielded in the init phase and called from a { fetch } handler at
  runtime — yielding and calling a binding in the same scope is always
  wrong and the old snippets modeled exactly that
- EKS intro rewritten like the ECS one: what EKS is, then the
  Kubernetes primitives (cluster, pod, Deployment, Service, Job,
  manifest) in K8s terminology before the Alchemy mapping
- New live test for AWS.ECS.every: deploys cluster + busybox task +
  cron schedule, verifies expression/ECS target/network/role via the
  Scheduler API out-of-band, destroys, verifies the schedule is gone
  (previously only exercised by the examples/aws-ecs deploy)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pod, Deployment, Service, Job, CronJob are always capitalized (matching
Kubernetes' own convention for API kinds); cluster, node, and manifest
stay lowercase (they're not kinds). "Pod Identity" capitalized as the
EKS feature name. Shell commands and builder function names keep their
literal code casing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cluster, Task Definition, Task, Service (ECS) and Cluster, Node, Pod,
Deployment, Service, Job, CronJob (EKS) — every named primitive gets a
capital first letter; only descriptive words (manifest, load balancer)
stay lowercase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the variant-union pattern

ContainerApplicationProps was a flat everything-bag that Container.ts
carved strict views out of with Omit-and-redeclare. Restructured to
match the AWS side (TaskPropsBase + variant interfaces + union):

- ContainerApplicationPropsBase — the shared naming/scaling/runtime
  config
- EffectfulContainerProps (main + image/inline-dockerfile environment
  + bundling options), ExternalContainerProps (context + dockerfile),
  RemoteContainerProps (image, required) — defined once next to the
  resource; Container.ts just re-exports them
- ContainerApplicationProps = the union of the three
- AnyContainerApplicationProps — INTERNAL loose view (every variant
  field optional at its widest type) that provider/bundle helpers
  annotate their params with, since each union member is assignable
  to it

Bundling options (handler/runtime/external/autoInstallExternals) now
live only on the effectful variant where they have meaning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lare containers

- validateContainerImageProps / containerEnvPreamble return Effects and
  surface invalid props via Effect.die — no raw throws (call sites
  yield* them; unit tests use an Effect-based dies() helper)
- delete src/Kubernetes (types + builders) and the alchemy/Kubernetes
  package export: EKS is the only Kubernetes surface. AWS.EKS.Manifest
  takes a literal KubernetesManifest object ({ apiVersion, kind,
  metadata, ...rest }; CRDs via API discovery); Deployment/Job
  podTemplate is a literal deep-partial object. Apply machinery stays
  internal to AWS.EKS
- delete website /kubernetes section (+ sidebar entries); eks.mdx
  Manifest examples are literal objects
- examples/aws-eks writes literal manifests / pod templates

Suites: Cloudflare Workers 177/0, Cloudflare Container 16/0
(sequential), EKS fast 17/0, unit 12/12.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sam-goodwin sam-goodwin changed the title feat!: unified container platforms — ECS Task/Service, EKS Deployment/Job/Manifest, Dockerfile.inline image composition feat(aws)!: unified container platforms — ECS Task/Service, EKS Deployment/Job/Manifest, Dockerfile.inline image composition Jul 21, 2026
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.

1 participant