| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Cluster } from "@/AWS/ECS/Cluster.ts"; |
| 3 | import { Service } from "@/AWS/ECS/Service.ts"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as aas from "@distilled.cloud/aws/application-auto-scaling"; |
| 6 | import * as logs from "@distilled.cloud/aws/cloudwatch-logs"; |
| 7 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 8 | import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; |
| 9 | import { expect } from "alchemy-test"; |
| 10 | import * as Data from "effect/Data"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Redacted from "effect/Redacted"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | import * as HttpClient from "effect/http/HttpClient"; |
| 15 | import { getDefaultVpcNetwork } from "../DefaultVpc.ts"; |
| 16 | |
| 17 | const { test } = Test.make({ providers: AWS.providers() }); |
| 18 | |
| 19 | class RouteMismatch extends Data.TaggedError("RouteMismatch")<{ |
| 20 | readonly path: string; |
| 21 | readonly status: number; |
| 22 | readonly body: string; |
| 23 | }> {} |
| 24 | |
| 25 | // The container compares its secret-sourced env vars against the KNOWN seed |
| 26 | // values and serves PRESENCE booleans (never the values) plus a /health |
| 27 | // route via busybox httpd. |
| 28 | // |
| 29 | // NOTE: an Effect-native fixture (`main:` + `{ fetch }`) would be the |
| 30 | // preferred shape here, but the Platform class form with Effect-valued |
| 31 | // props (needed to reference the cluster/parameter/secret) currently OOMs |
| 32 | // during construction — a pre-existing Platform.ts bug independent of the |
| 33 | // props under test. The external `image:` form covers the same Phase-2 |
| 34 | // surface: `secrets`, `loadBalancer.health`, `scaling`, and |
| 35 | // `logging.retention` are all provider/composition-level features. |
| 36 | const SERVE_SCRIPT = [ |
| 37 | "mkdir -p /www", |
| 38 | `if [ "$PHASE2_PARAM" = "phase2-parameter-value" ] && [ "$PHASE2_SECRET" = "phase2-secret-value" ]; then echo '{"param":true,"secret":true}' > /www/secrets; else echo '{"param":false,"secret":false}' > /www/secrets; fi`, |
| 39 | "echo ok > /www/health", |
| 40 | "exec httpd -f -p 3000 -h /www", |
| 41 | ].join(" && "); |
| 42 | |
| 43 | // Phase-2 kitchen sink: one service exercising `secrets` (SSM parameter + |
| 44 | // Secrets Manager secret injected via valueFrom), `loadBalancer.health` |
| 45 | // (per-TG override to /health), `scaling` (CPU + ALB request-count target |
| 46 | // tracking; asserted by describing the scalable target + policies, NOT by |
| 47 | // waiting for a scale event), and `logging.retention` — with a live HTTP |
| 48 | // round-trip over the owned ALB proving the container runs, the health |
| 49 | // override passes, and the secrets arrived (presence only). |
| 50 | // |
| 51 | // Requires a local Docker daemon (the `image:` source mirrors into ECR). |
| 52 | test.provider.skipIf(!!process.env.FAST)( |
| 53 | "phase2 service wires secrets, health overrides, scaling, and log retention", |
| 54 | (stack) => |
| 55 | Effect.gen(function* () { |
| 56 | yield* stack.destroy(); |
| 57 | const net = yield* getDefaultVpcNetwork; |
| 58 | const client = yield* HttpClient.HttpClient; |
| 59 | |
| 60 | // Only the per-AZ default subnets: concurrent suites create extra |
| 61 | // subnets, and an ALB rejects two subnets in one AZ. |
| 62 | const azSubnets = yield* ec2 |
| 63 | .describeSubnets({ |
| 64 | Filters: [ |
| 65 | { Name: "vpc-id", Values: [net.vpcId] }, |
| 66 | { Name: "default-for-az", Values: ["true"] }, |
| 67 | ], |
| 68 | }) |
| 69 | .pipe( |
| 70 | Effect.map((r) => |
| 71 | (r.Subnets ?? []).flatMap((s) => (s.SubnetId ? [s.SubnetId] : [])), |
| 72 | ), |
| 73 | ); |
| 74 | |
| 75 | const deployed = yield* stack.deploy( |
| 76 | Effect.gen(function* () { |
| 77 | const cluster = yield* Cluster("Phase2Cluster", { |
| 78 | clusterName: "alchemy-test-ecs-phase2", |
| 79 | }); |
| 80 | const parameter = yield* AWS.SSM.Parameter("Phase2Param", { |
| 81 | value: "phase2-parameter-value", |
| 82 | }); |
| 83 | const secret = yield* AWS.SecretsManager.Secret("Phase2Secret", { |
| 84 | secretString: Redacted.make("phase2-secret-value"), |
| 85 | }); |
| 86 | const service = yield* Service("Phase2Api", { |
| 87 | cluster, |
| 88 | image: "busybox:stable", |
| 89 | command: ["sh", "-c", SERVE_SCRIPT], |
| 90 | port: 3000, |
| 91 | desiredCount: 1, |
| 92 | // Run on ARM64 so the locally-cached (Apple Silicon) busybox the |
| 93 | // mirror pushes matches the Fargate runtime architecture. |
| 94 | runtimePlatform: { |
| 95 | cpuArchitecture: "ARM64", |
| 96 | operatingSystemFamily: "LINUX", |
| 97 | }, |
| 98 | vpcId: net.vpcId as string, |
| 99 | subnets: azSubnets, |
| 100 | // Default-VPC public subnets: tasks need a public IP to pull |
| 101 | // the mirrored image from ECR without a NAT. |
| 102 | assignPublicIp: true, |
| 103 | loadBalancer: { |
| 104 | rules: [{ listen: "80/http" }], |
| 105 | health: { |
| 106 | "3000/http": { |
| 107 | path: "/health", |
| 108 | interval: "15 seconds", |
| 109 | successCodes: "200", |
| 110 | }, |
| 111 | }, |
| 112 | }, |
| 113 | scaling: { |
| 114 | min: 1, |
| 115 | max: 2, |
| 116 | cpuUtilization: 70, |
| 117 | requestCount: 500, |
| 118 | scaleInCooldown: "2 minutes", |
| 119 | }, |
| 120 | secrets: { |
| 121 | PHASE2_PARAM: parameter.parameterArn, |
| 122 | PHASE2_SECRET: secret.secretArn, |
| 123 | }, |
| 124 | logging: { retention: "3 days" }, |
| 125 | }); |
| 126 | return { |
| 127 | url: service.url.as<string>(), |
| 128 | serviceName: service.serviceName.as<string>(), |
| 129 | clusterArn: service.clusterArn.as<string>(), |
| 130 | loadBalancerArn: service.loadBalancerArn.as<string>(), |
| 131 | targetGroupArn: service.targetGroupArn.as<string>(), |
| 132 | logGroupName: service.logGroupName.as<string>(), |
| 133 | }; |
| 134 | }), |
| 135 | ); |
| 136 | |
| 137 | expect(deployed.url).toMatch(/^http:\/\//); |
| 138 | |
| 139 | // ── live round-trip: /health (the overridden TG path) + /secrets ── |
| 140 | const fetchRoute = (path: string) => |
| 141 | client |
| 142 | .get(`${deployed.url}${path}`) |
| 143 | .pipe( |
| 144 | Effect.flatMap((res) => |
| 145 | Effect.map(res.text, (body) => ({ status: res.status, body })), |
| 146 | ), |
| 147 | ); |
| 148 | const awaitRoute = (path: string, predicate: (body: string) => boolean) => |
| 149 | fetchRoute(path).pipe( |
| 150 | Effect.flatMap(({ status, body }) => |
| 151 | status === 200 && predicate(body) |
| 152 | ? Effect.succeed(body) |
| 153 | : Effect.fail(new RouteMismatch({ path, status, body })), |
| 154 | ), |
| 155 | // ALB provisioning (~2 min) + image mirror + Fargate start. |
| 156 | Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 60 }), |
| 157 | ); |
| 158 | |
| 159 | yield* awaitRoute("/health", (body) => body.includes("ok")); |
| 160 | const secretsBody = yield* awaitRoute("/secrets", (body) => |
| 161 | body.includes("true"), |
| 162 | ); |
| 163 | const seen = JSON.parse(secretsBody) as { |
| 164 | param: boolean; |
| 165 | secret: boolean; |
| 166 | }; |
| 167 | expect(seen.param).toBe(true); |
| 168 | expect(seen.secret).toBe(true); |
| 169 | // The secret VALUES never leave the container. |
| 170 | expect(secretsBody).not.toContain("phase2-parameter-value"); |
| 171 | expect(secretsBody).not.toContain("phase2-secret-value"); |
| 172 | |
| 173 | // ── per-TG health override landed on the composed target group ──── |
| 174 | const targetGroups = yield* elbv2.describeTargetGroups({ |
| 175 | TargetGroupArns: [deployed.targetGroupArn], |
| 176 | }); |
| 177 | const targetGroup = targetGroups.TargetGroups?.[0]; |
| 178 | expect(targetGroup?.HealthCheckPath).toBe("/health"); |
| 179 | expect(targetGroup?.HealthCheckIntervalSeconds).toBe(15); |
| 180 | expect(targetGroup?.Matcher?.HttpCode).toBe("200"); |
| 181 | |
| 182 | // ── scaling: scalable target (min/max) + one policy per metric ──── |
| 183 | const resourceId = `service/alchemy-test-ecs-phase2/${deployed.serviceName}`; |
| 184 | const targets = yield* aas.describeScalableTargets({ |
| 185 | ServiceNamespace: "ecs", |
| 186 | ResourceIds: [resourceId], |
| 187 | }); |
| 188 | const scalableTarget = targets.ScalableTargets?.[0]; |
| 189 | expect(scalableTarget?.MinCapacity).toBe(1); |
| 190 | expect(scalableTarget?.MaxCapacity).toBe(2); |
| 191 | |
| 192 | const policies = yield* aas.describeScalingPolicies({ |
| 193 | ServiceNamespace: "ecs", |
| 194 | ResourceId: resourceId, |
| 195 | }); |
| 196 | const trackingConfigs = (policies.ScalingPolicies ?? []).map( |
| 197 | (policy) => policy.TargetTrackingScalingPolicyConfiguration, |
| 198 | ); |
| 199 | expect(trackingConfigs.length).toBe(2); |
| 200 | const cpuPolicy = trackingConfigs.find( |
| 201 | (config) => |
| 202 | config?.PredefinedMetricSpecification?.PredefinedMetricType === |
| 203 | "ECSServiceAverageCPUUtilization", |
| 204 | ); |
| 205 | expect(cpuPolicy?.TargetValue).toBe(70); |
| 206 | expect(cpuPolicy?.ScaleInCooldown).toBe(120); |
| 207 | const requestPolicy = trackingConfigs.find( |
| 208 | (config) => |
| 209 | config?.PredefinedMetricSpecification?.PredefinedMetricType === |
| 210 | "ALBRequestCountPerTarget", |
| 211 | ); |
| 212 | expect(requestPolicy?.TargetValue).toBe(500); |
| 213 | // ResourceLabel = app/{lb-name}/{lb-id}/targetgroup/{tg-name}/{tg-id}. |
| 214 | const expectedLabel = `${deployed.loadBalancerArn.slice( |
| 215 | deployed.loadBalancerArn.indexOf("loadbalancer/") + |
| 216 | "loadbalancer/".length, |
| 217 | )}/${deployed.targetGroupArn.slice( |
| 218 | deployed.targetGroupArn.indexOf("targetgroup/"), |
| 219 | )}`; |
| 220 | expect(requestPolicy?.PredefinedMetricSpecification?.ResourceLabel).toBe( |
| 221 | expectedLabel, |
| 222 | ); |
| 223 | |
| 224 | // ── logging.retention on the auto-created log group ─────────────── |
| 225 | const logGroups = yield* logs.describeLogGroups({ |
| 226 | logGroupNamePrefix: deployed.logGroupName, |
| 227 | }); |
| 228 | const logGroup = (logGroups.logGroups ?? []).find( |
| 229 | (group) => group.logGroupName === deployed.logGroupName, |
| 230 | ); |
| 231 | expect(logGroup?.retentionInDays).toBe(3); |
| 232 | |
| 233 | // ── destroy + zero-orphan proofs ────────────────────────────────── |
| 234 | yield* stack.destroy(); |
| 235 | |
| 236 | const targetsAfter = yield* aas.describeScalableTargets({ |
| 237 | ServiceNamespace: "ecs", |
| 238 | ResourceIds: [resourceId], |
| 239 | }); |
| 240 | expect((targetsAfter.ScalableTargets ?? []).length).toBe(0); |
| 241 | |
| 242 | const lbGone = yield* elbv2 |
| 243 | .describeLoadBalancers({ |
| 244 | LoadBalancerArns: [deployed.loadBalancerArn], |
| 245 | }) |
| 246 | .pipe( |
| 247 | Effect.map((r) => (r.LoadBalancers ?? []).length === 0), |
| 248 | Effect.catchTag("LoadBalancerNotFoundException", () => |
| 249 | Effect.succeed(true), |
| 250 | ), |
| 251 | ); |
| 252 | expect(lbGone).toBe(true); |
| 253 | |
| 254 | const logGroupsAfter = yield* logs.describeLogGroups({ |
| 255 | logGroupNamePrefix: deployed.logGroupName, |
| 256 | }); |
| 257 | expect( |
| 258 | (logGroupsAfter.logGroups ?? []).some( |
| 259 | (group) => group.logGroupName === deployed.logGroupName, |
| 260 | ), |
| 261 | ).toBe(false); |
| 262 | }), |
| 263 | { |
| 264 | tags: [ |
| 265 | "provider:aws", |
| 266 | "provider:aws:applicationautoscaling", |
| 267 | "provider:aws:ec2", |
| 268 | "provider:aws:ecs", |
| 269 | "provider:aws:secretsmanager", |
| 270 | "provider:aws:ssm", |
| 271 | "live", |
| 272 | ], |
| 273 | timeout: 900_000, |
| 274 | }, |
| 275 | ); |