1import * as networkconnectivity from "@distilled.cloud/gcp/networkconnectivity_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 NetworkConnectivityNotResolved,
19 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 lastSegment,
23 parentOfName,
24 parseName,
25 rfc1035,
26 toPhysicalId,
27 userLabels,
28 waitForOperation,
29 waitUntilGone,
30 waitUntilReady,
31} from "./internal.ts";
32
33const COLLECTION = "gatewayAdvertisedRoutes";
34const DEFAULT_PRIORITY = 100;
35
36export type GatewayAdvertisedRouteRecipient =
37 | networkconnectivity.GatewayAdvertisedRouteRecipientEnum
38 | (string & {});
39
40export type SpokesGatewayAdvertisedRouteProps = {
41 /**
42 * Parent gateway spoke resource name
43 * `projects/{project}/locations/{location}/spokes/{spoke}`. Immutable —
44 * changing it replaces the route.
45 */
46 parent: string;
47 /**
48 * Route id (the `{gateway_advertised_route}` segment). If omitted, a
49 * unique name is generated. Immutable — changing it replaces the
50 * route.
51 */
52 gatewayAdvertisedRouteId?: string;
53 /**
54 * Advertised CIDR (`"10.0.0.0/24"` or a single IP, interpreted as
55 * `/32` or `/128`). Immutable — changing it replaces the route.
56 */
57 ipRange: string;
58 /**
59 * Priority from `0` to `65335`. Defaults to `100`.
60 * @default 100
61 */
62 priority?: number;
63 /**
64 * Recipient of this advertised route.
65 */
66 recipient?: GatewayAdvertisedRouteRecipient;
67 /**
68 * Human-readable description. Immutable — changing it replaces the
69 * route (GCP rejects `description` in PATCH).
70 */
71 description?: string;
72 /**
73 * User labels. Alchemy ownership labels are merged in automatically.
74 * Immutable — changing them replaces the route.
75 */
76 labels?: Record<string, string>;
77};
78
79export type SpokesGatewayAdvertisedRoute = Resource<
80 "GCP.NetworkConnectivity.SpokesGatewayAdvertisedRoute",
81 SpokesGatewayAdvertisedRouteProps,
82 {
83 /** Full resource name `.../spokes/{spoke}/gatewayAdvertisedRoutes/{id}`. */
84 name: string;
85 /** Route id (last path segment). */
86 gatewayAdvertisedRouteId: string;
87 /** Parent gateway spoke resource name. */
88 parent: string;
89 /** Project id. */
90 project: string;
91 /** Location id. */
92 location: string;
93 /** Spoke id. */
94 spokeId: string;
95 /** Advertised CIDR. */
96 ipRange: string | undefined;
97 /** Advertised-route priority. */
98 priority: number | undefined;
99 /** Recipient of this advertised route. */
100 recipient: string | undefined;
101 /** User-provided description. */
102 description: string | undefined;
103 /** User labels (Alchemy ownership labels stripped). */
104 labels: Record<string, string>;
105 /** Server-generated UUID. */
106 uniqueId: string | undefined;
107 /** Server-reported lifecycle state. */
108 state: string | undefined;
109 /** RFC3339 creation timestamp. */
110 createTime: string | undefined;
111 /** RFC3339 last-update timestamp. */
112 updateTime: string | undefined;
113 },
114 never,
115 Providers
116>;
117
118/**
119 * A route a Network Connectivity Center gateway spoke advertises to a
120 * hub (or other recipient).
121 *
122 * Changing `parent`, `gatewayAdvertisedRouteId`, `ipRange`, `labels`, or
123 * `description` replaces the route (GCP does not allow updating labels or
124 * the description). `priority` and `recipient` update in place.
125 *
126 * ### Creating a GatewayAdvertisedRoute
127 * **Example:** Advertise a CIDR to the hub
128 * ```typescript
129 * const hub = yield* GCP.NetworkConnectivity.Hub("Mesh", {});
130 * const spoke = yield* GCP.NetworkConnectivity.Spoke("Gw", {
131 * location: "us-central1",
132 * hub: hub.name,
133 * gateway: {
134 * capacity: "CAPACITY_1_GBPS",
135 * ipRangeReservations: [{ ipRange: "10.200.0.0/23" }],
136 * },
137 * });
138 * const route = yield* GCP.NetworkConnectivity.SpokesGatewayAdvertisedRoute(
139 * "OnPrem",
140 * {
141 * parent: spoke.name,
142 * ipRange: "192.168.0.0/16",
143 * recipient: "ADVERTISE_TO_HUB",
144 * labels: { env: "prod" },
145 * },
146 * );
147 * ```
148 *
149 * ### Updating a GatewayAdvertisedRoute
150 * **Example:** Description and priority
151 * ```typescript
152 * const route = yield* GCP.NetworkConnectivity.SpokesGatewayAdvertisedRoute(
153 * "OnPrem",
154 * {
155 * parent: spoke.name,
156 * ipRange: "192.168.0.0/16",
157 * priority: 200,
158 * recipient: "ADVERTISE_TO_HUB",
159 * description: "on-prem v2",
160 * labels: { env: "prod" },
161 * },
162 * );
163 * ```
164 *
165 * @resource
166 * @category NetworkConnectivity
167 */
168export const SpokesGatewayAdvertisedRoute =
169 Resource<SpokesGatewayAdvertisedRoute>(
170 "GCP.NetworkConnectivity.SpokesGatewayAdvertisedRoute",
171 );
172
173const resourceNameOf = (parent: string, gatewayAdvertisedRouteId: string) =>
174 `${parent}/gatewayAdvertisedRoutes/${gatewayAdvertisedRouteId}`;
175
176const toAttrs = (
177 route: networkconnectivity.GatewayAdvertisedRoute,
178 project: string,
179 region: string,
180) => {
181 const name = route.name ?? "";
182 const parsed = parseName(name, COLLECTION, region);
183 const parent = parentOfName(name, COLLECTION);
184 return {
185 name,
186 gatewayAdvertisedRouteId: parsed.id,
187 parent,
188 project: parsed.project || project,
189 location: parsed.location || region,
190 spokeId: lastSegment(parent),
191 ipRange: route.ipRange,
192 priority: route.priority,
193 recipient: route.recipient,
194 description: route.description,
195 labels: userLabels(route.labels),
196 uniqueId: route.uniqueId,
197 state: route.state,
198 createTime: route.createTime,
199 updateTime: route.updateTime,
200 };
201};
202
203const getByName = (name: string) =>
204 networkconnectivity
205 .getProjectsLocationsSpokesGatewayAdvertisedRoutes({ name })
206 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
207
208export const SpokesGatewayAdvertisedRouteProvider = () =>
209 Provider.succeed(SpokesGatewayAdvertisedRoute, {
210 stables: [
211 "name",
212 "gatewayAdvertisedRouteId",
213 "parent",
214 "project",
215 "location",
216 "spokeId",
217 "ipRange",
218 "uniqueId",
219 "createTime",
220 ],
221
222 diff: Effect.fn(function* ({ news, olds, output }) {
223 if (!isResolved(news)) return undefined;
224 const previousId =
225 olds?.gatewayAdvertisedRouteId ?? output?.gatewayAdvertisedRouteId;
226 const nextId = news.gatewayAdvertisedRouteId
227 ? rfc1035(news.gatewayAdvertisedRouteId, "gateway-advertised-route")
228 : previousId;
229 const previousParent = olds?.parent ?? output?.parent;
230 const previousRange = olds?.ipRange ?? output?.ipRange;
231 const previousLabels = olds?.labels ?? output?.labels;
232 const previousDescription = olds?.description ?? output?.description;
233 const idChanged =
234 previousId !== undefined &&
235 nextId !== undefined &&
236 nextId !== previousId;
237 const parentChanged =
238 previousParent !== undefined && previousParent !== news.parent;
239 const rangeChanged =
240 previousRange !== undefined && previousRange !== news.ipRange;
241 // `labels` is not mutable on GatewayAdvertisedRoute (PATCH rejects the
242 // path), so a labels change replaces the route.
243 const labels = diffLabels(
244 toLabels(previousLabels),
245 toLabels(news.labels),
246 );
247 const labelsChanged =
248 previousLabels !== undefined &&
249 (labels.upsert.length > 0 || labels.removed.length > 0);
250 // `description` is not mutable either.
251 const descriptionChanged =
252 (olds !== undefined || output !== undefined) &&
253 (previousDescription ?? "") !== (news.description ?? "");
254 if (
255 idChanged ||
256 parentChanged ||
257 rangeChanged ||
258 labelsChanged ||
259 descriptionChanged
260 ) {
261 return {
262 action: "replace" as const,
263 // A route with a pinned id, or the same CIDR on the same spoke,
264 // collides with the old route; delete it first.
265 deleteFirst:
266 !parentChanged &&
267 (!rangeChanged ||
268 (news.gatewayAdvertisedRouteId !== undefined && !idChanged)),
269 };
270 }
271 return undefined;
272 }),
273
274 read: Effect.fn(function* ({ id, olds, output }) {
275 const env = yield* GcpEnvironment.current;
276 const gatewayAdvertisedRouteId = yield* toPhysicalId(
277 id,
278 olds?.gatewayAdvertisedRouteId,
279 output?.gatewayAdvertisedRouteId,
280 "gateway-advertised-route",
281 );
282 const parent = olds?.parent ?? output?.parent;
283 const name =
284 output?.name ??
285 (parent !== undefined
286 ? resourceNameOf(parent, gatewayAdvertisedRouteId)
287 : undefined);
288 if (name === undefined) return undefined;
289 const existing = yield* getByName(name);
290 if (existing === undefined) return undefined;
291 const attrs = toAttrs(existing, env.project, env.region);
292 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
293 ? attrs
294 : Unowned(attrs);
295 }),
296
297 list: () =>
298 Effect.gen(function* () {
299 const env = yield* GcpEnvironment.current;
300 const spokes = yield* collectPages(
301 networkconnectivity.listProjectsLocationsSpokes.pages({
302 parent: `projects/${env.project}/locations/-`,
303 pageSize: 1000,
304 }),
305 (page) => page.spokes,
306 );
307 const nested = yield* Effect.forEach(
308 spokes.filter((spoke) => spoke.name),
309 (spoke) =>
310 collectPages(
311 networkconnectivity.listProjectsLocationsSpokesGatewayAdvertisedRoutes.pages(
312 {
313 parent: spoke.name!,
314 pageSize: 1000,
315 },
316 ),
317 (page) => page.gatewayAdvertisedRoutes,
318 ),
319 { concurrency: 4 },
320 );
321 return nested
322 .flat()
323 .filter((item) => hasAlchemyLabelKeys(item.labels))
324 .map((item) => toAttrs(item, env.project, env.region));
325 }),
326
327 reconcile: Effect.fn(function* ({ id, news, output }) {
328 const env = yield* GcpEnvironment.current;
329 const gatewayAdvertisedRouteId = yield* toPhysicalId(
330 id,
331 news.gatewayAdvertisedRouteId,
332 output?.gatewayAdvertisedRouteId,
333 "gateway-advertised-route",
334 );
335 const name = resourceNameOf(news.parent, gatewayAdvertisedRouteId);
336 const desiredLabels = {
337 ...toLabels(news.labels),
338 ...(yield* createInternalLabels(id)),
339 };
340 const priority = news.priority ?? DEFAULT_PRIORITY;
341
342 let current = yield* getByName(name);
343
344 if (current === undefined) {
345 const created = yield* networkconnectivity
346 .createProjectsLocationsSpokesGatewayAdvertisedRoutes({
347 parent: news.parent,
348 gatewayAdvertisedRouteId,
349 body: {
350 ipRange: news.ipRange,
351 priority,
352 recipient: news.recipient,
353 description: news.description,
354 labels: desiredLabels,
355 },
356 })
357 .pipe(
358 Effect.retry({
359 while: (error) => error._tag === "Conflict",
360 times: 5,
361 schedule: Schedule.spaced("2 seconds"),
362 }),
363 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
364 );
365 if (created !== undefined) {
366 yield* waitForOperation(created);
367 }
368 current = yield* waitUntilReady(getByName(name), name);
369 }
370
371 if (current === undefined) {
372 return yield* new NetworkConnectivityNotResolved({ name });
373 }
374
375 // Labels and description are create-only; `diff` replaces the route
376 // when they change.
377 const priorityChanged =
378 (current.priority ?? DEFAULT_PRIORITY) !== priority;
379 const recipientChanged =
380 (current.recipient ?? "") !== (news.recipient ?? "");
381 const updateMask = changedFields([
382 ["priority", priorityChanged],
383 ["recipient", recipientChanged],
384 ]);
385
386 if (updateMask.length > 0) {
387 const operation =
388 yield* networkconnectivity.patchProjectsLocationsSpokesGatewayAdvertisedRoutes(
389 {
390 name: current.name ?? name,
391 updateMask: updateMask.join(","),
392 body: {
393 name: current.name ?? name,
394 description: news.description,
395 priority,
396 recipient: news.recipient,
397 },
398 },
399 );
400 yield* waitForOperation(operation);
401 current = yield* waitUntilReady(
402 getByName(current.name ?? name),
403 current.name ?? name,
404 );
405 }
406
407 return toAttrs(current, env.project, env.region);
408 }),
409
410 delete: Effect.fn(function* ({ output }) {
411 const operation = yield* networkconnectivity
412 .deleteProjectsLocationsSpokesGatewayAdvertisedRoutes({
413 name: output.name,
414 })
415 .pipe(
416 Effect.retry({
417 while: (error) => error._tag === "Conflict",
418 times: 8,
419 schedule: Schedule.spaced("2 seconds"),
420 }),
421 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
422 );
423 if (operation !== undefined) {
424 yield* waitForOperation(operation, { notFoundOk: true });
425 }
426 yield* waitUntilGone(getByName(output.name), output.name);
427 }),
428 });