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 type {
18 ExtensionChain,
19 ExtensionChainExtension,
20 ExtensionChainMatchCondition,
21} from "./LbEdgeExtension.ts";
22import {
23 canonicalizeLink,
24 changedFields,
25 collectPages,
26 hasAlchemyLabelKeys,
27 normalizeLocation,
28 parentOf,
29 parseName,
30 resourceName,
31 rfc1035,
32 sameJson,
33 sameStringList,
34 toPhysicalId,
35 userLabels,
36 waitForOperation,
37 waitUntilGone,
38 waitUntilPresent,
39} from "./internal.ts";
40
41const COLLECTION = "lbTrafficExtensions";
42
43export type LbTrafficExtensionLoadBalancingScheme =
44 | networkservices.LbTrafficExtensionLoadBalancingSchemeEnum
45 | (string & {});
46
47export type ExtensionEvent =
48 | networkservices.ExtensionChainExtensionSupportedEventsItemEnum
49 | (string & {});
50
51export type ExtensionBodySendMode =
52 | networkservices.ExtensionChainExtensionRequestBodySendModeEnum
53 | (string & {});
54
55export type {
56 ExtensionChain,
57 ExtensionChainExtension,
58 ExtensionChainMatchCondition,
59};
60
61export type LbTrafficExtensionProps = {
62 /**
63 * Extension id (the `{lbTrafficExtension}` segment of
64 * `projects/{project}/locations/{location}/lbTrafficExtensions/{lbTrafficExtension}`).
65 * If omitted, a unique name is generated from the stack, stage, and
66 * logical id. Immutable — changing it replaces the extension.
67 */
68 lbTrafficExtensionId?: string;
69 /**
70 * Location matching the forwarding rules (`global` or a region).
71 * Immutable — changing it replaces the extension. `US-CENTRAL1` is
72 * accepted and normalized to `us-central1`.
73 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
74 */
75 location?: string;
76 /**
77 * Human-readable description.
78 */
79 description?: string;
80 /**
81 * Forwarding-rule resource names this extension attaches to. At least
82 * one is required. A forwarding rule may have only one traffic
83 * extension.
84 */
85 forwardingRules: string[];
86 /**
87 * Ordered extension chains. The first matching chain runs. Limited to
88 * 5 chains.
89 */
90 extensionChains: ExtensionChain[];
91 /**
92 * Load balancing scheme shared by every referenced backend and
93 * forwarding rule (`INTERNAL_MANAGED` or `EXTERNAL_MANAGED`).
94 * Immutable — changing it replaces the extension.
95 */
96 loadBalancingScheme: LbTrafficExtensionLoadBalancingScheme;
97 /**
98 * Resource-level metadata included in `ProcessingRequest`. Invalid
99 * when any chain uses a plugin extension.
100 */
101 metadata?: Record<string, unknown>;
102 /**
103 * User labels. Alchemy ownership labels are merged in automatically.
104 */
105 labels?: Record<string, string>;
106};
107
108export type LbTrafficExtension = Resource<
109 "GCP.NetworkServices.LbTrafficExtension",
110 LbTrafficExtensionProps,
111 {
112 /** Full resource name `projects/{project}/locations/{location}/lbTrafficExtensions/{lbTrafficExtension}`. */
113 name: string;
114 /** Extension id (last path segment). */
115 lbTrafficExtensionId: string;
116 /** Project id. */
117 project: string;
118 /** Location id (`global`, `us-central1`, …). */
119 location: string;
120 /** User-provided description. */
121 description: string | undefined;
122 /** Attached forwarding-rule resource names. */
123 forwardingRules: string[];
124 /** Configured extension chains. */
125 extensionChains: ExtensionChain[];
126 /** Load balancing scheme. */
127 loadBalancingScheme: string | undefined;
128 /** Resource-level metadata. */
129 metadata: Record<string, unknown> | undefined;
130 /** User labels (Alchemy ownership labels stripped). */
131 labels: Record<string, string>;
132 /** RFC3339 creation timestamp. */
133 createTime: string | undefined;
134 /** RFC3339 last-update timestamp. */
135 updateTime: string | undefined;
136 },
137 never,
138 Providers
139>;
140
141/**
142 * An LbTrafficExtension lets a Service Extension modify request and
143 * response headers and payloads without changing backend selection.
144 *
145 * Changing `lbTrafficExtensionId`, `location`, or `loadBalancingScheme`
146 * replaces the resource. Description, labels, forwarding rules, chains,
147 * and metadata update in place.
148 *
149 * ### Creating an LbTrafficExtension
150 * **Example:** Callout on an internal managed load balancer
151 * ```typescript
152 * const extension = yield* GCP.NetworkServices.LbTrafficExtension("Inspect", {
153 * location: "us-central1",
154 * loadBalancingScheme: "INTERNAL_MANAGED",
155 * forwardingRules: [rule.selfLink ?? rule.name],
156 * extensionChains: [
157 * {
158 * name: "all-traffic",
159 * matchCondition: { celExpression: "true" },
160 * extensions: [
161 * {
162 * name: "header-rewriter",
163 * service: callout.selfLink ?? callout.name,
164 * authority: "ext.example.com",
165 * timeout: "0.1s",
166 * failOpen: true,
167 * supportedEvents: ["REQUEST_HEADERS", "RESPONSE_HEADERS"],
168 * },
169 * ],
170 * },
171 * ],
172 * });
173 * ```
174 *
175 * @resource
176 * @category NetworkServices
177 */
178export const LbTrafficExtension = Resource<LbTrafficExtension>(
179 "GCP.NetworkServices.LbTrafficExtension",
180);
181
182const toExtension = (
183 extension: ExtensionChainExtension | networkservices.ExtensionChainExtension,
184): ExtensionChainExtension => ({
185 name: extension.name,
186 authority: extension.authority,
187 service: extension.service ? canonicalizeLink(extension.service) : undefined,
188 supportedEvents: [...(extension.supportedEvents ?? [])],
189 timeout: extension.timeout,
190 failOpen: extension.failOpen,
191 forwardHeaders: [...(extension.forwardHeaders ?? [])],
192 forwardAttributes: [...(extension.forwardAttributes ?? [])],
193 metadata: extension.metadata
194 ? { ...(extension.metadata as Record<string, unknown>) }
195 : undefined,
196 requestBodySendMode: extension.requestBodySendMode,
197 responseBodySendMode: extension.responseBodySendMode,
198 observabilityMode: extension.observabilityMode,
199});
200
201const toChain = (
202 chain: ExtensionChain | networkservices.ExtensionChain,
203): ExtensionChain => ({
204 name: chain.name,
205 matchCondition: chain.matchCondition
206 ? { celExpression: chain.matchCondition.celExpression }
207 : undefined,
208 extensions: (chain.extensions ?? []).map(toExtension),
209});
210
211const toForwardingRules = (rules: readonly string[] | undefined) =>
212 (rules ?? []).map((rule) => canonicalizeLink(rule)).filter(Boolean);
213
214const toAttrs = (
215 extension: networkservices.LbTrafficExtension,
216 project: string,
217 region: string,
218) => {
219 const name = extension.name ?? "";
220 const parsed = parseName(name, COLLECTION, region);
221 return {
222 name,
223 lbTrafficExtensionId: parsed.id,
224 project: parsed.project || project,
225 location: parsed.location || region,
226 description: extension.description,
227 forwardingRules: toForwardingRules(extension.forwardingRules),
228 extensionChains: (extension.extensionChains ?? []).map(toChain),
229 loadBalancingScheme: extension.loadBalancingScheme,
230 metadata: extension.metadata
231 ? { ...(extension.metadata as Record<string, unknown>) }
232 : undefined,
233 labels: userLabels(extension.labels),
234 createTime: extension.createTime,
235 updateTime: extension.updateTime,
236 };
237};
238
239const getByName = (name: string) =>
240 networkservices
241 .getProjectsLocationsLbTrafficExtensions({ name })
242 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
243
244export const LbTrafficExtensionProvider = () =>
245 Provider.succeed(LbTrafficExtension, {
246 stables: [
247 "name",
248 "lbTrafficExtensionId",
249 "project",
250 "location",
251 "loadBalancingScheme",
252 "createTime",
253 ],
254
255 diff: Effect.fn(function* ({ news, olds, output }) {
256 if (!isResolved(news)) return undefined;
257 const env = yield* GcpEnvironment.current;
258 const previousId =
259 olds?.lbTrafficExtensionId ?? output?.lbTrafficExtensionId;
260 const nextId = news.lbTrafficExtensionId
261 ? rfc1035(news.lbTrafficExtensionId, "lb-traffic-extension")
262 : previousId;
263 const previousLocation = normalizeLocation(
264 olds?.location ?? output?.location,
265 env.region,
266 );
267 const nextLocation = normalizeLocation(
268 news.location ?? olds?.location ?? output?.location,
269 env.region,
270 );
271 const previousScheme = (
272 olds?.loadBalancingScheme ??
273 output?.loadBalancingScheme ??
274 ""
275 ).toUpperCase();
276 const nextScheme = news.loadBalancingScheme.toUpperCase();
277 if (
278 (previousId !== undefined &&
279 nextId !== undefined &&
280 nextId !== previousId) ||
281 previousLocation !== nextLocation ||
282 (previousScheme.length > 0 && previousScheme !== nextScheme)
283 ) {
284 return { action: "replace" as const };
285 }
286 return undefined;
287 }),
288
289 read: Effect.fn(function* ({ id, olds, output }) {
290 const env = yield* GcpEnvironment.current;
291 const lbTrafficExtensionId = yield* toPhysicalId(
292 id,
293 olds?.lbTrafficExtensionId,
294 output?.lbTrafficExtensionId,
295 "lb-traffic-extension",
296 );
297 const location = normalizeLocation(
298 olds?.location ?? output?.location,
299 env.region,
300 );
301 const name =
302 output?.name ??
303 resourceName(env.project, location, COLLECTION, lbTrafficExtensionId);
304 const existing = yield* getByName(name);
305 if (existing === undefined) return undefined;
306 const attrs = toAttrs(existing, env.project, env.region);
307 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
308 ? attrs
309 : Unowned(attrs);
310 }),
311
312 list: () =>
313 Effect.gen(function* () {
314 const env = yield* GcpEnvironment.current;
315 const items = yield* collectPages(
316 networkservices.listProjectsLocationsLbTrafficExtensions.pages({
317 parent: parentOf(env.project, "-"),
318 pageSize: 1000,
319 }),
320 (page) => page.lbTrafficExtensions,
321 );
322 return items
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 lbTrafficExtensionId = yield* toPhysicalId(
330 id,
331 news.lbTrafficExtensionId,
332 output?.lbTrafficExtensionId,
333 "lb-traffic-extension",
334 );
335 const location = normalizeLocation(
336 news.location ?? output?.location,
337 env.region,
338 );
339 const name = resourceName(
340 env.project,
341 location,
342 COLLECTION,
343 lbTrafficExtensionId,
344 );
345 const desiredLabels = {
346 ...toLabels(news.labels),
347 ...(yield* createInternalLabels(id)),
348 };
349 const desiredRules = toForwardingRules(news.forwardingRules);
350 const desiredChains = news.extensionChains.map(toChain);
351 const desiredMetadata = news.metadata ? { ...news.metadata } : undefined;
352
353 let current = yield* getByName(name);
354
355 if (current === undefined) {
356 const created = yield* networkservices
357 .createProjectsLocationsLbTrafficExtensions({
358 parent: parentOf(env.project, location),
359 lbTrafficExtensionId,
360 body: {
361 description: news.description,
362 labels: desiredLabels,
363 forwardingRules: desiredRules,
364 extensionChains: desiredChains,
365 loadBalancingScheme: news.loadBalancingScheme,
366 metadata: desiredMetadata,
367 },
368 })
369 .pipe(
370 Effect.retry({
371 while: (error) => error._tag === "Conflict",
372 times: 5,
373 schedule: Schedule.spaced("2 seconds"),
374 }),
375 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
376 );
377 if (created !== undefined) {
378 yield* waitForOperation(created);
379 }
380 current = yield* waitUntilPresent(getByName(name), name);
381 }
382
383 const observed = toAttrs(current, env.project, env.region);
384 const observedLabels = tagRecord(current.labels);
385 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
386 const labelsChanged = upsert.length > 0 || removed.length > 0;
387 const updateMask = changedFields([
388 ["labels", labelsChanged],
389 [
390 "description",
391 (current.description ?? "") !== (news.description ?? ""),
392 ],
393 [
394 "forwardingRules",
395 !sameStringList(observed.forwardingRules, desiredRules),
396 ],
397 ["extensionChains", !sameJson(observed.extensionChains, desiredChains)],
398 ["metadata", !sameJson(observed.metadata, desiredMetadata)],
399 ]);
400
401 if (updateMask.length > 0) {
402 const operation =
403 yield* networkservices.patchProjectsLocationsLbTrafficExtensions({
404 name: current.name ?? name,
405 updateMask: updateMask.join(","),
406 body: {
407 name: current.name ?? name,
408 labels: desiredLabels,
409 description: news.description,
410 forwardingRules: desiredRules,
411 extensionChains: desiredChains,
412 loadBalancingScheme: news.loadBalancingScheme,
413 metadata: desiredMetadata,
414 },
415 });
416 yield* waitForOperation(operation);
417 current = yield* waitUntilPresent(
418 getByName(current.name ?? name),
419 current.name ?? name,
420 );
421 }
422
423 return toAttrs(current, env.project, env.region);
424 }),
425
426 delete: Effect.fn(function* ({ output }) {
427 const operation = yield* networkservices
428 .deleteProjectsLocationsLbTrafficExtensions({ name: output.name })
429 .pipe(
430 Effect.retry({
431 while: (error) => error._tag === "Conflict",
432 times: 8,
433 schedule: Schedule.spaced("2 seconds"),
434 }),
435 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
436 );
437 if (operation !== undefined) {
438 yield* waitForOperation(operation, { notFoundOk: true });
439 }
440 yield* waitUntilGone(getByName(output.name), output.name);
441 }),
442 });