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
20const MAX_NAME_LENGTH = 63;
21const DEFAULT_SCOPE: compute.RolloutPlanLocationScopeEnum = "ZONAL";
22
23export type RolloutPlanLocationScope =
24 | compute.RolloutPlanLocationScopeEnum
25 | (string & {});
26export type RolloutPlanWave = compute.RolloutPlanWave;
27
28export type RolloutPlanProps = {
29 /**
30 * Rollout plan name (RFC1035, 1-63 characters). If omitted, a unique
31 * name is generated from the stack, stage, and logical id. Changing the
32 * name replaces the plan.
33 */
34 rolloutPlanName?: string;
35 /**
36 * Optional description. Compute rollout plans have no labels field, so
37 * Alchemy ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`)
38 * is stored in a `[alchemy …]` prefix for `list` / nuke.
39 */
40 description?: string;
41 /**
42 * Location scope of the plan (`ZONAL` or `REGIONAL`). Immutable —
43 * changing it replaces the plan.
44 * @default "ZONAL"
45 */
46 locationScope?: RolloutPlanLocationScope;
47 /**
48 * Waves that divide the rollout. The Compute API has no update method
49 * for rollout plans — changing waves replaces the plan.
50 */
51 waves?: ReadonlyArray<RolloutPlanWave>;
52};
53
54export type RolloutPlan = Resource<
55 "GCP.Compute.RolloutPlan",
56 RolloutPlanProps,
57 {
58 /** Rollout plan name. */
59 rolloutPlanName: string;
60 /** Server-assigned numeric id. */
61 rolloutPlanId: string | undefined;
62 /** Project id. */
63 project: string;
64 /** User description with the Alchemy ownership prefix stripped. */
65 description: string | undefined;
66 /** Location scope (`ZONAL` or `REGIONAL`). */
67 locationScope: string | undefined;
68 /** Waves in this plan. */
69 waves: ReadonlyArray<RolloutPlanWave>;
70 /** Compute Engine self-link. */
71 selfLink: string | undefined;
72 /** RFC3339 creation timestamp. */
73 creationTimestamp: string | undefined;
74 },
75 never,
76 Providers
77>;
78
79/**
80 * A project-global Compute Engine rollout plan.
81 *
82 * Rollout plans divide a large change (for example a global VM extension
83 * policy) into waves. Compute Engine has no labels and no update method on
84 * this resource, so Alchemy stamps ownership into the description and
85 * treats name, scope, description, and waves as replacement triggers.
86 *
87 * ### Creating a Rollout Plan
88 * **Example:** Single-wave zonal plan
89 * ```typescript
90 * const plan = yield* GCP.Compute.RolloutPlan("Fleet", {
91 * waves: [
92 * {
93 * displayName: "central",
94 * selectors: [
95 * {
96 * locationSelector: {
97 * includedLocations: ["us-central1-a"],
98 * },
99 * },
100 * ],
101 * validation: {
102 * type: "time",
103 * timeBasedValidationMetadata: { waitDuration: "0s" },
104 * },
105 * },
106 * ],
107 * });
108 * ```
109 *
110 * **Example:** Named plan with a description
111 * ```typescript
112 * const plan = yield* GCP.Compute.RolloutPlan("Fleet", {
113 * rolloutPlanName: "ops-agent-rollout",
114 * description: "ops-agent canary",
115 * locationScope: "ZONAL",
116 * waves: [
117 * {
118 * selectors: [
119 * {
120 * locationSelector: {
121 * includedLocations: ["us-central1-a", "us-central1-b"],
122 * },
123 * },
124 * ],
125 * validation: { type: "manual" },
126 * },
127 * ],
128 * });
129 * ```
130 *
131 * @resource
132 * @category Compute
133 */
134export const RolloutPlan = Resource<RolloutPlan>("GCP.Compute.RolloutPlan");
135
136export class RolloutPlanNotResolved extends Data.TaggedError(
137 "GCP.Compute.RolloutPlanNotResolved",
138)<{
139 rolloutPlanName: string;
140}> {}
141
142export class RolloutPlanStillExists extends Data.TaggedError(
143 "GCP.Compute.RolloutPlanStillExists",
144)<{
145 rolloutPlanName: string;
146}> {}
147
148const defaultWaves = (): compute.RolloutPlanWave[] => [
149 {
150 displayName: "default",
151 selectors: [
152 {
153 locationSelector: {
154 includedLocations: ["us-central1-a"],
155 },
156 },
157 ],
158 validation: {
159 type: "time",
160 timeBasedValidationMetadata: { waitDuration: "0s" },
161 },
162 },
163];
164
165const toName = (id: string, name: string | undefined, existing?: string) =>
166 Effect.gen(function* () {
167 if (name !== undefined) return name;
168 if (existing !== undefined) return existing;
169 const generated = yield* createPhysicalName({
170 id,
171 maxLength: MAX_NAME_LENGTH,
172 lowercase: true,
173 });
174 return /^[a-z]/.test(generated)
175 ? generated
176 : `p${generated}`.slice(0, MAX_NAME_LENGTH);
177 });
178
179const encodeDescription = (
180 labels: Record<string, string>,
181 description: string | undefined,
182): string => {
183 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
184 return description ? `${marker}\n${description}` : marker;
185};
186
187const parseDescription = (
188 description: string | undefined,
189): {
190 labels: Record<string, string>;
191 description: string | undefined;
192} => {
193 if (!description?.startsWith("[alchemy ")) {
194 return { labels: {}, description };
195 }
196 const end = description.indexOf("]");
197 if (end < 0) return { labels: {}, description };
198 const labels: Record<string, string> = {};
199 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
200 const eq = part.indexOf("=");
201 if (eq > 0) {
202 labels[part.slice(0, eq)] = part.slice(eq + 1);
203 }
204 }
205 const rest = description.slice(end + 1).replace(/^\n/, "");
206 return { labels, description: rest.length > 0 ? rest : undefined };
207};
208
209const hasOwnershipMarker = (description: string | undefined) =>
210 Object.keys(parseDescription(description).labels).some((key) =>
211 key.startsWith("alchemy-"),
212 );
213
214const sameJson = (left: unknown, right: unknown) =>
215 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
216
217const toBody = (
218 rolloutPlanName: string,
219 props: RolloutPlanProps,
220 ownership: Record<string, string>,
221): compute.RolloutPlan => ({
222 name: rolloutPlanName,
223 description: encodeDescription(ownership, props.description),
224 locationScope: props.locationScope ?? DEFAULT_SCOPE,
225 waves: [...(props.waves ?? defaultWaves())],
226});
227
228const toAttrs = (
229 plan: compute.RolloutPlan,
230 project: string,
231): RolloutPlan["Attributes"] => {
232 const parsed = parseDescription(plan.description);
233 return {
234 rolloutPlanName: plan.name ?? plan.id ?? "",
235 rolloutPlanId: plan.id,
236 project,
237 description: parsed.description,
238 locationScope: plan.locationScope,
239 waves: plan.waves ?? [],
240 selfLink: plan.selfLink,
241 creationTimestamp: plan.creationTimestamp,
242 };
243};
244
245const getByName = (project: string, rolloutPlan: string) =>
246 compute
247 .getRolloutPlans({ project, rolloutPlan })
248 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
249
250const waitPlanGone = (project: string, rolloutPlanName: string) =>
251 getByName(project, rolloutPlanName).pipe(
252 Effect.flatMap((plan) =>
253 plan === undefined
254 ? Effect.void
255 : Effect.fail(
256 new RolloutPlanStillExists({
257 rolloutPlanName,
258 }),
259 ),
260 ),
261 Effect.retry({
262 while: (error) => error instanceof RolloutPlanStillExists,
263 times: 10,
264 schedule: Schedule.spaced("2 seconds"),
265 }),
266 );
267
268export const RolloutPlanProvider = () =>
269 Provider.succeed(RolloutPlan, {
270 stables: [
271 "rolloutPlanName",
272 "rolloutPlanId",
273 "project",
274 "selfLink",
275 "creationTimestamp",
276 ],
277
278 diff: Effect.fn(function* ({ news, olds, output }) {
279 if (!isResolved(news)) return undefined;
280
281 const previousName = olds?.rolloutPlanName ?? output?.rolloutPlanName;
282 const nextName = news.rolloutPlanName ?? previousName;
283 const nameChanged =
284 previousName !== undefined &&
285 nextName !== undefined &&
286 previousName !== nextName;
287
288 const previousScope =
289 olds?.locationScope ?? output?.locationScope ?? DEFAULT_SCOPE;
290 const nextScope = news.locationScope ?? DEFAULT_SCOPE;
291 const previousWaves = olds?.waves ?? output?.waves;
292 const nextWaves = news.waves;
293 const wavesChanged =
294 nextWaves !== undefined &&
295 previousWaves !== undefined &&
296 !sameJson(previousWaves, nextWaves);
297 const previousDescription = olds?.description ?? output?.description;
298 const descriptionChanged =
299 news.description !== undefined &&
300 previousDescription !== undefined &&
301 news.description !== previousDescription;
302
303 if (nameChanged || previousScope !== nextScope || wavesChanged) {
304 return {
305 action: "replace" as const,
306 deleteFirst: !nameChanged,
307 };
308 }
309 if (descriptionChanged) {
310 return { action: "replace" as const, deleteFirst: true };
311 }
312 return undefined;
313 }),
314
315 read: Effect.fn(function* ({ id, olds, output }) {
316 const env = yield* GcpEnvironment.current;
317 const rolloutPlanName = yield* toName(
318 id,
319 olds?.rolloutPlanName,
320 output?.rolloutPlanName,
321 );
322 const existing = yield* getByName(env.project, rolloutPlanName);
323 if (existing === undefined) return undefined;
324 const attrs = toAttrs(existing, env.project);
325 const { labels } = parseDescription(existing.description);
326 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
327 }),
328
329 list: () =>
330 Effect.gen(function* () {
331 const env = yield* GcpEnvironment.current;
332 return yield* compute.listRolloutPlans
333 .items({
334 project: env.project,
335 maxResults: 500,
336 returnPartialSuccess: true,
337 })
338 .pipe(
339 Stream.filter((plan) => hasOwnershipMarker(plan.description)),
340 Stream.map((plan) => toAttrs(plan, env.project)),
341 Stream.runCollect,
342 Effect.map((chunk) => Array.from(chunk)),
343 Effect.catchTag("NotFound", () => Effect.succeed([])),
344 );
345 }),
346
347 reconcile: Effect.fn(function* ({ id, news, output }) {
348 const env = yield* GcpEnvironment.current;
349 const rolloutPlanName = yield* toName(
350 id,
351 news.rolloutPlanName,
352 output?.rolloutPlanName,
353 );
354 const ownership = yield* createInternalLabels(id);
355 const desired = toBody(rolloutPlanName, news, ownership);
356
357 let current = yield* getByName(env.project, rolloutPlanName);
358
359 if (current === undefined) {
360 const inserted = yield* compute
361 .insertRolloutPlans({
362 project: env.project,
363 body: desired,
364 })
365 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
366 if (inserted !== undefined) {
367 yield* waitGlobalOperation(env.project, inserted, {
368 ignore: ["RESOURCE_ALREADY_EXISTS"],
369 });
370 }
371 current = yield* getByName(env.project, rolloutPlanName).pipe(
372 Effect.filterOrFail(
373 (plan): plan is compute.RolloutPlan => plan !== undefined,
374 () => new RolloutPlanNotResolved({ rolloutPlanName }),
375 ),
376 Effect.retry({
377 while: (error) =>
378 error._tag === "GCP.Compute.RolloutPlanNotResolved",
379 times: 8,
380 schedule: Schedule.spaced("1 second"),
381 }),
382 );
383 }
384
385 if (current === undefined) {
386 return yield* new RolloutPlanNotResolved({ rolloutPlanName });
387 }
388
389 return toAttrs(current, env.project);
390 }),
391
392 delete: Effect.fn(function* ({ output }) {
393 const env = yield* GcpEnvironment.current;
394 const project = output.project || env.project;
395 const deleted = yield* compute
396 .deleteRolloutPlans({
397 project,
398 rolloutPlan: output.rolloutPlanName,
399 })
400 .pipe(
401 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
402 Effect.retry({
403 while: (error) => error._tag === "Conflict",
404 times: 8,
405 schedule: Schedule.spaced("2 seconds"),
406 }),
407 );
408 if (deleted !== undefined) {
409 yield* waitGlobalOperation(project, deleted, {
410 ignore: ["RESOURCE_NOT_FOUND"],
411 });
412 }
413 yield* waitPlanGone(project, output.rolloutPlanName);
414 }),
415 });