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 {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 DEFAULT_GLOBAL,
19 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 resourceName,
26 rfc1035,
27 sameJson,
28 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilGone,
32 waitUntilPresent,
33} from "./internal.ts";
34
35const COLLECTION = "wasmPlugins";
36
37export type WasmPluginLogLevel =
38 | networkservices.WasmPluginLogConfigMinLogLevelEnum
39 | (string & {});
40
41export type WasmPluginLogConfig = {
42 /**
43 * Whether plugin activity logs are exported to Cloud Logging.
44 * @default false
45 */
46 enable?: boolean;
47 /**
48 * Sampling rate in `[0.0, 1.0]` when logging is enabled. `1.0` reports
49 * every log statement.
50 * @default 1
51 */
52 sampleRate?: number;
53 /**
54 * Lowest plugin log level exported to Cloud Logging. Only set when
55 * logging is enabled.
56 * @default "INFO"
57 */
58 minLogLevel?: WasmPluginLogLevel;
59};
60
61export type WasmPluginVersionInput = {
62 /** Human-readable description of this version. */
63 description?: string;
64 /** User labels on this version. Alchemy ownership labels are merged in. */
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 on the child version resource.
71 */
72 imageUri?: string;
73 /**
74 * Base64-encoded plugin configuration delivered through `ON_CONFIGURE`.
75 * Mutually exclusive with `pluginConfigUri`.
76 */
77 pluginConfigData?: string;
78 /**
79 * Artifact Registry URI of an image that contains `plugin.config`.
80 * Mutually exclusive with `pluginConfigData`.
81 */
82 pluginConfigUri?: string;
83};
84
85export type WasmPluginVersionDetails = {
86 /** User-provided description. */
87 description: string | undefined;
88 /** User labels (Alchemy ownership labels stripped). */
89 labels: Record<string, string>;
90 /** Image URI that supplied the Wasm module. */
91 imageUri: string | undefined;
92 /** Resolved image digest. */
93 imageDigest: string | undefined;
94 /** Plugin-config Artifact Registry URI, if set. */
95 pluginConfigUri: string | undefined;
96 /** Digest of plugin configuration. */
97 pluginConfigDigest: string | undefined;
98 /** RFC3339 creation timestamp. */
99 createTime: string | undefined;
100 /** RFC3339 last-update timestamp. */
101 updateTime: string | undefined;
102};
103
104export type WasmPluginProps = {
105 /**
106 * WasmPlugin id (the `{wasmPlugin}` segment of
107 * `projects/{project}/locations/{location}/wasmPlugins/{wasmPlugin}`).
108 * If omitted, a unique name is generated from the stack, stage, and
109 * logical id. Must be 1-63 characters and not start with a number.
110 * Immutable — changing it replaces the plugin.
111 */
112 wasmPluginId?: string;
113 /**
114 * Location. Wasm plugins live in `global`. Immutable — changing it
115 * replaces the plugin. `GLOBAL` is accepted and normalized to `global`.
116 * @default "global"
117 */
118 location?: string;
119 /**
120 * Human-readable description.
121 */
122 description?: string;
123 /**
124 * User labels. Alchemy ownership labels are merged in automatically.
125 */
126 labels?: Record<string, string>;
127 /**
128 * Id of the currently serving `WasmPluginVersion`. Must name a child
129 * version of this plugin. Omit until a version exists; updating this
130 * field does not replace the plugin.
131 */
132 mainVersionId?: string;
133 /**
134 * Logging options for statements emitted by the Wasm module.
135 */
136 logConfig?: WasmPluginLogConfig;
137 /**
138 * Inline versions keyed by version id. When set, this map is
139 * authoritative on create and update — omitted keys are deleted.
140 * Leave unset to manage versions through `WasmPluginsVersion` without
141 * the plugin wiping them on reconcile.
142 */
143 versions?: Record<string, WasmPluginVersionInput>;
144};
145
146export type WasmPlugin = Resource<
147 "GCP.NetworkServices.WasmPlugin",
148 WasmPluginProps,
149 {
150 /** Full resource name `projects/{project}/locations/{location}/wasmPlugins/{wasmPlugin}`. */
151 name: string;
152 /** WasmPlugin id (last path segment). */
153 wasmPluginId: string;
154 /** Project id. */
155 project: string;
156 /** Location id (`global`). */
157 location: string;
158 /** User-provided description. */
159 description: string | undefined;
160 /** User labels (Alchemy ownership labels stripped). */
161 labels: Record<string, string>;
162 /** Currently serving version id, if set. */
163 mainVersionId: string | undefined;
164 /** Logging configuration, if set. */
165 logConfig: WasmPluginLogConfig | undefined;
166 /** Inline versions from a full-view get. */
167 versions: Record<string, WasmPluginVersionDetails>;
168 /** Extension resource names that reference this plugin. */
169 usedBy: string[];
170 /** RFC3339 creation timestamp. */
171 createTime: string | undefined;
172 /** RFC3339 last-update timestamp. */
173 updateTime: string | undefined;
174 },
175 never,
176 Providers
177>;
178
179/**
180 * A Network Services WasmPlugin — a Service Extensions plugin that
181 * runs a customer-provided Wasm module.
182 *
183 * Changing `wasmPluginId` or `location` replaces the plugin.
184 * Description, labels, `mainVersionId`, `logConfig`, and an explicit
185 * `versions` map update in place. Child `WasmPluginsVersion` resources
186 * are preserved unless `versions` is set on this plugin.
187 *
188 * ### Creating a WasmPlugin
189 * **Example:** Generated name
190 * ```typescript
191 * const plugin = yield* GCP.NetworkServices.WasmPlugin("Edge", {
192 * description: "edge plugin",
193 * });
194 * ```
195 *
196 * **Example:** Named plugin with logging
197 * ```typescript
198 * const plugin = yield* GCP.NetworkServices.WasmPlugin("Edge", {
199 * wasmPluginId: "app-edge",
200 * description: "prod edge",
201 * labels: { env: "prod" },
202 * logConfig: { enable: true, sampleRate: 1, minLogLevel: "WARN" },
203 * });
204 * ```
205 *
206 * ### Inline versions
207 * **Example:** Plugin plus a serving version
208 * ```typescript
209 * const plugin = yield* GCP.NetworkServices.WasmPlugin("Edge", {
210 * mainVersionId: "v1",
211 * versions: {
212 * v1: {
213 * imageUri: "us-central1-docker.pkg.dev/my-project/plugins/edge:v1",
214 * },
215 * },
216 * });
217 * ```
218 *
219 * @resource
220 * @category NetworkServices
221 */
222export const WasmPlugin = Resource<WasmPlugin>(
223 "GCP.NetworkServices.WasmPlugin",
224);
225
226const toLogConfig = (
227 value: WasmPluginLogConfig | networkservices.WasmPluginLogConfig | undefined,
228): WasmPluginLogConfig | undefined => {
229 if (value === undefined) return undefined;
230 if (
231 value.enable === undefined &&
232 value.sampleRate === undefined &&
233 value.minLogLevel === undefined
234 ) {
235 return undefined;
236 }
237 return {
238 enable: value.enable,
239 sampleRate: value.sampleRate,
240 minLogLevel: value.minLogLevel,
241 };
242};
243
244const toVersionDetails = (
245 details: networkservices.WasmPluginVersionDetails | undefined,
246): WasmPluginVersionDetails => ({
247 description: details?.description,
248 labels: userLabels(details?.labels),
249 imageUri: details?.imageUri,
250 imageDigest: details?.imageDigest,
251 pluginConfigUri: details?.pluginConfigUri,
252 pluginConfigDigest: details?.pluginConfigDigest,
253 createTime: details?.createTime,
254 updateTime: details?.updateTime,
255});
256
257const toVersionMap = (
258 versions: networkservices.WasmPluginVersionDetailsMap | undefined,
259): Record<string, WasmPluginVersionDetails> =>
260 Object.fromEntries(
261 Object.entries(versions ?? {}).map(([id, details]) => [
262 id,
263 toVersionDetails(details),
264 ]),
265 );
266
267const versionFingerprint = (
268 versions: Record<string, WasmPluginVersionInput> | undefined,
269 ownership: Record<string, string>,
270) => {
271 if (versions === undefined) return undefined;
272 return Object.fromEntries(
273 Object.keys(versions)
274 .sort()
275 .map((id) => {
276 const version = versions[id] ?? {};
277 return [
278 id,
279 {
280 description: version.description ?? "",
281 imageUri: version.imageUri ?? "",
282 pluginConfigUri: version.pluginConfigUri ?? "",
283 labels: {
284 ...toLabels(version.labels),
285 ...ownership,
286 },
287 },
288 ];
289 }),
290 );
291};
292
293const observedVersionFingerprint = (
294 versions: networkservices.WasmPluginVersionDetailsMap | undefined,
295) =>
296 Object.fromEntries(
297 Object.keys(versions ?? {})
298 .sort()
299 .map((id) => {
300 const version = versions?.[id] ?? {};
301 return [
302 id,
303 {
304 description: version.description ?? "",
305 imageUri: version.imageUri ?? "",
306 pluginConfigUri: version.pluginConfigUri ?? "",
307 labels: tagRecord(version.labels),
308 },
309 ];
310 }),
311 );
312
313const toVersionBodies = (
314 versions: Record<string, WasmPluginVersionInput> | undefined,
315 ownership: Record<string, string>,
316): networkservices.WasmPluginVersionDetailsMap | undefined => {
317 if (versions === undefined) return undefined;
318 return Object.fromEntries(
319 Object.entries(versions).map(([id, version]) => [
320 id,
321 {
322 description: version.description,
323 labels: { ...toLabels(version.labels), ...ownership },
324 imageUri: version.imageUri,
325 pluginConfigData: version.pluginConfigData,
326 pluginConfigUri: version.pluginConfigUri,
327 } satisfies networkservices.WasmPluginVersionDetails,
328 ]),
329 );
330};
331
332const toAttrs = (plugin: networkservices.WasmPlugin, project: string) => {
333 const name = plugin.name ?? "";
334 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
335 return {
336 name,
337 wasmPluginId: parsed.id,
338 project: parsed.project || project,
339 location: parsed.location || DEFAULT_GLOBAL,
340 description: plugin.description,
341 labels: userLabels(plugin.labels),
342 mainVersionId: plugin.mainVersionId,
343 logConfig: toLogConfig(plugin.logConfig),
344 versions: toVersionMap(plugin.versions),
345 usedBy: (plugin.usedBy ?? [])
346 .map((entry) => entry.name)
347 .filter((value): value is string => typeof value === "string"),
348 createTime: plugin.createTime,
349 updateTime: plugin.updateTime,
350 };
351};
352
353const getByName = (name: string) =>
354 networkservices
355 .getProjectsLocationsWasmPlugins({
356 name,
357 view: "WASM_PLUGIN_VIEW_FULL",
358 })
359 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
360
361export const WasmPluginProvider = () =>
362 Provider.succeed(WasmPlugin, {
363 stables: ["name", "wasmPluginId", "project", "location", "createTime"],
364
365 diff: Effect.fn(function* ({ news, olds, output }) {
366 if (!isResolved(news)) return undefined;
367 const previousId = olds?.wasmPluginId ?? output?.wasmPluginId;
368 const nextId = news.wasmPluginId
369 ? rfc1035(news.wasmPluginId, "wasm-plugin")
370 : previousId;
371 const previousLocation = normalizeLocation(
372 olds?.location ?? output?.location,
373 DEFAULT_GLOBAL,
374 );
375 const nextLocation = normalizeLocation(
376 news.location ?? olds?.location ?? output?.location,
377 DEFAULT_GLOBAL,
378 );
379 if (
380 (previousId !== undefined &&
381 nextId !== undefined &&
382 nextId !== previousId) ||
383 previousLocation !== nextLocation
384 ) {
385 return { action: "replace" as const };
386 }
387 return undefined;
388 }),
389
390 read: Effect.fn(function* ({ id, olds, output }) {
391 const env = yield* GcpEnvironment.current;
392 const wasmPluginId = yield* toPhysicalId(
393 id,
394 olds?.wasmPluginId,
395 output?.wasmPluginId,
396 "wasm-plugin",
397 );
398 const location = normalizeLocation(
399 olds?.location ?? output?.location,
400 DEFAULT_GLOBAL,
401 );
402 const name =
403 output?.name ??
404 resourceName(env.project, location, COLLECTION, wasmPluginId);
405 const existing = yield* getByName(name);
406 if (existing === undefined) return undefined;
407 const attrs = toAttrs(existing, env.project);
408 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
409 ? attrs
410 : Unowned(attrs);
411 }),
412
413 list: () =>
414 Effect.gen(function* () {
415 const env = yield* GcpEnvironment.current;
416 const items = yield* collectPages(
417 networkservices.listProjectsLocationsWasmPlugins.pages({
418 parent: parentOf(env.project, DEFAULT_GLOBAL),
419 pageSize: 1000,
420 }),
421 (page) => page.wasmPlugins,
422 );
423 return items
424 .filter((item) => hasAlchemyLabelKeys(item.labels))
425 .map((item) => toAttrs(item, env.project));
426 }),
427
428 reconcile: Effect.fn(function* ({ id, news, output }) {
429 const env = yield* GcpEnvironment.current;
430 const wasmPluginId = yield* toPhysicalId(
431 id,
432 news.wasmPluginId,
433 output?.wasmPluginId,
434 "wasm-plugin",
435 );
436 const location = normalizeLocation(
437 news.location ?? output?.location,
438 DEFAULT_GLOBAL,
439 );
440 const name = resourceName(
441 env.project,
442 location,
443 COLLECTION,
444 wasmPluginId,
445 );
446 const desiredLabels = {
447 ...toLabels(news.labels),
448 ...(yield* createInternalLabels(id)),
449 };
450 const desiredLogConfig = toLogConfig(news.logConfig);
451 const desiredVersions = toVersionBodies(news.versions, desiredLabels);
452
453 let current = yield* getByName(name);
454
455 if (current === undefined) {
456 const created = yield* networkservices
457 .createProjectsLocationsWasmPlugins({
458 parent: parentOf(env.project, location),
459 wasmPluginId,
460 body: {
461 labels: desiredLabels,
462 description: news.description,
463 mainVersionId: news.mainVersionId,
464 logConfig: desiredLogConfig,
465 versions: desiredVersions,
466 },
467 })
468 .pipe(
469 Effect.retry({
470 while: (error) => error._tag === "Conflict",
471 times: 5,
472 schedule: Schedule.spaced("2 seconds"),
473 }),
474 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
475 );
476 if (created !== undefined) {
477 yield* waitForOperation(created);
478 }
479 current = yield* waitUntilPresent(getByName(name), name);
480 }
481
482 const observedLabels = tagRecord(current.labels);
483 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
484 const labelsChanged = upsert.length > 0 || removed.length > 0;
485 const descriptionChanged =
486 (current.description ?? "") !== (news.description ?? "");
487 const mainVersionChanged =
488 news.mainVersionId !== undefined &&
489 (current.mainVersionId ?? "") !== news.mainVersionId;
490 const logConfigChanged =
491 news.logConfig !== undefined &&
492 !sameJson(toLogConfig(current.logConfig), desiredLogConfig);
493 const versionsChanged =
494 news.versions !== undefined &&
495 !sameJson(
496 observedVersionFingerprint(current.versions),
497 versionFingerprint(news.versions, desiredLabels),
498 );
499
500 const updateMask = changedFields([
501 ["labels", labelsChanged],
502 ["description", descriptionChanged],
503 ["mainVersionId", mainVersionChanged],
504 ["logConfig", logConfigChanged],
505 ["versions", versionsChanged],
506 ]);
507
508 if (updateMask.length > 0) {
509 const operation =
510 yield* networkservices.patchProjectsLocationsWasmPlugins({
511 name: current.name ?? name,
512 updateMask: updateMask.join(","),
513 body: {
514 name: current.name ?? name,
515 labels: desiredLabels,
516 description: news.description,
517 mainVersionId: news.mainVersionId,
518 logConfig: desiredLogConfig,
519 versions: versionsChanged ? desiredVersions : undefined,
520 },
521 });
522 yield* waitForOperation(operation);
523 current = yield* waitUntilPresent(
524 getByName(current.name ?? name),
525 current.name ?? name,
526 );
527 }
528
529 return toAttrs(current, env.project);
530 }),
531
532 delete: Effect.fn(function* ({ output }) {
533 yield* Effect.gen(function* () {
534 const operation = yield* networkservices
535 .deleteProjectsLocationsWasmPlugins({ name: output.name })
536 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
537 if (operation !== undefined) {
538 yield* waitForOperation(operation, { notFoundOk: true });
539 }
540 }).pipe(
541 Effect.retry({
542 while: (error) =>
543 error._tag === "Conflict" ||
544 // FAILED_PRECONDITION: still referenced by a route/extension.
545 (error._tag === "GCP.OperationFailed" && error.code === 9),
546 times: 8,
547 schedule: Schedule.spaced("2 seconds"),
548 }),
549 );
550 yield* waitUntilGone(getByName(output.name), output.name);
551 }),
552 });