1import * as vpclattice from "@distilled.cloud/aws/vpc-lattice";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 diffTags,
12 hasAlchemyTags,
13 tagRecord,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16import { retryOnConflict, waitUntilAbsent } from "./internal.ts";
17
18/**
19 * Authorization mode for a service network. `NONE` allows all traffic;
20 * `AWS_IAM` requires an auth policy.
21 */
22export type ServiceNetworkAuthType = "NONE" | "AWS_IAM";
23
24export interface ServiceNetworkProps {
25 /**
26 * Name of the service network. If omitted, a unique name is generated.
27 * Immutable — changing it replaces the resource.
28 */
29 name?: string;
30 /**
31 * Authorization type for the network.
32 * @default "NONE"
33 */
34 authType?: ServiceNetworkAuthType;
35 /**
36 * User-defined tags to apply to the service network.
37 */
38 tags?: Record<string, string>;
39}
40
41export interface ServiceNetwork extends Resource<
42 "AWS.VpcLattice.ServiceNetwork",
43 ServiceNetworkProps,
44 {
45 /**
46 * Service-assigned unique ID of the service network.
47 */
48 serviceNetworkId: string;
49 /**
50 * ARN of the service network.
51 */
52 serviceNetworkArn: string;
53 /**
54 * Physical name of the service network.
55 */
56 name: string;
57 /**
58 * Effective authorization type.
59 */
60 authType: ServiceNetworkAuthType;
61 /**
62 * Current tags reported for the service network.
63 */
64 tags: Record<string, string>;
65 },
66 never,
67 Providers
68> {}
69
70/**
71 * An Amazon VPC Lattice service network — the logical boundary that connects
72 * services and VPCs into one application network. Cheap control-plane resource
73 * with no per-hour charge until VPCs or services are associated.
74 *
75 * ### Creating Service Networks
76 * **Example:** Basic Service Network
77 * ```typescript
78 * const network = yield* ServiceNetwork("AppNetwork", {});
79 * ```
80 *
81 * **Example:** IAM-Authorized Network
82 * ```typescript
83 * const network = yield* ServiceNetwork("SecureNetwork", {
84 * authType: "AWS_IAM",
85 * tags: { Environment: "prod" },
86 * });
87 * ```
88 *
89 * @resource
90 */
91export const ServiceNetwork = Resource<ServiceNetwork>(
92 "AWS.VpcLattice.ServiceNetwork",
93);
94
95export const ServiceNetworkProvider = () =>
96 Provider.effect(
97 ServiceNetwork,
98 Effect.gen(function* () {
99 const toName = (id: string, props: { name?: string } = {}) =>
100 props.name
101 ? Effect.succeed(props.name)
102 : createPhysicalName({ id, maxLength: 63, lowercase: true });
103
104 // getServiceNetwork only accepts an id or ARN — never a name. Look up by
105 // id when we have one, and fall back to enumerating for name-based
106 // discovery (adoption / lost-state recovery / create-conflict recovery).
107 const observe = (serviceNetworkIdentifier: string) =>
108 vpclattice
109 .getServiceNetwork({ serviceNetworkIdentifier })
110 .pipe(
111 Effect.catchTag("ResourceNotFoundException", () =>
112 Effect.succeed(undefined),
113 ),
114 );
115
116 const findByName = (name: string) =>
117 vpclattice.listServiceNetworks.pages({}).pipe(
118 Stream.runCollect,
119 Effect.map((chunk) =>
120 Array.from(chunk)
121 .flatMap((page) => page.items ?? [])
122 .find((s) => s.name === name),
123 ),
124 Effect.flatMap((summary) =>
125 summary?.id ? observe(summary.id) : Effect.succeed(undefined),
126 ),
127 );
128
129 const syncTags = Effect.fn(function* (
130 arn: string,
131 desiredTags: Record<string, string>,
132 ) {
133 const listed = yield* vpclattice.listTagsForResource({
134 resourceArn: arn,
135 });
136 const { removed, upsert } = diffTags(
137 tagRecord(listed.tags),
138 desiredTags,
139 );
140 if (upsert.length > 0) {
141 yield* vpclattice.tagResource({
142 resourceArn: arn,
143 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
144 });
145 }
146 if (removed.length > 0) {
147 yield* vpclattice.untagResource({
148 resourceArn: arn,
149 tagKeys: removed,
150 });
151 }
152 });
153
154 const isOwnedAssociation = Effect.fn(function* (
155 arn: string,
156 ownerTags: Record<string, string>,
157 ) {
158 const listed = yield* vpclattice
159 .listTagsForResource({ resourceArn: arn })
160 .pipe(
161 Effect.catchTag("ResourceNotFoundException", () =>
162 Effect.succeed(undefined),
163 ),
164 );
165 if (!listed) return false;
166 const tags = tagRecord(listed.tags);
167 return (
168 tags["alchemy::id"] !== undefined &&
169 tags["alchemy::stack"] === ownerTags["alchemy::stack"] &&
170 tags["alchemy::stage"] === ownerTags["alchemy::stage"]
171 );
172 });
173
174 const deleteVpcAssociations = Effect.fn(function* (
175 serviceNetworkId: string,
176 ownerTags: Record<string, string>,
177 force: boolean,
178 ) {
179 const pages = yield* vpclattice.listServiceNetworkVpcAssociations
180 .pages({ serviceNetworkIdentifier: serviceNetworkId })
181 .pipe(Stream.runCollect);
182 const associations = Array.from(pages).flatMap(
183 (page) => page.items ?? [],
184 );
185 yield* Effect.forEach(
186 associations,
187 (association) =>
188 Effect.gen(function* () {
189 if (
190 !association.id ||
191 !association.arn ||
192 (!force &&
193 !(yield* isOwnedAssociation(association.arn, ownerTags)))
194 ) {
195 return;
196 }
197 yield* retryOnConflict(
198 vpclattice.deleteServiceNetworkVpcAssociation({
199 serviceNetworkVpcAssociationIdentifier: association.id,
200 }),
201 ).pipe(
202 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
203 );
204 yield* waitUntilAbsent(
205 vpclattice
206 .getServiceNetworkVpcAssociation({
207 serviceNetworkVpcAssociationIdentifier: association.id,
208 })
209 .pipe(
210 Effect.catchTag("ResourceNotFoundException", () =>
211 Effect.succeed(undefined),
212 ),
213 ),
214 );
215 }),
216 { concurrency: 10 },
217 );
218 });
219
220 const deleteServiceAssociations = Effect.fn(function* (
221 serviceNetworkId: string,
222 ownerTags: Record<string, string>,
223 force: boolean,
224 ) {
225 const pages = yield* vpclattice.listServiceNetworkServiceAssociations
226 .pages({ serviceNetworkIdentifier: serviceNetworkId })
227 .pipe(Stream.runCollect);
228 const associations = Array.from(pages).flatMap(
229 (page) => page.items ?? [],
230 );
231 yield* Effect.forEach(
232 associations,
233 (association) =>
234 Effect.gen(function* () {
235 if (
236 !association.id ||
237 !association.arn ||
238 (!force &&
239 !(yield* isOwnedAssociation(association.arn, ownerTags)))
240 ) {
241 return;
242 }
243 yield* retryOnConflict(
244 vpclattice.deleteServiceNetworkServiceAssociation({
245 serviceNetworkServiceAssociationIdentifier: association.id,
246 }),
247 ).pipe(
248 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
249 );
250 yield* waitUntilAbsent(
251 vpclattice
252 .getServiceNetworkServiceAssociation({
253 serviceNetworkServiceAssociationIdentifier: association.id,
254 })
255 .pipe(
256 Effect.catchTag("ResourceNotFoundException", () =>
257 Effect.succeed(undefined),
258 ),
259 ),
260 );
261 }),
262 { concurrency: 10 },
263 );
264 });
265
266 const deleteResourceAssociations = Effect.fn(function* (
267 serviceNetworkId: string,
268 ownerTags: Record<string, string>,
269 force: boolean,
270 ) {
271 const pages = yield* vpclattice.listServiceNetworkResourceAssociations
272 .pages({ serviceNetworkIdentifier: serviceNetworkId })
273 .pipe(Stream.runCollect);
274 const associations = Array.from(pages).flatMap(
275 (page) => page.items ?? [],
276 );
277 yield* Effect.forEach(
278 associations,
279 (association) =>
280 Effect.gen(function* () {
281 if (
282 !association.id ||
283 !association.arn ||
284 (!force &&
285 !(yield* isOwnedAssociation(association.arn, ownerTags)))
286 ) {
287 return;
288 }
289 yield* retryOnConflict(
290 vpclattice.deleteServiceNetworkResourceAssociation({
291 serviceNetworkResourceAssociationIdentifier: association.id,
292 }),
293 ).pipe(
294 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
295 );
296 yield* waitUntilAbsent(
297 vpclattice
298 .getServiceNetworkResourceAssociation({
299 serviceNetworkResourceAssociationIdentifier: association.id,
300 })
301 .pipe(
302 Effect.catchTag("ResourceNotFoundException", () =>
303 Effect.succeed(undefined),
304 ),
305 ),
306 );
307 }),
308 { concurrency: 10 },
309 );
310 });
311
312 return {
313 stables: ["serviceNetworkId", "serviceNetworkArn", "name"],
314 diff: Effect.fn(function* ({ id, olds, news }) {
315 if (!isResolved(news)) return;
316 if (
317 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
318 ) {
319 return { action: "replace" } as const;
320 }
321 }),
322 read: Effect.fn(function* ({ id, olds, output }) {
323 const network = output?.serviceNetworkId
324 ? yield* observe(output.serviceNetworkId)
325 : yield* findByName(yield* toName(id, olds ?? {}));
326 if (!network?.arn || !network.id) return undefined;
327 const listed = yield* vpclattice.listTagsForResource({
328 resourceArn: network.arn,
329 });
330 const attrs = {
331 serviceNetworkId: network.id,
332 serviceNetworkArn: network.arn,
333 name: network.name!,
334 authType: (network.authType as ServiceNetworkAuthType) ?? "NONE",
335 tags: tagRecord(listed.tags),
336 };
337 return (yield* hasAlchemyTags(id, listed.tags))
338 ? attrs
339 : Unowned(attrs);
340 }),
341 reconcile: Effect.fn(function* ({ id, news, output, session }) {
342 const name = yield* toName(id, news);
343 const internalTags = yield* createInternalTags(id);
344 const desiredTags = { ...internalTags, ...news.tags };
345 const desiredAuthType = news.authType ?? "NONE";
346
347 // Observe — prefer the stable id cache, fall back to name lookup.
348 let network = output?.serviceNetworkId
349 ? yield* observe(output.serviceNetworkId)
350 : yield* findByName(name);
351
352 // Ensure — create if missing.
353 if (!network?.arn || !network.id) {
354 network = yield* vpclattice
355 .createServiceNetwork({ name, authType: desiredAuthType })
356 .pipe(
357 Effect.catchTag("ConflictException", () => findByName(name)),
358 );
359 if (!network?.arn || !network.id) {
360 return yield* Effect.fail(
361 new Error(`Failed to create service network ${name}`),
362 );
363 }
364 } else if ((network.authType ?? "NONE") !== desiredAuthType) {
365 // Sync auth type — the only mutable setting.
366 yield* vpclattice.updateServiceNetwork({
367 serviceNetworkIdentifier: network.id,
368 authType: desiredAuthType,
369 });
370 }
371
372 yield* syncTags(network.arn, desiredTags);
373
374 yield* session.note(network.arn);
375 return {
376 serviceNetworkId: network.id,
377 serviceNetworkArn: network.arn,
378 name,
379 authType: desiredAuthType,
380 tags: desiredTags,
381 };
382 }),
383 list: () =>
384 Effect.gen(function* () {
385 const summaries = yield* vpclattice.listServiceNetworks
386 .pages({})
387 .pipe(
388 Stream.runCollect,
389 Effect.map((chunk) =>
390 Array.from(chunk).flatMap((page) => page.items ?? []),
391 ),
392 );
393 return yield* Effect.forEach(
394 summaries.filter(
395 (s): s is typeof s & { id: string; arn: string } =>
396 s.id != null && s.arn != null,
397 ),
398 (summary) =>
399 Effect.gen(function* () {
400 const network = yield* observe(summary.id);
401 const listed = yield* vpclattice.listTagsForResource({
402 resourceArn: summary.arn,
403 });
404 return {
405 serviceNetworkId: summary.id,
406 serviceNetworkArn: summary.arn,
407 name: summary.name!,
408 authType:
409 (network?.authType as ServiceNetworkAuthType) ?? "NONE",
410 tags: tagRecord(listed.tags),
411 };
412 }),
413 { concurrency: 10 },
414 );
415 }),
416 delete: Effect.fn(function* ({ output, force }) {
417 if (!(yield* observe(output.serviceNetworkId))) {
418 return;
419 }
420 // Nuke discovers resources independently and therefore cannot rely on
421 // the stack dependency graph to order association deletion before the
422 // service network. An operator-confirmed nuke may remove every
423 // attached association blocking deletion. Ordinary stack deletion is
424 // deliberately conservative and removes only associations owned by
425 // the same Alchemy stack/stage.
426 yield* Effect.all(
427 [
428 deleteVpcAssociations(
429 output.serviceNetworkId,
430 output.tags,
431 force === true,
432 ),
433 deleteServiceAssociations(
434 output.serviceNetworkId,
435 output.tags,
436 force === true,
437 ),
438 deleteResourceAssociations(
439 output.serviceNetworkId,
440 output.tags,
441 force === true,
442 ),
443 ],
444 { concurrency: 3 },
445 );
446 yield* retryOnConflict(
447 vpclattice.deleteServiceNetwork({
448 serviceNetworkIdentifier: output.serviceNetworkId,
449 }),
450 ).pipe(
451 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
452 );
453 yield* waitUntilAbsent(observe(output.serviceNetworkId));
454 }),
455 };
456 }),
457 );