1import * as monitoring from "@distilled.cloud/gcp/monitoring_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 stripInternalLabels,
17 toLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20import {
21 compactStringMap,
22 jsonEqual,
23 lastSegment,
24 parentOf,
25 toEmptyObject,
26} from "./ownership.ts";
27
28const MAX_SERVICE_ID_LENGTH = 63;
29
30export type GkeServiceIdentifier = {
31 /** Project that owns the Kubernetes service. */
32 projectId?: string;
33 /** Parent cluster name. */
34 clusterName?: string;
35 /** Parent namespace name. */
36 namespaceName?: string;
37 /** Kubernetes service name. */
38 serviceName?: string;
39 /** Cluster location (zone or region). */
40 location?: string;
41};
42
43export type ClusterIstioIdentifier = {
44 /** Istio destination service name. */
45 serviceName?: string;
46 /** Cluster location. */
47 location?: string;
48 /** Kubernetes cluster name. */
49 clusterName?: string;
50 /** Istio destination service namespace. */
51 serviceNamespace?: string;
52};
53
54export type AppEngineIdentifier = {
55 /** App Engine module id (`module_id` on `gae_app`). */
56 moduleId?: string;
57};
58
59export type GkeNamespaceIdentifier = {
60 /** Cluster location. */
61 location?: string;
62 /** Parent cluster name. */
63 clusterName?: string;
64 /** Project that owns the namespace. */
65 projectId?: string;
66 /** Namespace name. */
67 namespaceName?: string;
68};
69
70export type BasicServiceIdentifier = {
71 /**
72 * Well-known service type (`APP_ENGINE`, `CLOUD_RUN`,
73 * `ISTIO_CANONICAL_SERVICE`, …).
74 */
75 serviceType?: string;
76 /** Labels identifying the telemetry resource. */
77 serviceLabels?: Record<string, string>;
78};
79
80export type MeshIstioIdentifier = {
81 /** Istio destination service name. */
82 serviceName?: string;
83 /** Istio destination service namespace. */
84 serviceNamespace?: string;
85 /** Mesh uid (`mesh_uid` metric label). */
86 meshUid?: string;
87};
88
89export type IstioCanonicalServiceIdentifier = {
90 /** Canonical service namespace. */
91 canonicalServiceNamespace?: string;
92 /** Canonical service name. */
93 canonicalService?: string;
94 /** Mesh uid. */
95 meshUid?: string;
96};
97
98export type CloudRunIdentifier = {
99 /** Cloud Run service name. */
100 serviceName?: string;
101 /** Region the service runs in. */
102 location?: string;
103};
104
105export type GkeWorkloadIdentifier = {
106 /** Cluster location. */
107 location?: string;
108 /** Workload kind (`Deployment`, `DaemonSet`, …). */
109 topLevelControllerType?: string;
110 /** Parent namespace name. */
111 namespaceName?: string;
112 /** Parent cluster name. */
113 clusterName?: string;
114 /** Project that owns the workload. */
115 projectId?: string;
116 /** Workload name. */
117 topLevelControllerName?: string;
118};
119
120export type CloudEndpointsIdentifier = {
121 /** Cloud Endpoints service name. */
122 service?: string;
123};
124
125export type ServiceTelemetry = {
126 /** Full resource name of the telemetry source. */
127 resourceName?: string;
128};
129
130export type ServiceProps = {
131 /**
132 * Service id (the `{service}` segment of
133 * `projects/{project}/services/{service}`). If omitted, a unique id
134 * is generated from the stack, stage, and logical id. Must match
135 * `[a-z0-9-]`. Immutable — changing it replaces the service.
136 */
137 serviceId?: string;
138 /**
139 * Human-readable display name. If omitted, the service id is used.
140 */
141 displayName?: string;
142 /**
143 * User labels. Alchemy ownership labels are merged in automatically.
144 */
145 labels?: Record<string, string>;
146 /**
147 * Custom service identifier. Used when no other identifier is set.
148 */
149 custom?: Record<string, never>;
150 /**
151 * Well-known basic service type and labels.
152 */
153 basicService?: BasicServiceIdentifier;
154 /** App Engine module identifier. */
155 appEngine?: AppEngineIdentifier;
156 /** Cloud Run identifier. */
157 cloudRun?: CloudRunIdentifier;
158 /** Cloud Endpoints identifier. */
159 cloudEndpoints?: CloudEndpointsIdentifier;
160 /** GKE Service identifier. */
161 gkeService?: GkeServiceIdentifier;
162 /** GKE Namespace identifier. */
163 gkeNamespace?: GkeNamespaceIdentifier;
164 /** GKE Workload identifier. */
165 gkeWorkload?: GkeWorkloadIdentifier;
166 /** Cluster-scoped Istio identifier. */
167 clusterIstio?: ClusterIstioIdentifier;
168 /** Mesh-scoped Istio identifier. */
169 meshIstio?: MeshIstioIdentifier;
170 /** Canonical Istio service identifier. */
171 istioCanonicalService?: IstioCanonicalServiceIdentifier;
172 /**
173 * Telemetry resource to query for this service.
174 */
175 telemetry?: ServiceTelemetry;
176};
177
178export type Service = Resource<
179 "GCP.Monitoring.Service",
180 ServiceProps,
181 {
182 /** Full resource name `projects/{project}/services/{service}`. */
183 name: string;
184 /** Service id (last path segment). */
185 serviceId: string;
186 /** Project id. */
187 project: string;
188 /** Human-readable display name. */
189 displayName: string | undefined;
190 /** User labels (Alchemy ownership labels stripped). */
191 labels: Record<string, string>;
192 /** Custom identifier, if this is a custom service. */
193 custom: Record<string, never> | undefined;
194 /** Well-known basic service identifier, if set. */
195 basicService: BasicServiceIdentifier | undefined;
196 /** App Engine identifier, if set. */
197 appEngine: AppEngineIdentifier | undefined;
198 /** Cloud Run identifier, if set. */
199 cloudRun: CloudRunIdentifier | undefined;
200 /** Cloud Endpoints identifier, if set. */
201 cloudEndpoints: CloudEndpointsIdentifier | undefined;
202 /** GKE Service identifier, if set. */
203 gkeService: GkeServiceIdentifier | undefined;
204 /** GKE Namespace identifier, if set. */
205 gkeNamespace: GkeNamespaceIdentifier | undefined;
206 /** GKE Workload identifier, if set. */
207 gkeWorkload: GkeWorkloadIdentifier | undefined;
208 /** Cluster-scoped Istio identifier, if set. */
209 clusterIstio: ClusterIstioIdentifier | undefined;
210 /** Mesh-scoped Istio identifier, if set. */
211 meshIstio: MeshIstioIdentifier | undefined;
212 /** Canonical Istio identifier, if set. */
213 istioCanonicalService: IstioCanonicalServiceIdentifier | undefined;
214 /** Telemetry resource, if set. */
215 telemetry: ServiceTelemetry | undefined;
216 },
217 never,
218 Providers
219>;
220
221/**
222 * A Cloud Monitoring service — the root resource for SLO monitoring.
223 *
224 * Custom services (`custom: {}`) are the default when no other
225 * identifier is set. Alchemy stamps ownership into `userLabels` so
226 * `list` / `pnpm nuke:gcp` can find them. Changing `serviceId` or the
227 * identifier kind replaces the service. Display name, labels, and
228 * telemetry update in place.
229 *
230 * ### Creating a Service
231 * **Example:** Generated custom service
232 * ```typescript
233 * const checkout = yield* GCP.Monitoring.Service("Checkout", {
234 * displayName: "Checkout",
235 * labels: { env: "prod" },
236 * });
237 * ```
238 *
239 * **Example:** Explicit id
240 * ```typescript
241 * const checkout = yield* GCP.Monitoring.Service("Checkout", {
242 * serviceId: "checkout",
243 * displayName: "Checkout API",
244 * });
245 * ```
246 *
247 * ### Updating a Service
248 * **Example:** Rename and relabel
249 * ```typescript
250 * const checkout = yield* GCP.Monitoring.Service("Checkout", {
251 * displayName: "Checkout v2",
252 * labels: { env: "prod", team: "payments" },
253 * });
254 * ```
255 *
256 * @resource
257 * @category Monitoring
258 */
259export const Service = Resource<Service>("GCP.Monitoring.Service");
260
261export class ServiceNotResolved extends Data.TaggedError(
262 "GCP.Monitoring.ServiceNotResolved",
263)<{
264 name: string;
265}> {}
266
267export type IdentifierKind =
268 | "custom"
269 | "basicService"
270 | "appEngine"
271 | "cloudRun"
272 | "cloudEndpoints"
273 | "gkeService"
274 | "gkeNamespace"
275 | "gkeWorkload"
276 | "clusterIstio"
277 | "meshIstio"
278 | "istioCanonicalService";
279
280const IDENTIFIER_KINDS = [
281 "basicService",
282 "appEngine",
283 "cloudRun",
284 "cloudEndpoints",
285 "gkeService",
286 "gkeNamespace",
287 "gkeWorkload",
288 "clusterIstio",
289 "meshIstio",
290 "istioCanonicalService",
291 "custom",
292] as const satisfies readonly IdentifierKind[];
293
294const identifierKindOf = (value: {
295 basicService?: unknown;
296 appEngine?: unknown;
297 cloudRun?: unknown;
298 cloudEndpoints?: unknown;
299 gkeService?: unknown;
300 gkeNamespace?: unknown;
301 gkeWorkload?: unknown;
302 clusterIstio?: unknown;
303 meshIstio?: unknown;
304 istioCanonicalService?: unknown;
305 custom?: unknown;
306}): IdentifierKind => {
307 for (const kind of IDENTIFIER_KINDS) {
308 if (value[kind] !== undefined) return kind;
309 }
310 return "custom";
311};
312
313const resourceName = (project: string, serviceId: string) =>
314 `${parentOf(project)}/services/${serviceId}`;
315
316const userFacingLabels = (
317 labels: Record<string, string | undefined> | null | undefined,
318): Record<string, string> => stripInternalLabels(tagRecord(labels));
319
320const toId = (id: string, serviceId: string | undefined, existing?: string) =>
321 Effect.gen(function* () {
322 if (serviceId !== undefined) return serviceId;
323 if (existing !== undefined) return existing;
324 const generated = yield* createPhysicalName({
325 id,
326 maxLength: MAX_SERVICE_ID_LENGTH,
327 lowercase: true,
328 });
329 return generated.replace(/-+$/g, "").slice(0, MAX_SERVICE_ID_LENGTH);
330 });
331
332const toBasicService = (
333 value: monitoring.BasicService | undefined,
334): BasicServiceIdentifier | undefined => {
335 if (value === undefined) return undefined;
336 return {
337 serviceType: value.serviceType,
338 serviceLabels: compactStringMap(value.serviceLabels),
339 };
340};
341
342const toAttrs = (service: monitoring.Service, project: string) => {
343 const name = service.name ?? "";
344 return {
345 name,
346 serviceId: lastSegment(name),
347 project,
348 displayName: service.displayName,
349 labels: userFacingLabels(service.userLabels),
350 custom: toEmptyObject(service.custom),
351 basicService: toBasicService(service.basicService),
352 appEngine: service.appEngine,
353 cloudRun: service.cloudRun,
354 cloudEndpoints: service.cloudEndpoints,
355 gkeService: service.gkeService,
356 gkeNamespace: service.gkeNamespace,
357 gkeWorkload: service.gkeWorkload,
358 clusterIstio: service.clusterIstio,
359 meshIstio: service.meshIstio,
360 istioCanonicalService: service.istioCanonicalService,
361 telemetry: service.telemetry,
362 };
363};
364
365const identifierBody = (
366 news: ServiceProps,
367): Pick<
368 monitoring.Service,
369 | "custom"
370 | "basicService"
371 | "appEngine"
372 | "cloudRun"
373 | "cloudEndpoints"
374 | "gkeService"
375 | "gkeNamespace"
376 | "gkeWorkload"
377 | "clusterIstio"
378 | "meshIstio"
379 | "istioCanonicalService"
380> => {
381 switch (identifierKindOf(news)) {
382 case "basicService":
383 return { basicService: news.basicService };
384 case "appEngine":
385 return { appEngine: news.appEngine };
386 case "cloudRun":
387 return { cloudRun: news.cloudRun };
388 case "cloudEndpoints":
389 return { cloudEndpoints: news.cloudEndpoints };
390 case "gkeService":
391 return { gkeService: news.gkeService };
392 case "gkeNamespace":
393 return { gkeNamespace: news.gkeNamespace };
394 case "gkeWorkload":
395 return { gkeWorkload: news.gkeWorkload };
396 case "clusterIstio":
397 return { clusterIstio: news.clusterIstio };
398 case "meshIstio":
399 return { meshIstio: news.meshIstio };
400 case "istioCanonicalService":
401 return { istioCanonicalService: news.istioCanonicalService };
402 default:
403 return { custom: news.custom ?? {} };
404 }
405};
406
407const getByName = (name: string) =>
408 monitoring
409 .getServices({ name })
410 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
411
412const listPages = (project: string) =>
413 monitoring.listServices
414 .pages({
415 parent: parentOf(project),
416 pageSize: 1000,
417 })
418 .pipe(
419 Stream.flatMap((page) => Stream.fromIterable(page.services ?? [])),
420 Stream.runCollect,
421 Effect.map((chunk) => Array.from(chunk)),
422 Effect.catchTag("NotFound", () => Effect.succeed([])),
423 );
424
425const listOwned = (project: string) =>
426 Effect.gen(function* () {
427 const services = yield* listPages(project);
428 return services
429 .filter((service) =>
430 Object.keys(service.userLabels ?? {}).some((key) =>
431 key.startsWith("alchemy-"),
432 ),
433 )
434 .map((service) => toAttrs(service, project));
435 });
436
437const findOwned = (project: string, id: string) =>
438 Effect.gen(function* () {
439 const services = yield* listPages(project);
440 for (const service of services) {
441 if (yield* hasAlchemyLabels(id, tagRecord(service.userLabels))) {
442 return service;
443 }
444 }
445 return undefined;
446 });
447
448const observe = (project: string, id: string, name: string | undefined) =>
449 Effect.gen(function* () {
450 if (name !== undefined) {
451 const existing = yield* getByName(name);
452 if (existing !== undefined) return existing;
453 }
454 return yield* findOwned(project, id);
455 });
456
457export const ServiceProvider = () =>
458 Provider.succeed(Service, {
459 stables: ["name", "serviceId", "project"],
460
461 diff: Effect.fn(function* ({ news, olds, output }) {
462 if (!isResolved(news)) return undefined;
463 const previousId = olds?.serviceId ?? output?.serviceId;
464 const idChanged =
465 news.serviceId !== undefined &&
466 previousId !== undefined &&
467 news.serviceId !== previousId;
468 const previousKind = identifierKindOf({
469 basicService: olds?.basicService ?? output?.basicService,
470 appEngine: olds?.appEngine ?? output?.appEngine,
471 cloudRun: olds?.cloudRun ?? output?.cloudRun,
472 cloudEndpoints: olds?.cloudEndpoints ?? output?.cloudEndpoints,
473 gkeService: olds?.gkeService ?? output?.gkeService,
474 gkeNamespace: olds?.gkeNamespace ?? output?.gkeNamespace,
475 gkeWorkload: olds?.gkeWorkload ?? output?.gkeWorkload,
476 clusterIstio: olds?.clusterIstio ?? output?.clusterIstio,
477 meshIstio: olds?.meshIstio ?? output?.meshIstio,
478 istioCanonicalService:
479 olds?.istioCanonicalService ?? output?.istioCanonicalService,
480 custom: olds?.custom ?? output?.custom ?? {},
481 });
482 const nextKind = identifierKindOf(news);
483 const kindChanged = previousKind !== nextKind;
484 if (!idChanged && !kindChanged) return undefined;
485 return {
486 action: "replace" as const,
487 deleteFirst: !idChanged,
488 };
489 }),
490
491 read: Effect.fn(function* ({ id, output }) {
492 const env = yield* GcpEnvironment.current;
493 const existing = yield* observe(env.project, id, output?.name);
494 if (existing === undefined) return undefined;
495 const attrs = toAttrs(existing, env.project);
496 return (yield* hasAlchemyLabels(id, tagRecord(existing.userLabels)))
497 ? attrs
498 : Unowned(attrs);
499 }),
500
501 list: () =>
502 Effect.gen(function* () {
503 const env = yield* GcpEnvironment.current;
504 return yield* listOwned(env.project);
505 }),
506
507 reconcile: Effect.fn(function* ({ id, news, output }) {
508 const env = yield* GcpEnvironment.current;
509 const serviceId = yield* toId(id, news.serviceId, output?.serviceId);
510 const name = resourceName(env.project, serviceId);
511 const displayName = news.displayName ?? serviceId;
512 const desiredLabels = {
513 ...toLabels(news.labels),
514 ...(yield* createInternalLabels(id)),
515 };
516
517 let current = yield* observe(env.project, id, output?.name ?? name);
518
519 const body: monitoring.Service = {
520 ...identifierBody(news),
521 displayName,
522 userLabels: desiredLabels,
523 telemetry: news.telemetry,
524 };
525
526 if (current === undefined) {
527 const created = yield* monitoring
528 .createServices({
529 parent: parentOf(env.project),
530 serviceId,
531 body,
532 })
533 .pipe(
534 Effect.catchTag("Conflict", () =>
535 getByName(name).pipe(
536 Effect.flatMap((existing) =>
537 existing !== undefined
538 ? Effect.succeed(existing)
539 : findOwned(env.project, id),
540 ),
541 ),
542 ),
543 );
544 current = created ?? undefined;
545 }
546
547 if (current === undefined) {
548 return yield* new ServiceNotResolved({ name });
549 }
550
551 const resource = current.name ?? name;
552 const observedLabels = tagRecord(current.userLabels);
553 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
554 const labelsChanged = upsert.length > 0 || removed.length > 0;
555 const displayNameChanged = (current.displayName ?? "") !== displayName;
556 const telemetryChanged =
557 news.telemetry !== undefined &&
558 !jsonEqual(current.telemetry ?? null, news.telemetry);
559
560 const updateMask = [
561 displayNameChanged ? "display_name" : undefined,
562 labelsChanged ? "user_labels" : undefined,
563 telemetryChanged ? "telemetry" : undefined,
564 ].filter((field): field is string => field !== undefined);
565
566 if (updateMask.length > 0) {
567 current = yield* monitoring.patchServices({
568 name: resource,
569 updateMask: updateMask.join(","),
570 body: {
571 ...body,
572 name: resource,
573 },
574 });
575 }
576
577 return toAttrs(current, env.project);
578 }),
579
580 delete: Effect.fn(function* ({ output }) {
581 yield* monitoring
582 .deleteServices({ name: output.name })
583 .pipe(Effect.catchTag("NotFound", () => Effect.void));
584 }),
585 });