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 {
16 retryOnConflict,
17 waitUntilAbsent,
18 waitUntilStable,
19} from "./internal.ts";
20
21export interface ServiceNetworkServiceAssociationProps {
22 /**
23 * ID or ARN of the service network. Immutable — changing it replaces the
24 * association.
25 */
26 serviceNetworkIdentifier: string;
27 /**
28 * ID or ARN of the lattice service to associate. Immutable — changing it
29 * replaces the association.
30 */
31 serviceIdentifier: string;
32 /**
33 * User-defined tags to apply to the association.
34 */
35 tags?: Record<string, string>;
36}
37
38export interface ServiceNetworkServiceAssociation extends Resource<
39 "AWS.VpcLattice.ServiceNetworkServiceAssociation",
40 ServiceNetworkServiceAssociationProps,
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 lattice service.
56 */
57 serviceId?: string;
58 /**
59 * ID of the service network.
60 */
61 serviceNetworkId?: string;
62 /**
63 * DNS name clients in associated VPCs resolve the service by.
64 */
65 dnsName?: string;
66 /**
67 * Current tags reported for the association.
68 */
69 tags: Record<string, string>;
70 },
71 never,
72 Providers
73> {}
74
75/**
76 * Associates a VPC Lattice service with a service network, making the
77 * service reachable from every VPC associated with that network.
78 *
79 * ### Associating a Service
80 * **Example:** Basic Association
81 * ```typescript
82 * const assoc = yield* ServiceNetworkServiceAssociation("PaymentsLink", {
83 * serviceNetworkIdentifier: network.serviceNetworkId,
84 * serviceIdentifier: service.serviceId,
85 * });
86 * ```
87 *
88 * @resource
89 */
90export const ServiceNetworkServiceAssociation =
91 Resource<ServiceNetworkServiceAssociation>(
92 "AWS.VpcLattice.ServiceNetworkServiceAssociation",
93 );
94
95export const ServiceNetworkServiceAssociationProvider = () =>
96 Provider.effect(
97 ServiceNetworkServiceAssociation,
98 Effect.gen(function* () {
99 const observe = (id: string) =>
100 vpclattice
101 .getServiceNetworkServiceAssociation({
102 serviceNetworkServiceAssociationIdentifier: id,
103 })
104 .pipe(
105 Effect.catchTag("ResourceNotFoundException", () =>
106 Effect.succeed(undefined),
107 ),
108 );
109
110 // Discover an existing association between the pair (create-conflict
111 // recovery / lost-state recovery).
112 const findByPair = (
113 serviceNetworkIdentifier: string,
114 serviceIdentifier: string,
115 ) =>
116 vpclattice.listServiceNetworkServiceAssociations
117 .pages({ serviceNetworkIdentifier, serviceIdentifier })
118 .pipe(
119 Stream.runCollect,
120 Effect.map((chunk) =>
121 Array.from(chunk).flatMap((page) => page.items ?? []),
122 ),
123 Effect.flatMap((items) =>
124 items[0]?.id ? observe(items[0].id) : Effect.succeed(undefined),
125 ),
126 );
127
128 const syncTags = Effect.fn(function* (
129 arn: string,
130 desiredTags: Record<string, string>,
131 ) {
132 const listed = yield* vpclattice.listTagsForResource({
133 resourceArn: arn,
134 });
135 const { removed, upsert } = diffTags(
136 tagRecord(listed.tags),
137 desiredTags,
138 );
139 if (upsert.length > 0) {
140 yield* vpclattice.tagResource({
141 resourceArn: arn,
142 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
143 });
144 }
145 if (removed.length > 0) {
146 yield* vpclattice.untagResource({
147 resourceArn: arn,
148 tagKeys: removed,
149 });
150 }
151 });
152
153 return {
154 stables: ["associationId", "associationArn"],
155 diff: Effect.fn(function* ({ olds, news }) {
156 if (!isResolved(news)) return;
157 if (
158 olds?.serviceNetworkIdentifier !== news.serviceNetworkIdentifier ||
159 olds?.serviceIdentifier !== news.serviceIdentifier
160 ) {
161 return { action: "replace" } as const;
162 }
163 }),
164 read: Effect.fn(function* ({ id, olds, output }) {
165 const assoc = output?.associationId
166 ? yield* observe(output.associationId)
167 : olds
168 ? yield* findByPair(
169 olds.serviceNetworkIdentifier,
170 olds.serviceIdentifier,
171 )
172 : undefined;
173 if (!assoc?.arn || !assoc.id) return undefined;
174 const listed = yield* vpclattice.listTagsForResource({
175 resourceArn: assoc.arn,
176 });
177 const attrs = {
178 associationId: assoc.id,
179 associationArn: assoc.arn,
180 status: assoc.status ?? "UNKNOWN",
181 serviceId: assoc.serviceId,
182 serviceNetworkId: assoc.serviceNetworkId,
183 dnsName: assoc.dnsEntry?.domainName,
184 tags: tagRecord(listed.tags),
185 };
186 return (yield* hasAlchemyTags(id, listed.tags))
187 ? attrs
188 : Unowned(attrs);
189 }),
190 reconcile: Effect.fn(function* ({ id, news, output, session }) {
191 const internalTags = yield* createInternalTags(id);
192 const desiredTags = { ...internalTags, ...news.tags };
193
194 // Observe — prefer the stable id cache, fall back to pair lookup.
195 let assoc = output?.associationId
196 ? yield* observe(output.associationId)
197 : yield* findByPair(
198 news.serviceNetworkIdentifier,
199 news.serviceIdentifier,
200 );
201
202 // Ensure — create if missing. A ConflictException means the pair
203 // is already associated (a race); recover the existing association.
204 if (!assoc?.arn || !assoc.id) {
205 const created = yield* retryOnConflict(
206 vpclattice.createServiceNetworkServiceAssociation({
207 serviceNetworkIdentifier: news.serviceNetworkIdentifier,
208 serviceIdentifier: news.serviceIdentifier,
209 tags: desiredTags,
210 }),
211 ).pipe(
212 Effect.catchTag("ConflictException", () =>
213 findByPair(
214 news.serviceNetworkIdentifier,
215 news.serviceIdentifier,
216 ),
217 ),
218 );
219 if (!created?.arn || !created.id) {
220 return yield* Effect.fail(
221 new Error(
222 "Failed to create service network service association",
223 ),
224 );
225 }
226 assoc = yield* observe(created.id);
227 if (!assoc?.arn || !assoc.id) {
228 assoc = {
229 id: created.id,
230 arn: created.arn,
231 status: created.status,
232 };
233 }
234 }
235 const associationId = assoc.id;
236 const associationArn = assoc.arn;
237 if (!associationId || !associationArn) {
238 return yield* Effect.fail(
239 new Error(
240 "Service network service association is missing its id/arn",
241 ),
242 );
243 }
244
245 // Wait for the association to leave CREATE_IN_PROGRESS so
246 // dependents observe a routable service.
247 const stable = yield* waitUntilStable(observe(associationId));
248
249 yield* syncTags(associationArn, desiredTags);
250
251 yield* session.note(associationArn);
252 return {
253 associationId,
254 associationArn,
255 status: stable?.status ?? assoc.status ?? "ACTIVE",
256 serviceId: stable?.serviceId,
257 serviceNetworkId: stable?.serviceNetworkId,
258 dnsName: stable?.dnsEntry?.domainName,
259 tags: desiredTags,
260 };
261 }),
262 list: () =>
263 Effect.gen(function* () {
264 // ListServiceNetworkServiceAssociations requires a service
265 // network or service filter, so enumerate networks first.
266 const networks = yield* vpclattice.listServiceNetworks
267 .pages({})
268 .pipe(
269 Stream.runCollect,
270 Effect.map((chunk) =>
271 Array.from(chunk).flatMap((page) => page.items ?? []),
272 ),
273 );
274 const summaries = (yield* Effect.forEach(
275 networks.filter(
276 (n): n is typeof n & { id: string } => n.id != null,
277 ),
278 (network) =>
279 vpclattice.listServiceNetworkServiceAssociations
280 .pages({ serviceNetworkIdentifier: network.id })
281 .pipe(
282 Stream.runCollect,
283 Effect.map((chunk) =>
284 Array.from(chunk).flatMap((page) => page.items ?? []),
285 ),
286 ),
287 { concurrency: 5 },
288 )).flat();
289 return yield* Effect.forEach(
290 summaries.filter(
291 (s): s is typeof s & { id: string; arn: string } =>
292 s.id != null && s.arn != null,
293 ),
294 (summary) =>
295 Effect.gen(function* () {
296 const listed = yield* vpclattice.listTagsForResource({
297 resourceArn: summary.arn,
298 });
299 return {
300 associationId: summary.id,
301 associationArn: summary.arn,
302 status: summary.status ?? "UNKNOWN",
303 serviceId: summary.serviceId,
304 serviceNetworkId: summary.serviceNetworkId,
305 dnsName: summary.dnsEntry?.domainName,
306 tags: tagRecord(listed.tags),
307 };
308 }),
309 { concurrency: 10 },
310 );
311 }),
312 delete: Effect.fn(function* ({ output }) {
313 yield* retryOnConflict(
314 vpclattice.deleteServiceNetworkServiceAssociation({
315 serviceNetworkServiceAssociationIdentifier: output.associationId,
316 }),
317 ).pipe(
318 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
319 );
320 // Deletion is asynchronous; wait until the association is actually
321 // gone so dependent service/network deletes don't conflict.
322 yield* waitUntilAbsent(observe(output.associationId));
323 }),
324 };
325 }),
326 );