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 ImageRawDisk = {
24 /** GCS URI of a gzip-compressed tarball (`gs://` or `https://storage.googleapis.com/`). */
25 source: string;
26 /** SHA-1 checksum of the tarball, if known. */
27 sha1Checksum?: string;
28 /**
29 * Container type of the disk image.
30 * @default "TAR"
31 */
32 containerType?: "TAR" | (string & {});
33};
34
35export type ImageProps = {
36 /**
37 * Image name (RFC1035, 1-63 characters). If omitted, a unique name is
38 * generated from the stack, stage, and logical id. Changing the name
39 * replaces the image.
40 */
41 imageName?: string;
42 /**
43 * Optional description. Mutable in place via `images.patch`.
44 */
45 description?: string;
46 /**
47 * Image family. Disks can target the family to get the latest
48 * non-deprecated image. Mutable in place via `images.patch`.
49 */
50 family?: string;
51 /**
52 * User labels. Alchemy ownership labels are merged in automatically
53 * and synced via `images.setLabels`.
54 */
55 labels?: Record<string, string>;
56 /**
57 * Source disk URL (`projects/{project}/zones/{zone}/disks/{disk}`).
58 * Immutable — changing it replaces the image. Provide one of
59 * `sourceDisk`, `sourceImage`, `sourceSnapshot`, or `rawDisk`.
60 */
61 sourceDisk?: string;
62 /**
63 * Source image URL (`projects/{project}/global/images/{image}` or
64 * `.../images/family/{family}`). Immutable — changing it replaces
65 * the image.
66 */
67 sourceImage?: string;
68 /**
69 * Source snapshot URL (`projects/{project}/global/snapshots/{snapshot}`).
70 * Immutable — changing it replaces the image.
71 */
72 sourceSnapshot?: string;
73 /**
74 * Raw disk image stored in Cloud Storage. Immutable — changing it
75 * replaces the image.
76 */
77 rawDisk?: ImageRawDisk;
78 /**
79 * Size of the image when restored onto a persistent disk, in GB.
80 * Immutable — changing it replaces the image.
81 */
82 diskSizeGb?: number;
83 /**
84 * Cloud Storage locations for the image (`us-central1`, `us`, …).
85 * Immutable — changing it replaces the image.
86 */
87 storageLocations?: string[];
88 /**
89 * CPU architecture (`ARM64` or `X86_64`). Immutable — changing it
90 * replaces the image.
91 */
92 architecture?: compute.ImageArchitectureEnum | (string & {});
93 /**
94 * License URIs attached to the image. Immutable — changing it replaces
95 * the image.
96 */
97 licenses?: string[];
98};
99
100export type Image = Resource<
101 "GCP.Compute.Image",
102 ImageProps,
103 {
104 /** Image name. */
105 imageName: string;
106 /** Server-assigned numeric id. */
107 imageId: string | undefined;
108 /** Project id. */
109 project: string;
110 /** Server-reported status (`PENDING`, `READY`, `FAILED`, …). */
111 status: string | undefined;
112 /** Image family, if set. */
113 family: string | undefined;
114 /** Description. */
115 description: string | undefined;
116 /** User labels (Alchemy ownership labels stripped). */
117 labels: Record<string, string>;
118 /** Source disk URL, if any. */
119 sourceDisk: string | undefined;
120 /** Source image URL, if any. */
121 sourceImage: string | undefined;
122 /** Source snapshot URL, if any. */
123 sourceSnapshot: string | undefined;
124 /** Size in GB when restored onto a disk. */
125 diskSizeGb: string | undefined;
126 /** Architecture, if set. */
127 architecture: string | undefined;
128 /** Storage locations. */
129 storageLocations: string[];
130 /** Compute Engine self-link. */
131 selfLink: string | undefined;
132 /** RFC3339 creation timestamp. */
133 creationTimestamp: string | undefined;
134 /** Archive size in bytes, if reported. */
135 archiveSizeBytes: string | undefined;
136 },
137 never,
138 Providers
139>;
140
141/**
142 * A Compute Engine custom image used to create boot disks for VMs.
143 *
144 * Create from a disk, another image, a snapshot, or a GCS tarball.
145 * `family` and `description` update in place; labels are synced via
146 * `setLabels`. Source, size, architecture, licenses, and storage
147 * locations replace the image.
148 *
149 * ### Creating an Image
150 * **Example:** Image from a disk
151 * ```typescript
152 * const image = yield* GCP.Compute.Image("boot", {
153 * sourceDisk: "projects/my-project/zones/us-central1-a/disks/my-disk",
154 * family: "app-boot",
155 * labels: { env: "prod" },
156 * });
157 * ```
158 *
159 * **Example:** Clone a public image family
160 * ```typescript
161 * const image = yield* GCP.Compute.Image("debian", {
162 * sourceImage: "projects/debian-cloud/global/images/family/debian-12",
163 * storageLocations: ["us-central1"],
164 * });
165 * ```
166 *
167 * ### Updating an Image
168 * **Example:** Family, description, and labels
169 * ```typescript
170 * const image = yield* GCP.Compute.Image("boot", {
171 * imageName: "app-boot-v2",
172 * sourceDisk: "projects/my-project/zones/us-central1-a/disks/my-disk",
173 * family: "app-boot",
174 * description: "golden image",
175 * labels: { env: "prod", role: "boot" },
176 * });
177 * ```
178 *
179 * @resource
180 * @category Compute
181 */
182export const Image = Resource<Image>("GCP.Compute.Image");
183
184export class ImageNotResolved extends Data.TaggedError(
185 "GCP.Compute.ImageNotResolved",
186)<{
187 imageName: string;
188}> {}
189
190export class ImagePending extends Data.TaggedError("GCP.Compute.ImagePending")<{
191 imageName: string;
192 status: string;
193}> {}
194
195export class ImageFailed extends Data.TaggedError("GCP.Compute.ImageFailed")<{
196 imageName: string;
197 status: string;
198}> {}
199
200export class ImageStillExists extends Data.TaggedError(
201 "GCP.Compute.ImageStillExists",
202)<{
203 imageName: string;
204 status: string;
205}> {}
206
207export class ImageSourceRequired extends Data.TaggedError(
208 "GCP.Compute.ImageSourceRequired",
209)<{
210 imageName: string;
211}> {}
212
213const MAX_NAME_LENGTH = 63;
214
215const lastSegment = (value: string | undefined): string => {
216 if (value === undefined || value.length === 0) return "";
217 const parts = value.split("/");
218 return parts[parts.length - 1] || value;
219};
220
221const userLabels = (
222 labels: Record<string, string | undefined> | null | undefined,
223): Record<string, string> => stripInternalLabels(tagRecord(labels));
224
225const sameStrings = (left?: readonly string[], right?: readonly string[]) =>
226 JSON.stringify([...(left ?? [])].sort()) ===
227 JSON.stringify([...(right ?? [])].sort());
228
229const toName = (id: string, name: string | undefined, existing?: string) =>
230 Effect.gen(function* () {
231 if (name !== undefined) return name;
232 if (existing !== undefined) return existing;
233 const generated = yield* createPhysicalName({
234 id,
235 maxLength: MAX_NAME_LENGTH,
236 lowercase: true,
237 });
238 return /^[a-z]/.test(generated)
239 ? generated
240 : `i${generated}`.slice(0, MAX_NAME_LENGTH);
241 });
242
243const toAttrs = (image: compute.Image, project: string) => ({
244 imageName: image.name ?? image.id ?? "",
245 imageId: image.id,
246 project,
247 status: image.status,
248 family: image.family,
249 description: image.description,
250 labels: userLabels(image.labels),
251 sourceDisk: image.sourceDisk,
252 sourceImage: image.sourceImage,
253 sourceSnapshot: image.sourceSnapshot,
254 diskSizeGb: image.diskSizeGb,
255 architecture: image.architecture,
256 storageLocations: image.storageLocations ?? [],
257 selfLink: image.selfLink,
258 creationTimestamp: image.creationTimestamp,
259 archiveSizeBytes: image.archiveSizeBytes,
260});
261
262const getByName = (project: string, image: string) =>
263 compute
264 .getImages({ project, image })
265 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
266
267const waitUntilReady = (project: string, imageName: string) =>
268 getByName(project, imageName).pipe(
269 Effect.flatMap((image) =>
270 image?.status === "FAILED"
271 ? Effect.fail(new ImageFailed({ imageName, status: "FAILED" }))
272 : Effect.succeed(image),
273 ),
274 Effect.filterOrFail(
275 (image): image is compute.Image =>
276 image !== undefined && image.status === "READY",
277 (image) =>
278 new ImagePending({
279 imageName,
280 status: image?.status ?? "MISSING",
281 }),
282 ),
283 Effect.retry({
284 while: (error) => error._tag === "GCP.Compute.ImagePending",
285 times: 10,
286 schedule: Schedule.spaced("5 seconds"),
287 }),
288 );
289
290const waitUntilGone = (project: string, imageName: string) =>
291 getByName(project, imageName).pipe(
292 Effect.flatMap((image) =>
293 image === undefined
294 ? Effect.void
295 : Effect.fail(
296 new ImageStillExists({
297 imageName,
298 status: image.status ?? "UNKNOWN",
299 }),
300 ),
301 ),
302 Effect.retry({
303 while: (error) => error._tag === "GCP.Compute.ImageStillExists",
304 times: 18,
305 schedule: Schedule.spaced("3 seconds"),
306 }),
307 );
308
309const insertBody = (
310 imageName: string,
311 news: ImageProps,
312 labels: Record<string, string>,
313): compute.Image => ({
314 name: imageName,
315 description: news.description,
316 family: news.family,
317 labels,
318 sourceDisk: news.sourceDisk,
319 sourceImage: news.sourceImage,
320 sourceSnapshot: news.sourceSnapshot,
321 rawDisk: news.rawDisk
322 ? {
323 source: news.rawDisk.source,
324 sha1Checksum: news.rawDisk.sha1Checksum,
325 containerType: news.rawDisk.containerType ?? "TAR",
326 }
327 : undefined,
328 diskSizeGb:
329 news.diskSizeGb !== undefined ? String(news.diskSizeGb) : undefined,
330 storageLocations: news.storageLocations,
331 architecture: news.architecture,
332 licenses: news.licenses,
333});
334
335const hasSource = (props: ImageProps) =>
336 props.sourceDisk !== undefined ||
337 props.sourceImage !== undefined ||
338 props.sourceSnapshot !== undefined ||
339 props.rawDisk?.source !== undefined;
340
341const sourceIdentity = (props: ImageProps): string => {
342 if (props.sourceDisk !== undefined) {
343 return `disk:${lastSegment(props.sourceDisk)}`;
344 }
345 if (props.sourceImage !== undefined) {
346 return `image:${lastSegment(props.sourceImage)}`;
347 }
348 if (props.sourceSnapshot !== undefined) {
349 return `snapshot:${lastSegment(props.sourceSnapshot)}`;
350 }
351 if (props.rawDisk?.source !== undefined) {
352 return `raw:${props.rawDisk.source}:${props.rawDisk.sha1Checksum ?? ""}:${props.rawDisk.containerType ?? "TAR"}`;
353 }
354 return "";
355};
356
357const sourceChanged = (olds: ImageProps, news: ImageProps) => {
358 const previous = sourceIdentity(olds);
359 const next = sourceIdentity(news);
360 return previous.length > 0 && next.length > 0 && previous !== next;
361};
362
363export const ImageProvider = () =>
364 Provider.succeed(Image, {
365 stables: [
366 "imageName",
367 "imageId",
368 "project",
369 "selfLink",
370 "creationTimestamp",
371 "sourceDisk",
372 "sourceImage",
373 "sourceSnapshot",
374 "architecture",
375 ],
376
377 diff: Effect.fn(function* ({ news, olds, output }) {
378 if (!isResolved(news)) return undefined;
379 const previousName = olds.imageName ?? output?.imageName;
380 const nextName = news.imageName ?? previousName;
381 const nameChanged =
382 previousName !== undefined &&
383 nextName !== undefined &&
384 previousName !== nextName;
385
386 const previousSize = olds.diskSizeGb ?? Number(output?.diskSizeGb);
387 const sizeChanged =
388 news.diskSizeGb !== undefined &&
389 previousSize !== undefined &&
390 !Number.isNaN(previousSize) &&
391 news.diskSizeGb !== previousSize;
392
393 const architectureChanged =
394 news.architecture !== undefined &&
395 (olds.architecture ?? output?.architecture) !== undefined &&
396 news.architecture !== (olds.architecture ?? output?.architecture);
397
398 const storageChanged =
399 news.storageLocations !== undefined &&
400 !sameStrings(
401 news.storageLocations,
402 olds.storageLocations ?? output?.storageLocations,
403 );
404
405 const licensesChanged =
406 news.licenses !== undefined &&
407 olds.licenses !== undefined &&
408 !sameStrings(news.licenses, olds.licenses);
409
410 const replace =
411 nameChanged ||
412 sourceChanged(olds, news) ||
413 sizeChanged ||
414 architectureChanged ||
415 storageChanged ||
416 licensesChanged;
417
418 if (!replace) return undefined;
419 return {
420 action: "replace" as const,
421 deleteFirst:
422 previousName !== undefined &&
423 nextName !== undefined &&
424 nextName === previousName,
425 };
426 }),
427
428 read: Effect.fn(function* ({ id, olds, output }) {
429 const env = yield* GcpEnvironment.current;
430 const imageName = yield* toName(id, olds?.imageName, output?.imageName);
431 const existing = yield* getByName(env.project, imageName);
432 if (existing === undefined) return undefined;
433 const attrs = toAttrs(existing, env.project);
434 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
435 ? attrs
436 : Unowned(attrs);
437 }),
438
439 list: () =>
440 Effect.gen(function* () {
441 const env = yield* GcpEnvironment.current;
442 return yield* compute.listImages
443 .items({
444 project: env.project,
445 maxResults: 500,
446 filter: "labels.alchemy-id:*",
447 })
448 .pipe(
449 Stream.filter((image) =>
450 Object.keys(image.labels ?? {}).some((key) =>
451 key.startsWith("alchemy-"),
452 ),
453 ),
454 Stream.map((image) => toAttrs(image, env.project)),
455 Stream.runCollect,
456 Effect.map((chunk) => Array.from(chunk)),
457 );
458 }),
459
460 reconcile: Effect.fn(function* ({ id, news, output }) {
461 const env = yield* GcpEnvironment.current;
462 const imageName = yield* toName(id, news.imageName, output?.imageName);
463 const desiredLabels = {
464 ...toLabels(news.labels),
465 ...(yield* createInternalLabels(id)),
466 };
467
468 let current = yield* getByName(env.project, imageName);
469 if (current?.status === "DELETING") {
470 yield* waitUntilGone(env.project, imageName);
471 current = undefined;
472 }
473
474 if (current === undefined) {
475 if (!hasSource(news)) {
476 return yield* new ImageSourceRequired({ imageName });
477 }
478 const inserted = yield* compute
479 .insertImages({
480 project: env.project,
481 body: insertBody(imageName, news, desiredLabels),
482 })
483 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
484 if (inserted !== undefined) {
485 yield* waitGlobalOperation(env.project, inserted, {
486 ignore: ["RESOURCE_ALREADY_EXISTS"],
487 });
488 }
489 current = yield* waitUntilReady(env.project, imageName);
490 }
491
492 if (current === undefined) {
493 return yield* new ImageNotResolved({ imageName });
494 }
495
496 if (current.status !== "READY") {
497 current = yield* waitUntilReady(env.project, imageName);
498 }
499
500 if (current === undefined) {
501 return yield* new ImageNotResolved({ imageName });
502 }
503
504 const familyChanged = (current.family ?? "") !== (news.family ?? "");
505 const descriptionChanged =
506 (current.description ?? "") !== (news.description ?? "");
507 if (familyChanged || descriptionChanged) {
508 yield* compute
509 .patchImages({
510 project: env.project,
511 image: imageName,
512 body: {
513 family: news.family,
514 description: news.description,
515 },
516 })
517 .pipe(
518 Effect.flatMap((operation) =>
519 waitGlobalOperation(env.project, operation),
520 ),
521 );
522 current = (yield* getByName(env.project, imageName)) ?? current;
523 }
524
525 const observedLabels = tagRecord(current.labels);
526 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
527 if (upsert.length > 0 || removed.length > 0) {
528 yield* compute
529 .setLabelsImages({
530 project: env.project,
531 resource: imageName,
532 body: {
533 labels: desiredLabels,
534 labelFingerprint: current.labelFingerprint,
535 },
536 })
537 .pipe(
538 Effect.flatMap((operation) =>
539 waitGlobalOperation(env.project, operation),
540 ),
541 );
542 current = (yield* getByName(env.project, imageName)) ?? current;
543 }
544
545 if (current === undefined) {
546 return yield* new ImageNotResolved({ imageName });
547 }
548
549 return toAttrs(current, env.project);
550 }),
551
552 delete: Effect.fn(function* ({ output }) {
553 const env = yield* GcpEnvironment.current;
554 const operation = yield* compute
555 .deleteImages({
556 project: env.project,
557 image: output.imageName,
558 })
559 .pipe(
560 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
561 Effect.retry({
562 while: (error) => error._tag === "Conflict",
563 times: 8,
564 schedule: Schedule.spaced("2 seconds"),
565 }),
566 );
567 if (operation !== undefined) {
568 yield* waitGlobalOperation(env.project, operation, {
569 ignore: ["RESOURCE_NOT_FOUND"],
570 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
571 }
572 yield* waitUntilGone(env.project, output.imageName);
573 }),
574 });