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_GLOBAL,
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 = "lbEdgeExtensions";
37const DEFAULT_SCHEME = "EXTERNAL_MANAGED";
38
39export type LbEdgeExtensionLoadBalancingScheme =
40 | networkservices.LbEdgeExtensionLoadBalancingSchemeEnum
41 | (string & {});
42
43export type ExtensionChainMatchCondition = {
44 /**
45 * CEL expression used to select requests. See the Service Extensions
46 * CEL matcher language reference.
47 */
48 celExpression?: string;
49};
50
51export type ExtensionChainExtension = {
52 /**
53 * Extension name logged in HTTP request logs. RFC1034, 1-63
54 * characters. Required except on AuthzExtension.
55 */
56 name?: string;
57 /**
58 * `:authority` header sent from Envoy to the callout. Required for
59 * callout extensions; forbidden for plugin extensions.
60 */
61 authority?: string;
62 /**
63 * Backend service URL (callout) or WasmPlugin resource name (plugin).
64 */
65 service?: string;
66 /**
67 * Events that invoke this extension. `LbEdgeExtension` must list only
68 * `REQUEST_HEADERS`.
69 */
70 supportedEvents?: string[];
71 /**
72 * Per-message timeout between 10ms and 10000ms. Required for callouts.
73 */
74 timeout?: string;
75 /**
76 * Continue request processing if the callout fails or times out.
77 * @default false
78 */
79 failOpen?: boolean;
80 /** HTTP headers forwarded to the extension. Omitted sends every header. */
81 forwardHeaders?: string[];
82 /** Envoy attributes forwarded to the extension. Omitted sends none. */
83 forwardAttributes?: string[];
84 /**
85 * Metadata included in `ProcessingRequest.metadata_context`. Must not
86 * be set for plugin extensions.
87 */
88 metadata?: Record<string, unknown>;
89 /** Request body send mode (`STREAMED` or `FULL_DUPLEX_STREAMED`). */
90 requestBodySendMode?: string;
91 /** Response body send mode. */
92 responseBodySendMode?: string;
93 /**
94 * Run the callout asynchronously without pausing the request.
95 * @default false
96 */
97 observabilityMode?: boolean;
98};
99
100export type ExtensionChain = {
101 /** Chain name logged in HTTP request logs. RFC1034, 1-63 characters. */
102 name?: string;
103 /** Conditions under which this chain runs. */
104 matchCondition?: ExtensionChainMatchCondition;
105 /**
106 * Extensions executed for a matching request. `LbEdgeExtension` and
107 * `LbRouteExtension` allow one extension per chain.
108 */
109 extensions?: ExtensionChainExtension[];
110};
111
112export type LbEdgeExtensionProps = {
113 /**
114 * LbEdgeExtension id (the `{lbEdgeExtension}` segment of
115 * `projects/{project}/locations/{location}/lbEdgeExtensions/{lbEdgeExtension}`).
116 * If omitted, a unique name is generated from the stack, stage, and
117 * logical id. Must be 1-63 characters and not start with a number.
118 * Immutable — changing it replaces the extension.
119 */
120 lbEdgeExtensionId?: string;
121 /**
122 * Location (`global`, `us-central1`, …). Immutable — changing it
123 * replaces the extension. `US-CENTRAL1` is accepted and normalized to
124 * `us-central1`.
125 * @default "global"
126 */
127 location?: string;
128 /**
129 * Forwarding rule resource names this extension attaches to. At least
130 * one is required. Only one LbEdgeExtension may attach to a given
131 * forwarding rule.
132 */
133 forwardingRules: string[];
134 /**
135 * Ordered extension chains. The first matching chain runs. Limited to
136 * 5 chains.
137 */
138 extensionChains: ExtensionChain[];
139 /**
140 * Load balancing scheme shared by referenced forwarding rules.
141 * Immutable — changing it replaces the extension.
142 * @default "EXTERNAL_MANAGED"
143 */
144 loadBalancingScheme?: LbEdgeExtensionLoadBalancingScheme;
145 /**
146 * Human-readable description.
147 */
148 description?: string;
149 /**
150 * User labels. Alchemy ownership labels are merged in automatically.
151 */
152 labels?: Record<string, string>;
153};
154
155export type LbEdgeExtension = Resource<
156 "GCP.NetworkServices.LbEdgeExtension",
157 LbEdgeExtensionProps,
158 {
159 /** Full resource name `projects/{project}/locations/{location}/lbEdgeExtensions/{lbEdgeExtension}`. */
160 name: string;
161 /** LbEdgeExtension id (last path segment). */
162 lbEdgeExtensionId: string;
163 /** Project id. */
164 project: string;
165 /** Location id (`global`, `us-central1`, …). */
166 location: string;
167 /** Attached forwarding rule resource names. */
168 forwardingRules: string[];
169 /** Extension chains. */
170 extensionChains: ExtensionChain[];
171 /** Load balancing scheme. */
172 loadBalancingScheme: string | undefined;
173 /** User-provided description. */
174 description: string | undefined;
175 /** User labels (Alchemy ownership labels stripped). */
176 labels: Record<string, string>;
177 /** RFC3339 creation timestamp. */
178 createTime: string | undefined;
179 /** RFC3339 last-update timestamp. */
180 updateTime: string | undefined;
181 },
182 never,
183 Providers
184>;
185
186/**
187 * A Network Services LbEdgeExtension — a Service Extension that
188 * rewrites request headers so an Application Load Balancer can change
189 * backend selection and Cloud CDN cache keys.
190 *
191 * Changing `lbEdgeExtensionId`, `location`, or `loadBalancingScheme`
192 * replaces the extension. Description, labels, forwarding rules, and
193 * extension chains update in place.
194 *
195 * ### Creating an LbEdgeExtension
196 * **Example:** Attach to a global forwarding rule
197 * ```typescript
198 * const ext = yield* GCP.NetworkServices.LbEdgeExtension("Edge", {
199 * loadBalancingScheme: "EXTERNAL_MANAGED",
200 * forwardingRules: [rule.selfLink],
201 * extensionChains: [
202 * {
203 * name: "chain1",
204 * matchCondition: { celExpression: "true" },
205 * extensions: [
206 * {
207 * name: "ext1",
208 * authority: "ext1.example.com",
209 * service: backend.selfLink,
210 * timeout: "0.1s",
211 * supportedEvents: ["REQUEST_HEADERS"],
212 * },
213 * ],
214 * },
215 * ],
216 * });
217 * ```
218 *
219 * @resource
220 * @category NetworkServices
221 */
222export const LbEdgeExtension = Resource<LbEdgeExtension>(
223 "GCP.NetworkServices.LbEdgeExtension",
224);
225
226export const toExtension = (
227 value: ExtensionChainExtension | networkservices.ExtensionChainExtension,
228): ExtensionChainExtension => ({
229 name: value.name,
230 authority: value.authority,
231 service: value.service,
232 supportedEvents: value.supportedEvents ?? [],
233 timeout: value.timeout,
234 failOpen: value.failOpen,
235 forwardHeaders: value.forwardHeaders ?? [],
236 forwardAttributes: value.forwardAttributes ?? [],
237 metadata: value.metadata,
238 requestBodySendMode: value.requestBodySendMode,
239 responseBodySendMode: value.responseBodySendMode,
240 observabilityMode: value.observabilityMode,
241});
242
243export const toChain = (
244 value: ExtensionChain | networkservices.ExtensionChain,
245): ExtensionChain => ({
246 name: value.name,
247 matchCondition: value.matchCondition
248 ? { celExpression: value.matchCondition.celExpression }
249 : undefined,
250 extensions: (value.extensions ?? []).map(toExtension),
251});
252
253const toAttrs = (
254 extension: networkservices.LbEdgeExtension,
255 project: string,
256) => {
257 const name = extension.name ?? "";
258 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
259 return {
260 name,
261 lbEdgeExtensionId: parsed.id,
262 project: parsed.project || project,
263 location: parsed.location || DEFAULT_GLOBAL,
264 forwardingRules: extension.forwardingRules ?? [],
265 extensionChains: (extension.extensionChains ?? []).map(toChain),
266 loadBalancingScheme: extension.loadBalancingScheme,
267 description: extension.description,
268 labels: userLabels(extension.labels),
269 createTime: extension.createTime,
270 updateTime: extension.updateTime,
271 };
272};
273
274const getByName = (name: string) =>
275 networkservices
276 .getProjectsLocationsLbEdgeExtensions({ name })
277 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
278
279export const LbEdgeExtensionProvider = () =>
280 Provider.succeed(LbEdgeExtension, {
281 stables: ["name", "lbEdgeExtensionId", "project", "location", "createTime"],
282
283 diff: Effect.fn(function* ({ news, olds, output }) {
284 if (!isResolved(news)) return undefined;
285 const previousId = olds?.lbEdgeExtensionId ?? output?.lbEdgeExtensionId;
286 const nextId = news.lbEdgeExtensionId
287 ? rfc1035(news.lbEdgeExtensionId, "lb-edge-extension")
288 : previousId;
289 const previousLocation = normalizeLocation(
290 olds?.location ?? output?.location,
291 DEFAULT_GLOBAL,
292 );
293 const nextLocation = normalizeLocation(
294 news.location ?? olds?.location ?? output?.location,
295 DEFAULT_GLOBAL,
296 );
297 const previousScheme =
298 olds?.loadBalancingScheme ??
299 output?.loadBalancingScheme ??
300 DEFAULT_SCHEME;
301 const nextScheme = news.loadBalancingScheme ?? previousScheme;
302 if (
303 (previousId !== undefined &&
304 nextId !== undefined &&
305 nextId !== previousId) ||
306 previousLocation !== nextLocation ||
307 previousScheme !== nextScheme
308 ) {
309 return { action: "replace" as const };
310 }
311 return undefined;
312 }),
313
314 read: Effect.fn(function* ({ id, olds, output }) {
315 const env = yield* GcpEnvironment.current;
316 const lbEdgeExtensionId = yield* toPhysicalId(
317 id,
318 olds?.lbEdgeExtensionId,
319 output?.lbEdgeExtensionId,
320 "lb-edge-extension",
321 );
322 const location = normalizeLocation(
323 olds?.location ?? output?.location,
324 DEFAULT_GLOBAL,
325 );
326 const name =
327 output?.name ??
328 resourceName(env.project, location, COLLECTION, lbEdgeExtensionId);
329 const existing = yield* getByName(name);
330 if (existing === undefined) return undefined;
331 const attrs = toAttrs(existing, env.project);
332 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
333 ? attrs
334 : Unowned(attrs);
335 }),
336
337 list: () =>
338 Effect.gen(function* () {
339 const env = yield* GcpEnvironment.current;
340 const items = yield* collectPages(
341 networkservices.listProjectsLocationsLbEdgeExtensions.pages({
342 parent: parentOf(env.project, "-"),
343 pageSize: 1000,
344 }),
345 (page) => page.lbEdgeExtensions,
346 );
347 return items
348 .filter((item) => hasAlchemyLabelKeys(item.labels))
349 .map((item) => toAttrs(item, env.project));
350 }),
351
352 reconcile: Effect.fn(function* ({ id, news, output }) {
353 const env = yield* GcpEnvironment.current;
354 const lbEdgeExtensionId = yield* toPhysicalId(
355 id,
356 news.lbEdgeExtensionId,
357 output?.lbEdgeExtensionId,
358 "lb-edge-extension",
359 );
360 const location = normalizeLocation(
361 news.location ?? output?.location,
362 DEFAULT_GLOBAL,
363 );
364 const name = resourceName(
365 env.project,
366 location,
367 COLLECTION,
368 lbEdgeExtensionId,
369 );
370 const desiredLabels = {
371 ...toLabels(news.labels),
372 ...(yield* createInternalLabels(id)),
373 };
374 const loadBalancingScheme = news.loadBalancingScheme ?? DEFAULT_SCHEME;
375 const desiredRules = news.forwardingRules;
376 const desiredChains = news.extensionChains.map(toChain);
377
378 let current = yield* getByName(name);
379
380 if (current === undefined) {
381 const created = yield* networkservices
382 .createProjectsLocationsLbEdgeExtensions({
383 parent: parentOf(env.project, location),
384 lbEdgeExtensionId,
385 body: {
386 labels: desiredLabels,
387 description: news.description,
388 forwardingRules: desiredRules,
389 extensionChains: desiredChains,
390 loadBalancingScheme,
391 },
392 })
393 .pipe(
394 Effect.retry({
395 while: (error) => error._tag === "Conflict",
396 times: 5,
397 schedule: Schedule.spaced("2 seconds"),
398 }),
399 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
400 );
401 if (created !== undefined) {
402 yield* waitForOperation(created);
403 }
404 current = yield* waitUntilPresent(getByName(name), name);
405 }
406
407 const observedLabels = tagRecord(current.labels);
408 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
409 const labelsChanged = upsert.length > 0 || removed.length > 0;
410 const descriptionChanged =
411 (current.description ?? "") !== (news.description ?? "");
412 const rulesChanged = !sameStringList(
413 current.forwardingRules,
414 desiredRules,
415 );
416 const chainsChanged = !sameJson(
417 (current.extensionChains ?? []).map(toChain),
418 desiredChains,
419 );
420
421 const updateMask = changedFields([
422 ["labels", labelsChanged],
423 ["description", descriptionChanged],
424 ["forwardingRules", rulesChanged],
425 ["extensionChains", chainsChanged],
426 ]);
427
428 if (updateMask.length > 0) {
429 const operation =
430 yield* networkservices.patchProjectsLocationsLbEdgeExtensions({
431 name: current.name ?? name,
432 updateMask: updateMask.join(","),
433 body: {
434 name: current.name ?? name,
435 labels: desiredLabels,
436 description: news.description,
437 forwardingRules: desiredRules,
438 extensionChains: desiredChains,
439 loadBalancingScheme,
440 },
441 });
442 yield* waitForOperation(operation);
443 current = yield* waitUntilPresent(
444 getByName(current.name ?? name),
445 current.name ?? name,
446 );
447 }
448
449 return toAttrs(current, env.project);
450 }),
451
452 delete: Effect.fn(function* ({ output }) {
453 const operation = yield* networkservices
454 .deleteProjectsLocationsLbEdgeExtensions({ name: output.name })
455 .pipe(
456 Effect.retry({
457 while: (error) => error._tag === "Conflict",
458 times: 8,
459 schedule: Schedule.spaced("2 seconds"),
460 }),
461 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
462 );
463 if (operation !== undefined) {
464 yield* waitForOperation(operation, { notFoundOk: true });
465 }
466 yield* waitUntilGone(getByName(output.name), output.name);
467 }),
468 });