1import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
2import * as orgpolicy from "@distilled.cloud/gcp/orgpolicy_v2";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 alchemyLabelKeys,
14 createInternalLabels,
15 hasAlchemyLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18
19const CUSTOM_PREFIX = "custom.";
20const COLLECTION = "/customConstraints/";
21const DESCRIPTION_MAX = 2000;
22const GENERATED_ID_MAX = 60;
23
24export type CustomConstraintMethodType =
25 | "CREATE"
26 | "UPDATE"
27 | "DELETE"
28 | "REMOVE_GRANT"
29 | "GOVERN_TAGS";
30
31export type CustomConstraintActionType = "ALLOW" | "DENY";
32
33export type CustomConstraintProps = {
34 /**
35 * Constraint id (the `{customConstraint}` segment of
36 * `organizations/{organization}/customConstraints/{customConstraint}`).
37 * Must start with `custom.` and contain only letters, digits, and
38 * periods. If omitted, a unique id is generated from the stack, stage,
39 * and logical id. Immutable — changing it replaces the constraint.
40 */
41 constraintId?: string;
42 /**
43 * Parent organization (`organizations/{organization}` or the numeric
44 * id). Defaults to the project's Resource Manager ancestor. Immutable — changing it replaces the constraint.
45 */
46 organization?: string;
47 /**
48 * Resource types this constraint applies to, e.g.
49 * `compute.googleapis.com/Instance`. Immutable — changing the set
50 * replaces the constraint.
51 */
52 resourceTypes: string[];
53 /**
54 * Operations the constraint evaluates (`CREATE`, `UPDATE`, `DELETE`,
55 * `REMOVE_GRANT`, `GOVERN_TAGS`).
56 */
57 methodTypes: CustomConstraintMethodType[];
58 /**
59 * CEL condition evaluated against the resource. Max 1000 characters.
60 */
61 condition: string;
62 /**
63 * Whether matching resources are allowed or denied.
64 */
65 actionType: CustomConstraintActionType;
66 /**
67 * One-line display name (max 200 characters).
68 */
69 displayName?: string;
70 /**
71 * Human-readable description (max 2000 characters including the
72 * Alchemy ownership prefix). Custom constraints have no labels, so
73 * ownership is stored in a `[alchemy …]` prefix and stripped from
74 * attributes.
75 */
76 description?: string;
77};
78
79export type CustomConstraint = Resource<
80 "GCP.OrgPolicy.CustomConstraint",
81 CustomConstraintProps,
82 {
83 /** Full resource name `organizations/{organization}/customConstraints/{id}`. */
84 name: string;
85 /** Constraint id (last path segment, including the `custom.` prefix). */
86 constraintId: string;
87 /** Parent `organizations/{organization}`. */
88 organization: string;
89 /** Numeric organization id. */
90 organizationId: string;
91 /** Project id of the deploying stack. */
92 project: string;
93 /** Resource types this constraint applies to. */
94 resourceTypes: string[];
95 /** Operations the constraint evaluates. */
96 methodTypes: string[];
97 /** CEL condition. */
98 condition: string;
99 /** `ALLOW` or `DENY`. */
100 actionType: string;
101 /** One-line display name. */
102 displayName: string | undefined;
103 /** User description with the Alchemy ownership prefix stripped. */
104 description: string | undefined;
105 /** RFC3339 last-update timestamp. */
106 updateTime: string | undefined;
107 },
108 never,
109 Providers
110>;
111
112/**
113 * A Google Cloud Organization Policy custom constraint.
114 *
115 * Custom constraints can only be created on an organization. Creating one
116 * does not enforce anything — attach a `GCP.OrgPolicy.Policy` that
117 * references the constraint id to enforce it.
118 *
119 * Custom constraints have no labels. Alchemy stamps ownership into the
120 * description (`[alchemy alchemy-stack=… alchemy-stage=… alchemy-id=…]`)
121 * so `list` / `pnpm nuke:gcp` can find them.
122 *
123 * `constraintId`, `organization`, and `resourceTypes` are immutable —
124 * changing them replaces the constraint. `methodTypes`, `condition`,
125 * `actionType`, `displayName`, and `description` update in place.
126 *
127 * ### Creating a Custom Constraint
128 * **Example:** Deny matching Compute instances
129 * ```typescript
130 * const constraint = yield* GCP.OrgPolicy.CustomConstraint("NoTestVms", {
131 * resourceTypes: ["compute.googleapis.com/Instance"],
132 * methodTypes: ["CREATE"],
133 * condition: "resource.name.startsWith('test-')",
134 * actionType: "DENY",
135 * displayName: "Deny test VMs",
136 * });
137 * ```
138 *
139 * **Example:** Named constraint on an explicit organization
140 * ```typescript
141 * const constraint = yield* GCP.OrgPolicy.CustomConstraint("NoTestVms", {
142 * constraintId: "custom.denyTestVms",
143 * organization: "organizations/123456789",
144 * resourceTypes: ["compute.googleapis.com/Instance"],
145 * methodTypes: ["CREATE", "UPDATE"],
146 * condition: "resource.name.startsWith('test-')",
147 * actionType: "DENY",
148 * displayName: "Deny test VMs",
149 * description: "blocks test-prefixed instances",
150 * });
151 * ```
152 *
153 * ### Updating a Custom Constraint
154 * **Example:** Broaden methods and change the condition
155 * ```typescript
156 * const constraint = yield* GCP.OrgPolicy.CustomConstraint("NoTestVms", {
157 * constraintId: "custom.denyTestVms",
158 * resourceTypes: ["compute.googleapis.com/Instance"],
159 * methodTypes: ["CREATE", "UPDATE"],
160 * condition: "resource.name.startsWith('tmp-')",
161 * actionType: "DENY",
162 * displayName: "Deny tmp VMs",
163 * });
164 * ```
165 *
166 * @resource
167 * @category OrgPolicy
168 */
169export const CustomConstraint = Resource<CustomConstraint>(
170 "GCP.OrgPolicy.CustomConstraint",
171);
172
173export class CustomConstraintNotResolved extends Data.TaggedError(
174 "GCP.OrgPolicy.CustomConstraintNotResolved",
175)<{
176 name: string;
177}> {}
178
179export class OrganizationRequired extends Data.TaggedError(
180 "GCP.OrgPolicy.OrganizationRequired",
181)<{
182 project: string;
183}> {}
184
185const lastSegment = (value: string) => {
186 const trimmed = value.replace(/\/+$/, "");
187 const parts = trimmed.split("/");
188 return parts[parts.length - 1] || trimmed;
189};
190
191const organizationParent = (value: string) =>
192 value.startsWith("organizations/")
193 ? value
194 : `organizations/${lastSegment(value)}`;
195
196const constraintIdOf = (value: string) => {
197 const at = value.indexOf(COLLECTION);
198 return at >= 0 ? value.slice(at + COLLECTION.length) : lastSegment(value);
199};
200
201const normalizeConstraintId = (value: string) => {
202 const id = constraintIdOf(value);
203 return id.startsWith(CUSTOM_PREFIX) ? id : `${CUSTOM_PREFIX}${id}`;
204};
205
206const resourceName = (organization: string, constraintId: string) =>
207 `${organization}${COLLECTION}${constraintId}`;
208
209const parseName = (name: string) => {
210 const at = name.indexOf(COLLECTION);
211 if (at < 0) {
212 return { organization: "", constraintId: normalizeConstraintId(name) };
213 }
214 return {
215 organization: name.slice(0, at),
216 constraintId: name.slice(at + COLLECTION.length),
217 };
218};
219
220const sameOrganization = (
221 left: string | undefined,
222 right: string | undefined,
223) => {
224 if (left === undefined || right === undefined) return left === right;
225 return organizationParent(left) === organizationParent(right);
226};
227
228const sorted = (values: readonly string[] | undefined) =>
229 [...(values ?? [])].slice().sort();
230
231const sameStringList = (
232 left: readonly string[] | undefined,
233 right: readonly string[] | undefined,
234) => JSON.stringify(sorted(left)) === JSON.stringify(sorted(right));
235
236const parentOfResource = (name: string) =>
237 name.startsWith("projects/")
238 ? resourcemanager.getProjects({ name }).pipe(
239 Effect.map((resource) => resource.parent),
240 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
241 )
242 : name.startsWith("folders/")
243 ? resourcemanager.getFolders({ name }).pipe(
244 Effect.map((folder) => folder.parent),
245 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
246 )
247 : Effect.succeed(undefined);
248
249const tryResolveOrganization = (explicit?: string) =>
250 Effect.gen(function* () {
251 if (explicit !== undefined && explicit.length > 0) {
252 return organizationParent(explicit);
253 }
254 const env = yield* GcpEnvironment.current;
255 let current: string | undefined = `projects/${env.project}`;
256 for (let i = 0; i < 8; i++) {
257 if (current === undefined) return undefined;
258 if (current.startsWith("organizations/")) return current;
259 current = yield* parentOfResource(current);
260 }
261 return undefined;
262 });
263
264const resolveOrganization = (
265 explicit: string | undefined,
266 existing: string | undefined,
267) =>
268 Effect.gen(function* () {
269 const resolved = yield* tryResolveOrganization(explicit ?? existing);
270 if (resolved === undefined) {
271 const env = yield* GcpEnvironment.current;
272 return yield* new OrganizationRequired({ project: env.project });
273 }
274 return resolved;
275 });
276
277const toConstraintId = (
278 id: string,
279 explicit: string | undefined,
280 existing: string | undefined,
281) =>
282 Effect.gen(function* () {
283 if (explicit !== undefined) return normalizeConstraintId(explicit);
284 if (existing !== undefined) return existing;
285 const generated = yield* createPhysicalName({
286 id,
287 maxLength: GENERATED_ID_MAX,
288 lowercase: true,
289 delimiter: ".",
290 });
291 const body = generated.replace(/[^a-z0-9.]/g, "").replace(/^\.+|\.+$/g, "");
292 return `${CUSTOM_PREFIX}${body.length > 0 ? body : "c"}`;
293 });
294
295const encodeDescription = (
296 labels: Record<string, string>,
297 description: string | undefined,
298): string => {
299 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
300 if (!description) return marker.slice(0, DESCRIPTION_MAX);
301 const sep = "\n";
302 const budget = DESCRIPTION_MAX - marker.length - sep.length;
303 if (budget <= 0) return marker.slice(0, DESCRIPTION_MAX);
304 return `${marker}${sep}${description.slice(0, budget)}`;
305};
306
307const parseDescription = (
308 description: string | undefined,
309): {
310 labels: Record<string, string>;
311 description: string | undefined;
312} => {
313 if (!description?.startsWith("[alchemy ")) {
314 return { labels: {}, description };
315 }
316 const end = description.indexOf("]");
317 if (end < 0) return { labels: {}, description };
318 const labels: Record<string, string> = {};
319 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
320 const eq = part.indexOf("=");
321 if (eq > 0) {
322 labels[part.slice(0, eq)] = part.slice(eq + 1);
323 }
324 }
325 const rest = description.slice(end + 1).replace(/^\n/, "");
326 return { labels, description: rest.length > 0 ? rest : undefined };
327};
328
329const hasOwnershipMarker = (description: string | undefined) =>
330 Object.keys(parseDescription(description).labels).some((key) =>
331 key.startsWith("alchemy-"),
332 );
333
334const toAttrs = (
335 constraint: orgpolicy.GoogleCloudOrgpolicyV2CustomConstraint,
336 project: string,
337) => {
338 const name = constraint.name ?? "";
339 const parsedName = parseName(name);
340 const parsed = parseDescription(constraint.description);
341 return {
342 name,
343 constraintId: parsedName.constraintId,
344 organization: parsedName.organization,
345 organizationId: lastSegment(parsedName.organization),
346 project,
347 resourceTypes: [...(constraint.resourceTypes ?? [])],
348 methodTypes: [...(constraint.methodTypes ?? [])],
349 condition: constraint.condition ?? "",
350 actionType: constraint.actionType ?? "",
351 displayName: constraint.displayName,
352 description: parsed.description,
353 updateTime: constraint.updateTime,
354 };
355};
356
357const getByName = (name: string) =>
358 name.length === 0
359 ? Effect.succeed(undefined)
360 : orgpolicy
361 .getOrganizationsCustomConstraints({ name })
362 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
363
364const listCustomConstraints = (parent: string) =>
365 Effect.gen(function* () {
366 const found: orgpolicy.GoogleCloudOrgpolicyV2CustomConstraint[] = [];
367 let pageToken: string | undefined;
368 do {
369 const response = yield* orgpolicy.listOrganizationsCustomConstraints({
370 parent,
371 pageSize: 1000,
372 pageToken,
373 });
374 found.push(...(response.customConstraints ?? []));
375 pageToken = response.nextPageToken;
376 } while (pageToken !== undefined && pageToken !== "");
377 return found;
378 }).pipe(
379 Effect.catchTag("NotFound", () =>
380 Effect.succeed([] as orgpolicy.GoogleCloudOrgpolicyV2CustomConstraint[]),
381 ),
382 );
383
384const toBody = (
385 name: string,
386 news: CustomConstraintProps,
387 description: string,
388 resourceTypes: readonly string[],
389): orgpolicy.GoogleCloudOrgpolicyV2CustomConstraint => ({
390 name,
391 resourceTypes: [...resourceTypes],
392 methodTypes: [...news.methodTypes],
393 condition: news.condition,
394 actionType: news.actionType,
395 displayName: news.displayName,
396 description,
397});
398
399export const CustomConstraintProvider = () =>
400 Provider.succeed(CustomConstraint, {
401 stables: [
402 "name",
403 "constraintId",
404 "organization",
405 "organizationId",
406 "project",
407 ],
408
409 diff: Effect.fn(function* ({ news, olds, output }) {
410 if (!isResolved(news)) return undefined;
411 const previousId = olds?.constraintId ?? output?.constraintId;
412 const nextId =
413 news.constraintId !== undefined
414 ? normalizeConstraintId(news.constraintId)
415 : previousId;
416 const idChanged =
417 previousId !== undefined &&
418 nextId !== undefined &&
419 previousId !== nextId;
420
421 const previousOrg = olds?.organization ?? output?.organization;
422 const orgChanged =
423 news.organization !== undefined &&
424 previousOrg !== undefined &&
425 !sameOrganization(news.organization, previousOrg);
426
427 const previousTypes = olds?.resourceTypes ?? output?.resourceTypes;
428 const typesChanged =
429 news.resourceTypes !== undefined &&
430 previousTypes !== undefined &&
431 !sameStringList(news.resourceTypes, previousTypes);
432
433 if (!idChanged && !orgChanged && !typesChanged) return undefined;
434 return {
435 action: "replace" as const,
436 deleteFirst: !idChanged && !orgChanged,
437 };
438 }),
439
440 read: Effect.fn(function* ({ id, olds, output }) {
441 const env = yield* GcpEnvironment.current;
442 const organization = yield* tryResolveOrganization(
443 olds?.organization ?? output?.organization,
444 );
445 const constraintId = yield* toConstraintId(
446 id,
447 olds?.constraintId,
448 output?.constraintId,
449 );
450 const name =
451 output?.name ??
452 (organization !== undefined
453 ? resourceName(organization, constraintId)
454 : "");
455 const existing = yield* getByName(name);
456 if (existing === undefined) return undefined;
457 const attrs = toAttrs(existing, env.project);
458 const { labels } = parseDescription(existing.description);
459 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
460 }),
461
462 list: () =>
463 Effect.gen(function* () {
464 const env = yield* GcpEnvironment.current;
465 const organization = yield* tryResolveOrganization();
466 if (organization === undefined) return [];
467 const constraints = yield* listCustomConstraints(organization);
468 return constraints
469 .filter((constraint) => hasOwnershipMarker(constraint.description))
470 .map((constraint) => toAttrs(constraint, env.project));
471 }),
472
473 reconcile: Effect.fn(function* ({ id, news, output }) {
474 const env = yield* GcpEnvironment.current;
475 const organization = yield* resolveOrganization(
476 news.organization,
477 output?.organization,
478 );
479 const constraintId = yield* toConstraintId(
480 id,
481 news.constraintId,
482 output?.constraintId,
483 );
484 const name = output?.name ?? resourceName(organization, constraintId);
485 const ownership = yield* createInternalLabels(id);
486 const desiredDescription = encodeDescription(ownership, news.description);
487 const desiredTypes = [...news.resourceTypes];
488
489 let current = yield* getByName(name);
490
491 if (current === undefined) {
492 const created = yield* orgpolicy
493 .createOrganizationsCustomConstraints({
494 parent: organization,
495 body: toBody(
496 resourceName(organization, constraintId),
497 news,
498 desiredDescription,
499 desiredTypes,
500 ),
501 })
502 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
503 current = created ?? undefined;
504 }
505
506 if (current === undefined) {
507 return yield* new CustomConstraintNotResolved({ name });
508 }
509
510 const methodsChanged = !sameStringList(
511 current.methodTypes,
512 news.methodTypes,
513 );
514 const conditionChanged = (current.condition ?? "") !== news.condition;
515 const actionChanged = (current.actionType ?? "") !== news.actionType;
516 const displayChanged =
517 (current.displayName ?? "") !== (news.displayName ?? "");
518 const descriptionChanged =
519 (current.description ?? "") !== desiredDescription;
520
521 if (
522 methodsChanged ||
523 conditionChanged ||
524 actionChanged ||
525 displayChanged ||
526 descriptionChanged
527 ) {
528 current = yield* orgpolicy
529 .patchOrganizationsCustomConstraints({
530 name: current.name ?? name,
531 body: toBody(
532 current.name ?? name,
533 news,
534 desiredDescription,
535 current.resourceTypes ?? desiredTypes,
536 ),
537 })
538 .pipe(
539 Effect.retry({
540 while: (error) => error._tag === "Conflict",
541 times: 5,
542 schedule: Schedule.exponential("200 millis"),
543 }),
544 );
545 }
546
547 return toAttrs(current, env.project);
548 }),
549
550 delete: Effect.fn(function* ({ output }) {
551 yield* orgpolicy
552 .deleteOrganizationsCustomConstraints({ name: output.name })
553 .pipe(Effect.catchTag("NotFound", () => Effect.void));
554 }),
555 });