1import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 DEFAULT_LIEN_ORIGIN,
13 DEFAULT_LIEN_RESTRICTIONS,
14 LIEN_ORIGIN_MAX,
15 LIEN_REASON_MAX,
16 collectPages,
17 projectNumberOf,
18 projectParent,
19 sameStringList,
20} from "./internal.ts";
21
22export type LienProps = {
23 /**
24 * Resource this lien attaches to, e.g. `projects/{project}`. Defaults
25 * to the current project (numeric form). Immutable — changing it
26 * replaces the lien.
27 */
28 parent?: string;
29 /**
30 * Origin of the lien (max 200 characters). Programmatic identifier
31 * for the system that created it.
32 * @default "alchemy.effect"
33 */
34 origin?: string;
35 /**
36 * User-visible reason the restriction exists (max 200 characters).
37 * @default "Protected by a lien."
38 */
39 reason?: string;
40 /**
41 * IAM permissions this lien blocks. An empty list is rejected.
42 * @default ["resourcemanager.projects.delete"]
43 */
44 restrictions?: string[];
45};
46
47export type Lien = Resource<
48 "GCP.ResourceManager.Lien",
49 LienProps,
50 {
51 /** Resource name `liens/{lien_id}`. */
52 name: string;
53 /** Parent resource this lien is attached to. */
54 parent: string;
55 /** Origin identifier. */
56 origin: string;
57 /** Reason shown when a restricted operation is refused. */
58 reason: string | undefined;
59 /** Blocked IAM permissions. */
60 restrictions: string[];
61 /** RFC3339 creation timestamp. */
62 createTime: string | undefined;
63 /** Project id used to default `parent`. */
64 project: string;
65 },
66 never,
67 Providers
68>;
69
70/**
71 * A Cloud Resource Manager lien — an encumbrance that blocks selected
72 * operations on a project (most commonly project deletion).
73 *
74 * Liens have no update API, id, or labels: the lien on `parent` with the
75 * same origin, reason, and restrictions is the managed one, and `list` /
76 * `pnpm nuke:gcp` return liens whose `origin` is `alchemy.effect`. Changing `parent`,
77 * `origin`, `reason`, or `restrictions` replaces the lien (delete-first).
78 *
79 * ### Creating a Lien
80 * **Example:** Block deletion of the current project
81 * ```typescript
82 * const lien = yield* GCP.ResourceManager.Lien("Hold", {
83 * reason: "production API key",
84 * });
85 * ```
86 *
87 * **Example:** Explicit parent and restrictions
88 * ```typescript
89 * const lien = yield* GCP.ResourceManager.Lien("Hold", {
90 * parent: "projects/123456789",
91 * origin: "alchemy.effect",
92 * reason: "holds billing export",
93 * restrictions: ["resourcemanager.projects.delete"],
94 * });
95 * ```
96 *
97 * @resource
98 * @category ResourceManager
99 */
100export const Lien = Resource<Lien>("GCP.ResourceManager.Lien");
101
102export class LienNotResolved extends Data.TaggedError(
103 "GCP.ResourceManager.LienNotResolved",
104)<{
105 name: string;
106}> {}
107
108export class LienStillExists extends Data.TaggedError(
109 "GCP.ResourceManager.LienStillExists",
110)<{
111 name: string;
112}> {}
113
114const unique = (values: string[]) => [
115 ...new Set(values.filter((value) => value.length > 0)),
116];
117
118const parentAliases = (
119 parent: string,
120 project: string,
121 projectNumber: string,
122) => {
123 const normalized = projectParent(parent);
124 if (!normalized.startsWith("projects/")) return [normalized];
125 return unique([
126 normalized,
127 `projects/${project}`,
128 `projects/${projectNumber}`,
129 ]);
130};
131
132const sameParent = (
133 left: string,
134 right: string,
135 project: string,
136 projectNumber: string,
137) => {
138 const aliases = new Set(parentAliases(left, project, projectNumber));
139 return parentAliases(right, project, projectNumber).some((alias) =>
140 aliases.has(alias),
141 );
142};
143
144const restrictionsOf = (values: readonly string[] | undefined) => {
145 const next = [...(values ?? DEFAULT_LIEN_RESTRICTIONS)].filter(
146 (value) => value.length > 0,
147 );
148 return next.length > 0 ? next : [...DEFAULT_LIEN_RESTRICTIONS];
149};
150
151const originOf = (origin: string | undefined) => {
152 const next = (origin ?? DEFAULT_LIEN_ORIGIN).trim();
153 return next.length > 0 ? next.slice(0, LIEN_ORIGIN_MAX) : DEFAULT_LIEN_ORIGIN;
154};
155
156const toAttrs = (lien: resourcemanager.Lien, project: string) => ({
157 name: lien.name ?? "",
158 parent: lien.parent ?? "",
159 origin: lien.origin ?? DEFAULT_LIEN_ORIGIN,
160 reason: lien.reason,
161 restrictions: lien.restrictions ?? [],
162 createTime: lien.createTime,
163 project,
164});
165
166const getByName = (name: string) =>
167 resourcemanager
168 .getLiens({ name })
169 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
170
171const listOnParent = (parent: string) =>
172 collectPages(
173 resourcemanager.listLiens.pages({
174 parent,
175 pageSize: 300,
176 }),
177 (page) => page.liens,
178 ).pipe(
179 Effect.catchTag("NotFound", () =>
180 Effect.succeed([] as resourcemanager.Lien[]),
181 ),
182 );
183
184const listOnParents = (parents: string[]) =>
185 Effect.gen(function* () {
186 const pages = yield* Effect.forEach(parents, listOnParent, {
187 concurrency: 2,
188 });
189 const byName = new Map<string, resourcemanager.Lien>();
190 for (const lien of pages.flat()) {
191 if (lien.name) byName.set(lien.name, lien);
192 }
193 return [...byName.values()];
194 });
195
196const matchesDesired = (
197 lien: resourcemanager.Lien,
198 parent: string,
199 origin: string,
200 reason: string,
201 restrictions: readonly string[],
202 project: string,
203 projectNumber: string,
204) =>
205 sameParent(lien.parent ?? "", parent, project, projectNumber) &&
206 (lien.origin ?? "") === origin &&
207 (lien.reason ?? "") === reason &&
208 sameStringList(lien.restrictions, restrictions);
209
210const DEFAULT_REASON = "Protected by a lien.";
211
212const reasonOf = (reason: string | undefined) =>
213 (reason ?? DEFAULT_REASON).slice(0, LIEN_REASON_MAX);
214
215/**
216 * Liens have no user-chosen id or labels: a lien on the parent with the
217 * same origin, reason, and restrictions is the one this resource manages.
218 */
219const findMatching = (
220 parent: string,
221 origin: string,
222 reason: string,
223 restrictions: readonly string[],
224 project: string,
225 projectNumber: string,
226 resourceName?: string,
227) =>
228 Effect.gen(function* () {
229 if (resourceName !== undefined && resourceName.length > 0) {
230 const byName = yield* getByName(resourceName);
231 if (byName !== undefined) return byName;
232 }
233 const liens = yield* listOnParents(
234 parentAliases(parent, project, projectNumber),
235 );
236 return liens.find((lien) =>
237 matchesDesired(
238 lien,
239 parent,
240 origin,
241 reason,
242 restrictions,
243 project,
244 projectNumber,
245 ),
246 );
247 });
248
249const waitUntilExists = (
250 parent: string,
251 origin: string,
252 reason: string,
253 restrictions: readonly string[],
254 project: string,
255 projectNumber: string,
256) =>
257 findMatching(
258 parent,
259 origin,
260 reason,
261 restrictions,
262 project,
263 projectNumber,
264 ).pipe(
265 Effect.filterOrFail(
266 (lien): lien is resourcemanager.Lien => lien !== undefined,
267 () => new LienNotResolved({ name: parent }),
268 ),
269 Effect.retry({
270 while: (error) => error._tag === "GCP.ResourceManager.LienNotResolved",
271 times: 8,
272 schedule: Schedule.spaced("1 second"),
273 }),
274 );
275
276const waitUntilGone = (name: string) =>
277 getByName(name).pipe(
278 Effect.flatMap((lien) =>
279 lien === undefined
280 ? Effect.void
281 : Effect.fail(new LienStillExists({ name })),
282 ),
283 Effect.retry({
284 while: (error) => error._tag === "GCP.ResourceManager.LienStillExists",
285 times: 10,
286 schedule: Schedule.spaced("1 second"),
287 }),
288 );
289
290export const LienProvider = () =>
291 Provider.succeed(Lien, {
292 stables: ["name", "parent", "origin", "createTime", "project"],
293
294 diff: Effect.fn(function* ({ news, olds, output }) {
295 if (!isResolved(news)) return undefined;
296 const env = yield* GcpEnvironment.current;
297 const projectNumber = yield* projectNumberOf(env.project);
298 const previousParent = olds?.parent ?? output?.parent;
299 const nextParent = projectParent(
300 news.parent ?? previousParent ?? env.project,
301 );
302 const parentChanged =
303 previousParent !== undefined &&
304 !sameParent(previousParent, nextParent, env.project, projectNumber);
305
306 const previousOrigin = originOf(olds?.origin ?? output?.origin);
307 const originChanged =
308 news.origin !== undefined && originOf(news.origin) !== previousOrigin;
309
310 const previousRestrictions = restrictionsOf(
311 olds?.restrictions ?? output?.restrictions,
312 );
313 const restrictionsChanged =
314 news.restrictions !== undefined &&
315 !sameStringList(news.restrictions, previousRestrictions);
316
317 const previousReason = olds?.reason ?? output?.reason;
318 const reasonChanged =
319 news.reason !== undefined && news.reason !== previousReason;
320
321 if (
322 !parentChanged &&
323 !originChanged &&
324 !restrictionsChanged &&
325 !reasonChanged
326 ) {
327 return undefined;
328 }
329 return { action: "replace" as const, deleteFirst: true };
330 }),
331
332 read: Effect.fn(function* ({ olds, output }) {
333 const env = yield* GcpEnvironment.current;
334 const projectNumber = yield* projectNumberOf(env.project);
335 const parent = projectParent(
336 output?.parent ?? olds?.parent ?? `projects/${projectNumber}`,
337 );
338 const existing = yield* findMatching(
339 parent,
340 originOf(olds?.origin ?? output?.origin),
341 reasonOf(olds?.reason ?? output?.reason),
342 restrictionsOf(olds?.restrictions ?? output?.restrictions),
343 env.project,
344 projectNumber,
345 output?.name,
346 );
347 if (existing === undefined) return undefined;
348 const attrs = toAttrs(existing, env.project);
349 // No labels field: without prior state it may not be ours.
350 return output !== undefined ? attrs : Unowned(attrs);
351 }),
352
353 list: () =>
354 Effect.gen(function* () {
355 const env = yield* GcpEnvironment.current;
356 const projectNumber = yield* projectNumberOf(env.project);
357 const liens = yield* listOnParents(
358 parentAliases(
359 `projects/${projectNumber}`,
360 env.project,
361 projectNumber,
362 ),
363 );
364 // `origin` identifies the system that placed a lien.
365 return liens
366 .filter((lien) => lien.origin === DEFAULT_LIEN_ORIGIN)
367 .map((lien) => toAttrs(lien, env.project));
368 }),
369
370 reconcile: Effect.fn(function* ({ news, output }) {
371 const env = yield* GcpEnvironment.current;
372 const projectNumber = yield* projectNumberOf(env.project);
373 const parent = projectParent(
374 news.parent ?? output?.parent ?? `projects/${projectNumber}`,
375 );
376 const origin = originOf(news.origin);
377 const restrictions = restrictionsOf(news.restrictions);
378 const reason = reasonOf(news.reason);
379
380 let current = yield* findMatching(
381 parent,
382 origin,
383 reason,
384 restrictions,
385 env.project,
386 projectNumber,
387 output?.name,
388 );
389
390 if (
391 current !== undefined &&
392 !matchesDesired(
393 current,
394 parent,
395 origin,
396 reason,
397 restrictions,
398 env.project,
399 projectNumber,
400 )
401 ) {
402 if (current.name !== undefined) {
403 yield* resourcemanager
404 .deleteLiens({ name: current.name })
405 .pipe(Effect.catchTag("NotFound", () => Effect.void));
406 yield* waitUntilGone(current.name);
407 }
408 current = undefined;
409 }
410
411 if (current === undefined) {
412 const created = yield* resourcemanager
413 .createLiens({
414 body: {
415 parent,
416 origin,
417 reason,
418 restrictions,
419 },
420 })
421 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
422 if (created !== undefined && created.name !== undefined) {
423 current = created;
424 } else {
425 current = yield* waitUntilExists(
426 parent,
427 origin,
428 reason,
429 restrictions,
430 env.project,
431 projectNumber,
432 );
433 }
434 }
435
436 if (current === undefined || current.name === undefined) {
437 return yield* new LienNotResolved({ name: parent });
438 }
439
440 return toAttrs(current, env.project);
441 }),
442
443 delete: Effect.fn(function* ({ output }) {
444 yield* resourcemanager
445 .deleteLiens({ name: output.name })
446 .pipe(Effect.catchTag("NotFound", () => Effect.void));
447 yield* waitUntilGone(output.name);
448 }),
449 });