1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitZoneOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20const DEFAULT_ZONE = "us-central1-a";
21const DEFAULT_PRIORITY = 1000;
22const MAX_NAME_LENGTH = 63;
23
24export type ZoneVmExtensionPolicyExtensionPolicy =
25 compute.VmExtensionPolicyExtensionPolicy;
26export type ZoneVmExtensionPolicyInstanceSelector =
27 compute.VmExtensionPolicyInstanceSelector;
28export type ZoneVmExtensionPolicyExtensionPolicyMap =
29 compute.VmExtensionPolicyExtensionPolicyMap;
30
31export type ZoneVmExtensionPolicyProps = {
32 /**
33 * Policy name (RFC1035, 1-63 characters). If omitted, a unique name is
34 * generated from the stack, stage, and logical id. Changing the name
35 * replaces the policy.
36 */
37 vmExtensionPolicyName?: string;
38 /**
39 * Zone the policy lives in (e.g. `us-central1-a`). Immutable — changing
40 * it replaces the policy.
41 * @default "us-central1-a"
42 */
43 zone?: string;
44 /**
45 * Optional description. Zone VM extension policies have no labels
46 * field, so Alchemy ownership (`alchemy-stack` / `alchemy-stage` /
47 * `alchemy-id`) is stored in a `[alchemy …]` prefix for `list` / nuke.
48 */
49 description?: string;
50 /**
51 * Policy priority (0-65535, lower is higher priority).
52 * @default 1000
53 */
54 priority?: number;
55 /**
56 * Selectors that target VMs. A VM matches if it matches any selector.
57 * Empty applies the policy to every VM in the zone.
58 */
59 instanceSelectors?: ReadonlyArray<ZoneVmExtensionPolicyInstanceSelector>;
60 /**
61 * Map of extension name (`ops-agent`, `google-cloud-sap-extension`,
62 * `google-cloud-workload-extension`) to configuration.
63 */
64 extensionPolicies?: ZoneVmExtensionPolicyExtensionPolicyMap;
65};
66
67export type ZoneVmExtensionPolicy = Resource<
68 "GCP.Compute.ZoneVmExtensionPolicy",
69 ZoneVmExtensionPolicyProps,
70 {
71 /** Policy name. */
72 vmExtensionPolicyName: string;
73 /** Server-assigned numeric id. */
74 vmExtensionPolicyId: string | undefined;
75 /** Project id. */
76 project: string;
77 /** Zone short name (`us-central1-a`). */
78 zone: string;
79 /** User description with the Alchemy ownership prefix stripped. */
80 description: string | undefined;
81 /** Policy priority. */
82 priority: number | undefined;
83 /** VM selectors. */
84 instanceSelectors: ReadonlyArray<ZoneVmExtensionPolicyInstanceSelector>;
85 /** Extension configurations. */
86 extensionPolicies: ZoneVmExtensionPolicyExtensionPolicyMap | undefined;
87 /** Server-reported state (`ACTIVE`, `DELETING`, …). */
88 state: string | undefined;
89 /** Whether a global policy manages this zonal policy. */
90 managedByGlobal: boolean;
91 /** Link to the managing global policy, if any. */
92 globalResourceLink: string | undefined;
93 /** Compute Engine self-link. */
94 selfLink: string | undefined;
95 /** RFC3339 creation timestamp. */
96 creationTimestamp: string | undefined;
97 },
98 never,
99 Providers
100>;
101
102/**
103 * A zonal Compute Engine VM extension policy.
104 *
105 * Zone VM extension policies install Google-provided extensions (Ops
106 * Agent, SAP, workload) on VMs that match label selectors. Compute Engine
107 * has no labels on this resource, so Alchemy stamps ownership into the
108 * description so `list` / `pnpm nuke:gcp` can find them.
109 *
110 * Name and zone are immutable — changing them replaces the policy.
111 * Description, priority, selectors, and extension configurations update
112 * in place via `zoneVmExtensionPolicies.patch`.
113 *
114 * ### Creating a Zone VM Extension Policy
115 * **Example:** Install Ops Agent on labeled VMs
116 * ```typescript
117 * const policy = yield* GCP.Compute.ZoneVmExtensionPolicy("Ops", {
118 * zone: "us-central1-a",
119 * extensionPolicies: {
120 * "ops-agent": { pinnedVersion: "2.58.0" },
121 * },
122 * instanceSelectors: [
123 * { labelSelector: { inclusionLabels: { env: "prod" } } },
124 * ],
125 * });
126 * ```
127 *
128 * **Example:** Named policy with a description
129 * ```typescript
130 * const policy = yield* GCP.Compute.ZoneVmExtensionPolicy("Ops", {
131 * vmExtensionPolicyName: "ops-agent-prod",
132 * description: "ops-agent for prod VMs",
133 * priority: 500,
134 * extensionPolicies: { "ops-agent": {} },
135 * instanceSelectors: [
136 * { labelSelector: { inclusionLabels: { role: "app" } } },
137 * ],
138 * });
139 * ```
140 *
141 * @resource
142 * @category Compute
143 */
144export const ZoneVmExtensionPolicy = Resource<ZoneVmExtensionPolicy>(
145 "GCP.Compute.ZoneVmExtensionPolicy",
146);
147
148export class ZoneVmExtensionPolicyNotResolved extends Data.TaggedError(
149 "GCP.Compute.ZoneVmExtensionPolicyNotResolved",
150)<{
151 vmExtensionPolicyName: string;
152 zone: string;
153}> {}
154
155export class ZoneVmExtensionPolicyNotReady extends Data.TaggedError(
156 "GCP.Compute.ZoneVmExtensionPolicyNotReady",
157)<{
158 vmExtensionPolicyName: string;
159 status: string;
160}> {}
161
162export class ZoneVmExtensionPolicyStillExists extends Data.TaggedError(
163 "GCP.Compute.ZoneVmExtensionPolicyStillExists",
164)<{
165 vmExtensionPolicyName: string;
166 status: string;
167}> {}
168
169const lastSegment = (value: string | undefined): string => {
170 if (value === undefined || value.length === 0) return "";
171 const trimmed = value.replace(/\/+$/, "");
172 const parts = trimmed.split("/");
173 return parts[parts.length - 1] || trimmed;
174};
175
176const normalizeZone = (zone: string | undefined) =>
177 lastSegment(zone ?? DEFAULT_ZONE).toLowerCase();
178
179const toName = (id: string, name: string | undefined, existing?: string) =>
180 Effect.gen(function* () {
181 if (name !== undefined) return name;
182 if (existing !== undefined) return existing;
183 const generated = yield* createPhysicalName({
184 id,
185 maxLength: MAX_NAME_LENGTH,
186 lowercase: true,
187 });
188 return /^[a-z]/.test(generated)
189 ? generated
190 : `v${generated}`.slice(0, MAX_NAME_LENGTH);
191 });
192
193const encodeDescription = (
194 labels: Record<string, string>,
195 description: string | undefined,
196): string => {
197 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
198 return description ? `${marker}\n${description}` : marker;
199};
200
201const parseDescription = (
202 description: string | undefined,
203): {
204 labels: Record<string, string>;
205 description: string | undefined;
206} => {
207 if (!description?.startsWith("[alchemy ")) {
208 return { labels: {}, description };
209 }
210 const end = description.indexOf("]");
211 if (end < 0) return { labels: {}, description };
212 const labels: Record<string, string> = {};
213 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
214 const eq = part.indexOf("=");
215 if (eq > 0) {
216 labels[part.slice(0, eq)] = part.slice(eq + 1);
217 }
218 }
219 const rest = description.slice(end + 1).replace(/^\n/, "");
220 return { labels, description: rest.length > 0 ? rest : undefined };
221};
222
223const hasOwnershipMarker = (description: string | undefined) =>
224 Object.keys(parseDescription(description).labels).some((key) =>
225 key.startsWith("alchemy-"),
226 );
227
228const sameJson = (left: unknown, right: unknown) =>
229 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
230
231const defaultExtensions = (): compute.VmExtensionPolicyExtensionPolicyMap => ({
232 "ops-agent": {},
233});
234
235const toBody = (
236 vmExtensionPolicyName: string,
237 props: ZoneVmExtensionPolicyProps,
238 ownership: Record<string, string>,
239): compute.VmExtensionPolicy => ({
240 name: vmExtensionPolicyName,
241 description: encodeDescription(ownership, props.description),
242 priority: props.priority ?? DEFAULT_PRIORITY,
243 instanceSelectors: props.instanceSelectors
244 ? [...props.instanceSelectors]
245 : undefined,
246 extensionPolicies: props.extensionPolicies ?? defaultExtensions(),
247});
248
249const toAttrs = (
250 policy: compute.VmExtensionPolicy,
251 project: string,
252 zone: string,
253): ZoneVmExtensionPolicy["Attributes"] => {
254 const parsed = parseDescription(policy.description);
255 return {
256 vmExtensionPolicyName: policy.name ?? policy.id ?? "",
257 vmExtensionPolicyId: policy.id,
258 project,
259 zone: normalizeZone(zone),
260 description: parsed.description,
261 priority: policy.priority,
262 instanceSelectors: policy.instanceSelectors ?? [],
263 extensionPolicies: policy.extensionPolicies,
264 state: policy.state,
265 managedByGlobal: policy.managedByGlobal === true,
266 globalResourceLink: policy.globalResourceLink,
267 selfLink: policy.selfLink,
268 creationTimestamp: policy.creationTimestamp,
269 };
270};
271
272const getByName = (project: string, zone: string, vmExtensionPolicy: string) =>
273 compute
274 .getZoneVmExtensionPolicies({ project, zone, vmExtensionPolicy })
275 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
276
277const waitPolicyReady = (
278 project: string,
279 zone: string,
280 vmExtensionPolicyName: string,
281) =>
282 getByName(project, zone, vmExtensionPolicyName).pipe(
283 Effect.filterOrFail(
284 (policy): policy is compute.VmExtensionPolicy =>
285 policy !== undefined &&
286 (policy.state === "ACTIVE" || policy.state === undefined),
287 (policy) =>
288 new ZoneVmExtensionPolicyNotReady({
289 vmExtensionPolicyName,
290 status: policy?.state ?? "MISSING",
291 }),
292 ),
293 Effect.retry({
294 while: (error) =>
295 error._tag === "GCP.Compute.ZoneVmExtensionPolicyNotReady",
296 times: 10,
297 schedule: Schedule.spaced("2 seconds"),
298 }),
299 );
300
301const waitPolicyGone = (
302 project: string,
303 zone: string,
304 vmExtensionPolicyName: string,
305) =>
306 getByName(project, zone, vmExtensionPolicyName).pipe(
307 Effect.flatMap((policy) =>
308 policy === undefined
309 ? Effect.void
310 : Effect.fail(
311 new ZoneVmExtensionPolicyStillExists({
312 vmExtensionPolicyName,
313 status: policy.state ?? "UNKNOWN",
314 }),
315 ),
316 ),
317 Effect.retry({
318 while: (error) => error instanceof ZoneVmExtensionPolicyStillExists,
319 times: 10,
320 schedule: Schedule.spaced("2 seconds"),
321 }),
322 );
323
324export const ZoneVmExtensionPolicyProvider = () =>
325 Provider.succeed(ZoneVmExtensionPolicy, {
326 stables: [
327 "vmExtensionPolicyName",
328 "vmExtensionPolicyId",
329 "project",
330 "zone",
331 "selfLink",
332 "creationTimestamp",
333 ],
334
335 diff: Effect.fn(function* ({ news, olds, output }) {
336 if (!isResolved(news)) return undefined;
337
338 const previousName =
339 olds?.vmExtensionPolicyName ?? output?.vmExtensionPolicyName;
340 const nextName = news.vmExtensionPolicyName ?? previousName;
341 const nameChanged =
342 previousName !== undefined &&
343 nextName !== undefined &&
344 previousName !== nextName;
345
346 const previousZone = normalizeZone(olds?.zone ?? output?.zone);
347 const nextZone = normalizeZone(news.zone ?? output?.zone);
348
349 if (nameChanged || previousZone !== nextZone) {
350 return {
351 action: "replace" as const,
352 deleteFirst:
353 previousName !== undefined &&
354 nextName === previousName &&
355 previousZone === nextZone,
356 };
357 }
358 return undefined;
359 }),
360
361 read: Effect.fn(function* ({ id, olds, output }) {
362 const env = yield* GcpEnvironment.current;
363 const vmExtensionPolicyName = yield* toName(
364 id,
365 olds?.vmExtensionPolicyName,
366 output?.vmExtensionPolicyName,
367 );
368 const zone = normalizeZone(olds?.zone ?? output?.zone);
369 const existing = yield* getByName(
370 env.project,
371 zone,
372 vmExtensionPolicyName,
373 );
374 if (existing === undefined) return undefined;
375 const attrs = toAttrs(existing, env.project, zone);
376 const { labels } = parseDescription(existing.description);
377 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
378 }),
379
380 list: () =>
381 Effect.gen(function* () {
382 const env = yield* GcpEnvironment.current;
383 const zones = yield* compute.listZones
384 .items({
385 project: env.project,
386 maxResults: 500,
387 })
388 .pipe(
389 Stream.filter((zone) => zone.status === "UP" && !!zone.name),
390 Stream.runCollect,
391 Effect.map((chunk) => Array.from(chunk)),
392 );
393 const pages = yield* Effect.forEach(
394 zones,
395 (zone) =>
396 compute.listZoneVmExtensionPolicies
397 .items({
398 project: env.project,
399 zone: zone.name!,
400 maxResults: 500,
401 returnPartialSuccess: true,
402 })
403 .pipe(
404 Stream.filter((policy) =>
405 hasOwnershipMarker(policy.description),
406 ),
407 Stream.map((policy) =>
408 toAttrs(policy, env.project, zone.name!),
409 ),
410 Stream.runCollect,
411 Effect.map((chunk) => Array.from(chunk)),
412 Effect.catchTag("NotFound", () => Effect.succeed([])),
413 ),
414 { concurrency: 8 },
415 );
416 return pages.flat();
417 }),
418
419 reconcile: Effect.fn(function* ({ id, news, output }) {
420 const env = yield* GcpEnvironment.current;
421 const vmExtensionPolicyName = yield* toName(
422 id,
423 news.vmExtensionPolicyName,
424 output?.vmExtensionPolicyName,
425 );
426 const zone = normalizeZone(news.zone ?? output?.zone);
427 const ownership = yield* createInternalLabels(id);
428 const desired = toBody(vmExtensionPolicyName, news, ownership);
429
430 let current = yield* getByName(env.project, zone, vmExtensionPolicyName);
431 if (current?.state === "DELETING") {
432 yield* waitPolicyGone(env.project, zone, vmExtensionPolicyName);
433 current = undefined;
434 }
435
436 if (current === undefined) {
437 const inserted = yield* compute
438 .insertZoneVmExtensionPolicies({
439 project: env.project,
440 zone,
441 body: desired,
442 })
443 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
444 if (inserted !== undefined) {
445 yield* waitZoneOperation(env.project, zone, inserted, {
446 ignore: ["RESOURCE_ALREADY_EXISTS"],
447 });
448 }
449 current = yield* waitPolicyReady(
450 env.project,
451 zone,
452 vmExtensionPolicyName,
453 );
454 }
455
456 if (current === undefined) {
457 return yield* new ZoneVmExtensionPolicyNotResolved({
458 vmExtensionPolicyName,
459 zone,
460 });
461 }
462
463 const descriptionChanged =
464 (current.description ?? "") !== (desired.description ?? "");
465 const priorityChanged =
466 (current.priority ?? DEFAULT_PRIORITY) !==
467 (desired.priority ?? DEFAULT_PRIORITY);
468 const selectorsChanged = !sameJson(
469 current.instanceSelectors ?? [],
470 desired.instanceSelectors ?? [],
471 );
472 const extensionsChanged = !sameJson(
473 current.extensionPolicies,
474 desired.extensionPolicies,
475 );
476
477 if (
478 !current.managedByGlobal &&
479 (descriptionChanged ||
480 priorityChanged ||
481 selectorsChanged ||
482 extensionsChanged)
483 ) {
484 const patched = yield* compute.updateZoneVmExtensionPolicies({
485 project: env.project,
486 zone,
487 vmExtensionPolicy: vmExtensionPolicyName,
488 body: desired,
489 });
490 yield* waitZoneOperation(env.project, zone, patched);
491 current =
492 (yield* getByName(env.project, zone, vmExtensionPolicyName)) ??
493 (yield* waitPolicyReady(env.project, zone, vmExtensionPolicyName));
494 }
495
496 if (current === undefined) {
497 return yield* new ZoneVmExtensionPolicyNotResolved({
498 vmExtensionPolicyName,
499 zone,
500 });
501 }
502
503 return toAttrs(current, env.project, zone);
504 }),
505
506 delete: Effect.fn(function* ({ output }) {
507 const env = yield* GcpEnvironment.current;
508 const project = output.project || env.project;
509 const zone = normalizeZone(output.zone);
510 const deleted = yield* compute
511 .deleteZoneVmExtensionPolicies({
512 project,
513 zone,
514 vmExtensionPolicy: output.vmExtensionPolicyName,
515 })
516 .pipe(
517 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
518 Effect.retry({
519 while: (error) => error._tag === "Conflict",
520 times: 8,
521 schedule: Schedule.spaced("2 seconds"),
522 }),
523 );
524 if (deleted !== undefined) {
525 yield* waitZoneOperation(project, zone, deleted, {
526 ignore: ["RESOURCE_NOT_FOUND"],
527 });
528 }
529 yield* waitPolicyGone(project, zone, output.vmExtensionPolicyName);
530 }),
531 });