Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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",
image: "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
135 changes: 82 additions & 53 deletions examples/aws-ecs/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,109 @@
/**
* 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 network = yield* OrdersNetwork;
const cluster = yield* OrdersCluster;
const table = yield* OrdersTable;
const { alb, listener } = yield* OrdersIngress;

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",
},
],
},
);
// ── 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 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;

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 }],
},
});

// ── 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", {
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",
},
});

yield* AWS.ECS.Service("ExampleQueuePollerService", {
// ── 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", {
image: "busybox:stable",
command: ["sh", "-c", "echo orders heartbeat"],
cpu: 256,
memory: 512,
});
yield* AWS.ECS.every("HeartbeatSchedule", "cron(0 3 * * ? *)", {
cluster,
task: queuePollerTask,
vpcId: network.vpcId,
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)),
);
171 changes: 171 additions & 0 deletions examples/aws-ecs/src/Api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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. The
* cluster is explicit at the binding — a task definition is
* cluster-independent.
*/
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,
image: "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 network = yield* OrdersNetwork;
const cluster = yield* OrdersCluster;
const seedTask = yield* SeedTask;

const scan = yield* AWS.DynamoDB.Scan(table);
const getItem = yield* AWS.DynamoDB.GetItem(table);
// The launch cluster is explicit at the binding — the task definition
// itself is cluster-independent.
const runSeedTask = yield* AWS.ECS.RunTask(cluster, seedTask);

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