1import * as networkservices from "@distilled.cloud/gcp/networkservices_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 DEFAULT_GLOBAL,
14 canonicalizeLink,
15 collectPages,
16 hasAlchemyLabelKeys,
17 lastSegment,
18
19 normalizeLocation,
20 parentOf,
21 parseName,
22 rfc1035,
23 toPhysicalId,
24 userLabels,
25 waitForOperation,
26 waitUntilGone,
27 waitUntilPresent,
28} from "./internal.ts";
29
30const COLLECTION = "versions";
31
32export type WasmPluginsVersionProps = {
33 /**
34 * Parent WasmPlugin. Full name
35 * `projects/{project}/locations/{location}/wasmPlugins/{wasmPlugin}`
36 * or the plugin id (combined with `location`). Immutable — changing
37 * it replaces the version.
38 */
39 wasmPlugin: string;
40 /**
41 * Version id (the `{wasmPluginVersion}` segment of
42 * `.../wasmPlugins/{wasmPlugin}/versions/{wasmPluginVersion}`). If
43 * omitted, a unique name is generated from the stack, stage, and
44 * logical id. Must be 1-63 characters and not start with a number.
45 * Immutable — changing it replaces the version.
46 */
47 wasmPluginVersionId?: string;
48 /**
49 * Location of the parent plugin. Used when `wasmPlugin` is a bare
50 * id. Wasm plugins live in `global`. Immutable — changing it
51 * replaces the version. `GLOBAL` is accepted and normalized to
52 * `global`.
53 * @default "global"
54 */
55 location?: string;
56 /**
57 * Human-readable description. Versions have no update API, so
58 * changing this field replaces the version.
59 */
60 description?: string;
61 /**
62 * User labels. Alchemy ownership labels are merged in automatically.
63 * Versions have no update API, so changing labels replaces the version.
64 */
65 labels?: Record<string, string>;
66 /**
67 * Artifact Registry URI of the image that contains `plugin.wasm`.
68 * Docker (`LOCATION-docker.pkg.dev/...`) or generic artifact
69 * (`projects/{project}/locations/{location}/repositories/{repository}/genericArtifacts/{package}:{version}`)
70 * forms are accepted. Immutable — changing it replaces the version.
71 */
72 imageUri?: string;
73 /**
74 * Base64-encoded plugin configuration delivered through `ON_CONFIGURE`.
75 * Mutually exclusive with `pluginConfigUri`. Immutable — changing it
76 * replaces the version.
77 */
78 pluginConfigData?: string;
79 /**
80 * Artifact Registry URI of an image that contains `plugin.config`.
81 * Mutually exclusive with `pluginConfigData`. Immutable — changing it
82 * replaces the version.
83 */
84 pluginConfigUri?: string;
85};
86
87export type WasmPluginsVersion = Resource<
88 "GCP.NetworkServices.WasmPluginsVersion",
89 WasmPluginsVersionProps,
90 {
91 /** Full resource name `projects/{project}/locations/{location}/wasmPlugins/{wasmPlugin}/versions/{wasmPluginVersion}`. */
92 name: string;
93 /** Version id (last path segment). */
94 wasmPluginVersionId: string;
95 /** Parent WasmPlugin resource name. */
96 wasmPlugin: string;
97 /** Parent WasmPlugin id. */
98 wasmPluginId: string;
99 /** Project id. */
100 project: string;
101 /** Location id (`global`). */
102 location: string;
103 /** User-provided description. */
104 description: string | undefined;
105 /** User labels (Alchemy ownership labels stripped). */
106 labels: Record<string, string>;
107 /** Image URI that supplied the Wasm module. */
108 imageUri: string | undefined;
109 /** Resolved image digest. */
110 imageDigest: string | undefined;
111 /** Plugin-config Artifact Registry URI, if set. */
112 pluginConfigUri: string | undefined;
113 /** Digest of plugin configuration. */
114 pluginConfigDigest: string | undefined;
115 /** RFC3339 creation timestamp. */
116 createTime: string | undefined;
117 /** RFC3339 last-update timestamp. */
118 updateTime: string | undefined;
119 },
120 never,
121 Providers
122>;
123
124/**
125 * An immutable WasmPluginVersion nested under a WasmPlugin. Each
126 * version points at a Wasm module in Artifact Registry and optionally
127 * a runtime config.
128 *
129 * There is no patch API. Changing the parent plugin, version id,
130 * location, image URI, or plugin config replaces the version.
131 * Description and labels are applied at create time.
132 *
133 * ### Creating a WasmPluginsVersion
134 * **Example:** Version from a Docker image
135 * ```typescript
136 * const version = yield* GCP.NetworkServices.WasmPluginsVersion("V1", {
137 * wasmPlugin: plugin.name,
138 * imageUri: "us-central1-docker.pkg.dev/my-project/plugins/edge:v1",
139 * });
140 * ```
141 *
142 * **Example:** Named version with config
143 * ```typescript
144 * const version = yield* GCP.NetworkServices.WasmPluginsVersion("V1", {
145 * wasmPlugin: plugin.name,
146 * wasmPluginVersionId: "v1",
147 * description: "prod edge v1",
148 * labels: { env: "prod" },
149 * imageUri:
150 * "projects/my-project/locations/us-central1/repositories/plugins/genericArtifacts/edge:v1",
151 * pluginConfigData: "e30=",
152 * });
153 * ```
154 *
155 * @resource
156 * @category NetworkServices
157 */
158export const WasmPluginsVersion = Resource<WasmPluginsVersion>(
159 "GCP.NetworkServices.WasmPluginsVersion",
160);
161
162const parentFromVersionName = (name: string) => {
163 const index = name.lastIndexOf("/versions/");
164 return index >= 0 ? name.slice(0, index) : name;
165};
166
167const locationFromParent = (wasmPlugin: string, fallback: string) => {
168 const canonical = canonicalizeLink(wasmPlugin);
169 if (!canonical.includes("/locations/")) return fallback;
170 return parseName(canonical, "wasmPlugins", fallback).location;
171};
172
173const parentPluginName = (
174 project: string,
175 location: string,
176 wasmPlugin: string,
177) => {
178 const canonical = canonicalizeLink(wasmPlugin);
179 if (canonical.includes("/wasmPlugins/")) return canonical;
180 const id = lastSegment(canonical);
181 return `projects/${project}/locations/${location}/wasmPlugins/${id}`;
182};
183
184const resourceNameOf = (parent: string, versionId: string) =>
185 `${parent}/versions/${versionId}`;
186
187const linkKey = (value: string | undefined) =>
188 lastSegment(canonicalizeLink(value)).toLowerCase();
189
190const toAttrs = (
191 version: networkservices.WasmPluginVersion,
192 project: string,
193) => {
194 const name = version.name ?? "";
195 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
196 const parent = parentFromVersionName(name);
197 const pluginParsed = parseName(parent, "wasmPlugins", DEFAULT_GLOBAL);
198 return {
199 name,
200 wasmPluginVersionId: parsed.id,
201 wasmPlugin: parent,
202 wasmPluginId: pluginParsed.id,
203 project: parsed.project || project,
204 location: parsed.location || DEFAULT_GLOBAL,
205 description: version.description,
206 labels: userLabels(version.labels),
207 imageUri: version.imageUri,
208 imageDigest: version.imageDigest,
209 pluginConfigUri: version.pluginConfigUri,
210 pluginConfigDigest: version.pluginConfigDigest,
211 createTime: version.createTime,
212 updateTime: version.updateTime,
213 };
214};
215
216const getByName = (name: string) =>
217 name.length === 0 ||
218 !name.includes("/wasmPlugins/") ||
219 name.includes("/wasmPlugins//")
220 ? Effect.succeed(undefined)
221 : networkservices
222 .getProjectsLocationsWasmPluginsVersions({ name })
223 .pipe(Effect.catchTag(["NotFound"], () => Effect.succeed(undefined)));
224
225const getPlugin = (name: string) =>
226 networkservices
227 .getProjectsLocationsWasmPlugins({ name })
228 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
229
230const replaceIfChanged = (
231 previous: string | undefined,
232 next: string | undefined,
233) =>
234 previous !== undefined &&
235 next !== undefined &&
236 previous.length > 0 &&
237 next.length > 0 &&
238 previous !== next;
239
240export const WasmPluginsVersionProvider = () =>
241 Provider.succeed(WasmPluginsVersion, {
242 stables: [
243 "name",
244 "wasmPluginVersionId",
245 "wasmPlugin",
246 "wasmPluginId",
247 "project",
248 "location",
249 "createTime",
250 ],
251
252 diff: Effect.fn(function* ({ news, olds, output }) {
253 if (!isResolved(news)) return undefined;
254 const previousId =
255 olds?.wasmPluginVersionId ?? output?.wasmPluginVersionId;
256 const nextId = news.wasmPluginVersionId
257 ? rfc1035(news.wasmPluginVersionId, "wasm-plugin-version")
258 : previousId;
259 const previousLocation = normalizeLocation(
260 olds?.location ?? output?.location,
261 DEFAULT_GLOBAL,
262 );
263 const nextLocation = normalizeLocation(
264 news.location ?? locationFromParent(news.wasmPlugin, previousLocation),
265 DEFAULT_GLOBAL,
266 );
267 const previousParent = linkKey(olds?.wasmPlugin ?? output?.wasmPlugin);
268 const nextParent = linkKey(news.wasmPlugin);
269 if (
270 replaceIfChanged(previousId, nextId) ||
271 previousLocation !== nextLocation ||
272 (previousParent.length > 0 && previousParent !== nextParent) ||
273 replaceIfChanged(olds?.imageUri ?? output?.imageUri, news.imageUri) ||
274 replaceIfChanged(
275 olds?.pluginConfigUri ?? output?.pluginConfigUri,
276 news.pluginConfigUri,
277 ) ||
278 replaceIfChanged(olds?.pluginConfigData, news.pluginConfigData)
279 ) {
280 return { action: "replace" as const };
281 }
282 return undefined;
283 }),
284
285 read: Effect.fn(function* ({ id, olds, output }) {
286 const env = yield* GcpEnvironment.current;
287 const location = normalizeLocation(
288 olds?.location ??
289 output?.location ??
290 (olds?.wasmPlugin
291 ? locationFromParent(olds.wasmPlugin, DEFAULT_GLOBAL)
292 : undefined),
293 DEFAULT_GLOBAL,
294 );
295 const parent = parentPluginName(
296 env.project,
297 location,
298 olds?.wasmPlugin ?? output?.wasmPlugin ?? "",
299 );
300 const versionId = yield* toPhysicalId(
301 id,
302 olds?.wasmPluginVersionId,
303 output?.wasmPluginVersionId,
304 "wasm-plugin-version",
305 );
306 const name = output?.name ?? resourceNameOf(parent, versionId);
307 const existing = yield* getByName(name);
308 if (existing === undefined) return undefined;
309 const attrs = toAttrs(existing, env.project);
310 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
311 ? attrs
312 : Unowned(attrs);
313 }),
314
315 list: () =>
316 Effect.gen(function* () {
317 const env = yield* GcpEnvironment.current;
318 const plugins = yield* collectPages(
319 networkservices.listProjectsLocationsWasmPlugins.pages({
320 parent: parentOf(env.project, DEFAULT_GLOBAL),
321 pageSize: 1000,
322 }),
323 (page) => page.wasmPlugins,
324 );
325 const nested = yield* Effect.forEach(
326 plugins.filter((plugin) => (plugin.name ?? "").length > 0),
327 (plugin) =>
328 collectPages(
329 networkservices.listProjectsLocationsWasmPluginsVersions.pages({
330 parent: plugin.name ?? "",
331 pageSize: 1000,
332 }),
333 (page) => page.wasmPluginVersions,
334 ),
335 { concurrency: 4 },
336 );
337 return nested
338 .flat()
339 .filter((item) => hasAlchemyLabelKeys(item.labels))
340 .map((item) => toAttrs(item, env.project));
341 }),
342
343 reconcile: Effect.fn(function* ({ id, news, output }) {
344 const env = yield* GcpEnvironment.current;
345 const location = normalizeLocation(
346 news.location ??
347 output?.location ??
348 locationFromParent(news.wasmPlugin, DEFAULT_GLOBAL),
349 DEFAULT_GLOBAL,
350 );
351 const parent = parentPluginName(env.project, location, news.wasmPlugin);
352 const versionId = yield* toPhysicalId(
353 id,
354 news.wasmPluginVersionId,
355 output?.wasmPluginVersionId,
356 "wasm-plugin-version",
357 );
358 const name = resourceNameOf(parent, versionId);
359 const desiredLabels = {
360 ...toLabels(news.labels),
361 ...(yield* createInternalLabels(id)),
362 };
363
364 let current = yield* getByName(name);
365
366 if (current === undefined) {
367 const created = yield* networkservices
368 .createProjectsLocationsWasmPluginsVersions({
369 parent,
370 wasmPluginVersionId: versionId,
371 body: {
372 labels: desiredLabels,
373 description: news.description,
374 imageUri: news.imageUri,
375 pluginConfigData: news.pluginConfigData,
376 pluginConfigUri: news.pluginConfigUri,
377 },
378 })
379 .pipe(
380 Effect.retry({
381 while: (error) => error._tag === "Conflict",
382 times: 5,
383 schedule: Schedule.spaced("2 seconds"),
384 }),
385 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
386 );
387 if (created !== undefined) {
388 yield* waitForOperation(created);
389 }
390 current = yield* waitUntilPresent(getByName(name), name);
391 }
392
393 return toAttrs(current, env.project);
394 }),
395
396 delete: Effect.fn(function* ({ output }) {
397 const plugin = yield* getPlugin(output.wasmPlugin);
398 if (
399 plugin !== undefined &&
400 (plugin.mainVersionId ?? "") === output.wasmPluginVersionId
401 ) {
402 const siblings = yield* collectPages(
403 networkservices.listProjectsLocationsWasmPluginsVersions.pages({
404 parent: output.wasmPlugin,
405 pageSize: 1000,
406 }),
407 (page) => page.wasmPluginVersions,
408 );
409 const other = siblings.find(
410 (version) =>
411 lastSegment(version.name ?? "") !== output.wasmPluginVersionId,
412 );
413 if (other !== undefined) {
414 const nextMain = lastSegment(other.name ?? "");
415 const switched = yield* networkservices
416 .patchProjectsLocationsWasmPlugins({
417 name: output.wasmPlugin,
418 updateMask: "mainVersionId",
419 body: {
420 name: output.wasmPlugin,
421 mainVersionId: nextMain,
422 },
423 })
424 .pipe(
425 Effect.catchTag(["NotFound"], () => Effect.succeed(undefined)),
426 );
427 if (switched !== undefined) {
428 yield* waitForOperation(switched, { notFoundOk: true });
429 }
430 }
431 }
432
433 yield* Effect.gen(function* () {
434 const operation = yield* networkservices
435 .deleteProjectsLocationsWasmPluginsVersions({ name: output.name })
436 .pipe(Effect.catchTag(["NotFound"], () => Effect.succeed(undefined)));
437 if (operation !== undefined) {
438 yield* waitForOperation(operation, { notFoundOk: true });
439 }
440 }).pipe(
441 Effect.retry({
442 while: (error) =>
443 error._tag === "Conflict" ||
444 // FAILED_PRECONDITION: still referenced by a route/extension.
445 (error._tag === "GCP.OperationFailed" && error.code === 9),
446 times: 8,
447 schedule: Schedule.spaced("2 seconds"),
448 }),
449 );
450 yield* waitUntilGone(getByName(output.name), output.name);
451 }),
452 });