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
24const DEFAULT_AGGREGATION = "NO_AGGREGATION";
25
26export type RegionHealthCheckServiceAggregationPolicy =
27 | compute.HealthCheckServiceHealthStatusAggregationPolicyEnum
28 | (string & {});
29
30export type RegionHealthCheckServiceProps = {
31 /**
32 * Service name (RFC1035, 1-63 characters). If omitted, a unique name is
33 * generated from the stack, stage, and logical id. Changing it replaces
34 * the service.
35 */
36 serviceName?: string;
37 /**
38 * Region the service lives in. Immutable — changing it replaces the
39 * service. `US-CENTRAL1` is accepted and normalized to `us-central1`.
40 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
41 */
42 region?: string;
43 /**
44 * Optional description. Health check services have no labels field, so
45 * Alchemy ownership is stored in a `[alchemy …]` prefix for `list` /
46 * nuke.
47 */
48 description?: string;
49 /**
50 * Regional health-check URLs. Must have at least one and at most 10.
51 * Health checks must use `USE_SERVING_PORT` or `USE_FIXED_PORT`.
52 */
53 healthChecks: string[];
54 /**
55 * Network endpoint group URLs (at most 100). Regional NEGs must be in
56 * zones of this region.
57 */
58 networkEndpointGroups?: string[];
59 /**
60 * Notification endpoint URLs (at most 10). Must be regional and in the
61 * same region.
62 */
63 notificationEndpoints?: string[];
64 /**
65 * How results from multiple health checks for the same endpoint are
66 * aggregated (`AND` or `NO_AGGREGATION`).
67 * @default "NO_AGGREGATION"
68 */
69 healthStatusAggregationPolicy?: RegionHealthCheckServiceAggregationPolicy;
70};
71
72export type RegionHealthCheckService = Resource<
73 "GCP.Compute.RegionHealthCheckService",
74 RegionHealthCheckServiceProps,
75 {
76 /** Service name. */
77 serviceName: string;
78 /** Project id. */
79 project: string;
80 /** Region short name (`us-central1`). */
81 region: string;
82 /** User description with the Alchemy ownership prefix stripped. */
83 description: string | undefined;
84 /** Attached health-check URLs. */
85 healthChecks: string[];
86 /** Attached NEG URLs. */
87 networkEndpointGroups: string[];
88 /** Attached notification endpoint URLs. */
89 notificationEndpoints: string[];
90 /** Aggregation policy. */
91 healthStatusAggregationPolicy: string;
92 /** Optimistic-locking fingerprint. */
93 fingerprint: string | undefined;
94 /** Server-defined URL. */
95 selfLink: string | undefined;
96 /** Server-assigned numeric id. */
97 serviceId: string | undefined;
98 /** RFC3339 creation timestamp. */
99 creationTimestamp: string | undefined;
100 /** Resource kind. */
101 kind: string | undefined;
102 },
103 never,
104 Providers
105>;
106
107/**
108 * A regional Compute Engine Health Check as a Service (HCSS) resource.
109 *
110 * Health check services publish endpoint health from NEGs to notification
111 * endpoints. Name and region are immutable. Health checks, NEGs,
112 * notification endpoints, aggregation policy, and description update in
113 * place via `regionHealthCheckServices.patch`.
114 *
115 * ### Creating a Health Check Service
116 * **Example:** Generated name with a regional HTTP health check
117 * ```typescript
118 * const check = yield* GCP.Compute.RegionHealthCheck("api", {
119 * httpHealthCheck: { port: 80, portSpecification: "USE_FIXED_PORT" },
120 * });
121 * const service = yield* GCP.Compute.RegionHealthCheckService("Hcss", {
122 * healthChecks: [check.selfLink],
123 * description: "endpoint health",
124 * });
125 * ```
126 *
127 * **Example:** AND aggregation
128 * ```typescript
129 * const service = yield* GCP.Compute.RegionHealthCheckService("Hcss", {
130 * healthChecks: [check.selfLink],
131 * healthStatusAggregationPolicy: "AND",
132 * });
133 * ```
134 *
135 * @resource
136 * @category Compute
137 */
138export const RegionHealthCheckService = Resource<RegionHealthCheckService>(
139 "GCP.Compute.RegionHealthCheckService",
140);
141
142export class RegionHealthCheckServiceNotResolved extends Data.TaggedError(
143 "GCP.Compute.RegionHealthCheckServiceNotResolved",
144)<{
145 serviceName: string;
146 region: string;
147}> {}
148
149const aggregationOf = (value: string | undefined) =>
150 (value ?? DEFAULT_AGGREGATION).toUpperCase();
151
152const toAttrs = (
153 service: compute.HealthCheckService,
154 project: string,
155): RegionHealthCheckService["Attributes"] => {
156 const parsed = parseDescription(service.description);
157 return {
158 serviceName: service.name ?? lastSegment(service.selfLink),
159 project,
160 region: lastSegment(service.region).toLowerCase(),
161 description: parsed.description,
162 healthChecks: service.healthChecks ?? [],
163 networkEndpointGroups: service.networkEndpointGroups ?? [],
164 notificationEndpoints: service.notificationEndpoints ?? [],
165 healthStatusAggregationPolicy: aggregationOf(
166 service.healthStatusAggregationPolicy,
167 ),
168 fingerprint: service.fingerprint,
169 selfLink: service.selfLink,
170 serviceId: service.id,
171 creationTimestamp: service.creationTimestamp,
172 kind: service.kind,
173 };
174};
175
176const getByName = (project: string, region: string, name: string) =>
177 compute
178 .getRegionHealthCheckServices({
179 project,
180 region,
181 healthCheckService: name,
182 })
183 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
184
185const awaitResource = (project: string, region: string, serviceName: string) =>
186 getByName(project, region, serviceName).pipe(
187 Effect.flatMap((service) =>
188 service !== undefined
189 ? Effect.succeed(service)
190 : Effect.fail(
191 new RegionHealthCheckServiceNotResolved({ serviceName, region }),
192 ),
193 ),
194 Effect.retry({
195 while: (error) =>
196 error._tag === "GCP.Compute.RegionHealthCheckServiceNotResolved",
197 times: 8,
198 schedule: Schedule.spaced("1 second"),
199 }),
200 );
201
202export const RegionHealthCheckServiceProvider = () =>
203 Provider.succeed(RegionHealthCheckService, {
204 stables: [
205 "serviceName",
206 "project",
207 "region",
208 "serviceId",
209 "selfLink",
210 "creationTimestamp",
211 ],
212
213 diff: Effect.fn(function* ({ news, olds, output }) {
214 if (!isResolved(news)) return undefined;
215 const env = yield* GcpEnvironment.current;
216 const previousName = olds?.serviceName ?? output?.serviceName;
217 const nextName = news.serviceName ?? previousName;
218 const previousRegion = normalizeRegion(
219 olds?.region ?? output?.region,
220 env.region,
221 );
222 const nextRegion = normalizeRegion(
223 news.region ?? (previousRegion || env.region),
224 env.region,
225 );
226 const nameChanged =
227 previousName !== undefined &&
228 nextName !== undefined &&
229 previousName !== nextName;
230 const regionChanged =
231 previousRegion.length > 0 && previousRegion !== nextRegion;
232 if (nameChanged || regionChanged) {
233 return {
234 action: "replace" as const,
235 deleteFirst:
236 !nameChanged || nextName === undefined || nextName === previousName,
237 };
238 }
239 return undefined;
240 }),
241
242 read: Effect.fn(function* ({ id, olds, output }) {
243 const env = yield* GcpEnvironment.current;
244 const serviceName = yield* toPhysicalName(
245 id,
246 olds?.serviceName,
247 output?.serviceName,
248 "service",
249 );
250 const region = normalizeRegion(
251 olds?.region ?? output?.region,
252 env.region,
253 );
254 const existing = yield* getByName(env.project, region, serviceName);
255 if (existing === undefined) return undefined;
256 const attrs = toAttrs(existing, env.project);
257 const { labels } = parseDescription(existing.description);
258 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
259 }),
260
261 list: () =>
262 Effect.gen(function* () {
263 const env = yield* GcpEnvironment.current;
264 const pages = yield* compute.aggregatedListRegionHealthCheckServices
265 .pages({
266 project: env.project,
267 maxResults: 500,
268 returnPartialSuccess: true,
269 })
270 .pipe(
271 Stream.runCollect,
272 Effect.catchTag("NotFound", () => Effect.succeed([] as never[])),
273 );
274 return Array.from(
275 pages as readonly compute.HealthCheckServiceAggregatedList[],
276 ).flatMap((page) =>
277 Object.values(page.items ?? {}).flatMap((scoped) =>
278 (scoped?.resources ?? [])
279 .filter((item) => hasOwnershipMarker(item.description))
280 .map((item) => toAttrs(item, env.project)),
281 ),
282 );
283 }),
284
285 reconcile: Effect.fn(function* ({ id, news, output }) {
286 const env = yield* GcpEnvironment.current;
287 const serviceName = yield* toPhysicalName(
288 id,
289 news.serviceName,
290 output?.serviceName,
291 "service",
292 );
293 const region = normalizeRegion(news.region ?? output?.region, env.region);
294 const ownership = yield* createInternalLabels(id);
295 const desiredDescription = encodeDescription(ownership, news.description);
296 const aggregation = aggregationOf(news.healthStatusAggregationPolicy);
297 const healthChecks = news.healthChecks;
298 const networkEndpointGroups = news.networkEndpointGroups ?? [];
299 const notificationEndpoints = news.notificationEndpoints ?? [];
300
301 let current = yield* getByName(env.project, region, serviceName);
302
303 if (current === undefined) {
304 yield* runRegionOp(
305 env.project,
306 region,
307 compute.insertRegionHealthCheckServices({
308 project: env.project,
309 region,
310 body: {
311 name: serviceName,
312 description: desiredDescription,
313 healthChecks,
314 networkEndpointGroups:
315 networkEndpointGroups.length > 0
316 ? networkEndpointGroups
317 : undefined,
318 notificationEndpoints:
319 notificationEndpoints.length > 0
320 ? notificationEndpoints
321 : undefined,
322 healthStatusAggregationPolicy: aggregation,
323 },
324 }),
325 { ignoreAlreadyExists: true },
326 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
327 current = yield* awaitResource(env.project, region, serviceName);
328 }
329
330 if (current === undefined) {
331 return yield* new RegionHealthCheckServiceNotResolved({
332 serviceName,
333 region,
334 });
335 }
336
337 const patch: compute.HealthCheckService = {
338 fingerprint: current.fingerprint,
339 };
340 let dirty = false;
341 if ((current.description ?? "") !== desiredDescription) {
342 patch.description = desiredDescription;
343 dirty = true;
344 }
345 if (!sameUrlList(current.healthChecks, healthChecks)) {
346 patch.healthChecks = healthChecks;
347 dirty = true;
348 }
349 if (
350 news.networkEndpointGroups !== undefined &&
351 !sameUrlList(current.networkEndpointGroups, networkEndpointGroups)
352 ) {
353 patch.networkEndpointGroups = networkEndpointGroups;
354 dirty = true;
355 }
356 if (
357 news.notificationEndpoints !== undefined &&
358 !sameUrlList(current.notificationEndpoints, notificationEndpoints)
359 ) {
360 patch.notificationEndpoints = notificationEndpoints;
361 dirty = true;
362 }
363 if (
364 aggregationOf(current.healthStatusAggregationPolicy) !== aggregation
365 ) {
366 patch.healthStatusAggregationPolicy = aggregation;
367 dirty = true;
368 }
369 if (dirty) {
370 yield* runRegionOp(
371 env.project,
372 region,
373 compute.patchRegionHealthCheckServices({
374 project: env.project,
375 region,
376 healthCheckService: serviceName,
377 body: patch,
378 }),
379 );
380 current =
381 (yield* getByName(env.project, region, serviceName)) ?? current;
382 }
383
384 return toAttrs(current, env.project);
385 }),
386
387 delete: Effect.fn(function* ({ output }) {
388 const env = yield* GcpEnvironment.current;
389 const region = normalizeRegion(output.region, env.region);
390 yield* runRegionOp(
391 env.project,
392 region,
393 compute.deleteRegionHealthCheckServices({
394 project: env.project,
395 region,
396 healthCheckService: output.serviceName,
397 }),
398 { ignoreNotFound: true },
399 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
400 }),
401 });