1import * as aiplatform from "@distilled.cloud/gcp/aiplatform_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 { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13import { listLocations } from "./names.ts";
14import {
15 lastSegment,
16 normalizeLocation,
17 parentOf,
18 parseResourceName,
19 rfc1035,
20 resourceNameFromOperation,
21 waitForOperation,
22} from "./internal.ts";
23import type { EncryptionSpec } from "./shared.ts";
24
25export type MachineSpec = {
26 /** Machine type, for example `n1-standard-4`. Immutable. */
27 machineType?: string;
28 /** Accelerator type. Immutable. */
29 acceleratorType?: string;
30 /** Accelerators per replica. Immutable. */
31 acceleratorCount?: number;
32};
33
34export type AutoscalingMetricSpec = {
35 /** Resource metric name. */
36 metricName?: string;
37 /** Target utilization percent (1–100). Defaults to 60. */
38 target?: number;
39};
40
41export type DedicatedResources = {
42 /**
43 * Minimum always-on replicas. Must be >= 1.
44 * @default 1
45 */
46 minReplicaCount?: number;
47 /**
48 * Maximum replicas under load. Defaults to `minReplicaCount`.
49 */
50 maxReplicaCount?: number;
51 /**
52 * Replicas required for the deploy to succeed.
53 */
54 requiredReplicaCount?: number;
55 /**
56 * Schedule the workload on spot VMs.
57 */
58 spot?: boolean;
59 /**
60 * Metric specs that override the default 60% CPU/accelerator targets.
61 */
62 autoscalingMetricSpecs?: AutoscalingMetricSpec[];
63 /**
64 * Single-machine spec. Immutable — changing it replaces the pool.
65 * @default { machineType: "n1-standard-2" }
66 */
67 machineSpec?: MachineSpec;
68};
69
70export type DeploymentResourcePoolProps = {
71 /**
72 * Pool id (the `{deployment_resource_pool}` segment). If omitted, a
73 * unique name is generated. Must match
74 * `[a-z]([a-z0-9-]{0,61}[a-z0-9])?`. Immutable — changing it replaces
75 * the pool.
76 */
77 deploymentResourcePoolId?: string;
78 /**
79 * Region. Immutable — changing it replaces the pool.
80 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
81 */
82 location?: string;
83 /**
84 * Dedicated machine resources shared by deployed models.
85 */
86 dedicatedResources?: DedicatedResources;
87 /**
88 * Disable container stdout/stderr logging. Vertex AI accepts but does not
89 * apply updates to it, so changing it replaces the pool.
90 */
91 disableContainerLogging?: boolean;
92 /**
93 * Runtime service account email.
94 */
95 serviceAccount?: string;
96 /**
97 * Customer-managed encryption. Immutable — changing it replaces the
98 * pool.
99 */
100 encryptionSpec?: EncryptionSpec;
101};
102
103export type DeploymentResourcePool = Resource<
104 "GCP.AIPlatform.DeploymentResourcePool",
105 DeploymentResourcePoolProps,
106 {
107 /** Full resource name `.../deploymentResourcePools/{pool}`. */
108 name: string;
109 /** Pool id (last path segment). */
110 deploymentResourcePoolId: string;
111 /** Project id. */
112 project: string;
113 /** Location id. */
114 location: string;
115 /** Dedicated resources. */
116 dedicatedResources: DedicatedResources | undefined;
117 /** Whether container logging is disabled. */
118 disableContainerLogging: boolean;
119 /** Runtime service account. */
120 serviceAccount: string | undefined;
121 /** Customer-managed KMS key, if any. */
122 kmsKeyName: string | undefined;
123 /** RFC3339 creation timestamp. */
124 createTime: string | undefined;
125 },
126 never,
127 Providers
128>;
129
130/**
131 * A Vertex AI DeploymentResourcePool — dedicated machines shared by
132 * multiple DeployedModels.
133 *
134 * Deployment resource pools have no labels field. Generated ids are
135 * prefixed with `alch-` so `list` / nuke can find them. Location, pool
136 * id, machine spec, and CMEK are immutable. Replica counts, logging, and
137 * service account update in place.
138 *
139 * ### Creating a Pool
140 * **Example:** Single n1-standard-2 replica
141 * ```typescript
142 * const pool = yield* GCP.AIPlatform.DeploymentResourcePool("Shared", {
143 * dedicatedResources: {
144 * minReplicaCount: 1,
145 * maxReplicaCount: 1,
146 * machineSpec: { machineType: "n1-standard-2" },
147 * },
148 * });
149 * ```
150 *
151 * ### Scaling a Pool
152 * **Example:** Raise max replicas
153 * ```typescript
154 * // Same logical id, changed props: the engine updates it in place.
155 * const pool = yield* GCP.AIPlatform.DeploymentResourcePool("Shared", {
156 * dedicatedResources: {
157 * minReplicaCount: 1,
158 * maxReplicaCount: 2,
159 * machineSpec: { machineType: "n1-standard-2" },
160 * },
161 * });
162 * ```
163 *
164 * @resource
165 * @category AIPlatform
166 */
167export const DeploymentResourcePool = Resource<DeploymentResourcePool>(
168 "GCP.AIPlatform.DeploymentResourcePool",
169);
170
171export class DeploymentResourcePoolNotResolved extends Data.TaggedError(
172 "GCP.AIPlatform.DeploymentResourcePoolNotResolved",
173)<{
174 name: string;
175}> {}
176
177export class DeploymentResourcePoolStillExists extends Data.TaggedError(
178 "GCP.AIPlatform.DeploymentResourcePoolStillExists",
179)<{
180 name: string;
181}> {}
182
183const DEFAULT_MACHINE_TYPE = "n1-standard-2";
184const OWNED_ID_PREFIX = "alch-";
185
186const resourceName = (project: string, location: string, poolId: string) =>
187 `projects/${project}/locations/${location}/deploymentResourcePools/${poolId}`;
188
189const ownedId = (value: string) => {
190 const base = rfc1035(value);
191 if (base.startsWith(OWNED_ID_PREFIX)) return base.slice(0, 63);
192 return `${OWNED_ID_PREFIX}${base}`.slice(0, 63).replace(/-+$/g, "");
193};
194
195const isOwnedId = (poolId: string) => poolId.startsWith(OWNED_ID_PREFIX);
196
197const toId = (id: string, poolId: string | undefined, existing?: string) =>
198 Effect.gen(function* () {
199 if (poolId !== undefined) return rfc1035(poolId);
200 if (existing !== undefined) return existing;
201 return ownedId(
202 yield* createPhysicalName({
203 id,
204 maxLength: 58,
205 lowercase: true,
206 }),
207 );
208 });
209
210const dedicatedOf = (
211 resources:
212 | DedicatedResources
213 | aiplatform.GoogleCloudAiplatformV1DedicatedResources
214 | undefined,
215): DedicatedResources | undefined => {
216 if (resources === undefined) return undefined;
217 return {
218 minReplicaCount: resources.minReplicaCount,
219 maxReplicaCount: resources.maxReplicaCount,
220 requiredReplicaCount: resources.requiredReplicaCount,
221 spot: resources.spot,
222 autoscalingMetricSpecs: resources.autoscalingMetricSpecs?.map((spec) => ({
223 metricName: spec.metricName,
224 target: spec.target,
225 })),
226 machineSpec: resources.machineSpec
227 ? {
228 machineType: resources.machineSpec.machineType,
229 acceleratorType: resources.machineSpec.acceleratorType,
230 acceleratorCount: resources.machineSpec.acceleratorCount,
231 }
232 : undefined,
233 };
234};
235
236const desiredDedicated = (
237 resources: DedicatedResources | undefined,
238): aiplatform.GoogleCloudAiplatformV1DedicatedResources => ({
239 minReplicaCount: resources?.minReplicaCount ?? 1,
240 maxReplicaCount:
241 resources?.maxReplicaCount ?? resources?.minReplicaCount ?? 1,
242 requiredReplicaCount: resources?.requiredReplicaCount,
243 spot: resources?.spot,
244 autoscalingMetricSpecs: resources?.autoscalingMetricSpecs,
245 machineSpec: {
246 machineType: resources?.machineSpec?.machineType ?? DEFAULT_MACHINE_TYPE,
247 acceleratorType: resources?.machineSpec?.acceleratorType,
248 acceleratorCount: resources?.machineSpec?.acceleratorCount,
249 },
250});
251
252const toAttrs = (
253 pool: aiplatform.GoogleCloudAiplatformV1DeploymentResourcePool,
254 project: string,
255) => {
256 const name = pool.name ?? "";
257 const parsed = parseResourceName(name, "deploymentResourcePools");
258 return {
259 name,
260 deploymentResourcePoolId: parsed.id,
261 project: parsed.project || project,
262 location: parsed.location,
263 dedicatedResources: dedicatedOf(pool.dedicatedResources),
264 disableContainerLogging: pool.disableContainerLogging === true,
265 serviceAccount: pool.serviceAccount,
266 kmsKeyName: pool.encryptionSpec?.kmsKeyName,
267 createTime: pool.createTime,
268 };
269};
270
271const getByName = (name: string) =>
272 aiplatform
273 .getProjectsLocationsDeploymentResourcePools({ name })
274 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
275
276const listPools = (project: string, region: string) => {
277 const collect = (parent: string) =>
278 aiplatform.listProjectsLocationsDeploymentResourcePools
279 .pages({ parent, pageSize: 100 })
280 .pipe(
281 Stream.flatMap((page) =>
282 Stream.fromIterable(page.deploymentResourcePools ?? []),
283 ),
284 Stream.runCollect,
285 Effect.map((chunk) => Array.from(chunk)),
286 );
287 const fallback = Effect.forEach(listLocations(region), (location) =>
288 collect(`projects/${project}/locations/${location}`),
289 ).pipe(Effect.map((pages) => pages.flat()));
290 // Vertex AI has no `locations/-` wildcard; scan known locations.
291 return fallback.pipe(Effect.catchTag("NotFound", () => Effect.succeed([])));
292};
293
294const findOwned = (hinted: string | undefined) =>
295 Effect.gen(function* () {
296 if (hinted === undefined || hinted.length === 0) return undefined;
297 return yield* getByName(hinted);
298 });
299
300const waitUntilExists = (name: string) =>
301 getByName(name).pipe(
302 Effect.flatMap((pool) =>
303 pool
304 ? Effect.succeed(pool)
305 : Effect.fail(new DeploymentResourcePoolNotResolved({ name })),
306 ),
307 Effect.retry({
308 while: (error) =>
309 error._tag === "GCP.AIPlatform.DeploymentResourcePoolNotResolved",
310 times: 8,
311 schedule: Schedule.spaced("2 seconds"),
312 }),
313 );
314
315const waitUntilGone = (name: string) =>
316 getByName(name).pipe(
317 Effect.flatMap((pool) =>
318 pool === undefined
319 ? Effect.void
320 : Effect.fail(new DeploymentResourcePoolStillExists({ name })),
321 ),
322 Effect.retry({
323 while: (error) =>
324 error._tag === "GCP.AIPlatform.DeploymentResourcePoolStillExists",
325 times: 10,
326 schedule: Schedule.spaced("3 seconds"),
327 }),
328 );
329
330const machineKey = (spec: MachineSpec | undefined) =>
331 `${spec?.machineType ?? DEFAULT_MACHINE_TYPE}|${spec?.acceleratorType ?? ""}|${spec?.acceleratorCount ?? 0}`;
332
333export const DeploymentResourcePoolProvider = () =>
334 Provider.succeed(DeploymentResourcePool, {
335 stables: [
336 "name",
337 "deploymentResourcePoolId",
338 "project",
339 "location",
340 "createTime",
341 ],
342
343 diff: Effect.fn(function* ({ news, olds, output }) {
344 const env = yield* GcpEnvironment.current;
345 if (!isResolved(news)) return undefined;
346 const previousId =
347 olds?.deploymentResourcePoolId ?? output?.deploymentResourcePoolId;
348 const nextId = news.deploymentResourcePoolId ?? previousId;
349 const previousLocation = normalizeLocation(
350 olds?.location ?? output?.location,
351 env.region,
352 );
353 const nextLocation = normalizeLocation(
354 news.location ?? olds?.location ?? output?.location,
355 env.region,
356 );
357 const previousMachine = machineKey(
358 olds?.dedicatedResources?.machineSpec ??
359 output?.dedicatedResources?.machineSpec,
360 );
361 const nextMachine = machineKey(
362 news.dedicatedResources?.machineSpec ??
363 olds?.dedicatedResources?.machineSpec ??
364 output?.dedicatedResources?.machineSpec,
365 );
366 const previousKey =
367 olds?.encryptionSpec?.kmsKeyName ?? output?.kmsKeyName ?? "";
368 const nextKey = news.encryptionSpec?.kmsKeyName ?? previousKey;
369 const replace =
370 (previousId !== undefined &&
371 nextId !== undefined &&
372 rfc1035(nextId) !== previousId) ||
373 previousLocation !== nextLocation ||
374 previousMachine !== nextMachine ||
375 previousKey !== nextKey ||
376 (olds !== undefined &&
377 (news.disableContainerLogging === true) !==
378 (olds.disableContainerLogging === true));
379 if (!replace) return undefined;
380 return {
381 action: "replace" as const,
382 deleteFirst:
383 previousLocation === nextLocation &&
384 previousId !== undefined &&
385 rfc1035(nextId ?? previousId) === previousId,
386 };
387 }),
388
389 read: Effect.fn(function* ({ id, olds, output }) {
390 const env = yield* GcpEnvironment.current;
391 const poolId = yield* toId(
392 id,
393 olds?.deploymentResourcePoolId,
394 output?.deploymentResourcePoolId,
395 );
396 const location = normalizeLocation(
397 olds?.location ?? output?.location,
398 env.region,
399 );
400 const name = output?.name ?? resourceName(env.project, location, poolId);
401 const existing = yield* getByName(name);
402 if (existing === undefined) return undefined;
403 const attrs = toAttrs(existing, env.project);
404 return isOwnedId(attrs.deploymentResourcePoolId) ||
405 output?.name === existing.name
406 ? attrs
407 : Unowned(attrs);
408 }),
409
410 list: () =>
411 Effect.gen(function* () {
412 const env = yield* GcpEnvironment.current;
413 const pools = yield* listPools(env.project, env.region);
414 return pools
415 .filter((pool) => isOwnedId(lastSegment(pool.name ?? "")))
416 .map((pool) => toAttrs(pool, env.project));
417 }),
418
419 reconcile: Effect.fn(function* ({ id, news, output }) {
420 const env = yield* GcpEnvironment.current;
421 const location = normalizeLocation(
422 news.location ?? output?.location,
423 env.region,
424 );
425 const poolId = yield* toId(
426 id,
427 news.deploymentResourcePoolId,
428 output?.deploymentResourcePoolId,
429 );
430 const name = resourceName(env.project, location, poolId);
431 const dedicated = desiredDedicated(news.dedicatedResources);
432 const serviceAccount = news.serviceAccount;
433 const disableContainerLogging = news.disableContainerLogging === true;
434
435 let current = yield* findOwned(output?.name ?? name);
436
437 if (current === undefined) {
438 const created = yield* aiplatform
439 .createProjectsLocationsDeploymentResourcePools({
440 parent: parentOf(env.project, location),
441 body: {
442 deploymentResourcePoolId: poolId,
443 deploymentResourcePool: {
444 dedicatedResources: dedicated,
445 disableContainerLogging,
446 serviceAccount,
447 encryptionSpec: news.encryptionSpec,
448 },
449 },
450 })
451 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
452 if (created !== undefined) {
453 const done = yield* waitForOperation(created);
454 const createdName = resourceNameFromOperation(done) ?? name;
455 current = yield* waitUntilExists(createdName);
456 }
457 if (current === undefined) {
458 current = yield* findOwned(name);
459 }
460 }
461
462 if (current === undefined) {
463 return yield* new DeploymentResourcePoolNotResolved({ name });
464 }
465
466 const currentName = current.name ?? name;
467 const observed = dedicatedOf(current.dedicatedResources);
468 const minChanged =
469 (observed?.minReplicaCount ?? 1) !== (dedicated.minReplicaCount ?? 1);
470 const maxChanged =
471 (observed?.maxReplicaCount ?? observed?.minReplicaCount ?? 1) !==
472 (dedicated.maxReplicaCount ?? dedicated.minReplicaCount ?? 1);
473 const saChanged =
474 serviceAccount !== undefined &&
475 (current.serviceAccount ?? "") !== serviceAccount;
476
477 if (minChanged || maxChanged || saChanged) {
478 const patched =
479 yield* aiplatform.patchProjectsLocationsDeploymentResourcePools({
480 name: currentName,
481 updateMask: [
482 minChanged ? "dedicated_resources.min_replica_count" : undefined,
483 maxChanged ? "dedicated_resources.max_replica_count" : undefined,
484 saChanged ? "service_account" : undefined,
485 ]
486 .filter((field): field is string => field !== undefined)
487 .join(","),
488 body: {
489 name: currentName,
490 dedicatedResources: dedicated,
491 serviceAccount,
492 },
493 });
494 yield* waitForOperation(patched);
495 current = yield* waitUntilExists(currentName);
496 }
497
498 return toAttrs(current, env.project);
499 }),
500
501 delete: Effect.fn(function* ({ output }) {
502 const existing = yield* getByName(output.name);
503 if (existing === undefined) return;
504 const operation = yield* aiplatform
505 .deleteProjectsLocationsDeploymentResourcePools({
506 name: output.name,
507 })
508 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
509 if (operation !== undefined) {
510 yield* waitForOperation(operation, { notFoundOk: true });
511 }
512 yield* waitUntilGone(output.name);
513 }),
514 });