1import * as apihub from "@distilled.cloud/gcp/apihub_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 ApihubInstanceFailed,
13 ApihubNotResolved,
14 encodeOwnershipLine,
15 hasOwnershipMarker,
16 locationParent,
17 MAX_PLUGIN_INSTANCE_DISPLAY_NAME_LENGTH,
18 normalizeLocation,
19 ownedByAlchemy,
20 ownershipLabels,
21 parseName,
22 parseOwnership,
23 replaceOnIdentity,
24 sameJson,
25 sameText,
26 toPhysicalId,
27 updateMaskOf,
28 waitUntilExists,
29 waitUntilGone,
30} from "./internal.ts";
31import { waitForDeleteOperation, waitForOperation } from "./internal.ts";
32
33export type PluginInstanceAction =
34 apihub.GoogleCloudApihubV1PluginInstanceAction;
35export type AuthConfig = apihub.GoogleCloudApihubV1AuthConfig;
36export type ConfigVariableMap = apihub.GoogleCloudApihubV1ConfigVariableMap;
37export type SourceEnvironmentMap =
38 apihub.GoogleCloudApihubV1SourceEnvironmentMap;
39
40export type PluginsInstanceProps = {
41 /**
42 * Parent plugin resource name
43 * `projects/{project}/locations/{location}/plugins/{plugin}`.
44 * Immutable — changing it replaces the instance.
45 */
46 plugin: string;
47 /**
48 * Plugin instance id (the `{instance}` segment). If omitted, a unique
49 * id is generated. Immutable — changing it replaces the instance.
50 */
51 pluginInstanceId?: string;
52 /**
53 * Location of the API Hub instance. Defaults to the parent plugin's
54 * location. Immutable — changing it replaces the instance.
55 */
56 location?: string;
57 /**
58 * Display name. Max 255 characters. Plugin instances have no
59 * description, so Alchemy stamps ownership into a `[alchemy …]` prefix
60 * and strips it from attributes.
61 */
62 displayName?: string;
63 /**
64 * Actions this instance should run. Each `actionId` must match an
65 * action on the parent plugin. Defaults to `{ actionId: "sync-metadata" }`.
66 */
67 actions?: PluginInstanceAction[];
68 /**
69 * Authentication configuration for this instance.
70 */
71 authConfig?: AuthConfig;
72 /**
73 * Additional config variables keyed by template id.
74 */
75 additionalConfig?: ConfigVariableMap;
76 /**
77 * Source project id (runtime project for Google Cloud plugins).
78 */
79 sourceProjectId?: string;
80 /**
81 * Source environment config keyed by environment name.
82 */
83 sourceEnvironmentsConfig?: SourceEnvironmentMap;
84};
85
86export type PluginsInstance = Resource<
87 "GCP.ApiHub.PluginsInstance",
88 PluginsInstanceProps,
89 {
90 /** Full resource name. */
91 name: string;
92 /** Plugin instance id (last path segment). */
93 pluginInstanceId: string;
94 /** Parent plugin resource name. */
95 plugin: string;
96 /** Parent plugin id. */
97 pluginId: string;
98 /** Project id. */
99 project: string;
100 /** Location id. */
101 location: string;
102 /** User display name with the Alchemy ownership prefix stripped. */
103 displayName: string | undefined;
104 /** Actions. */
105 actions: PluginInstanceAction[] | undefined;
106 /** Auth config. */
107 authConfig: AuthConfig | undefined;
108 /** Additional config. */
109 additionalConfig: ConfigVariableMap | undefined;
110 /** Source project id. */
111 sourceProjectId: string | undefined;
112 /** Source environment config. */
113 sourceEnvironmentsConfig: SourceEnvironmentMap | undefined;
114 /** Instance state. */
115 state: string | undefined;
116 /** Error message when state is ERROR or FAILED. */
117 errorMessage: string | undefined;
118 /** RFC3339 creation timestamp. */
119 createTime: string | undefined;
120 /** RFC3339 last-update timestamp. */
121 updateTime: string | undefined;
122 },
123 never,
124 Providers
125>;
126
127/**
128 * An API Hub plugin instance — a configured running copy of a plugin.
129 *
130 * Plugin instances have no labels or description, so Alchemy stamps
131 * ownership into the display name for `list` / nuke. Plugin, location,
132 * and instance id are identity. Display name (and schedule cron on
133 * actions) update in place; auth/additional config changes replace the
134 * instance because ApplyPluginInstanceConfig is not in the distilled SDK.
135 *
136 * ### Creating a Plugin Instance
137 * **Example:** Instance of a user-owned plugin
138 * ```typescript
139 * const plugin = yield* GCP.ApiHub.Plugin("OnRamp", {
140 * displayName: "on-ramp",
141 * });
142 * const instance = yield* GCP.ApiHub.PluginsInstance("Collector", {
143 * plugin: plugin.name,
144 * displayName: "orders collector",
145 * });
146 * ```
147 *
148 * **Example:** Named instance with an explicit action
149 * ```typescript
150 * const instance = yield* GCP.ApiHub.PluginsInstance("Collector", {
151 * plugin: plugin.name,
152 * pluginInstanceId: "orders-collector",
153 * actions: [{ actionId: "sync-metadata" }],
154 * });
155 * ```
156 *
157 * @resource
158 * @category ApiHub
159 */
160export const PluginsInstance = Resource<PluginsInstance>(
161 "GCP.ApiHub.PluginsInstance",
162);
163
164const DEFAULT_ACTIONS: PluginInstanceAction[] = [{ actionId: "sync-metadata" }];
165
166const resourceName = (plugin: string, pluginInstanceId: string) =>
167 `${plugin}/instances/${pluginInstanceId}`;
168
169const toAttrs = (
170 instance: apihub.GoogleCloudApihubV1PluginInstance,
171 project: string,
172 region: string,
173): PluginsInstance["Attributes"] => {
174 const name = instance.name ?? "";
175 const parsed = parseName(name, "instances", region);
176 const { text } = parseOwnership(instance.displayName);
177 return {
178 name,
179 pluginInstanceId: parsed.id,
180 plugin: parsed.plugin ?? parsed.parent,
181 pluginId: parsed.pluginId,
182 project: parsed.project || project,
183 location: parsed.location,
184 displayName: text,
185 actions: instance.actions,
186 authConfig: instance.authConfig,
187 additionalConfig: instance.additionalConfig,
188 sourceProjectId: instance.sourceProjectId,
189 sourceEnvironmentsConfig: instance.sourceEnvironmentsConfig,
190 state: instance.state === undefined ? undefined : `${instance.state}`,
191 errorMessage: instance.errorMessage,
192 createTime: instance.createTime,
193 updateTime: instance.updateTime,
194 };
195};
196
197const getByName = (name: string) =>
198 name.length === 0
199 ? Effect.succeed(undefined)
200 : apihub
201 .getProjectsLocationsPluginsInstances({ name })
202 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
203
204const listAt = (parent: string, project: string, region: string) =>
205 apihub.listProjectsLocationsPluginsInstances
206 .pages({ parent, pageSize: 1000 })
207 .pipe(
208 Stream.flatMap((page) => Stream.fromIterable(page.pluginInstances ?? [])),
209 Stream.filter((item) => hasOwnershipMarker(item.displayName)),
210 Stream.map((item) => toAttrs(item, project, region)),
211 Stream.runCollect,
212 Effect.map((chunk) => Array.from(chunk)),
213 Effect.catchTag("NotFound", () => Effect.succeed([])),
214 );
215
216const waitUntilReady = (name: string) =>
217 getByName(name).pipe(
218 Effect.filterOrFail(
219 (current): current is apihub.GoogleCloudApihubV1PluginInstance =>
220 current !== undefined &&
221 current.state !== "CREATING" &&
222 current.state !== "APPLYING_CONFIG",
223 () => new ApihubNotResolved({ name }),
224 ),
225 Effect.retry({
226 while: (error) => error._tag === "GCP.ApiHub.ResourceNotResolved",
227 times: 10,
228 schedule: Schedule.spaced("3 seconds"),
229 }),
230 Effect.flatMap((current) => {
231 if (current.state === "ERROR" || current.state === "FAILED") {
232 return Effect.fail(
233 new ApihubInstanceFailed({
234 name,
235 state: current.state,
236 message: current.errorMessage,
237 }),
238 );
239 }
240 return Effect.succeed(current);
241 }),
242 );
243
244const scheduleOf = (actions: PluginInstanceAction[] | undefined) =>
245 (actions ?? []).map((action) => ({
246 actionId: action.actionId,
247 scheduleCronExpression: action.scheduleCronExpression ?? "",
248 scheduleTimeZone: action.scheduleTimeZone ?? "",
249 }));
250
251export const PluginsInstanceProvider = () =>
252 Provider.succeed(PluginsInstance, {
253 stables: [
254 "name",
255 "pluginInstanceId",
256 "plugin",
257 "pluginId",
258 "project",
259 "location",
260 "createTime",
261 ],
262
263 diff: Effect.fn(function* ({ news, olds, output }) {
264 if (!isResolved(news)) return undefined;
265 const env = yield* GcpEnvironment.current;
266 const extra =
267 olds !== undefined &&
268 (!sameJson(news.authConfig, olds.authConfig) ||
269 !sameJson(news.additionalConfig, olds.additionalConfig) ||
270 !sameText(news.sourceProjectId, olds.sourceProjectId) ||
271 !sameJson(
272 news.sourceEnvironmentsConfig,
273 olds.sourceEnvironmentsConfig,
274 ) ||
275 !sameJson(
276 (news.actions ?? DEFAULT_ACTIONS).map((action) => action.actionId),
277 (olds.actions ?? DEFAULT_ACTIONS).map((action) => action.actionId),
278 ));
279 const previousPlugin = olds?.plugin ?? output?.plugin;
280 const pluginChanged =
281 previousPlugin !== undefined &&
282 news.plugin !== undefined &&
283 news.plugin !== previousPlugin;
284 const identity = replaceOnIdentity({
285 previousId: olds?.pluginInstanceId ?? output?.pluginInstanceId,
286 nextId:
287 news.pluginInstanceId ??
288 olds?.pluginInstanceId ??
289 output?.pluginInstanceId,
290 previousLocation: normalizeLocation(
291 olds?.location ?? output?.location,
292 env.region,
293 ),
294 nextLocation: normalizeLocation(
295 news.location ?? olds?.location ?? output?.location,
296 env.region,
297 ),
298 extra: extra || pluginChanged,
299 });
300 if (identity !== undefined) return identity;
301 if (pluginChanged) {
302 return { action: "replace" as const, deleteFirst: false };
303 }
304 return undefined;
305 }),
306
307 read: Effect.fn(function* ({ id, olds, output }) {
308 const env = yield* GcpEnvironment.current;
309 const plugin = olds?.plugin ?? output?.plugin ?? "";
310 const pluginInstanceId = yield* toPhysicalId(
311 id,
312 olds?.pluginInstanceId,
313 output?.pluginInstanceId,
314 );
315 const name =
316 output?.name ??
317 (plugin.length > 0 ? resourceName(plugin, pluginInstanceId) : "");
318 const existing = yield* getByName(name);
319 if (existing === undefined) return undefined;
320 const attrs = toAttrs(existing, env.project, env.region);
321 return (yield* ownedByAlchemy(id, existing.displayName))
322 ? attrs
323 : Unowned(attrs);
324 }),
325
326 list: () =>
327 Effect.gen(function* () {
328 const env = yield* GcpEnvironment.current;
329 return yield* listAt(
330 `${locationParent(env.project, env.region)}/plugins/-`,
331 env.project,
332 env.region,
333 );
334 }),
335
336 reconcile: Effect.fn(function* ({ id, news, output }) {
337 const env = yield* GcpEnvironment.current;
338 const plugin = news.plugin;
339 const parsedPlugin = parseName(plugin, "plugins", env.region);
340 const location = normalizeLocation(
341 news.location ?? parsedPlugin.location ?? output?.location,
342 env.region,
343 );
344 const pluginName = plugin.includes("/")
345 ? plugin
346 : `${locationParent(env.project, location)}/plugins/${plugin}`;
347 const pluginInstanceId = yield* toPhysicalId(
348 id,
349 news.pluginInstanceId,
350 output?.pluginInstanceId,
351 );
352 const name = resourceName(pluginName, pluginInstanceId);
353 const ownership = yield* ownershipLabels(id);
354 const displayName = encodeOwnershipLine(
355 ownership,
356 news.displayName,
357 MAX_PLUGIN_INSTANCE_DISPLAY_NAME_LENGTH,
358 );
359 const actions = news.actions ?? DEFAULT_ACTIONS;
360
361 let current = yield* getByName(output?.name ?? name);
362
363 if (current === undefined) {
364 const created = yield* apihub
365 .createProjectsLocationsPluginsInstances({
366 parent: pluginName,
367 pluginInstanceId,
368 body: {
369 displayName,
370 actions,
371 authConfig: news.authConfig,
372 additionalConfig: news.additionalConfig,
373 sourceProjectId: news.sourceProjectId,
374 sourceEnvironmentsConfig: news.sourceEnvironmentsConfig,
375 },
376 })
377 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
378 if (created !== undefined) {
379 yield* waitForOperation(created);
380 current = yield* waitUntilExists(getByName(name), name);
381 }
382 if (current === undefined) {
383 current = yield* waitUntilExists(getByName(name), name).pipe(
384 Effect.catchTag("GCP.ApiHub.ResourceNotResolved", () =>
385 Effect.succeed(undefined),
386 ),
387 );
388 }
389 }
390
391 if (current === undefined) {
392 return yield* new ApihubNotResolved({ name });
393 }
394
395 const currentName = current.name ?? name;
396 let ready = yield* waitUntilReady(currentName);
397
398 const observedDisplay = parseOwnership(ready.displayName).text;
399 const displayChanged = !sameText(observedDisplay, news.displayName);
400 const scheduleChanged = !sameJson(
401 scheduleOf(ready.actions),
402 scheduleOf(actions),
403 );
404 const updateMask = updateMaskOf(
405 displayChanged ? "display_name" : undefined,
406 scheduleChanged ? "actions" : undefined,
407 );
408
409 if (updateMask.length > 0) {
410 ready = yield* apihub.patchProjectsLocationsPluginsInstances({
411 name: currentName,
412 updateMask,
413 body: {
414 name: currentName,
415 displayName,
416 actions,
417 },
418 });
419 }
420
421 return toAttrs(ready, env.project, env.region);
422 }),
423
424 delete: Effect.fn(function* ({ output }) {
425 const operation = yield* apihub
426 .deleteProjectsLocationsPluginsInstances({ name: output.name })
427 .pipe(
428 Effect.retry({
429 while: (error) => error._tag === "Conflict",
430 times: 8,
431 schedule: Schedule.spaced("2 seconds"),
432 }),
433 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
434 );
435 if (operation !== undefined) {
436 yield* waitForDeleteOperation(operation);
437 }
438 yield* waitUntilGone(getByName(output.name), output.name);
439 }),
440 });