1import * as apihub from "@distilled.cloud/gcp/apihub_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { tagRecord } from "../../Tags.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 ApihubNotResolved,
13 MAX_INSTANCE_ID_LENGTH,
14 encodeOwnership,
15 hasAlchemyLabelMap,
16 hasOwnershipMarker,
17 locationParent,
18 normalizeLocation,
19 parseOwnership,
20 parseResourceName,
21 replaceOnIdentity,
22 sameText,
23 toPhysicalId,
24 updateMaskOf,
25 userLabels,
26 waitForDeleteOperation,
27 waitForOperation,
28 waitUntilExists,
29 waitUntilGone,
30} from "./internal.ts";
31
32export type ApiHubInstanceConfig = {
33 /** Disable search for the instance. */
34 disableSearch?: boolean;
35 /**
36 * Customer-managed encryption key
37 * `projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`.
38 * Immutable — changing it replaces the instance.
39 */
40 cmekKeyName?: string;
41 /**
42 * Encryption type. Immutable — changing it replaces the instance.
43 * @default "GMEK"
44 */
45 encryptionType?:
46 | "ENCRYPTION_TYPE_UNSPECIFIED"
47 | "GMEK"
48 | "CMEK"
49 | (string & {});
50 /** Vertex AI location used for the data store. */
51 vertexLocation?: string;
52 /** When true, MCP data is not synced to Agent Registry. */
53 agentRegistrySyncDisabled?: boolean;
54};
55
56export type ApiHubInstanceProps = {
57 /**
58 * ApiHub instance id (the `{apiHubInstance}` segment of
59 * `projects/{project}/locations/{location}/apiHubInstances/{apiHubInstance}`).
60 * If omitted, a unique id is generated. Must be 4-40 characters matching
61 * `[a-z0-9-_]+`. Immutable — changing it replaces the instance.
62 */
63 apiHubInstanceId?: string;
64 /**
65 * Location (`us-central1`, …). Only one ApiHub instance is allowed per
66 * project. Immutable — changing it replaces the instance.
67 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
68 */
69 location?: string;
70 /**
71 * Human-readable description. Ownership is stored in labels; the
72 * description is also stamped with `[alchemy …]` so `list` / nuke can
73 * find instances that predate label updates.
74 */
75 description?: string;
76 /**
77 * User labels. Alchemy ownership labels are merged in automatically.
78 */
79 labels?: Record<string, string>;
80 /**
81 * Provisioning config. `cmekKeyName` and `encryptionType` are immutable.
82 * Search, Vertex location, and Agent Registry sync update in place.
83 */
84 config?: ApiHubInstanceConfig;
85};
86
87export type ApiHubInstance = Resource<
88 "GCP.ApiHub.ApiHubInstance",
89 ApiHubInstanceProps,
90 {
91 /** Full resource name. */
92 name: string;
93 /** Instance id (last path segment). */
94 apiHubInstanceId: string;
95 /** Project id. */
96 project: string;
97 /** Location id. */
98 location: string;
99 /** User description with the Alchemy ownership prefix stripped. */
100 description: string | undefined;
101 /** User labels (Alchemy ownership labels stripped). */
102 labels: Record<string, string>;
103 /** Server-reported instance state. */
104 state: string | undefined;
105 /** Extra information when `state` is `FAILED`. */
106 stateMessage: string | undefined;
107 /** Whether search is disabled. */
108 disableSearch: boolean;
109 /** Customer-managed encryption key, if any. */
110 cmekKeyName: string | undefined;
111 /** Encryption type. */
112 encryptionType: string | undefined;
113 /** Vertex AI location. */
114 vertexLocation: string | undefined;
115 /** Whether Agent Registry sync is disabled. */
116 agentRegistrySyncDisabled: boolean;
117 /** RFC3339 creation timestamp. */
118 createTime: string | undefined;
119 /** RFC3339 last-update timestamp. */
120 updateTime: string | undefined;
121 },
122 never,
123 Providers
124>;
125
126/**
127 * A Google Cloud API Hub instance. One instance is allowed per project.
128 *
129 * Location, instance id, CMEK, and encryption type are immutable. Search,
130 * Vertex location, and Agent Registry sync update in place.
131 *
132 * ### Creating an ApiHub Instance
133 * **Example:** Generated id in us-central1
134 * ```typescript
135 * const hub = yield* GCP.ApiHub.ApiHubInstance("Hub", {
136 * labels: { env: "test" },
137 * config: { disableSearch: false },
138 * });
139 * ```
140 *
141 * **Example:** Named instance with Vertex location
142 * ```typescript
143 * const hub = yield* GCP.ApiHub.ApiHubInstance("Hub", {
144 * apiHubInstanceId: "alchemy-hub",
145 * location: "us-central1",
146 * config: { vertexLocation: "us-central1" },
147 * });
148 * ```
149 *
150 * @resource
151 * @category ApiHub
152 */
153export const ApiHubInstance = Resource<ApiHubInstance>(
154 "GCP.ApiHub.ApiHubInstance",
155);
156
157const resourceName = (
158 project: string,
159 location: string,
160 apiHubInstanceId: string,
161) => `${locationParent(project, location)}/apiHubInstances/${apiHubInstanceId}`;
162
163const toAttrs = (
164 instance: apihub.GoogleCloudApihubV1ApiHubInstance,
165 project: string,
166 region: string,
167) => {
168 const name = instance.name ?? "";
169 const parsed = parseResourceName(name, "apiHubInstances", region);
170 const description = parseOwnership(instance.description).text;
171 return {
172 name,
173 apiHubInstanceId: parsed.id,
174 project: parsed.project || project,
175 location: parsed.location,
176 description,
177 labels: userLabels(instance.labels),
178 state: instance.state,
179 stateMessage: instance.stateMessage,
180 disableSearch: instance.config?.disableSearch === true,
181 cmekKeyName: instance.config?.cmekKeyName,
182 encryptionType: instance.config?.encryptionType,
183 vertexLocation: instance.config?.vertexLocation,
184 agentRegistrySyncDisabled:
185 instance.config?.agentRegistrySyncConfig?.disabled === true,
186 createTime: instance.createTime,
187 updateTime: instance.updateTime,
188 };
189};
190
191const getByName = (name: string) =>
192 name.length === 0
193 ? Effect.succeed(undefined)
194 : apihub
195 .getProjectsLocationsApiHubInstances({ name })
196 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
197
198const lookupAt = (parent: string) =>
199 apihub.lookupProjectsLocationsApiHubInstances({ parent }).pipe(
200 Effect.map((response) => response.apiHubInstance),
201 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
202 );
203
204const configOf = (
205 news: ApiHubInstanceProps,
206): apihub.GoogleCloudApihubV1Config => ({
207 disableSearch: news.config?.disableSearch,
208 cmekKeyName: news.config?.cmekKeyName,
209 encryptionType: news.config?.encryptionType,
210 vertexLocation: news.config?.vertexLocation,
211 agentRegistrySyncConfig:
212 news.config?.agentRegistrySyncDisabled === undefined
213 ? undefined
214 : { disabled: news.config.agentRegistrySyncDisabled },
215});
216
217export const ApiHubInstanceProvider = () =>
218 Provider.succeed(ApiHubInstance, {
219 stables: ["name", "apiHubInstanceId", "project", "location", "createTime"],
220
221 diff: Effect.fn(function* ({ news, olds, output }) {
222 if (!isResolved(news)) return undefined;
223 const env = yield* GcpEnvironment.current;
224 return replaceOnIdentity({
225 previousId: olds?.apiHubInstanceId ?? output?.apiHubInstanceId,
226 nextId:
227 news.apiHubInstanceId ??
228 olds?.apiHubInstanceId ??
229 output?.apiHubInstanceId,
230 previousLocation: normalizeLocation(
231 olds?.location ?? output?.location,
232 env.region,
233 ),
234 nextLocation: normalizeLocation(
235 news.location ?? olds?.location ?? output?.location,
236 env.region,
237 ),
238 extra:
239 (news.config?.cmekKeyName ?? olds?.config?.cmekKeyName) !==
240 (olds?.config?.cmekKeyName ?? output?.cmekKeyName) ||
241 (news.config?.encryptionType ?? olds?.config?.encryptionType) !==
242 (olds?.config?.encryptionType ?? output?.encryptionType),
243 });
244 }),
245
246 read: Effect.fn(function* ({ id, olds, output }) {
247 const env = yield* GcpEnvironment.current;
248 const location = normalizeLocation(
249 olds?.location ?? output?.location,
250 env.region,
251 );
252 const apiHubInstanceId = yield* toPhysicalId(
253 id,
254 olds?.apiHubInstanceId,
255 output?.apiHubInstanceId,
256 MAX_INSTANCE_ID_LENGTH,
257 );
258 const name =
259 output?.name ?? resourceName(env.project, location, apiHubInstanceId);
260 let existing = yield* getByName(name);
261 if (existing === undefined) {
262 existing = yield* lookupAt(locationParent(env.project, location));
263 }
264 if (existing === undefined) return undefined;
265 const attrs = toAttrs(existing, env.project, env.region);
266 const labeled = yield* hasAlchemyLabels(id, tagRecord(existing.labels));
267 const described = yield* ownedByLabelsOrDescription(
268 id,
269 existing.labels,
270 existing.description,
271 );
272 return labeled || described ? attrs : Unowned(attrs);
273 }),
274
275 list: () =>
276 Effect.gen(function* () {
277 const env = yield* GcpEnvironment.current;
278 const existing = yield* lookupAt(
279 locationParent(env.project, env.region),
280 );
281 if (existing === undefined) return [];
282 if (
283 !hasAlchemyLabelMap(existing.labels) &&
284 !hasOwnershipMarker(existing.description)
285 ) {
286 return [];
287 }
288 return [toAttrs(existing, env.project, env.region)];
289 }),
290
291 reconcile: Effect.fn(function* ({ id, news, output }) {
292 const env = yield* GcpEnvironment.current;
293 const location = normalizeLocation(
294 news.location ?? output?.location,
295 env.region,
296 );
297 const parent = locationParent(env.project, location);
298 const apiHubInstanceId = yield* toPhysicalId(
299 id,
300 news.apiHubInstanceId,
301 output?.apiHubInstanceId,
302 MAX_INSTANCE_ID_LENGTH,
303 );
304 const name = resourceName(env.project, location, apiHubInstanceId);
305 const ownership = yield* createInternalLabels(id);
306 const desiredLabels = {
307 ...toLabels(news.labels),
308 ...ownership,
309 };
310 const description = encodeOwnership(ownership, news.description);
311 const config = configOf(news);
312
313 let current = yield* getByName(output?.name ?? name);
314 if (current === undefined) {
315 current = yield* lookupAt(parent);
316 }
317
318 if (current === undefined) {
319 const created = yield* apihub
320 .createProjectsLocationsApiHubInstances({
321 parent,
322 apiHubInstanceId,
323 body: {
324 description,
325 labels: desiredLabels,
326 config,
327 },
328 })
329 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
330 if (created !== undefined) {
331 yield* waitForOperation(created);
332 }
333 current =
334 (yield* lookupAt(parent)) ??
335 (yield* waitUntilExists(getByName(name), name));
336 }
337
338 if (current === undefined) {
339 return yield* new ApihubNotResolved({ name });
340 }
341
342 const currentName = current.name ?? name;
343 const disableChanged =
344 (current.config?.disableSearch === true) !==
345 (news.config?.disableSearch === true);
346 const vertexChanged = !sameText(
347 current.config?.vertexLocation,
348 news.config?.vertexLocation,
349 );
350 const syncChanged =
351 (current.config?.agentRegistrySyncConfig?.disabled === true) !==
352 (news.config?.agentRegistrySyncDisabled === true);
353
354 if (disableChanged || vertexChanged || syncChanged) {
355 const operation = yield* apihub.patchProjectsLocationsApiHubInstances({
356 name: currentName,
357 updateMask: updateMaskOf(
358 disableChanged ? "config.disable_search" : undefined,
359 vertexChanged ? "config.vertex_location" : undefined,
360 syncChanged ? "config.agent_registry_sync_config" : undefined,
361 ),
362 body: {
363 name: currentName,
364 config,
365 },
366 });
367 yield* waitForOperation(operation);
368 current = yield* waitUntilExists(getByName(currentName), currentName);
369 }
370
371 if (current === undefined) {
372 return yield* new ApihubNotResolved({ name: currentName });
373 }
374
375 return toAttrs(current, env.project, env.region);
376 }),
377
378 delete: Effect.fn(function* ({ output }) {
379 const operation = yield* apihub
380 .deleteProjectsLocationsApiHubInstances({ name: output.name })
381 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
382 if (operation !== undefined) {
383 yield* waitForDeleteOperation(operation);
384 }
385 yield* waitUntilGone(getByName(output.name), output.name);
386 }),
387 });
388
389const ownedByLabelsOrDescription = (
390 id: string,
391 labels: Record<string, string | undefined> | null | undefined,
392 description: string | undefined,
393) =>
394 Effect.gen(function* () {
395 if (yield* hasAlchemyLabels(id, tagRecord(labels))) return true;
396 const parsed = parseOwnership(description);
397 return yield* hasAlchemyLabels(id, parsed.labels);
398 });