1import * as microvms from "@distilled.cloud/aws/lambda-microvms";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Redacted from "effect/Redacted";
7import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";
9
10import type { ValidationException } from "@distilled.cloud/aws/Errors";
11import * as Artifacts from "../../Artifacts.ts";
12import type { ScopedPlanStatusSession } from "../../Report.ts";
13import { isResolved } from "../../Diff.ts";
14import type { Input, InputProps } from "../../Input.ts";
15import { createPhysicalName } from "../../PhysicalName.ts";
16import * as Provider from "../../Provider.ts";
17import { createInternalTags, diffTags } from "../../Tags.ts";
18import { sha256 } from "../../Util/sha256.ts";
19import { Assets } from "../Assets.ts";
20import {
21 buildMicrovmDockerfile,
22 bundleMicrovmProgram,
23 DEFAULT_MICROVM_PORT,
24 readContextDirectory,
25 zipFiles,
26} from "./MicrovmBundle.ts";
27import { MicrovmImage, type MicrovmImageProps } from "./MicrovmImage.ts";
28
29// Fold the `env` map (user-provided + capability-binding contributions) into the
30// MicroVM API's `environmentVariables` (a `Record<string, string>`). Redacted
31// values are unwrapped; non-string values are JSON-encoded.
32const foldEnv = (
33 env: Record<string, any> | undefined,
34): Record<string, string> | undefined => {
35 if (!env) return undefined;
36 const out: Record<string, string> = {};
37 for (const [key, value] of Object.entries(env)) {
38 if (value === undefined) continue;
39 const v = Redacted.isRedacted(value) ? Redacted.value(value) : value;
40 out[key] = typeof v === "string" ? v : JSON.stringify(v);
41 }
42 return Object.keys(out).length > 0 ? out : undefined;
43};
44
45const READY_STATES = new Set<string>(["CREATED", "UPDATED"]);
46const FAILED_STATES = new Set<string>([
47 "CREATE_FAILED",
48 "UPDATE_FAILED",
49 "DELETE_FAILED",
50]);
51
52const toIso = (value: Date | string | undefined): string | undefined =>
53 value instanceof Date ? value.toISOString() : value;
54
55// Resolve `buildRole` / `baseImage` (each a string ARN or a materialized
56// resource reference) to a plain ARN string. The engine materializes a
57// whole-resource prop into its stable attributes (see Plan.ts `resolveInput`),
58// so the instance's `.roleArn` / `.imageArn` is a plain string at reconcile.
59const resolveBuildRoleArn = (news: MicrovmImageProps): string | undefined =>
60 news.buildRole === undefined
61 ? undefined
62 : typeof news.buildRole === "string"
63 ? news.buildRole
64 : (news.buildRole.roleArn as unknown as string);
65
66const resolveBaseImageArn = (news: MicrovmImageProps): string | undefined =>
67 news.baseImage === undefined
68 ? undefined
69 : typeof news.baseImage === "string"
70 ? news.baseImage
71 : (news.baseImage.imageArn as unknown as string);
72
73// Properties (besides the artifact content) that affect the built image.
74const buildPropsIdentity = (news: MicrovmImageProps) =>
75 JSON.stringify({
76 baseImageArn: resolveBaseImageArn(news) ?? null,
77 baseImageVersion: news.baseImageVersion ?? null,
78 description: news.description ?? null,
79 logging: news.logging ?? null,
80 egressNetworkConnectors: [...(news.egressNetworkConnectors ?? [])].sort(),
81 cpuConfigurations: news.cpuConfigurations ?? [],
82 resources: news.resources ?? [],
83 additionalOsCapabilities: [...(news.additionalOsCapabilities ?? [])].sort(),
84 hooks: news.hooks ?? null,
85 env: news.env ?? {},
86 });
87
88interface ResolvedArtifact {
89 uri: string;
90 hash: string;
91 contentHash: string;
92}
93
94/**
95 * The content half of an image's artifact identity, before any upload: the
96 * bundled program (or packaged context) plus the hash that names THAT —
97 * independent of the props, which fold into the full `hash` later.
98 */
99type ArtifactContent = {
100 /** Stored on the attrs; compared by `diff`. */
101 readonly contentHash: string;
102 /**
103 * The raw content identity the full `hash` is derived from — kept
104 * separate so that formula stays byte-identical to what deployed images
105 * already carry (`sha256(identity + ":" + propsId)`).
106 */
107 readonly identity: string;
108} & ({ readonly archive: Uint8Array } | { readonly uri: string });
109
110/** The props that decide what the artifact's CONTENT is. */
111type ContentInputs = Pick<
112 MicrovmImageProps,
113 | "main"
114 | "context"
115 | "codeArtifact"
116 | "runtime"
117 | "port"
118 | "dockerfile"
119 | "isExternal"
120 | "external"
121 | "build"
122>;
123
124/**
125 * Pick the content inputs off the plan-time props, or `undefined` when they
126 * cannot be read yet. At plan time the props may be unresolved — the build
127 * role, env, … are Outputs, and the whole object may itself be an
128 * Output/Effect/Config — so only a plain object whose content inputs have
129 * all resolved qualifies.
130 */
131const resolvedContentInputs = (
132 news: Input<MicrovmImageProps>,
133): ContentInputs | undefined => {
134 if (typeof news !== "object" || news === null) return undefined;
135 const n = news as InputProps<MicrovmImageProps>;
136 const picked: Input<ContentInputs> = {
137 main: n.main,
138 context: n.context,
139 codeArtifact: n.codeArtifact,
140 runtime: n.runtime,
141 port: n.port,
142 dockerfile: n.dockerfile,
143 isExternal: n.isExternal,
144 external: n.external,
145 build: n.build,
146 };
147 if (!isResolved(picked)) return undefined;
148 // `isResolved` has verified every field; the cast only re-states that
149 // for a type the guard cannot express on a mapped `Input<…>` object.
150 const resolved = picked as ContentInputs;
151 // An Effect/Config-shaped props object has none of these set.
152 if (!resolved.main && !resolved.context && !resolved.codeArtifact?.uri) {
153 return undefined;
154 }
155 return resolved;
156};
157
158const resolveName = (id: string, name?: string) =>
159 name
160 ? Effect.succeed(name)
161 : createPhysicalName({ id, maxLength: 64, delimiter: "-" });
162
163// `getMicrovmImage` requires an ARN or ID — it rejects a plain name with a
164// `ValidationException` ("Invalid ARN format"). Only call it with an
165// ARN/ID.
166const getImage = (identifier: string) =>
167 microvms
168 .getMicrovmImage({ imageIdentifier: identifier })
169 .pipe(
170 Effect.catchTag("ResourceNotFoundException", () =>
171 Effect.succeed(undefined),
172 ),
173 );
174
175// Look an image up by its (name-only) identifier via the name filter, since
176// `getMicrovmImage` won't accept a name. Returns the full image or undefined.
177const findImageByName = Effect.fn(function* (name: string) {
178 const summaries = yield* microvms.listMicrovmImages
179 .items({ nameFilter: name })
180 .pipe(
181 Stream.runCollect,
182 Effect.map((chunk) => Array.from(chunk)),
183 );
184 const match = summaries.find((summary) => summary.name === name);
185 return match ? yield* getImage(match.imageArn) : undefined;
186});
187
188const defaultBaseImageArn = Effect.fn(function* () {
189 const items = yield* microvms.listManagedMicrovmImages.items({}).pipe(
190 Stream.runCollect,
191 Effect.map((c) => Array.from(c)),
192 );
193 const base = items.find((i) => i.imageArn.includes("al2023")) ?? items[0];
194 if (!base) {
195 return yield* Effect.die(
196 "No managed MicroVM base images available; set `baseImage`.",
197 );
198 }
199 return base.imageArn;
200});
201
202/**
203 * Bundle (or package) the image's code and hash the result. Nothing is
204 * uploaded here and no prop outside {@link contentInputs} is read: `diff`
205 * calls this before the props have resolved (the build role is an Output
206 * at plan time) to see a content-only edit of the in-VM program, and
207 * `reconcile` calls it again for the archive to upload. Memoized per
208 * resource for the run so a diff→reconcile cycle bundles once.
209 */
210const artifactContent = (
211 id: string,
212 news: ContentInputs,
213 note: (message: string) => Effect.Effect<void>,
214) =>
215 Effect.gen(function* () {
216 if (news.main) {
217 const runtime = news.runtime ?? "node";
218 const port = news.port ?? DEFAULT_MICROVM_PORT;
219 yield* note("Bundling MicroVM program...");
220 const { files, hash: bundleHash } = yield* bundleMicrovmProgram({
221 main: news.main,
222 runtime,
223 isExternal: news.isExternal ?? false,
224 external: news.external,
225 port,
226 build: news.build,
227 });
228 const dockerfile = buildMicrovmDockerfile(news.dockerfile, runtime, port);
229 const identity = `${bundleHash}:${dockerfile}`;
230 const contentHash = yield* sha256(identity);
231 const archive = yield* zipFiles([
232 { path: "Dockerfile", content: dockerfile },
233 ...files,
234 ]);
235 return { contentHash, identity, archive } satisfies ArtifactContent;
236 }
237
238 if (news.context) {
239 const path = yield* Path.Path;
240 const fs = yield* FileSystem.FileSystem;
241 yield* note("Packaging MicroVM build context...");
242 const files = yield* readContextDirectory(news.context);
243 // Ensure a Dockerfile is present (resolve from `dockerfile` path or default).
244 const dockerfileName = news.dockerfile ?? "Dockerfile";
245 const hasDockerfile = files.some((f) => f.path === dockerfileName);
246 if (!hasDockerfile) {
247 const dockerfilePath = path.join(news.context, dockerfileName);
248 const content = yield* fs
249 .readFile(dockerfilePath)
250 .pipe(Effect.catch(() => Effect.succeed(undefined)));
251 if (content) files.push({ path: "Dockerfile", content });
252 }
253 // Hash the BYTES, not `path:byteLength` — a same-length edit (a
254 // flipped version string, a swapped constant) must count as a change.
255 const contentId = (yield* Effect.forEach(
256 [...files].sort((a, b) => (a.path < b.path ? -1 : 1)),
257 (f) => Effect.map(sha256(f.content), (hash) => `${f.path}:${hash}`),
258 )).join("|");
259 const archive = yield* zipFiles(files);
260 return {
261 contentHash: yield* sha256(contentId),
262 identity: contentId,
263 archive,
264 } satisfies ArtifactContent;
265 }
266
267 if (news.codeArtifact?.uri) {
268 const uri: string = news.codeArtifact.uri;
269 return {
270 contentHash: yield* sha256(uri),
271 identity: uri,
272 uri,
273 } satisfies ArtifactContent;
274 }
275
276 return yield* Effect.die(
277 "MicrovmImage requires one of `main`, `context`, or `codeArtifact.uri`.",
278 );
279 }).pipe(
280 // Re-widen: inference normalizes the object-literal union (adding
281 // `uri?: undefined`), which defeats `"uri" in content` narrowing.
282 Effect.map((content): ArtifactContent => content),
283 Artifacts.cached(`microvm-image-content:${id}`),
284 );
285
286// Materialize + upload the code artifact and compute its build identity hash
287// (content + props). The `hash` formulas are unchanged from before
288// `contentHash` existed, so images deployed earlier are not rebuilt once.
289const resolveArtifact = Effect.fn(function* (
290 id: string,
291 news: MicrovmImageProps,
292 session: ScopedPlanStatusSession,
293) {
294 const propsId = buildPropsIdentity(news);
295 const content = yield* artifactContent(id, news, session.note);
296 const hash = yield* sha256(`${content.identity}:${propsId}`);
297 if ("uri" in content) {
298 return {
299 uri: content.uri,
300 hash,
301 contentHash: content.contentHash,
302 } satisfies ResolvedArtifact;
303 }
304 const assets = yield* Assets;
305 const key = yield* assets.uploadAsset(hash, content.archive);
306 const bucket = yield* assets.bucketName;
307 return {
308 uri: `s3://${bucket}/${key}`,
309 hash,
310 contentHash: content.contentHash,
311 } satisfies ResolvedArtifact;
312});
313
314const toAttrs = (
315 image: microvms.GetMicrovmImageOutput,
316 artifact?: ResolvedArtifact,
317): MicrovmImage["Attributes"] => ({
318 imageArn: image.imageArn,
319 name: image.name,
320 state: image.state,
321 imageVersion:
322 image.latestActiveImageVersion ?? image.latestFailedImageVersion,
323 latestActiveImageVersion: image.latestActiveImageVersion,
324 latestFailedImageVersion: image.latestFailedImageVersion,
325 createdAt: toIso(image.createdAt),
326 updatedAt: toIso(image.updatedAt),
327 codeArtifact: artifact
328 ? {
329 uri: artifact.uri,
330 hash: artifact.hash,
331 contentHash: artifact.contentHash,
332 }
333 : undefined,
334});
335
336const requireBuildRole = (news: MicrovmImageProps) => {
337 const arn = resolveBuildRoleArn(news);
338 return arn
339 ? Effect.succeed(arn)
340 : Effect.die("MicrovmImage requires `buildRole` to build the image.");
341};
342
343const createImage = Effect.fn(function* (
344 name: string,
345 news: MicrovmImageProps,
346 artifact: ResolvedArtifact,
347 baseImageArn: string,
348 desiredTags: Record<string, string>,
349 session: ScopedPlanStatusSession,
350) {
351 const buildRoleArn = yield* requireBuildRole(news);
352 yield* session.note(`Creating MicroVM image ${name}...`);
353 const created = yield* microvms
354 .createMicrovmImage({
355 name,
356 baseImageArn,
357 baseImageVersion: news.baseImageVersion,
358 buildRoleArn,
359 codeArtifact: { uri: artifact.uri },
360 description: news.description,
361 logging: news.logging,
362 egressNetworkConnectors: news.egressNetworkConnectors,
363 cpuConfigurations: news.cpuConfigurations,
364 resources: news.resources,
365 additionalOsCapabilities: news.additionalOsCapabilities,
366 hooks: news.hooks,
367 environmentVariables: foldEnv(news.env),
368 tags: desiredTags,
369 })
370 .pipe(
371 Effect.catchTag("ConflictException", () =>
372 getImage(name).pipe(
373 Effect.flatMap((existing) =>
374 existing
375 ? Effect.succeed({ imageArn: existing.imageArn })
376 : Effect.die(
377 `MicroVM image ${name} conflicted but was not found.`,
378 ),
379 ),
380 ),
381 ),
382 );
383 return yield* waitForReady(created.imageArn, session);
384});
385
386const updateImage = Effect.fn(function* (
387 imageArn: string,
388 news: MicrovmImageProps,
389 artifact: ResolvedArtifact,
390 baseImageArn: string,
391 session: ScopedPlanStatusSession,
392) {
393 const buildRoleArn = yield* requireBuildRole(news);
394 yield* session.note(`Updating MicroVM image ${news.name ?? imageArn}...`);
395 const updated = yield* microvms.updateMicrovmImage({
396 imageIdentifier: imageArn,
397 baseImageArn,
398 baseImageVersion: news.baseImageVersion,
399 buildRoleArn,
400 codeArtifact: { uri: artifact.uri },
401 description: news.description,
402 logging: news.logging,
403 egressNetworkConnectors: news.egressNetworkConnectors,
404 cpuConfigurations: news.cpuConfigurations,
405 resources: news.resources,
406 additionalOsCapabilities: news.additionalOsCapabilities,
407 hooks: news.hooks,
408 environmentVariables: foldEnv(news.env),
409 });
410 return yield* waitForReady(updated.imageArn, session);
411});
412
413const syncTags = Effect.fn(function* (
414 imageArn: string,
415 observedTags: Record<string, string>,
416 desiredTags: Record<string, string>,
417) {
418 const { removed, upsert } = diffTags(observedTags, desiredTags);
419 if (removed.length > 0) {
420 yield* microvms.untagResource({ Resource: imageArn, TagKeys: removed });
421 }
422 if (upsert.length > 0) {
423 yield* microvms.tagResource({
424 Resource: imageArn,
425 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
426 });
427 }
428});
429
430export const MicrovmImageProvider = () =>
431 Provider.succeed(MicrovmImage, {
432 stables: ["imageArn", "name"],
433
434 diff: Effect.fn(function* ({ id, olds, news, output }) {
435 // A content-only edit of the in-VM program changes none of the props,
436 // so the engine's structural diff would call it a noop and the image
437 // would never rebuild. Bundle (memoized for the run) and compare the
438 // CONTENT hash, exactly as the Lambda Function diff does. This runs
439 // before the props resolve — the build role is an Output at plan
440 // time — which is fine because only the plain content inputs are
441 // read. Rows persisted before `contentHash` existed skip the check
442 // until their next reconcile stamps it.
443 const previousContentHash = output?.codeArtifact?.contentHash;
444 const content = resolvedContentInputs(news);
445 if (previousContentHash !== undefined && content !== undefined) {
446 const { contentHash } = yield* artifactContent(
447 id,
448 content,
449 () => Effect.void,
450 );
451 if (contentHash !== previousContentHash) {
452 return { action: "update" } as const;
453 }
454 }
455 if (!isResolved(news)) return;
456 const oldName = yield* resolveName(id, olds.name);
457 const newName = yield* resolveName(id, news.name);
458 if (oldName !== newName) {
459 return { action: "replace" } as const;
460 }
461 }),
462
463 read: Effect.fn(function* ({ id, olds, output }) {
464 // Prefer the cached ARN; otherwise look up by name (getMicrovmImage
465 // rejects names).
466 const image = output?.imageArn
467 ? yield* getImage(output.imageArn)
468 : yield* findImageByName(
469 output?.name ?? (yield* resolveName(id, olds?.name)),
470 );
471 return image
472 ? toAttrs(image, output?.codeArtifact as ResolvedArtifact | undefined)
473 : undefined;
474 }),
475
476 list: () =>
477 Effect.gen(function* () {
478 const summaries = yield* microvms.listMicrovmImages.items({}).pipe(
479 Stream.runCollect,
480 Effect.map((chunk) => Array.from(chunk)),
481 );
482 const images = yield* Effect.forEach(
483 summaries,
484 (summary) => getImage(summary.imageArn),
485 { concurrency: 10 },
486 );
487 return images.flatMap((image) => (image ? [toAttrs(image)] : []));
488 }),
489
490 reconcile: Effect.fn(function* ({ id, news, output, session }) {
491 const name = yield* resolveName(id, news.name);
492 const internalTags = yield* createInternalTags(id);
493 const desiredTags = { ...internalTags, ...news.tags };
494 const baseImageArn =
495 resolveBaseImageArn(news) ?? (yield* defaultBaseImageArn());
496
497 // Resolve + upload the artifact and compute its build identity.
498 const artifact = yield* resolveArtifact(id, news, session);
499
500 // Observe — prefer the cached ARN; otherwise look up by name (a name
501 // is not a valid `getMicrovmImage` identifier).
502 const observed = output?.imageArn
503 ? yield* getImage(output.imageArn)
504 : yield* findImageByName(name);
505
506 // Ensure + sync: rebuild only when the build identity changed.
507 const image = !observed
508 ? yield* createImage(
509 name,
510 news,
511 artifact,
512 baseImageArn,
513 desiredTags,
514 session,
515 )
516 : output?.codeArtifact?.hash === artifact.hash
517 ? observed
518 : yield* updateImage(
519 observed.imageArn,
520 news,
521 artifact,
522 baseImageArn,
523 session,
524 );
525
526 yield* syncTags(
527 image.imageArn,
528 (image.tags ?? {}) as Record<string, string>,
529 desiredTags,
530 );
531
532 yield* session.note(`MicroVM image ${name} is ${image.state}`);
533 return toAttrs(image, artifact);
534 }),
535
536 delete: Effect.fn(function* ({ output, session }) {
537 // An image can't be deleted while it still has MicroVMs running — tear
538 // them down first and wait for them to disappear.
539 yield* terminateRunningMicrovms(output.imageArn, session);
540 yield* session.note(`Deleting MicroVM image ${output.name}...`);
541
542 yield* microvms
543 .deleteMicrovmImage({ imageIdentifier: output.imageArn })
544 .pipe(
545 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
546 // A MicroVM caught mid-termination still blocks the delete; retry
547 // briefly until the instances are fully gone.
548 Effect.retry({
549 while: (e): e is ValidationException =>
550 e._tag === "ValidationException" &&
551 e.message.includes("running MicroVMs"),
552 schedule: Schedule.max([
553 Schedule.fixed(5_000),
554 Schedule.recurs(12),
555 ]),
556 }),
557 );
558 yield* waitForDeleted(output.imageArn, session);
559 }),
560 });
561
562class ImageBuilding extends Data.TaggedError("ImageBuilding")<{
563 imageArn: string;
564 state: string;
565}> {}
566
567class ImageFailed extends Data.TaggedError("ImageFailed")<{
568 imageArn: string;
569 state: string;
570 reason?: string;
571}> {}
572
573class MicrovmsActive extends Data.TaggedError("MicrovmsActive")<{
574 count: number;
575}> {}
576
577// MicroVM states that still hold a slot on the image and block its deletion.
578const ACTIVE_MICROVM_STATES = new Set<string>([
579 "PENDING",
580 "RUNNING",
581 "SUSPENDING",
582 "SUSPENDED",
583]);
584
585const listActiveMicrovms = (imageArn: string) =>
586 microvms.listMicrovms.items({ imageIdentifier: imageArn }).pipe(
587 Stream.runCollect,
588 Effect.map((chunk) =>
589 Array.from(chunk).filter((m) => ACTIVE_MICROVM_STATES.has(m.state)),
590 ),
591 Effect.catchTag("ResourceNotFoundException", () =>
592 Effect.succeed([] as microvms.MicrovmItem[]),
593 ),
594 );
595
596// Terminate every running MicroVM launched from this image and wait until none
597// remain active — a prerequisite for deleting the image.
598const terminateRunningMicrovms = Effect.fn(function* (
599 imageArn: string,
600 session: ScopedPlanStatusSession,
601) {
602 const active = yield* listActiveMicrovms(imageArn);
603 if (active.length === 0) return;
604 yield* session.note(`Terminating ${active.length} running MicroVM(s)...`);
605 yield* Effect.forEach(
606 active,
607 (m) =>
608 microvms.terminateMicrovm({ microvmIdentifier: m.microvmId }).pipe(
609 // Already gone or mid-transition — the wait below converges anyway.
610 Effect.catchTag(
611 [
612 "ResourceNotFoundException",
613 "ConflictException",
614 "ValidationException",
615 ],
616 () => Effect.void,
617 ),
618 ),
619 { concurrency: 5, discard: true },
620 );
621 yield* listActiveMicrovms(imageArn).pipe(
622 Effect.flatMap((remaining) =>
623 remaining.length === 0
624 ? Effect.void
625 : new MicrovmsActive({ count: remaining.length }),
626 ),
627 Effect.retry({
628 while: (e) => e._tag === "MicrovmsActive",
629 schedule: Schedule.max([Schedule.fixed(5_000), Schedule.recurs(24)]).pipe(
630 Schedule.tap(() =>
631 session.note("Waiting for MicroVMs to terminate..."),
632 ),
633 ),
634 }),
635 );
636});
637
638// Drill into the failed version's `stateReason` (and per-architecture build
639// state reasons) so a build failure surfaces an actionable message instead of
640// just `CREATE_FAILED`.
641const buildFailureReason = Effect.fn(function* (
642 imageArn: string,
643 imageVersion: string | undefined,
644) {
645 if (!imageVersion) return undefined;
646 const version = yield* microvms
647 .getMicrovmImageVersion({ imageIdentifier: imageArn, imageVersion })
648 .pipe(Effect.catch(() => Effect.succeed(undefined)));
649 // A version fans out to one build per architecture/chipset; report the reason
650 // from each build that actually FAILED (keyed by architecture so multi-platform
651 // failures are distinguishable). `buildState` is the reliable signal — don't
652 // guess via timestamps.
653 const builds = yield* microvms.listMicrovmImageBuilds
654 .items({ imageIdentifier: imageArn, imageVersion })
655 .pipe(
656 Stream.runCollect,
657 Effect.map((chunk) => Array.from(chunk)),
658 Effect.catch(() =>
659 Effect.succeed([] as microvms.MicrovmImageBuildSummary[]),
660 ),
661 );
662 const failedBuildReasons = builds
663 .filter((b) => b.buildState === "FAILED" && b.stateReason)
664 .map((b) => `${b.architecture}: ${b.stateReason}`);
665 // Prefer the per-build failure reasons; fall back to the version-level reason.
666 const reasons =
667 failedBuildReasons.length > 0
668 ? [...new Set(failedBuildReasons)]
669 : [version?.stateReason].filter((r): r is string => !!r);
670 return reasons.join("; ");
671});
672
673const waitForReady = (imageArn: string, session: ScopedPlanStatusSession) =>
674 Effect.gen(function* () {
675 const image = yield* microvms.getMicrovmImage({
676 imageIdentifier: imageArn,
677 });
678 if (READY_STATES.has(image.state)) return image;
679 if (FAILED_STATES.has(image.state)) {
680 const reason = yield* buildFailureReason(
681 imageArn,
682 image.latestFailedImageVersion,
683 );
684 yield* session.note(
685 `MicroVM image build ${image.state}: ${reason ?? "(no reason reported)"}`,
686 );
687 yield* Effect.logError(
688 `MicroVM image ${imageArn} build ${image.state}: ${reason ?? "(no reason reported)"}`,
689 );
690 return yield* new ImageFailed({ imageArn, state: image.state, reason });
691 }
692 return yield* new ImageBuilding({ imageArn, state: image.state });
693 }).pipe(
694 Effect.retry({
695 while: (e) => e._tag === "ImageBuilding",
696 schedule: Schedule.max([
697 Schedule.fixed(10_000),
698 Schedule.recurs(72),
699 ]).pipe(
700 Schedule.tap(({ attempt }) =>
701 session.note(`Waiting for MicroVM image build... (${attempt * 10}s)`),
702 ),
703 ),
704 }),
705 );
706
707const waitForDeleted = (imageArn: string, session: ScopedPlanStatusSession) =>
708 Effect.gen(function* () {
709 const image = yield* microvms
710 .getMicrovmImage({ imageIdentifier: imageArn })
711 .pipe(
712 Effect.catchTag("ResourceNotFoundException", () =>
713 Effect.succeed(undefined),
714 ),
715 );
716 if (!image || image.state === "DELETED") return;
717 if (image.state === "DELETE_FAILED") {
718 return yield* new ImageFailed({ imageArn, state: image.state });
719 }
720 return yield* new ImageBuilding({ imageArn, state: image.state });
721 }).pipe(
722 Effect.retry({
723 while: (e) => e._tag === "ImageBuilding",
724 schedule: Schedule.max([
725 Schedule.fixed(10_000),
726 Schedule.recurs(72),
727 ]).pipe(
728 Schedule.tap(({ attempt }) =>
729 session.note(
730 `Waiting for MicroVM image deletion... (${attempt * 10}s)`,
731 ),
732 ),
733 ),
734 }),
735 );