| 1 | import * as microvms from "@distilled.cloud/aws/lambda-microvms"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | import * as Stream from "effect/Stream"; |
| 9 | |
| 10 | import type { ValidationException } from "@distilled.cloud/aws/Errors"; |
| 11 | import * as Artifacts from "../../Artifacts.ts"; |
| 12 | import type { ScopedPlanStatusSession } from "../../Report.ts"; |
| 13 | import { isResolved } from "../../Diff.ts"; |
| 14 | import type { Input, InputProps } from "../../Input.ts"; |
| 15 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 16 | import * as Provider from "../../Provider.ts"; |
| 17 | import { createInternalTags, diffTags } from "../../Tags.ts"; |
| 18 | import { sha256 } from "../../Util/sha256.ts"; |
| 19 | import { Assets } from "../Assets.ts"; |
| 20 | import { |
| 21 | buildMicrovmDockerfile, |
| 22 | bundleMicrovmProgram, |
| 23 | DEFAULT_MICROVM_PORT, |
| 24 | readContextDirectory, |
| 25 | zipFiles, |
| 26 | } from "./MicrovmBundle.ts"; |
| 27 | import { 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. |
| 32 | const 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 | |
| 45 | const READY_STATES = new Set<string>(["CREATED", "UPDATED"]); |
| 46 | const FAILED_STATES = new Set<string>([ |
| 47 | "CREATE_FAILED", |
| 48 | "UPDATE_FAILED", |
| 49 | "DELETE_FAILED", |
| 50 | ]); |
| 51 | |
| 52 | const 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. |
| 59 | const 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 | |
| 66 | const 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. |
| 74 | const 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 | |
| 88 | interface 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 | */ |
| 99 | type 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. */ |
| 111 | type 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 | */ |
| 131 | const 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 | |
| 158 | const 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. |
| 166 | const 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. |
| 177 | const 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 | |
| 188 | const 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 | */ |
| 210 | const 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. |
| 289 | const 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 | |
| 314 | const 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 | |
| 336 | const 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 | |
| 343 | const 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 | |
| 386 | const 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 | |
| 413 | const 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 | |
| 430 | export 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 | |
| 562 | class ImageBuilding extends Data.TaggedError("ImageBuilding")<{ |
| 563 | imageArn: string; |
| 564 | state: string; |
| 565 | }> {} |
| 566 | |
| 567 | class ImageFailed extends Data.TaggedError("ImageFailed")<{ |
| 568 | imageArn: string; |
| 569 | state: string; |
| 570 | reason?: string; |
| 571 | }> {} |
| 572 | |
| 573 | class 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. |
| 578 | const ACTIVE_MICROVM_STATES = new Set<string>([ |
| 579 | "PENDING", |
| 580 | "RUNNING", |
| 581 | "SUSPENDING", |
| 582 | "SUSPENDED", |
| 583 | ]); |
| 584 | |
| 585 | const 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. |
| 598 | const 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`. |
| 641 | const 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 | |
| 673 | const 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 | |
| 707 | const 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 | ); |