1import * as AWS from "@/AWS";
2import { Subnet, Vpc } from "@/AWS/EC2";
3import {
4 Listener,
5 Rule,
6 Service,
7 ServiceNetwork,
8 ServiceNetworkServiceAssociation,
9 TargetGroup,
10} from "@/AWS/VpcLattice";
11import * as Test from "@/Test/Alchemy";
12import * as vpclattice from "@distilled.cloud/aws/vpc-lattice";
13import { expect } from "alchemy-test";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import * as Schedule from "effect/Schedule";
17
18const { test } = Test.make({ providers: AWS.providers() });
19
20class StillExists extends Data.TaggedError("StillExists")<{
21 readonly id: string;
22}> {}
23
24const assertTargetGroupDeleted = (id: string) =>
25 vpclattice.getTargetGroup({ targetGroupIdentifier: id }).pipe(
26 Effect.flatMap(() => Effect.fail(new StillExists({ id }))),
27 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
28 Effect.retry({
29 while: (e): boolean => e._tag === "StillExists",
30 schedule: Schedule.max([
31 Schedule.spaced("3 seconds"),
32 Schedule.recurs(15),
33 ]),
34 }),
35 );
36
37const assertServiceDeleted = (id: string) =>
38 vpclattice.getService({ serviceIdentifier: id }).pipe(
39 Effect.flatMap(() => Effect.fail(new StillExists({ id }))),
40 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
41 Effect.retry({
42 while: (e): boolean => e._tag === "StillExists",
43 schedule: Schedule.max([
44 Schedule.spaced("3 seconds"),
45 Schedule.recurs(15),
46 ]),
47 }),
48 );
49
50test.provider(
51 "service chain: target group, listener, rule, service association",
52 (stack) =>
53 Effect.gen(function* () {
54 yield* stack.destroy();
55
56 const program = (options: {
57 defaultStatusCode: number;
58 rulePriority: number;
59 targetIp: string;
60 healthCheckEnabled: boolean;
61 }) =>
62 Effect.gen(function* () {
63 const vpc = yield* Vpc("ChainVpc", { cidrBlock: "10.31.0.0/16" });
64 // IP targets must fall inside a subnet of the VPC.
65 const subnet = yield* Subnet("ChainSubnet", {
66 vpcId: vpc.vpcId,
67 cidrBlock: "10.31.0.0/24",
68 });
69 const network = yield* ServiceNetwork("ChainNetwork", {});
70 const service = yield* Service("ChainService", {});
71 const association = yield* ServiceNetworkServiceAssociation(
72 "ChainAssociation",
73 {
74 serviceNetworkIdentifier: network.serviceNetworkId,
75 serviceIdentifier: service.serviceId,
76 },
77 );
78 const targetGroup = yield* TargetGroup("ChainTargets", {
79 type: "IP",
80 port: 80,
81 protocol: "HTTP",
82 // Reference the VPC through the subnet so target registration
83 // (which requires a subnet covering the target IP) is ordered
84 // after the subnet exists.
85 vpcIdentifier: subnet.vpcId,
86 healthCheck: {
87 enabled: options.healthCheckEnabled,
88 protocol: "HTTP",
89 path: "/health",
90 healthCheckInterval: "30 seconds",
91 healthCheckTimeout: "5 seconds",
92 },
93 targets: [{ id: options.targetIp, port: 80 }],
94 });
95 const listener = yield* Listener("ChainListener", {
96 serviceIdentifier: service.serviceId,
97 protocol: "HTTP",
98 port: 80,
99 defaultAction: {
100 fixedResponse: { statusCode: options.defaultStatusCode },
101 },
102 });
103 const rule = yield* Rule("ChainRule", {
104 serviceIdentifier: service.serviceId,
105 listenerIdentifier: listener.listenerId,
106 priority: options.rulePriority,
107 match: {
108 httpMatch: { pathMatch: { match: { prefix: "/api" } } },
109 },
110 action: {
111 forward: {
112 targetGroups: [
113 {
114 targetGroupIdentifier: targetGroup.targetGroupId,
115 weight: 100,
116 },
117 ],
118 },
119 },
120 });
121 return { association, listener, network, rule, service, targetGroup };
122 });
123
124 const first = yield* stack.deploy(
125 program({
126 defaultStatusCode: 404,
127 rulePriority: 10,
128 targetIp: "10.31.0.10",
129 healthCheckEnabled: false,
130 }),
131 );
132
133 expect(first.targetGroup.targetGroupId).toMatch(/^tg-/);
134 expect(first.targetGroup.type).toBe("IP");
135 expect(first.listener.listenerId).toMatch(/^listener-/);
136 expect(first.rule.ruleId).toMatch(/^rule-/);
137 expect(first.association.associationId).toMatch(/^snsa-/);
138
139 // Live listener has the fixed default action.
140 const liveListener = yield* vpclattice.getListener({
141 serviceIdentifier: first.service.serviceId,
142 listenerIdentifier: first.listener.listenerId,
143 });
144 expect(liveListener.defaultAction).toEqual({
145 fixedResponse: { statusCode: 404 },
146 });
147
148 // Live rule forwards /api to the target group at priority 10.
149 const liveRule = yield* vpclattice.getRule({
150 serviceIdentifier: first.service.serviceId,
151 listenerIdentifier: first.listener.listenerId,
152 ruleIdentifier: first.rule.ruleId,
153 });
154 expect(liveRule.priority).toBe(10);
155 expect(liveRule.action).toEqual({
156 forward: {
157 targetGroups: [
158 {
159 targetGroupIdentifier: first.targetGroup.targetGroupId,
160 weight: 100,
161 },
162 ],
163 },
164 });
165
166 // The IP target is registered.
167 const liveTargets = yield* vpclattice.listTargets({
168 targetGroupIdentifier: first.targetGroup.targetGroupId,
169 });
170 expect(liveTargets.items.map((t) => t.id)).toEqual(["10.31.0.10"]);
171
172 // The service is associated with the network.
173 const liveAssociation =
174 yield* vpclattice.getServiceNetworkServiceAssociation({
175 serviceNetworkServiceAssociationIdentifier:
176 first.association.associationId,
177 });
178 expect(liveAssociation.serviceId).toBe(first.service.serviceId);
179 expect(["ACTIVE", "CREATE_IN_PROGRESS"]).toContain(
180 liveAssociation.status,
181 );
182
183 // Update in place: default action, rule priority, health check, target.
184 const second = yield* stack.deploy(
185 program({
186 defaultStatusCode: 500,
187 rulePriority: 20,
188 targetIp: "10.31.0.11",
189 healthCheckEnabled: true,
190 }),
191 );
192 expect(second.listener.listenerId).toBe(first.listener.listenerId);
193 expect(second.rule.ruleId).toBe(first.rule.ruleId);
194 expect(second.targetGroup.targetGroupId).toBe(
195 first.targetGroup.targetGroupId,
196 );
197 expect(second.association.associationId).toBe(
198 first.association.associationId,
199 );
200
201 const updatedListener = yield* vpclattice.getListener({
202 serviceIdentifier: first.service.serviceId,
203 listenerIdentifier: first.listener.listenerId,
204 });
205 expect(updatedListener.defaultAction).toEqual({
206 fixedResponse: { statusCode: 500 },
207 });
208
209 const updatedRule = yield* vpclattice.getRule({
210 serviceIdentifier: first.service.serviceId,
211 listenerIdentifier: first.listener.listenerId,
212 ruleIdentifier: first.rule.ruleId,
213 });
214 expect(updatedRule.priority).toBe(20);
215
216 const updatedGroup = yield* vpclattice.getTargetGroup({
217 targetGroupIdentifier: first.targetGroup.targetGroupId,
218 });
219 expect(updatedGroup.config?.healthCheck?.enabled).toBe(true);
220 expect(updatedGroup.config?.healthCheck?.path).toBe("/health");
221 expect(updatedGroup.config?.healthCheck?.healthCheckIntervalSeconds).toBe(
222 30,
223 );
224
225 const updatedTargets = yield* vpclattice.listTargets({
226 targetGroupIdentifier: first.targetGroup.targetGroupId,
227 });
228 expect(
229 updatedTargets.items
230 .filter((t) => t.status !== "DRAINING")
231 .map((t) => t.id),
232 ).toEqual(["10.31.0.11"]);
233
234 yield* stack.destroy();
235 yield* assertTargetGroupDeleted(first.targetGroup.targetGroupId);
236 yield* assertServiceDeleted(first.service.serviceId);
237 }).pipe(
238 // A mid-test failure must not orphan the VPC (default quota is 5 per
239 // region) — always tear the stack down.
240 Effect.ensuring(Effect.ignore(stack.destroy())),
241 ),
242 {
243 tags: [
244 "provider:aws",
245 "provider:aws:ec2",
246 "provider:aws:vpclattice",
247 "live",
248 ],
249 timeout: 600_000,
250 },
251);