Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8b5fbc5
feat(aws/ecs)!: image-source union, baseImage, external tasks, Servic…
sam-goodwin Jul 18, 2026
c3c46aa
feat(aws/eks)!: Deployment/Job/Manifest platforms; Kubernetes becomes…
sam-goodwin Jul 18, 2026
36305c2
feat(cloudflare)!: Container baseImage replaces dockerfile-as-content
sam-goodwin Jul 18, 2026
3fd811a
docs: rewrite Kubernetes/EKS guides for the container platform redesign
sam-goodwin Jul 18, 2026
7abe112
docs: rewrite ECS guide for the platform redesign; drop ghost Kuberne…
sam-goodwin Jul 18, 2026
1637db8
feat(aws/ecs): shared load balancers via listener rules on Service
sam-goodwin Jul 18, 2026
c35217c
fix(aws): four deploy-blocking container-platform bugs found by live …
sam-goodwin Jul 18, 2026
26d95fb
feat(examples/aws-ecs): orders app exercising the full ECS surface
sam-goodwin Jul 18, 2026
2136129
feat(aws/ecs): Service Phase 2 — domain, NLB, scaling, capacity, secr…
sam-goodwin Jul 18, 2026
1e90129
fix(aws/ecs): tagged-form bootstrap, main-source drift detection, Sch…
sam-goodwin Jul 18, 2026
a725ddf
test(aws/ecs): offline regression coverage for the serve-recursion OOM
sam-goodwin Jul 20, 2026
ba7d931
fix(aws/eks,test): bounded flat cluster polls + live hook output
sam-goodwin Jul 20, 2026
b29346f
fix(aws/eks): NLB url carries the Service port; subnet ELB discovery …
sam-goodwin Jul 21, 2026
a180ede
fix(test): hook-line streaming without synchronous I/O
sam-goodwin Jul 21, 2026
79a7b1b
feat(aws/ecs): RunTask/StartTask infer the cluster from the Task
sam-goodwin Jul 21, 2026
eae2263
feat(examples/aws-eks): guestbook app restructured like aws-ecs
sam-goodwin Jul 21, 2026
e148268
fix(aws/eks): normalize Layer entrypoints in the pod bootstraps
sam-goodwin Jul 21, 2026
7a21cd1
feat(containers): final image-source design — image composes with mai…
sam-goodwin Jul 21, 2026
020d184
revert(aws/ecs): Task drops cluster; RunTask/StartTask take it explic…
sam-goodwin Jul 21, 2026
1a8ca81
docs(aws): describe ECS plainly; drop "one-shot" jargon
sam-goodwin Jul 21, 2026
d862685
docs(aws): fix runtimePlatform tip + phase-separated binding examples…
sam-goodwin Jul 21, 2026
ee6d970
docs(eks): consistent Kubernetes casing — API kinds capitalized
sam-goodwin Jul 21, 2026
25e9e8e
docs(aws): capitalize all container primitives in the ECS/EKS intros
sam-goodwin Jul 21, 2026
adc259d
refactor(cloudflare/containers): align ContainerApplicationProps with…
sam-goodwin Jul 21, 2026
a5a5336
refactor!: remove alchemy/Kubernetes; Effect.die validation on Cloudf…
sam-goodwin Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion benchmark/container/src/effectful-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class EffectfulContainer extends Cloudflare.Container<
export default EffectfulContainer.make(
{
main: import.meta.filename,
dockerfile: "FROM oven/bun:latest",
baseImage: "oven/bun:latest",
// Match wrangler's container config so the comparison isolates the
// framework, not the tier: max_instances 100 (Alchemy defaults to 1),
// instance_type "lite", and instances 0 (pure scale-from-zero).
Expand Down
137 changes: 84 additions & 53 deletions examples/aws-ecs/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,111 @@
/**
* An "orders" app on ECS Fargate that exercises the full container surface:
*
* - a stack-owned SHARED ALB + HTTP listener (`src/infra.ts`) that two
* services attach path rules to,
* - `Api` — an effectful `AWS.ECS.Service` (bundled `main:` image source)
* serving `/api/*` with DynamoDB + `RunTask` bindings (`src/Api.ts`),
* - `Web` — an EXTERNAL `AWS.ECS.Service` (registry `image:` source, no
* Effect runtime in the container) serving the catch-all `/*`,
* - `SeedTask` — an inline-effect one-shot `AWS.ECS.Task` (`{ run }`) that
* seeds the orders table, launched from `POST /api/seed` (`src/SeedTask.ts`),
* - `ReportTask` — an external one-shot task built from a local Dockerfile
* (`context:` image source, `src/report/Dockerfile`),
* - `HeartbeatTask` — an external one-shot task on an EventBridge cron
* schedule via `AWS.ECS.every`.
*/
import * as Alchemy from "alchemy";
import * as AWS from "alchemy/AWS";
import * as Output from "alchemy/Output";
import * as Effect from "effect/Effect";
import QueueConsumerTask from "./src/QueueConsumerTask.ts";
import ApiTask from "./src/Task.ts";

const aws = AWS.providers();
import ApiLive, { Api } from "./src/Api.ts";
import {
OrdersCluster,
OrdersIngress,
OrdersNetwork,
OrdersTable,
} from "./src/infra.ts";

export default Alchemy.Stack(
"AwsEcsExample",
{
providers: aws,
providers: AWS.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const network = yield* AWS.EC2.Network("ExampleNetwork", {
cidrBlock: "10.42.0.0/16",
availabilityZones: 2,
});

const serviceSecurityGroup = yield* AWS.EC2.SecurityGroup(
"ExampleServiceSecurityGroup",
{
vpcId: network.vpcId,
description: "Security group for the ECS example services",
ingress: [
{
ipProtocol: "tcp",
fromPort: 80,
toPort: 80,
cidrIpv4: "0.0.0.0/0",
},
{
ipProtocol: "tcp",
fromPort: 3000,
toPort: 3000,
cidrIpv4: "0.0.0.0/0",
},
],
},
);
const network = yield* OrdersNetwork;
const cluster = yield* OrdersCluster;
const table = yield* OrdersTable;
const { alb, listener } = yield* OrdersIngress;

const queue = yield* AWS.SQS.Queue("ExampleJobsQueue", {
receiveMessageWaitTime: "20 seconds",
visibilityTimeout: "60 seconds",
});

const cluster = yield* AWS.ECS.Cluster("ExampleCluster", {});
const apiTask = yield* ApiTask;
const queuePollerTask = yield* QueueConsumerTask;
// ── Api — effectful service in the TAGGED form (bundled `main:`),
// /api/* on the shared ALB. `ApiLive` (the `Api.make(...)` Layer,
// provided below) carries the props + init program; its init also
// declares SeedTask (for the RunTask binding) and the DynamoDB
// bindings; see src/Api.ts.
const api = yield* Api;

const apiService = yield* AWS.ECS.Service("ExampleApiService", {
// ── Web — EXTERNAL service: a pre-built registry image (mirrored into
// ECR), no Effect runtime in the container. Attaches the catch-all `/*`
// rule to the shared listener; the explicit priority orders it AFTER
// Api's `/api/*` rule (lower number evaluates first).
const web = yield* AWS.ECS.Service("Web", {
cluster,
task: apiTask,
image: "nginxdemos/hello:plain-text",
port: 80,
desiredCount: 1,
vpcId: network.vpcId,
subnets: network.publicSubnetIds,
securityGroups: [serviceSecurityGroup.groupId],
assignPublicIp: true,
public: true,
healthCheckPath: "/",
loadBalancer: {
listener,
rules: [{ path: "/*", priority: 20000 }],
},
});

yield* AWS.ECS.Service("ExampleQueuePollerService", {
// ── ReportTask — EXTERNAL one-shot task built from the example's own
// Dockerfile (`context:` image source; `dockerfile` defaults to
// `${context}/Dockerfile`). Deploy builds + pushes the image and
// registers the task definition; run it with `RunTask` or a schedule.
const reportTask = yield* AWS.ECS.Task("ReportTask", {
cluster,
task: queuePollerTask,
vpcId: network.vpcId,
context: `${import.meta.dirname}/src/report`,
cpu: 256,
memory: 512,
// Build/run on ARM64 so an image built on an Apple Silicon host
// matches the Fargate runtime architecture (Graviton).
runtimePlatform: {
cpuArchitecture: "ARM64",
operatingSystemFamily: "LINUX",
},
});

// ── HeartbeatTask — EXTERNAL one-shot task (registry `image:` source)
// run nightly by an EventBridge Scheduler cron via `AWS.ECS.every`
// (which provisions the schedule plus the ecs:RunTask invoke role).
const heartbeatTask = yield* AWS.ECS.Task("HeartbeatTask", {
cluster,
image: "busybox:stable",
command: ["sh", "-c", "echo orders heartbeat"],
cpu: 256,
memory: 512,
});
yield* AWS.ECS.every("HeartbeatSchedule", "cron(0 3 * * ? *)", {
cluster,
task: heartbeatTask,
subnets: network.publicSubnetIds,
securityGroups: [serviceSecurityGroup.groupId],
assignPublicIp: true,
desiredCount: 1,
});

return {
url: apiService.url,
queueUrl: queue.queueUrl,
enqueueExample: Output.interpolate`${apiService.url}/enqueue?message=hello`,
url: Output.interpolate`http://${alb.dnsName}`,
apiUrl: Output.interpolate`http://${alb.dnsName}/api/orders`,
seedUrl: Output.interpolate`http://${alb.dnsName}/api/seed`,
tableName: table.tableName,
apiServiceName: api.serviceName,
webServiceName: web.serviceName,
reportTaskDefinitionArn: reportTask.taskDefinitionArn,
heartbeatTaskDefinitionArn: heartbeatTask.taskDefinitionArn,
};
}),
}).pipe(Effect.provide(ApiLive)),
);
167 changes: 167 additions & 0 deletions examples/aws-ecs/src/Api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import {
OrdersCluster,
OrdersIngress,
OrdersNetwork,
OrdersTable,
} from "./infra.ts";
import SeedTask from "./SeedTask.ts";

/**
* The orders API: an `AWS.ECS.Service` in the TAGGED form — the class
* declares the service identity, and the default export is
* `Api.make(props, impl)`: a Layer pairing the props with an init Effect
* whose impl returns `{ fetch }`, bundled into a generated image
* (`main: import.meta.url`). The Service synthesizes its own task
* definition (task + execution roles, log group, ECR repository) from the
* same surface as `AWS.ECS.Task`.
*
* The stack provides the Layer and yields the class (see `alchemy.run.ts`):
*
* ```typescript
* const api = yield* Api; // with Effect.provide(ApiLive)
* ```
*
* Ingress is the SHARED listener composed at the stack level (see
* `src/infra.ts`): this service only adds its own target group and a
* `path: "/api/*"` listener rule. The explicit `priority: 10` orders it
* ahead of `Web`'s catch-all `/*` rule.
*
* Bindings work exactly as on Lambda:
* - `AWS.DynamoDB.GetItem` / `Scan` grant read access to the orders table
* and inject its name into the container environment.
* - `AWS.ECS.RunTask(cluster, SeedTask)` grants `ecs:RunTask` on the seed
* task's definition (plus `iam:PassRole` on its roles), so
* `POST /api/seed` can launch the one-shot seeding task on Fargate.
*/
export class Api extends AWS.ECS.Service<Api>()("Api") {}

export default Api.make(
// Props are themselves an Effect so they can reference shared resources.
Effect.gen(function* () {
const cluster = yield* OrdersCluster;
const network = yield* OrdersNetwork;
const { listener } = yield* OrdersIngress;
return {
cluster,
main: import.meta.url,
baseImage: "oven/bun:1",
port: 3000,
cpu: 256,
memory: 512,
desiredCount: 1,
// Build/run on ARM64 so an image built on an Apple Silicon host
// matches the Fargate runtime architecture (Graviton).
runtimePlatform: {
cpuArchitecture: "ARM64",
operatingSystemFamily: "LINUX",
},
vpcId: network.vpcId,
subnets: network.publicSubnetIds,
// Public subnets, no NAT: a public IP is required to pull the image.
assignPublicIp: true,
loadBalancer: {
listener,
rules: [{ path: "/api/*", priority: 10 }],
},
};
}),
Effect.gen(function* () {
const table = yield* OrdersTable;
const cluster = yield* OrdersCluster;
const network = yield* OrdersNetwork;
const seedTask = yield* SeedTask;

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).


// First public subnet, bound into the environment so the runtime can
// build the seed task's awsvpc network configuration.
const SubnetId = yield* network.publicSubnetIds[0]!;

return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const url = new URL(request.originalUrl);

// GET /api/orders — list everything in the table.
if (request.method === "GET" && url.pathname === "/api/orders") {
const result = yield* scan({});
const orders = (result.Items ?? []).map((item) => ({
id: item.pk?.S?.replace(/^order#/, ""),
customer: item.customer?.S,
total: item.total?.N ? Number(item.total.N) : undefined,
}));
return yield* HttpServerResponse.json({
count: orders.length,
orders,
});
}

// GET /api/orders/<id> — read one order.
const match = url.pathname.match(/^\/api\/orders\/([^/]+)$/);
if (request.method === "GET" && match) {
const result = yield* getItem({
Key: { pk: { S: `order#${match[1]}` } },
});
if (!result.Item) {
return yield* HttpServerResponse.json(
{ error: "not found" },
{ status: 404 },
);
}
return yield* HttpServerResponse.json({
id: match[1],
customer: result.Item.customer?.S,
total: result.Item.total?.N
? Number(result.Item.total.N)
: undefined,
});
}

// POST /api/seed — launch the one-shot SeedTask on Fargate via the
// RunTask binding (cluster + task definition ARNs are injected).
if (request.method === "POST" && url.pathname === "/api/seed") {
const subnetId = yield* SubnetId;
const response = yield* runSeedTask({
launchType: "FARGATE",
count: 1,
startedBy: "orders-api-seed",
networkConfiguration: {
awsvpcConfiguration: {
subnets: [subnetId],
// No securityGroups: the VPC default security group applies
// (all egress — enough to pull from ECR and reach DynamoDB).
assignPublicIp: "ENABLED",
},
},
});
return yield* HttpServerResponse.json({
taskArn: response.tasks?.[0]?.taskArn,
failures: response.failures ?? [],
});
}

// Everything else — including the ALB health check on "/" (health
// checks hit the container directly, not through listener rules).
return yield* HttpServerResponse.json({
ok: true,
service: "orders-api",
});
}).pipe(Effect.orDie),
};
}).pipe(
Effect.provide(
Layer.mergeAll(
AWS.DynamoDB.ScanHttp,
AWS.DynamoDB.GetItemHttp,
AWS.ECS.RunTaskHttp,
),
),
),
);
16 changes: 0 additions & 16 deletions examples/aws-ecs/src/JobsQueue.ts

This file was deleted.

Loading
Loading