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 { toChain, type ExtensionChain } from "./LbEdgeExtension.ts";
18import {
19 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 resourceName,
26 rfc1035,
27 sameJson,
28 sameStringList,
29 toPhysicalId,
30 userLabels,
31 waitForOperation,
32 waitUntilGone,
33 waitUntilPresent,
34} from "./internal.ts";
35
36const COLLECTION = "lbRouteExtensions";
37const DEFAULT_SCHEME = "INTERNAL_MANAGED";
38
39export type LbRouteExtensionLoadBalancingScheme =
40 | networkservices.LbRouteExtensionLoadBalancingSchemeEnum
41 | (string & {});
42
43export type { ExtensionChain };
44
45export type LbRouteExtensionProps = {
46 /**
47 * LbRouteExtension id (the `{lbRouteExtension}` segment of
48 * `projects/{project}/locations/{location}/lbRouteExtensions/{lbRouteExtension}`).
49 * If omitted, a unique name is generated from the stack, stage, and
50 * logical id. Must be 1-63 characters and not start with a number.
51 * Immutable — changing it replaces the extension.
52 */
53 lbRouteExtensionId?: string;
54 /**
55 * Location (`us-central1`, `global`, …). Immutable — changing it
56 * replaces the extension. `US-CENTRAL1` is accepted and normalized to
57 * `us-central1`.
58 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
59 */
60 location?: string;
61 /**
62 * Forwarding rule resource names this extension attaches to. At least
63 * one is required. Only one LbRouteExtension may attach to a given
64 * forwarding rule.
65 */
66 forwardingRules: string[];
67 /**
68 * Ordered extension chains. The first matching chain runs. Limited to
69 * 5 chains; each chain is limited to 1 extension.
70 */
71 extensionChains: ExtensionChain[];
72 /**
73 * Load balancing scheme shared by referenced backend services and
74 * forwarding rules. Immutable — changing it replaces the extension.
75 * @default "INTERNAL_MANAGED"
76 */
77 loadBalancingScheme?: LbRouteExtensionLoadBalancingScheme;
78 /**
79 * Metadata included under `com.google.lb_route_extension.` in the
80 * `ProcessingRequest`. Must not be set when any chain contains a
81 * plugin extension.
82 */
83 metadata?: Record<string, unknown>;
84 /**
85 * Human-readable description.
86 */
87 description?: string;
88 /**
89 * User labels. Alchemy ownership labels are merged in automatically.
90 */
91 labels?: Record<string, string>;
92};
93
94export type LbRouteExtension = Resource<
95 "GCP.NetworkServices.LbRouteExtension",
96 LbRouteExtensionProps,
97 {
98 /** Full resource name `projects/{project}/locations/{location}/lbRouteExtensions/{lbRouteExtension}`. */
99 name: string;
100 /** LbRouteExtension id (last path segment). */
101 lbRouteExtensionId: string;
102 /** Project id. */
103 project: string;
104 /** Location id (`us-central1`, `global`, …). */
105 location: string;
106 /** Attached forwarding rule resource names. */
107 forwardingRules: string[];
108 /** Extension chains. */
109 extensionChains: ExtensionChain[];
110 /** Load balancing scheme. */
111 loadBalancingScheme: string | undefined;
112 /** Resource-level callout metadata. */
113 metadata: Record<string, unknown> | undefined;
114 /** User-provided description. */
115 description: string | undefined;
116 /** User labels (Alchemy ownership labels stripped). */
117 labels: Record<string, string>;
118 /** RFC3339 creation timestamp. */
119 createTime: string | undefined;
120 /** RFC3339 last-update timestamp. */
121 updateTime: string | undefined;
122 },
123 never,
124 Providers
125>;
126
127/**
128 * A Network Services LbRouteExtension — a Service Extension that
129 * controls where an Application Load Balancer routes a given request.
130 *
131 * Changing `lbRouteExtensionId`, `location`, or `loadBalancingScheme`
132 * replaces the extension. Description, labels, forwarding rules,
133 * extension chains, and metadata update in place.
134 *
135 * ### Creating an LbRouteExtension
136 * **Example:** Attach to a regional forwarding rule
137 * ```typescript
138 * const ext = yield* GCP.NetworkServices.LbRouteExtension("Route", {
139 * location: "us-central1",
140 * loadBalancingScheme: "INTERNAL_MANAGED",
141 * forwardingRules: [rule.selfLink],
142 * extensionChains: [
143 * {
144 * name: "chain1",
145 * matchCondition: { celExpression: "true" },
146 * extensions: [
147 * {
148 * name: "ext1",
149 * authority: "ext1.example.com",
150 * service: backend.selfLink,
151 * timeout: "0.1s",
152 * supportedEvents: ["REQUEST_HEADERS"],
153 * },
154 * ],
155 * },
156 * ],
157 * });
158 * ```
159 *
160 * @resource
161 * @category NetworkServices
162 */
163export const LbRouteExtension = Resource<LbRouteExtension>(
164 "GCP.NetworkServices.LbRouteExtension",
165);
166
167const toAttrs = (
168 extension: networkservices.LbRouteExtension,
169 project: string,
170 region: string,
171) => {
172 const name = extension.name ?? "";
173 const parsed = parseName(name, COLLECTION, region);
174 return {
175 name,
176 lbRouteExtensionId: parsed.id,
177 project: parsed.project || project,
178 location: parsed.location || region,
179 forwardingRules: extension.forwardingRules ?? [],
180 extensionChains: (extension.extensionChains ?? []).map(toChain),
181 loadBalancingScheme: extension.loadBalancingScheme,
182 metadata: extension.metadata,
183 description: extension.description,
184 labels: userLabels(extension.labels),
185 createTime: extension.createTime,
186 updateTime: extension.updateTime,
187 };
188};
189
190const getByName = (name: string) =>
191 networkservices
192 .getProjectsLocationsLbRouteExtensions({ name })
193 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
194
195export const LbRouteExtensionProvider = () =>
196 Provider.succeed(LbRouteExtension, {
197 stables: [
198 "name",
199 "lbRouteExtensionId",
200 "project",
201 "location",
202 "createTime",
203 ],
204
205 diff: Effect.fn(function* ({ news, olds, output }) {
206 if (!isResolved(news)) return undefined;
207 const env = yield* GcpEnvironment.current;
208 const previousId = olds?.lbRouteExtensionId ?? output?.lbRouteExtensionId;
209 const nextId = news.lbRouteExtensionId
210 ? rfc1035(news.lbRouteExtensionId, "lb-route-extension")
211 : previousId;
212 const previousLocation = normalizeLocation(
213 olds?.location ?? output?.location,
214 env.region,
215 );
216 const nextLocation = normalizeLocation(
217 news.location ?? olds?.location ?? output?.location,
218 env.region,
219 );
220 const previousScheme =
221 olds?.loadBalancingScheme ??
222 output?.loadBalancingScheme ??
223 DEFAULT_SCHEME;
224 const nextScheme = news.loadBalancingScheme ?? previousScheme;
225 if (
226 (previousId !== undefined &&
227 nextId !== undefined &&
228 nextId !== previousId) ||
229 previousLocation !== nextLocation ||
230 previousScheme !== nextScheme
231 ) {
232 return { action: "replace" as const };
233 }
234 return undefined;
235 }),
236
237 read: Effect.fn(function* ({ id, olds, output }) {
238 const env = yield* GcpEnvironment.current;
239 const lbRouteExtensionId = yield* toPhysicalId(
240 id,
241 olds?.lbRouteExtensionId,
242 output?.lbRouteExtensionId,
243 "lb-route-extension",
244 );
245 const location = normalizeLocation(
246 olds?.location ?? output?.location,
247 env.region,
248 );
249 const name =
250 output?.name ??
251 resourceName(env.project, location, COLLECTION, lbRouteExtensionId);
252 const existing = yield* getByName(name);
253 if (existing === undefined) return undefined;
254 const attrs = toAttrs(existing, env.project, env.region);
255 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
256 ? attrs
257 : Unowned(attrs);
258 }),
259
260 list: () =>
261 Effect.gen(function* () {
262 const env = yield* GcpEnvironment.current;
263 const items = yield* collectPages(
264 networkservices.listProjectsLocationsLbRouteExtensions.pages({
265 parent: parentOf(env.project, "-"),
266 pageSize: 1000,
267 }),
268 (page) => page.lbRouteExtensions,
269 );
270 return items
271 .filter((item) => hasAlchemyLabelKeys(item.labels))
272 .map((item) => toAttrs(item, env.project, env.region));
273 }),
274
275 reconcile: Effect.fn(function* ({ id, news, output }) {
276 const env = yield* GcpEnvironment.current;
277 const lbRouteExtensionId = yield* toPhysicalId(
278 id,
279 news.lbRouteExtensionId,
280 output?.lbRouteExtensionId,
281 "lb-route-extension",
282 );
283 const location = normalizeLocation(
284 news.location ?? output?.location,
285 env.region,
286 );
287 const name = resourceName(
288 env.project,
289 location,
290 COLLECTION,
291 lbRouteExtensionId,
292 );
293 const desiredLabels = {
294 ...toLabels(news.labels),
295 ...(yield* createInternalLabels(id)),
296 };
297 const loadBalancingScheme = news.loadBalancingScheme ?? DEFAULT_SCHEME;
298 const desiredRules = news.forwardingRules;
299 const desiredChains = news.extensionChains.map(toChain);
300
301 let current = yield* getByName(name);
302
303 if (current === undefined) {
304 const created = yield* networkservices
305 .createProjectsLocationsLbRouteExtensions({
306 parent: parentOf(env.project, location),
307 lbRouteExtensionId,
308 body: {
309 labels: desiredLabels,
310 description: news.description,
311 forwardingRules: desiredRules,
312 extensionChains: desiredChains,
313 loadBalancingScheme,
314 metadata: news.metadata,
315 },
316 })
317 .pipe(
318 Effect.retry({
319 while: (error) => error._tag === "Conflict",
320 times: 5,
321 schedule: Schedule.spaced("2 seconds"),
322 }),
323 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
324 );
325 if (created !== undefined) {
326 yield* waitForOperation(created);
327 }
328 current = yield* waitUntilPresent(getByName(name), name);
329 }
330
331 const observedLabels = tagRecord(current.labels);
332 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
333 const labelsChanged = upsert.length > 0 || removed.length > 0;
334 const descriptionChanged =
335 (current.description ?? "") !== (news.description ?? "");
336 const rulesChanged = !sameStringList(
337 current.forwardingRules,
338 desiredRules,
339 );
340 const chainsChanged = !sameJson(
341 (current.extensionChains ?? []).map(toChain),
342 desiredChains,
343 );
344 const metadataChanged = !sameJson(current.metadata, news.metadata);
345
346 const updateMask = changedFields([
347 ["labels", labelsChanged],
348 ["description", descriptionChanged],
349 ["forwardingRules", rulesChanged],
350 ["extensionChains", chainsChanged],
351 ["metadata", metadataChanged],
352 ]);
353
354 if (updateMask.length > 0) {
355 const operation =
356 yield* networkservices.patchProjectsLocationsLbRouteExtensions({
357 name: current.name ?? name,
358 updateMask: updateMask.join(","),
359 body: {
360 name: current.name ?? name,
361 labels: desiredLabels,
362 description: news.description,
363 forwardingRules: desiredRules,
364 extensionChains: desiredChains,
365 loadBalancingScheme,
366 metadata: news.metadata,
367 },
368 });
369 yield* waitForOperation(operation);
370 current = yield* waitUntilPresent(
371 getByName(current.name ?? name),
372 current.name ?? name,
373 );
374 }
375
376 return toAttrs(current, env.project, env.region);
377 }),
378
379 delete: Effect.fn(function* ({ output }) {
380 const operation = yield* networkservices
381 .deleteProjectsLocationsLbRouteExtensions({ name: output.name })
382 .pipe(
383 Effect.retry({
384 while: (error) => error._tag === "Conflict",
385 times: 8,
386 schedule: Schedule.spaced("2 seconds"),
387 }),
388 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
389 );
390 if (operation !== undefined) {
391 yield* waitForOperation(operation, { notFoundOk: true });
392 }
393 yield* waitUntilGone(getByName(output.name), output.name);
394 }),
395 });