1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import {
3 encodeDescription,
4 hasOwnershipMarker,
5 lastSegment,
6 normalizeRegion,
7 parseDescription,
8 runRegionOp,
9 sameUrlList,
10 toPhysicalName,
11} from "./internal.ts";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as Schedule from "effect/Schedule";
15import * as Stream from "effect/Stream";
16import { Unowned } from "../../AdoptPolicy.ts";
17import { isResolved } from "../../Diff.ts";
18import * as Provider from "../../Provider.ts";
19import { Resource } from "../../Resource.ts";
20import { GcpEnvironment } from "../Environment.ts";
21import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
22import type { Providers } from "../Providers.ts";
23
24export type RegionCompositeHealthCheckProps = {
25 /**
26 * Health check name (RFC1035, 1-63 characters). If omitted, a unique
27 * name is generated from the stack, stage, and logical id. Changing it
28 * replaces the resource.
29 */
30 healthCheckName?: string;
31 /**
32 * Region the composite health check lives in. Immutable — changing it
33 * replaces the resource. `US-CENTRAL1` is accepted and normalized to
34 * `us-central1`.
35 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
36 */
37 region?: string;
38 /**
39 * Optional description. Composite health checks have no labels field,
40 * so Alchemy ownership is stored in a `[alchemy …]` prefix for `list` /
41 * nuke.
42 */
43 description?: string;
44 /**
45 * URL of the destination ForwardingRule. Must be regional, in the same
46 * region, and use load balancing scheme `INTERNAL` or
47 * `INTERNAL_MANAGED`.
48 */
49 healthDestination: string;
50 /**
51 * URLs of HealthSource resources whose results are AND'ed. Must have at
52 * least 1 and at most 10. Must be regional and in the same region.
53 */
54 healthSources: string[];
55};
56
57export type RegionCompositeHealthCheck = Resource<
58 "GCP.Compute.RegionCompositeHealthCheck",
59 RegionCompositeHealthCheckProps,
60 {
61 /** Health check name. */
62 healthCheckName: string;
63 /** Project id. */
64 project: string;
65 /** Region short name (`us-central1`). */
66 region: string;
67 /** User description with the Alchemy ownership prefix stripped. */
68 description: string | undefined;
69 /** Destination forwarding-rule URL. */
70 healthDestination: string | undefined;
71 /** Health source URLs. */
72 healthSources: string[];
73 /** Optimistic-locking fingerprint. */
74 fingerprint: string | undefined;
75 /** Server-defined URL. */
76 selfLink: string | undefined;
77 /** Server-defined URL including the numeric id. */
78 selfLinkWithId: string | undefined;
79 /** Server-assigned numeric id. */
80 healthCheckId: string | undefined;
81 /** RFC3339 creation timestamp. */
82 creationTimestamp: string | undefined;
83 /** Resource kind. */
84 kind: string | undefined;
85 },
86 never,
87 Providers
88>;
89
90/**
91 * A regional Compute Engine composite health check.
92 *
93 * Composite health checks AND the results of one or more HealthSource
94 * resources and publish the aggregate to a destination ForwardingRule.
95 * Name and region are immutable. Destination, sources, and description
96 * update in place via `regionCompositeHealthChecks.patch`.
97 *
98 * ### Creating a Composite Health Check
99 * **Example:** Generated name
100 * ```typescript
101 * const check = yield* GCP.Compute.RegionCompositeHealthCheck("Comp", {
102 * healthDestination: rule.selfLink,
103 * healthSources: [source.selfLink],
104 * description: "and backends",
105 * });
106 * ```
107 *
108 * **Example:** Named check
109 * ```typescript
110 * const check = yield* GCP.Compute.RegionCompositeHealthCheck("Comp", {
111 * healthCheckName: "app-composite",
112 * region: "us-central1",
113 * healthDestination: rule.selfLink,
114 * healthSources: [source.selfLink],
115 * });
116 * ```
117 *
118 * @resource
119 * @category Compute
120 */
121export const RegionCompositeHealthCheck = Resource<RegionCompositeHealthCheck>(
122 "GCP.Compute.RegionCompositeHealthCheck",
123);
124
125export class RegionCompositeHealthCheckNotResolved extends Data.TaggedError(
126 "GCP.Compute.RegionCompositeHealthCheckNotResolved",
127)<{
128 healthCheckName: string;
129 region: string;
130}> {}
131
132const toAttrs = (
133 check: compute.CompositeHealthCheck,
134 project: string,
135): RegionCompositeHealthCheck["Attributes"] => {
136 const parsed = parseDescription(check.description);
137 return {
138 healthCheckName: check.name ?? lastSegment(check.selfLink),
139 project,
140 region: lastSegment(check.region).toLowerCase(),
141 description: parsed.description,
142 healthDestination: check.healthDestination,
143 healthSources: check.healthSources ?? [],
144 fingerprint: check.fingerprint,
145 selfLink: check.selfLink,
146 selfLinkWithId: check.selfLinkWithId,
147 healthCheckId: check.id,
148 creationTimestamp: check.creationTimestamp,
149 kind: check.kind,
150 };
151};
152
153const getByName = (project: string, region: string, name: string) =>
154 compute
155 .getRegionCompositeHealthChecks({
156 project,
157 region,
158 compositeHealthCheck: name,
159 })
160 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
161
162const awaitResource = (
163 project: string,
164 region: string,
165 healthCheckName: string,
166) =>
167 getByName(project, region, healthCheckName).pipe(
168 Effect.flatMap((check) =>
169 check !== undefined
170 ? Effect.succeed(check)
171 : Effect.fail(
172 new RegionCompositeHealthCheckNotResolved({
173 healthCheckName,
174 region,
175 }),
176 ),
177 ),
178 Effect.retry({
179 while: (error) =>
180 error._tag === "GCP.Compute.RegionCompositeHealthCheckNotResolved",
181 times: 20,
182 schedule: Schedule.spaced("2 seconds"),
183 }),
184 );
185
186export const RegionCompositeHealthCheckProvider = () =>
187 Provider.succeed(RegionCompositeHealthCheck, {
188 stables: [
189 "healthCheckName",
190 "project",
191 "region",
192 "healthCheckId",
193 "selfLink",
194 "creationTimestamp",
195 ],
196
197 diff: Effect.fn(function* ({ news, olds, output }) {
198 if (!isResolved(news)) return undefined;
199 const env = yield* GcpEnvironment.current;
200 const previousName = olds?.healthCheckName ?? output?.healthCheckName;
201 const nextName = news.healthCheckName ?? previousName;
202 const previousRegion = normalizeRegion(
203 olds?.region ?? output?.region,
204 env.region,
205 );
206 const nextRegion = normalizeRegion(
207 news.region ?? (previousRegion || env.region),
208 env.region,
209 );
210 const nameChanged =
211 previousName !== undefined &&
212 nextName !== undefined &&
213 previousName !== nextName;
214 const regionChanged =
215 previousRegion.length > 0 && previousRegion !== nextRegion;
216 if (nameChanged || regionChanged) {
217 return {
218 action: "replace" as const,
219 deleteFirst:
220 !nameChanged || nextName === undefined || nextName === previousName,
221 };
222 }
223 return undefined;
224 }),
225
226 read: Effect.fn(function* ({ id, olds, output }) {
227 const env = yield* GcpEnvironment.current;
228 const healthCheckName = yield* toPhysicalName(
229 id,
230 olds?.healthCheckName,
231 output?.healthCheckName,
232 "healthcheck",
233 );
234 const region = normalizeRegion(
235 olds?.region ?? output?.region,
236 env.region,
237 );
238 const existing = yield* getByName(env.project, region, healthCheckName);
239 if (existing === undefined) return undefined;
240 const attrs = toAttrs(existing, env.project);
241 const { labels } = parseDescription(existing.description);
242 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
243 }),
244
245 list: () =>
246 Effect.gen(function* () {
247 const env = yield* GcpEnvironment.current;
248 const pages = yield* compute.aggregatedListRegionCompositeHealthChecks
249 .pages({
250 project: env.project,
251 maxResults: 500,
252 returnPartialSuccess: true,
253 })
254 .pipe(
255 Stream.runCollect,
256 Effect.catchTag("NotFound", () => Effect.succeed([] as never[])),
257 );
258 return Array.from(
259 pages as readonly compute.CompositeHealthCheckAggregatedList[],
260 ).flatMap((page) =>
261 Object.values(page.items ?? {}).flatMap((scoped) =>
262 (scoped?.compositeHealthChecks ?? [])
263 .filter((item) => hasOwnershipMarker(item.description))
264 .map((item) => toAttrs(item, env.project)),
265 ),
266 );
267 }),
268
269 reconcile: Effect.fn(function* ({ id, news, output }) {
270 const env = yield* GcpEnvironment.current;
271 const healthCheckName = yield* toPhysicalName(
272 id,
273 news.healthCheckName,
274 output?.healthCheckName,
275 "healthcheck",
276 );
277 const region = normalizeRegion(news.region ?? output?.region, env.region);
278 const ownership = yield* createInternalLabels(id);
279 const desiredDescription = encodeDescription(ownership, news.description);
280
281 let current = yield* getByName(env.project, region, healthCheckName);
282
283 if (current === undefined) {
284 yield* runRegionOp(
285 env.project,
286 region,
287 compute.insertRegionCompositeHealthChecks({
288 project: env.project,
289 region,
290 body: {
291 name: healthCheckName,
292 description: desiredDescription,
293 healthDestination: news.healthDestination,
294 healthSources: news.healthSources,
295 },
296 }),
297 { ignoreAlreadyExists: true },
298 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
299 current = yield* awaitResource(env.project, region, healthCheckName);
300 }
301
302 if (current === undefined) {
303 return yield* new RegionCompositeHealthCheckNotResolved({
304 healthCheckName,
305 region,
306 });
307 }
308
309 const patch: compute.CompositeHealthCheck = {
310 fingerprint: current.fingerprint,
311 };
312 let dirty = false;
313 if ((current.description ?? "") !== desiredDescription) {
314 patch.description = desiredDescription;
315 dirty = true;
316 }
317 if (
318 lastSegment(current.healthDestination) !==
319 lastSegment(news.healthDestination)
320 ) {
321 patch.healthDestination = news.healthDestination;
322 dirty = true;
323 }
324 if (!sameUrlList(current.healthSources, news.healthSources)) {
325 patch.healthSources = news.healthSources;
326 dirty = true;
327 }
328 if (dirty) {
329 yield* runRegionOp(
330 env.project,
331 region,
332 compute.patchRegionCompositeHealthChecks({
333 project: env.project,
334 region,
335 compositeHealthCheck: healthCheckName,
336 body: patch,
337 }),
338 );
339 current =
340 (yield* getByName(env.project, region, healthCheckName)) ?? current;
341 }
342
343 return toAttrs(current, env.project);
344 }),
345
346 delete: Effect.fn(function* ({ output }) {
347 const env = yield* GcpEnvironment.current;
348 const region = normalizeRegion(output.region, env.region);
349 yield* runRegionOp(
350 env.project,
351 region,
352 compute.deleteRegionCompositeHealthChecks({
353 project: env.project,
354 region,
355 compositeHealthCheck: output.healthCheckName,
356 }),
357 { ignoreNotFound: true },
358 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
359 }),
360 });