aws2-6 Exercise 2: A Fargate Service in the CDK: Synth, Lint, Tests, and the Size Table ================================================================================== Run with Docker 28.0.4 (Docker Desktop engine, Linux containers, x86-64 Windows 11 host); python:3.13-slim image; Node 24.18.0 with aws-cdk 2.1145.0, aws-cdk-lib 2.273.0, constructs 10.8.1, typescript 7.0.2, jest 30.5.2 (the chapter 5 install, reached from a junction); cfn-lint 1.57.2 and PyYAML from the chapter 4 environment; boto3/botocore 1.43.112 from the chapter 3 environment (offline, stubbed client). Scratch folder C:/lwaws/c6, nothing installed globally. VERIFICATION LEVEL: 1 (run here; no AWS account, no AWS credentials, nothing pushed or deployed). Outputs below are saved from the real runs of 2026-10-13. --- package.json --- { "name": "reports-cdk", "version": "1.0.0", "private": true, "scripts": { "build": "tsc -p tsconfig.json", "test": "node node_modules/jest/bin/jest.js" }, "jest": { "testMatch": [ "**/dist/test/**/*.test.js" ], "testEnvironment": "node" }, "dependencies": { "@types/jest": "30.0.0", "@types/node": "26.6.5", "aws-cdk": "2.1145.0", "aws-cdk-lib": "2.273.0", "constructs": "10.8.1", "jest": "30.5.2", "typescript": "7.0.2" } } --- tsconfig.json --- { "compilerOptions": { "target": "ES2022", "module": "node16", "moduleResolution": "node16", "lib": ["es2022"], "outDir": "dist", "rootDir": ".", "strict": true, "noUnusedLocals": true, "noImplicitReturns": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["node", "jest"] }, "include": ["bin/**/*.ts", "lib/**/*.ts", "test/**/*.ts"], "exclude": ["node_modules", "dist", "cdk.out"] } --- cdk.json --- { "app": "node dist/bin/app.js", "context": { "environment": "dev" } } --- bin/app.ts --- import * as cdk from "aws-cdk-lib"; import { ServiceStack } from "../lib/service-stack"; const app = new cdk.App(); const num = (k: string, d: number) => Number(app.node.tryGetContext(k) ?? d); new ServiceStack(app, "Service", { cpu: num("cpu", 256), memoryMiB: num("memory", 512), desiredCount: num("count", 2) }); --- lib/service-stack.ts (final version; the first used dataBucket.grantRead(taskRole), see the first test run) --- import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as ecr from "aws-cdk-lib/aws-ecr"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as iam from "aws-cdk-lib/aws-iam"; import * as logs from "aws-cdk-lib/aws-logs"; import * as s3 from "aws-cdk-lib/aws-s3"; export interface ServiceProps extends cdk.StackProps { cpu: number; memoryMiB: number; desiredCount: number; } // A deliberately explicit Fargate service: nothing is created for us that we cannot see. export class ServiceStack extends cdk.Stack { constructor(scope: Construct, id: string, props: ServiceProps) { super(scope, id, props); const vpc = new ec2.Vpc(this, "Vpc", { maxAzs: 2, natGateways: 0, subnetConfiguration: [{ name: "public", subnetType: ec2.SubnetType.PUBLIC }] }); const cluster = new ecs.Cluster(this, "Cluster", { vpc }); const repo = new ecr.Repository(this, "Repo", { imageScanOnPush: true, imageTagMutability: ecr.TagMutability.IMMUTABLE, removalPolicy: cdk.RemovalPolicy.DESTROY, emptyOnDelete: true, lifecycleRules: [{ maxImageCount: 10, description: "keep the last 10 images" }], }); const dataBucket = new s3.Bucket(this, "Data", { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, enforceSSL: true }); const taskRole = new iam.Role(this, "TaskRole", { assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com") }); // explicit instead of dataBucket.grantRead(taskRole), which generates s3:GetObject*, s3:GetBucket* and s3:List* taskRole.addToPolicy(new iam.PolicyStatement({ actions: ["s3:GetObject"], resources: [dataBucket.arnForObjects("*")] })); taskRole.addToPolicy(new iam.PolicyStatement({ actions: ["s3:ListBucket"], resources: [dataBucket.bucketArn] })); const task = new ecs.FargateTaskDefinition(this, "Task", { cpu: props.cpu, memoryLimitMiB: props.memoryMiB, taskRole, runtimePlatform: { cpuArchitecture: ecs.CpuArchitecture.X86_64, operatingSystemFamily: ecs.OperatingSystemFamily.LINUX }, }); const logGroup = new logs.LogGroup(this, "Logs", { retention: logs.RetentionDays.ONE_WEEK, removalPolicy: cdk.RemovalPolicy.DESTROY }); task.addContainer("web", { image: ecs.ContainerImage.fromEcrRepository(repo, "1.0.0"), portMappings: [{ containerPort: 8080 }], readonlyRootFilesystem: true, user: "10001", stopTimeout: cdk.Duration.seconds(20), linuxParameters: new ecs.LinuxParameters(this, "Linux", { initProcessEnabled: true }), logging: ecs.LogDrivers.awsLogs({ streamPrefix: "web", logGroup }), healthCheck: { command: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/health')\" || exit 1"], interval: cdk.Duration.seconds(15), timeout: cdk.Duration.seconds(5), retries: 3, startPeriod: cdk.Duration.seconds(10), }, }); const sg = new ec2.SecurityGroup(this, "ServiceSg", { vpc, allowAllOutbound: true }); sg.addIngressRule(ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Port.tcp(8080), "from inside the VPC only"); new ecs.FargateService(this, "Service", { cluster, taskDefinition: task, desiredCount: props.desiredCount, securityGroups: [sg], assignPublicIp: true, minHealthyPercent: 100, maxHealthyPercent: 200, circuitBreaker: { rollback: true }, }); } } --- test/service-stack.test.ts (final version; the first version of the task-role test matched /^s3:(GetObject|GetBucket|List)/) --- import * as cdk from "aws-cdk-lib"; import { Match, Template } from "aws-cdk-lib/assertions"; import { ServiceStack } from "../lib/service-stack"; const build = (cpu = 256, memoryMiB = 512, desiredCount = 2) => { const app = new cdk.App(); return Template.fromStack(new ServiceStack(app, "Service", { cpu, memoryMiB, desiredCount })); }; const t = build(); const policies = () => Object.values(t.findResources("AWS::IAM::Policy")) as any[]; const actionsOf = (policyLogicalPrefix: string) => policies().filter((p) => JSON.stringify(p.Properties.Roles).includes(policyLogicalPrefix)) .flatMap((p) => p.Properties.PolicyDocument.Statement).flatMap((s: any) => [].concat(s.Action)); test("task role and execution role are different roles", () => { const td = Object.values(t.findResources("AWS::ECS::TaskDefinition"))[0] as any; expect(td.Properties.TaskRoleArn["Fn::GetAtt"][0]).not.toEqual(td.Properties.ExecutionRoleArn["Fn::GetAtt"][0]); }); test("execution role can pull images and write logs, and nothing else", () => { expect(new Set(actionsOf("TaskExecutionRole"))).toEqual(new Set([ "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:GetAuthorizationToken", "logs:CreateLogStream", "logs:PutLogEvents"])); }); test("task role can only read the data bucket", () => { const acts = actionsOf("TaskRole"); expect(acts.length).toBeGreaterThan(0); for (const a of acts) expect(a).toMatch(/^s3:(GetObject|ListBucket)$/); expect(acts.join(",")).not.toMatch(/Put|Delete|\*$/); }); test("container: non-root, read-only root fs, init process, stop timeout within the documented 120 s", () => { t.hasResourceProperties("AWS::ECS::TaskDefinition", { ContainerDefinitions: [Match.objectLike({ User: "10001", ReadonlyRootFilesystem: true, StopTimeout: 20, LinuxParameters: Match.objectLike({ InitProcessEnabled: true }) })] }); }); test("health check is defined and the image is a pinned tag, not latest", () => { const td = Object.values(t.findResources("AWS::ECS::TaskDefinition"))[0] as any; const c = td.Properties.ContainerDefinitions[0]; expect(c.HealthCheck.Command[0]).toBe("CMD-SHELL"); expect(JSON.stringify(c.Image)).toContain(":1.0.0"); expect(JSON.stringify(c.Image)).not.toContain(":latest"); }); test("logs go to a log group with a retention period", () => { t.hasResourceProperties("AWS::Logs::LogGroup", { RetentionInDays: 7 }); }); test("repository scans on push, tags are immutable, and a lifecycle rule exists", () => { t.hasResourceProperties("AWS::ECR::Repository", { ImageScanningConfiguration: { ScanOnPush: true }, ImageTagMutability: "IMMUTABLE", LifecyclePolicy: Match.anyValue() }); }); test("rolling update settings leave room to start a task, and the circuit breaker rolls back", () => { t.hasResourceProperties("AWS::ECS::Service", { DeploymentConfiguration: Match.objectLike({ MinimumHealthyPercent: 100, MaximumPercent: 200, DeploymentCircuitBreaker: { Enable: true, Rollback: true } }) }); }); test("no NAT gateway and no load balancer in this stack", () => { t.resourceCountIs("AWS::EC2::NatGateway", 0); t.resourceCountIs("AWS::ElasticLoadBalancingV2::LoadBalancer", 0); }); test("the service security group allows only 8080 from the VPC", () => { t.hasResourceProperties("AWS::EC2::SecurityGroup", { SecurityGroupIngress: [Match.objectLike({ FromPort: 8080, ToPort: 8080, IpProtocol: "tcp" })] }); }); --- jest, first run (before replacing grantRead) (saved output of the real run) --- passed 9 failed 1 PASSED - task role and execution role are different roles PASSED - execution role can pull images and write logs, and nothing else FAILED - task role can only read the data bucket Error: expect(received).not.toMatch(expected) PASSED - container: non-root, read-only root fs, init process, stop timeout within the documented 120 s PASSED - health check is defined and the image is a pinned tag, not latest PASSED - logs go to a log group with a retention period PASSED - repository scans on push, tags are immutable, and a lifecycle rule exists PASSED - rolling update settings leave room to start a task, and the circuit breaker rolls back PASSED - no NAT gateway and no load balancer in this stack PASSED - the service security group allows only 8080 from the VPC --- jest, final run (saved output of the real run) --- passed 10 failed 0 PASSED - task role and execution role are different roles PASSED - execution role can pull images and write logs, and nothing else PASSED - task role can only read the data bucket PASSED - container: non-root, read-only root fs, init process, stop timeout within the documented 120 s PASSED - health check is defined and the image is a pinned tag, not latest PASSED - logs go to a log group with a retention period PASSED - repository scans on push, tags are immutable, and a lifecycle rule exists PASSED - rolling update settings leave room to start a task, and the circuit breaker rolls back PASSED - no NAT gateway and no load balancer in this stack PASSED - the service security group allows only 8080 from the VPC --- cfn-lint on the template in cdk.out and on the YAML printed by cdk synth (saved output of the real run) --- $ cfn-lint cdk.out/Service.template.json (exit status 0) $ cfn-lint out/synth_service.yaml (the YAML that cdk synth prints on stdout) W2001 Parameter BootstrapVersion not used. out\synth_service.yaml:516:3 (exit status 4) Resources identical in both files: True | sections only in cdk.out: ['Rules'] --- fargate_model.py --- """Two small models written from the ECS documentation pages read on 2026-10-13. They are not AWS: they encode the documented rules and are tested on the documented examples. 1. valid_fargate_size(cpu, memory_mib) - the Linux CPU/memory table. 2. rolling_limits(desired, min_healthy_pct, max_pct) - how many tasks a rolling update may stop and start. """ import math GB = 1024 # cpu -> list of allowed memory values in MiB (Linux). Source: task-cpu-memory-error page. TABLE = { 256: [512, 1 * GB, 2 * GB], 512: [g * GB for g in (1, 2, 3, 4)], 1024: [g * GB for g in range(2, 9)], 2048: [g * GB for g in range(4, 17)], 4096: [g * GB for g in range(8, 31)], 8192: [g * GB for g in range(16, 61, 4)], 16384: [g * GB for g in range(32, 121, 8)], 32768: [g * GB for g in (60, 120, 244)], } def valid_fargate_size(cpu, memory_mib): return cpu in TABLE and memory_mib in TABLE[cpu] def rolling_limits(desired, min_healthy_pct, max_pct): """minimumHealthyPercent is rounded UP, maximumPercent is rounded DOWN (documented).""" floor_tasks = math.ceil(desired * min_healthy_pct / 100) ceiling_tasks = math.floor(desired * max_pct / 100) return {"may_stop": desired - floor_tasks, "may_start": ceiling_tasks - desired, "min_running": floor_tasks, "max_running": ceiling_tasks} if __name__ == "__main__": # documented examples for rolling updates (deployment-type-ecs page) cases = [ ("50% min, 4 desired: can stop two before starting two", (4, 50, 200), "may_stop", 2), ("75% min, 2 desired: can't stop any (result is two)", (2, 75, 200), "may_stop", 0), ("200% max, 4 desired: can start four", (4, 100, 200), "may_start", 4), ("125% max, 3 desired: can't start any (result is three)", (3, 100, 125), "may_start", 0), ] bad = 0 for label, args, key, want in cases: got = rolling_limits(*args)[key] ok = got == want bad += not ok print("%-4s %s -> %s=%d (documented %d)" % ("ok" if ok else "FAIL", label, key, got, want)) # spot checks of the size table against the documented rows for cpu, mem, want in [(256, 512, True), (256, 4096, False), (512, 512, False), (1024, 8192, True), (2048, 3072, False), (4096, 30 * GB, True), (8192, 20 * GB, True), (8192, 18 * GB, False), (32768, 244 * GB, True), (300, 512, False)]: got = valid_fargate_size(cpu, mem) bad += got != want print("%-4s cpu %5d memory %6d MiB -> %s" % ("ok" if got == want else "FAIL", cpu, mem, got)) print("mismatches:", bad) raise SystemExit(1 if bad else 0) --- py fargate_model.py (saved output of the real run) --- ok 50% min, 4 desired: can stop two before starting two -> may_stop=2 (documented 2) ok 75% min, 2 desired: can't stop any (result is two) -> may_stop=0 (documented 0) ok 200% max, 4 desired: can start four -> may_start=4 (documented 4) ok 125% max, 3 desired: can't start any (result is three) -> may_start=0 (documented 0) ok cpu 256 memory 512 MiB -> True ok cpu 256 memory 4096 MiB -> False ok cpu 512 memory 512 MiB -> False ok cpu 1024 memory 8192 MiB -> True ok cpu 2048 memory 3072 MiB -> False ok cpu 4096 memory 30720 MiB -> True ok cpu 8192 memory 20480 MiB -> True ok cpu 8192 memory 18432 MiB -> False ok cpu 32768 memory 249856 MiB -> True ok cpu 300 memory 512 MiB -> False mismatches: 0 --- cross_check.py (run with the chapter 4 environment's python, which has cfn-lint on the PATH) --- """Cross-check fargate_model.valid_fargate_size against cfn-lint (E3047/E3048) over every cpu x memory (MiB, multiples of 512 up to 250 GB). Each task definition resource holds one combination; cfn-lint is run in batches of 400 resources (its limit is 500).""" import json, subprocess, sys, tempfile, os from fargate_model import TABLE, valid_fargate_size CPUS = list(TABLE) + [128, 300] MEMS = [512 * i for i in range(1, 250 * 2 + 1)] combos = [(c, m) for c in CPUS for m in MEMS] bad_by_lint, tot = set(), 0 for i in range(0, len(combos), 400): batch = combos[i:i + 400] res = {} for j, (c, m) in enumerate(batch): res["T%d" % j] = {"Type": "AWS::ECS::TaskDefinition", "Properties": {"Cpu": str(c), "Memory": str(m), "NetworkMode": "awsvpc", "RequiresCompatibilities": ["FARGATE"], "ContainerDefinitions": [{"Name": "a", "Image": "x"}]}} p = os.path.join(tempfile.gettempdir(), "xc.json") json.dump({"Resources": res}, open(p, "w")) out = subprocess.run(["cfn-lint", "-f", "json", p], capture_output=True, text=True).stdout for f in json.loads(out): if f["Rule"]["Id"] in ("E3047", "E3048"): idx = int(f["Location"]["Path"][1][1:]) bad_by_lint.add(batch[idx]) tot += len(batch) mism = [(c, m) for (c, m) in combos if (valid_fargate_size(c, m)) == ((c, m) in bad_by_lint)] print("combinations checked:", tot) print("model says valid:", sum(valid_fargate_size(c, m) for c, m in combos), " cfn-lint accepts:", tot - len(bad_by_lint)) print("disagreements:", len(mism)) for c, m in mism[:20]: print(" cpu", c, "memory", m, "model valid =", valid_fargate_size(c, m), " cfn-lint flagged =", (c, m) in bad_by_lint) --- python cross_check.py (saved output of the real run) --- combinations checked: 5000 model says valid: 77 cfn-lint accepts: 77 disagreements: 0 --- cdk synth -c cpu=... -c memory=... for six pairs (saved output of the real run) --- cpu=256 memory=512: cdk synth exit 0; cdk message: ; cfn-lint: nothing beyond W2001 cpu=256 memory=4096: cdk synth exit 0; cdk message: [Warning] Template validation found issues in your templates (reported as warnings).; cfn-lint: E3047 Cpu '256' is not compatible with memory '4096' cpu=512 memory=512: cdk synth exit 0; cdk message: [Warning] Template validation found issues in your templates (reported as warnings).; cfn-lint: E3047 Cpu '512' is not compatible with memory '512' cpu=300 memory=512: cdk synth exit 0; cdk message: [Warning] Template validation found issues in your templates (reported as warnings).; cfn-lint: E3047 Cpu '300' is not compatible with memory '512' E3048 '300' is not one of ['256', '512', '1024', '2048', '4096', '8192', '16384', '32768'] cpu=1024 memory=8192: cdk synth exit 0; cdk message: ; cfn-lint: nothing beyond W2001 cpu=2048 memory=3072: cdk synth exit 0; cdk message: [Warning] Template validation found issues in your templates (reported as warnings).; cfn-lint: E3047 Cpu '2048' is not compatible with memory '3072' --- cfn-lint on the template with StopTimeout 300 / health interval 1 / retries 20 / timeout 90 (each edited in turn) (saved output of the real run) --- stopTimeout 300 cfn-lint: nothing beyond W2001 health interval 1 cfn-lint: nothing beyond W2001 health retries 20 cfn-lint: nothing beyond W2001 health timeout 90 cfn-lint: nothing beyond W2001 WHY THIS WORKS AS AN ANSWER --------------------------- The stack is explicit so every resource is visible. The tests state the properties worth keeping (different roles, exact execution-role actions, least-privilege task role, non-root, read-only, init, health check, pinned tag, scan on push, immutable tags, rollout settings, no NAT, no load balancer) and the first run found a real problem: grantRead produced s3:GetObject*, s3:GetBucket* and s3:List*. Explicit statements fixed it (10 tests pass). The size model and cfn-lint were written independently and agree on 5,000 pairs (77 valid), which is a stronger check than testing the model on my own expectations. cdk synth exits 0 on a bad size and prints only a warning; cfn-lint flags it; neither flagged StopTimeout 300 or a health interval of 1. Limits of the answer: the model encodes a documentation page read on 2026-10-13; the test titled 'within the documented 120 s' only asserts the value 20; the lint of cdk.out versus stdout differs because stdout lacks the Rules section. Nothing was registered with ECS.