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 { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 diffLabels,
17 hasAlchemyLabels,
18 stripInternalLabels,
19 toLabels,
20} from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22
23export type MachineImageProps = {
24 /**
25 * Machine image name (RFC1035, 1-63 characters). If omitted, a unique
26 * name is generated from the stack, stage, and logical id. Changing the
27 * name replaces the machine image.
28 */
29 machineImageName?: string;
30 /**
31 * Source instance URL used to create the machine image
32 * (`projects/{project}/zones/{zone}/instances/{instance}` or a full
33 * self-link). Immutable — changing it replaces the machine image.
34 */
35 sourceInstance: string;
36 /**
37 * Optional description. Immutable — changing it replaces the machine
38 * image (the API has no patch).
39 */
40 description?: string;
41 /**
42 * User labels. Alchemy ownership labels are merged in automatically
43 * and synced via `machineImages.setLabels`.
44 */
45 labels?: Record<string, string>;
46 /**
47 * Cloud Storage locations for the machine image (`us-central1`, `us`,
48 * …). Immutable — changing it replaces the machine image.
49 */
50 storageLocations?: string[];
51 /**
52 * Attempt an application-consistent image by flushing the guest OS.
53 * Input-only; not persisted on the resource.
54 */
55 guestFlush?: boolean;
56};
57
58export type MachineImage = Resource<
59 "GCP.Compute.MachineImage",
60 MachineImageProps,
61 {
62 /** Machine image name. */
63 machineImageName: string;
64 /** Server-assigned numeric id. */
65 machineImageId: string | undefined;
66 /** Project id. */
67 project: string;
68 /** Server-reported status (`CREATING`, `READY`, `DELETING`, …). */
69 status: string | undefined;
70 /** Description. */
71 description: string | undefined;
72 /** User labels (Alchemy ownership labels stripped). */
73 labels: Record<string, string>;
74 /** Source instance URL. */
75 sourceInstance: string | undefined;
76 /** Cloud Storage locations. */
77 storageLocations: string[];
78 /** Total storage used by the machine image, in bytes. */
79 totalStorageBytes: string | undefined;
80 /** Compute Engine self-link. */
81 selfLink: string | undefined;
82 /** RFC3339 creation timestamp. */
83 creationTimestamp: string | undefined;
84 },
85 never,
86 Providers
87>;
88
89/**
90 * A Compute Engine machine image capturing a VM's disks, metadata, and
91 * configuration so it can be used to create new instances.
92 *
93 * Create from a source instance. Name, source instance, description, and
94 * storage locations replace the machine image. Labels are synced in place
95 * via `setLabels`.
96 *
97 * ### Creating a Machine Image
98 * **Example:** Machine image from an instance
99 * ```typescript
100 * const vm = yield* GCP.Compute.Instance("web", {
101 * zone: "us-central1-a",
102 * machineType: "e2-micro",
103 * });
104 * const image = yield* GCP.Compute.MachineImage("backup", {
105 * sourceInstance: vm.selfLink,
106 * labels: { env: "prod" },
107 * });
108 * ```
109 *
110 * **Example:** Explicit name and regional storage
111 * ```typescript
112 * const image = yield* GCP.Compute.MachineImage("backup", {
113 * machineImageName: "web-golden",
114 * sourceInstance:
115 * "projects/{project}/zones/us-central1-a/instances/web",
116 * description: "golden image of web",
117 * storageLocations: ["us-central1"],
118 * });
119 * ```
120 *
121 * ### Updating a Machine Image
122 * **Example:** Labels
123 * ```typescript
124 * const image = yield* GCP.Compute.MachineImage("backup", {
125 * machineImageName: "web-golden",
126 * sourceInstance:
127 * "projects/{project}/zones/us-central1-a/instances/web",
128 * labels: { env: "prod", role: "golden" },
129 * });
130 * ```
131 *
132 * @resource
133 * @category Compute
134 */
135export const MachineImage = Resource<MachineImage>("GCP.Compute.MachineImage");
136
137export class MachineImageNotResolved extends Data.TaggedError(
138 "GCP.Compute.MachineImageNotResolved",
139)<{
140 machineImageName: string;
141}> {}
142
143export class MachineImagePending extends Data.TaggedError(
144 "GCP.Compute.MachineImagePending",
145)<{
146 machineImageName: string;
147 status: string;
148}> {}
149
150export class MachineImageFailed extends Data.TaggedError(
151 "GCP.Compute.MachineImageFailed",
152)<{
153 machineImageName: string;
154 status: string;
155}> {}
156
157export class MachineImageStillExists extends Data.TaggedError(
158 "GCP.Compute.MachineImageStillExists",
159)<{
160 machineImageName: string;
161 status: string;
162}> {}
163
164export class MachineImageSourceRequired extends Data.TaggedError(
165 "GCP.Compute.MachineImageSourceRequired",
166)<{
167 machineImageName: string;
168}> {}
169
170const MAX_NAME_LENGTH = 63;
171
172const lastSegment = (value: string | undefined): string => {
173 if (value === undefined || value.length === 0) return "";
174 const parts = value.split("/");
175 return parts[parts.length - 1] || value;
176};
177
178const userLabels = (
179 labels: Record<string, string | undefined> | null | undefined,
180): Record<string, string> => stripInternalLabels(tagRecord(labels));
181
182const sameStrings = (left?: readonly string[], right?: readonly string[]) => {
183 const a = [...(left ?? [])].map((value) => value.toLowerCase()).sort();
184 const b = [...(right ?? [])].map((value) => value.toLowerCase()).sort();
185 return a.length === b.length && a.every((value, index) => value === b[index]);
186};
187
188const toName = (id: string, name: string | undefined, existing?: string) =>
189 Effect.gen(function* () {
190 if (name !== undefined) return name;
191 if (existing !== undefined) return existing;
192 const generated = yield* createPhysicalName({
193 id,
194 maxLength: MAX_NAME_LENGTH,
195 lowercase: true,
196 });
197 return /^[a-z]/.test(generated)
198 ? generated
199 : `m${generated}`.slice(0, MAX_NAME_LENGTH);
200 });
201
202const toAttrs = (image: compute.MachineImage, project: string) => ({
203 machineImageName: image.name ?? image.id ?? "",
204 machineImageId: image.id,
205 project,
206 status: image.status,
207 description: image.description,
208 labels: userLabels(image.labels),
209 sourceInstance: image.sourceInstance,
210 storageLocations: image.storageLocations ?? [],
211 totalStorageBytes: image.totalStorageBytes,
212 selfLink: image.selfLink,
213 creationTimestamp: image.creationTimestamp,
214});
215
216const getByName = (project: string, machineImage: string) =>
217 compute
218 .getMachineImages({ project, machineImage })
219 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
220
221const canonicalizeSourceInstance = (source: string | undefined): string => {
222 if (source === undefined || source.length === 0) return "";
223 const cleaned = source.split("?")[0] ?? source;
224 const full = cleaned.match(
225 /(projects\/[^/]+\/zones\/[^/]+\/instances\/[^/]+)$/,
226 );
227 if (full?.[1] !== undefined) return full[1];
228 const zonal = cleaned.match(/(zones\/[^/]+\/instances\/[^/]+)$/);
229 if (zonal?.[1] !== undefined) return zonal[1];
230 return lastSegment(cleaned);
231};
232
233const waitUntilReady = (project: string, machineImageName: string) =>
234 getByName(project, machineImageName).pipe(
235 Effect.flatMap((image) =>
236 image?.status === "INVALID" || image?.status === "FAILED"
237 ? Effect.fail(
238 new MachineImageFailed({
239 machineImageName,
240 status: image.status ?? "INVALID",
241 }),
242 )
243 : Effect.succeed(image),
244 ),
245 Effect.filterOrFail(
246 (image): image is compute.MachineImage =>
247 image !== undefined && image.status === "READY",
248 (image) =>
249 new MachineImagePending({
250 machineImageName,
251 status: image?.status ?? "MISSING",
252 }),
253 ),
254 Effect.retry({
255 while: (error) => error._tag === "GCP.Compute.MachineImagePending",
256 times: 10,
257 schedule: Schedule.spaced("5 seconds"),
258 }),
259 );
260
261const waitUntilGone = (project: string, machineImageName: string) =>
262 getByName(project, machineImageName).pipe(
263 Effect.flatMap((image) =>
264 image === undefined
265 ? Effect.void
266 : Effect.fail(
267 new MachineImageStillExists({
268 machineImageName,
269 status: image.status ?? "UNKNOWN",
270 }),
271 ),
272 ),
273 Effect.retry({
274 while: (error) => error._tag === "GCP.Compute.MachineImageStillExists",
275 times: 10,
276 schedule: Schedule.spaced("3 seconds"),
277 }),
278 );
279
280const insertBody = (
281 machineImageName: string,
282 news: MachineImageProps,
283 labels: Record<string, string>,
284): compute.MachineImage => ({
285 name: machineImageName,
286 sourceInstance: news.sourceInstance,
287 description: news.description,
288 labels,
289 storageLocations: news.storageLocations,
290 guestFlush: news.guestFlush,
291});
292
293export const MachineImageProvider = () =>
294 Provider.succeed(MachineImage, {
295 stables: [
296 "machineImageName",
297 "machineImageId",
298 "project",
299 "selfLink",
300 "creationTimestamp",
301 "sourceInstance",
302 ],
303
304 diff: Effect.fn(function* ({ news, olds, output }) {
305 if (!isResolved(news)) return undefined;
306 const previousName = olds.machineImageName ?? output?.machineImageName;
307 const nextName = news.machineImageName ?? previousName;
308 const nameChanged =
309 previousName !== undefined &&
310 nextName !== undefined &&
311 previousName !== nextName;
312
313 const previousSource = canonicalizeSourceInstance(
314 olds.sourceInstance ?? output?.sourceInstance,
315 );
316 const nextSource = canonicalizeSourceInstance(news.sourceInstance);
317 const sourceChanged =
318 previousSource.length > 0 &&
319 nextSource.length > 0 &&
320 previousSource !== nextSource;
321
322 const previousDescription = olds.description ?? output?.description ?? "";
323 const nextDescription = news.description ?? "";
324 const descriptionChanged = previousDescription !== nextDescription;
325
326 const storageChanged =
327 news.storageLocations !== undefined &&
328 !sameStrings(
329 news.storageLocations,
330 olds.storageLocations ?? output?.storageLocations,
331 );
332
333 const replace =
334 nameChanged || sourceChanged || descriptionChanged || storageChanged;
335
336 if (!replace) return undefined;
337 return {
338 action: "replace" as const,
339 deleteFirst:
340 previousName !== undefined &&
341 nextName !== undefined &&
342 nextName === previousName,
343 };
344 }),
345
346 read: Effect.fn(function* ({ id, olds, output }) {
347 const env = yield* GcpEnvironment.current;
348 const machineImageName = yield* toName(
349 id,
350 olds?.machineImageName,
351 output?.machineImageName,
352 );
353 const existing = yield* getByName(env.project, machineImageName);
354 if (existing === undefined) return undefined;
355 const attrs = toAttrs(existing, env.project);
356 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
357 ? attrs
358 : Unowned(attrs);
359 }),
360
361 list: () =>
362 Effect.gen(function* () {
363 const env = yield* GcpEnvironment.current;
364 return yield* compute.listMachineImages
365 .items({
366 project: env.project,
367 maxResults: 500,
368 filter: "labels.alchemy-id:*",
369 })
370 .pipe(
371 Stream.filter((image) =>
372 Object.keys(image.labels ?? {}).some((key) =>
373 key.startsWith("alchemy-"),
374 ),
375 ),
376 Stream.map((image) => toAttrs(image, env.project)),
377 Stream.runCollect,
378 Effect.map((chunk) => Array.from(chunk)),
379 );
380 }),
381
382 reconcile: Effect.fn(function* ({ id, news, output }) {
383 const env = yield* GcpEnvironment.current;
384 const machineImageName = yield* toName(
385 id,
386 news.machineImageName,
387 output?.machineImageName,
388 );
389 const desiredLabels = {
390 ...toLabels(news.labels),
391 ...(yield* createInternalLabels(id)),
392 };
393
394 let current = yield* getByName(env.project, machineImageName);
395 if (current?.status === "DELETING") {
396 yield* waitUntilGone(env.project, machineImageName);
397 current = undefined;
398 }
399
400 if (current === undefined) {
401 if (
402 news.sourceInstance === undefined ||
403 news.sourceInstance.length === 0
404 ) {
405 return yield* new MachineImageSourceRequired({ machineImageName });
406 }
407 const inserted = yield* compute
408 .insertMachineImages({
409 project: env.project,
410 sourceInstance: news.sourceInstance,
411 body: insertBody(machineImageName, news, desiredLabels),
412 })
413 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
414 if (inserted !== undefined) {
415 yield* waitGlobalOperation(env.project, inserted, {
416 ignore: ["RESOURCE_ALREADY_EXISTS"],
417 });
418 }
419 current = yield* waitUntilReady(env.project, machineImageName);
420 }
421
422 if (current === undefined) {
423 return yield* new MachineImageNotResolved({ machineImageName });
424 }
425
426 if (current.status !== "READY") {
427 current = yield* waitUntilReady(env.project, machineImageName);
428 }
429
430 if (current === undefined) {
431 return yield* new MachineImageNotResolved({ machineImageName });
432 }
433
434 const observedLabels = tagRecord(current.labels);
435 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
436 if (upsert.length > 0 || removed.length > 0) {
437 yield* compute
438 .setLabelsMachineImages({
439 project: env.project,
440 resource: machineImageName,
441 body: {
442 labels: desiredLabels,
443 labelFingerprint: current.labelFingerprint,
444 },
445 })
446 .pipe(
447 Effect.flatMap((operation) =>
448 waitGlobalOperation(env.project, operation),
449 ),
450 );
451 current = (yield* getByName(env.project, machineImageName)) ?? current;
452 }
453
454 if (current === undefined) {
455 return yield* new MachineImageNotResolved({ machineImageName });
456 }
457
458 return toAttrs(current, env.project);
459 }),
460
461 delete: Effect.fn(function* ({ output }) {
462 const env = yield* GcpEnvironment.current;
463 const operation = yield* compute
464 .deleteMachineImages({
465 project: env.project,
466 machineImage: output.machineImageName,
467 })
468 .pipe(
469 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
470 Effect.retry({
471 while: (error) => error._tag === "Conflict",
472 times: 8,
473 schedule: Schedule.spaced("2 seconds"),
474 }),
475 );
476 if (operation !== undefined) {
477 yield* waitGlobalOperation(env.project, operation, {
478 ignore: ["RESOURCE_NOT_FOUND"],
479 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
480 }
481 yield* waitUntilGone(env.project, output.machineImageName);
482 }),
483 });