1import * as workloadmanager from "@distilled.cloud/gcp/workloadmanager_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 fieldMask,
19 fingerprint,
20 listAtLocation,
21 listLabeledPages,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 sameStringList,
28 sameText,
29 stringMap,
30 toPhysicalId,
31 userLabels,
32 waitForOperation,
33 waitUntilExists,
34 waitUntilGone,
35 waitUntilReady,
36} from "./internal.ts";
37
38export type GceInstanceFilter = {
39 /**
40 * Service accounts whose associated Compute Engine instances are
41 * included in the evaluation.
42 */
43 serviceAccounts?: string[];
44};
45
46export type ResourceFilter = {
47 /**
48 * Evaluation scopes. Format `projects/{project}`, `folders/{folder}`,
49 * or `organizations/{organization}`. Bare project ids are expanded.
50 * Defaults to the current project.
51 */
52 scopes?: string[];
53 /**
54 * Labels that must exist on a resource for it to be included
55 * (for example VM instance labels).
56 */
57 inclusionLabels?: Record<string, string>;
58 /**
59 * Filter Compute Engine instances by service account.
60 */
61 gceInstanceFilter?: GceInstanceFilter;
62 /**
63 * Resource-id patterns. A pattern of `prod-cluster` matches resources
64 * whose id contains that substring.
65 */
66 resourceIdPatterns?: string[];
67};
68
69export type BigQueryDestination = {
70 /**
71 * Destination dataset for evaluation results.
72 */
73 destinationDataset?: string;
74 /**
75 * When true, a new results table is created for each Execution.
76 */
77 createNewResultsTable?: boolean;
78};
79
80export type EvaluationType =
81 | workloadmanager.EvaluationEvaluationTypeEnum
82 | (string & {});
83
84export type EvaluationProps = {
85 /**
86 * Evaluation id (the `{evaluation}` segment of
87 * `projects/{project}/locations/{location}/evaluations/{evaluation}`).
88 * If omitted, a unique RFC1035 name is generated from the stack, stage,
89 * and logical id. Immutable — changing it replaces the evaluation.
90 */
91 evaluationId?: string;
92 /**
93 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
94 * replaces the evaluation. `US-CENTRAL1` is accepted and normalized
95 * to `us-central1`.
96 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
97 */
98 location?: string;
99 /**
100 * Human-readable description.
101 */
102 description?: string;
103 /**
104 * Scope of Cloud resources to evaluate. When omitted, the current
105 * project is used as the sole scope.
106 */
107 resourceFilter?: ResourceFilter;
108 /**
109 * Rule names applied by this evaluation. List available names with
110 * `listProjectsLocationsRules`.
111 */
112 ruleNames?: string[];
113 /**
114 * Customer-managed encryption key
115 * `projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`.
116 * Immutable — changing it replaces the evaluation.
117 */
118 kmsKey?: string;
119 /**
120 * BigQuery destination for detailed evaluation results.
121 */
122 bigQueryDestination?: BigQueryDestination;
123 /**
124 * Workload type (`SAP`, `SQL_SERVER`, `OTHER`, …).
125 */
126 evaluationType?: EvaluationType;
127 /**
128 * User labels. Alchemy ownership labels are merged in automatically.
129 */
130 labels?: Record<string, string>;
131 /**
132 * Crontab schedule. Workload Manager only accepts a fixed set of
133 * cadences (hourly, every 6 or 12 hours, daily, weekly, every 14
134 * days, monthly).
135 */
136 schedule?: string;
137 /**
138 * Cloud Storage bucket that holds custom rules.
139 */
140 customRulesBucket?: string;
141};
142
143export type Evaluation = Resource<
144 "GCP.WorkloadManager.Evaluation",
145 EvaluationProps,
146 {
147 /** Full resource name. */
148 name: string;
149 /** Evaluation id (last path segment). */
150 evaluationId: string;
151 /** Project id. */
152 project: string;
153 /** Location id. */
154 location: string;
155 /** Human-readable description. */
156 description: string | undefined;
157 /** Resource filter. */
158 resourceFilter: ResourceFilter | undefined;
159 /** Applied rule names. */
160 ruleNames: string[];
161 /** Customer-managed encryption key. */
162 kmsKey: string | undefined;
163 /** Lifecycle state of the evaluation resource. */
164 resourceStatus: string | undefined;
165 /** BigQuery destination. */
166 bigQueryDestination: BigQueryDestination | undefined;
167 /** Workload type. */
168 evaluationType: string | undefined;
169 /** User labels (Alchemy ownership labels stripped). */
170 labels: Record<string, string>;
171 /** Crontab schedule. */
172 schedule: string | undefined;
173 /** Custom-rules bucket. */
174 customRulesBucket: string | undefined;
175 /** RFC3339 creation timestamp. */
176 createTime: string | undefined;
177 /** RFC3339 last-update timestamp. */
178 updateTime: string | undefined;
179 },
180 never,
181 Providers
182>;
183
184/**
185 * A Workload Manager evaluation — a named set of best-practice rules
186 * validated against a scope of Cloud resources (SAP, SQL Server, or
187 * custom).
188 *
189 * Changing `evaluationId`, `location`, or `kmsKey` replaces the
190 * evaluation. Description, labels, resource filter, rule names,
191 * schedule, custom-rules bucket, BigQuery destination, and
192 * `evaluationType` update in place.
193 *
194 * ### Creating an Evaluation
195 * **Example:** Generated name
196 * ```typescript
197 * const evaluation = yield* GCP.WorkloadManager.Evaluation("SapBest", {
198 * evaluationType: "SAP",
199 * ruleNames: ["sap-hana"],
200 * resourceFilter: { scopes: ["projects/my-project"] },
201 * labels: { env: "test" },
202 * });
203 * ```
204 *
205 * **Example:** Explicit id
206 * ```typescript
207 * const evaluation = yield* GCP.WorkloadManager.Evaluation("SapBest", {
208 * evaluationId: "nightly-sap",
209 * evaluationType: "SAP",
210 * ruleNames: ["sap-hana"],
211 * description: "nightly SAP scan",
212 * });
213 * ```
214 *
215 * ### Updating an Evaluation
216 * **Example:** Description and labels
217 * ```typescript
218 * // Same logical id, changed props: the engine updates it in place.
219 * const evaluation = yield* GCP.WorkloadManager.Evaluation("SapBest", {
220 * evaluationType: "SAP",
221 * ruleNames: ["sap-hana"],
222 * description: "nightly SAP scan v2",
223 * labels: { env: "prod", team: "platform" },
224 * });
225 * ```
226 *
227 * @resource
228 * @category WorkloadManager
229 */
230export const Evaluation = Resource<Evaluation>(
231 "GCP.WorkloadManager.Evaluation",
232);
233
234const resourceName = (
235 project: string,
236 location: string,
237 evaluationId: string,
238) => `projects/${project}/locations/${location}/evaluations/${evaluationId}`;
239
240const expandScope = (value: string, project: string) => {
241 const trimmed = value.replace(/\/+$/, "");
242 if (trimmed.includes("/")) return trimmed;
243 return `projects/${trimmed.length > 0 ? trimmed : project}`;
244};
245
246const toFilter = (
247 filter: workloadmanager.ResourceFilter | undefined,
248): ResourceFilter | undefined =>
249 filter === undefined
250 ? undefined
251 : {
252 scopes: filter.scopes,
253 inclusionLabels: stringMap(filter.inclusionLabels),
254 gceInstanceFilter: filter.gceInstanceFilter,
255 resourceIdPatterns: filter.resourceIdPatterns,
256 };
257
258const toBigQuery = (
259 destination: workloadmanager.BigQueryDestination | undefined,
260): BigQueryDestination | undefined =>
261 destination === undefined
262 ? undefined
263 : {
264 destinationDataset: destination.destinationDataset,
265 createNewResultsTable: destination.createNewResultsTable,
266 };
267
268const desiredFilter = (
269 filter: ResourceFilter | undefined,
270 project: string,
271): workloadmanager.ResourceFilter => ({
272 scopes: (filter?.scopes === undefined || filter.scopes.length === 0
273 ? [`projects/${project}`]
274 : filter.scopes
275 ).map((scope) => expandScope(scope, project)),
276 inclusionLabels: filter?.inclusionLabels,
277 gceInstanceFilter: filter?.gceInstanceFilter,
278 resourceIdPatterns: filter?.resourceIdPatterns,
279});
280
281const toAttrs = (
282 item: workloadmanager.Evaluation,
283 project: string,
284 region: string,
285) => {
286 const name = item.name ?? "";
287 const parsed = parseName(name, "evaluations", region);
288 return {
289 name,
290 evaluationId: parsed.id,
291 project: parsed.project || project,
292 location: parsed.location,
293 description: item.description,
294 resourceFilter: toFilter(item.resourceFilter),
295 ruleNames: item.ruleNames ?? [],
296 kmsKey: item.kmsKey,
297 resourceStatus: item.resourceStatus?.state,
298 bigQueryDestination: toBigQuery(item.bigQueryDestination),
299 evaluationType: item.evaluationType,
300 labels: userLabels(item.labels),
301 schedule: item.schedule,
302 customRulesBucket: item.customRulesBucket,
303 createTime: item.createTime,
304 updateTime: item.updateTime,
305 };
306};
307
308const getByName = (name: string) =>
309 workloadmanager
310 .getProjectsLocationsEvaluations({ name })
311 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
312
313const listOwned = (project: string, region: string) =>
314 listAtLocation(project, region, (parent) =>
315 listLabeledPages(
316 workloadmanager.listProjectsLocationsEvaluations.pages({
317 parent,
318 pageSize: 1000,
319 }),
320 (page) => page.evaluations,
321 (item) => item.labels,
322 ),
323 );
324
325export const EvaluationProvider = () =>
326 Provider.succeed(Evaluation, {
327 stables: [
328 "name",
329 "evaluationId",
330 "project",
331 "location",
332 "kmsKey",
333 "createTime",
334 ],
335
336 diff: Effect.fn(function* ({ news, olds, output }) {
337 if (!isResolved(news)) return undefined;
338 const env = yield* GcpEnvironment.current;
339 return replaceOnIdentity({
340 previousId: olds?.evaluationId ?? output?.evaluationId,
341 nextId: news.evaluationId ?? olds?.evaluationId ?? output?.evaluationId,
342 previousLocation: normalizeLocation(
343 olds?.location ?? output?.location,
344 env.region,
345 ),
346 nextLocation: normalizeLocation(
347 news.location ?? olds?.location ?? output?.location,
348 env.region,
349 ),
350 extra:
351 (olds?.kmsKey ?? output?.kmsKey) !== undefined &&
352 news.kmsKey !== undefined &&
353 (olds?.kmsKey ?? output?.kmsKey) !== news.kmsKey,
354 });
355 }),
356
357 read: Effect.fn(function* ({ id, olds, output }) {
358 const env = yield* GcpEnvironment.current;
359 const evaluationId = yield* toPhysicalId(
360 id,
361 olds?.evaluationId,
362 output?.evaluationId,
363 "evaluation",
364 );
365 const location = normalizeLocation(
366 olds?.location ?? output?.location,
367 env.region,
368 );
369 const name =
370 output?.name ?? resourceName(env.project, location, evaluationId);
371 const existing = yield* getByName(name);
372 if (existing === undefined) return undefined;
373 const attrs = toAttrs(existing, env.project, env.region);
374 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
375 ? attrs
376 : Unowned(attrs);
377 }),
378
379 list: () =>
380 Effect.gen(function* () {
381 const env = yield* GcpEnvironment.current;
382 const items = yield* listOwned(env.project, env.region);
383 return items.map((item) => toAttrs(item, env.project, env.region));
384 }),
385
386 reconcile: Effect.fn(function* ({ id, news, output }) {
387 const env = yield* GcpEnvironment.current;
388 const evaluationId = yield* toPhysicalId(
389 id,
390 news.evaluationId,
391 output?.evaluationId,
392 "evaluation",
393 );
394 const location = normalizeLocation(
395 news.location ?? output?.location,
396 env.region,
397 );
398 const name = resourceName(env.project, location, evaluationId);
399 const desiredLabels = {
400 ...toLabels(news.labels),
401 ...(yield* createInternalLabels(id)),
402 };
403 const resourceFilter = desiredFilter(news.resourceFilter, env.project);
404 const ruleNames = news.ruleNames;
405 const body: workloadmanager.Evaluation = {
406 description: news.description,
407 resourceFilter,
408 ruleNames,
409 kmsKey: news.kmsKey,
410 bigQueryDestination: news.bigQueryDestination,
411 evaluationType: news.evaluationType,
412 labels: desiredLabels,
413 schedule: news.schedule,
414 customRulesBucket: news.customRulesBucket,
415 };
416
417 let current = yield* getByName(output?.name ?? name);
418
419 if (current === undefined) {
420 const created = yield* workloadmanager
421 .createProjectsLocationsEvaluations({
422 parent: parentOf(env.project, location),
423 evaluationId,
424 body,
425 })
426 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
427 if (created !== undefined) {
428 // ALREADY_EXISTS (6): a concurrent create won the race.
429 yield* waitForOperation(created).pipe(
430 Effect.catchTag("GCP.OperationFailed", (error) =>
431 error.code === 6 ? Effect.void : Effect.fail(error),
432 ),
433 );
434 }
435 current = yield* waitUntilExists(getByName(name), name);
436 }
437
438 if (current === undefined) {
439 return yield* new ResourceNotResolved({ name });
440 }
441
442 current = yield* waitUntilReady(
443 getByName(current.name ?? name),
444 current.name ?? name,
445 (item) => item.resourceStatus?.state,
446 );
447
448 const observedLabels = tagRecord(current.labels);
449 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
450 const mask = fieldMask([
451 (upsert.length > 0 || removed.length > 0) && "labels",
452 !sameText(current.description, news.description) && "description",
453 fingerprint(toFilter(current.resourceFilter)) !==
454 fingerprint(toFilter(resourceFilter)) && "resourceFilter",
455 !sameStringList(current.ruleNames, ruleNames) && "ruleNames",
456 fingerprint(toBigQuery(current.bigQueryDestination)) !==
457 fingerprint(news.bigQueryDestination) && "bigQueryDestination",
458 !sameText(current.evaluationType, news.evaluationType) &&
459 "evaluationType",
460 !sameText(current.schedule, news.schedule) && "schedule",
461 !sameText(current.customRulesBucket, news.customRulesBucket) &&
462 "customRulesBucket",
463 ]);
464
465 if (mask.length > 0) {
466 const operation =
467 yield* workloadmanager.patchProjectsLocationsEvaluations({
468 name: current.name ?? name,
469 updateMask: mask,
470 body,
471 });
472 yield* waitForOperation(operation);
473 current = yield* waitUntilReady(
474 getByName(current.name ?? name),
475 current.name ?? name,
476 (item) => item.resourceStatus?.state,
477 );
478 }
479
480 return toAttrs(current, env.project, env.region);
481 }),
482
483 delete: Effect.fn(function* ({ output }) {
484 const operation = yield* workloadmanager
485 .deleteProjectsLocationsEvaluations({
486 name: output.name,
487 force: true,
488 })
489 .pipe(
490 Effect.retry({
491 while: (error) => error._tag === "Conflict",
492 times: 8,
493 schedule: Schedule.spaced("2 seconds"),
494 }),
495 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
496 );
497 if (operation !== undefined) {
498 // NOT_FOUND (5): already gone.
499 yield* waitForOperation(operation).pipe(
500 Effect.catchTag("GCP.OperationFailed", (error) =>
501 error.code === 5 ? Effect.void : Effect.fail(error),
502 ),
503 );
504 }
505 yield* waitUntilGone(getByName(output.name), output.name);
506 }),
507 });