1import * as AWS from "@/AWS";
2import type { ScopedPlanStatusSession } from "@/Report.ts";
3import * as Provider from "@/Provider";
4import { Vpc } from "@/AWS/EC2";
5import { normalizePolicyDocument } from "@/AWS/IAM/Policy.ts";
6import {
7 AuthPolicy,
8 ResourcePolicy,
9 Service,
10 ServiceNetwork,
11 ServiceNetworkVpcAssociation,
12} from "@/AWS/VpcLattice";
13import * as Test from "@/Test/Alchemy";
14import * as vpclattice from "@distilled.cloud/aws/vpc-lattice";
15import { expect } from "alchemy-test";
16import * as Data from "effect/Data";
17import * as Effect from "effect/Effect";
18import * as Result from "effect/Result";
19import * as Schedule from "effect/Schedule";
20import { getDefaultVpc } from "../DefaultVpc.ts";
21
22const { test } = Test.make({ providers: AWS.providers() });
23
24const stubSession = {
25 note: () => Effect.void,
26} as unknown as ScopedPlanStatusSession;
27
28const findServiceNetwork = (id: string) =>
29 vpclattice
30 .getServiceNetwork({ serviceNetworkIdentifier: id })
31 .pipe(
32 Effect.catchTag("ResourceNotFoundException", () =>
33 Effect.succeed(undefined),
34 ),
35 );
36
37class StillExists extends Data.TaggedError("StillExists")<{
38 readonly id: string;
39}> {}
40
41class AssociationNotReady extends Data.TaggedError("AssociationNotReady")<{
42 readonly serviceNetworkId: string;
43 readonly vpcId: string;
44}> {}
45
46const assertServiceNetworkDeleted = (id: string) =>
47 findServiceNetwork(id).pipe(
48 Effect.flatMap((sn) =>
49 sn === undefined ? Effect.void : Effect.fail(new StillExists({ id })),
50 ),
51 Effect.retry({
52 while: (e) => e._tag === "StillExists",
53 schedule: Schedule.max([
54 Schedule.spaced("3 seconds"),
55 Schedule.recurs(15),
56 ]),
57 }),
58 );
59
60const assertAssociationDeleted = (id: string) =>
61 vpclattice
62 .getServiceNetworkVpcAssociation({
63 serviceNetworkVpcAssociationIdentifier: id,
64 })
65 .pipe(
66 Effect.flatMap((assoc) =>
67 assoc.status === "DELETE_IN_PROGRESS"
68 ? Effect.fail(new StillExists({ id }))
69 : Effect.void,
70 ),
71 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
72 Effect.retry({
73 while: (e) => e._tag === "StillExists",
74 schedule: Schedule.max([
75 Schedule.spaced("3 seconds"),
76 Schedule.recurs(20),
77 ]),
78 }),
79 );
80
81test.provider(
82 "create, update authType, delete a service network",
83 (stack) =>
84 Effect.gen(function* () {
85 yield* stack.destroy();
86
87 const network = yield* stack.deploy(
88 Effect.gen(function* () {
89 return yield* ServiceNetwork("TestServiceNetwork", {
90 tags: { Environment: "test" },
91 });
92 }),
93 );
94
95 expect(network.serviceNetworkId).toMatch(/^sn-/);
96 expect(network.serviceNetworkArn).toContain(":servicenetwork/");
97 expect(network.authType).toBe("NONE");
98
99 const live = yield* findServiceNetwork(network.serviceNetworkId);
100 expect(live?.arn).toBe(network.serviceNetworkArn);
101 const tags = yield* vpclattice
102 .listTagsForResource({ resourceArn: network.serviceNetworkArn })
103 .pipe(Effect.map((r) => r.tags ?? {}));
104 expect(tags["alchemy::id"]).toBe("TestServiceNetwork");
105
106 // Update the auth type in place.
107 const updated = yield* stack.deploy(
108 Effect.gen(function* () {
109 return yield* ServiceNetwork("TestServiceNetwork", {
110 authType: "AWS_IAM",
111 tags: { Environment: "test" },
112 });
113 }),
114 );
115 expect(updated.serviceNetworkId).toBe(network.serviceNetworkId);
116 expect(updated.authType).toBe("AWS_IAM");
117 const live2 = yield* findServiceNetwork(network.serviceNetworkId);
118 expect(live2?.authType).toBe("AWS_IAM");
119
120 yield* stack.destroy();
121 yield* assertServiceNetworkDeleted(network.serviceNetworkId);
122 }),
123 {
124 tags: ["provider:aws", "provider:aws:vpclattice", "live"],
125 timeout: 180_000,
126 },
127);
128
129test.provider(
130 "create, update idle timeout, delete a service",
131 (stack) =>
132 Effect.gen(function* () {
133 yield* stack.destroy();
134
135 const service = yield* stack.deploy(
136 Effect.gen(function* () {
137 return yield* Service("TestLatticeService", {
138 idleTimeout: "60 seconds",
139 });
140 }),
141 );
142
143 expect(service.serviceId).toMatch(/^svc-/);
144 expect(service.serviceArn).toContain(":service/");
145
146 const live = yield* vpclattice
147 .getService({ serviceIdentifier: service.serviceId })
148 .pipe(
149 Effect.catchTag("ResourceNotFoundException", () =>
150 Effect.succeed(undefined),
151 ),
152 );
153 expect(live?.idleTimeoutSeconds).toBe(60);
154
155 const updated = yield* stack.deploy(
156 Effect.gen(function* () {
157 return yield* Service("TestLatticeService", {
158 idleTimeout: "120 seconds",
159 });
160 }),
161 );
162 expect(updated.serviceId).toBe(service.serviceId);
163 const live2 = yield* vpclattice
164 .getService({ serviceIdentifier: service.serviceId })
165 .pipe(
166 Effect.catchTag("ResourceNotFoundException", () =>
167 Effect.succeed(undefined),
168 ),
169 );
170 expect(live2?.idleTimeoutSeconds).toBe(120);
171
172 yield* stack.destroy();
173 }),
174 {
175 tags: ["provider:aws", "provider:aws:vpclattice", "live"],
176 timeout: 180_000,
177 },
178);
179
180test.provider(
181 "auth + resource policies deploy from PolicyDocument and re-deploy clean",
182 (stack) =>
183 Effect.gen(function* () {
184 yield* stack.destroy();
185
186 // Deploy the network alone first — the policy documents below embed the
187 // resolved ARN/account as plain strings (Outputs are unresolved proxies
188 // during plan, so string ops on them are impossible inside the program).
189 const base = yield* stack.deploy(
190 Effect.gen(function* () {
191 return yield* ServiceNetwork("PolicyServiceNetwork", {
192 authType: "AWS_IAM",
193 });
194 }),
195 );
196 const account = base.serviceNetworkArn.split(":")[4];
197 const networkArn = base.serviceNetworkArn;
198
199 const program = Effect.gen(function* () {
200 const network = yield* ServiceNetwork("PolicyServiceNetwork", {
201 authType: "AWS_IAM",
202 });
203 const authPolicy = yield* AuthPolicy("NetworkAuthPolicy", {
204 resourceIdentifier: network.serviceNetworkId,
205 policy: {
206 Version: "2012-10-17",
207 Statement: [
208 {
209 Effect: "Allow",
210 Principal: { AWS: `arn:aws:iam::${account}:root` },
211 Action: ["vpc-lattice-svcs:Invoke"],
212 Resource: "*",
213 },
214 ],
215 },
216 });
217 const resourcePolicy = yield* ResourcePolicy("NetworkResourcePolicy", {
218 resourceArn: network.serviceNetworkArn,
219 policy: {
220 Version: "2012-10-17",
221 Statement: [
222 {
223 Effect: "Allow",
224 Principal: { AWS: `arn:aws:iam::${account}:root` },
225 Action: [
226 "vpc-lattice:CreateServiceNetworkVpcAssociation",
227 "vpc-lattice:CreateServiceNetworkServiceAssociation",
228 "vpc-lattice:GetServiceNetwork",
229 ],
230 Resource: networkArn,
231 },
232 ],
233 },
234 });
235 return { authPolicy, network, resourcePolicy };
236 });
237
238 const first = yield* stack.deploy(program);
239
240 expect(first.authPolicy.resourceIdentifier).toBe(
241 first.network.serviceNetworkId,
242 );
243 expect(first.authPolicy.policy).toContain("vpc-lattice-svcs:Invoke");
244 expect(first.resourcePolicy.resourceArn).toBe(
245 first.network.serviceNetworkArn,
246 );
247
248 // The live documents match the PolicyDocument we deployed (canonicalized).
249 const liveAuth = yield* vpclattice.getAuthPolicy({
250 resourceIdentifier: first.network.serviceNetworkId,
251 });
252 expect(normalizePolicyDocument(liveAuth.policy!)).toBe(
253 normalizePolicyDocument(first.authPolicy.policy),
254 );
255 const liveResource = yield* vpclattice.getResourcePolicy({
256 resourceArn: first.network.serviceNetworkArn,
257 });
258 expect(normalizePolicyDocument(liveResource.policy!)).toBe(
259 normalizePolicyDocument(first.resourcePolicy.policy),
260 );
261
262 // Re-deploy the identical program — the normalized observed-vs-desired
263 // comparison must skip the puts, so the auth policy's lastUpdatedAt
264 // timestamp is unchanged (a put always bumps it).
265 const second = yield* stack.deploy(program);
266 expect(second.authPolicy.policy).toBe(first.authPolicy.policy);
267 expect(second.resourcePolicy.policy).toBe(first.resourcePolicy.policy);
268 const liveAuth2 = yield* vpclattice.getAuthPolicy({
269 resourceIdentifier: first.network.serviceNetworkId,
270 });
271 expect(liveAuth2.lastUpdatedAt?.toISOString()).toBe(
272 liveAuth.lastUpdatedAt?.toISOString(),
273 );
274
275 yield* stack.destroy();
276 yield* assertServiceNetworkDeleted(first.network.serviceNetworkId);
277 }),
278 {
279 tags: [
280 "provider:aws",
281 "provider:aws:iam",
282 "provider:aws:vpclattice",
283 "live",
284 ],
285 timeout: 240_000,
286 },
287);
288
289test.provider(
290 "associate a VPC with a service network",
291 (stack) =>
292 Effect.gen(function* () {
293 yield* stack.destroy();
294
295 const { association, network } = yield* stack.deploy(
296 Effect.gen(function* () {
297 const vpc = yield* Vpc("LatticeVpc", { cidrBlock: "10.30.0.0/16" });
298 const network = yield* ServiceNetwork("AssocServiceNetwork", {});
299 const association = yield* ServiceNetworkVpcAssociation(
300 "VpcAssociation",
301 {
302 serviceNetworkIdentifier: network.serviceNetworkId,
303 vpcIdentifier: vpc.vpcId,
304 },
305 );
306 return { association, network };
307 }),
308 );
309
310 expect(association.associationId).toMatch(/^snva-/);
311 expect(association.associationArn).toContain(
312 ":servicenetworkvpcassociation/",
313 );
314
315 const live = yield* vpclattice.getServiceNetworkVpcAssociation({
316 serviceNetworkVpcAssociationIdentifier: association.associationId,
317 });
318 expect(live.serviceNetworkId).toBe(network.serviceNetworkId);
319 expect(["ACTIVE", "CREATE_IN_PROGRESS"]).toContain(live.status);
320
321 yield* stack.destroy();
322 yield* assertAssociationDeleted(association.associationId);
323 yield* assertServiceNetworkDeleted(network.serviceNetworkId);
324 }),
325 {
326 tags: [
327 "provider:aws",
328 "provider:aws:ec2",
329 "provider:aws:vpclattice",
330 "live",
331 ],
332 timeout: 240_000,
333 },
334);
335
336test.provider(
337 "force delete removes an unowned association but normal delete protects it",
338 (stack) =>
339 Effect.gen(function* () {
340 yield* stack.destroy();
341
342 const network = yield* stack.deploy(
343 ServiceNetwork("ForceDeleteServiceNetwork", {}),
344 );
345 const defaultVpc = yield* getDefaultVpc;
346 const association = yield* Effect.gen(function* () {
347 const listed = yield* vpclattice.listServiceNetworkVpcAssociations({
348 serviceNetworkIdentifier: network.serviceNetworkId,
349 vpcIdentifier: defaultVpc.vpcId,
350 });
351 const existing = listed.items.find(
352 (item) => item.status !== "DELETE_IN_PROGRESS",
353 );
354 if (existing?.id) return existing;
355
356 // A prior interrupted run or a simultaneous observation/create race
357 // may have already established this unique network/VPC association.
358 // Conflict is therefore an observe-again signal, not a test failure.
359 const created = yield* vpclattice
360 .createServiceNetworkVpcAssociation({
361 // Idempotency tokens are scoped to the exact request parameters.
362 // Derive it from both physical identifiers so a replacement
363 // network after an interrupted run does not reuse a token whose
364 // prior request referenced a different network.
365 clientToken: `alchemy-${network.serviceNetworkId}-${defaultVpc.vpcId}`,
366 serviceNetworkIdentifier: network.serviceNetworkId,
367 vpcIdentifier: defaultVpc.vpcId,
368 })
369 .pipe(
370 Effect.catchTag("ConflictException", () =>
371 Effect.succeed(undefined),
372 ),
373 );
374 if (created?.id) return created;
375
376 const raced = yield* vpclattice.listServiceNetworkVpcAssociations({
377 serviceNetworkIdentifier: network.serviceNetworkId,
378 vpcIdentifier: defaultVpc.vpcId,
379 });
380 const observed = raced.items.find(
381 (item) => item.status !== "DELETE_IN_PROGRESS",
382 );
383 if (observed?.id) return observed;
384 return yield* Effect.fail(
385 new AssociationNotReady({
386 serviceNetworkId: network.serviceNetworkId,
387 vpcId: defaultVpc.vpcId,
388 }),
389 );
390 }).pipe(
391 Effect.retry({
392 while: (error) => error._tag === "AssociationNotReady",
393 schedule: Schedule.max([
394 Schedule.spaced("3 seconds"),
395 Schedule.recurs(20),
396 ]),
397 }),
398 );
399 const associationId = association.id;
400 if (!associationId) {
401 return yield* Effect.die(
402 new Error("VPC Lattice did not return an association id"),
403 );
404 }
405
406 // Converge any association recovered from an interrupted run to the
407 // intended out-of-band, unowned fixture state.
408 if (association.arn) {
409 const listedTags = yield* vpclattice.listTagsForResource({
410 resourceArn: association.arn,
411 });
412 const tagKeys = Object.keys(listedTags.tags ?? {});
413 if (tagKeys.length > 0) {
414 yield* vpclattice.untagResource({
415 resourceArn: association.arn,
416 tagKeys,
417 });
418 }
419 }
420
421 const provider = yield* Provider.findProvider(ServiceNetwork);
422 const input = {
423 id: "ForceDeleteServiceNetwork",
424 fqn: "ForceDeleteServiceNetwork",
425 instanceId: "force-delete-test",
426 olds: {},
427 output: network,
428 session: stubSession,
429 bindings: [],
430 };
431
432 // A normal stack destroy must not take ownership of an untagged,
433 // out-of-band association. The network delete therefore remains blocked.
434 const normalDelete = yield* Effect.result(provider.delete(input));
435 expect(Result.isFailure(normalDelete)).toBe(true);
436 const stillAttached = yield* vpclattice.getServiceNetworkVpcAssociation({
437 serviceNetworkVpcAssociationIdentifier: associationId,
438 });
439 expect(stillAttached.id).toBe(associationId);
440
441 // Nuke is explicitly operator-confirmed and passes force=true. It may
442 // remove all associations that prevent deletion of the listed network.
443 yield* provider.delete({ ...input, force: true });
444 yield* assertAssociationDeleted(associationId);
445 yield* assertServiceNetworkDeleted(network.serviceNetworkId);
446
447 // The stack still has stale state for the now-deleted network; provider
448 // delete is idempotent and must clean that state without another API error.
449 yield* stack.destroy();
450 }),
451 {
452 tags: [
453 "provider:aws",
454 "provider:aws:ec2",
455 "provider:aws:vpclattice",
456 "live",
457 ],
458 timeout: 180_000,
459 },
460);