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 * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import { hasAlchemyLabels } from "../Labels.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 alchemyIdFilter,
16 createInternalLabels,
17 hasAlchemyPrefix,
18 labelsDiffer,
19 listLocations,
20 locationOf,
21 lastSegment,
22 normalizeLocation,
23 projectOf,
24 resourceNameFromOperation,
25 toDisplayName,
26 toLabels,
27 userLabels,
28} from "./names.ts";
29import { waitForOperation } from "./operations.ts";
30
31export type IndexEndpointProps = {
32 /**
33 * Vertex AI location (`us-central1`, …). Immutable — changing it
34 * replaces the endpoint. `US-CENTRAL1` is accepted and normalized to
35 * `us-central1`.
36 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
37 */
38 location?: string;
39 /**
40 * Display name (max 128 Unicode characters). Generated from the stack,
41 * stage, and logical id when omitted.
42 */
43 displayName?: string;
44 /**
45 * Human-readable description.
46 */
47 description?: string;
48 /**
49 * User labels. Alchemy ownership labels are merged in automatically.
50 */
51 labels?: Record<string, string>;
52 /**
53 * When true, the deployed index is reachable through a public endpoint.
54 * @default true
55 */
56 publicEndpointEnabled?: boolean;
57 /**
58 * VPC network to peer, as
59 * `projects/{projectNumber}/global/networks/{network}`. Mutually
60 * exclusive with private service connect. Immutable.
61 */
62 network?: string;
63 /**
64 * Customer-managed encryption key. Immutable.
65 */
66 encryptionSpec?: aiplatform.GoogleCloudAiplatformV1EncryptionSpec;
67 /**
68 * Private Service Connect config. Mutually exclusive with `network`.
69 * Immutable.
70 */
71 privateServiceConnectConfig?: aiplatform.GoogleCloudAiplatformV1PrivateServiceConnectConfig;
72};
73
74export type IndexEndpoint = Resource<
75 "GCP.AIPlatform.IndexEndpoint",
76 IndexEndpointProps,
77 {
78 /** Full resource name `projects/{project}/locations/{location}/indexEndpoints/{indexEndpoint}`. */
79 name: string;
80 /** IndexEndpoint id (last path segment). */
81 indexEndpointId: string;
82 /** Project id. */
83 project: string;
84 /** Location id. */
85 location: string;
86 /** Display name. */
87 displayName: string | undefined;
88 /** User-provided description. */
89 description: string | undefined;
90 /** User labels (Alchemy ownership labels stripped). */
91 labels: Record<string, string>;
92 /** Whether a public endpoint is enabled. */
93 publicEndpointEnabled: boolean;
94 /** Public endpoint domain, if enabled. */
95 publicEndpointDomainName: string | undefined;
96 /** Peered VPC network, if set. */
97 network: string | undefined;
98 /** Deployed index resource names. */
99 deployedIndexes: string[];
100 /** RFC3339 creation timestamp. */
101 createTime: string | undefined;
102 /** RFC3339 last-update timestamp. */
103 updateTime: string | undefined;
104 },
105 never,
106 Providers
107>;
108
109/**
110 * A Vertex AI Matching Engine IndexEndpoint that serves one or more
111 * deployed indexes.
112 *
113 * Changing `location`, `network`, encryption, or private-service-connect
114 * configuration replaces the endpoint. Display name, description, and
115 * labels update in place.
116 *
117 * ### Creating an IndexEndpoint
118 * **Example:** Public endpoint
119 * ```typescript
120 * const endpoint = yield* GCP.AIPlatform.IndexEndpoint("Search", {
121 * displayName: "product-search",
122 * publicEndpointEnabled: true,
123 * labels: { env: "prod" },
124 * });
125 * ```
126 *
127 * @resource
128 * @category AIPlatform
129 */
130export const IndexEndpoint = Resource<IndexEndpoint>(
131 "GCP.AIPlatform.IndexEndpoint",
132);
133
134export class IndexEndpointNotResolved extends Data.TaggedError(
135 "GCP.AIPlatform.IndexEndpointNotResolved",
136)<{
137 name: string;
138}> {}
139
140export class IndexEndpointStillExists extends Data.TaggedError(
141 "GCP.AIPlatform.IndexEndpointStillExists",
142)<{
143 name: string;
144}> {}
145
146const toAttrs = (
147 endpoint: aiplatform.GoogleCloudAiplatformV1IndexEndpoint,
148 project: string,
149) => {
150 const name = endpoint.name ?? "";
151 return {
152 name,
153 indexEndpointId: lastSegment(name),
154 project: projectOf(name, project),
155 location: locationOf(name),
156 displayName: endpoint.displayName,
157 description: endpoint.description,
158 labels: userLabels(endpoint.labels),
159 publicEndpointEnabled: endpoint.publicEndpointEnabled === true,
160 publicEndpointDomainName: endpoint.publicEndpointDomainName,
161 network: endpoint.network,
162 deployedIndexes: (endpoint.deployedIndexes ?? [])
163 .map((item) => item.index)
164 .filter((value): value is string => typeof value === "string"),
165 createTime: endpoint.createTime,
166 updateTime: endpoint.updateTime,
167 };
168};
169
170const getByName = (name: string) =>
171 aiplatform
172 .getProjectsLocationsIndexEndpoints({ name })
173 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
174
175const listPage = (parent: string, filter?: string) =>
176 aiplatform.listProjectsLocationsIndexEndpoints
177 .pages({ parent, pageSize: 100, filter })
178 .pipe(
179 Stream.runCollect,
180 Effect.map((pages) =>
181 Array.from(pages).flatMap((page) => page.indexEndpoints ?? []),
182 ),
183 Effect.catchTag("NotFound", () =>
184 Effect.succeed([] as aiplatform.GoogleCloudAiplatformV1IndexEndpoint[]),
185 ),
186 );
187
188const findOwned = (
189 project: string,
190 location: string,
191 labels: Record<string, string>,
192) =>
193 listPage(
194 `projects/${project}/locations/${location}`,
195 alchemyIdFilter(labels),
196 ).pipe(
197 Effect.map(
198 (items) =>
199 items.find((item) => hasAlchemyPrefix(item.labels)) ?? undefined,
200 ),
201 );
202
203const waitUntilExists = (name: string) =>
204 getByName(name).pipe(
205 Effect.filterOrFail(
206 (endpoint): endpoint is aiplatform.GoogleCloudAiplatformV1IndexEndpoint =>
207 endpoint !== undefined,
208 () => new IndexEndpointNotResolved({ name }),
209 ),
210 Effect.retry({
211 while: (error) =>
212 error._tag === "GCP.AIPlatform.IndexEndpointNotResolved",
213 times: 8,
214 schedule: Schedule.spaced("2 seconds"),
215 }),
216 );
217
218const waitUntilGone = (name: string) =>
219 getByName(name).pipe(
220 Effect.filterOrFail(
221 (endpoint) => endpoint === undefined,
222 () => new IndexEndpointStillExists({ name }),
223 ),
224 Effect.asVoid,
225 Effect.retry({
226 while: (error) =>
227 error._tag === "GCP.AIPlatform.IndexEndpointStillExists",
228 times: 10,
229 schedule: Schedule.spaced("2 seconds"),
230 }),
231 );
232
233export const IndexEndpointProvider = () =>
234 Provider.succeed(IndexEndpoint, {
235 stables: [
236 "name",
237 "indexEndpointId",
238 "project",
239 "location",
240 "network",
241 "createTime",
242 ],
243
244 diff: Effect.fn(function* ({ news, olds, output }) {
245 const env = yield* GcpEnvironment.current;
246 if (!isResolved(news)) return undefined;
247 const previousLocation = normalizeLocation(
248 olds?.location ?? output?.location,
249 env.region,
250 );
251 const nextLocation = normalizeLocation(
252 news.location ?? olds?.location ?? output?.location,
253 env.region,
254 );
255 const previousNetwork = olds?.network ?? output?.network ?? "";
256 const nextNetwork = news.network ?? previousNetwork;
257 const previousKms = olds?.encryptionSpec?.kmsKeyName ?? "";
258 const nextKms = news.encryptionSpec?.kmsKeyName ?? previousKms;
259 if (
260 previousLocation !== nextLocation ||
261 previousNetwork !== nextNetwork ||
262 nextKms !== previousKms
263 ) {
264 return { action: "replace" as const, deleteFirst: false };
265 }
266 return undefined;
267 }),
268
269 read: Effect.fn(function* ({ id, olds, output }) {
270 const env = yield* GcpEnvironment.current;
271 const location = normalizeLocation(
272 olds?.location ?? output?.location,
273 env.region,
274 );
275 const ownership = yield* createInternalLabels(id);
276 const existing =
277 (output?.name !== undefined
278 ? yield* getByName(output.name)
279 : undefined) ?? (yield* findOwned(env.project, location, ownership));
280 if (existing === undefined) return undefined;
281 const attrs = toAttrs(existing, env.project);
282 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
283 ? attrs
284 : Unowned(attrs);
285 }),
286
287 list: () =>
288 Effect.gen(function* () {
289 const env = yield* GcpEnvironment.current;
290 const pages = yield* Effect.forEach(
291 listLocations(env.region),
292 (location) =>
293 listPage(`projects/${env.project}/locations/${location}`),
294 { concurrency: 4 },
295 );
296 return pages
297 .flat()
298 .filter((endpoint) => hasAlchemyPrefix(endpoint.labels))
299 .map((endpoint) => toAttrs(endpoint, env.project));
300 }),
301
302 reconcile: Effect.fn(function* ({ id, news, output }) {
303 const env = yield* GcpEnvironment.current;
304 const location = normalizeLocation(
305 news.location ?? output?.location,
306 env.region,
307 );
308 const displayName = yield* toDisplayName(
309 id,
310 news.displayName,
311 output?.displayName,
312 );
313 const desiredLabels = {
314 ...toLabels(news.labels),
315 ...(yield* createInternalLabels(id)),
316 };
317 const publicEndpointEnabled = news.publicEndpointEnabled ?? true;
318
319 let current =
320 (output?.name !== undefined
321 ? yield* getByName(output.name)
322 : undefined) ??
323 (yield* findOwned(env.project, location, desiredLabels));
324
325 if (current === undefined) {
326 const created = yield* aiplatform
327 .createProjectsLocationsIndexEndpoints({
328 parent: `projects/${env.project}/locations/${location}`,
329 body: {
330 displayName,
331 description: news.description,
332 labels: desiredLabels,
333 publicEndpointEnabled,
334 network: news.network,
335 encryptionSpec: news.encryptionSpec,
336 privateServiceConnectConfig: news.privateServiceConnectConfig,
337 },
338 })
339 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
340 if (created !== undefined) {
341 const done = yield* waitForOperation(created);
342 const name = resourceNameFromOperation(done);
343 current =
344 name !== undefined
345 ? yield* waitUntilExists(name)
346 : yield* findOwned(env.project, location, desiredLabels);
347 }
348 if (current === undefined) {
349 current = yield* findOwned(env.project, location, desiredLabels);
350 }
351 }
352
353 if (current === undefined || current.name === undefined) {
354 return yield* new IndexEndpointNotResolved({
355 name: output?.name ?? `${location}/indexEndpoints`,
356 });
357 }
358
359 const name = current.name;
360 const descriptionChanged =
361 (current.description ?? "") !== (news.description ?? "");
362 const displayChanged = (current.displayName ?? "") !== displayName;
363 const labelsChanged = labelsDiffer(current.labels, desiredLabels);
364
365 if (descriptionChanged || displayChanged || labelsChanged) {
366 current = yield* aiplatform.patchProjectsLocationsIndexEndpoints({
367 name,
368 updateMask: [
369 descriptionChanged ? "description" : undefined,
370 displayChanged ? "display_name" : undefined,
371 labelsChanged ? "labels" : undefined,
372 ]
373 .filter((field): field is string => field !== undefined)
374 .join(","),
375 body: {
376 name,
377 displayName,
378 description: news.description,
379 labels: desiredLabels,
380 },
381 });
382 }
383
384 return toAttrs(current, env.project);
385 }),
386
387 delete: Effect.fn(function* ({ output }) {
388 const operation = yield* aiplatform
389 .deleteProjectsLocationsIndexEndpoints({ name: output.name })
390 .pipe(
391 Effect.retry({
392 while: (error) => error._tag === "Conflict",
393 times: 8,
394 schedule: Schedule.spaced("2 seconds"),
395 }),
396 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
397 );
398 if (operation !== undefined) {
399 yield* waitForOperation(operation, { notFoundOk: true });
400 }
401 yield* waitUntilGone(output.name);
402 }),
403 });