1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } 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
20export type GlobalVmExtensionPolicyExtensionPolicy =
21 compute.GlobalVmExtensionPolicyExtensionPolicy;
22export type GlobalVmExtensionPolicyInstanceSelector =
23 compute.GlobalVmExtensionPolicyInstanceSelector;
24export type GlobalVmExtensionPolicyRolloutOperation =
25 compute.GlobalVmExtensionPolicyRolloutOperation;
26
27export type GlobalVmExtensionPolicyProps = {
28 /**
29 * Policy name (RFC1035, 1-63 chars). If omitted, a unique name is
30 * generated from the stack, stage, and logical id. Changing it replaces
31 * the policy.
32 */
33 policyName?: string;
34 /**
35 * Optional description. Global VM extension policies have no labels
36 * field — Alchemy ownership is stored in a `[alchemy …]` prefix for
37 * `list` / nuke.
38 */
39 description?: string;
40 /**
41 * Map of extension name (e.g. `"ops-agent"`) to policy configuration.
42 * Required by the API.
43 */
44 extensionPolicies?: compute.GlobalVmExtensionPolicyExtensionPolicyMap;
45 /**
46 * Rollout plan. Defaults to a fast project-wide rollout.
47 */
48 rolloutOperation?: GlobalVmExtensionPolicyRolloutOperation;
49 /**
50 * Conflict-resolution priority. Larger numbers win.
51 * @default 0
52 */
53 priority?: number;
54 /**
55 * VM selectors. Empty applies the policy to every VM. Selectors are
56 * AND'd together.
57 */
58 instanceSelectors?: GlobalVmExtensionPolicyInstanceSelector[];
59};
60
61export type GlobalVmExtensionPolicy = Resource<
62 "GCP.Compute.GlobalVmExtensionPolicy",
63 GlobalVmExtensionPolicyProps,
64 {
65 /** Policy name. */
66 policyName: string;
67 /** Project id. */
68 project: string;
69 /** User description with the Alchemy ownership prefix stripped. */
70 description: string | undefined;
71 /** Extension policy map. */
72 extensionPolicies: compute.GlobalVmExtensionPolicyExtensionPolicyMap;
73 /** Rollout operation. */
74 rolloutOperation: GlobalVmExtensionPolicyRolloutOperation | undefined;
75 /** Priority. */
76 priority: number | undefined;
77 /** VM selectors. */
78 instanceSelectors: GlobalVmExtensionPolicyInstanceSelector[];
79 /** Server-assigned numeric id. */
80 policyId: string | undefined;
81 /** Server-defined URL. */
82 selfLink: string | undefined;
83 /** RFC3339 creation timestamp. */
84 creationTimestamp: string | undefined;
85 /** RFC3339 update timestamp. */
86 updateTimestamp: string | undefined;
87 /** Scoped resource purge status. */
88 scopedResourceStatus: string | undefined;
89 },
90 never,
91 Providers
92>;
93
94/**
95 * A project-level Compute Engine VM extension policy.
96 *
97 * Global policies roll out guest extensions (Ops Agent, and similar) to
98 * matching VMs. Name replaces the resource; description, priority,
99 * selectors, extensions, and the rollout plan update in place. Compute
100 * has no labels field, so Alchemy stamps ownership into the description.
101 *
102 * ### Creating a Global VM Extension Policy
103 * **Example:** Ops Agent, fast rollout
104 * ```typescript
105 * const policy = yield* GCP.Compute.GlobalVmExtensionPolicy("ops", {
106 * extensionPolicies: { "ops-agent": {} },
107 * rolloutOperation: {
108 * rolloutInput: { predefinedRolloutPlan: "FAST_ROLLOUT" },
109 * },
110 * });
111 * ```
112 *
113 * **Example:** Label-selected VMs
114 * ```typescript
115 * const policy = yield* GCP.Compute.GlobalVmExtensionPolicy("ops", {
116 * extensionPolicies: { "ops-agent": { pinnedVersion: "2.53.0" } },
117 * instanceSelectors: [
118 * { labelSelector: { inclusionLabels: { role: "web" } } },
119 * ],
120 * priority: 100,
121 * });
122 * ```
123 *
124 * @resource
125 * @category Compute
126 */
127export const GlobalVmExtensionPolicy = Resource<GlobalVmExtensionPolicy>(
128 "GCP.Compute.GlobalVmExtensionPolicy",
129);
130
131export class GlobalVmExtensionPolicyNotResolved extends Data.TaggedError(
132 "GCP.Compute.GlobalVmExtensionPolicyNotResolved",
133)<{
134 policyName: string;
135}> {}
136
137export class GlobalVmExtensionPolicyStillExists extends Data.TaggedError(
138 "GCP.Compute.GlobalVmExtensionPolicyStillExists",
139)<{
140 policyName: string;
141}> {}
142
143const DEFAULT_ROLLOUT: GlobalVmExtensionPolicyRolloutOperation = {
144 rolloutInput: { predefinedRolloutPlan: "FAST_ROLLOUT" },
145};
146
147const rfc1035 = (name: string): string => {
148 let next = name
149 .toLowerCase()
150 .replace(/[^a-z0-9-]/g, "-")
151 .replace(/-+/g, "-")
152 .replace(/^-+/, "")
153 .replace(/-+$/, "");
154 if (!/^[a-z]/.test(next)) {
155 next = `v${next}`;
156 }
157 next = next.slice(0, 63).replace(/-+$/, "");
158 return next.length > 0 ? next : "vmextensionpolicy";
159};
160
161const toName = (id: string, name: string | undefined, existing?: string) =>
162 Effect.gen(function* () {
163 if (name !== undefined) return name;
164 if (existing !== undefined) return existing;
165 return rfc1035(
166 yield* createPhysicalName({
167 id,
168 maxLength: 63,
169 lowercase: true,
170 }),
171 );
172 });
173
174const encodeDescription = (
175 labels: Record<string, string>,
176 description: string | undefined,
177): string => {
178 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
179 return description ? `${marker}\n${description}` : marker;
180};
181
182const parseDescription = (
183 description: string | undefined,
184): {
185 labels: Record<string, string>;
186 description: string | undefined;
187} => {
188 if (!description?.startsWith("[alchemy ")) {
189 return { labels: {}, description };
190 }
191 const end = description.indexOf("]");
192 if (end < 0) return { labels: {}, description };
193 const labels: Record<string, string> = {};
194 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
195 const eq = part.indexOf("=");
196 if (eq > 0) {
197 labels[part.slice(0, eq)] = part.slice(eq + 1);
198 }
199 }
200 const rest = description.slice(end + 1).replace(/^\n/, "");
201 return { labels, description: rest.length > 0 ? rest : undefined };
202};
203
204const jsonOf = (value: unknown) => JSON.stringify(value ?? null);
205
206// Compute echoes unset extension fields back as empty strings
207// (`{ pinnedVersion: "", stringConfig: "" }`), so compare only set fields.
208const extensionPoliciesKey = (
209 policies: compute.GlobalVmExtensionPolicy["extensionPolicies"],
210) =>
211 jsonOf(
212 Object.entries(policies ?? {})
213 .sort(([a], [b]) => a.localeCompare(b))
214 .map(([name, policy]) => [
215 name,
216 Object.entries(policy ?? {})
217 .filter(([, value]) => value !== "" && value != null)
218 .sort(([a], [b]) => a.localeCompare(b)),
219 ]),
220 );
221
222const toBody = (
223 policyName: string,
224 props: GlobalVmExtensionPolicyProps,
225 ownership: Record<string, string>,
226): compute.GlobalVmExtensionPolicy => ({
227 name: policyName,
228 description: encodeDescription(ownership, props.description),
229 extensionPolicies: props.extensionPolicies ?? { "ops-agent": {} },
230 rolloutOperation: props.rolloutOperation ?? DEFAULT_ROLLOUT,
231 priority: props.priority,
232 instanceSelectors: props.instanceSelectors,
233});
234
235const toAttrs = (
236 policy: compute.GlobalVmExtensionPolicy,
237 project: string,
238): GlobalVmExtensionPolicy["Attributes"] => {
239 const parsed = parseDescription(policy.description);
240 return {
241 policyName: policy.name ?? policy.id ?? "",
242 project,
243 description: parsed.description,
244 extensionPolicies: policy.extensionPolicies ?? {},
245 rolloutOperation: policy.rolloutOperation,
246 priority: policy.priority,
247 instanceSelectors: policy.instanceSelectors ?? [],
248 policyId: policy.id,
249 selfLink: policy.selfLink,
250 creationTimestamp: policy.creationTimestamp,
251 updateTimestamp: policy.updateTimestamp,
252 scopedResourceStatus: policy.scopedResourceStatus,
253 };
254};
255
256const needsUpdate = (
257 current: compute.GlobalVmExtensionPolicy,
258 desired: compute.GlobalVmExtensionPolicy,
259) => {
260 if ((current.description ?? "") !== (desired.description ?? "")) return true;
261 if ((current.priority ?? 0) !== (desired.priority ?? 0)) return true;
262 if (
263 extensionPoliciesKey(current.extensionPolicies) !==
264 extensionPoliciesKey(desired.extensionPolicies)
265 ) {
266 return true;
267 }
268 if (
269 jsonOf(current.instanceSelectors ?? []) !==
270 jsonOf(desired.instanceSelectors ?? [])
271 ) {
272 return true;
273 }
274 const currentPlan =
275 current.rolloutOperation?.rolloutInput?.predefinedRolloutPlan;
276 const desiredPlan =
277 desired.rolloutOperation?.rolloutInput?.predefinedRolloutPlan;
278 return (currentPlan ?? "") !== (desiredPlan ?? "");
279};
280
281const getByName = (project: string, globalVmExtensionPolicy: string) =>
282 compute
283 .getGlobalVmExtensionPolicies({ project, globalVmExtensionPolicy })
284 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
285
286const awaitResource = (project: string, policyName: string) =>
287 getByName(project, policyName).pipe(
288 Effect.repeat({
289 schedule: Schedule.spaced("1 second"),
290 until: (item) => item !== undefined,
291 times: 8,
292 }),
293 );
294
295export const GlobalVmExtensionPolicyProvider = () =>
296 Provider.succeed(GlobalVmExtensionPolicy, {
297 stables: [
298 "policyName",
299 "project",
300 "policyId",
301 "selfLink",
302 "creationTimestamp",
303 ],
304
305 diff: Effect.fn(function* ({ news, olds, output }) {
306 if (!isResolved(news)) return undefined;
307 const previousName = olds?.policyName ?? output?.policyName;
308 const nextName = news.policyName;
309 if (
310 previousName !== undefined &&
311 nextName !== undefined &&
312 previousName !== nextName
313 ) {
314 return { action: "replace" as const };
315 }
316 return undefined;
317 }),
318
319 read: Effect.fn(function* ({ id, olds, output }) {
320 const env = yield* GcpEnvironment.current;
321 const policyName = yield* toName(
322 id,
323 olds?.policyName,
324 output?.policyName,
325 );
326 const existing = yield* getByName(env.project, policyName);
327 if (existing === undefined) return undefined;
328 const attrs = toAttrs(existing, env.project);
329 const { labels } = parseDescription(existing.description);
330 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
331 }),
332
333 list: () =>
334 Effect.gen(function* () {
335 const env = yield* GcpEnvironment.current;
336 return yield* compute.listGlobalVmExtensionPolicies
337 .items({ project: env.project, maxResults: 500 })
338 .pipe(
339 Stream.filter((policy) => {
340 const { labels } = parseDescription(policy.description);
341 return Object.keys(labels).some((key) =>
342 key.startsWith("alchemy-"),
343 );
344 }),
345 Stream.map((policy) => toAttrs(policy, env.project)),
346 Stream.runCollect,
347 Effect.map((chunk) => Array.from(chunk)),
348 );
349 }),
350
351 reconcile: Effect.fn(function* ({ id, news, output }) {
352 const env = yield* GcpEnvironment.current;
353 const policyName = yield* toName(id, news.policyName, output?.policyName);
354 const ownership = yield* createInternalLabels(id);
355 const desired = toBody(policyName, news, ownership);
356
357 let current = yield* getByName(env.project, policyName);
358
359 if (current === undefined) {
360 yield* compute
361 .insertGlobalVmExtensionPolicies({
362 project: env.project,
363 body: desired,
364 })
365 .pipe(
366 Effect.flatMap((operation) =>
367 waitGlobalOperation(env.project, operation, {
368 ignore: ["RESOURCE_ALREADY_EXISTS"],
369 }),
370 ),
371 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
372 );
373 current = yield* awaitResource(env.project, policyName);
374 }
375
376 if (current === undefined) {
377 return yield* new GlobalVmExtensionPolicyNotResolved({ policyName });
378 }
379
380 if (needsUpdate(current, desired)) {
381 yield* compute
382 .updateGlobalVmExtensionPolicies({
383 project: env.project,
384 globalVmExtensionPolicy: policyName,
385 body: desired,
386 })
387 .pipe(
388 Effect.flatMap((operation) =>
389 waitGlobalOperation(env.project, operation),
390 ),
391 );
392 current = yield* getByName(env.project, policyName);
393 if (current === undefined) {
394 return yield* new GlobalVmExtensionPolicyNotResolved({
395 policyName,
396 });
397 }
398 }
399
400 return toAttrs(current, env.project);
401 }),
402
403 delete: Effect.fn(function* ({ output }) {
404 const env = yield* GcpEnvironment.current;
405 const requestDelete = (retryUuid?: string) =>
406 compute
407 .deleteGlobalVmExtensionPolicies({
408 project: env.project,
409 globalVmExtensionPolicy: output.policyName,
410 body: { predefinedRolloutPlan: "FAST_ROLLOUT", retryUuid },
411 })
412 .pipe(
413 Effect.retry({
414 while: (error) => error._tag === "Conflict",
415 schedule: Schedule.spaced("2 seconds"),
416 times: 8,
417 }),
418 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
419 Effect.flatMap((operation) =>
420 operation === undefined
421 ? Effect.void
422 : waitGlobalOperation(env.project, operation).pipe(
423 Effect.catchTag("NotFound", () => Effect.void),
424 Effect.asVoid,
425 ),
426 ),
427 );
428 // A policy left in DELETING by an earlier (possibly failed) delete
429 // rollout only accepts another delete with a fresh `retryUuid`.
430 const observed = yield* getByName(env.project, output.policyName);
431 if (observed === undefined) return;
432 const retryUuid =
433 observed.scopedResourceStatus === "SCOPED_RESOURCE_STATUS_DELETING"
434 ? yield* Effect.sync(() => crypto.randomUUID())
435 : undefined;
436 yield* requestDelete(retryUuid);
437 // Deletion is itself a rollout; the policy stays readable until the
438 // rollout finishes.
439 yield* getByName(env.project, output.policyName).pipe(
440 Effect.filterOrFail(
441 (policy) => policy === undefined,
442 () =>
443 new GlobalVmExtensionPolicyStillExists({
444 policyName: output.policyName,
445 }),
446 ),
447 Effect.retry({
448 while: (error) =>
449 error._tag === "GCP.Compute.GlobalVmExtensionPolicyStillExists",
450 schedule: Schedule.spaced("5 seconds"),
451 times: 60,
452 }),
453 );
454 }),
455 });