1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import { tagRecord } from "../../Tags.ts";
6import {
7 alchemyLabelKeys,
8 createInternalLabels,
9 stripInternalLabels,
10} from "../Labels.ts";
11
12export const MAX_NAME_LENGTH = 63;
13export const MAX_PIPELINE_JOB_ID_LENGTH = 128;
14
15export class AiPlatformNotResolved extends Data.TaggedError(
16 "GCP.AIPlatform.NotResolved",
17)<{
18 name: string;
19}> {}
20
21export class AiPlatformStillExists extends Data.TaggedError(
22 "GCP.AIPlatform.StillExists",
23)<{
24 name: string;
25}> {}
26
27export type EncryptionSpec = {
28 /** Cloud KMS key resource name. Immutable on most Vertex resources. */
29 kmsKeyName?: string;
30};
31
32export type MachineSpec = {
33 /** Machine type (e.g. `e2-standard-4`, `n1-standard-4`). Immutable. */
34 machineType?: string;
35 /** Accelerator type (`NVIDIA_TESLA_T4`, …). Immutable. */
36 acceleratorType?: string;
37 /** Number of accelerators to attach. */
38 acceleratorCount?: number;
39 /** Nvidia GPU partition size (MIG). Immutable. */
40 gpuPartitionSize?: string;
41 /** TPU topology (e.g. `2x2x1`). Immutable. */
42 tpuTopology?: string;
43};
44
45export type NetworkSpec = {
46 /** Enable public internet access. @default false */
47 enableInternetAccess?: boolean;
48 /** Subnetwork resource name. */
49 subnetwork?: string;
50 /** VPC network resource name. */
51 network?: string;
52};
53
54export type PersistentDiskSpec = {
55 /** Disk type (`pd-standard`, `pd-ssd`, …). */
56 diskType?: string;
57 /** Size in GB (string, e.g. `"100"`). */
58 diskSizeGb?: string;
59};
60
61export type DiskSpec = {
62 /** Boot disk type (`pd-ssd`, `pd-standard`, `hyperdisk-balanced`). */
63 bootDiskType?: string;
64 /** Boot disk size in GB. @default 100 */
65 bootDiskSizeGb?: number;
66};
67
68export const lastSegment = (value: string) => {
69 const trimmed = value.replace(/\/+$/, "");
70 const parts = trimmed.split("/");
71 return parts[parts.length - 1] || trimmed;
72};
73
74export const normalizeLocation = (
75 location: string | undefined,
76 fallback: string,
77) => lastSegment(location ?? fallback).toLowerCase();
78
79export const locationParent = (project: string, location: string) =>
80 `projects/${project}/locations/${location}`;
81
82export const parseResourceName = (name: string, collection: string) => {
83 const parts = name.split("/").filter((part) => part.length > 0);
84 const collectionAt = parts.lastIndexOf(collection);
85 const locationsAt = parts.lastIndexOf("locations");
86 const projectsAt = parts.lastIndexOf("projects");
87 return {
88 project:
89 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
90 location:
91 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
92 id:
93 collectionAt >= 0 && parts[collectionAt + 1]
94 ? parts[collectionAt + 1]!
95 : lastSegment(name),
96 };
97};
98
99export const parentBefore = (name: string, collection: string) => {
100 const marker = `/${collection}/`;
101 const at = name.lastIndexOf(marker);
102 return at >= 0 ? name.slice(0, at) : name;
103};
104
105export const rfc1035 = (name: string, maxLength = MAX_NAME_LENGTH): string => {
106 let next = name
107 .toLowerCase()
108 .replace(/[^a-z0-9-]/g, "-")
109 .replace(/-+/g, "-")
110 .replace(/^-+|-+$/g, "");
111 if (!/^[a-z]/.test(next)) next = `a${next}`;
112 next = next.slice(0, maxLength).replace(/-+$/g, "");
113 if (next.length === 0) return "resource";
114 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, maxLength - 1)}0`;
115 return next.slice(0, maxLength);
116};
117
118export const toPhysicalId = (
119 id: string,
120 explicit: string | undefined,
121 existing: string | undefined,
122 maxLength = MAX_NAME_LENGTH,
123) =>
124 Effect.gen(function* () {
125 if (explicit !== undefined) return explicit;
126 if (existing !== undefined) return existing;
127 return rfc1035(
128 yield* createPhysicalName({
129 id,
130 maxLength,
131 lowercase: true,
132 }),
133 maxLength,
134 );
135 });
136
137export const userLabels = (
138 labels: Record<string, string | undefined> | null | undefined,
139): Record<string, string> => stripInternalLabels(tagRecord(labels));
140
141export const encodeDescription = (
142 labels: Record<string, string>,
143 description: string | undefined,
144): string => {
145 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
146 return description ? `${marker}\n${description}` : marker;
147};
148
149export const parseDescription = (
150 description: string | undefined,
151): {
152 labels: Record<string, string>;
153 description: string | undefined;
154} => {
155 if (!description?.startsWith("[alchemy ")) {
156 return { labels: {}, description };
157 }
158 const end = description.indexOf("]");
159 if (end < 0) return { labels: {}, description };
160 const labels: Record<string, string> = {};
161 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
162 const eq = part.indexOf("=");
163 if (eq > 0) {
164 labels[part.slice(0, eq)] = part.slice(eq + 1);
165 }
166 }
167 const rest = description.slice(end + 1).replace(/^\n/, "");
168 return { labels, description: rest.length > 0 ? rest : undefined };
169};
170
171export const hasDescriptionOwnership = (description: string | undefined) =>
172 Object.keys(parseDescription(description).labels).some((key) =>
173 key.startsWith("alchemy-"),
174 );
175
176/**
177 * Compact displayName stamp for APIs that cap display names at 63
178 * characters and have no labels field.
179 */
180const DISPLAY_ID_LENGTH = 20;
181
182export const encodeDisplayName = (
183 labels: Record<string, string>,
184 displayName: string | undefined,
185 maxLength = 63,
186): string => {
187 const id = (labels[alchemyLabelKeys.id] ?? "x").slice(0, DISPLAY_ID_LENGTH);
188 const marker = `[alc ${id}]`;
189 const rest = (displayName ?? "").replace(/[\r\n]+/g, " ").trim();
190 const combined = rest.length > 0 ? `${marker} ${rest}` : marker;
191 return combined.slice(0, maxLength);
192};
193
194export const parseDisplayName = (
195 displayName: string | undefined,
196): {
197 labels: Record<string, string>;
198 displayName: string | undefined;
199} => {
200 if (!displayName?.startsWith("[alc ")) {
201 return { labels: {}, displayName };
202 }
203 const end = displayName.indexOf("]");
204 if (end < 0) return { labels: {}, displayName };
205 const id = displayName.slice("[alc ".length, end).trim();
206 const rest = displayName.slice(end + 1).replace(/^[\s\n]+/, "");
207 return {
208 labels: id.length > 0 ? { [alchemyLabelKeys.id]: id } : {},
209 displayName: rest.length > 0 ? rest : undefined,
210 };
211};
212
213export const hasDisplayNameOwnership = (displayName: string | undefined) =>
214 Object.keys(parseDisplayName(displayName).labels).some((key) =>
215 key.startsWith("alchemy-"),
216 );
217
218export const ownedById = (id: string, labels: Record<string, string>) =>
219 Effect.gen(function* () {
220 const expected = yield* createInternalLabels(id);
221 const expectedId = (expected[alchemyLabelKeys.id] ?? "").slice(
222 0,
223 DISPLAY_ID_LENGTH,
224 );
225 return labels[alchemyLabelKeys.id] === expectedId;
226 });
227
228export const collectPages = <A, E, R>(stream: Stream.Stream<A, E, R>) =>
229 stream.pipe(
230 Stream.runCollect,
231 Effect.map((chunk) => Array.from(chunk)),
232 );
233
234export const jsonEqual = (left: unknown, right: unknown) =>
235 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);