1import * as binaryauthorization from "@distilled.cloud/gcp/binaryauthorization_v1";
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 { createPhysicalName } from "../../PhysicalName.ts";
7import {
8 alchemyLabelKeys,
9 createInternalLabels,
10 hasAlchemyLabels,
11} from "../Labels.ts";
12import { isTransientGcpError } from "../Errors.ts";
13
14export const MAX_ID_LENGTH = 63;
15export const DEFAULT_PLATFORM = "gke";
16export const LIST_PLATFORMS = ["gke", "cloudRun"] as const;
17
18export class ResourceNotResolved extends Data.TaggedError(
19 "GCP.BinaryAuthorization.ResourceNotResolved",
20)<{
21 name: string;
22}> {}
23
24export const lastSegment = (value: string) => {
25 const trimmed = value.replace(/\/+$/, "");
26 const parts = trimmed.split("/");
27 return parts[parts.length - 1] || trimmed;
28};
29
30export const rfc1035 = (name: string, fallback = "binauthz"): string => {
31 let next = name
32 .toLowerCase()
33 .replace(/[^a-z0-9-]/g, "-")
34 .replace(/-+/g, "-")
35 .replace(/^-+|-+$/g, "");
36 if (!/^[a-z]/.test(next)) next = `a${next}`;
37 next = next.slice(0, MAX_ID_LENGTH).replace(/-+$/g, "");
38 if (next.length === 0) return fallback;
39 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_ID_LENGTH - 1)}0`;
40 return next.slice(0, MAX_ID_LENGTH);
41};
42
43export const toPhysicalId = (
44 id: string,
45 explicit: string | undefined,
46 existing: string | undefined,
47 fallback = "binauthz",
48) =>
49 Effect.gen(function* () {
50 if (explicit !== undefined) return rfc1035(explicit, fallback);
51 if (existing !== undefined) return existing;
52 return rfc1035(
53 yield* createPhysicalName({
54 id,
55 maxLength: MAX_ID_LENGTH,
56 lowercase: true,
57 }),
58 fallback,
59 );
60 });
61
62export const projectParent = (project: string) => `projects/${project}`;
63
64export const attestorName = (project: string, attestorId: string) =>
65 `${projectParent(project)}/attestors/${attestorId}`;
66
67export const platformParent = (project: string, platform: string) =>
68 `${projectParent(project)}/platforms/${platform}`;
69
70export const policyName = (
71 project: string,
72 platform: string,
73 policyId: string,
74) => `${platformParent(project, platform)}/policies/${policyId}`;
75
76export const parseAttestorName = (name: string) => {
77 const parts = name.split("/").filter((part) => part.length > 0);
78 const attestorsAt = parts.lastIndexOf("attestors");
79 const projectsAt = parts.lastIndexOf("projects");
80 return {
81 project:
82 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
83 attestorId:
84 attestorsAt >= 0 && parts[attestorsAt + 1]
85 ? parts[attestorsAt + 1]!
86 : lastSegment(name),
87 };
88};
89
90export const parsePolicyName = (name: string) => {
91 const parts = name.split("/").filter((part) => part.length > 0);
92 const policiesAt = parts.lastIndexOf("policies");
93 const platformsAt = parts.lastIndexOf("platforms");
94 const projectsAt = parts.lastIndexOf("projects");
95 return {
96 project:
97 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
98 platform:
99 platformsAt >= 0 && parts[platformsAt + 1]
100 ? parts[platformsAt + 1]!
101 : DEFAULT_PLATFORM,
102 policyId:
103 policiesAt >= 0 && parts[policiesAt + 1]
104 ? parts[policiesAt + 1]!
105 : lastSegment(name),
106 };
107};
108
109export const expandNoteReference = (value: string, project: string) => {
110 const trimmed = value.replace(/\/+$/, "");
111 if (trimmed.includes("/")) return trimmed;
112 return `${projectParent(project)}/notes/${trimmed}`;
113};
114
115export const encodeDescription = (
116 labels: Record<string, string>,
117 description: string | undefined,
118): string => {
119 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
120 return description ? `${marker}\n${description}` : marker;
121};
122
123export const parseDescription = (
124 description: string | undefined,
125): {
126 labels: Record<string, string>;
127 description: string | undefined;
128} => {
129 if (!description?.startsWith("[alchemy ")) {
130 return { labels: {}, description };
131 }
132 const end = description.indexOf("]");
133 if (end < 0) return { labels: {}, description };
134 const labels: Record<string, string> = {};
135 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
136 const eq = part.indexOf("=");
137 if (eq > 0) {
138 labels[part.slice(0, eq)] = part.slice(eq + 1);
139 }
140 }
141 const rest = description.slice(end + 1).replace(/^\n/, "");
142 return { labels, description: rest.length > 0 ? rest : undefined };
143};
144
145export const hasOwnershipMarker = (description: string | undefined) =>
146 Object.keys(parseDescription(description).labels).some((key) =>
147 key.startsWith("alchemy-"),
148 );
149
150export const ownedBy = (id: string, labels: Record<string, string>) =>
151 hasAlchemyLabels(id, labels);
152
153export const createOwnership = (id: string) => createInternalLabels(id);
154
155export const sameText = (left: string | undefined, right: string | undefined) =>
156 (left ?? "") === (right ?? "");
157
158export const sameJson = (left: unknown, right: unknown) =>
159 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
160
161export const replaceOnIdentity = (input: {
162 previousId?: string;
163 nextId?: string;
164 previousParent?: string;
165 nextParent?: string;
166 extra?: boolean;
167 deleteFirst?: boolean;
168}) => {
169 if (input.extra === true) {
170 return {
171 action: "replace" as const,
172 deleteFirst: input.deleteFirst === true,
173 };
174 }
175 const idChanged =
176 input.previousId !== undefined &&
177 input.nextId !== undefined &&
178 input.previousId !== input.nextId;
179 const parentChanged =
180 (input.previousParent ?? "") !== "" &&
181 (input.nextParent ?? "") !== "" &&
182 (input.previousParent ?? "") !== input.nextParent;
183 if (!idChanged && !parentChanged) return undefined;
184 return { action: "replace" as const, deleteFirst: false };
185};
186
187export const retryTransient = <A, E extends { readonly _tag: string }, R>(
188 effect: Effect.Effect<A, E, R>,
189) =>
190 effect.pipe(
191 Effect.retry({
192 while: isTransientGcpError,
193 times: 8,
194 schedule: Schedule.exponential("250 millis"),
195 }),
196 );
197
198const isNotFound = <E extends { readonly _tag: string }>(
199 error: E,
200): error is E & { readonly _tag: "NotFound" } => error._tag === "NotFound";
201
202export const ignoreGone = <A, E extends { readonly _tag: string }, R>(
203 effect: Effect.Effect<A, E, R>,
204) => effect.pipe(Effect.catchIf(isNotFound, () => Effect.void));
205
206export const missingGet =
207 <A, E extends { readonly _tag: string }, R>(
208 effect: (input: { name: string }) => Effect.Effect<A, E, R>,
209 ) =>
210 (name: string) =>
211 name.length === 0
212 ? Effect.succeed(undefined)
213 : effect({ name }).pipe(
214 Effect.catchIf(isNotFound, () => Effect.succeed(undefined)),
215 );
216
217const emptyList = <A>() => Effect.succeed<A[]>([]);
218
219export const collectPages = <
220 Page,
221 Item,
222 E extends { readonly _tag: string },
223 R,
224>(
225 pages: Stream.Stream<Page, E, R>,
226 items: (page: Page) => readonly Item[] | null | undefined,
227) =>
228 pages.pipe(
229 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
230 Stream.runCollect,
231 Effect.map((chunk): Item[] => Array.from(chunk)),
232 Effect.catchIf(isNotFound, () => emptyList<Item>()),
233 );
234
235export const listAttestors = (project: string) =>
236 collectPages(
237 binaryauthorization.listProjectsAttestors.pages({
238 parent: projectParent(project),
239 pageSize: 1000,
240 }),
241 (page) => page.attestors,
242 );
243
244export const listPlatformPolicies = (project: string) =>
245 Effect.gen(function* () {
246 const pages = yield* Effect.forEach(
247 LIST_PLATFORMS,
248 (platform) =>
249 collectPages(
250 binaryauthorization.listProjectsPlatformsPolicies.pages({
251 parent: platformParent(project, platform),
252 pageSize: 1000,
253 }),
254 (page) => page.platformPolicies,
255 ),
256 { concurrency: "unbounded" },
257 );
258 return pages.flat();
259 });
260
261const normalizePem = (value: string | undefined) =>
262 (value ?? "")
263 .replace(/-----BEGIN PUBLIC KEY-----/g, "")
264 .replace(/-----END PUBLIC KEY-----/g, "")
265 .replace(/\s+/g, "");
266
267const normalizeAlgorithm = (value: string | undefined) => {
268 const next = (value ?? "").toUpperCase();
269 if (next === "ECDSA_P256_SHA256") return "EC_SIGN_P256_SHA256";
270 if (next === "ECDSA_P384_SHA384") return "EC_SIGN_P384_SHA384";
271 if (next === "ECDSA_P521_SHA512") return "EC_SIGN_P521_SHA512";
272 return next;
273};
274
275export const publicKeysOf = (
276 keys: readonly binaryauthorization.AttestorPublicKey[] | undefined,
277): binaryauthorization.AttestorPublicKey[] => [...(keys ?? [])];
278
279export const samePublicKeys = (
280 observed: readonly binaryauthorization.AttestorPublicKey[] | undefined,
281 desired: readonly binaryauthorization.AttestorPublicKey[] | undefined,
282) => {
283 const left = observed ?? [];
284 const right = desired ?? [];
285 if (left.length !== right.length) return false;
286 return left.every((obs, index) => {
287 const want = right[index];
288 if (want === undefined) return false;
289 if ((obs.comment ?? "") !== (want.comment ?? "")) return false;
290 if (
291 normalizePem(obs.asciiArmoredPgpPublicKey) !==
292 normalizePem(want.asciiArmoredPgpPublicKey)
293 ) {
294 return false;
295 }
296 if (
297 normalizePem(obs.pkixPublicKey?.publicKeyPem) !==
298 normalizePem(want.pkixPublicKey?.publicKeyPem)
299 ) {
300 return false;
301 }
302 if (
303 normalizeAlgorithm(obs.pkixPublicKey?.signatureAlgorithm) !==
304 normalizeAlgorithm(want.pkixPublicKey?.signatureAlgorithm)
305 ) {
306 return false;
307 }
308 if (want.id !== undefined && obs.id !== want.id) return false;
309 if (
310 want.pkixPublicKey?.keyId !== undefined &&
311 obs.pkixPublicKey?.keyId !== want.pkixPublicKey.keyId
312 ) {
313 return false;
314 }
315 return true;
316 });
317};
318
319export const gkePolicyOf = (
320 policy: binaryauthorization.GkePolicy | undefined,
321): binaryauthorization.GkePolicy => policy ?? {};