1import * as AWS from "@/AWS";
2import { PrivateDnsNamespace, Service } from "@/AWS/CloudMap";
3import * as Test from "@/Test/Alchemy";
4import * as sd from "@distilled.cloud/aws/servicediscovery";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import { getDefaultVpc } from "../DefaultVpc.ts";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const findService = (serviceId: string) =>
15 sd.getService({ Id: serviceId }).pipe(
16 Effect.map((r) => r.Service),
17 Effect.catchTag("ServiceNotFound", () => Effect.succeed(undefined)),
18 );
19
20class ServiceStillExists extends Data.TaggedError("ServiceStillExists")<{
21 readonly serviceId: string;
22}> {}
23
24const assertServiceDeleted = (serviceId: string) =>
25 findService(serviceId).pipe(
26 Effect.flatMap((service) =>
27 service === undefined
28 ? Effect.void
29 : Effect.fail(new ServiceStillExists({ serviceId })),
30 ),
31 Effect.retry({
32 while: (e) => e._tag === "ServiceStillExists",
33 schedule: Schedule.max([
34 Schedule.spaced("3 seconds"),
35 Schedule.recurs(20),
36 ]),
37 }),
38 );
39
40test.provider(
41 "DNS service lifecycle: create, update TTL + description, replace on record type change",
42 (stack) =>
43 Effect.gen(function* () {
44 yield* stack.destroy();
45 const vpc = yield* getDefaultVpc;
46
47 const makeStack = (
48 records: { type: "A" | "SRV"; ttl: Duration.Input }[],
49 description: string,
50 attributes?: Record<string, string>,
51 ) =>
52 Effect.gen(function* () {
53 const namespace = yield* PrivateDnsNamespace("ServiceTestNamespace", {
54 name: "alchemy-test-cloudmap-service.local",
55 vpc: vpc.vpcId,
56 });
57 // no pinned name: replacement must generate a fresh physical name
58 // (a pinned name would collide during create-before-delete)
59 const service = yield* Service("Backend", {
60 namespaceId: namespace.namespaceId,
61 description,
62 dnsRecords: records,
63 routingPolicy: "MULTIVALUE",
64 attributes,
65 tags: { Environment: "test" },
66 });
67 return { namespace, service };
68 });
69
70 const findServiceAttributes = (serviceId: string) =>
71 sd
72 .getServiceAttributes({ ServiceId: serviceId })
73 .pipe(Effect.map((r) => r.ServiceAttributes?.Attributes ?? {}));
74
75 const { namespace, service } = yield* stack.deploy(
76 makeStack([{ type: "A", ttl: "10 seconds" }], "initial", {
77 tier: "backend",
78 stage: "one",
79 }),
80 );
81
82 expect(service.serviceId).toBeDefined();
83 expect(service.serviceArn).toContain(":service/");
84 // engine-generated physical name (long test-derived prefixes truncate,
85 // so only assert shape)
86 expect(service.serviceName.length).toBeGreaterThan(0);
87 expect(service.serviceName.length).toBeLessThanOrEqual(63);
88 expect(service.namespaceId).toBe(namespace.namespaceId);
89 expect(service.namespaceName).toBe("alchemy-test-cloudmap-service.local");
90
91 // out-of-band verification via distilled
92 const created = yield* findService(service.serviceId);
93 expect(created?.Description).toBe("initial");
94 expect(created?.DnsConfig?.RoutingPolicy).toBe("MULTIVALUE");
95 expect(created?.DnsConfig?.DnsRecords).toEqual([{ Type: "A", TTL: 10 }]);
96 const tags = yield* sd
97 .listTagsForResource({ ResourceARN: service.serviceArn })
98 .pipe(
99 Effect.map((r) =>
100 Object.fromEntries((r.Tags ?? []).map((t) => [t.Key, t.Value])),
101 ),
102 );
103 expect(tags.Environment).toBe("test");
104 expect(tags["alchemy::id"]).toBe("Backend");
105
106 // custom service attributes were written on create
107 expect(yield* findServiceAttributes(service.serviceId)).toEqual({
108 tier: "backend",
109 stage: "one",
110 });
111
112 // TTL + description + attributes are mutable via updateService /
113 // updateServiceAttributes / deleteServiceAttributes (tier changed,
114 // stage removed, extra added)
115 const updated = yield* stack.deploy(
116 makeStack([{ type: "A", ttl: "30 seconds" }], "updated", {
117 tier: "web",
118 extra: "x",
119 }),
120 );
121 expect(updated.service.serviceId).toBe(service.serviceId);
122
123 const afterUpdate = yield* findService(service.serviceId);
124 expect(afterUpdate?.Description).toBe("updated");
125 expect(afterUpdate?.DnsConfig?.DnsRecords).toEqual([
126 { Type: "A", TTL: 30 },
127 ]);
128 expect(yield* findServiceAttributes(service.serviceId)).toEqual({
129 tier: "web",
130 extra: "x",
131 });
132
133 // changing the record TYPE replaces the service
134 const replaced = yield* stack.deploy(
135 makeStack([{ type: "SRV", ttl: "30 seconds" }], "updated"),
136 );
137 expect(replaced.service.serviceId).not.toBe(service.serviceId);
138 const afterReplace = yield* findService(replaced.service.serviceId);
139 expect(afterReplace?.DnsConfig?.DnsRecords).toEqual([
140 { Type: "SRV", TTL: 30 },
141 ]);
142 yield* assertServiceDeleted(service.serviceId);
143
144 yield* stack.destroy();
145 yield* assertServiceDeleted(replaced.service.serviceId);
146 }),
147 {
148 tags: ["provider:aws", "provider:aws:cloudmap", "provider:aws:ec2", "live"],
149 timeout: 240_000,
150 },
151);