1import * as iam from "@distilled.cloud/gcp/iam_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import {
6 alchemyLabelKeys,
7 createInternalLabels,
8 hasAlchemyLabels,
9 sanitizeLabelValue,
10 toLabels,
11} from "../Labels.ts";
12import {
13 waitForOperation as waitForLongRunning,
14 type LongRunningOperation,
15} from "../Operation.ts";
16
17export const MAX_POLICY_ID = 63;
18
19export class ResourceNotResolved extends Data.TaggedError(
20 "GCP.IAM.ResourceNotResolved",
21)<{
22 name: string;
23}> {}
24
25export const lastSegment = (value: string) => {
26 const trimmed = value.replace(/\/+$/, "");
27 const parts = trimmed.split("/");
28 return parts[parts.length - 1] || trimmed;
29};
30
31export const attachmentPointOf = (
32 project: string,
33 attachmentPoint?: string,
34) => {
35 if (attachmentPoint && attachmentPoint.length > 0) {
36 return attachmentPoint.includes("%2F")
37 ? attachmentPoint
38 : encodeURIComponent(attachmentPoint);
39 }
40 return encodeURIComponent(
41 `cloudresourcemanager.googleapis.com/projects/${project}`,
42 );
43};
44
45export const denypoliciesParent = (attachmentPoint: string) =>
46 `policies/${attachmentPoint}/denypolicies`;
47
48export const policyName = (attachmentPoint: string, policyId: string) =>
49 `${denypoliciesParent(attachmentPoint)}/${policyId}`;
50
51export const parsePolicyName = (name: string) => {
52 const parts = name.split("/").filter((part) => part.length > 0);
53 const policiesAt = parts.indexOf("policies");
54 const denyAt = parts.lastIndexOf("denypolicies");
55 return {
56 attachmentPoint:
57 policiesAt >= 0 && parts[policiesAt + 1] ? parts[policiesAt + 1]! : "",
58 policyId:
59 denyAt >= 0 && parts[denyAt + 1] ? parts[denyAt + 1]! : lastSegment(name),
60 };
61};
62
63const rfc1035 = (value: string) => {
64 let next = value
65 .toLowerCase()
66 .replace(/[^a-z0-9.-]/g, "-")
67 .replace(/-+/g, "-")
68 .replace(/^-+|-+$/g, "");
69 if (!/^[a-z]/.test(next)) next = `p${next}`;
70 next = next.slice(0, MAX_POLICY_ID).replace(/-+$/g, "");
71 if (next.length < 3) next = `${next}xxx`.slice(0, MAX_POLICY_ID);
72 return next;
73};
74
75export const toPolicyId = (
76 id: string,
77 requested: string | undefined,
78 existing?: string,
79) =>
80 Effect.gen(function* () {
81 if (requested !== undefined) return rfc1035(requested);
82 if (existing !== undefined) return existing;
83 return rfc1035(
84 yield* createPhysicalName({
85 id,
86 maxLength: MAX_POLICY_ID,
87 lowercase: true,
88 }),
89 );
90 });
91
92export const ownershipAnnotations = (id: string) =>
93 Effect.gen(function* () {
94 return toLabels(yield* createInternalLabels(id));
95 });
96
97export const hasOwnershipAnnotations = (
98 annotations: Record<string, string | undefined> | null | undefined,
99) => Object.keys(annotations ?? {}).some((key) => key.startsWith("alchemy-"));
100
101export const ownedByAlchemy = (
102 id: string,
103 annotations: Record<string, string | undefined> | null | undefined,
104) =>
105 hasAlchemyLabels(
106 id,
107 Object.fromEntries(
108 Object.entries(annotations ?? {}).filter(
109 (entry): entry is [string, string] => typeof entry[1] === "string",
110 ),
111 ),
112 );
113
114export const userAnnotations = (
115 annotations: Record<string, string | undefined> | null | undefined,
116): Record<string, string> =>
117 Object.fromEntries(
118 Object.entries(annotations ?? {}).filter(
119 ([key, value]) =>
120 !key.startsWith("alchemy-") && typeof value === "string",
121 ),
122 ) as Record<string, string>;
123
124export { alchemyLabelKeys, sanitizeLabelValue };
125
126/** Deny policy operations finish within seconds. */
127const OPERATION_BUDGET = "5 minutes";
128
129/**
130 * Wait for a long-running operation. ALREADY_EXISTS (code 6) means a
131 * concurrent create won the race; reconcile observes the resource next.
132 */
133export const waitForOperation = (operation: LongRunningOperation) =>
134 waitForLongRunning(operation, (name) => iam.getPoliciesOperations({ name }), {
135 budget: OPERATION_BUDGET,
136 }).pipe(
137 Effect.catchIf(
138 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
139 () => Effect.succeed(operation),
140 ),
141 );
142
143/**
144 * Wait for a delete operation. A vanished operation or NOT_FOUND (code 5)
145 * means the resource is already gone.
146 */
147export const waitForDeleteOperation = (operation: LongRunningOperation) =>
148 waitForOperation(operation).pipe(
149 Effect.catchIf(
150 (error) =>
151 error._tag === "NotFound" ||
152 (error._tag === "GCP.OperationFailed" && error.code === 5),
153 () => Effect.succeed(operation),
154 ),
155 );