1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import {
3 encodeDescription,
4 hasOwnershipMarker,
5 lastSegment,
6 normalizeRegion,
7 parseDescription,
8 runRegionOp,
9 toPhysicalName,
10} from "./internal.ts";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14import * as Stream from "effect/Stream";
15import { Unowned } from "../../AdoptPolicy.ts";
16import { isResolved } from "../../Diff.ts";
17import * as Provider from "../../Provider.ts";
18import { Resource } from "../../Resource.ts";
19import { GcpEnvironment } from "../Environment.ts";
20import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22
23const DEFAULT_POLICY_TYPE = "BACKEND_SERVICE_POLICY";
24const DEFAULT_MIN_HEALTHY = 1;
25const DEFAULT_HEALTHY_PERCENT = 60;
26
27export type RegionHealthAggregationPolicyType =
28 | compute.HealthAggregationPolicyPolicyTypeEnum
29 | (string & {});
30
31export type RegionHealthAggregationPolicyProps = {
32 /**
33 * Policy name (RFC1035, 1-63 characters). If omitted, a unique name is
34 * generated from the stack, stage, and logical id. Changing it replaces
35 * the policy.
36 */
37 policyName?: string;
38 /**
39 * Region the policy lives in. Immutable — changing it replaces the
40 * policy. `US-CENTRAL1` is accepted and normalized to `us-central1`.
41 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
42 */
43 region?: string;
44 /**
45 * Optional description. Health aggregation policies have no labels
46 * field, so Alchemy ownership is stored in a `[alchemy …]` prefix for
47 * `list` / nuke.
48 */
49 description?: string;
50 /**
51 * Policy type. Regional policies must be `BACKEND_SERVICE_POLICY`.
52 * Immutable — changing it replaces the policy.
53 * @default "BACKEND_SERVICE_POLICY"
54 */
55 policyType?: RegionHealthAggregationPolicyType;
56 /**
57 * Minimum number of healthy endpoints required for the aggregate to be
58 * HEALTHY. Must be positive.
59 * @default 1
60 */
61 minHealthyThreshold?: number;
62 /**
63 * Percent of healthy endpoints required for the aggregate to be
64 * HEALTHY. Range `[0, 100]`.
65 * @default 60
66 */
67 healthyPercentThreshold?: number;
68};
69
70export type RegionHealthAggregationPolicy = Resource<
71 "GCP.Compute.RegionHealthAggregationPolicy",
72 RegionHealthAggregationPolicyProps,
73 {
74 /** Policy name. */
75 policyName: string;
76 /** Project id. */
77 project: string;
78 /** Region short name (`us-central1`). */
79 region: string;
80 /** Policy type. */
81 policyType: string;
82 /** User description with the Alchemy ownership prefix stripped. */
83 description: string | undefined;
84 /** Minimum healthy endpoint count. */
85 minHealthyThreshold: number;
86 /** Healthy percent threshold. */
87 healthyPercentThreshold: number;
88 /** Optimistic-locking fingerprint. */
89 fingerprint: string | undefined;
90 /** Server-defined URL. */
91 selfLink: string | undefined;
92 /** Server-defined URL including the numeric id. */
93 selfLinkWithId: string | undefined;
94 /** Server-assigned numeric id. */
95 policyId: string | undefined;
96 /** RFC3339 creation timestamp. */
97 creationTimestamp: string | undefined;
98 /** Resource kind. */
99 kind: string | undefined;
100 },
101 never,
102 Providers
103>;
104
105/**
106 * A regional Compute Engine health aggregation policy.
107 *
108 * Health aggregation policies define how endpoint health is rolled up for
109 * backend services used by composite health checks. Type is immutable —
110 * changing it replaces the policy. Thresholds and description update in
111 * place via `regionHealthAggregationPolicies.patch`.
112 *
113 * ### Creating a Health Aggregation Policy
114 * **Example:** Generated name with defaults
115 * ```typescript
116 * const policy = yield* GCP.Compute.RegionHealthAggregationPolicy(
117 * "Agg",
118 * {},
119 * );
120 * ```
121 *
122 * **Example:** Custom thresholds
123 * ```typescript
124 * const policy = yield* GCP.Compute.RegionHealthAggregationPolicy(
125 * "Agg",
126 * {
127 * description: "backend rollup",
128 * minHealthyThreshold: 2,
129 * healthyPercentThreshold: 80,
130 * },
131 * );
132 * ```
133 *
134 * @resource
135 * @category Compute
136 */
137export const RegionHealthAggregationPolicy =
138 Resource<RegionHealthAggregationPolicy>(
139 "GCP.Compute.RegionHealthAggregationPolicy",
140 );
141
142export class RegionHealthAggregationPolicyNotResolved extends Data.TaggedError(
143 "GCP.Compute.RegionHealthAggregationPolicyNotResolved",
144)<{
145 policyName: string;
146 region: string;
147}> {}
148
149const typeOf = (value: string | undefined) =>
150 (value ?? DEFAULT_POLICY_TYPE).toUpperCase();
151
152const toAttrs = (
153 policy: compute.HealthAggregationPolicy,
154 project: string,
155): RegionHealthAggregationPolicy["Attributes"] => {
156 const parsed = parseDescription(policy.description);
157 return {
158 policyName: policy.name ?? lastSegment(policy.selfLink),
159 project,
160 region: lastSegment(policy.region).toLowerCase(),
161 policyType: typeOf(policy.policyType),
162 description: parsed.description,
163 minHealthyThreshold: policy.minHealthyThreshold ?? DEFAULT_MIN_HEALTHY,
164 healthyPercentThreshold:
165 policy.healthyPercentThreshold ?? DEFAULT_HEALTHY_PERCENT,
166 fingerprint: policy.fingerprint,
167 selfLink: policy.selfLink,
168 selfLinkWithId: policy.selfLinkWithId,
169 policyId: policy.id,
170 creationTimestamp: policy.creationTimestamp,
171 kind: policy.kind,
172 };
173};
174
175const getByName = (project: string, region: string, name: string) =>
176 compute
177 .getRegionHealthAggregationPolicies({
178 project,
179 region,
180 healthAggregationPolicy: name,
181 })
182 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
183
184const awaitResource = (project: string, region: string, policyName: string) =>
185 getByName(project, region, policyName).pipe(
186 Effect.flatMap((policy) =>
187 policy !== undefined
188 ? Effect.succeed(policy)
189 : Effect.fail(
190 new RegionHealthAggregationPolicyNotResolved({
191 policyName,
192 region,
193 }),
194 ),
195 ),
196 Effect.retry({
197 while: (error) =>
198 error._tag === "GCP.Compute.RegionHealthAggregationPolicyNotResolved",
199 times: 20,
200 schedule: Schedule.spaced("2 seconds"),
201 }),
202 );
203
204export const RegionHealthAggregationPolicyProvider = () =>
205 Provider.succeed(RegionHealthAggregationPolicy, {
206 stables: [
207 "policyName",
208 "project",
209 "region",
210 "policyType",
211 "policyId",
212 "selfLink",
213 "creationTimestamp",
214 ],
215
216 diff: Effect.fn(function* ({ news, olds, output }) {
217 if (!isResolved(news)) return undefined;
218 const env = yield* GcpEnvironment.current;
219 const previousName = olds?.policyName ?? output?.policyName;
220 const nextName = news.policyName ?? previousName;
221 const previousRegion = normalizeRegion(
222 olds?.region ?? output?.region,
223 env.region,
224 );
225 const nextRegion = normalizeRegion(
226 news.region ?? (previousRegion || env.region),
227 env.region,
228 );
229 const nameChanged =
230 previousName !== undefined &&
231 nextName !== undefined &&
232 previousName !== nextName;
233 const regionChanged =
234 previousRegion.length > 0 && previousRegion !== nextRegion;
235 const previousType = typeOf(olds?.policyType ?? output?.policyType);
236 const nextType = typeOf(news.policyType ?? output?.policyType);
237 if (nameChanged || regionChanged || previousType !== nextType) {
238 return {
239 action: "replace" as const,
240 deleteFirst:
241 !nameChanged || nextName === undefined || nextName === previousName,
242 };
243 }
244 return undefined;
245 }),
246
247 read: Effect.fn(function* ({ id, olds, output }) {
248 const env = yield* GcpEnvironment.current;
249 const policyName = yield* toPhysicalName(
250 id,
251 olds?.policyName,
252 output?.policyName,
253 "policy",
254 );
255 const region = normalizeRegion(
256 olds?.region ?? output?.region,
257 env.region,
258 );
259 const existing = yield* getByName(env.project, region, policyName);
260 if (existing === undefined) return undefined;
261 const attrs = toAttrs(existing, env.project);
262 const { labels } = parseDescription(existing.description);
263 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
264 }),
265
266 list: () =>
267 Effect.gen(function* () {
268 const env = yield* GcpEnvironment.current;
269 const pages =
270 yield* compute.aggregatedListRegionHealthAggregationPolicies
271 .pages({
272 project: env.project,
273 maxResults: 500,
274 returnPartialSuccess: true,
275 })
276 .pipe(
277 Stream.runCollect,
278 Effect.catchTag("NotFound", () => Effect.succeed([] as never[])),
279 );
280 return Array.from(
281 pages as readonly compute.HealthAggregationPolicyAggregatedList[],
282 ).flatMap((page) =>
283 Object.values(page.items ?? {}).flatMap((scoped) =>
284 (scoped?.healthAggregationPolicies ?? [])
285 .filter((item) => hasOwnershipMarker(item.description))
286 .map((item) => toAttrs(item, env.project)),
287 ),
288 );
289 }),
290
291 reconcile: Effect.fn(function* ({ id, news, output }) {
292 const env = yield* GcpEnvironment.current;
293 const policyName = yield* toPhysicalName(
294 id,
295 news.policyName,
296 output?.policyName,
297 "policy",
298 );
299 const region = normalizeRegion(news.region ?? output?.region, env.region);
300 const ownership = yield* createInternalLabels(id);
301 const desiredDescription = encodeDescription(ownership, news.description);
302 const policyType = typeOf(news.policyType);
303 const minHealthyThreshold =
304 news.minHealthyThreshold ?? DEFAULT_MIN_HEALTHY;
305 const healthyPercentThreshold =
306 news.healthyPercentThreshold ?? DEFAULT_HEALTHY_PERCENT;
307
308 let current = yield* getByName(env.project, region, policyName);
309
310 if (current === undefined) {
311 yield* runRegionOp(
312 env.project,
313 region,
314 compute.insertRegionHealthAggregationPolicies({
315 project: env.project,
316 region,
317 body: {
318 name: policyName,
319 description: desiredDescription,
320 policyType,
321 minHealthyThreshold,
322 healthyPercentThreshold,
323 },
324 }),
325 { ignoreAlreadyExists: true },
326 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
327 current = yield* awaitResource(env.project, region, policyName);
328 }
329
330 if (current === undefined) {
331 return yield* new RegionHealthAggregationPolicyNotResolved({
332 policyName,
333 region,
334 });
335 }
336
337 const patch: compute.HealthAggregationPolicy = {
338 fingerprint: current.fingerprint,
339 };
340 let dirty = false;
341 if ((current.description ?? "") !== desiredDescription) {
342 patch.description = desiredDescription;
343 dirty = true;
344 }
345 if (
346 (current.minHealthyThreshold ?? DEFAULT_MIN_HEALTHY) !==
347 minHealthyThreshold
348 ) {
349 patch.minHealthyThreshold = minHealthyThreshold;
350 dirty = true;
351 }
352 if (
353 (current.healthyPercentThreshold ?? DEFAULT_HEALTHY_PERCENT) !==
354 healthyPercentThreshold
355 ) {
356 patch.healthyPercentThreshold = healthyPercentThreshold;
357 dirty = true;
358 }
359 if (dirty) {
360 yield* runRegionOp(
361 env.project,
362 region,
363 compute.patchRegionHealthAggregationPolicies({
364 project: env.project,
365 region,
366 healthAggregationPolicy: policyName,
367 body: patch,
368 }),
369 );
370 current =
371 (yield* getByName(env.project, region, policyName)) ?? current;
372 }
373
374 return toAttrs(current, env.project);
375 }),
376
377 delete: Effect.fn(function* ({ output }) {
378 const env = yield* GcpEnvironment.current;
379 const region = normalizeRegion(output.region, env.region);
380 yield* runRegionOp(
381 env.project,
382 region,
383 compute.deleteRegionHealthAggregationPolicies({
384 project: env.project,
385 region,
386 healthAggregationPolicy: output.policyName,
387 }),
388 { ignoreNotFound: true },
389 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
390 }),
391 });