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 ec2 from "@distilled.cloud/aws/ec2";
6import * as ecs from "@distilled.cloud/aws/ecs";
7import * as sd from "@distilled.cloud/aws/servicediscovery";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { getDefaultVpcNetwork } from "../DefaultVpc.ts";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14// Describe-level coverage for the Phase-2 config sugar that doesn't need a
15// running task: `serviceRegistry` (composed Cloud Map service wired into
16// `serviceRegistries`), the `{ efs, path }` volume sugar, and the
17// container-level `healthCheck`. `desiredCount: 0` keeps it fast — the task
18// definition and service wiring are fully observable without placement.
19//
20// Requires a local Docker daemon (the `image:` source mirrors into ECR).
21test.provider.skipIf(!!process.env.FAST)(
22 "serviceRegistry, EFS volume sugar, and container healthCheck land on the service",
23 (stack) =>
24 Effect.gen(function* () {
25 yield* stack.destroy();
26 const net = yield* getDefaultVpcNetwork;
27
28 const azSubnets = yield* ec2
29 .describeSubnets({
30 Filters: [
31 { Name: "vpc-id", Values: [net.vpcId] },
32 { Name: "default-for-az", Values: ["true"] },
33 ],
34 })
35 .pipe(
36 Effect.map((r) =>
37 (r.Subnets ?? []).flatMap((s) => (s.SubnetId ? [s.SubnetId] : [])),
38 ),
39 );
40
41 const deployed = yield* stack.deploy(
42 Effect.gen(function* () {
43 const cluster = yield* Cluster("ConfigCluster", {
44 clusterName: "alchemy-test-ecs-phase2-config",
45 });
46 const namespace = yield* AWS.CloudMap.PrivateDnsNamespace(
47 "ConfigNamespace",
48 {
49 name: "phase2-config.alchemy-test.internal",
50 vpc: net.vpcId,
51 },
52 );
53 const fileSystem = yield* AWS.EFS.FileSystem("ConfigFs", {});
54 const service = yield* Service("ConfigSvc", {
55 cluster,
56 image: "busybox:stable",
57 command: ["sh", "-c", "while true; do sleep 30; done"],
58 port: 8080,
59 desiredCount: 0,
60 vpcId: net.vpcId as string,
61 subnets: azSubnets,
62 serviceRegistry: { namespace },
63 volumes: [{ efs: fileSystem, path: "/mnt/data" }],
64 healthCheck: {
65 command: ["CMD-SHELL", "exit 0"],
66 interval: "30 seconds",
67 timeout: "5 seconds",
68 retries: 3,
69 startPeriod: "10 seconds",
70 },
71 });
72 return {
73 serviceName: service.serviceName.as<string>(),
74 clusterArn: service.clusterArn.as<string>(),
75 taskDefinitionArn: service.taskDefinitionArn.as<string>(),
76 fileSystemId: fileSystem.fileSystemId.as<string>(),
77 namespaceId: namespace.namespaceId.as<string>(),
78 };
79 }),
80 );
81
82 // ── serviceRegistry: the composed Cloud Map service is wired in ───
83 const services = yield* ecs.describeServices({
84 cluster: deployed.clusterArn,
85 services: [deployed.serviceName],
86 });
87 const registryArn =
88 services.services?.[0]?.serviceRegistries?.[0]?.registryArn;
89 expect(registryArn).toContain(":servicediscovery:");
90 const cloudMapServiceId = registryArn!.split("/").pop()!;
91 const cloudMapService = yield* sd.getService({ Id: cloudMapServiceId });
92 expect(cloudMapService.Service?.NamespaceId).toBe(deployed.namespaceId);
93 expect(
94 cloudMapService.Service?.DnsConfig?.DnsRecords?.map((r) => r.Type),
95 ).toEqual(["A"]);
96
97 // ── volumes + healthCheck sugar on the task definition ────────────
98 const described = yield* ecs.describeTaskDefinition({
99 taskDefinition: deployed.taskDefinitionArn,
100 });
101 const volume = described.taskDefinition?.volumes?.[0];
102 expect(volume?.name).toBe("efs-0");
103 expect(volume?.efsVolumeConfiguration?.fileSystemId).toBe(
104 deployed.fileSystemId,
105 );
106 expect(volume?.efsVolumeConfiguration?.transitEncryption).toBe("ENABLED");
107 const container = described.taskDefinition?.containerDefinitions?.[0];
108 const mountPoint = container?.mountPoints?.[0];
109 expect(container?.mountPoints?.length).toBe(1);
110 expect(mountPoint?.sourceVolume).toBe("efs-0");
111 expect(mountPoint?.containerPath).toBe("/mnt/data");
112 expect(container?.healthCheck?.command).toEqual(["CMD-SHELL", "exit 0"]);
113 expect(container?.healthCheck?.interval).toBe(30);
114 expect(container?.healthCheck?.timeout).toBe(5);
115 expect(container?.healthCheck?.retries).toBe(3);
116 expect(container?.healthCheck?.startPeriod).toBe(10);
117
118 // ── destroy + zero-orphan proofs ──────────────────────────────────
119 yield* stack.destroy();
120
121 const cloudMapGone = yield* sd.getService({ Id: cloudMapServiceId }).pipe(
122 Effect.map(() => false),
123 Effect.catchTag("ServiceNotFound", () => Effect.succeed(true)),
124 );
125 expect(cloudMapGone).toBe(true);
126
127 const namespaceGone = yield* sd
128 .getNamespace({ Id: deployed.namespaceId })
129 .pipe(
130 Effect.map(() => false),
131 Effect.catchTag("NamespaceNotFound", () => Effect.succeed(true)),
132 );
133 expect(namespaceGone).toBe(true);
134 }),
135 {
136 tags: [
137 "provider:aws",
138 "provider:aws:cloudmap",
139 "provider:aws:ec2",
140 "provider:aws:ecs",
141 "provider:aws:efs",
142 "live",
143 ],
144 timeout: 600_000,
145 },
146);