1import * as AWS from "@/AWS";
2import { Cluster } from "@/AWS/ECS/Cluster.ts";
3import { Service } from "@/AWS/ECS/Service.ts";
4import * as Test from "@/Test/Alchemy";
5import * as aas from "@distilled.cloud/aws/application-auto-scaling";
6import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
7import * as ec2 from "@distilled.cloud/aws/ec2";
8import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
9import { expect } from "alchemy-test";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Redacted from "effect/Redacted";
13import * as Schedule from "effect/Schedule";
14import * as HttpClient from "effect/http/HttpClient";
15import { getDefaultVpcNetwork } from "../DefaultVpc.ts";
16
17const { test } = Test.make({ providers: AWS.providers() });
18
19class 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.
36const 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).
52test.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);