1import * as AWS from "@/AWS";
2import { Cluster } from "@/AWS/ECS/Cluster.ts";
3import { Service } from "@/AWS/ECS/Service.ts";
4import { HostedZone } from "@/AWS/Route53";
5import * as Test from "@/Test/Alchemy";
6import * as acm from "@distilled.cloud/aws/acm";
7import * as ec2 from "@distilled.cloud/aws/ec2";
8import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
9import * as route53 from "@distilled.cloud/aws/route-53";
10import { expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import * as Result from "effect/Result";
13import * as Schedule from "effect/Schedule";
14import { getDefaultVpcNetwork } from "../DefaultVpc.ts";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18const ZONE_NAME = "alchemy-ecs-domain-test.example";
19const DOMAIN = `svc.${ZONE_NAME}`;
20
21// Self-signed certificate for `svc.alchemy-ecs-domain-test.example`,
22// generated once with
23// `openssl req -x509 -newkey rsa:2048 -days 3650 -nodes -subj "/CN=..."`
24// and checked in (fixtures are never generated at test time). ALB listeners
25// accept any imported ACM certificate — no CA validation.
26const CERT_PEM = `-----BEGIN CERTIFICATE-----
27MIIC2DCCAcACCQDk7zeaJumy7TANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDDCNz
28dmMuYWxjaGVteS1lY3MtZG9tYWluLXRlc3QuZXhhbXBsZTAeFw0yNjA3MTgwOTI3
29MzZaFw0zNjA3MTUwOTI3MzZaMC4xLDAqBgNVBAMMI3N2Yy5hbGNoZW15LWVjcy1k
30b21haW4tdGVzdC5leGFtcGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
31AQEApCFJJ3VI2ZhjXi4KlmvwJYcctR+0bXQEh9uWrmMLYexYA03pdDSHPU/j1E4A
322G8KtQZC325Psz+8tHfdcOPSlzVBiCuEp7TypQ9FfeJTYNDkYBY94FjdrPx8nX8z
33YYXwv5y4aGUdRNJB+qSaGSAOK0Fc/m+qWCENLmRTRrcC3040NLMJedScT48KvUcp
34fjyjSL9wlB09pInWHuNgx3pSaLa+QptV6Y5NJEXyfna7yjo4rYJsvqZcY2iTldiH
353FiUWeXTdw+TjSppS8OZZqB6UJXitBPnyuSI/yr8aJCxLQ2I+pZbT+vG9fJ0UdZV
36IvdBKN3im4WFLri/nlspxR86qwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQB9VVjs
37N2ZgdN9PpnSbiyhk/HaaoeAKNsxtzHvPT5YqgIu80Yc1hV6xYhCPnGXDQxB6Ilms
38Ul8JoGkyiX0zq2DhYtzdmtUwfFQsZGJb7BmDjqee5aNthTKdEABEIMuclncLSfIz
39Y63/MjXMchvisRunRQomxUiGUP5nre2iEgRLYEPA0C/2QY5fHmI0RUNMhL+RqdYv
403bieRGv0Ssg1AOImDEb14/+YM2RQo5cZTOcHC4eT5VgAbCQnhD0zxx2vERmEMwB0
413G7cRrmUnfw9fLz0ta4qaGp4/gJDGa4eNR/BNBuN7T1z+oxquadYvNDH2BzNqNq/
42eM6uU4Wgh9A+ctDm
43-----END CERTIFICATE-----`;
44
45const KEY_PEM = `-----BEGIN PRIVATE KEY-----
46MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkIUkndUjZmGNe
47LgqWa/Alhxy1H7RtdASH25auYwth7FgDTel0NIc9T+PUTgDYbwq1BkLfbk+zP7y0
48d91w49KXNUGIK4SntPKlD0V94lNg0ORgFj3gWN2s/HydfzNhhfC/nLhoZR1E0kH6
49pJoZIA4rQVz+b6pYIQ0uZFNGtwLfTjQ0swl51JxPjwq9Ryl+PKNIv3CUHT2kidYe
5042DHelJotr5Cm1Xpjk0kRfJ+drvKOjitgmy+plxjaJOV2IfcWJRZ5dN3D5ONKmlL
51w5lmoHpQleK0E+fK5Ij/KvxokLEtDYj6lltP68b18nRR1lUi90Eo3eKbhYUuuL+e
52WynFHzqrAgMBAAECggEBAJ3slSoNVPpiAYK2RGO2GgzR85JnnkEOt+lNJbBIBsTD
53F3Cef/nbLSGWhD5ci721IpVKIABCcRelRVpUV1LvM0tg59wxG2QO3MZrak8U/WsT
54tBqsa/85Ipr3GqSkpvi4WwzTrDBu0nnM4cVcqhVw3ZFLREJhiYNg0gEIcYSCRZY8
55a2OptUq+lEd5Uk9aODpTnQiZ7keE7XFQwnu0XfsW03pRfHu5Tr9mL5NI+sNc010y
56hwdLOOC9eqKrjFc4y16mCb/WAJRdYvUo4+Vnyi66dlojwKRGYIpyaM8wAXQ7fN39
57KsMQS0acK0ns7FPDpGrhxpX0WAy/I5lGRUIpgmVzm6ECgYEA1sRAL3vmrRCzfaNk
58VzOjKRx10bOvQ73+534J2LlZsKnTp6ma2SzJSKCI8PDJ0TluZb+CIfOlwSnYk1F3
592MjpCNbMnb49QeQX+kztqjHAuLlyI/w/p+3Cfa8agvGim6O9Epn2lGjLG4hgSbr6
60K37HUOAlragBziaWXJwoK3YNeLECgYEAw6RBAso61Dx31G9s9ECnZ5IUlMx3njtV
61I1RPFtUnkUl+PxJpbrEpyIbWcJjDnoHZTiVlBDURhYRfrKihlGbmu1b+uQXlGQmo
62HkFQe1x2JrLWpfdObCr8YaO/W/hO+vvsXA2wQP2D1xTblbe5K+RxeOfpl8vTgsYV
63bRIgVTHLgBsCgYEAzJnsWBBew3OMu93EQvsTrp/Jcr0O9Kd1x04gzVJPKumvlSQU
64PF/ZZpJhw8BxVBy1msNmkzfOKobWKArN9T36HAyepIzPSQ2m9jMBnEmHw7QQFE5E
65ypPM3PXLeQIAmuvTvXvmGJ75GELFEJjLqlI+SkMwQg8zoRTL3HB8KGNwFBECgYEA
66sLfnuVtutFNCcnOL2T2leVpsmNPwoM87YkQ/hIJZ0VSvYs2Ayungsi+J9/8lhEIK
67RhplGt3My+v8d/uCqIgilspIIF0AyhL89wdMaYHlf6M/XH79KZvSiWfd7Hdo8DEr
68GSOMN8hHlLpUdvKTouAg13O0ftcwYQoIRJOA/TOTfE0CgYB+uvQKgrjoRrlKbAb9
69qU/xBytHvLHI/WtcrQRz7ZXUHxML5F1M3v2LWdAVnEKGrqfEGTIa+OqTQq1vMHlj
70y8xY8yCojeFN5c5xdhHUzOtg4OSFMJxfmet6ObnhYDfc/T2bqwyyPDvrIskGTl1k
71qquR06mkJP9PmFqXnf9SpyM2qg==
72-----END PRIVATE KEY-----`;
73
74// `domain` with an explicit `cert:` — the composed HTTPS listener carries
75// the certificate and alias A/AAAA records land in the (test-created,
76// undelegated) hosted zone; `url` prefers the domain. The zone is deployed
77// FIRST (the zone lookup runs at composition time), then the service.
78// Everything is describe-level except the deploy itself: the zone is not
79// publicly delegated, so the domain never resolves on the open internet.
80//
81// Requires a local Docker daemon (the `image:` source mirrors into ECR).
82test.provider.skipIf(!!process.env.FAST)(
83 "domain with explicit cert wires the HTTPS listener + alias records; url prefers the domain",
84 (stack) =>
85 Effect.gen(function* () {
86 yield* stack.destroy();
87 const net = yield* getDefaultVpcNetwork;
88
89 // Import the self-signed certificate out of band (deleted on the way
90 // out); ALB HTTPS listeners require an in-region ACM certificate.
91 const imported = yield* acm.importCertificate({
92 Certificate: new TextEncoder().encode(CERT_PEM),
93 PrivateKey: new TextEncoder().encode(KEY_PEM),
94 });
95 const certificateArn = imported.CertificateArn!;
96 // Failure-path backup only (the happy path deletes the cert in-body,
97 // below). It runs inside the implicit file afterAll, whose budget is
98 // the 120s default — so this retry MUST stay well under that, or a
99 // still-in-use cert turns a passing file into an afterAll TimeoutError.
100 // If the cert is still ELB-held after ~60s, leak it for `pnpm nuke`.
101 yield* Effect.addFinalizer(() =>
102 acm.deleteCertificate({ CertificateArn: certificateArn }).pipe(
103 Effect.retry({
104 while: (e) => e._tag === "ResourceInUseException",
105 schedule: Schedule.max([
106 Schedule.spaced("5 seconds"),
107 Schedule.recurs(12),
108 ]),
109 }),
110 Effect.ignore,
111 ),
112 );
113
114 const azSubnets = yield* ec2
115 .describeSubnets({
116 Filters: [
117 { Name: "vpc-id", Values: [net.vpcId] },
118 { Name: "default-for-az", Values: ["true"] },
119 ],
120 })
121 .pipe(
122 Effect.map((r) =>
123 (r.Subnets ?? []).flatMap((s) => (s.SubnetId ? [s.SubnetId] : [])),
124 ),
125 );
126
127 const program = (includeService: boolean) =>
128 Effect.gen(function* () {
129 const zone = yield* HostedZone("DomainZone", { name: ZONE_NAME });
130 if (!includeService) {
131 return { zoneId: zone.id.as<string>() };
132 }
133 const cluster = yield* Cluster("DomainCluster", {
134 clusterName: "alchemy-test-ecs-domain",
135 });
136 const service = yield* Service("DomainSvc", {
137 cluster,
138 image: "busybox:stable",
139 command: ["sh", "-c", "while true; do sleep 30; done"],
140 port: 80,
141 desiredCount: 0,
142 vpcId: net.vpcId as string,
143 subnets: azSubnets,
144 loadBalancer: {
145 domain: { name: DOMAIN, cert: certificateArn },
146 },
147 });
148 return {
149 zoneId: zone.id.as<string>(),
150 url: service.url.as<string>(),
151 loadBalancerArn: service.loadBalancerArn.as<string>(),
152 listenerArn: service.listenerArn.as<string>(),
153 };
154 });
155
156 // Deploy the zone FIRST: the service's zone lookup runs at
157 // composition time, so the zone must exist before the service does.
158 yield* stack.deploy(program(false));
159 const deployed = (yield* stack.deploy(program(true))) as {
160 zoneId: string;
161 url: string;
162 loadBalancerArn: string;
163 listenerArn: string;
164 };
165
166 // `url` prefers the domain (https on the default 443 → no port).
167 expect(deployed.url).toBe(`https://${DOMAIN}`);
168
169 // The default listener is 443/HTTPS carrying the explicit cert.
170 const listeners = yield* elbv2.describeListeners({
171 LoadBalancerArn: deployed.loadBalancerArn,
172 });
173 const httpsListener = (listeners.Listeners ?? []).find(
174 (l) => l.Port === 443,
175 );
176 expect(httpsListener?.Protocol).toBe("HTTPS");
177 expect(
178 (httpsListener?.Certificates ?? []).some(
179 (c) => c.CertificateArn === certificateArn,
180 ),
181 ).toBe(true);
182
183 // Alias A + AAAA records for the domain point at the ALB.
184 const zoneId = deployed.zoneId.replace(/^\/hostedzone\//, "");
185 const loadBalancers = yield* elbv2.describeLoadBalancers({
186 LoadBalancerArns: [deployed.loadBalancerArn],
187 });
188 const albDns = loadBalancers.LoadBalancers?.[0]?.DNSName ?? "";
189 const records = yield* route53.listResourceRecordSets({
190 HostedZoneId: zoneId,
191 });
192 const aliasRecords = (records.ResourceRecordSets ?? []).filter(
193 (record) => record.Name === `${DOMAIN}.` && record.AliasTarget,
194 );
195 expect(aliasRecords.map((r) => r.Type).sort()).toEqual(["A", "AAAA"]);
196 for (const record of aliasRecords) {
197 expect(
198 record
199 .AliasTarget!.DNSName!.toLowerCase()
200 .includes(albDns.toLowerCase()),
201 ).toBe(true);
202 }
203
204 // ── destroy + zero-orphan proofs ──────────────────────────────────
205 yield* stack.destroy();
206
207 const lbGone = yield* elbv2
208 .describeLoadBalancers({
209 LoadBalancerArns: [deployed.loadBalancerArn],
210 })
211 .pipe(
212 Effect.map((r) => (r.LoadBalancers ?? []).length === 0),
213 Effect.catchTag("LoadBalancerNotFoundException", () =>
214 Effect.succeed(true),
215 ),
216 );
217 expect(lbGone).toBe(true);
218
219 // The zone (and with it the alias records) is destroyed by the stack.
220 const zoneGone = yield* route53.getHostedZone({ Id: zoneId }).pipe(
221 Effect.map(() => false),
222 Effect.catchTag("NoSuchHostedZone", () => Effect.succeed(true)),
223 );
224 expect(zoneGone).toBe(true);
225
226 // Delete the imported certificate here, inside the test's 900s budget:
227 // ELB can report ResourceInUseException for minutes after the listener
228 // is gone, and the scope finalizer registered above runs under the
229 // afterAll hook's much smaller budget — relying on it for the happy
230 // path times the hook out and leaks the certificate. The finalizer
231 // stays as a failure-path backup and no-ops once this succeeds.
232 yield* acm.deleteCertificate({ CertificateArn: certificateArn }).pipe(
233 Effect.retry({
234 while: (e) => e._tag === "ResourceInUseException",
235 schedule: Schedule.max([
236 Schedule.spaced("5 seconds"),
237 Schedule.recurs(60),
238 ]),
239 }),
240 );
241 }),
242 {
243 tags: [
244 "provider:aws",
245 "provider:aws:acm",
246 "provider:aws:ec2",
247 "provider:aws:ecs",
248 "provider:aws:route53",
249 "live",
250 ],
251 timeout: 900_000,
252 },
253);
254
255// Typed probe (cheap, no infra): a `domain` whose hosted zone doesn't exist
256// fails the deploy with ServiceHostedZoneNotFound before composing anything.
257test.provider(
258 "domain without a matching hosted zone fails with ServiceHostedZoneNotFound",
259 (stack) =>
260 Effect.gen(function* () {
261 const result = yield* stack
262 .deploy(
263 Effect.gen(function* () {
264 return yield* Service("ZonelessSvc", {
265 cluster:
266 "arn:aws:ecs:us-east-1:123456789012:cluster/never-created" as any,
267 image: "busybox:stable",
268 port: 80,
269 desiredCount: 0,
270 loadBalancer: {
271 domain: "svc.alchemy-nonexistent-zone-84721.example",
272 },
273 });
274 }),
275 )
276 .pipe(Effect.result);
277 expect(Result.isFailure(result)).toBe(true);
278 const rendered = Result.isFailure(result)
279 ? `${JSON.stringify(result.failure)} ${String(result.failure)}`
280 : "";
281 expect(rendered).toContain("no public Route 53 hosted zone");
282 yield* stack.destroy();
283 }),
284 { tags: ["provider:aws", "provider:aws:ecs", "live"], timeout: 120_000 },
285);
286
287// Full ACM-issuance e2e (composed DNS-validated certificate) needs a REAL
288// delegated public zone so validation records resolve — gated behind
289// AWS_TEST_DOMAIN=<zone-name> (e.g. an account with `example.com` delegated
290// runs `AWS_TEST_DOMAIN=example.com`). Issuance takes minutes.
291test.provider.skipIf(!process.env.AWS_TEST_DOMAIN)(
292 "domain composes a DNS-validated ACM certificate in a delegated zone",
293 (stack) =>
294 Effect.gen(function* () {
295 yield* stack.destroy();
296 const zoneName = process.env.AWS_TEST_DOMAIN!;
297 const domainName = `alchemy-ecs-e2e.${zoneName}`;
298 const net = yield* getDefaultVpcNetwork;
299 const azSubnets = yield* ec2
300 .describeSubnets({
301 Filters: [
302 { Name: "vpc-id", Values: [net.vpcId] },
303 { Name: "default-for-az", Values: ["true"] },
304 ],
305 })
306 .pipe(
307 Effect.map((r) =>
308 (r.Subnets ?? []).flatMap((s) => (s.SubnetId ? [s.SubnetId] : [])),
309 ),
310 );
311
312 const deployed = yield* stack.deploy(
313 Effect.gen(function* () {
314 const cluster = yield* Cluster("AcmDomainCluster", {
315 clusterName: "alchemy-test-ecs-acm-domain",
316 });
317 const service = yield* Service("AcmDomainSvc", {
318 cluster,
319 image: "busybox:stable",
320 command: ["sh", "-c", "while true; do sleep 30; done"],
321 port: 80,
322 desiredCount: 0,
323 vpcId: net.vpcId as string,
324 subnets: azSubnets,
325 loadBalancer: { domain: domainName },
326 });
327 return {
328 url: service.url.as<string>(),
329 listenerArn: service.listenerArn.as<string>(),
330 loadBalancerArn: service.loadBalancerArn.as<string>(),
331 };
332 }),
333 );
334
335 expect(deployed.url).toBe(`https://${domainName}`);
336 const listeners = yield* elbv2.describeListeners({
337 LoadBalancerArn: deployed.loadBalancerArn,
338 });
339 const httpsListener = (listeners.Listeners ?? []).find(
340 (l) => l.Port === 443,
341 );
342 expect(httpsListener?.Protocol).toBe("HTTPS");
343 expect((httpsListener?.Certificates ?? []).length).toBeGreaterThan(0);
344
345 yield* stack.destroy();
346 }),
347 {
348 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
349 timeout: 900_000,
350 },
351);