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 * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import {
9 createInternalTags,
10 diffTags,
11 hasAlchemyTags,
12 tagRecord,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import { retryOnConflict, waitUntilAbsent } from "./internal.ts";
16
17export interface ServiceNetworkVpcAssociationProps {
18 /**
19 * ID or ARN of the service network to associate. Immutable — changing it
20 * replaces the association.
21 */
22 serviceNetworkIdentifier: string;
23 /**
24 * ID of the VPC to associate. Immutable — changing it replaces the
25 * association.
26 */
27 vpcIdentifier: string;
28 /**
29 * Security group IDs controlling access from the VPC to the service network.
30 */
31 securityGroupIds?: string[];
32 /**
33 * User-defined tags to apply to the association.
34 */
35 tags?: Record<string, string>;
36}
37
38export interface ServiceNetworkVpcAssociation extends Resource<
39 "AWS.VpcLattice.ServiceNetworkVpcAssociation",
40 ServiceNetworkVpcAssociationProps,
41 {
42 /**
43 * Service-assigned unique ID of the association.
44 */
45 associationId: string;
46 /**
47 * ARN of the association.
48 */
49 associationArn: string;
50 /**
51 * Current lifecycle status (e.g. `ACTIVE`, `CREATE_IN_PROGRESS`).
52 */
53 status: string;
54 /**
55 * ID of the associated service network.
56 */
57 serviceNetworkId?: string;
58 /**
59 * ID of the associated VPC.
60 */
61 vpcId?: string;
62 /**
63 * Current tags reported for the association.
64 */
65 tags: Record<string, string>;
66 },
67 never,
68 Providers
69> {}
70
71/**
72 * Associates a VPC with a VPC Lattice service network, letting workloads in the
73 * VPC reach every service in the network (subject to auth policies). The most
74 * common VPC Lattice wiring step.
75 *
76 * ### Associating a VPC
77 * **Example:** Basic Association
78 * ```typescript
79 * const assoc = yield* ServiceNetworkVpcAssociation("AppVpcLink", {
80 * serviceNetworkIdentifier: network.serviceNetworkId,
81 * vpcIdentifier: vpc.vpcId,
82 * securityGroupIds: [sg.groupId],
83 * });
84 * ```
85 *
86 * @resource
87 */
88export const ServiceNetworkVpcAssociation =
89 Resource<ServiceNetworkVpcAssociation>(
90 "AWS.VpcLattice.ServiceNetworkVpcAssociation",
91 );
92
93export const ServiceNetworkVpcAssociationProvider = () =>
94 Provider.effect(
95 ServiceNetworkVpcAssociation,
96 Effect.gen(function* () {
97 const observe = (id: string) =>
98 vpclattice
99 .getServiceNetworkVpcAssociation({
100 serviceNetworkVpcAssociationIdentifier: id,
101 })
102 .pipe(
103 Effect.catchTag("ResourceNotFoundException", () =>
104 Effect.succeed(undefined),
105 ),
106 );
107
108 const syncTags = Effect.fn(function* (
109 arn: string,
110 desiredTags: Record<string, string>,
111 ) {
112 const listed = yield* vpclattice.listTagsForResource({
113 resourceArn: arn,
114 });
115 const { removed, upsert } = diffTags(
116 tagRecord(listed.tags),
117 desiredTags,
118 );
119 if (upsert.length > 0) {
120 yield* vpclattice.tagResource({
121 resourceArn: arn,
122 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
123 });
124 }
125 if (removed.length > 0) {
126 yield* vpclattice.untagResource({
127 resourceArn: arn,
128 tagKeys: removed,
129 });
130 }
131 });
132
133 return {
134 stables: ["associationId", "associationArn"],
135 diff: Effect.fn(function* ({ olds, news }) {
136 if (!isResolved(news)) return;
137 if (
138 olds?.serviceNetworkIdentifier !== news.serviceNetworkIdentifier ||
139 olds?.vpcIdentifier !== news.vpcIdentifier
140 ) {
141 return { action: "replace" } as const;
142 }
143 }),
144 read: Effect.fn(function* ({ id, output }) {
145 if (!output?.associationId) return undefined;
146 const assoc = yield* observe(output.associationId);
147 if (!assoc?.arn || !assoc.id) return undefined;
148 const listed = yield* vpclattice.listTagsForResource({
149 resourceArn: assoc.arn,
150 });
151 const attrs = {
152 associationId: assoc.id,
153 associationArn: assoc.arn,
154 status: assoc.status ?? "UNKNOWN",
155 serviceNetworkId: assoc.serviceNetworkId,
156 vpcId: assoc.vpcId,
157 tags: tagRecord(listed.tags),
158 };
159 return (yield* hasAlchemyTags(id, listed.tags))
160 ? attrs
161 : Unowned(attrs);
162 }),
163 reconcile: Effect.fn(function* ({ id, news, output, session }) {
164 const internalTags = yield* createInternalTags(id);
165 const desiredTags = { ...internalTags, ...news.tags };
166
167 let assoc = output?.associationId
168 ? yield* observe(output.associationId)
169 : undefined;
170
171 if (!assoc?.arn || !assoc.id) {
172 const created =
173 yield* vpclattice.createServiceNetworkVpcAssociation({
174 serviceNetworkIdentifier: news.serviceNetworkIdentifier,
175 vpcIdentifier: news.vpcIdentifier,
176 securityGroupIds: news.securityGroupIds,
177 tags: desiredTags,
178 });
179 if (!created.arn || !created.id) {
180 return yield* Effect.fail(
181 new Error("Failed to create service network VPC association"),
182 );
183 }
184 assoc = yield* observe(created.id);
185 if (!assoc?.arn || !assoc.id) {
186 assoc = {
187 id: created.id,
188 arn: created.arn,
189 status: created.status,
190 securityGroupIds: created.securityGroupIds,
191 };
192 }
193 } else if (
194 news.securityGroupIds &&
195 news.securityGroupIds.length > 0
196 ) {
197 // Sync security groups (association keeps at least one).
198 const current = (assoc.securityGroupIds ?? []).slice().sort();
199 const desired = news.securityGroupIds.slice().sort();
200 if (JSON.stringify(current) !== JSON.stringify(desired)) {
201 yield* vpclattice.updateServiceNetworkVpcAssociation({
202 serviceNetworkVpcAssociationIdentifier: assoc.id,
203 securityGroupIds: news.securityGroupIds,
204 });
205 }
206 }
207
208 // Every branch above guarantees id/arn; this narrows the reassigned
209 // `let` for the type-checker and guards a malformed API response.
210 if (!assoc?.arn || !assoc.id) {
211 return yield* Effect.fail(
212 new Error(
213 "service network VPC association is missing its id/arn",
214 ),
215 );
216 }
217
218 yield* syncTags(assoc.arn, desiredTags);
219
220 yield* session.note(assoc.arn);
221 return {
222 associationId: assoc.id,
223 associationArn: assoc.arn,
224 status: assoc.status ?? "ACTIVE",
225 serviceNetworkId: assoc.serviceNetworkId,
226 vpcId: assoc.vpcId,
227 tags: desiredTags,
228 };
229 }),
230 list: () =>
231 Effect.gen(function* () {
232 // AWS does not support an unfiltered account-wide association
233 // listing: at least one serviceNetworkIdentifier or vpcIdentifier
234 // is required. Enumerate service networks first, then list each
235 // network's associations so nuke can discover orphaned links.
236 const serviceNetworkIds = yield* vpclattice.listServiceNetworks
237 .pages({})
238 .pipe(
239 Stream.runCollect,
240 Effect.map((chunk) =>
241 Array.from(chunk)
242 .flatMap((page) => page.items ?? [])
243 .flatMap((network) =>
244 network.id === undefined ? [] : [network.id],
245 ),
246 ),
247 );
248 const summaries = (yield* Effect.forEach(
249 serviceNetworkIds,
250 (serviceNetworkIdentifier) =>
251 vpclattice.listServiceNetworkVpcAssociations
252 .pages({ serviceNetworkIdentifier })
253 .pipe(
254 Stream.runCollect,
255 Effect.map((chunk) =>
256 Array.from(chunk).flatMap((page) => page.items ?? []),
257 ),
258 ),
259 { concurrency: 10 },
260 )).flat();
261 return yield* Effect.forEach(
262 summaries.filter(
263 (s): s is typeof s & { id: string; arn: string } =>
264 s.id != null && s.arn != null,
265 ),
266 (summary) =>
267 Effect.gen(function* () {
268 const listed = yield* vpclattice.listTagsForResource({
269 resourceArn: summary.arn,
270 });
271 return {
272 associationId: summary.id,
273 associationArn: summary.arn,
274 status: summary.status ?? "UNKNOWN",
275 serviceNetworkId: summary.serviceNetworkId,
276 vpcId: summary.vpcId,
277 tags: tagRecord(listed.tags),
278 };
279 }),
280 { concurrency: 10 },
281 );
282 }),
283 delete: Effect.fn(function* ({ output }) {
284 yield* retryOnConflict(
285 vpclattice.deleteServiceNetworkVpcAssociation({
286 serviceNetworkVpcAssociationIdentifier: output.associationId,
287 }),
288 ).pipe(
289 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
290 );
291 // Deletion is asynchronous (the association sits in
292 // DELETE_IN_PROGRESS while hyperplane ENIs are torn down). Wait
293 // until it is actually gone so a dependent ServiceNetwork delete
294 // doesn't hit `ConflictException: has VPC(s) associated`.
295 yield* waitUntilAbsent(observe(output.associationId));
296 }),
297 };
298 }),
299 );