1import * as monitoring from "@distilled.cloud/gcp/monitoring_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 encodeDisplayName,
15 hasOwnershipMarker,
16 lastSegment,
17 parentOf,
18 parseMarker,
19} from "./ownership.ts";
20
21const MAX_DISPLAY_NAME_LENGTH = 512;
22
23export type GroupProps = {
24 /**
25 * Human-readable display name. If omitted, a unique name is generated
26 * from the stack, stage, and logical id. Groups have no labels field,
27 * so Alchemy stamps ownership into this value for `list` / nuke.
28 */
29 displayName?: string;
30 /**
31 * Monitoring filter matching member resources (for example
32 * `resource.metadata.region="us-central1"`).
33 */
34 filter: string;
35 /**
36 * Parent group resource name
37 * (`projects/{project}/groups/{group}`). Empty means a root group.
38 */
39 parentName?: string;
40 /**
41 * When true, members are treated as a cluster for extra analysis.
42 * @default false
43 */
44 isCluster?: boolean;
45};
46
47export type Group = Resource<
48 "GCP.Monitoring.Group",
49 GroupProps,
50 {
51 /** Full resource name `projects/{project}/groups/{group}`. */
52 name: string;
53 /** Server-assigned group id (last path segment). */
54 groupId: string;
55 /** Project id. */
56 project: string;
57 /** Human-readable display name (ownership marker stripped). */
58 displayName: string | undefined;
59 /** Membership filter. */
60 filter: string;
61 /** Parent group resource name, or empty for a root group. */
62 parentName: string;
63 /** Whether members are treated as a cluster. */
64 isCluster: boolean;
65 },
66 never,
67 Providers
68>;
69
70/**
71 * A Cloud Monitoring group — a dynamic collection of monitored
72 * resources selected by a filter.
73 *
74 * Group ids are assigned by the API. Groups have no labels field, so
75 * Alchemy stamps ownership into `displayName` (`[alchemy
76 * alchemy-stack=… alchemy-stage=… alchemy-id=…]`) so `list` /
77 * `pnpm nuke:gcp` can find them. Display name, filter, parent, and
78 * cluster flag update in place.
79 *
80 * ### Creating a Group
81 * **Example:** Root group of GCE instances in a region
82 * ```typescript
83 * const group = yield* GCP.Monitoring.Group("Prod", {
84 * displayName: "production instances",
85 * filter: 'resource.metadata.region="us-central1"',
86 * });
87 * ```
88 *
89 * **Example:** Nested group
90 * ```typescript
91 * const parent = yield* GCP.Monitoring.Group("Prod", {
92 * filter: 'resource.metadata.region="us-central1"',
93 * });
94 * const child = yield* GCP.Monitoring.Group("Workers", {
95 * filter: 'resource.metadata.tag="worker"',
96 * parentName: parent.name,
97 * });
98 * ```
99 *
100 * ### Updating a Group
101 * **Example:** Retarget the filter and mark as a cluster
102 * ```typescript
103 * const group = yield* GCP.Monitoring.Group("Prod", {
104 * displayName: "production instances",
105 * filter: 'resource.metadata.region="us-east1"',
106 * isCluster: true,
107 * });
108 * ```
109 *
110 * @resource
111 * @category Monitoring
112 */
113export const Group = Resource<Group>("GCP.Monitoring.Group");
114
115export class GroupNotResolved extends Data.TaggedError(
116 "GCP.Monitoring.GroupNotResolved",
117)<{
118 name: string;
119}> {}
120
121const toDisplayName = (
122 id: string,
123 displayName: string | undefined,
124 existing?: string,
125) =>
126 Effect.gen(function* () {
127 return (
128 displayName ??
129 existing ??
130 (yield* createPhysicalName({
131 id,
132 maxLength: MAX_DISPLAY_NAME_LENGTH,
133 lowercase: true,
134 }))
135 );
136 });
137
138const toAttrs = (group: monitoring.Group, project: string) => {
139 const name = group.name ?? "";
140 const parsed = parseMarker(group.displayName);
141 return {
142 name,
143 groupId: lastSegment(name),
144 project,
145 displayName: parsed.rest,
146 filter: group.filter ?? "",
147 parentName: group.parentName ?? "",
148 isCluster: group.isCluster === true,
149 };
150};
151
152const getByName = (name: string) =>
153 monitoring
154 .getProjectsGroups({ name })
155 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
156
157const listPages = (project: string) =>
158 monitoring.listProjectsGroups
159 .pages({
160 name: parentOf(project),
161 pageSize: 1000,
162 })
163 .pipe(
164 Stream.flatMap((page) => Stream.fromIterable(page.group ?? [])),
165 Stream.runCollect,
166 Effect.map((chunk) => Array.from(chunk)),
167 Effect.catchTag("NotFound", () => Effect.succeed([])),
168 );
169
170const listOwned = (project: string) =>
171 Effect.gen(function* () {
172 const groups = yield* listPages(project);
173 return groups
174 .filter((group) => hasOwnershipMarker(group.displayName))
175 .map((group) => toAttrs(group, project));
176 });
177
178const findOwned = (project: string, id: string) =>
179 Effect.gen(function* () {
180 const groups = yield* listPages(project);
181 for (const group of groups) {
182 if (yield* hasAlchemyLabels(id, parseMarker(group.displayName).labels)) {
183 return group;
184 }
185 }
186 return undefined;
187 });
188
189const observe = (project: string, id: string, name: string | undefined) =>
190 Effect.gen(function* () {
191 if (name !== undefined) {
192 const existing = yield* getByName(name);
193 if (existing !== undefined) return existing;
194 }
195 return yield* findOwned(project, id);
196 });
197
198/**
199 * Monitoring answers 409 "Too many concurrent edits to the project
200 * configuration" while another write to the project's monitoring config is
201 * in flight; it clears within seconds.
202 */
203const retryConcurrentEdits = <A, E extends { readonly _tag: string }, R>(
204 effect: Effect.Effect<A, E, R>,
205) =>
206 effect.pipe(
207 Effect.retry({
208 while: (error) => error._tag === "Conflict",
209 times: 8,
210 schedule: Schedule.exponential("500 millis"),
211 }),
212 );
213
214export const GroupProvider = () =>
215 Provider.succeed(Group, {
216 stables: ["name", "groupId", "project"],
217
218 read: Effect.fn(function* ({ id, output }) {
219 const env = yield* GcpEnvironment.current;
220 const existing = yield* observe(env.project, id, output?.name);
221 if (existing === undefined) return undefined;
222 const attrs = toAttrs(existing, env.project);
223 return (yield* hasAlchemyLabels(
224 id,
225 parseMarker(existing.displayName).labels,
226 ))
227 ? attrs
228 : Unowned(attrs);
229 }),
230
231 list: () =>
232 Effect.gen(function* () {
233 const env = yield* GcpEnvironment.current;
234 return yield* listOwned(env.project);
235 }),
236
237 reconcile: Effect.fn(function* ({ id, news, output }) {
238 const env = yield* GcpEnvironment.current;
239 const displayName = yield* toDisplayName(
240 id,
241 news.displayName,
242 output?.displayName,
243 );
244 const ownership = yield* createInternalLabels(id);
245 const encodedDisplayName = encodeDisplayName(ownership, displayName);
246 const desiredParent = news.parentName ?? "";
247 const desiredCluster = news.isCluster === true;
248
249 let current = yield* observe(env.project, id, output?.name);
250
251 if (current === undefined) {
252 const created = yield* monitoring
253 .createProjectsGroups({
254 name: parentOf(env.project),
255 body: {
256 displayName: encodedDisplayName,
257 filter: news.filter,
258 parentName: news.parentName,
259 isCluster: desiredCluster,
260 },
261 })
262 .pipe(Effect.catchTag("Conflict", () => findOwned(env.project, id)));
263 current = created ?? undefined;
264 }
265
266 if (current === undefined) {
267 return yield* new GroupNotResolved({
268 name: output?.name ?? displayName,
269 });
270 }
271
272 const name = current.name ?? output?.name ?? "";
273 const needsUpdate =
274 (current.displayName ?? "") !== encodedDisplayName ||
275 (current.filter ?? "") !== news.filter ||
276 (current.parentName ?? "") !== desiredParent ||
277 (current.isCluster === true) !== desiredCluster;
278
279 if (needsUpdate) {
280 current = yield* monitoring
281 .updateProjectsGroups({
282 name,
283 body: {
284 name,
285 displayName: encodedDisplayName,
286 filter: news.filter,
287 parentName: desiredParent,
288 isCluster: desiredCluster,
289 },
290 })
291 .pipe(retryConcurrentEdits);
292 }
293
294 return toAttrs(current, env.project);
295 }),
296
297 delete: Effect.fn(function* ({ output }) {
298 yield* monitoring
299 .deleteProjectsGroups({ name: output.name, recursive: true })
300 .pipe(
301 retryConcurrentEdits,
302 Effect.catchTag("NotFound", () => Effect.void),
303 );
304 }),
305 });