Skip to content

Commit c5898be

Browse files
whummerclaude
andcommitted
Replace Lambda fulfill step with ECS Fargate task via Step Functions
- services/fulfillment/: new Docker container — reads order from DynamoDB, sets fulfilled status, writes S3 receipt - terraform: ECR repo, ECS cluster, task definition, IAM execution+task roles; FulfillOrder state now uses ecs:runTask.sync:2 instead of Lambda - order_processor: remove fulfill step and s3/RECEIPTS_BUCKET dependency - Makefile: add `build` target (ECR push); `deploy` runs build first Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1184a37 commit c5898be

5 files changed

Lines changed: 182 additions & 33 deletions

File tree

01-serverless-app/lambdas/order_processor/handler.py

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,11 @@
66
import boto3
77

88
dynamodb = boto3.resource("dynamodb")
9-
s3 = boto3.client("s3")
109
sfn = boto3.client("stepfunctions")
1110

12-
TABLE_NAME = os.environ["ORDERS_TABLE"]
13-
RECEIPTS_BUCKET = os.environ["RECEIPTS_BUCKET"]
11+
TABLE_NAME = os.environ["ORDERS_TABLE"]
1412
STATE_MACHINE_ARN = os.environ["STATE_MACHINE_ARN"]
1513

16-
TERMINAL_STATUSES = {"fulfilled", "failed"}
17-
1814

1915
def now():
2016
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -64,7 +60,6 @@ def handler(event, context):
6460

6561
if step == "validate": return validate(order)
6662
if step == "process_payment": return process_payment(order)
67-
if step == "fulfill": return fulfill(order)
6863
if step == "handle_failure": return handle_failure(order)
6964

7065
raise ValueError(f"Unknown step: {step}")
@@ -82,20 +77,6 @@ def process_payment(order):
8277
return order
8378

8479

