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 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilGone,
32 waitUntilPresent,
33} from "./internal.ts";
34
35const COLLECTION = "serviceLbPolicies";
36
37export type ServiceLbPolicyLoadBalancingAlgorithm =
38 | networkservices.ServiceLbPolicyLoadBalancingAlgorithmEnum
39 | (string & {});
40
41export type ServiceLbPolicyIsolationGranularity =
42 | networkservices.ServiceLbPolicyIsolationConfigIsolationGranularityEnum
43 | (string & {});
44
45export type ServiceLbPolicyIsolationMode =
46 | networkservices.ServiceLbPolicyIsolationConfigIsolationModeEnum
47 | (string & {});
48
49export type ServiceLbPolicyAutoCapacityDrain = {
50 /**
51 * When true, an unhealthy IG/NEG is drained. An IG/NEG is unhealthy
52 * if fewer than 25% of its endpoints are healthy. Never drains more
53 * than 50% of configured backends.
54 */
55 enable?: boolean;
56};
57
58export type ServiceLbPolicyFailoverConfig = {
59 /**
60 * Percentage of healthy endpoints below which traffic is sent to
61 * failover backends. Range 1-99. Defaults to 50 for classic global
62 * external HTTP(S) and proxyless mesh, 70 otherwise.
63 */
64 failoverHealthThreshold?: number;
65};
66
67export type ServiceLbPolicyIsolationConfig = {
68 /** Isolation granularity (`REGION`). */
69 isolationGranularity?: ServiceLbPolicyIsolationGranularity;
70 /** Isolation mode (`NEAREST` or `STRICT`). */
71 isolationMode?: ServiceLbPolicyIsolationMode;
72};
73
74export type ServiceLbPolicyProps = {
75 /**
76 * Policy id (the `{serviceLbPolicy}` segment of
77 * `projects/{project}/locations/{location}/serviceLbPolicies/{serviceLbPolicy}`).
78 * If omitted, a unique name is generated from the stack, stage, and
79 * logical id. Immutable — changing it replaces the policy.
80 */
81 serviceLbPolicyId?: string;
82 /**
83 * Location (`global`, `us-central1`, …). Immutable — changing it
84 * replaces the policy. `US-CENTRAL1` is accepted and normalized to
85 * `us-central1`.
86 * @default "global"
87 */
88 location?: string;
89 /**
90 * Human-readable description. Max 1024 characters.
91 */
92 description?: string;
93 /**
94 * Load balancing algorithm. Defaults to `WATERFALL_BY_REGION`.
95 */
96 loadBalancingAlgorithm?: ServiceLbPolicyLoadBalancingAlgorithm;
97 /**
98 * Automatically drain traffic from unhealthy instance groups or NEGs.
99 */
100 autoCapacityDrain?: ServiceLbPolicyAutoCapacityDrain;
101 /**
102 * Health-based failover threshold.
103 */
104 failoverConfig?: ServiceLbPolicyFailoverConfig;
105 /**
106 * Backend isolation for the associated backend service.
107 */
108 isolationConfig?: ServiceLbPolicyIsolationConfig;
109 /**
110 * User labels. Alchemy ownership labels are merged in automatically.
111 */
112 labels?: Record<string, string>;
113};
114
115export type ServiceLbPolicy = Resource<
116 "GCP.NetworkServices.ServiceLbPolicy",
117 ServiceLbPolicyProps,
118 {
119 /** Full resource name `projects/{project}/locations/{location}/serviceLbPolicies/{serviceLbPolicy}`. */
120 name: string;
121 /** Policy id (last path segment). */
122 serviceLbPolicyId: string;
123 /** Project id. */
124 project: string;
125 /** Location id (`global`, `us-central1`, …). */
126 location: string;
127 /** User-provided description. */
128 description: string | undefined;
129 /** Configured load balancing algorithm. */
130 loadBalancingAlgorithm: string | undefined;
131 /** Auto capacity drain settings. */
132 autoCapacityDrain: ServiceLbPolicyAutoCapacityDrain | undefined;
133 /** Failover configuration. */
134 failoverConfig: ServiceLbPolicyFailoverConfig | undefined;
135 /** Isolation configuration. */
136 isolationConfig: ServiceLbPolicyIsolationConfig | undefined;
137 /** User labels (Alchemy ownership labels stripped). */
138 labels: Record<string, string>;
139 /** RFC3339 creation timestamp. */
140 createTime: string | undefined;
141 /** RFC3339 last-update timestamp. */
142 updateTime: string | undefined;
143 },
144 never,
145 Providers
146>;
147
148/**
149 * A ServiceLbPolicy holds global load-balancing and traffic-distribution
150 * settings that can be attached to a BackendService.
151 *
152 * Changing `serviceLbPolicyId` or `location` replaces the policy.
153 * Description, labels, algorithm, drain, failover, and isolation update
154 * in place.
155 *
156 * ### Creating a ServiceLbPolicy
157 * **Example:** Generated name
158 * ```typescript
159 * const policy = yield* GCP.NetworkServices.ServiceLbPolicy("Spread", {});
160 * ```
161 *
162 * **Example:** Spray-to-region with failover
163 * ```typescript
164 * const policy = yield* GCP.NetworkServices.ServiceLbPolicy("Spread", {
165 * serviceLbPolicyId: "app-lb-policy",
166 * loadBalancingAlgorithm: "SPRAY_TO_REGION",
167 * autoCapacityDrain: { enable: true },
168 * failoverConfig: { failoverHealthThreshold: 70 },
169 * labels: { env: "prod" },
170 * });
171 * ```
172 *
173 * @resource
174 * @category NetworkServices
175 */
176export const ServiceLbPolicy = Resource<ServiceLbPolicy>(
177 "GCP.NetworkServices.ServiceLbPolicy",
178);
179
180const toDrain = (
181 drain:
182 | ServiceLbPolicyAutoCapacityDrain
183 | networkservices.ServiceLbPolicyAutoCapacityDrain
184 | undefined,
185): ServiceLbPolicyAutoCapacityDrain | undefined => {
186 if (drain === undefined) return undefined;
187 return { enable: drain.enable };
188};
189
190const toFailover = (
191 failover:
192 | ServiceLbPolicyFailoverConfig
193 | networkservices.ServiceLbPolicyFailoverConfig
194 | undefined,
195): ServiceLbPolicyFailoverConfig | undefined => {
196 if (failover === undefined) return undefined;
197 return { failoverHealthThreshold: failover.failoverHealthThreshold };
198};
199
200const toIsolation = (
201 isolation:
202 | ServiceLbPolicyIsolationConfig
203 | networkservices.ServiceLbPolicyIsolationConfig
204 | undefined,
205): ServiceLbPolicyIsolationConfig | undefined => {
206 if (isolation === undefined) return undefined;
207 return {
208 isolationGranularity: isolation.isolationGranularity,
209 isolationMode: isolation.isolationMode,
210 };
211};
212
213const toAttrs = (policy: networkservices.ServiceLbPolicy, project: string) => {
214 const name = policy.name ?? "";
215 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
216 return {
217 name,
218 serviceLbPolicyId: parsed.id,
219 project: parsed.project || project,
220 location: parsed.location || DEFAULT_GLOBAL,
221 description: policy.description,
222 loadBalancingAlgorithm: policy.loadBalancingAlgorithm,
223 autoCapacityDrain: toDrain(policy.autoCapacityDrain),
224 failoverConfig: toFailover(policy.failoverConfig),
225 isolationConfig: toIsolation(policy.isolationConfig),
226 labels: userLabels(policy.labels),
227 createTime: policy.createTime,
228 updateTime: policy.updateTime,
229 };
230};
231
232const getByName = (name: string) =>
233 networkservices
234 .getProjectsLocationsServiceLbPolicies({ name })
235 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
236
237export const ServiceLbPolicyProvider = () =>
238 Provider.succeed(ServiceLbPolicy, {
239 stables: ["name", "serviceLbPolicyId", "project", "location", "createTime"],
240
241 diff: Effect.fn(function* ({ news, olds, output }) {
242 if (!isResolved(news)) return undefined;
243 const previousId = olds?.serviceLbPolicyId ?? output?.serviceLbPolicyId;
244 const nextId = news.serviceLbPolicyId
245 ? rfc1035(news.serviceLbPolicyId, "service-lb-policy")
246 : previousId;
247 const previousLocation = normalizeLocation(
248 olds?.location ?? output?.location,
249 DEFAULT_GLOBAL,
250 );
251 const nextLocation = normalizeLocation(
252 news.location ?? olds?.location ?? output?.location,
253 DEFAULT_GLOBAL,
254 );
255 if (
256 (previousId !== undefined &&
257 nextId !== undefined &&
258 nextId !== previousId) ||
259 previousLocation !== nextLocation
260 ) {
261 return { action: "replace" as const };
262 }
263 return undefined;
264 }),
265
266 read: Effect.fn(function* ({ id, olds, output }) {
267 const env = yield* GcpEnvironment.current;
268 const serviceLbPolicyId = yield* toPhysicalId(
269 id,
270 olds?.serviceLbPolicyId,
271 output?.serviceLbPolicyId,
272 "service-lb-policy",
273 );
274 const location = normalizeLocation(
275 olds?.location ?? output?.location,
276 DEFAULT_GLOBAL,
277 );
278 const name =
279 output?.name ??
280 resourceName(env.project, location, COLLECTION, serviceLbPolicyId);
281 const existing = yield* getByName(name);
282 if (existing === undefined) return undefined;
283 const attrs = toAttrs(existing, env.project);
284 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
285 ? attrs
286 : Unowned(attrs);
287 }),
288
289 list: () =>
290 Effect.gen(function* () {
291 const env = yield* GcpEnvironment.current;
292 const items = yield* collectPages(
293 networkservices.listProjectsLocationsServiceLbPolicies.pages({
294 parent: parentOf(env.project, "-"),
295 pageSize: 1000,
296 }),
297 (page) => page.serviceLbPolicies,
298 );
299 return items
300 .filter((item) => hasAlchemyLabelKeys(item.labels))
301 .map((item) => toAttrs(item, env.project));
302 }),
303
304 reconcile: Effect.fn(function* ({ id, news, output }) {
305 const env = yield* GcpEnvironment.current;
306 const serviceLbPolicyId = yield* toPhysicalId(
307 id,
308 news.serviceLbPolicyId,
309 output?.serviceLbPolicyId,
310 "service-lb-policy",
311 );
312 const location = normalizeLocation(
313 news.location ?? output?.location,
314 DEFAULT_GLOBAL,
315 );
316 const name = resourceName(
317 env.project,
318 location,
319 COLLECTION,
320 serviceLbPolicyId,
321 );
322 const desiredLabels = {
323 ...toLabels(news.labels),
324 ...(yield* createInternalLabels(id)),
325 };
326 const desiredDrain = toDrain(news.autoCapacityDrain);
327 const desiredFailover = toFailover(news.failoverConfig);
328 const desiredIsolation = toIsolation(news.isolationConfig);
329
330 let current = yield* getByName(name);
331
332 if (current === undefined) {
333 const created = yield* networkservices
334 .createProjectsLocationsServiceLbPolicies({
335 parent: parentOf(env.project, location),
336 serviceLbPolicyId,
337 body: {
338 description: news.description,
339 labels: desiredLabels,
340 loadBalancingAlgorithm: news.loadBalancingAlgorithm,
341 autoCapacityDrain: desiredDrain,
342 failoverConfig: desiredFailover,
343 isolationConfig: desiredIsolation,
344 },
345 })
346 .pipe(
347 Effect.retry({
348 while: (error) => error._tag === "Conflict",
349 times: 5,
350 schedule: Schedule.spaced("2 seconds"),
351 }),
352 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
353 );
354 if (created !== undefined) {
355 yield* waitForOperation(created);
356 }
357 current = yield* waitUntilPresent(getByName(name), name);
358 }
359
360 const observedLabels = tagRecord(current.labels);
361 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
362 const labelsChanged = upsert.length > 0 || removed.length > 0;
363 const updateMask = changedFields([
364 ["labels", labelsChanged],
365 [
366 "description",
367 (current.description ?? "") !== (news.description ?? ""),
368 ],
369 [
370 "loadBalancingAlgorithm",
371 (current.loadBalancingAlgorithm ?? "") !==
372 (news.loadBalancingAlgorithm ?? ""),
373 ],
374 [
375 "autoCapacityDrain",
376 !sameJson(toDrain(current.autoCapacityDrain), desiredDrain),
377 ],
378 [
379 "failoverConfig",
380 !sameJson(toFailover(current.failoverConfig), desiredFailover),
381 ],
382 [
383 "isolationConfig",
384 !sameJson(toIsolation(current.isolationConfig), desiredIsolation),
385 ],
386 ]);
387
388 if (updateMask.length > 0) {
389 const operation =
390 yield* networkservices.patchProjectsLocationsServiceLbPolicies({
391 name: current.name ?? name,
392 updateMask: updateMask.join(","),
393 body: {
394 name: current.name ?? name,
395 labels: desiredLabels,
396 description: news.description,
397 loadBalancingAlgorithm: news.loadBalancingAlgorithm,
398 autoCapacityDrain: desiredDrain,
399 failoverConfig: desiredFailover,
400 isolationConfig: desiredIsolation,
401 },
402 });
403 yield* waitForOperation(operation);
404 current = yield* waitUntilPresent(
405 getByName(current.name ?? name),
406 current.name ?? name,
407 );
408 }
409
410 return toAttrs(current, env.project);
411 }),
412
413 delete: Effect.fn(function* ({ output }) {
414 const operation = yield* networkservices
415 .deleteProjectsLocationsServiceLbPolicies({ name: output.name })
416 .pipe(
417 Effect.retry({
418 while: (error) => error._tag === "Conflict",
419 times: 8,
420 schedule: Schedule.spaced("2 seconds"),
421 }),
422 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
423 );
424 if (operation !== undefined) {
425 yield* waitForOperation(operation, { notFoundOk: true });
426 }
427 yield* waitUntilGone(getByName(output.name), output.name);
428 }),
429 });