| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Cluster } from "@/AWS/ECS/Cluster.ts"; |
| 3 | import { Service } from "@/AWS/ECS/Service.ts"; |
| 4 | import { HostedZone } from "@/AWS/Route53"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import * as acm from "@distilled.cloud/aws/acm"; |
| 7 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 8 | import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; |
| 9 | import * as route53 from "@distilled.cloud/aws/route-53"; |
| 10 | import { expect } from "alchemy-test"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Result from "effect/Result"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | import { getDefaultVpcNetwork } from "../DefaultVpc.ts"; |
| 15 | |
| 16 | const { test } = Test.make({ providers: AWS.providers() }); |
| 17 | |
| 18 | const ZONE_NAME = "alchemy-ecs-domain-test.example"; |
| 19 | const 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. |
| 26 | const CERT_PEM = `-----BEGIN CERTIFICATE----- |
| 27 | MIIC2DCCAcACCQDk7zeaJumy7TANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDDCNz |
| 28 | dmMuYWxjaGVteS1lY3MtZG9tYWluLXRlc3QuZXhhbXBsZTAeFw0yNjA3MTgwOTI3 |
| 29 | MzZaFw0zNjA3MTUwOTI3MzZaMC4xLDAqBgNVBAMMI3N2Yy5hbGNoZW15LWVjcy1k |
| 30 | b21haW4tdGVzdC5leGFtcGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC |
| 31 | AQEApCFJJ3VI2ZhjXi4KlmvwJYcctR+0bXQEh9uWrmMLYexYA03pdDSHPU/j1E4A |
| 32 | 2G8KtQZC325Psz+8tHfdcOPSlzVBiCuEp7TypQ9FfeJTYNDkYBY94FjdrPx8nX8z |
| 33 | YYXwv5y4aGUdRNJB+qSaGSAOK0Fc/m+qWCENLmRTRrcC3040NLMJedScT48KvUcp |
| 34 | fjyjSL9wlB09pInWHuNgx3pSaLa+QptV6Y5NJEXyfna7yjo4rYJsvqZcY2iTldiH |
| 35 | 3FiUWeXTdw+TjSppS8OZZqB6UJXitBPnyuSI/yr8aJCxLQ2I+pZbT+vG9fJ0UdZV |
| 36 | IvdBKN3im4WFLri/nlspxR86qwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQB9VVjs |
| 37 | N2ZgdN9PpnSbiyhk/HaaoeAKNsxtzHvPT5YqgIu80Yc1hV6xYhCPnGXDQxB6Ilms |
| 38 | Ul8JoGkyiX0zq2DhYtzdmtUwfFQsZGJb7BmDjqee5aNthTKdEABEIMuclncLSfIz |
| 39 | Y63/MjXMchvisRunRQomxUiGUP5nre2iEgRLYEPA0C/2QY5fHmI0RUNMhL+RqdYv |
| 40 | 3bieRGv0Ssg1AOImDEb14/+YM2RQo5cZTOcHC4eT5VgAbCQnhD0zxx2vERmEMwB0 |
| 41 | 3G7cRrmUnfw9fLz0ta4qaGp4/gJDGa4eNR/BNBuN7T1z+oxquadYvNDH2BzNqNq/ |
| 42 | eM6uU4Wgh9A+ctDm |
| 43 | -----END CERTIFICATE-----`; |
| 44 | |
| 45 | const KEY_PEM = `-----BEGIN PRIVATE KEY----- |
| 46 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkIUkndUjZmGNe |
| 47 | LgqWa/Alhxy1H7RtdASH25auYwth7FgDTel0NIc9T+PUTgDYbwq1BkLfbk+zP7y0 |
| 48 | d91w49KXNUGIK4SntPKlD0V94lNg0ORgFj3gWN2s/HydfzNhhfC/nLhoZR1E0kH6 |
| 49 | pJoZIA4rQVz+b6pYIQ0uZFNGtwLfTjQ0swl51JxPjwq9Ryl+PKNIv3CUHT2kidYe |
| 50 | 42DHelJotr5Cm1Xpjk0kRfJ+drvKOjitgmy+plxjaJOV2IfcWJRZ5dN3D5ONKmlL |
| 51 | w5lmoHpQleK0E+fK5Ij/KvxokLEtDYj6lltP68b18nRR1lUi90Eo3eKbhYUuuL+e |
| 52 | WynFHzqrAgMBAAECggEBAJ3slSoNVPpiAYK2RGO2GgzR85JnnkEOt+lNJbBIBsTD |
| 53 | F3Cef/nbLSGWhD5ci721IpVKIABCcRelRVpUV1LvM0tg59wxG2QO3MZrak8U/WsT |
| 54 | tBqsa/85Ipr3GqSkpvi4WwzTrDBu0nnM4cVcqhVw3ZFLREJhiYNg0gEIcYSCRZY8 |
| 55 | a2OptUq+lEd5Uk9aODpTnQiZ7keE7XFQwnu0XfsW03pRfHu5Tr9mL5NI+sNc010y |
| 56 | hwdLOOC9eqKrjFc4y16mCb/WAJRdYvUo4+Vnyi66dlojwKRGYIpyaM8wAXQ7fN39 |
| 57 | KsMQS0acK0ns7FPDpGrhxpX0WAy/I5lGRUIpgmVzm6ECgYEA1sRAL3vmrRCzfaNk |
| 58 | VzOjKRx10bOvQ73+534J2LlZsKnTp6ma2SzJSKCI8PDJ0TluZb+CIfOlwSnYk1F3 |
| 59 | 2MjpCNbMnb49QeQX+kztqjHAuLlyI/w/p+3Cfa8agvGim6O9Epn2lGjLG4hgSbr6 |
| 60 | K37HUOAlragBziaWXJwoK3YNeLECgYEAw6RBAso61Dx31G9s9ECnZ5IUlMx3njtV |
| 61 | I1RPFtUnkUl+PxJpbrEpyIbWcJjDnoHZTiVlBDURhYRfrKihlGbmu1b+uQXlGQmo |
| 62 | HkFQe1x2JrLWpfdObCr8YaO/W/hO+vvsXA2wQP2D1xTblbe5K+RxeOfpl8vTgsYV |
| 63 | bRIgVTHLgBsCgYEAzJnsWBBew3OMu93EQvsTrp/Jcr0O9Kd1x04gzVJPKumvlSQU |
| 64 | PF/ZZpJhw8BxVBy1msNmkzfOKobWKArN9T36HAyepIzPSQ2m9jMBnEmHw7QQFE5E |
| 65 | ypPM3PXLeQIAmuvTvXvmGJ75GELFEJjLqlI+SkMwQg8zoRTL3HB8KGNwFBECgYEA |
| 66 | sLfnuVtutFNCcnOL2T2leVpsmNPwoM87YkQ/hIJZ0VSvYs2Ayungsi+J9/8lhEIK |
| 67 | RhplGt3My+v8d/uCqIgilspIIF0AyhL89wdMaYHlf6M/XH79KZvSiWfd7Hdo8DEr |
| 68 | GSOMN8hHlLpUdvKTouAg13O0ftcwYQoIRJOA/TOTfE0CgYB+uvQKgrjoRrlKbAb9 |
| 69 | qU/xBytHvLHI/WtcrQRz7ZXUHxML5F1M3v2LWdAVnEKGrqfEGTIa+OqTQq1vMHlj |
| 70 | y8xY8yCojeFN5c5xdhHUzOtg4OSFMJxfmet6ObnhYDfc/T2bqwyyPDvrIskGTl1k |
| 71 | qquR06mkJP9PmFqXnf9SpyM2qg== |
| 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). |
| 82 | test.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. |
| 257 | test.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. |
| 291 | test.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 | ); |