1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import {
3 waitGlobalOperation,
4 waitOrganizationOperation,
5 waitRegionOperation,
6} from "./operations.ts";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import { alchemyLabelKeys } from "../Labels.ts";
11
12export const MAX_NAME_LENGTH = 63;
13
14export const lastSegment = (value: string | undefined): string => {
15 if (value === undefined || value.length === 0) return "";
16 const parts = value.split("/").filter((part) => part.length > 0);
17 return parts[parts.length - 1] ?? value;
18};
19
20export const normalizeRegion = (
21 region: string | undefined,
22 defaultRegion: string,
23) => lastSegment(region ?? defaultRegion).toLowerCase();
24
25export const rfc1035 = (name: string, fallback: string): string => {
26 let next = name
27 .toLowerCase()
28 .replace(/[^a-z0-9-]/g, "-")
29 .replace(/-+/g, "-")
30 .replace(/^-+/, "")
31 .replace(/-+$/, "");
32 if (!/^[a-z]/.test(next)) {
33 next = `${fallback[0] ?? "r"}${next}`;
34 }
35 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
36 return next.length > 0 ? next : fallback;
37};
38
39export const toPhysicalName = (
40 id: string,
41 name: string | undefined,
42 existing: string | undefined,
43 fallback: string,
44) =>
45 Effect.gen(function* () {
46 if (name !== undefined) return name;
47 if (existing !== undefined) return existing;
48 return rfc1035(
49 yield* createPhysicalName({
50 id,
51 maxLength: MAX_NAME_LENGTH,
52 lowercase: true,
53 }),
54 fallback,
55 );
56 });
57
58export const encodeDescription = (
59 labels: Record<string, string>,
60 description: string | undefined,
61): string => {
62 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
63 return description ? `${marker}\n${description}` : marker;
64};
65
66export const parseDescription = (
67 description: string | undefined,
68): {
69 labels: Record<string, string>;
70 description: string | undefined;
71} => {
72 if (!description?.startsWith("[alchemy ")) {
73 return { labels: {}, description };
74 }
75 const end = description.indexOf("]");
76 if (end < 0) return { labels: {}, description };
77 const labels: Record<string, string> = {};
78 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
79 const eq = part.indexOf("=");
80 if (eq > 0) {
81 labels[part.slice(0, eq)] = part.slice(eq + 1);
82 }
83 }
84 const rest = description.slice(end + 1).replace(/^\n/, "");
85 return { labels, description: rest.length > 0 ? rest : undefined };
86};
87
88export const hasOwnershipMarker = (description: string | undefined) =>
89 Object.keys(parseDescription(description).labels).some((key) =>
90 key.startsWith("alchemy-"),
91 );
92
93export const sameJson = (left: unknown, right: unknown) =>
94 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
95
96export const sorted = (values: readonly string[] | undefined) =>
97 [...(values ?? [])].slice().sort();
98
99export const sameUrlList = (
100 left: readonly string[] | undefined,
101 right: readonly string[] | undefined,
102) =>
103 sameJson(
104 sorted((left ?? []).map(lastSegment)),
105 sorted((right ?? []).map(lastSegment)),
106 );
107
108export interface RunOperationOptions {
109 /** Treat `RESOURCE_ALREADY_EXISTS` as success (idempotent insert). */
110 ignoreAlreadyExists?: boolean;
111 /** Treat `RESOURCE_NOT_FOUND` as success (idempotent delete). */
112 ignoreNotFound?: boolean;
113}
114
115export const ignoredCodes = (options: RunOperationOptions | undefined) => [
116 ...(options?.ignoreAlreadyExists === true ? ["RESOURCE_ALREADY_EXISTS"] : []),
117 ...(options?.ignoreNotFound === true ? ["RESOURCE_NOT_FOUND"] : []),
118];
119
120/**
121 * Start a Compute operation and wait for it. The insert/delete call itself
122 * is retried while another operation holds the resource (`Conflict`).
123 */
124const runOp = <E extends { readonly _tag: string }, R, E2, R2>(
125 start: Effect.Effect<compute.Operation, E, R>,
126 wait: (
127 operation: compute.Operation,
128 ) => Effect.Effect<compute.Operation, E2, R2>,
129) =>
130 start.pipe(
131 Effect.retry({
132 while: (error) => error._tag === "Conflict",
133 times: 5,
134 schedule: Schedule.spaced("1 second"),
135 }),
136 Effect.flatMap(wait),
137 );
138
139export const runRegionOp = <E extends { readonly _tag: string }, R>(
140 project: string,
141 region: string,
142 start: Effect.Effect<compute.Operation, E, R>,
143 options?: RunOperationOptions,
144) =>
145 runOp(start, (operation) =>
146 waitRegionOperation(project, region, operation, {
147 ignore: ignoredCodes(options),
148 }),
149 );
150
151export const runGlobalOp = <E extends { readonly _tag: string }, R>(
152 project: string,
153 start: Effect.Effect<compute.Operation, E, R>,
154 options?: RunOperationOptions,
155) =>
156 runOp(start, (operation) =>
157 waitGlobalOperation(project, operation, { ignore: ignoredCodes(options) }),
158 );
159
160export const runOrgOp = <E extends { readonly _tag: string }, R>(
161 parentId: string,
162 start: Effect.Effect<compute.Operation, E, R>,
163 options?: RunOperationOptions,
164) =>
165 runOp(start, (operation) =>
166 waitOrganizationOperation(operation, parentId, {
167 ignore: ignoredCodes(options),
168 }),
169 );