85-
def fulfill(order):
86-
time.sleep(2)
87-
set_status(order["order_id"], "fulfilled")
88-
receipt = {k: order[k] for k in ("order_id", "item", "quantity")}
89-
receipt["status"] = "fulfilled"
90-
s3.put_object(
91-
Bucket=RECEIPTS_BUCKET,
92-
Key=f"receipts/{order['order_id']}.json",
93-
Body=json.dumps(receipt),
94-
ContentType="application/json",
95-
)
96-
return order
97-
98-
9980
def handle_failure(order):
10081
set_status(order["order_id"], "failed")
10182
return order
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
FROM python:3.12-slim
2+
WORKDIR /app
3+
RUN pip install --no-cache-dir boto3
4+
COPY fulfill.py .
5+
CMD ["python", "fulfill.py"]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""ECS task: fulfill an order — update DynamoDB status and write S3 receipt."""
2+
import json
3+
import os
4+
from datetime import datetime, timezone
5+
6+
import boto3
7+
8+
ORDER_ID = os.environ["ORDER_ID"]
9+
TABLE_NAME = os.environ["ORDERS_TABLE"]
10+
RECEIPTS_BUCKET = os.environ["RECEIPTS_BUCKET"]
11+
12+
# LocalStack injects AWS_ENDPOINT_URL automatically into ECS task containers.
13+
dynamodb = boto3.resource("dynamodb")
14+
s3 = boto3.client("s3")
15+
16+
17+
def now():
18+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
19+
20+
21+
table = dynamodb.Table(TABLE_NAME)
22+
23+
resp = table.get_item(Key={"order_id": ORDER_ID})
24+
order = resp["Item"]
25+
26+
table.update_item(
27+
Key={"order_id": ORDER_ID},
28+
UpdateExpression="SET #s = :s, fulfilled_at = :ts",
29+
ExpressionAttributeNames={"#s": "status"},
30+
ExpressionAttributeValues={":s": "fulfilled", ":ts": now()},
31+
)
32+
33+
receipt = {
34+
"order_id": ORDER_ID,
35+
"item": order["item"],
36+
"quantity": int(order["quantity"]),
37+
"status": "fulfilled",
38+
"fulfilled_at": now(),
39+
}
40+
s3.put_object(
41+
Bucket=RECEIPTS_BUCKET,
42+
Key=f"receipts/{ORDER_ID}.json",
43+
Body=json.dumps(receipt),
44+
ContentType="application/json",
45+
)
46+
47+
print(f"Order {ORDER_ID} fulfilled: {order['item']} x{order['quantity']}")

01-serverless-app/terraform/main.tf

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,88 @@ resource "aws_dynamodb_table" "orders" {
6464
}
6565
}
6666

67+
# ── ECR ───────────────────────────────────────────────────────────────────────
68+
69+
resource "aws_ecr_repository" "fulfillment" {
70+
name = "fulfillment"
71+
}
72+
73+
# ── ECS ───────────────────────────────────────────────────────────────────────
74+
75+
resource "aws_ecs_cluster" "main" {
76+
name = "workshop"
77+
}
78+
79+
resource "aws_iam_role" "ecs_execution" {
80+
name = "ecs-execution-role"
81+
82+
assume_role_policy = jsonencode({
83+
Version = "2012-10-17"
84+
Statement = [{
85+
Action = "sts:AssumeRole"
86+
Effect = "Allow"
87+
Principal = { Service = "ecs-tasks.amazonaws.com" }
88+
}]
89+
})
90+
}
91+
92+
resource "aws_iam_role_policy_attachment" "ecs_execution" {
93+
role = aws_iam_role.ecs_execution.name
94+
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
95+
}
96+
97+
resource "aws_iam_role" "ecs_task" {
98+
name = "ecs-task-role"
99+
100+
assume_role_policy = jsonencode({
101+
Version = "2012-10-17"
102+
Statement = [{
103+
Action = "sts:AssumeRole"
104+
Effect = "Allow"
105+
Principal = { Service = "ecs-tasks.amazonaws.com" }
106+
}]
107+
})
108+
}
109+
110+
resource "aws_iam_role_policy" "ecs_task" {
111+
role = aws_iam_role.ecs_task.id
112+
113+
policy = jsonencode({
114+
Version = "2012-10-17"
115+
Statement = [
116+
{
117+
Effect = "Allow"
118+
Action = ["dynamodb:GetItem", "dynamodb:UpdateItem"]
119+
Resource = aws_dynamodb_table.orders.arn
120+
},
121+
{
122+
Effect = "Allow"
123+
Action = ["s3:PutObject"]
124+
Resource = "${aws_s3_bucket.receipts.arn}/*"
125+
}
126+
]
127+
})
128+
}
129+
130+
resource "aws_ecs_task_definition" "fulfillment" {
131+
family = "fulfillment"
132+
requires_compatibilities = ["FARGATE"]
133+
network_mode = "awsvpc"
134+
cpu = 256
135+
memory = 512
136+
execution_role_arn = aws_iam_role.ecs_execution.arn
137+
task_role_arn = aws_iam_role.ecs_task.arn
138+
139+
container_definitions = jsonencode([{
140+
name = "fulfillment"
141+
image = "${aws_ecr_repository.fulfillment.repository_url}:latest"
142+
environment = [
143+
{ name = "ORDERS_TABLE", value = aws_dynamodb_table.orders.name },
144+
{ name = "RECEIPTS_BUCKET", value = aws_s3_bucket.receipts.bucket },
145+
]
146+
}])
147+
}
148+
67149
# ── S3 ────────────────────────────────────────────────────────────────────────
68150

69151
resource "aws_s3_bucket" "receipts" {
@@ -148,11 +230,28 @@ resource "aws_iam_role_policy" "sfn_policy" {
148230

149231
policy = jsonencode({
150232
Version = "2012-10-17"
151-
Statement = [{
152-
Effect = "Allow"
153-
Action = "lambda:InvokeFunction"
154-
Resource = aws_lambda_function.order_processor.arn
155-
}]
233+
Statement = [
234+
{
235+
Effect = "Allow"
236+
Action = "lambda:InvokeFunction"
237+
Resource = aws_lambda_function.order_processor.arn
238+
},
239+
{
240+
Effect = "Allow"
241+
Action = ["ecs:RunTask", "ecs:StopTask", "ecs:DescribeTasks"]
242+
Resource = "*"
243+
},
244+
{
245+
Effect = "Allow"
246+
Action = "iam:PassRole"
247+
Resource = [aws_iam_role.ecs_execution.arn, aws_iam_role.ecs_task.arn]
248+
},
249+
{
250+
Effect = "Allow"
251+
Action = ["events:PutTargets", "events:PutRule", "events:DescribeRule"]
252+
Resource = "*"
253+
}
254+
]
156255
})
157256
}
158257

@@ -202,7 +301,6 @@ resource "aws_lambda_function" "order_processor" {
202301
environment {
203302
variables = {
204303
ORDERS_TABLE = aws_dynamodb_table.orders.name
205-
RECEIPTS_BUCKET = aws_s3_bucket.receipts.bucket
206304
STATE_MACHINE_ARN = local.state_machine_arn
207305
}
208306
}
@@ -251,12 +349,21 @@ resource "aws_sfn_state_machine" "order_processing" {
251349
}
252350
FulfillOrder = {
253351
Type = "Task"
254-
Resource = aws_lambda_function.order_processor.arn
352+
Resource = "arn:aws:states:::ecs:runTask.sync:2"
255353
Parameters = {
256-
"step" = "fulfill"
257-
"order.$" = "$.order"
354+
LaunchType = "FARGATE"
355+
Cluster = aws_ecs_cluster.main.arn
356+
TaskDefinition = aws_ecs_task_definition.fulfillment.arn
357+
Overrides = {
358+
ContainerOverrides = [{
359+
Name = "fulfillment"
360+
Environment = [
361+
{ "Name" = "ORDER_ID", "Value.$" = "$.order.order_id" }
362+
]
363+
}]
364+
}
258365
}
259-
ResultPath = "$.order"
366+
ResultPath = null
260367
Catch = [{ ErrorEquals = ["States.ALL"], Next = "HandleFailure", ResultPath = "$.error" }]
261368
End = true
262369
}

Makefile

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
.DEFAULT_GOAL := help
2-
TERRAFORM_DIR := 01-serverless-app/terraform
2+
TERRAFORM_DIR := 01-serverless-app/terraform
3+
ECR_REGISTRY := 000000000000.dkr.ecr.us-east-1.localhost.localstack.cloud:4566
4+
FULFILLMENT_DIR := 01-serverless-app/services/fulfillment
35

46
help: ## Show this help
57
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage: make \033[36m<target>\033[0m\n\nTargets:\n"} \
@@ -27,7 +29,14 @@ setup: ## Fetch auth token and start LocalStack (runs 00-setup/setup.sh)
2729
init: ## Initialise Terraform (only needed once)
2830
cd $(TERRAFORM_DIR) && tflocal init
2931

30-
deploy: ## Deploy the full app to LocalStack via Terraform
32+
build: ## Build and push the fulfillment service image to local ECR
33+
awslocal ecr create-repository --repository-name fulfillment 2>/dev/null || true
34+
awslocal ecr get-login-password | \
35+
docker login --username AWS --password-stdin $(ECR_REGISTRY)
36+
docker build -t $(ECR_REGISTRY)/fulfillment:latest $(FULFILLMENT_DIR)
37+
docker push $(ECR_REGISTRY)/fulfillment:latest
38+
39+
deploy: build ## Build fulfillment image then deploy the full app via Terraform
3140
@[ -d $(TERRAFORM_DIR)/.terraform ] || (cd $(TERRAFORM_DIR) && tflocal init)
3241
cd $(TERRAFORM_DIR) && tflocal apply -auto-approve
3342

@@ -77,6 +86,6 @@ replay-dlq: ## Replay messages from the DLQ back to the main queue
7786
publish-token: ## Upload LOCALSTACK_AUTH_TOKEN to S3 for workshop participants
7887
bash scripts/publish-workshop-token.sh
7988

80-
.PHONY: help start stop status logs setup init deploy destroy redeploy outputs \
89+
.PHONY: help start stop status logs setup init build deploy destroy redeploy outputs \
8190
test test-fast open-ui api-endpoint inject-fault remove-fault replay-dlq \
8291
publish-token

0 commit comments

Comments
 (0)