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_SOURCE_TYPE = "BACKEND_SERVICE";
25
26export type RegionHealthSourceType =
27 | compute.HealthSourceSourceTypeEnum
28 | (string & {});
29
30export type RegionHealthSourceProps = {
31 /**
32 * Source 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 source.
35 */
36 sourceName?: string;
37 /**
38 * Region the source lives in. Immutable — changing it replaces the
39 * source. `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 sources have no labels field, so Alchemy
45 * ownership is stored in a `[alchemy …]` prefix for `list` / nuke.
46 */
47 description?: string;
48 /**
49 * Source type. The only allowed value is `BACKEND_SERVICE`. Immutable —
50 * changing it replaces the source.
51 * @default "BACKEND_SERVICE"
52 */
53 sourceType?: RegionHealthSourceType;
54 /**
55 * URLs to the source resources. Must be size 1. Must be a regional
56 * BackendService with load balancing scheme `INTERNAL` or
57 * `INTERNAL_MANAGED` in the same region.
58 */
59 sources: string[];
60 /**
61 * URL of the regional HealthAggregationPolicy in the same region.
62 */
63 healthAggregationPolicy: string;
64};
65
66export type RegionHealthSource = Resource<
67 "GCP.Compute.RegionHealthSource",
68 RegionHealthSourceProps,
69 {
70 /** Source name. */
71 sourceName: string;
72 /** Project id. */
73 project: string;
74 /** Region short name (`us-central1`). */
75 region: string;
76 /** Source type. */
77 sourceType: string;
78 /** User description with the Alchemy ownership prefix stripped. */
79 description: string | undefined;
80 /** Source resource URLs. */
81 sources: string[];
82 /** Health aggregation policy URL. */
83 healthAggregationPolicy: string | undefined;
84 /** Optimistic-locking fingerprint. */
85 fingerprint: string | undefined;
86 /** Server-defined URL. */
87 selfLink: string | undefined;
88 /** Server-defined URL including the numeric id. */
89 selfLinkWithId: string | undefined;
90 /** Server-assigned numeric id. */
91 sourceId: string | undefined;
92 /** RFC3339 creation timestamp. */
93 creationTimestamp: string | undefined;
94 /** Resource kind. */
95 kind: string | undefined;
96 },
97 never,
98 Providers
99>;
100
101/**
102 * A regional Compute Engine health source.
103 *
104 * A health source names the backend resources whose health is aggregated
105 * by a HealthAggregationPolicy (used by composite health checks). Type is
106 * immutable. Sources, aggregation policy, and description update in place
107 * via `regionHealthSources.patch`.
108 *
109 * ### Creating a Health Source
110 * **Example:** Backend-service source
111 * ```typescript
112 * const policy = yield* GCP.Compute.RegionHealthAggregationPolicy(
113 * "Agg",
114 * {},
115 * );
116 * const backend = yield* GCP.Compute.RegionBackendService("Web", {
117 * protocol: "TCP",
118 * loadBalancingScheme: "INTERNAL",
119 * });
120 * const source = yield* GCP.Compute.RegionHealthSource("Src", {
121 * sources: [backend.selfLink],
122 * healthAggregationPolicy: policy.selfLink,
123 * });
124 * ```
125 *
126 * **Example:** Named source
127 * ```typescript
128 * const source = yield* GCP.Compute.RegionHealthSource("Src", {
129 * sourceName: "web-health",
130 * sources: [backend.selfLink],
131 * healthAggregationPolicy: policy.selfLink,
132 * description: "internal backends",
133 * });
134 * ```
135 *
136 * @resource
137 * @category Compute
138 */
139export const RegionHealthSource = Resource<RegionHealthSource>(
140 "GCP.Compute.RegionHealthSource",
141);
142
143export class RegionHealthSourceNotResolved extends Data.TaggedError(
144 "GCP.Compute.RegionHealthSourceNotResolved",
145)<{
146 sourceName: string;
147 region: string;
148}> {}
149
150const typeOf = (value: string | undefined) =>
151 (value ?? DEFAULT_SOURCE_TYPE).toUpperCase();
152
153const toBackendServiceUrl = (
154 project: string,
155 region: string,
156 value: string | undefined,
157) => {
158 if (value === undefined || value.length === 0) return undefined;
159 if (value.includes("/backendServices/")) return value;
160 return `projects/${project}/regions/${region}/backendServices/${lastSegment(value)}`;
161};
162
163const toHealthAggregationPolicyUrl = (
164 project: string,
165 region: string,
166 value: string | undefined,
167) => {
168 if (value === undefined || value.length === 0) return undefined;
169 if (value.includes("/healthAggregationPolicies/")) return value;
170 return `projects/${project}/regions/${region}/healthAggregationPolicies/${lastSegment(value)}`;
171};
172
173const toAttrs = (
174 source: compute.HealthSource,
175 project: string,
176): RegionHealthSource["Attributes"] => {
177 const parsed = parseDescription(source.description);
178 return {
179 sourceName: source.name ?? lastSegment(source.selfLink),
180 project,
181 region: lastSegment(source.region).toLowerCase(),
182 sourceType: typeOf(source.sourceType),
183 description: parsed.description,
184 sources: source.sources ?? [],
185 healthAggregationPolicy: source.healthAggregationPolicy,
186 fingerprint: source.fingerprint,
187 selfLink: source.selfLink,
188 selfLinkWithId: source.selfLinkWithId,
189 sourceId: source.id,
190 creationTimestamp: source.creationTimestamp,
191 kind: source.kind,
192 };
193};
194
195const getByName = (project: string, region: string, name: string) =>
196 compute
197 .getRegionHealthSources({ project, region, healthSource: name })
198 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
199
200const awaitResource = (project: string, region: string, sourceName: string) =>
201 getByName(project, region, sourceName).pipe(
202 Effect.flatMap((source) =>
203 source !== undefined
204 ? Effect.succeed(source)
205 : Effect.fail(
206 new RegionHealthSourceNotResolved({ sourceName, region }),
207 ),
208 ),
209 Effect.retry({
210 while: (error) =>
211 error._tag === "GCP.Compute.RegionHealthSourceNotResolved",
212 times: 8,
213 schedule: Schedule.spaced("1 second"),
214 }),
215 );
216
217export const RegionHealthSourceProvider = () =>
218 Provider.succeed(RegionHealthSource, {
219 stables: [
220 "sourceName",
221 "project",
222 "region",
223 "sourceType",
224 "sourceId",
225 "selfLink",
226 "creationTimestamp",
227 ],
228
229 diff: Effect.fn(function* ({ news, olds, output }) {
230 if (!isResolved(news)) return undefined;
231 const env = yield* GcpEnvironment.current;
232 const previousName = olds?.sourceName ?? output?.sourceName;
233 const nextName = news.sourceName ?? previousName;
234 const previousRegion = normalizeRegion(
235 olds?.region ?? output?.region,
236 env.region,
237 );
238 const nextRegion = normalizeRegion(
239 news.region ?? (previousRegion || env.region),
240 env.region,
241 );
242 const nameChanged =
243 previousName !== undefined &&
244 nextName !== undefined &&
245 previousName !== nextName;
246 const regionChanged =
247 previousRegion.length > 0 && previousRegion !== nextRegion;
248 const previousType = typeOf(olds?.sourceType ?? output?.sourceType);
249 const nextType = typeOf(news.sourceType ?? output?.sourceType);
250 if (nameChanged || regionChanged || previousType !== nextType) {
251 return {
252 action: "replace" as const,
253 deleteFirst:
254 !nameChanged || nextName === undefined || nextName === previousName,
255 };
256 }
257 return undefined;
258 }),
259
260 read: Effect.fn(function* ({ id, olds, output }) {
261 const env = yield* GcpEnvironment.current;
262 const sourceName = yield* toPhysicalName(
263 id,
264 olds?.sourceName,
265 output?.sourceName,
266 "source",
267 );
268 const region = normalizeRegion(
269 olds?.region ?? output?.region,
270 env.region,
271 );
272 const existing = yield* getByName(env.project, region, sourceName);
273 if (existing === undefined) return undefined;
274 const attrs = toAttrs(existing, env.project);
275 const { labels } = parseDescription(existing.description);
276 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
277 }),
278
279 list: () =>
280 Effect.gen(function* () {
281 const env = yield* GcpEnvironment.current;
282 const pages = yield* compute.aggregatedListRegionHealthSources
283 .pages({
284 project: env.project,
285 maxResults: 500,
286 returnPartialSuccess: true,
287 })
288 .pipe(
289 Stream.runCollect,
290 Effect.catchTag("NotFound", () => Effect.succeed([] as never[])),
291 );
292 return Array.from(
293 pages as readonly compute.HealthSourceAggregatedList[],
294 ).flatMap((page) =>
295 Object.values(page.items ?? {}).flatMap((scoped) =>
296 (scoped?.healthSources ?? [])
297 .filter((item) => hasOwnershipMarker(item.description))
298 .map((item) => toAttrs(item, env.project)),
299 ),
300 );
301 }),
302
303 reconcile: Effect.fn(function* ({ id, news, output }) {
304 const env = yield* GcpEnvironment.current;
305 const sourceName = yield* toPhysicalName(
306 id,
307 news.sourceName,
308 output?.sourceName,
309 "source",
310 );
311 const region = normalizeRegion(news.region ?? output?.region, env.region);
312 const ownership = yield* createInternalLabels(id);
313 const desiredDescription = encodeDescription(ownership, news.description);
314 const sourceType = typeOf(news.sourceType);
315 const sources = news.sources.flatMap((source) => {
316 const url = toBackendServiceUrl(env.project, region, source);
317 return url === undefined ? [] : [url];
318 });
319 const healthAggregationPolicy =
320 toHealthAggregationPolicyUrl(
321 env.project,
322 region,
323 news.healthAggregationPolicy,
324 ) ?? news.healthAggregationPolicy;
325
326 let current = yield* getByName(env.project, region, sourceName);
327
328 if (current === undefined) {
329 yield* runRegionOp(
330 env.project,
331 region,
332 compute.insertRegionHealthSources({
333 project: env.project,
334 region,
335 body: {
336 name: sourceName,
337 description: desiredDescription,
338 sourceType,
339 sources,
340 healthAggregationPolicy,
341 },
342 }),
343 { ignoreAlreadyExists: true },
344 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
345 current = yield* awaitResource(env.project, region, sourceName);
346 }
347
348 if (current === undefined) {
349 return yield* new RegionHealthSourceNotResolved({
350 sourceName,
351 region,
352 });
353 }
354
355 const patch: compute.HealthSource = {
356 fingerprint: current.fingerprint,
357 };
358 let dirty = false;
359 if ((current.description ?? "") !== desiredDescription) {
360 patch.description = desiredDescription;
361 dirty = true;
362 }
363 if (!sameUrlList(current.sources, sources)) {
364 patch.sources = sources;
365 dirty = true;
366 }
367 if (
368 lastSegment(current.healthAggregationPolicy) !==
369 lastSegment(healthAggregationPolicy)
370 ) {
371 patch.healthAggregationPolicy = healthAggregationPolicy;
372 dirty = true;
373 }
374 if (dirty) {
375 yield* runRegionOp(
376 env.project,
377 region,
378 compute.patchRegionHealthSources({
379 project: env.project,
380 region,
381 healthSource: sourceName,
382 body: patch,
383 }),
384 );
385 current =
386 (yield* getByName(env.project, region, sourceName)) ?? current;
387 }
388
389 return toAttrs(current, env.project);
390 }),
391
392 delete: Effect.fn(function* ({ output }) {
393 const env = yield* GcpEnvironment.current;
394 const region = normalizeRegion(output.region, env.region);
395 yield* runRegionOp(
396 env.project,
397 region,
398 compute.deleteRegionHealthSources({
399 project: env.project,
400 region,
401 healthSource: output.sourceName,
402 }),
403 { ignoreNotFound: true },
404 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
405 }),
406 });