1import * as networkservices from "@distilled.cloud/gcp/networkservices_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 DEFAULT_ZONE,
19 MAX_MULTICAST_NAME_LENGTH,
20 changedFields,
21 collectPages,
22 hasAlchemyLabelKeys,
23 linkKey,
24 normalizeLocation,
25 parentOf,
26 parseName,
27 resourceName,
28 rfc1035,
29 toMulticastNetwork,
30 toNamedResource,
31 toPhysicalId,
32 userLabels,
33 waitForOperation,
34 waitUntilGone,
35 waitUntilPresent,
36} from "./internal.ts";
37
38const COLLECTION = "multicastConsumerAssociations";
39
40export type MulticastConsumerAssociationProps = {
41 /**
42 * Association id (the `{multicastConsumerAssociation}` segment of
43 * `projects/{project}/locations/{location}/multicastConsumerAssociations/{multicastConsumerAssociation}`).
44 * If omitted, a unique name is generated from the stack, stage, and
45 * logical id. Must be at most 48 characters. Immutable — changing it
46 * replaces the association.
47 */
48 multicastConsumerAssociationId?: string;
49 /**
50 * Zone of the association (`us-central1-a`, …). Immutable — changing
51 * it replaces the association. `US-CENTRAL1-A` is accepted and
52 * normalized to `us-central1-a`.
53 * @default "us-central1-a"
54 */
55 location?: string;
56 /**
57 * Consumer VPC network
58 * `projects/{project}/locations/global/networks/{network}`. Immutable
59 * — changing it replaces the association.
60 */
61 network: string;
62 /**
63 * Multicast domain activation in the same zone
64 * (`projects/{project}/locations/{location}/multicastDomainActivations/{activation}`).
65 * The live API currently requires this field. Immutable — changing it
66 * replaces the association.
67 */
68 multicastDomainActivation?: string;
69 /**
70 * Human-readable description.
71 */
72 description?: string;
73 /**
74 * User labels. Alchemy ownership labels are merged in automatically.
75 */
76 labels?: Record<string, string>;
77};
78
79export type MulticastConsumerAssociation = Resource<
80 "GCP.NetworkServices.MulticastConsumerAssociation",
81 MulticastConsumerAssociationProps,
82 {
83 /** Full resource name `projects/{project}/locations/{location}/multicastConsumerAssociations/{multicastConsumerAssociation}`. */
84 name: string;
85 /** Association id (last path segment). */
86 multicastConsumerAssociationId: string;
87 /** Project id. */
88 project: string;
89 /** Zone id (`us-central1-a`, …). */
90 location: string;
91 /** Consumer VPC network resource name. */
92 network: string | undefined;
93 /** Attached multicast domain activation, if set. */
94 multicastDomainActivation: string | undefined;
95 /** User-provided description. */
96 description: string | undefined;
97 /** Deprecated resource-state enum. */
98 resourceState: string | undefined;
99 /** Nested multicast resource state. */
100 state: string | undefined;
101 /** Google-generated UUID. */
102 uniqueId: string | undefined;
103 /** Placement-policy resource name, if set. */
104 placementPolicy: string | undefined;
105 /** User labels (Alchemy ownership labels stripped). */
106 labels: Record<string, string>;
107 /** RFC3339 creation timestamp. */
108 createTime: string | undefined;
109 /** RFC3339 last-update timestamp. */
110 updateTime: string | undefined;
111 },
112 never,
113 Providers
114>;
115
116/**
117 * A multicast consumer association attaches a consumer VPC to Cloud
118 * Multicast in a zone so VMs in that network can join multicast groups.
119 *
120 * Changing `multicastConsumerAssociationId`, `location`, `network`, or
121 * `multicastDomainActivation` replaces the association. Description and
122 * labels update in place.
123 *
124 * ### Creating a MulticastConsumerAssociation
125 * **Example:** Associate a VPC
126 * ```typescript
127 * const association = yield* GCP.NetworkServices.MulticastConsumerAssociation(
128 * "Consumers",
129 * {
130 * location: "us-central1-a",
131 * network: `projects/${vpc.project}/locations/global/networks/${vpc.networkName}`,
132 * labels: { env: "prod" },
133 * },
134 * );
135 * ```
136 *
137 * @resource
138 * @category NetworkServices
139 */
140export const MulticastConsumerAssociation =
141 Resource<MulticastConsumerAssociation>(
142 "GCP.NetworkServices.MulticastConsumerAssociation",
143 );
144
145const toActivation = (
146 project: string,
147 location: string,
148 value: string | undefined,
149) =>
150 value
151 ? toNamedResource(project, location, "multicastDomainActivations", value)
152 : undefined;
153
154const toAttrs = (
155 association: networkservices.MulticastConsumerAssociation,
156 project: string,
157) => {
158 const name = association.name ?? "";
159 const parsed = parseName(name, COLLECTION, DEFAULT_ZONE);
160 const location = parsed.location || DEFAULT_ZONE;
161 const proj = parsed.project || project;
162 return {
163 name,
164 multicastConsumerAssociationId: parsed.id,
165 project: proj,
166 location,
167 network: association.network
168 ? toMulticastNetwork(proj, association.network)
169 : undefined,
170 multicastDomainActivation: toActivation(
171 proj,
172 location,
173 association.multicastDomainActivation,
174 ),
175 description: association.description,
176 resourceState: association.resourceState,
177 state: association.state?.state,
178 uniqueId: association.uniqueId,
179 placementPolicy: association.placementPolicy,
180 labels: userLabels(association.labels),
181 createTime: association.createTime,
182 updateTime: association.updateTime,
183 };
184};
185
186const getByName = (name: string) =>
187 networkservices
188 .getProjectsLocationsMulticastConsumerAssociations({ name })
189 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
190
191export const MulticastConsumerAssociationProvider = () =>
192 Provider.succeed(MulticastConsumerAssociation, {
193 stables: [
194 "name",
195 "multicastConsumerAssociationId",
196 "project",
197 "location",
198 "network",
199 "multicastDomainActivation",
200 "uniqueId",
201 "createTime",
202 ],
203
204 diff: Effect.fn(function* ({ news, olds, output }) {
205 if (!isResolved(news)) return undefined;
206 const previousId =
207 olds?.multicastConsumerAssociationId ??
208 output?.multicastConsumerAssociationId;
209 const nextId = news.multicastConsumerAssociationId
210 ? rfc1035(
211 news.multicastConsumerAssociationId,
212 "mcast-assoc",
213 MAX_MULTICAST_NAME_LENGTH,
214 )
215 : previousId;
216 const previousLocation = normalizeLocation(
217 olds?.location ?? output?.location,
218 DEFAULT_ZONE,
219 );
220 const nextLocation = normalizeLocation(
221 news.location ?? olds?.location ?? output?.location,
222 DEFAULT_ZONE,
223 );
224 const previousNetwork = linkKey(olds?.network ?? output?.network);
225 const nextNetwork = linkKey(news.network);
226 const previousActivation = linkKey(
227 olds?.multicastDomainActivation ?? output?.multicastDomainActivation,
228 );
229 const nextActivation = linkKey(news.multicastDomainActivation);
230 if (
231 (previousId !== undefined &&
232 nextId !== undefined &&
233 nextId !== previousId) ||
234 previousLocation !== nextLocation ||
235 (previousNetwork.length > 0 && previousNetwork !== nextNetwork) ||
236 (previousActivation.length > 0 &&
237 nextActivation.length > 0 &&
238 previousActivation !== nextActivation)
239 ) {
240 return { action: "replace" as const };
241 }
242 return undefined;
243 }),
244
245 read: Effect.fn(function* ({ id, olds, output }) {
246 const env = yield* GcpEnvironment.current;
247 const multicastConsumerAssociationId = yield* toPhysicalId(
248 id,
249 olds?.multicastConsumerAssociationId,
250 output?.multicastConsumerAssociationId,
251 "mcast-assoc",
252 MAX_MULTICAST_NAME_LENGTH,
253 );
254 const location = normalizeLocation(
255 olds?.location ?? output?.location,
256 DEFAULT_ZONE,
257 );
258 const name =
259 output?.name ??
260 resourceName(
261 env.project,
262 location,
263 COLLECTION,
264 multicastConsumerAssociationId,
265 );
266 const existing = yield* getByName(name);
267 if (existing === undefined) return undefined;
268 const attrs = toAttrs(existing, env.project);
269 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
270 ? attrs
271 : Unowned(attrs);
272 }),
273
274 list: () =>
275 Effect.gen(function* () {
276 const env = yield* GcpEnvironment.current;
277 const items = yield* collectPages(
278 networkservices.listProjectsLocationsMulticastConsumerAssociations.pages(
279 {
280 parent: parentOf(env.project, "-"),
281 pageSize: 1000,
282 },
283 ),
284 (page) => page.multicastConsumerAssociations,
285 );
286 return items
287 .filter((item) => hasAlchemyLabelKeys(item.labels))
288 .map((item) => toAttrs(item, env.project));
289 }),
290
291 reconcile: Effect.fn(function* ({ id, news, output }) {
292 const env = yield* GcpEnvironment.current;
293 const multicastConsumerAssociationId = yield* toPhysicalId(
294 id,
295 news.multicastConsumerAssociationId,
296 output?.multicastConsumerAssociationId,
297 "mcast-assoc",
298 MAX_MULTICAST_NAME_LENGTH,
299 );
300 const location = normalizeLocation(
301 news.location ?? output?.location,
302 DEFAULT_ZONE,
303 );
304 const name = resourceName(
305 env.project,
306 location,
307 COLLECTION,
308 multicastConsumerAssociationId,
309 );
310 const desiredLabels = {
311 ...toLabels(news.labels),
312 ...(yield* createInternalLabels(id)),
313 };
314 const network = toMulticastNetwork(env.project, news.network);
315 const multicastDomainActivation = toActivation(
316 env.project,
317 location,
318 news.multicastDomainActivation,
319 );
320
321 let current = yield* getByName(name);
322
323 if (current === undefined) {
324 const created = yield* networkservices
325 .createProjectsLocationsMulticastConsumerAssociations({
326 parent: parentOf(env.project, location),
327 multicastConsumerAssociationId,
328 body: {
329 description: news.description,
330 labels: desiredLabels,
331 network,
332 multicastDomainActivation,
333 },
334 })
335 .pipe(
336 Effect.retry({
337 while: (error) => error._tag === "Conflict",
338 times: 5,
339 schedule: Schedule.spaced("2 seconds"),
340 }),
341 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
342 );
343 if (created !== undefined) {
344 yield* waitForOperation(created);
345 }
346 current = yield* waitUntilPresent(getByName(name), name);
347 }
348
349 const observedLabels = tagRecord(current.labels);
350 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
351 const labelsChanged = upsert.length > 0 || removed.length > 0;
352 const updateMask = changedFields([
353 ["labels", labelsChanged],
354 [
355 "description",
356 (current.description ?? "") !== (news.description ?? ""),
357 ],
358 ]);
359
360 if (updateMask.length > 0) {
361 const operation =
362 yield* networkservices.patchProjectsLocationsMulticastConsumerAssociations(
363 {
364 name: current.name ?? name,
365 updateMask: updateMask.join(","),
366 body: {
367 name: current.name ?? name,
368 labels: desiredLabels,
369 description: news.description,
370 },
371 },
372 );
373 yield* waitForOperation(operation);
374 current = yield* waitUntilPresent(
375 getByName(current.name ?? name),
376 current.name ?? name,
377 );
378 }
379
380 return toAttrs(current, env.project);
381 }),
382
383 delete: Effect.fn(function* ({ output }) {
384 const operation = yield* networkservices
385 .deleteProjectsLocationsMulticastConsumerAssociations({
386 name: output.name,
387 })
388 .pipe(
389 Effect.retry({
390 while: (error) => error._tag === "Conflict",
391 times: 8,
392 schedule: Schedule.spaced("2 seconds"),
393 }),
394 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
395 );
396 if (operation !== undefined) {
397 yield* waitForOperation(operation, { notFoundOk: true });
398 }
399 yield* waitUntilGone(getByName(output.name), output.name);
400 }),
401 });