microvms_core/control/image.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Image create, the build wait, and the stalled-build probe (TRAP-1, TRAP-2).
3//!
4//! # Why the wait needs a probe at all
5//!
6//! `CREATING` covers two situations that are indistinguishable from the outside: a build in
7//! progress, and a build the service **never scheduled**. The second is the `clientToken`
8//! replay signature — the image was created as a no-op replay of an earlier create, its
9//! builds were never queued, it cannot be deleted (`CREATING` forbids it) and its only
10//! version cannot be dropped (it is the last one).
11//!
12//! Waiting through it burns the full 45-minute build timeout and then reports "did not
13//! become usable in time", which is a timeout message hiding a cause that was knowable in
14//! four minutes. So after [`WaitOpts::stall_grace`] the wait probes the build list: every
15//! build still `PENDING` means nothing was ever scheduled, and that is reported as
16//! [`ErrorKind::BuildWedged`] naming the replay signature.
17//!
18//! # The field is `buildState`
19//!
20//! `MicrovmImageBuildSummary` has no `state` member. Reading `state` returns nothing from
21//! every real response, which is exactly how this guard was dead for a review round while
22//! its unit test passed — see [`crate::control::ops`]. The deserializer refuses a
23//! `state`-spelled summary outright, so that failure mode is now a test rather than a
24//! comment.
25
26use std::time::Duration;
27
28use super::transport::{Call, paths, send_with_retry};
29use super::{ControlPlane, CreateImageRequest, artifact, hooks_block, ops, timed_out, token};
30use crate::error::{Error, ErrorKind};
31use crate::sizing::SizeClass;
32
33/// How long an image may sit in `CREATING` before the stall probe runs.
34///
35/// Four minutes: long enough that a genuinely slow build is not accused, short enough that a
36/// wedged one does not burn the full build timeout in silence.
37pub const DEFAULT_STALL_GRACE: Duration = Duration::from_secs(240);
38
39/// The default build timeout, 45 minutes.
40pub const DEFAULT_BUILD_TIMEOUT: Duration = Duration::from_secs(45 * 60);
41
42/// The default gap between polls.
43///
44/// Fifteen seconds, matching the Python client's `max(poll_interval, 15.0)` for the image
45/// wait: a build takes minutes, so a tighter interval only spends control-plane quota.
46pub const DEFAULT_IMAGE_POLL_INTERVAL: Duration = Duration::from_secs(15);
47
48/// The log-group prefix the service writes build logs under.
49///
50/// Measured 2026-08-05. **Not** `/aws/lambda/microvms/*` — an IAM policy granting that
51/// plausible-looking prefix instead produces server-side builds with **no logs at all**,
52/// and then every build failure reads `reason=unknown`, which looks like the service
53/// failing to populate `stateReason` when it is the caller's own policy discarding the
54/// evidence.
55pub const BUILD_LOG_GROUP_PREFIX: &str = "/aws/lambda-microvms";
56
57/// A built image, and the log group the service created alongside it.
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct Image {
60 /// The image ARN, which is what `imageIdentifier` takes.
61 pub identifier: String,
62 /// The image name.
63 pub name: String,
64 /// The version the create call produced.
65 pub version: String,
66 /// The state the service last reported.
67 pub state: String,
68 /// The class the requested baseline selected.
69 ///
70 /// Carried on the image because billing follows the baseline requested at *create*
71 /// time, and by the time anyone asks what a run cost the request is gone.
72 pub size: SizeClass,
73 /// The build log group the create call configured, or `None` for the service default
74 /// ([`Image::build_log_group`]'s derivation).
75 ///
76 /// Carried for the same reason `size` is: by the time anyone asks where the build's
77 /// logs went, the request is gone.
78 pub log_group: Option<String>,
79 /// The **resolved** exact log stream the create call configured — the caller's prefix
80 /// plus `/` plus the sixteen-hex per-build discriminator — or `None` when no stream
81 /// was configured.
82 ///
83 /// This is the only place the resolved name exists: the discriminator is minted fresh
84 /// inside [`ControlPlane::create_image`], so a caller who wants to read their build's
85 /// logs has to read the name off this field rather than re-deriving it. The CLI's
86 /// build envelope reports it as `logStream` for exactly that reason.
87 pub log_stream: Option<String>,
88}
89
90impl Image {
91 /// The configured build log group, or the derived default
92 /// `/aws/lambda-microvms/<image-name>`.
93 ///
94 /// The default is the group the service creates itself, so a Terraform stack never
95 /// owns it and `terraform destroy` leaves it behind — "the stack destroyed cleanly"
96 /// is not "the account is clean". Six of these accumulated before anyone noticed. A
97 /// configured group ([`crate::control::CreateImageRequest::log_group`]) replaces the
98 /// derivation, because the derived name is then a group no build ever writes to.
99 pub fn build_log_group(&self) -> String {
100 match &self.log_group {
101 Some(group) => group.clone(),
102 None => format!("{BUILD_LOG_GROUP_PREFIX}/{}", self.name),
103 }
104 }
105
106 /// Whether the state the service reported means "built and usable".
107 ///
108 /// Accepts the two model-backed spellings and the two tolerated ones. Kept generous
109 /// because the service has answered differently across API versions, and a hard
110 /// equality check on one spelling is how a working build looks like a stalled one.
111 pub fn is_ready(state: &str) -> bool {
112 crate::constants::MODEL_IMAGE_READY_STATES.contains(&state)
113 || crate::constants::TOLERATED_IMAGE_READY_STATES.contains(&state)
114 }
115
116 /// Whether the state means the build failed.
117 ///
118 /// A substring test rather than a set, because the model's failure spellings are
119 /// `CREATE_FAILED`, `UPDATE_FAILED`, and `DELETE_FAILED` and the thing they share is
120 /// the word — matching that means a fourth added later is still recognised as a
121 /// failure rather than polled until the deadline.
122 pub fn is_failed(state: &str) -> bool {
123 state.contains("FAILED")
124 }
125}
126
127/// How long to wait and how often to poll.
128#[derive(Clone, Copy, Debug)]
129pub struct WaitOpts {
130 /// The client-side deadline.
131 pub timeout: Duration,
132 /// The gap between polls.
133 pub poll_interval: Duration,
134 /// How long `CREATING` is tolerated before the stall probe runs (TRAP-2).
135 ///
136 /// Only meaningful for the image wait; the launch wait has no equivalent, because a
137 /// launch has no build list to probe.
138 pub stall_grace: Duration,
139}
140
141impl Default for WaitOpts {
142 fn default() -> Self {
143 Self {
144 timeout: DEFAULT_BUILD_TIMEOUT,
145 poll_interval: DEFAULT_IMAGE_POLL_INTERVAL,
146 stall_grace: DEFAULT_STALL_GRACE,
147 }
148 }
149}
150
151impl WaitOpts {
152 /// Options for the launch wait: five minutes, five-second polls.
153 ///
154 /// `stall_grace` is set past the timeout rather than to zero, since a zero grace would
155 /// read as "probe immediately" — and there is nothing to probe on the launch path.
156 pub fn for_launch() -> Self {
157 Self {
158 timeout: Duration::from_secs(300),
159 poll_interval: Duration::from_secs(5),
160 stall_grace: Duration::MAX,
161 }
162 }
163}
164
165impl ControlPlane {
166 /// Creates an image, closing every local guard **before** the call.
167 ///
168 /// Order matters and is the same order the Python client uses for the same reason: the
169 /// create call happens *after* the artifact upload, so a rejection the service raises
170 /// costs the caller the upload first. Everything checkable is checked here — and the
171 /// checks are [`Self::preflight`], so an uploading caller can run the same list before
172 /// the upload and pay nothing for a request this client itself refuses.
173 ///
174 /// The artifact is built here too, but **not** uploaded:
175 /// [`CreateImageRequest::code_artifact_uri`] is where the caller says they have already
176 /// put it, and [`ControlPlane::build_artifact_for`] produces the bytes to upload.
177 /// [`crate::sandbox::Sandbox::ensure_image`] is the path that uploads for itself.
178 pub async fn create_image(&self, request: CreateImageRequest) -> Result<Image, Error> {
179 self.preflight(&request)?;
180
181 // The discriminator rule: a configured logStream never reaches the wire verbatim.
182 // The member is an EXACT stream name (prefixes unsupported) and one build is three
183 // VMs writing three streams, so a verbatim name collapses every build's streams
184 // into one — this suffixes `/<16 hex>` of fresh CSPRNG per create attempt, the
185 // same mechanism as the clientToken nonce below (docs/PLATFORM.md, 'An image
186 // build is three VMs and three log streams'). Resolved here, once, so the value
187 // on the wire and the value returned to the caller are the same string.
188 let resolved_stream = request.log_stream.as_deref().map(token::resolve_log_stream);
189 let logging = request
190 .log_group
191 .as_deref()
192 .map(|group| ops::Logging::cloud_watch(group, resolved_stream.clone()));
193
194 let wire = ops::CreateMicrovmImageWire {
195 name: request.name.clone(),
196 base_image_arn: request.base_image.arn(&self.region),
197 base_image_version: request.base_image_version.clone(),
198 build_role_arn: request.build_role_arn.clone(),
199 code_artifact: ops::CodeArtifact {
200 uri: request.code_artifact_uri.clone(),
201 },
202 // The enum's only value. Injected rather than accepted, because a field could
203 // only ever express a request AWS rejects — after the upload.
204 cpu_configurations: vec![ops::CpuConfiguration {
205 architecture: crate::constants::ARCHITECTURES[0].to_string(),
206 }],
207 resources: vec![ops::Resources {
208 minimum_memory_in_mib: request.size.baseline_mib(),
209 }],
210 hooks: hooks_block(
211 self.port,
212 request.run_hook_timeout,
213 request.build_hook_timeout,
214 ),
215 // TRAP-3: the boolean intent becomes the one accepted enum value. There is no
216 // list a caller could put `CAP_SYS_ADMIN` in.
217 additional_os_capabilities: request
218 .repair_guest_identity
219 .then(|| vec![crate::constants::CAPABILITIES[0].to_string()]),
220 logging,
221 tags: (!request.tags.is_empty()).then(|| request.tags.clone()),
222 // TRAP-1: minted here, from a label. Never from the caller.
223 client_token: token::create_token(
224 request.token_scope.as_deref().unwrap_or(&request.name),
225 ),
226 };
227
228 let call = Call::post_json("CreateMicrovmImage", paths::microvm_images(), &wire)?;
229 let reply = send_with_retry(self.transport(), call).await?;
230 let created: ops::CreateMicrovmImageResponseWire = reply.json("CreateMicrovmImage")?;
231
232 Ok(Image {
233 identifier: created.image_arn,
234 name: created.name,
235 version: created.image_version,
236 state: created.state,
237 size: request.size,
238 log_group: request.log_group.clone(),
239 // The resolved name, discriminator included, so the caller can find their
240 // logs — the wire value is the only copy and it was minted here.
241 log_stream: resolved_stream,
242 })
243 }
244
245 /// Every local guard [`Self::create_image`] runs, callable **before** the artifact upload.
246 ///
247 /// # One list of guards, reachable from before the upload
248 ///
249 /// The guards below are pure functions of the request — no call, no credential — and
250 /// `create_image` runs them before its own wire call. But `create_image` runs *after*
251 /// the caller's artifact upload, so from inside it a locally-refusable request has
252 /// already cost the caller the S3 PUT (issue #47). This method is the same list,
253 /// extracted so an uploading caller invokes it first and the list cannot drift between
254 /// call sites — `create_image` delegates here rather than keeping its own copy.
255 ///
256 /// A caller who skips it loses nothing but the upload: `create_image` still refuses
257 /// before the wire.
258 pub fn preflight(&self, request: &CreateImageRequest) -> Result<(), Error> {
259 super::require_valid_image_name(&request.name)?;
260
261 if request.inherit_workdir {
262 artifact::require_workdir(&request.base_image, request.dockerfile.as_deref())?;
263 }
264 if let Some(dockerfile) = request.dockerfile.as_deref() {
265 artifact::require_matching_from(&request.base_image, dockerfile)?;
266 artifact::require_matching_agentd_port(self.port, dockerfile)?;
267 artifact::require_keepalive_under_idle_timeout(
268 crate::session::exec::DEFAULT_STREAM_IDLE_TIMEOUT,
269 dockerfile,
270 )?;
271 // The artifact carries the daemon unconditionally; a Dockerfile that never runs
272 // it builds cleanly and fails as a run-hook timeout naming nothing (issue #46).
273 artifact::require_daemon_cmd(dockerfile)?;
274 // Same shape for the project files (#74): they enter the artifact
275 // unconditionally, and a Dockerfile that ignores them bakes no layer while
276 // building cleanly.
277 if let Some(project) = &request.project_files {
278 artifact::require_project_install(project, dockerfile)?;
279 }
280 }
281
282 // Pinned only when the caller asked for it, and refused locally when they asked for
283 // something the `Version` shape cannot carry — see `require_valid_version`. A blank
284 // or whitespace-bearing value here is a `ValidationException` raised *after* the
285 // artifact upload, which is the ordering this whole function is arranged to protect
286 // against.
287 if let Some(version) = request.base_image_version.as_deref() {
288 super::require_valid_version("baseImageVersion", version)?;
289 }
290
291 // The logging pair, when configured. The stream requires the group because a
292 // stream inside a group the service names randomly is a stream whose location
293 // nobody can predict — the configured pair is the whole point of configuring.
294 // Both checked here for the create-after-upload ordering everything else in this
295 // list is about.
296 if let Some(group) = request.log_group.as_deref() {
297 super::require_valid_log_group(group)?;
298 }
299 if let Some(stream) = request.log_stream.as_deref() {
300 if request.log_group.is_none() {
301 return Err(Error::invalid_arg(
302 "logging.cloudWatch.logStream was configured with no logGroup. The \
303 stream lives inside the group, and with no group configured the \
304 service creates one with random stream names — so a configured \
305 stream alone names a location that does not exist. Configure the \
306 group too, or drop the stream."
307 .to_string(),
308 ));
309 }
310 super::require_valid_log_stream(stream)?;
311 }
312
313 // Every remaining member the model constrains, in the order the request lists them.
314 // Each one is here for the same reason `require_valid_image_name` above is: the
315 // create call happens *after* the caller's artifact upload, so a rejection the
316 // service raises has already cost them the upload. Issue #24 listed all four as
317 // reachable with no guard.
318 //
319 // `baseImageArn` is derived from `BaseImage` and the region, so it cannot be blank
320 // today — checked anyway, because `BaseImage` is a public type with public fields and
321 // a caller can construct one with an empty `name`, which renders an ARN this client
322 // built and the service refuses.
323 // `hooks.port` is deliberately **not** checked here. `ControlPlane::port` is private and
324 // `with_port` is its only setter, and that setter refuses 0 against
325 // `HooksPortInteger.min` — so a plane whose port is illegal cannot exist and a check here
326 // would be a branch no input can reach. An unfalsifiable guard is worse than none: it
327 // reads as protection and no test can make it fire. See
328 // `the_hooks_port_that_reaches_the_wire_is_legal_by_construction`.
329 super::require_valid_role_arn("buildRoleArn", &request.build_role_arn)?;
330 super::require_non_blank("codeArtifact.uri", &request.code_artifact_uri)?;
331 super::require_non_blank("baseImageArn", &request.base_image.arn(&self.region))?;
332 super::require_valid_tags(&request.tags)
333 }
334
335 /// The artifact bytes to upload to [`CreateImageRequest::code_artifact_uri`].
336 ///
337 /// Separate from [`ControlPlane::create_image`] because the upload is the caller's — and
338 /// because the byte-scan guard (AC-2-3) needs to inspect these bytes without making a
339 /// control-plane call.
340 pub fn build_artifact_for(&self, request: &CreateImageRequest) -> Result<Vec<u8>, Error> {
341 let dockerfile = match request.dockerfile.as_deref() {
342 Some(dockerfile) => dockerfile.to_string(),
343 None => artifact::default_dockerfile(
344 self.port,
345 None,
346 &request.base_image,
347 request.project_files.as_ref().map(|files| files.ecosystem),
348 ),
349 };
350 artifact::build_artifact(&request.binary, &dockerfile, request.project_files.as_ref())
351 }
352
353 /// Polls until the image is usable, distinguishing a stalled build from a slow one.
354 ///
355 /// Returns the image as the service last described it. Raises
356 /// [`ErrorKind::BuildWedged`] for the replay signature (TRAP-2),
357 /// [`ErrorKind::Platform`] for a reported build failure, and [`ErrorKind::Timeout`]
358 /// when the deadline elapses.
359 pub async fn wait_for_image(
360 &self,
361 identifier: &str,
362 size: SizeClass,
363 opts: WaitOpts,
364 ) -> Result<Image, Error> {
365 // Before the loop rather than inside it: an empty identifier would collapse the URI
366 // onto the collection and poll the *listing* until the deadline, which is a 45-minute
367 // timeout about a resource nobody addressed. See `require_valid_identifier`.
368 super::require_valid_identifier("imageIdentifier", identifier)?;
369 let started = self.clock().elapsed();
370 let mut probed = false;
371
372 loop {
373 let call = Call::get("GetMicrovmImage", paths::microvm_image(identifier));
374 let reply = send_with_retry(self.transport(), call).await?;
375 let got: ops::GetMicrovmImageResponseWire = reply.json("GetMicrovmImage")?;
376
377 if Image::is_ready(&got.state) {
378 return Ok(Image {
379 identifier: got.image_arn,
380 name: got.name,
381 version: got.latest_active_image_version.unwrap_or_default(),
382 state: got.state,
383 size,
384 // `None`, not "unknown": `GetMicrovmImage` reports no logging config,
385 // so the wait cannot know one. The create call is the only place the
386 // resolved stream exists, and `Sandbox::build_image` carries the
387 // create's values across this readback for that reason.
388 log_group: None,
389 log_stream: None,
390 });
391 }
392 if Image::is_failed(&got.state) {
393 return Err(self.build_failure(&got).await);
394 }
395
396 let elapsed = self.clock().elapsed().saturating_sub(started);
397 if !probed && elapsed > opts.stall_grace {
398 probed = true;
399 // Raises for the replay signature; returns for anything else, including a
400 // listing that failed.
401 self.probe_stalled_build(identifier, elapsed).await?;
402 }
403 if elapsed >= opts.timeout {
404 return Err(timed_out(
405 &format!(
406 "image {identifier} did not become usable (last state {})",
407 got.state
408 ),
409 elapsed,
410 ));
411 }
412 self.clock().sleep(opts.poll_interval).await;
413 }
414 }
415
416 /// Raises [`ErrorKind::BuildWedged`] when every build is still `PENDING`.
417 ///
418 /// # Why a listing failure is not swallowed silently
419 ///
420 /// The Python probe caught every exception and returned, on the reasoning that a
421 /// best-effort probe must never break the wait (`sandbox.py:879`). That is right about
422 /// the wait and wrong about the diagnosis: a probe that cannot see the build list
423 /// cannot say the build is wedged, but it also should not report "everything is fine".
424 /// So a listing failure is **mapped to retryable and returned as `Ok`** — the wait
425 /// continues, the probe re-arms nothing, and the caller ends up with the honest
426 /// timeout rather than a wedge claim made without evidence.
427 ///
428 /// The distinction that matters is the one the empty-log-group diagnostic makes for
429 /// build failures: unknown is not empty, and a claim made on a throttled API call sends
430 /// the reader after the wrong cause.
431 async fn probe_stalled_build(&self, identifier: &str, elapsed: Duration) -> Result<(), Error> {
432 let Some(builds) = self.builds_of_first_version(identifier).await else {
433 // Could not see the build list. Say nothing rather than guess.
434 return Ok(());
435 };
436
437 if builds.is_empty() {
438 // No builds listed at all is not the signature: the replay case lists builds
439 // and leaves them PENDING. An empty list is a version whose builds have not
440 // been enumerated yet.
441 return Ok(());
442 }
443 // `buildState`, not `state`. The deserializer refuses the other spelling, so this
444 // read cannot silently produce nothing the way `b.get("state")` did.
445 if !builds.iter().all(|build| build.build_state == "PENDING") {
446 return Ok(());
447 }
448
449 Err(Error::new(
450 ErrorKind::BuildWedged,
451 format!(
452 "build never scheduled after {:.0}s: all {} builds are still PENDING ({}). This \
453 is the clientToken replay signature — a clientToken is a permanent idempotency \
454 key, so a create whose token repeats an earlier one is replayed as a no-op: the \
455 image sits in CREATING with its builds never queued, cannot be deleted (CREATING \
456 forbids it), and its only version cannot be dropped either because it is the last \
457 one. Two images were wedged this way for ~15 hours (docs/PLATFORM.md, \
458 '`clientToken` is a permanent idempotency key'). Waiting will not help.",
459 elapsed.as_secs_f64(),
460 builds.len(),
461 builds
462 .iter()
463 .map(ops::MicrovmImageBuildSummaryWire::describe)
464 .collect::<Vec<_>>()
465 .join(", "),
466 ),
467 ))
468 }
469
470 /// Every build of the image's first version, or `None` when the listing could not be
471 /// read.
472 ///
473 /// Two calls — versions, then builds — because `ListMicrovmImageBuilds` requires an
474 /// `imageVersion` in its path.
475 ///
476 /// # Every page, because the verdict is over *all* builds
477 ///
478 /// `ListMicrovmImageBuilds` caps `maxResults` at 50, and [`Self::probe_stalled_build`]
479 /// asserts **all** builds are `PENDING`. Over a truncated page that quantifier is a
480 /// claim about a subset presented as a claim about the set, and it is wrong in both
481 /// directions: 50 pending builds on page one with a scheduled build on page two reads
482 /// as a wedge on a healthy image, and one page of scheduled builds hides a real wedge.
483 /// Since the verdict is a universal, the loop reads to the last page before deciding.
484 async fn builds_of_first_version(
485 &self,
486 identifier: &str,
487 ) -> Option<Vec<ops::MicrovmImageBuildSummaryWire>> {
488 let version = self.first_version(identifier).await?.image_version;
489 self.builds_of_version(identifier, &version).await
490 }
491
492 /// Every build of `version`, or `None` when the listing could not be read.
493 async fn builds_of_version(
494 &self,
495 identifier: &str,
496 version: &str,
497 ) -> Option<Vec<ops::MicrovmImageBuildSummaryWire>> {
498 let mut builds = Vec::new();
499 let mut next_token: Option<String> = None;
500 loop {
501 let call = Call::get(
502 "ListMicrovmImageBuilds",
503 paths::image_builds(identifier, version, next_token.as_deref()),
504 );
505 let page: ops::ListImageBuildsResponseWire = send_with_retry(self.transport(), call)
506 .await
507 .ok()?
508 .json("ListMicrovmImageBuilds")
509 .ok()?;
510 builds.extend(page.items);
511 match page.next_token {
512 Some(token) => next_token = Some(token),
513 None => return Some(builds),
514 }
515 }
516 }
517
518 /// The first version listed, or `None` when the listing could not be read or the image
519 /// has none.
520 ///
521 /// Only the first page is fetched, and that is correct rather than a second instance
522 /// of the same bug: this reads `items.first()`, so a later page cannot change the
523 /// answer. Fetching the rest would be calls whose results are discarded.
524 async fn first_version(&self, identifier: &str) -> Option<ops::MicrovmImageVersionSummaryWire> {
525 let call = Call::get(
526 "ListMicrovmImageVersions",
527 paths::image_versions(identifier, None),
528 );
529 let versions: ops::ListImageVersionsResponseWire = send_with_retry(self.transport(), call)
530 .await
531 .ok()?
532 .json("ListMicrovmImageVersions")
533 .ok()?;
534 versions.items.into_iter().next()
535 }
536
537 /// The message for a build the service reported as failed, with whatever reason the
538 /// service already stated.
539 ///
540 /// # The reasons come first, because the service usually has one
541 ///
542 /// `GetMicrovmImage` has no `stateReason` member — the shape structurally cannot carry
543 /// one — so for a while this message could only name the log group and guess. It does
544 /// not have to guess: `MicrovmImageVersionSummary` and `MicrovmImageBuildSummary` both
545 /// carry a `stateReason`, and `GetMicrovmImage` names the failed version in
546 /// `latestFailedImageVersion`. So two listings the client already knows how to make
547 /// turn "the build failed, here is where the logs would be" into "the build failed
548 /// because X".
549 ///
550 /// The lookup is **best effort**: a listing that cannot be read leaves the reasons out
551 /// and the message keeps its log-group remedy, for the reason
552 /// [`Self::probe_stalled_build`] gives at length — a diagnosis made on a throttled call
553 /// sends the reader after the wrong cause. What it must never do is turn a real build
554 /// failure into a listing error, so nothing here returns `Err`.
555 ///
556 /// # The log-group paragraph stays
557 ///
558 /// It names the required prefix, because a failure that reads as "unknown" is most often
559 /// a build role granted the *plausible* prefix rather than the measured one. This client
560 /// cannot check the log group — CloudWatch is not in its dependency set — so the prefix
561 /// is named whenever no reason was found, rather than unconditionally. That conditional
562 /// is what the reasons above buy back: this used to be "a deliberate weakening" of the
563 /// Python diagnostic precisely because the client had no reason in hand, and now it does.
564 /// The full assessment of why no log *read* happens here is in
565 /// `microvms-cli/src/commands/local.rs`'s `logs`, beside the command a caller reaches
566 /// for instead.
567 async fn build_failure(&self, got: &ops::GetMicrovmImageResponseWire) -> Error {
568 let name = &got.name;
569 let state = &got.state;
570 let reasons = self.failure_reasons(got).await;
571
572 let said = if reasons.is_empty() {
573 format!(
574 "Neither the failed version nor any of its builds carried a stateReason, so the \
575 service stated no cause. If the build log group {BUILD_LOG_GROUP_PREFIX}/{name} \
576 also contains no events, the cause is most likely the build role's log \
577 permissions rather than a silent service: the role must grant logs on the \
578 {BUILD_LOG_GROUP_PREFIX}/* prefix, and a policy granting /aws/lambda/microvms/* \
579 instead — the plausible spelling, and the wrong one — produces builds with no \
580 logs at all (docs/PLATFORM.md, 'Build logs go to \
581 {BUILD_LOG_GROUP_PREFIX}/<image-name>')."
582 )
583 } else {
584 format!(
585 "The service stated the cause: {}. Full build logs are in \
586 {BUILD_LOG_GROUP_PREFIX}/{name}.",
587 reasons.join("; "),
588 )
589 };
590
591 Error::new(
592 ErrorKind::Platform,
593 format!("the image build for {name:?} failed: {state}. {said}"),
594 )
595 }
596
597 /// Every `stateReason` the service already stated about this image's failure: the
598 /// version's, then each failed build's.
599 ///
600 /// Empty when nothing could be read or nothing carried a reason, which the caller
601 /// reports as the service having stated no cause rather than as an absent lookup.
602 async fn failure_reasons(&self, got: &ops::GetMicrovmImageResponseWire) -> Vec<String> {
603 // The version the service itself named as the failed one, falling back to the first
604 // listed — which is what an image whose only version failed looks like.
605 let Some(version) = self.failed_version(got).await else {
606 return Vec::new();
607 };
608
609 let mut reasons = Vec::new();
610 if let Some(reason) = version.state_reason.as_deref() {
611 reasons.push(format!(
612 "version {} is {} because {reason}",
613 version.image_version, version.state
614 ));
615 }
616
617 // The build-level reasons are the specific ones: a version's reason is often "one
618 // or more builds failed", which names the shape of the problem and not the problem.
619 if let Some(builds) = self
620 .builds_of_version(&got.image_arn, &version.image_version)
621 .await
622 {
623 for build in builds
624 .iter()
625 .filter(|build| build.state_reason.is_some() && build.build_state != "PENDING")
626 {
627 reasons.push(format!(
628 "build {}",
629 self.describe_build_deeply(&got.image_arn, &version.image_version, build)
630 .await
631 ));
632 }
633 }
634 reasons
635 }
636
637 /// One failed build, described as fully as the service will say: the listing's line, or
638 /// `GetMicrovmImageBuild`'s richer one when that call answers.
639 ///
640 /// # What the extra call buys, and why it is worth one per failed build
641 ///
642 /// The listing already carries the `stateReason`, so this is not about the reason. It is
643 /// about `snapshotBuild`, which the listing has no member for and which is the **only**
644 /// size any operation in this model reports. On a real failure it comes back **partial** —
645 /// measured 2026-08-16 against a `FAILED` build, `codeInstallSizeInBytes` alone with no
646 /// memory and no disk snapshot — and that partial shape is itself the diagnosis: the build
647 /// installed 1.7 GB of code and then never produced a snapshot, which distinguishes "the
648 /// image was too big / the ready hook never answered" from "the Dockerfile failed before
649 /// anything was installed". It also names the `chipsetGeneration`, which matters because
650 /// one `CreateMicrovmImage` fans out into one build per generation and they can disagree.
651 ///
652 /// # Best effort, in the same sense the rest of this diagnosis is
653 ///
654 /// A `GetMicrovmImageBuild` that fails leaves the listing's own line in place rather than
655 /// dropping the build from the report or turning a build failure into a lookup error. That
656 /// is the rule [`Self::probe_stalled_build`] states at length: a diagnosis made on a
657 /// throttled call sends the reader after the wrong cause, and the reason the listing
658 /// already gave is not made less true by a second call failing.
659 async fn describe_build_deeply(
660 &self,
661 identifier: &str,
662 version: &str,
663 listed: &ops::MicrovmImageBuildSummaryWire,
664 ) -> String {
665 match self
666 .get_image_build(identifier, version, &listed.build_id)
667 .await
668 {
669 Ok(deeper) => deeper.describe(),
670 Err(_) => listed.describe(),
671 }
672 }
673
674 /// The version `latestFailedImageVersion` names, or the first version listed when the
675 /// service named none.
676 async fn failed_version(
677 &self,
678 got: &ops::GetMicrovmImageResponseWire,
679 ) -> Option<ops::MicrovmImageVersionSummaryWire> {
680 let Some(wanted) = got.latest_failed_image_version.as_deref() else {
681 return self.first_version(&got.image_arn).await;
682 };
683
684 let mut next_token: Option<String> = None;
685 loop {
686 let call = Call::get(
687 "ListMicrovmImageVersions",
688 paths::image_versions(&got.image_arn, next_token.as_deref()),
689 );
690 let page: ops::ListImageVersionsResponseWire = send_with_retry(self.transport(), call)
691 .await
692 .ok()?
693 .json("ListMicrovmImageVersions")
694 .ok()?;
695 if let Some(hit) = page
696 .items
697 .into_iter()
698 .find(|item| item.image_version == wanted)
699 {
700 return Some(hit);
701 }
702 next_token = Some(page.next_token?);
703 }
704 }
705
706 // ── build and version introspection ──────────────────────────────────────
707
708 /// `GetMicrovmImageBuild`: one build, with the snapshot sizes the listing does not carry.
709 ///
710 /// # What this adds over the listing, and it is not marginal
711 ///
712 /// `snapshotBuild` — the memory, code, and disk byte counts — is the **only** size any
713 /// operation in this model reports for anything the platform builds. `GetMicrovmImage`
714 /// carries none, `GetMicrovm` carries none, and both listings carry none, so a storage
715 /// estimate had nothing to multiply but the size class's baseline. These three figures are
716 /// what the snapshot read, write, and storage line items actually bill on.
717 ///
718 /// It also carries a `stateReason` at the same level the listing does, which is the level
719 /// that is populated on a real failure — the version's is null (docs/PLATFORM.md).
720 ///
721 /// `build_id` comes off [`ops::MicrovmImageBuildSummaryWire::build_id`], which the listing
722 /// already parses; nothing else in the API mints one.
723 pub async fn get_image_build(
724 &self,
725 identifier: &str,
726 version: &str,
727 build_id: &str,
728 ) -> Result<ops::GetImageBuildResponseWire, Error> {
729 super::require_valid_identifier("imageIdentifier", identifier)?;
730 // Both URI members are `NonBlankString`, not `Version` — `GetMicrovmImageBuild` is the
731 // one operation where `imageVersion` names the reused shape rather than the version one,
732 // and the two are identical today. Checked as what the model says they are.
733 super::require_non_blank("imageVersion", version)?;
734 super::require_non_blank("buildId", build_id)?;
735 let call = Call::get(
736 "GetMicrovmImageBuild",
737 paths::image_build(identifier, version, build_id),
738 );
739 let reply = send_with_retry(self.transport(), call).await?;
740 reply.json("GetMicrovmImageBuild")
741 }
742
743 /// `GetMicrovmImageVersion`: one version's whole configuration, its state, and its
744 /// availability status.
745 ///
746 /// # The only full config readback there is
747 ///
748 /// The response echoes the creation request: `baseImageArn` with the `baseImageVersion`
749 /// that was actually used, the `buildRoleArn`, the `codeArtifact` URI, the egress
750 /// connectors, the CPU configurations, and `resources` — which is where a built image's
751 /// size class lives, since `GetMicrovm` reports no memory figure at all. It is how a
752 /// caller who no longer holds the request finds out what an image was built with.
753 ///
754 /// `status` is the member that matters for a retire: `ACTIVE` means `RunMicrovm` will
755 /// launch this version, `INACTIVE` means it refuses. See
756 /// [`Self::set_image_version_status`].
757 ///
758 /// One call rather than a filtered `ListMicrovmImageVersions`, and the difference is not
759 /// only cost: a listing can page, so "find version X" is a loop that may read the whole
760 /// history, while this addresses the version directly and answers
761 /// `ResourceNotFoundException` when it is gone.
762 pub async fn get_image_version(
763 &self,
764 identifier: &str,
765 version: &str,
766 ) -> Result<ops::MicrovmImageVersionSummaryWire, Error> {
767 super::require_valid_identifier("imageIdentifier", identifier)?;
768 super::require_non_blank("imageVersion", version)?;
769 let call = Call::get(
770 "GetMicrovmImageVersion",
771 paths::image_version(identifier, version),
772 );
773 let reply = send_with_retry(self.transport(), call).await?;
774 reply.json("GetMicrovmImageVersion")
775 }
776
777 /// `UpdateMicrovmImageVersion`: set a version `ACTIVE` or `INACTIVE`.
778 ///
779 /// # The model's only non-destructive retire
780 ///
781 /// `DeleteMicrovmImageVersion` is the alternative and it is irreversible, cannot be
782 /// applied to an image's last version at all, and destroys the readback a post-mortem
783 /// needs. `INACTIVE` does none of that: `RunMicrovm` refuses to launch the version,
784 /// **already-running VMs keep running**, and [`Self::get_image_version`] still answers
785 /// with the whole configuration. So the rollback for a bad build is two calls that both
786 /// preserve evidence — set the new version INACTIVE, re-pin the launch to the old one —
787 /// rather than a delete.
788 ///
789 /// # Why the status is typed and the version is checked
790 ///
791 /// [`ops::VersionStatus`] has two variants, so `"INACTIVATE"` is a compile error rather
792 /// than a `ValidationException` on the only member this request has. The version string is
793 /// checked against the `Version` shape locally for the reason
794 /// [`super::require_valid_version`] gives: this call is made at the moment someone is
795 /// rolling back, which is when a failure about the request rather than about the version
796 /// is most expensive.
797 ///
798 /// Returns the readback, which carries the status the service now reports — so a caller
799 /// confirms the change rather than assuming the 200 meant it took.
800 pub async fn set_image_version_status(
801 &self,
802 identifier: &str,
803 version: &str,
804 status: ops::VersionStatus,
805 ) -> Result<ops::MicrovmImageVersionSummaryWire, Error> {
806 super::require_valid_identifier("imageIdentifier", identifier)?;
807 super::require_valid_version("imageVersion", version)?;
808
809 let wire = ops::UpdateImageVersionWire { status };
810 let call = Call::patch_json(
811 "UpdateMicrovmImageVersion",
812 paths::image_version(identifier, version),
813 &wire,
814 )?;
815 let reply = send_with_retry(self.transport(), call).await?;
816 let updated: ops::MicrovmImageVersionSummaryWire =
817 reply.json("UpdateMicrovmImageVersion")?;
818
819 // The readback is asserted rather than trusted, for the reason
820 // `ops::DeleteImageResponseWire` gives about a 2xx that reports a failure state: a
821 // request the service accepts and does not apply is indistinguishable from one it
822 // applied, and here the consequence is a version a caller believes is retired while
823 // `RunMicrovm` still launches it.
824 if updated.status != status.as_str() {
825 return Err(Error::new(
826 ErrorKind::Platform,
827 format!(
828 "UpdateMicrovmImageVersion answered 2xx for version {version} of \
829 {identifier} but read back status {} rather than the requested {status}. A \
830 version that is still ACTIVE is one RunMicrovm will still launch, so \
831 treating the 200 as the change having taken would leave a retired version \
832 reachable.",
833 updated.status,
834 ),
835 ));
836 }
837 Ok(updated)
838 }
839
840 /// `ListManagedMicrovmImageVersions`, read to its last page: the versions of a managed
841 /// base image, newest first as the service orders them.
842 ///
843 /// # What this is for
844 ///
845 /// [`ops::CreateMicrovmImageWire::base_image_version`] is how a build stops floating on
846 /// whatever the service currently defaults to, and this is the only operation that says
847 /// what the legal values are. The default has already moved once: `al2023-1` carried one
848 /// version in June and two by July (`"0"` and `"1"`, measured 2026-08-16), so every build
849 /// made before this existed recorded no base version and is not reproducible.
850 ///
851 /// # Every page, and an ARN rather than a name
852 ///
853 /// Every page for the reason the other listings give: a version on page two is still a
854 /// version, and a caller pinning "the newest" from a truncated page pins the wrong one.
855 ///
856 /// The identifier must be the base's **full ARN** — a bare `al2023-1` answers
857 /// `ValidationException: Invalid ARN format` (measured 2026-08-16) — so that is checked
858 /// here rather than at the wire, because the service's message names the value without
859 /// saying which member wanted an ARN or that
860 /// [`super::BaseImage::arn`] is what produces one.
861 pub async fn managed_base_versions(
862 &self,
863 base_image_arn: &str,
864 ) -> Result<Vec<ops::ManagedMicrovmImageVersionWire>, Error> {
865 // The shape bound first, then the ARN precondition. Order matters only for the empty
866 // case: `""` is not an ARN either, and "needs the full ARN, not \"\"" is a less useful
867 // message than the identifier guard's account of what an empty URI parameter does to the
868 // path.
869 super::require_valid_identifier("imageIdentifier", base_image_arn)?;
870 if !base_image_arn.starts_with("arn:") {
871 return Err(Error::new(
872 ErrorKind::Precondition,
873 format!(
874 "ListManagedMicrovmImageVersions needs the managed base's full ARN, not \
875 {base_image_arn:?}. The service answers `ValidationException: Invalid ARN \
876 format: {base_image_arn}` for a bare name (measured 2026-08-16), which \
877 names the value without saying which member wanted an ARN. \
878 `BaseImage::al2023().arn(region)` builds one — \
879 arn:aws:lambda:{}:aws:microvm-image:{base_image_arn}.",
880 self.region.as_str(),
881 ),
882 ));
883 }
884
885 let mut items = Vec::new();
886 let mut next_token: Option<String> = None;
887 loop {
888 let call = Call::get(
889 "ListManagedMicrovmImageVersions",
890 paths::managed_image_versions(base_image_arn, next_token.as_deref()),
891 );
892 let reply = send_with_retry(self.transport(), call).await?;
893 let page: ops::ListManagedVersionsResponseWire =
894 reply.json("ListManagedMicrovmImageVersions")?;
895 items.extend(page.items);
896 match page.next_token {
897 Some(token) => next_token = Some(token),
898 None => return Ok(items),
899 }
900 }
901 }
902
903 /// `ListManagedMicrovmImages`, read to its last page: the base images AWS publishes.
904 ///
905 /// # Informational only, and that is a limitation rather than a choice
906 ///
907 /// `ManagedMicrovmImageSummary` carries an ARN and two timestamps and **nothing else** —
908 /// no registry reference, no architecture, no working directory. A
909 /// [`super::BaseImage`] needs all three of ARN, Dockerfile `FROM`, and WORKDIR knowledge,
910 /// because `require_matching_from` compares a caller's Dockerfile against the `FROM` and
911 /// `require_workdir` refuses inheritance when the base declares none. Neither guard has an
912 /// input here, and the registry ref is not derivable from the ARN — `al2023-1` pairs with
913 /// `public.ecr.aws/amazonlinux/amazonlinux:2023-minimal` and nothing in the ARN says so.
914 ///
915 /// So a discovered base **cannot safely be built from**, and this exists to answer one
916 /// question: has AWS published a base this client does not know about? `microvm doctor`
917 /// reports it. Measured 2026-08-16, the answer is one item, so hardcoding `al2023-1`
918 /// currently misses nothing.
919 pub async fn managed_base_images(
920 &self,
921 ) -> Result<Vec<ops::ManagedMicrovmImageSummaryWire>, Error> {
922 let mut items = Vec::new();
923 let mut next_token: Option<String> = None;
924 loop {
925 let call = Call::get(
926 "ListManagedMicrovmImages",
927 paths::managed_microvm_images(next_token.as_deref()),
928 );
929 let reply = send_with_retry(self.transport(), call).await?;
930 let page: ops::ListManagedImagesResponseWire =
931 reply.json("ListManagedMicrovmImages")?;
932 items.extend(page.items);
933 match page.next_token {
934 Some(token) => next_token = Some(token),
935 None => return Ok(items),
936 }
937 }
938 }
939
940 /// One page of `ListManagedMicrovmImages`: whether the service answers a signed,
941 /// free, read-only request in this region (BIND-15's service check), and how many managed
942 /// bases the page carried.
943 ///
944 /// One operation, never the whole listing, so a preflight makes one AWS operation at most
945 /// (BIND-16); `send_with_retry` may repeat it on a throttle. An unsupported region answers
946 /// `AccessDeniedException` with a null message (TRAP-6), which this surfaces as the error
947 /// it is.
948 pub async fn answers_listing(&self) -> Result<usize, Error> {
949 let call = Call::get(
950 "ListManagedMicrovmImages",
951 paths::managed_microvm_images(None),
952 );
953 let reply = send_with_retry(self.transport(), call).await?;
954 let page: ops::ListManagedImagesResponseWire = reply.json("ListManagedMicrovmImages")?;
955 Ok(page.items.len())
956 }
957
958 /// Resolves the credentials this client signs with, sending nothing to the service
959 /// (BIND-15's credentials check). See [`super::transport::Transport::resolve_credentials`].
960 pub async fn resolve_credentials(&self) -> Result<(), Error> {
961 self.transport().resolve_credentials().await
962 }
963
964 /// `ListMicrovmImages`, read to its last page.
965 ///
966 /// # Why this is public where the private walkers are not
967 ///
968 /// [`Self::find_image_by_name`] sends `nameFilter` and answers one image, which is what a
969 /// resolver wants and is a different question from "what is in this account". The live tier
970 /// asks the second: `tests/live_versions.rs` walks the account looking for an image with a
971 /// version in a particular state, because *which* image that is is an account fact a test
972 /// must not hardcode. `microvm ls` deliberately does **not** use this — it reads the local
973 /// ledger, because the resources worth asking about are the ones a killed process never got
974 /// to report and no listing can attribute those back to a command.
975 pub async fn list_images(&self) -> Result<Vec<ops::MicrovmImageSummaryWire>, Error> {
976 let mut items = Vec::new();
977 let mut next_token: Option<String> = None;
978 loop {
979 let call = Call::get(
980 "ListMicrovmImages",
981 paths::microvm_images_list(None, next_token.as_deref()),
982 );
983 let reply = send_with_retry(self.transport(), call).await?;
984 let page: ops::ListImagesResponseWire = reply.json("ListMicrovmImages")?;
985 items.extend(page.items);
986 match page.next_token {
987 Some(token) => next_token = Some(token),
988 None => return Ok(items),
989 }
990 }
991 }
992
993 /// `ListMicrovmImageVersions`, read to its last page.
994 ///
995 /// A `Result` where the private [`Self::builds_of_version`] answers `Option`, and the
996 /// difference is the caller: the private one feeds a *diagnosis*, where a listing failure
997 /// must not become a wedge claim, and this one feeds a caller who asked about the versions
998 /// and needs to know if the answer is unavailable.
999 pub async fn list_image_versions(
1000 &self,
1001 identifier: &str,
1002 ) -> Result<Vec<ops::MicrovmImageVersionSummaryWire>, Error> {
1003 super::require_valid_identifier("imageIdentifier", identifier)?;
1004 let mut items = Vec::new();
1005 let mut next_token: Option<String> = None;
1006 loop {
1007 let call = Call::get(
1008 "ListMicrovmImageVersions",
1009 paths::image_versions(identifier, next_token.as_deref()),
1010 );
1011 let reply = send_with_retry(self.transport(), call).await?;
1012 let page: ops::ListImageVersionsResponseWire =
1013 reply.json("ListMicrovmImageVersions")?;
1014 items.extend(page.items);
1015 match page.next_token {
1016 Some(token) => next_token = Some(token),
1017 None => return Ok(items),
1018 }
1019 }
1020 }
1021
1022 /// `ListMicrovmImageBuilds` for one version, read to its last page.
1023 ///
1024 /// The identifiers [`Self::get_image_build`] takes come from here: nothing else in the API
1025 /// mints a `buildId`. See [`Self::list_image_versions`] on why this answers a `Result` where
1026 /// the diagnosis path's walker answers an `Option`.
1027 pub async fn list_image_builds(
1028 &self,
1029 identifier: &str,
1030 version: &str,
1031 ) -> Result<Vec<ops::MicrovmImageBuildSummaryWire>, Error> {
1032 super::require_valid_identifier("imageIdentifier", identifier)?;
1033 super::require_non_blank("imageVersion", version)?;
1034 let mut items = Vec::new();
1035 let mut next_token: Option<String> = None;
1036 loop {
1037 let call = Call::get(
1038 "ListMicrovmImageBuilds",
1039 paths::image_builds(identifier, version, next_token.as_deref()),
1040 );
1041 let reply = send_with_retry(self.transport(), call).await?;
1042 let page: ops::ListImageBuildsResponseWire = reply.json("ListMicrovmImageBuilds")?;
1043 items.extend(page.items);
1044 match page.next_token {
1045 Some(token) => next_token = Some(token),
1046 None => return Ok(items),
1047 }
1048 }
1049 }
1050
1051 /// The image with exactly `name`, or `None` when no page of the listing has one.
1052 ///
1053 /// # `nameFilter` narrows, it does not answer
1054 ///
1055 /// The model's `nameFilter` is a server-side **substring** filter ("images whose name
1056 /// contains the specified string"), so `coding-agents` also matches
1057 /// `coding-agents-old`. The filter is sent — it keeps a large account's listing to one
1058 /// page in practice — and the exact-match comparison happens here, on the client,
1059 /// because "contains" and "is" are different questions and only the second one is
1060 /// safe to launch from.
1061 ///
1062 /// # The listing paginates, and every page is read
1063 ///
1064 /// An image on page two is still an image. A resolver that read only the first page
1065 /// would answer "no image named X" for a name that exists — the confident wrong
1066 /// answer, which then sends the caller to rebuild an image they already paid for.
1067 pub async fn find_image_by_name(
1068 &self,
1069 name: &str,
1070 ) -> Result<Option<ops::MicrovmImageSummaryWire>, Error> {
1071 // `nameFilter` is a `NonBlankString` **querystring** member, which is the reason a blank
1072 // one is worth refusing rather than letting through: it does not fail as a missing field,
1073 // it goes out as `?nameFilter=` and either 400s or filters differently from what was
1074 // meant. This function is the resolver a launch depends on, so a silently different
1075 // filter is a launch against the wrong image.
1076 super::require_non_blank("nameFilter", name)?;
1077 let mut next_token: Option<String> = None;
1078 loop {
1079 let call = Call::get(
1080 "ListMicrovmImages",
1081 paths::microvm_images_list(Some(name), next_token.as_deref()),
1082 );
1083 let reply = send_with_retry(self.transport(), call).await?;
1084 let listed: ops::ListImagesResponseWire = reply.json("ListMicrovmImages")?;
1085
1086 if let Some(hit) = listed.items.into_iter().find(|item| item.name == name) {
1087 return Ok(Some(hit));
1088 }
1089 match listed.next_token {
1090 Some(token) => next_token = Some(token),
1091 None => return Ok(None),
1092 }
1093 }
1094 }
1095
1096 /// The ARN for `identifier`: an ARN passes through untouched, a bare name is
1097 /// resolved through the image listing by exact name.
1098 ///
1099 /// # Why this exists
1100 ///
1101 /// `RunMicrovm.imageIdentifier` takes an ARN, and a bare name sent verbatim is
1102 /// answered with HTTP 400 "Malformed ARN" — after a credential resolution and a
1103 /// signed call, and with a message that says nothing about names. Every consumer was
1104 /// scripting this resolution with the AWS CLI (the coding-agents example did); this
1105 /// is that script, once, with the pagination handled.
1106 ///
1107 /// # An ARN costs zero calls
1108 ///
1109 /// The passthrough is checked first, so a caller who already holds the ARN pays
1110 /// nothing for the convenience existing. The prefix test is `arn:` exactly — the one
1111 /// spelling every AWS ARN starts with, and a string the `ImageName` pattern
1112 /// (`[a-zA-Z0-9-_]+`) cannot produce, so no legal image name is mistaken for one.
1113 pub async fn resolve_image_arn(&self, identifier: &str) -> Result<String, Error> {
1114 if identifier.starts_with("arn:") {
1115 return Ok(identifier.to_string());
1116 }
1117 match self.find_image_by_name(identifier).await? {
1118 Some(image) => Ok(image.image_arn),
1119 None => Err(Error::new(
1120 ErrorKind::Precondition,
1121 format!(
1122 "no image named {identifier:?} exists in {} (the listing was read to its \
1123 last page and resolution requires an exact name match). Build one first — \
1124 `microvm build <binary> --name {identifier}` — or pass the image ARN \
1125 directly.",
1126 self.region.as_str(),
1127 ),
1128 )),
1129 }
1130 }
1131
1132 /// The content hash of the artifact `request` would build, for content-addressed
1133 /// image reuse.
1134 ///
1135 /// Derives the Dockerfile exactly as [`ControlPlane::build_artifact_for`] does —
1136 /// same default, same port — so the name a reuse check computes is the name a build
1137 /// of the same request would carry. See [`artifact::artifact_content_hash`] for what
1138 /// is hashed and why it is the inputs rather than the zip.
1139 pub fn artifact_content_hash_for(&self, request: &CreateImageRequest) -> String {
1140 let dockerfile = match request.dockerfile.as_deref() {
1141 Some(dockerfile) => dockerfile.to_string(),
1142 None => artifact::default_dockerfile(
1143 self.port,
1144 None,
1145 &request.base_image,
1146 request.project_files.as_ref().map(|files| files.ecosystem),
1147 ),
1148 };
1149 artifact::artifact_content_hash(
1150 &request.binary,
1151 &dockerfile,
1152 request.project_files.as_ref(),
1153 )
1154 }
1155
1156 /// Deletes every version but the first, then the image, retrying.
1157 ///
1158 /// # Why it retries
1159 ///
1160 /// Not politeness: an image in `CREATING` refuses deletion and a VM still terminating
1161 /// holds a reference. This loop is the difference between a clean account and a billed
1162 /// leak.
1163 ///
1164 /// # Why not `backon`, unlike `transport::send_with_retry`
1165 ///
1166 /// Assessed for the dependency sweep (issue #91) and kept as a loop: the sleeps here
1167 /// go through the injected [`Clock`] seam so tests drive them without real time, and
1168 /// `Clock::sleep` returns a future borrowing `&self` — `backon`'s `Sleeper` needs a
1169 /// future that owns its state, so bridging the seam costs an adapter bigger than the
1170 /// five lines it would replace. The fixed interval is also the contract: teardown
1171 /// polls a state that resolves on the service's schedule, not a growing backoff.
1172 ///
1173 /// # Why the first version is kept
1174 ///
1175 /// The last remaining version cannot be deleted on its own, only together with the
1176 /// image. Trying produces a `ConflictException` that reads like a permissions problem.
1177 ///
1178 /// Returns `true` when the image was deleted, `false` when every attempt failed. It
1179 /// does not raise, because the caller is a teardown path and the original failure is
1180 /// the one worth reading.
1181 ///
1182 /// # The identifier check is here rather than in [`Self::try_delete_image`]
1183 ///
1184 /// Inside the attempt it would be re-decided on every retry, which for the default twenty
1185 /// attempts is nineteen backoff sleeps over a value that cannot become valid. Before the loop
1186 /// it costs one comparison and zero calls. The refusal is a `false` rather than an `Err`
1187 /// because this function's whole contract is that it does not raise into a teardown path —
1188 /// and `false` from here is honest: no image was deleted.
1189 pub async fn delete_image(&self, identifier: &str, attempts: u32, backoff: Duration) -> bool {
1190 if super::require_valid_identifier("imageIdentifier", identifier).is_err() {
1191 return false;
1192 }
1193 for attempt in 0..attempts.max(1) {
1194 if attempt > 0 {
1195 self.clock().sleep(backoff).await;
1196 }
1197 if self.try_delete_image(identifier).await.is_ok() {
1198 return true;
1199 }
1200 }
1201 false
1202 }
1203
1204 /// One deletion attempt: drop the extra versions, then the image.
1205 ///
1206 /// # Every page of versions, or the image cannot be deleted at all
1207 ///
1208 /// `ListMicrovmImageVersions` caps `maxResults` at 50. Reading one page and then
1209 /// deleting the image is not a partial cleanup, it is a **permanent** one: the versions
1210 /// on page two still exist, so the final `DeleteMicrovmImage` answers
1211 /// `ConflictException`, [`Self::delete_image`]'s retry loop re-reads the same first
1212 /// page every attempt, and the call returns `false` forever. The outcome is a billing
1213 /// image nothing can delete through this client, which is the exact outcome the retry
1214 /// loop exists to prevent.
1215 ///
1216 /// The whole listing is collected before the first delete rather than deleted
1217 /// page-by-page, because deleting from under a cursor is how a paginated traversal
1218 /// skips items: the service's page boundaries move as the collection shrinks.
1219 async fn try_delete_image(&self, identifier: &str) -> Result<(), Error> {
1220 let mut versions: Vec<String> = Vec::new();
1221 let mut next_token: Option<String> = None;
1222 loop {
1223 let call = Call::get(
1224 "ListMicrovmImageVersions",
1225 paths::image_versions(identifier, next_token.as_deref()),
1226 );
1227 let page: ops::ListImageVersionsResponseWire = send_with_retry(self.transport(), call)
1228 .await?
1229 .json("ListMicrovmImageVersions")?;
1230 versions.extend(page.items.into_iter().map(|item| item.image_version));
1231 match page.next_token {
1232 Some(token) => next_token = Some(token),
1233 None => break,
1234 }
1235 }
1236
1237 // Skip the first: the last remaining version goes with the image.
1238 for version in versions.iter().skip(1) {
1239 let call = Call::delete(
1240 "DeleteMicrovmImageVersion",
1241 paths::image_version(identifier, version),
1242 );
1243 send_with_retry(self.transport(), call).await?;
1244 }
1245
1246 let call = Call::delete("DeleteMicrovmImage", paths::microvm_image(identifier));
1247 let reply = send_with_retry(self.transport(), call).await?;
1248
1249 // The readback is parsed rather than discarded, so `DeleteImageResponseWire` is a
1250 // live shape. See [`ops::DeleteImageResponseWire`] for why the state is checked
1251 // for a *failure* spelling only: DELETING and DELETED are both the call having
1252 // worked, and treating DELETING as incomplete would retry a deletion in progress.
1253 let deleted: ops::DeleteImageResponseWire = reply.json("DeleteMicrovmImage")?;
1254 if Image::is_failed(&deleted.state) {
1255 return Err(Error::new(
1256 ErrorKind::Platform,
1257 format!(
1258 "DeleteMicrovmImage answered 2xx for {} but reported state {}, so the image \
1259 still exists and still bills. A DELETE_FAILED readback is the service \
1260 accepting the request and refusing the work, which is not something a retry \
1261 of the identical request fixes.",
1262 deleted.image_identifier, deleted.state,
1263 ),
1264 ));
1265 }
1266 Ok(())
1267 }
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use std::sync::Arc;
1273
1274 use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
1275 use super::*;
1276 use crate::hooks::{BuildHookTimeout, RunHookTimeout};
1277 use crate::region::Region;
1278
1279 /// A plane over the fake, and the two handles a test asserts through.
1280 fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
1281 let fake = Arc::new(FakeControlPlane::new());
1282 let clock = Arc::new(TestClock::new());
1283 let plane = ControlPlane::with_transport(fake.clone(), Region::UsEast1, clock.clone());
1284 (plane, fake, clock)
1285 }
1286
1287 fn a_request() -> CreateImageRequest {
1288 CreateImageRequest::new(
1289 "agentd-conformance",
1290 b"\x7fELF fake daemon".to_vec(),
1291 "s3://bucket/agentd-conformance.zip",
1292 "arn:aws:iam::123456789012:role/build",
1293 )
1294 }
1295
1296 /// The create request lands on the model's path and method, and its body carries the
1297 /// model's member names. Asserted through the recorder's generic JSON view, so a
1298 /// misspelled member here is visible rather than hidden by a matching struct.
1299 #[tokio::test]
1300 async fn create_image_emits_the_models_path_method_and_members() {
1301 let (plane, fake, _) = planted();
1302 fake.answer(
1303 "CreateMicrovmImage",
1304 Answer::created(fake::create_image_response("agentd-conformance")),
1305 );
1306
1307 let image = plane.create_image(a_request()).await.expect("creates");
1308 assert_eq!(image.name, "agentd-conformance");
1309 assert_eq!(image.state, "CREATING");
1310 assert_eq!(image.version, "1");
1311
1312 let calls = fake.calls();
1313 assert_eq!(calls.len(), 1);
1314 assert_eq!(calls[0].method, super::super::transport::Method::Post);
1315 assert_eq!(calls[0].path, "/2025-09-09/microvm-images");
1316
1317 let body = fake.first_body("CreateMicrovmImage");
1318 assert_eq!(body["name"], "agentd-conformance");
1319 assert_eq!(
1320 body["baseImageArn"],
1321 "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
1322 );
1323 assert_eq!(
1324 body["codeArtifact"]["uri"],
1325 "s3://bucket/agentd-conformance.zip"
1326 );
1327 assert_eq!(body["cpuConfigurations"][0]["architecture"], "ARM_64");
1328 assert_eq!(body["resources"][0]["minimumMemoryInMiB"], 2048);
1329 assert_eq!(body["hooks"]["port"], 9000);
1330 }
1331
1332 /// **TRAP-3.** Identity repair sets `additionalOsCapabilities` to exactly `["ALL"]`,
1333 /// and the field is **absent** when repair was not requested.
1334 ///
1335 /// Read off the wire member, which is the assertion that matters: a bool field on the
1336 /// request type proves nothing about what got emitted.
1337 ///
1338 /// **Falsification** — change the injection to a caller-supplied list and there is no
1339 /// longer a bool to set, so this test does not compile; change it to emit
1340 /// `["CAP_SYS_ADMIN"]` and the first assertion fails.
1341 #[tokio::test]
1342 async fn identity_repair_sets_the_one_accepted_capability_and_nothing_else() {
1343 let (plane, fake, _) = planted();
1344 fake.answer(
1345 "CreateMicrovmImage",
1346 Answer::created(fake::create_image_response("img")),
1347 );
1348 let mut request = a_request();
1349 request.repair_guest_identity = true;
1350 plane.create_image(request).await.expect("creates");
1351
1352 let body = fake.first_body("CreateMicrovmImage");
1353 assert_eq!(
1354 body["additionalOsCapabilities"],
1355 serde_json::json!(["ALL"]),
1356 "the Capability enum has exactly one value"
1357 );
1358
1359 let (plane, fake, _) = planted();
1360 fake.answer(
1361 "CreateMicrovmImage",
1362 Answer::created(fake::create_image_response("img")),
1363 );
1364 plane.create_image(a_request()).await.expect("creates");
1365 let body = fake.first_body("CreateMicrovmImage");
1366 assert!(
1367 body.get("additionalOsCapabilities").is_none(),
1368 "omitted rather than empty when repair was not asked for: {body}"
1369 );
1370 }
1371
1372 /// **TRAP-1, observed on the wire.** Creating the same image name twice emits two
1373 /// distinct `clientToken` values, as the fake saw them.
1374 ///
1375 /// This is the create-delete-recreate case: same name, same bytes, same scope. A token
1376 /// derived from any of those would repeat and wedge the image.
1377 ///
1378 /// **Falsification** — replace the nonce with a digest of the scope and this fails
1379 /// with two identical tokens.
1380 #[tokio::test]
1381 async fn recreating_the_same_image_name_emits_two_distinct_client_tokens() {
1382 let (plane, fake, _) = planted();
1383 fake.answer(
1384 "CreateMicrovmImage",
1385 Answer::created(fake::create_image_response("img")),
1386 );
1387
1388 plane.create_image(a_request()).await.expect("first create");
1389 plane.create_image(a_request()).await.expect("recreate");
1390
1391 let tokens = fake.client_tokens();
1392 assert_eq!(tokens.len(), 2, "both creates carried a token");
1393 assert_ne!(
1394 tokens[0], tokens[1],
1395 "a repeated clientToken wedges the image in CREATING for ~15 hours"
1396 );
1397 for token in &tokens {
1398 assert!(token.starts_with("create-"), "{token}");
1399 assert!(
1400 token.len() <= crate::constants::MAX_CLIENT_TOKEN_LEN,
1401 "{token}"
1402 );
1403 }
1404 }
1405
1406 /// The token's label follows `token_scope` when one is given — the CloudTrail
1407 /// readability affordance — and the token is still per-attempt distinct.
1408 #[tokio::test]
1409 async fn a_token_scope_labels_the_token_without_becoming_it() {
1410 let (plane, fake, _) = planted();
1411 fake.answer(
1412 "CreateMicrovmImage",
1413 Answer::created(fake::create_image_response("img")),
1414 );
1415 let mut request = a_request();
1416 request.token_scope = Some("nightly-run-42".to_string());
1417 plane.create_image(request.clone()).await.expect("creates");
1418 plane.create_image(request).await.expect("creates again");
1419
1420 let tokens = fake.client_tokens();
1421 assert!(tokens[0].contains("nightly-run-42"), "{}", tokens[0]);
1422 assert_ne!(tokens[0], tokens[1], "the label is not the token");
1423 }
1424
1425 /// **The discriminator rule, observed on the wire.** A configured log stream reaches
1426 /// `CreateMicrovmImage` as `<user value>/<16 hex>` — never verbatim — and the resolved
1427 /// name on the returned [`Image`] is byte-identical to what went out.
1428 ///
1429 /// Asserted on the serialized body through the recorder's generic JSON view, because
1430 /// the claim is about what the service sees: the member is an exact stream name, one
1431 /// build is three VMs writing three streams, and a verbatim configured name collapses
1432 /// them all (docs/PLATFORM.md, 'An image build is three VMs and three log streams').
1433 ///
1434 /// **Falsification** — pass `request.log_stream` straight into `Logging::cloud_watch`
1435 /// instead of through `token::resolve_log_stream` and the suffix assertions go red
1436 /// with the verbatim name on the wire. Done 2026-08-30; failed as stated; restored.
1437 #[tokio::test]
1438 async fn a_configured_log_stream_reaches_the_wire_with_a_fresh_discriminator() {
1439 let (plane, fake, _) = planted();
1440 fake.answer(
1441 "CreateMicrovmImage",
1442 Answer::created(fake::create_image_response("img")),
1443 );
1444 let mut request = a_request();
1445 request.log_group = Some("/aws/lambda-microvms/conformance-builds".to_string());
1446 request.log_stream = Some("ci-image".to_string());
1447
1448 let image = plane.create_image(request).await.expect("creates");
1449
1450 let body = fake.first_body("CreateMicrovmImage");
1451 assert_eq!(
1452 body["logging"]["cloudWatch"]["logGroup"],
1453 "/aws/lambda-microvms/conformance-builds"
1454 );
1455 let wire_stream = body["logging"]["cloudWatch"]["logStream"]
1456 .as_str()
1457 .expect("a stream was sent");
1458 assert_ne!(
1459 wire_stream, "ci-image",
1460 "the caller's value must never reach the wire verbatim"
1461 );
1462 assert!(
1463 wire_stream.starts_with("ci-image/"),
1464 "the caller's value survives as the prefix: {wire_stream}"
1465 );
1466 let suffix = &wire_stream["ci-image/".len()..];
1467 assert_eq!(suffix.len(), 16, "{wire_stream}");
1468 assert!(
1469 suffix.bytes().all(|b| b.is_ascii_hexdigit()),
1470 "the discriminator is sixteen hex characters: {wire_stream}"
1471 );
1472
1473 // The resolved name is threaded back, byte-identical to the wire's — this is the
1474 // only copy of the discriminator, so the caller reads it here or never.
1475 assert_eq!(image.log_stream.as_deref(), Some(wire_stream));
1476 assert_eq!(
1477 image.log_group.as_deref(),
1478 Some("/aws/lambda-microvms/conformance-builds")
1479 );
1480 assert_eq!(
1481 image.build_log_group(),
1482 "/aws/lambda-microvms/conformance-builds",
1483 "a configured group replaces the derived default"
1484 );
1485 }
1486
1487 /// Two creates from the **same** request resolve two different streams — the
1488 /// per-attempt property, which is what keeps successive builds of one image apart.
1489 ///
1490 /// The same claim `recreating_the_same_image_name_emits_two_distinct_client_tokens`
1491 /// makes for the token, on the surface that reuses its nonce.
1492 #[tokio::test]
1493 async fn two_creates_from_one_request_resolve_two_different_streams() {
1494 let (plane, fake, _) = planted();
1495 fake.answer(
1496 "CreateMicrovmImage",
1497 Answer::created(fake::create_image_response("img")),
1498 );
1499 let mut request = a_request();
1500 request.log_group = Some("/aws/lambda-microvms/builds".to_string());
1501 request.log_stream = Some("ci".to_string());
1502
1503 let first = plane
1504 .create_image(request.clone())
1505 .await
1506 .expect("first create");
1507 let second = plane.create_image(request).await.expect("second create");
1508
1509 assert_ne!(
1510 first.log_stream, second.log_stream,
1511 "a repeated stream name collapses two builds' logs into one"
1512 );
1513
1514 let streams: Vec<String> = fake
1515 .bodies_as_text()
1516 .iter()
1517 .filter_map(|body| {
1518 serde_json::from_str::<serde_json::Value>(body)
1519 .ok()
1520 .map(|v| {
1521 v["logging"]["cloudWatch"]["logStream"]
1522 .as_str()
1523 .unwrap_or_default()
1524 .to_string()
1525 })
1526 })
1527 .filter(|stream| !stream.is_empty())
1528 .collect();
1529 assert_eq!(streams.len(), 2, "both creates carried a stream");
1530 assert_ne!(streams[0], streams[1], "distinct on the wire too");
1531 }
1532
1533 /// A create with no logging config serialises **no** `logging` member at all —
1534 /// absent, not null — so an unconfigured build emits byte-for-byte the request this
1535 /// client always sent.
1536 #[tokio::test]
1537 async fn an_unconfigured_build_serialises_no_logging_member() {
1538 let (plane, fake, _) = planted();
1539 fake.answer(
1540 "CreateMicrovmImage",
1541 Answer::created(fake::create_image_response("img")),
1542 );
1543 let image = plane.create_image(a_request()).await.expect("creates");
1544
1545 let body = fake.first_body("CreateMicrovmImage");
1546 assert!(
1547 body.get("logging").is_none(),
1548 "absent, not null: an absent member takes the service default: {body}"
1549 );
1550 assert_eq!(image.log_group, None);
1551 assert_eq!(image.log_stream, None);
1552 assert_eq!(
1553 image.build_log_group(),
1554 "/aws/lambda-microvms/img",
1555 "no configured group means the derived default"
1556 );
1557 }
1558
1559 /// A group with no stream sends the group alone, and the service names the streams:
1560 /// the `logStream` member is omitted rather than sent empty or invented.
1561 #[tokio::test]
1562 async fn a_group_without_a_stream_sends_the_group_alone() {
1563 let (plane, fake, _) = planted();
1564 fake.answer(
1565 "CreateMicrovmImage",
1566 Answer::created(fake::create_image_response("img")),
1567 );
1568 let mut request = a_request();
1569 request.log_group = Some("/aws/lambda-microvms/builds".to_string());
1570
1571 let image = plane.create_image(request).await.expect("creates");
1572 let body = fake.first_body("CreateMicrovmImage");
1573 assert_eq!(
1574 body["logging"]["cloudWatch"]["logGroup"],
1575 "/aws/lambda-microvms/builds"
1576 );
1577 assert!(
1578 body["logging"]["cloudWatch"].get("logStream").is_none(),
1579 "no configured stream, no invented one: {body}"
1580 );
1581 assert_eq!(image.log_stream, None);
1582 }
1583
1584 /// The log-stream pattern's two forbidden characters are refused locally, with zero
1585 /// calls, naming the platform pattern — and the length checks bracket the caller's
1586 /// 495 ceiling.
1587 ///
1588 /// 495 rather than the shape's 512, because the discriminator needs its 17 characters:
1589 /// the boundary cases assert that 495 passes and 496 is refused with the arithmetic in
1590 /// the message.
1591 #[tokio::test]
1592 async fn an_illegal_log_stream_is_refused_before_any_call() {
1593 for (label, stream, named) in [
1594 ("a colon", "ci:image".to_string(), "[^:*]*"),
1595 ("a star", "ci-*".to_string(), "[^:*]*"),
1596 ("over the caller ceiling", "s".repeat(496), "495"),
1597 ] {
1598 let (plane, fake, _) = planted();
1599 let mut request = a_request();
1600 request.log_group = Some("/aws/lambda-microvms/builds".to_string());
1601 request.log_stream = Some(stream);
1602
1603 let error = plane.create_image(request).await.expect_err(label);
1604 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
1605 assert_eq!(error.code(), "ERR_INVALID_ARG", "{label}");
1606 assert!(
1607 error.to_string().contains(named),
1608 "{label}: the refusal must name the constraint: {error}"
1609 );
1610 assert_eq!(fake.calls().len(), 0, "{label}: nothing reached the wire");
1611 }
1612
1613 // The boundary passes: 495 characters resolve to exactly the shape's 512.
1614 let (plane, fake, _) = planted();
1615 fake.answer(
1616 "CreateMicrovmImage",
1617 Answer::created(fake::create_image_response("img")),
1618 );
1619 let mut request = a_request();
1620 request.log_group = Some("g".to_string());
1621 request.log_stream = Some("s".repeat(495));
1622 let image = plane.create_image(request).await.expect("495 is legal");
1623 assert_eq!(
1624 image.log_stream.expect("resolved").len(),
1625 512,
1626 "the longest legal prefix resolves to exactly the shape's ceiling"
1627 );
1628 }
1629
1630 /// An illegal log group is refused locally too, with the pattern named — the colon
1631 /// case is a pasted ARN, which is the mistake the message calls out.
1632 #[tokio::test]
1633 async fn an_illegal_log_group_is_refused_before_any_call() {
1634 for (label, group) in [
1635 (
1636 "a pasted ARN",
1637 "arn:aws:logs:us-east-1:1:log-group:g".to_string(),
1638 ),
1639 ("a space", "my builds".to_string()),
1640 ("over the ceiling", "g".repeat(513)),
1641 ("empty", String::new()),
1642 ] {
1643 let (plane, fake, _) = planted();
1644 let mut request = a_request();
1645 request.log_group = Some(group);
1646
1647 let error = plane.create_image(request).await.expect_err(label);
1648 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
1649 assert_eq!(fake.calls().len(), 0, "{label}: nothing reached the wire");
1650 }
1651 }
1652
1653 /// A stream with no group is refused: the stream lives inside the group, and with no
1654 /// group configured the service creates one with random stream names, so the
1655 /// configured stream would name a location that does not exist.
1656 #[tokio::test]
1657 async fn a_stream_without_a_group_is_refused_before_any_call() {
1658 let (plane, fake, _) = planted();
1659 let mut request = a_request();
1660 request.log_stream = Some("ci".to_string());
1661
1662 let error = plane.create_image(request).await.expect_err("refused");
1663 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1664 assert!(error.to_string().contains("logGroup"), "{error}");
1665 assert_eq!(fake.calls().len(), 0);
1666 }
1667
1668 /// A name the service would reject never reaches the wire, so the caller does not pay
1669 /// for the artifact upload first.
1670 #[tokio::test]
1671 async fn an_invalid_image_name_is_refused_before_any_call() {
1672 let (plane, fake, _) = planted();
1673 let mut request = a_request();
1674 request.name = "my.image".to_string();
1675
1676 let error = plane
1677 .create_image(request)
1678 .await
1679 .expect_err("dots are refused");
1680 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1681 assert_eq!(fake.calls().len(), 0, "nothing reached the control plane");
1682 }
1683
1684 /// A Dockerfile disagreeing with the base image is refused locally too — same reason,
1685 /// and the check is only run when a Dockerfile was supplied.
1686 #[tokio::test]
1687 async fn a_mismatched_dockerfile_is_refused_before_any_call() {
1688 let (plane, fake, _) = planted();
1689 let mut request = a_request();
1690 request.dockerfile = Some("FROM ubuntu:24.04\n".to_string());
1691
1692 let error = plane.create_image(request).await.expect_err("refused");
1693 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1694 assert!(error.to_string().contains("ubuntu:24.04"), "{error}");
1695 assert_eq!(fake.calls().len(), 0);
1696 }
1697
1698 /// **Issue #46 at the create boundary.** A Dockerfile that never runs the daemon — no
1699 /// `CMD`, or an `ENTRYPOINT` that swallows it — is refused before any call, like every
1700 /// other agreement guard.
1701 ///
1702 /// **Falsification** — run 2026-08-17. Delete the `require_daemon_cmd` call from
1703 /// `preflight` and both halves go red with the create having reached the fake.
1704 #[tokio::test]
1705 async fn a_dockerfile_that_never_runs_the_daemon_is_refused_before_any_call() {
1706 for dockerfile in [
1707 // No CMD: the base's default process runs instead of the daemon.
1708 "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\nCOPY agentd /agentd\n",
1709 // A non-empty ENTRYPOINT: the CMD becomes its arguments.
1710 "FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n\
1711 ENTRYPOINT [\"/bin/sh\", \"-c\"]\nCMD [\"/agentd\"]\n",
1712 ] {
1713 let (plane, fake, _) = planted();
1714 let mut request = a_request();
1715 request.dockerfile = Some(dockerfile.to_string());
1716
1717 let error = plane.create_image(request).await.expect_err("refused");
1718 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{dockerfile}");
1719 assert!(
1720 error.to_string().contains("run-hook timeout"),
1721 "the symptom the reader would otherwise chase has to be named: {error}"
1722 );
1723 assert_eq!(fake.calls().len(), 0, "nothing reached the control plane");
1724 }
1725 }
1726
1727 /// **Issue #47's contract, stated at the library boundary.** `preflight` and
1728 /// `create_image` refuse the same request for the same reason, with zero calls — which
1729 /// is what lets an uploading caller run the list before paying for the upload.
1730 ///
1731 /// Asserted over every guard family `preflight` carries, not just the Dockerfile ones,
1732 /// because the extraction's risk is a guard left behind in `create_image` where the
1733 /// pre-upload caller never sees it.
1734 #[tokio::test]
1735 async fn preflight_and_create_image_refuse_identically_with_zero_calls() {
1736 type Breakage = Box<dyn Fn(&mut CreateImageRequest)>;
1737 let broken: [(&str, Breakage); 7] = [
1738 ("an invalid name", Box::new(|r| r.name = "my.image".into())),
1739 (
1740 "a log group with a colon",
1741 Box::new(|r| r.log_group = Some("arn:aws:logs:g".into())),
1742 ),
1743 (
1744 "a log stream with no group",
1745 Box::new(|r| r.log_stream = Some("ci".into())),
1746 ),
1747 (
1748 "a mismatched FROM",
1749 Box::new(|r| r.dockerfile = Some("FROM ubuntu:24.04\nCMD [\"/agentd\"]\n".into())),
1750 ),
1751 (
1752 "a Dockerfile with no CMD",
1753 Box::new(|r| {
1754 r.dockerfile =
1755 Some("FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal\n".into());
1756 }),
1757 ),
1758 (
1759 "a blank base version",
1760 Box::new(|r| r.base_image_version = Some(" ".into())),
1761 ),
1762 (
1763 "a blank build role",
1764 Box::new(|r| r.build_role_arn = String::new()),
1765 ),
1766 ];
1767
1768 for (label, break_it) in broken {
1769 let (plane, fake, _) = planted();
1770 let mut request = a_request();
1771 break_it(&mut request);
1772
1773 let from_preflight = plane.preflight(&request).expect_err(label).to_string();
1774 let from_create = plane
1775 .create_image(request)
1776 .await
1777 .expect_err(label)
1778 .to_string();
1779 assert_eq!(
1780 from_preflight, from_create,
1781 "{label}: one list of guards, not two that can drift"
1782 );
1783 assert_eq!(fake.calls().len(), 0, "{label}: zero calls either way");
1784 }
1785
1786 // And the request every other test builds passes preflight, so the guard list is
1787 // refusing the breakage rather than the baseline.
1788 let (plane, _, _) = planted();
1789 plane.preflight(&a_request()).expect("the baseline passes");
1790 }
1791
1792 /// The hook timeouts reach the wire in their own families, so a build-sized value
1793 /// cannot land in a run-family field.
1794 #[tokio::test]
1795 async fn the_two_hook_families_reach_their_own_wire_members() {
1796 let (plane, fake, _) = planted();
1797 fake.answer(
1798 "CreateMicrovmImage",
1799 Answer::created(fake::create_image_response("img")),
1800 );
1801 let mut request = a_request();
1802 request.run_hook_timeout = RunHookTimeout::try_new(60).expect("the run ceiling");
1803 request.build_hook_timeout = BuildHookTimeout::try_new(3600).expect("the build ceiling");
1804 plane.create_image(request).await.expect("creates");
1805
1806 let body = fake.first_body("CreateMicrovmImage");
1807 assert_eq!(body["hooks"]["microvmHooks"]["runTimeoutInSeconds"], 60);
1808 assert_eq!(
1809 body["hooks"]["microvmImageHooks"]["readyTimeoutInSeconds"],
1810 3600
1811 );
1812 }
1813
1814 /// The wait returns as soon as the image is usable, and accepts every ready spelling —
1815 /// including the two tolerated ones, since a hard check on `CREATED` alone is how a
1816 /// working build looks like a stalled one.
1817 #[tokio::test]
1818 async fn the_wait_returns_on_every_ready_spelling() {
1819 for state in ["CREATED", "UPDATED", "ACTIVE", "AVAILABLE"] {
1820 let (plane, fake, _) = planted();
1821 fake.answer(
1822 "GetMicrovmImage",
1823 Answer::ok(fake::get_image_response("img", state)),
1824 );
1825 let image = plane
1826 .wait_for_image("arn:image", SizeClass::DEFAULT, WaitOpts::default())
1827 .await
1828 .unwrap_or_else(|error| panic!("{state} is usable: {error}"));
1829 assert_eq!(image.state, state);
1830 assert_eq!(fake.call_count("GetMicrovmImage"), 1, "no extra polls");
1831 }
1832 }
1833
1834 /// It polls through `CREATING` and returns when the state changes, without the stall
1835 /// probe firing — the grace period has not elapsed.
1836 #[tokio::test]
1837 async fn the_wait_polls_through_creating_without_probing_inside_the_grace() {
1838 let (plane, fake, _) = planted();
1839 fake.answer(
1840 "GetMicrovmImage",
1841 Answer::ok(fake::get_image_response("img", "CREATING")),
1842 )
1843 .answer(
1844 "GetMicrovmImage",
1845 Answer::ok(fake::get_image_response("img", "CREATED")),
1846 );
1847
1848 let image = plane
1849 .wait_for_image("arn:image", SizeClass::DEFAULT, WaitOpts::default())
1850 .await
1851 .expect("becomes usable");
1852 assert_eq!(image.state, "CREATED");
1853 assert_eq!(fake.call_count("GetMicrovmImage"), 2);
1854 assert_eq!(
1855 fake.call_count("ListMicrovmImageBuilds"),
1856 0,
1857 "one poll interval is well inside the four-minute grace"
1858 );
1859 }
1860
1861 /// **TRAP-2.** Past the grace, in `CREATING`, with every build `PENDING`: the wait is
1862 /// rejected naming the client-token replay signature.
1863 ///
1864 /// The response bodies are literal model-spelled JSON from the fake — `buildState`, not
1865 /// `state` — which is what makes this guard falsifiable at all.
1866 ///
1867 /// **Falsification** — three of them, and all three were run: (a) delete the
1868 /// `probe_stalled_build` call from the wait and this test times out instead, (b) read
1869 /// `state` instead of `buildState` and the deserializer refuses the fake's body, (c)
1870 /// drop the `all PENDING` condition and the polls-through-CREATING test above goes red.
1871 #[tokio::test]
1872 async fn a_build_stuck_creating_with_all_builds_pending_names_the_replay_signature() {
1873 let (plane, fake, _) = planted();
1874 fake.answer(
1875 "GetMicrovmImage",
1876 Answer::ok(fake::get_image_response("img", "CREATING")),
1877 )
1878 .answer(
1879 "ListMicrovmImageVersions",
1880 Answer::ok(fake::list_versions_response("1")),
1881 )
1882 .answer(
1883 "ListMicrovmImageBuilds",
1884 Answer::ok(fake::list_builds_response("PENDING")),
1885 );
1886
1887 let error = plane
1888 .wait_for_image(
1889 "arn:image",
1890 SizeClass::DEFAULT,
1891 WaitOpts {
1892 stall_grace: Duration::from_secs(240),
1893 poll_interval: Duration::from_secs(120),
1894 timeout: Duration::from_secs(2700),
1895 },
1896 )
1897 .await
1898 .expect_err("a wedged image must not be waited out");
1899
1900 assert_eq!(error.kind(), ErrorKind::BuildWedged);
1901 assert_eq!(error.code(), "ERR_BUILD_WEDGED");
1902 let message = error.to_string();
1903 assert!(
1904 message.contains("clientToken replay signature"),
1905 "the message must name the signature: {message}"
1906 );
1907 assert!(message.contains("PENDING"), "{message}");
1908 assert!(
1909 message.contains("permanent idempotency key"),
1910 "the cause has to be nameable: {message}"
1911 );
1912 assert!(
1913 message.contains("Waiting will not help"),
1914 "the remedy is not patience: {message}"
1915 );
1916 assert!(message.contains("docs/PLATFORM.md"), "{message}");
1917 }
1918
1919 /// A build that is genuinely in progress past the grace is **not** accused. `IN_PROGRESS`
1920 /// means the service scheduled it, which is exactly what the replay case never does.
1921 ///
1922 /// Three `CREATING` answers rather than two, because the probe only runs on a poll that
1923 /// *sees* `CREATING` after the grace has elapsed: with two answers the second poll
1924 /// already returns `CREATED` and the probe never fires, so the test would assert nothing
1925 /// about the probe's verdict. The `ListMicrovmImageBuilds` count at the bottom is what
1926 /// keeps that honest.
1927 #[tokio::test]
1928 async fn a_slow_but_scheduled_build_is_not_accused_of_being_wedged() {
1929 let (plane, fake, _) = planted();
1930 fake.answer(
1931 "GetMicrovmImage",
1932 Answer::ok(fake::get_image_response("img", "CREATING")),
1933 )
1934 .answer(
1935 "GetMicrovmImage",
1936 Answer::ok(fake::get_image_response("img", "CREATING")),
1937 )
1938 .answer(
1939 "GetMicrovmImage",
1940 Answer::ok(fake::get_image_response("img", "CREATED")),
1941 )
1942 .answer(
1943 "ListMicrovmImageVersions",
1944 Answer::ok(fake::list_versions_response("1")),
1945 )
1946 .answer(
1947 "ListMicrovmImageBuilds",
1948 Answer::ok(fake::list_builds_response("IN_PROGRESS")),
1949 );
1950
1951 let image = plane
1952 .wait_for_image(
1953 "arn:image",
1954 SizeClass::DEFAULT,
1955 WaitOpts {
1956 stall_grace: Duration::from_secs(240),
1957 poll_interval: Duration::from_secs(300),
1958 timeout: Duration::from_secs(2700),
1959 },
1960 )
1961 .await
1962 .expect("a scheduled build is allowed to be slow");
1963 assert_eq!(image.state, "CREATED");
1964 assert_eq!(
1965 fake.call_count("ListMicrovmImageBuilds"),
1966 1,
1967 "the probe runs once and does not re-arm"
1968 );
1969 }
1970
1971 /// **Issue #23.** One scheduled build on page **two** of the build listing is enough to
1972 /// clear a healthy image, so a truncated page cannot produce a false wedge verdict.
1973 ///
1974 /// `probe_stalled_build` asserts **all** builds are PENDING. Over one page that
1975 /// quantifier is a claim about a subset presented as a claim about the set: fifty pending
1976 /// builds on page one plus one `IN_PROGRESS` build on page two is a healthy image that
1977 /// the old code would have called wedged, and `ERR_BUILD_WEDGED` is an exit code a caller
1978 /// is told to trust.
1979 ///
1980 /// **Falsification** — run 2026-08-15. Stop the loop after the first page in
1981 /// `builds_of_version` and this fails with `ErrorKind::BuildWedged`, which is exactly the
1982 /// false verdict issue #23 describes. Restored.
1983 #[tokio::test]
1984 async fn a_scheduled_build_on_page_two_prevents_a_false_wedge_verdict() {
1985 let (plane, fake, _) = planted();
1986 fake.answer(
1987 "GetMicrovmImage",
1988 Answer::ok(fake::get_image_response("img", "CREATING")),
1989 )
1990 .answer(
1991 "GetMicrovmImage",
1992 Answer::ok(fake::get_image_response("img", "CREATING")),
1993 )
1994 .answer(
1995 "GetMicrovmImage",
1996 Answer::ok(fake::get_image_response("img", "CREATED")),
1997 )
1998 .answer(
1999 "ListMicrovmImageVersions",
2000 Answer::ok(fake::list_versions_response("1")),
2001 )
2002 // Page one is entirely PENDING, which is the wedge signature over a partial list.
2003 .answer(
2004 "ListMicrovmImageBuilds",
2005 Answer::ok(fake::list_builds_page(
2006 &[("build-1", "PENDING"), ("build-2", "PENDING")],
2007 Some("builds-page-2"),
2008 )),
2009 )
2010 // Page two says the service scheduled something, so nothing is wedged.
2011 .answer(
2012 "ListMicrovmImageBuilds",
2013 Answer::ok(fake::list_builds_page(&[("build-3", "IN_PROGRESS")], None)),
2014 );
2015
2016 let image = plane
2017 .wait_for_image(
2018 "arn:image",
2019 SizeClass::DEFAULT,
2020 WaitOpts {
2021 stall_grace: Duration::from_secs(240),
2022 poll_interval: Duration::from_secs(300),
2023 timeout: Duration::from_secs(2700),
2024 },
2025 )
2026 .await
2027 .expect("a build scheduled on page two is a scheduled build");
2028 assert_eq!(image.state, "CREATED");
2029 assert_eq!(
2030 fake.call_count("ListMicrovmImageBuilds"),
2031 2,
2032 "the probe read both pages before deciding"
2033 );
2034
2035 let listings: Vec<String> = fake
2036 .calls()
2037 .into_iter()
2038 .filter(|call| call.operation == "ListMicrovmImageBuilds")
2039 .map(|call| call.path)
2040 .collect();
2041 assert!(
2042 listings[1].contains("nextToken=builds-page-2"),
2043 "the second request must carry the first page's token: {}",
2044 listings[1]
2045 );
2046 }
2047
2048 /// The other direction: a wedge that is only visible on page two is still caught. All
2049 /// builds PENDING **across every page** is the signature, and the page-two builds are
2050 /// named in the message.
2051 ///
2052 /// Without this, a paginating probe that read every page but formed its verdict from the
2053 /// last one would pass the test above while missing a real wedge.
2054 #[tokio::test]
2055 async fn a_wedge_is_still_named_when_the_builds_span_two_pages() {
2056 let (plane, fake, _) = planted();
2057 fake.answer(
2058 "GetMicrovmImage",
2059 Answer::ok(fake::get_image_response("img", "CREATING")),
2060 )
2061 .answer(
2062 "ListMicrovmImageVersions",
2063 Answer::ok(fake::list_versions_response("1")),
2064 )
2065 .answer(
2066 "ListMicrovmImageBuilds",
2067 Answer::ok(fake::list_builds_page(
2068 &[("build-1", "PENDING")],
2069 Some("builds-page-2"),
2070 )),
2071 )
2072 .answer(
2073 "ListMicrovmImageBuilds",
2074 Answer::ok(fake::list_builds_page(&[("build-2", "PENDING")], None)),
2075 );
2076
2077 let error = plane
2078 .wait_for_image(
2079 "arn:image",
2080 SizeClass::DEFAULT,
2081 WaitOpts {
2082 stall_grace: Duration::from_secs(240),
2083 poll_interval: Duration::from_secs(120),
2084 timeout: Duration::from_secs(2700),
2085 },
2086 )
2087 .await
2088 .expect_err("a wedged image must not be waited out");
2089
2090 assert_eq!(error.kind(), ErrorKind::BuildWedged);
2091 let message = error.to_string();
2092 assert!(
2093 message.contains("all 2 builds"),
2094 "the count must be over every page, not one: {message}"
2095 );
2096 assert!(
2097 message.contains("build-2"),
2098 "the page-two build must be in the verdict: {message}"
2099 );
2100 assert!(message.contains("build-1"), "{message}");
2101 }
2102
2103 /// A build list that cannot be read produces **no** wedge claim. Unknown is not
2104 /// wedged, and a claim made on a throttled call sends the reader after the wrong cause.
2105 #[tokio::test]
2106 async fn an_unreadable_build_list_does_not_produce_a_wedge_claim() {
2107 let (plane, fake, _) = planted();
2108 fake.answer(
2109 "GetMicrovmImage",
2110 Answer::ok(fake::get_image_response("img", "CREATING")),
2111 )
2112 .answer(
2113 "ListMicrovmImageVersions",
2114 Answer::failure(429, "Rate exceeded"),
2115 );
2116
2117 let error = plane
2118 .wait_for_image(
2119 "arn:image",
2120 SizeClass::DEFAULT,
2121 WaitOpts {
2122 stall_grace: Duration::from_secs(240),
2123 poll_interval: Duration::from_secs(300),
2124 timeout: Duration::from_secs(600),
2125 },
2126 )
2127 .await
2128 .expect_err("the wait still ends at its deadline");
2129
2130 assert_eq!(
2131 error.kind(),
2132 ErrorKind::Timeout,
2133 "an honest timeout, not a wedge claim: {error}"
2134 );
2135 assert!(!error.to_string().contains("replay signature"), "{error}");
2136 }
2137
2138 /// A reported build failure with **no** reason anywhere names the required log-group
2139 /// prefix, because a failure that reads as "unknown" is most often the wrong IAM prefix
2140 /// rather than a silent service.
2141 ///
2142 /// The listings answer, and answer with no `stateReason`, which is the case that
2143 /// distinguishes "the service stated no cause" from "we did not look".
2144 #[tokio::test]
2145 async fn a_failed_build_with_no_stated_reason_names_the_required_log_group_prefix() {
2146 let (plane, fake, _) = planted();
2147 fake.answer(
2148 "GetMicrovmImage",
2149 Answer::ok(fake::get_image_response("img", "CREATE_FAILED")),
2150 )
2151 .answer(
2152 "ListMicrovmImageVersions",
2153 Answer::ok(fake::list_versions_response("1")),
2154 )
2155 .answer(
2156 "ListMicrovmImageBuilds",
2157 Answer::ok(fake::list_builds_response("FAILED")),
2158 );
2159
2160 let error = plane
2161 .wait_for_image("arn:image", SizeClass::DEFAULT, WaitOpts::default())
2162 .await
2163 .expect_err("a failed build is a failure");
2164 assert_eq!(error.kind(), ErrorKind::Platform);
2165 let message = error.to_string();
2166 assert!(
2167 message.contains("stated no cause"),
2168 "the absence has to be stated as an absence: {message}"
2169 );
2170 assert!(message.contains("/aws/lambda-microvms/img"), "{message}");
2171 assert!(
2172 message.contains("/aws/lambda/microvms/*"),
2173 "the wrong prefix has to be named as wrong: {message}"
2174 );
2175 assert!(message.contains("the plausible spelling"), "{message}");
2176 }
2177
2178 /// **Issue #25.** A build failure reports the `stateReason` the service already stated,
2179 /// at both levels: the version's and the failed build's.
2180 ///
2181 /// `GetMicrovmImage` structurally cannot carry a reason — the shape has no such member —
2182 /// so before this the message could only name a log group and guess. The version reason
2183 /// is the general one ("one or more builds failed") and the build reason is the specific
2184 /// one, which is why both are surfaced rather than just the nearest.
2185 ///
2186 /// The failed build is on **page two** of the build listing, so this doubles as a
2187 /// pagination guard on the diagnosis path.
2188 ///
2189 /// # And now the build's own `GetMicrovmImageBuild`, for the sizes
2190 ///
2191 /// The listing's reason is not the whole of what the service will say. `GetMicrovmImageBuild`
2192 /// adds `snapshotBuild`, and on a real failure it comes back **partial** — measured
2193 /// 2026-08-16, a `FAILED` build answered `codeInstallSizeInBytes` alone with no memory and
2194 /// no disk snapshot, which is a build that installed the code and then never produced a
2195 /// snapshot. That distinguishes "the ready hook never answered" from "the Dockerfile broke
2196 /// before installing anything", and the listing has no member for it. The generation is in
2197 /// the line for the same reason: one create fans out per Graviton generation and they can
2198 /// disagree.
2199 ///
2200 /// **Falsification** — (a) Drop the `failure_reasons` call from `build_failure` and the
2201 /// message loses both reasons: the first two assertions go red. (b) Stop following
2202 /// `nextToken` in `builds_of_version` (return `Some(builds)` in the `Some(token)` arm) and
2203 /// only the version reason survives: the `no space left` assertion goes red. (c) Drop
2204 /// `state_reason` from `MicrovmImageBuildSummaryWire` and it does not compile. (d) Replace
2205 /// `describe_build_deeply` with `listed.describe()` and the size and generation assertions
2206 /// go red while the reason ones still pass — which is what makes the extra call earn itself.
2207 /// All four run.
2208 #[tokio::test]
2209 async fn a_failed_build_reports_the_reason_the_service_already_stated() {
2210 let (plane, fake, _) = planted();
2211 fake.answer(
2212 "GetMicrovmImage",
2213 Answer::ok(fake::get_image_response_failed("img", "CREATE_FAILED", "2")),
2214 )
2215 // Version "2" is on page two of the version listing, and it is the one
2216 // `latestFailedImageVersion` names — so a resolver reading one page finds the wrong
2217 // version, or none.
2218 .answer(
2219 "ListMicrovmImageVersions",
2220 Answer::ok(fake::list_versions_page(&["1"], Some("versions-page-2"))),
2221 )
2222 .answer(
2223 "ListMicrovmImageVersions",
2224 Answer::ok(fake::list_versions_response_failed(
2225 "2",
2226 "FAILED",
2227 Some("one or more builds failed"),
2228 )),
2229 )
2230 // The build that carries the specific reason is on page two of the build listing.
2231 .answer(
2232 "ListMicrovmImageBuilds",
2233 Answer::ok(fake::list_builds_page_with_reasons(
2234 &[("build-1", "SUCCESSFUL", None)],
2235 Some("builds-page-2"),
2236 )),
2237 )
2238 .answer(
2239 "ListMicrovmImageBuilds",
2240 Answer::ok(fake::list_builds_page_with_reasons(
2241 &[("build-2", "FAILED", Some("no space left on device"))],
2242 None,
2243 )),
2244 )
2245 // The per-build `GetMicrovmImageBuild`, answering the partial `snapshotBuild` a real
2246 // failed build carries: code installed, no snapshot produced.
2247 .answer(
2248 "GetMicrovmImageBuild",
2249 Answer::ok(fake::get_image_build_response(
2250 "build-2",
2251 "FAILED",
2252 "4",
2253 Some("no space left on device"),
2254 Some(r#"{"codeInstallSizeInBytes": 1724940288}"#),
2255 )),
2256 );
2257
2258 let error = plane
2259 .wait_for_image("arn:image", SizeClass::DEFAULT, WaitOpts::default())
2260 .await
2261 .expect_err("a failed build is a failure");
2262 assert_eq!(error.kind(), ErrorKind::Platform);
2263 let message = error.to_string();
2264
2265 assert!(
2266 message.contains("one or more builds failed"),
2267 "the version-level reason must reach the message: {message}"
2268 );
2269 assert!(
2270 message.contains("no space left on device"),
2271 "the build-level reason is the specific one, and it was on page two: {message}"
2272 );
2273 assert!(
2274 message.contains("build-2"),
2275 "the buildId is what GetMicrovmImageBuild takes, so it has to be named: {message}"
2276 );
2277 // The two things only `GetMicrovmImageBuild` can say. The size is the diagnosis a
2278 // listing structurally cannot carry, and the generation is which of the fan-out's
2279 // builds this was.
2280 assert!(
2281 message.contains("code 1724940288 bytes"),
2282 "the snapshot size the listing has no member for must reach the message: {message}"
2283 );
2284 assert!(
2285 message.contains("chipset generation 4"),
2286 "one create fans out per generation, so the failing one has to be named: {message}"
2287 );
2288 assert!(
2289 !message.contains("memory"),
2290 "a size the service did not report must not be invented: {message}"
2291 );
2292 assert_eq!(
2293 fake.call_count("GetMicrovmImageBuild"),
2294 1,
2295 "one call per failed build carrying a reason, not per listed build"
2296 );
2297 assert!(
2298 message.contains("The service stated the cause"),
2299 "{message}"
2300 );
2301 assert!(
2302 !message.contains("stated no cause"),
2303 "a message with reasons must not also claim there were none: {message}"
2304 );
2305
2306 assert_eq!(
2307 fake.call_count("ListMicrovmImageVersions"),
2308 2,
2309 "both version pages were read to find the version the service named"
2310 );
2311 assert_eq!(
2312 fake.call_count("ListMicrovmImageBuilds"),
2313 2,
2314 "both build pages were read"
2315 );
2316 }
2317
2318 /// A build failure whose listings cannot be read at all still reports the failure, and
2319 /// still names the log group. A diagnosis lookup must never turn a real build failure
2320 /// into a listing error.
2321 #[tokio::test]
2322 async fn an_unreadable_listing_still_reports_the_build_failure() {
2323 let (plane, fake, _) = planted();
2324 fake.answer(
2325 "GetMicrovmImage",
2326 Answer::ok(fake::get_image_response("img", "CREATE_FAILED")),
2327 )
2328 .answer(
2329 "ListMicrovmImageVersions",
2330 Answer::failure(429, "Rate exceeded"),
2331 );
2332
2333 let error = plane
2334 .wait_for_image("arn:image", SizeClass::DEFAULT, WaitOpts::default())
2335 .await
2336 .expect_err("the build still failed");
2337 assert_eq!(
2338 error.kind(),
2339 ErrorKind::Platform,
2340 "a build failure, not a throttle: {error}"
2341 );
2342 assert!(error.to_string().contains("CREATE_FAILED"), "{error}");
2343 assert!(
2344 error.to_string().contains("/aws/lambda-microvms/img"),
2345 "{error}"
2346 );
2347 }
2348
2349 /// Every model failure spelling is recognised, so a build that failed is never polled
2350 /// to the deadline.
2351 #[test]
2352 fn every_model_failure_state_is_recognised_as_a_failure() {
2353 for state in ["CREATE_FAILED", "UPDATE_FAILED", "DELETE_FAILED"] {
2354 assert!(Image::is_failed(state), "{state}");
2355 assert!(!Image::is_ready(state), "{state}");
2356 }
2357 for state in ["CREATING", "UPDATING", "DELETING", "DELETED"] {
2358 assert!(!Image::is_failed(state), "{state}");
2359 assert!(!Image::is_ready(state), "{state}");
2360 }
2361 }
2362
2363 /// The wait ends at its deadline with a `Timeout` naming the last state seen — which is
2364 /// the difference between "we gave up" and "we gave up while it was still CREATING".
2365 #[tokio::test]
2366 async fn the_wait_ends_at_its_deadline_naming_the_last_state() {
2367 let (plane, fake, _) = planted();
2368 fake.answer(
2369 "GetMicrovmImage",
2370 Answer::ok(fake::get_image_response("img", "CREATING")),
2371 );
2372
2373 let error = plane
2374 .wait_for_image(
2375 "arn:image",
2376 SizeClass::DEFAULT,
2377 WaitOpts {
2378 timeout: Duration::from_secs(60),
2379 poll_interval: Duration::from_secs(30),
2380 stall_grace: Duration::MAX,
2381 },
2382 )
2383 .await
2384 .expect_err("the deadline elapses");
2385 assert_eq!(error.kind(), ErrorKind::Timeout);
2386 let message = error.to_string();
2387 assert!(message.contains("CREATING"), "{message}");
2388 assert!(message.contains("nothing was cancelled"), "{message}");
2389 }
2390
2391 /// The log group is the measured prefix, not the plausible one.
2392 #[test]
2393 fn the_build_log_group_uses_the_measured_prefix() {
2394 let image = Image {
2395 identifier: "arn:image".to_string(),
2396 name: "agentd-conformance".to_string(),
2397 version: "1".to_string(),
2398 state: "CREATED".to_string(),
2399 size: SizeClass::DEFAULT,
2400 log_group: None,
2401 log_stream: None,
2402 };
2403 assert_eq!(
2404 image.build_log_group(),
2405 "/aws/lambda-microvms/agentd-conformance"
2406 );
2407 assert!(
2408 !image.build_log_group().starts_with("/aws/lambda/microvms"),
2409 "the wrong prefix produces builds with no logs at all"
2410 );
2411 }
2412
2413 /// Deletion drops every version but the first, then the image. The first is kept
2414 /// because the last remaining version cannot be deleted alone.
2415 #[tokio::test]
2416 async fn deletion_keeps_the_first_version_and_deletes_the_image() {
2417 let (plane, fake, _) = planted();
2418 fake.answer(
2419 "ListMicrovmImageVersions",
2420 Answer::ok(fake::list_versions_response("1")),
2421 )
2422 .answer(
2423 "DeleteMicrovmImage",
2424 Answer::ok(fake::delete_image_response()),
2425 );
2426
2427 assert!(
2428 plane
2429 .delete_image("arn:image", 20, Duration::from_secs(15))
2430 .await
2431 );
2432 assert_eq!(
2433 fake.call_count("DeleteMicrovmImageVersion"),
2434 0,
2435 "one version means nothing to delete separately"
2436 );
2437 assert_eq!(fake.call_count("DeleteMicrovmImage"), 1);
2438 }
2439
2440 /// **Issue #23, the worst of the three.** Deletion follows `nextToken` and drops the
2441 /// versions on page **two**, so an image with more versions than one page can hold is
2442 /// still deletable.
2443 ///
2444 /// Reading one page and then deleting the image is not a partial cleanup, it is a
2445 /// permanent one: page-two versions still exist, the final `DeleteMicrovmImage` conflicts,
2446 /// the retry loop re-reads the same first page every attempt, and `delete_image` returns
2447 /// `false` forever — a billing image nothing can delete through this client.
2448 ///
2449 /// The assertion is the **delete count**, not the call count: five versions across two
2450 /// pages means four `DeleteMicrovmImageVersion` calls, and the two on page two are named
2451 /// individually so a loop that read the second page but deleted from the first would
2452 /// still fail.
2453 ///
2454 /// **Falsification** — run 2026-08-15. Replace the `Some(token) => next_token = ...` arm
2455 /// with `Some(_) => break` and this fails with 1 deletion instead of 4, and the
2456 /// `v-4`/`v-5` path assertions go red. Restored.
2457 #[tokio::test]
2458 async fn deletion_follows_next_token_and_drops_the_versions_on_page_two() {
2459 let (plane, fake, _) = planted();
2460 fake.answer(
2461 "ListMicrovmImageVersions",
2462 Answer::ok(fake::list_versions_page(
2463 &["v-1", "v-2", "v-3"],
2464 Some("versions-page-2"),
2465 )),
2466 )
2467 .answer(
2468 "ListMicrovmImageVersions",
2469 Answer::ok(fake::list_versions_page(&["v-4", "v-5"], None)),
2470 )
2471 .answer(
2472 "DeleteMicrovmImageVersion",
2473 Answer::ok(fake::empty_response()),
2474 )
2475 .answer(
2476 "DeleteMicrovmImage",
2477 Answer::ok(fake::delete_image_response()),
2478 );
2479
2480 assert!(
2481 plane
2482 .delete_image("arn:image", 20, Duration::from_secs(15))
2483 .await,
2484 "an image whose versions span two pages must still be deletable"
2485 );
2486
2487 // The delete count first, because it is the outcome the bug produced: reading one
2488 // page deletes one version and leaves two billing. A call count would report the
2489 // same breakage as "one listing" and bury what that cost.
2490 assert_eq!(
2491 fake.call_count("DeleteMicrovmImageVersion"),
2492 4,
2493 "five versions, keeping the first: four deletions, two of them from page two"
2494 );
2495
2496 // Named individually, because a loop that reads page two and then deletes from page
2497 // one would still make four calls.
2498 let deleted: Vec<String> = fake
2499 .calls()
2500 .into_iter()
2501 .filter(|call| call.operation == "DeleteMicrovmImageVersion")
2502 .map(|call| call.path)
2503 .collect();
2504 for version in ["v-2", "v-3", "v-4", "v-5"] {
2505 assert!(
2506 deleted.iter().any(|path| path.ends_with(version)),
2507 "version {version} was never deleted, so it still bills: {deleted:?}"
2508 );
2509 }
2510 assert!(
2511 !deleted.iter().any(|path| path.ends_with("v-1")),
2512 "the first version goes with the image: {deleted:?}"
2513 );
2514 assert_eq!(fake.call_count("DeleteMicrovmImage"), 1);
2515 assert_eq!(
2516 fake.call_count("ListMicrovmImageVersions"),
2517 2,
2518 "both pages were read"
2519 );
2520
2521 // The second listing request carries the first page's cursor. Without this the loop
2522 // could re-read page one forever and still satisfy a count of two.
2523 let listings: Vec<String> = fake
2524 .calls()
2525 .into_iter()
2526 .filter(|call| call.operation == "ListMicrovmImageVersions")
2527 .map(|call| call.path)
2528 .collect();
2529 assert!(
2530 listings[1].contains("nextToken=versions-page-2"),
2531 "the second request must carry the first page's token: {}",
2532 listings[1]
2533 );
2534 assert!(
2535 !listings[0].contains("nextToken"),
2536 "the first request carries no cursor: {}",
2537 listings[0]
2538 );
2539 }
2540
2541 /// **Issue #25.** A `DeleteMicrovmImage` that answers 2xx while reporting a `*_FAILED`
2542 /// state is a failure, not a success.
2543 ///
2544 /// That readback is why `DeleteImageResponseWire` is kept rather than deleted as dead:
2545 /// without it the service can accept the delete request, refuse the work, and have
2546 /// `delete_image` answer `true` — teardown reports clean and the image keeps billing.
2547 ///
2548 /// **Falsification** — run 2026-08-15. Drop the `Image::is_failed` check from
2549 /// `try_delete_image` and this reports `true` after one attempt: both assertions go red.
2550 /// Restored.
2551 #[tokio::test]
2552 async fn a_delete_that_reads_back_failed_is_not_reported_as_deleted() {
2553 let (plane, fake, _) = planted();
2554 fake.answer(
2555 "ListMicrovmImageVersions",
2556 Answer::ok(fake::list_versions_response("1")),
2557 )
2558 .answer(
2559 "DeleteMicrovmImage",
2560 Answer::ok(fake::delete_image_response_in("DELETE_FAILED")),
2561 );
2562
2563 assert!(
2564 !plane
2565 .delete_image("arn:image", 2, Duration::from_secs(15))
2566 .await,
2567 "a DELETE_FAILED readback means the image still exists and still bills"
2568 );
2569 assert_eq!(
2570 fake.call_count("DeleteMicrovmImage"),
2571 2,
2572 "every attempt ran rather than the first being taken as success"
2573 );
2574 }
2575
2576 /// `DELETING` and `DELETED` are **both** success. The deletion is asynchronous, so
2577 /// `DELETING` is the ordinary answer, and treating it as incomplete would re-issue a
2578 /// delete already in progress and then report failure on the conflict that comes back.
2579 #[tokio::test]
2580 async fn both_deleting_and_deleted_read_back_as_success() {
2581 for state in ["DELETING", "DELETED"] {
2582 let (plane, fake, _) = planted();
2583 fake.answer(
2584 "ListMicrovmImageVersions",
2585 Answer::ok(fake::list_versions_response("1")),
2586 )
2587 .answer(
2588 "DeleteMicrovmImage",
2589 Answer::ok(fake::delete_image_response_in(state)),
2590 );
2591
2592 assert!(
2593 plane
2594 .delete_image("arn:image", 20, Duration::from_secs(15))
2595 .await,
2596 "{state} is the delete having worked"
2597 );
2598 assert_eq!(
2599 fake.call_count("DeleteMicrovmImage"),
2600 1,
2601 "{state} must not be retried"
2602 );
2603 }
2604 }
2605
2606 /// Deletion retries a conflict — an image in `CREATING` refuses deletion, and a VM
2607 /// still terminating holds a reference — and gives up without raising, because the
2608 /// caller is a teardown path.
2609 #[tokio::test]
2610 async fn deletion_retries_a_conflict_and_gives_up_without_raising() {
2611 let (plane, fake, clock) = planted();
2612 fake.answer(
2613 "ListMicrovmImageVersions",
2614 Answer::ok(fake::list_versions_response("1")),
2615 )
2616 .answer(
2617 "DeleteMicrovmImage",
2618 Answer::failure(409, "image is in CREATING"),
2619 );
2620
2621 let deleted = plane
2622 .delete_image("arn:image", 3, Duration::from_secs(15))
2623 .await;
2624 assert!(!deleted, "it reports failure rather than raising");
2625 assert_eq!(
2626 fake.call_count("DeleteMicrovmImage"),
2627 3,
2628 "every attempt ran"
2629 );
2630 assert_eq!(
2631 clock.now(),
2632 Duration::from_secs(30),
2633 "two backoffs between three attempts"
2634 );
2635 }
2636
2637 /// A deletion that succeeds on the second attempt stops there.
2638 #[tokio::test]
2639 async fn deletion_stops_as_soon_as_it_succeeds() {
2640 let (plane, fake, _) = planted();
2641 fake.answer(
2642 "ListMicrovmImageVersions",
2643 Answer::ok(fake::list_versions_response("1")),
2644 )
2645 .answer("DeleteMicrovmImage", Answer::failure(409, "still CREATING"))
2646 .answer(
2647 "DeleteMicrovmImage",
2648 Answer::ok(fake::delete_image_response()),
2649 );
2650
2651 assert!(
2652 plane
2653 .delete_image("arn:image", 20, Duration::from_secs(15))
2654 .await
2655 );
2656 assert_eq!(fake.call_count("DeleteMicrovmImage"), 2);
2657 }
2658
2659 /// The artifact bytes are available without a control-plane call, which is what lets the
2660 /// byte-scan guard inspect them.
2661 #[test]
2662 fn the_artifact_is_buildable_without_touching_the_control_plane() {
2663 let (plane, fake, _) = planted();
2664 let bytes = plane
2665 .build_artifact_for(&a_request())
2666 .expect("builds the zip");
2667 assert!(!bytes.is_empty());
2668 assert_eq!(fake.calls().len(), 0);
2669 }
2670
2671 // ── name resolution ──────────────────────────────────────────────────────
2672
2673 /// A bare name resolves to its image's ARN through the listing, and the request
2674 /// carries the model's `nameFilter` query member.
2675 ///
2676 /// The listing here answers with a *substring* superset — `agentd-conformance-old`
2677 /// beside the exact name — because that is what `nameFilter` really returns, and the
2678 /// exact-match rule is what this test is for: `contains` and `is` are different
2679 /// questions, and only `is` is safe to launch from.
2680 ///
2681 /// **Falsification** — return the first item regardless of name match (replace the
2682 /// `item.name == name` comparison with `true`) and this resolves to `...-old`'s ARN:
2683 /// the equality below goes red. Run 2026-08-14; it failed exactly there and was
2684 /// restored.
2685 #[tokio::test]
2686 async fn a_bare_name_resolves_to_the_exactly_matching_images_arn() {
2687 let (plane, fake, _) = planted();
2688 fake.answer(
2689 "ListMicrovmImages",
2690 Answer::ok(fake::list_images_response(
2691 &["agentd-conformance-old", "agentd-conformance"],
2692 None,
2693 )),
2694 );
2695
2696 let arn = plane
2697 .resolve_image_arn("agentd-conformance")
2698 .await
2699 .expect("resolves");
2700 assert_eq!(
2701 arn, "arn:aws:lambda:us-east-1:123456789012:microvm-image:agentd-conformance",
2702 "the exact match wins, not the first substring hit"
2703 );
2704
2705 let calls = fake.calls();
2706 assert_eq!(calls.len(), 1);
2707 assert_eq!(calls[0].method, super::super::transport::Method::Get);
2708 assert_eq!(
2709 calls[0].path,
2710 "/2025-09-09/microvm-images?nameFilter=agentd-conformance"
2711 );
2712 }
2713
2714 /// An identifier already shaped like an ARN passes through untouched, with **zero**
2715 /// listing calls — the caller who holds the ARN pays nothing.
2716 #[tokio::test]
2717 async fn an_arn_identifier_passes_through_with_no_listing_call() {
2718 let (plane, fake, _) = planted();
2719 let arn = "arn:aws:lambda:us-east-1:123456789012:microvm-image:img";
2720 let resolved = plane.resolve_image_arn(arn).await.expect("passes through");
2721 assert_eq!(resolved, arn);
2722 assert_eq!(
2723 fake.call_count("ListMicrovmImages"),
2724 0,
2725 "an ARN must cost zero extra calls"
2726 );
2727 assert_eq!(fake.calls().len(), 0);
2728 }
2729
2730 /// Zero matches is a local `Precondition` error naming the name and the remedy —
2731 /// not the service's "Malformed ARN", which says nothing about names.
2732 #[tokio::test]
2733 async fn an_unknown_name_is_a_precondition_error_naming_the_name_and_the_remedy() {
2734 let (plane, fake, _) = planted();
2735 fake.answer(
2736 "ListMicrovmImages",
2737 Answer::ok(fake::list_images_response(&[], None)),
2738 );
2739
2740 let error = plane
2741 .resolve_image_arn("no-such-image")
2742 .await
2743 .expect_err("nothing to resolve");
2744 assert_eq!(error.kind(), ErrorKind::Precondition);
2745 assert_eq!(error.code(), "ERR_PRECONDITION");
2746 let message = error.to_string();
2747 assert!(message.contains("no-such-image"), "{message}");
2748 assert!(message.contains("microvm build"), "{message}");
2749 assert!(
2750 message.contains("last page"),
2751 "the message must say the whole listing was read: {message}"
2752 );
2753 }
2754
2755 /// Resolution follows `nextToken` across pages: an image on page two is found, and
2756 /// the second request carries the first page's token.
2757 ///
2758 /// **Falsification** — stop the loop after the first page (replace the
2759 /// `Some(token)` arm with `return Ok(None)`) and this reports the name missing.
2760 /// Run 2026-08-14; it failed exactly there and was restored.
2761 #[tokio::test]
2762 async fn resolution_follows_next_token_to_an_image_on_the_second_page() {
2763 let (plane, fake, _) = planted();
2764 fake.answer(
2765 "ListMicrovmImages",
2766 Answer::ok(fake::list_images_response(
2767 &["some-other-image"],
2768 Some("page-2-token"),
2769 )),
2770 )
2771 .answer(
2772 "ListMicrovmImages",
2773 Answer::ok(fake::list_images_response(&["wanted-image"], None)),
2774 );
2775
2776 let arn = plane
2777 .resolve_image_arn("wanted-image")
2778 .await
2779 .expect("found on page two");
2780 assert_eq!(
2781 arn,
2782 "arn:aws:lambda:us-east-1:123456789012:microvm-image:wanted-image"
2783 );
2784 assert_eq!(fake.call_count("ListMicrovmImages"), 2, "both pages read");
2785
2786 let paths = fake.paths();
2787 assert!(
2788 paths[1].contains("nextToken=page-2-token"),
2789 "the second request must carry the first page's token: {}",
2790 paths[1]
2791 );
2792 assert!(
2793 paths[0].contains("nameFilter=wanted-image") && !paths[0].contains("nextToken"),
2794 "the first request has the filter and no token: {}",
2795 paths[0]
2796 );
2797 }
2798
2799 // ── the four new operations ──────────────────────────────────────────────
2800
2801 /// **A real build failure carries no version-level reason, and the build's line is the
2802 /// whole diagnosis.**
2803 ///
2804 /// Measured 2026-08-16 against a deliberately failing build (`RUN … && exit 42`, image
2805 /// `microvm-cli-cpc-fail`): `GetMicrovmImageVersion` answered `state: FAILED, status:
2806 /// INACTIVE` with **no `stateReason` member at all**, while both of the version's builds
2807 /// carried `The container image build failed.` That confirms the entry in
2808 /// docs/PLATFORM.md rather than merely restating it, and it is the case that decides how
2809 /// the diagnosis has to be assembled: a message built only from the version's reason would
2810 /// say nothing on a real failure.
2811 ///
2812 /// The fake's version here therefore carries **no** reason, unlike the sibling test above
2813 /// — so the message must still name the cause, and it can only get it from the builds.
2814 ///
2815 /// **Falsification** — run 2026-08-16. Drop the build-level extension from
2816 /// `failure_reasons` (keep only the version's reason) and the `stated no cause` assertion
2817 /// goes red, because with no version reason there is nothing left.
2818 #[tokio::test]
2819 async fn a_failure_with_no_version_reason_still_names_the_cause_from_the_builds() {
2820 let (plane, fake, _) = planted();
2821 fake.answer(
2822 "GetMicrovmImage",
2823 Answer::ok(fake::get_image_response_failed(
2824 "img",
2825 "CREATE_FAILED",
2826 "1.0",
2827 )),
2828 )
2829 // `FAILED` / `INACTIVE` with no `stateReason` — the shape a real failure has.
2830 .answer(
2831 "ListMicrovmImageVersions",
2832 Answer::ok(format!(
2833 r#"{{"items": [{}]}}"#,
2834 fake::get_image_version_response("1.0", "FAILED", "INACTIVE")
2835 )),
2836 )
2837 // Both builds of the fan-out failed with the same reason, which is what a real one does.
2838 .answer(
2839 "ListMicrovmImageBuilds",
2840 Answer::ok(fake::list_builds_page_with_reasons(
2841 &[
2842 (
2843 "ad8dc894-df5e-499c-800c-89711db15f21",
2844 "FAILED",
2845 Some("The container image build failed."),
2846 ),
2847 (
2848 "fbf3cc24-baf7-4498-8f05-13de7c34f2a4",
2849 "FAILED",
2850 Some("The container image build failed."),
2851 ),
2852 ],
2853 None,
2854 )),
2855 )
2856 // And `GetMicrovmImageBuild` on a real *container-build* failure carries **no**
2857 // `snapshotBuild` at all — measured 2026-08-16. So the line has the reason and the
2858 // generation and no sizes, which the renderer must handle without a dangling clause.
2859 .answer(
2860 "GetMicrovmImageBuild",
2861 Answer::ok(fake::get_image_build_response(
2862 "ad8dc894-df5e-499c-800c-89711db15f21",
2863 "FAILED",
2864 "4",
2865 Some("The container image build failed."),
2866 None,
2867 )),
2868 )
2869 .answer(
2870 "GetMicrovmImageBuild",
2871 Answer::ok(fake::get_image_build_response(
2872 "fbf3cc24-baf7-4498-8f05-13de7c34f2a4",
2873 "FAILED",
2874 "3",
2875 Some("The container image build failed."),
2876 None,
2877 )),
2878 );
2879
2880 let error = plane
2881 .wait_for_image("arn:image", SizeClass::DEFAULT, WaitOpts::default())
2882 .await
2883 .expect_err("a failed build is a failure");
2884 let message = error.to_string();
2885 assert!(
2886 message.contains("The service stated the cause"),
2887 "the version carried no reason, so the builds have to supply it: {message}"
2888 );
2889 assert!(
2890 !message.contains("stated no cause"),
2891 "a message with build reasons must not claim there were none: {message}"
2892 );
2893 assert!(
2894 message.contains("The container image build failed."),
2895 "{message}"
2896 );
2897 // Both generations named, because one create fans out into one build per generation
2898 // and a report naming only one hides a partial failure.
2899 assert!(message.contains("chipset generation 4"), "{message}");
2900 assert!(message.contains("chipset generation 3"), "{message}");
2901 assert!(
2902 !message.contains("bytes"),
2903 "a container-build failure reports no snapshotBuild, so no size may be invented: \
2904 {message}"
2905 );
2906 assert_eq!(
2907 fake.call_count("GetMicrovmImageBuild"),
2908 2,
2909 "one per failed build, which is the fan-out's width"
2910 );
2911 }
2912
2913 /// A version-level reason, when the service does give one, still reaches the message —
2914 /// and the version's `status` is read alongside its `state`.
2915 ///
2916 /// The pair with the test above: this is the case docs/PLATFORM.md records as *not* what a
2917 /// real failure looks like, kept because a diagnosis that only worked when the version was
2918 /// silent would break the moment AWS started populating the member.
2919 #[tokio::test]
2920 async fn a_version_level_reason_is_used_when_the_service_does_give_one() {
2921 let (plane, fake, _) = planted();
2922 fake.answer(
2923 "GetMicrovmImage",
2924 Answer::ok(fake::get_image_response_failed(
2925 "img",
2926 "CREATE_FAILED",
2927 "2.0",
2928 )),
2929 )
2930 .answer(
2931 "ListMicrovmImageVersions",
2932 Answer::ok(format!(
2933 r#"{{"items": [{}]}}"#,
2934 fake::get_image_version_response_with_reason(
2935 "2.0",
2936 "FAILED",
2937 "INACTIVE",
2938 "one or more builds failed",
2939 )
2940 )),
2941 )
2942 .answer(
2943 "ListMicrovmImageBuilds",
2944 Answer::ok(fake::list_builds_page_with_reasons(
2945 &[("build-1", "FAILED", Some("no space left on device"))],
2946 None,
2947 )),
2948 )
2949 .answer(
2950 "GetMicrovmImageBuild",
2951 Answer::ok(fake::get_image_build_response(
2952 "build-1",
2953 "FAILED",
2954 "4",
2955 Some("no space left on device"),
2956 Some(r#"{"codeInstallSizeInBytes": 1724940288}"#),
2957 )),
2958 );
2959
2960 let error = plane
2961 .wait_for_image("arn:image", SizeClass::DEFAULT, WaitOpts::default())
2962 .await
2963 .expect_err("a failed build is a failure");
2964 let message = error.to_string();
2965 assert!(
2966 message.contains("version 2.0 is FAILED because one or more builds failed"),
2967 "the version's own reason has to survive: {message}"
2968 );
2969 assert!(
2970 message.contains("no space left on device"),
2971 "the build's reason is the specific one: {message}"
2972 );
2973 assert!(
2974 message.contains("code 1724940288 bytes"),
2975 "and the size only GetMicrovmImageBuild has: {message}"
2976 );
2977 }
2978
2979 /// `GetMicrovmImageBuild` lands on the model's path and method, and answers with the
2980 /// snapshot sizes.
2981 ///
2982 /// The path is asserted as a literal rather than built from the helper, because the whole
2983 /// content of the change is one extra segment and a helper shared with the code under test
2984 /// would agree with a wrong helper.
2985 ///
2986 /// **Falsification** — run 2026-08-16. Change `paths::image_build` to append `/build/`
2987 /// instead of `/builds/` and the path assertion goes red; the request would otherwise
2988 /// succeed against a fake that keys on the operation name.
2989 #[tokio::test]
2990 async fn getting_one_build_emits_the_models_path_and_reads_the_snapshot_sizes() {
2991 let (plane, fake, _) = planted();
2992 fake.answer(
2993 "GetMicrovmImageBuild",
2994 Answer::ok(fake::get_image_build_response(
2995 "build-abc",
2996 "SUCCESSFUL",
2997 "4",
2998 None,
2999 Some(
3000 r#"{"memorySnapshotSizeInBytes": 582238208,
3001 "codeInstallSizeInBytes": 2355486720,
3002 "diskSnapshotSizeInBytes": 23760896}"#,
3003 ),
3004 )),
3005 );
3006
3007 let build = plane
3008 .get_image_build("arn:image", "1.0", "build-abc")
3009 .await
3010 .expect("reads");
3011 assert_eq!(build.build_state, "SUCCESSFUL");
3012 assert_eq!(build.chipset_generation, "4");
3013 let sizes = build.snapshot_build.expect("the sizes are why we called");
3014 assert_eq!(sizes.memory_snapshot_size_in_bytes, Some(582_238_208));
3015
3016 let calls = fake.calls();
3017 assert_eq!(calls.len(), 1);
3018 assert_eq!(calls[0].method, super::super::transport::Method::Get);
3019 assert_eq!(
3020 calls[0].path,
3021 "/2025-09-09/microvm-images/arn%3Aimage/versions/1.0/builds/build-abc"
3022 );
3023 assert_eq!(calls[0].body, None, "a GET carries no body");
3024 }
3025
3026 /// `GetMicrovmImageVersion` lands on the version path with a `GET`, and reads the
3027 /// availability status.
3028 ///
3029 /// The same path a `DELETE` of the version uses, which is the model's own arrangement —
3030 /// so the assertion that matters is the **method**: a `DELETE` here would destroy the
3031 /// version this call exists to inspect.
3032 ///
3033 /// **Falsification** — run 2026-08-16. Build the call with `Call::delete` and the method
3034 /// assertion goes red while the path assertion still passes, which is the whole reason both
3035 /// are here.
3036 #[tokio::test]
3037 async fn getting_one_version_reads_it_with_a_get_on_the_delete_path() {
3038 let (plane, fake, _) = planted();
3039 fake.answer(
3040 "GetMicrovmImageVersion",
3041 Answer::ok(fake::get_image_version_response(
3042 "2.0",
3043 "SUCCESSFUL",
3044 "INACTIVE",
3045 )),
3046 );
3047
3048 let version = plane
3049 .get_image_version("arn:image", "2.0")
3050 .await
3051 .expect("reads");
3052 assert_eq!(version.image_version, "2.0");
3053 assert_eq!(version.status, "INACTIVE");
3054 assert!(!version.is_active(), "INACTIVE will not launch");
3055
3056 let calls = fake.calls();
3057 assert_eq!(
3058 calls[0].method,
3059 super::super::transport::Method::Get,
3060 "a DELETE on this path destroys the version this call inspects"
3061 );
3062 assert_eq!(
3063 calls[0].path,
3064 "/2025-09-09/microvm-images/arn%3Aimage/versions/2.0"
3065 );
3066 }
3067
3068 /// **`UpdateMicrovmImageVersion` is a `PATCH` whose body is one member.**
3069 ///
3070 /// The method, the path, and the body's exact key set, because each is a separate way to
3071 /// get the model's only non-destructive retire wrong: a `POST` reaches a route the service
3072 /// does not declare, a path built from the listing carries a cursor, and a body echoing the
3073 /// URI parameters sends members the shape does not have.
3074 ///
3075 /// **Falsification** — run 2026-08-16, three ways. (a) `Call::post_json` instead of
3076 /// `patch_json`: the method assertion goes red. (b) Add `imageVersion` to
3077 /// `UpdateImageVersionWire`: the exact-key-set assertion goes red. (c) Send
3078 /// `VersionStatus::Active` while asking for `Inactive`: the body assertion goes red *and*
3079 /// the readback check in `set_image_version_status` raises, which is the next test.
3080 #[tokio::test]
3081 async fn retiring_a_version_patches_one_member_onto_the_version_path() {
3082 let (plane, fake, _) = planted();
3083 fake.answer(
3084 "UpdateMicrovmImageVersion",
3085 Answer::ok(fake::get_image_version_response(
3086 "2.0",
3087 "SUCCESSFUL",
3088 "INACTIVE",
3089 )),
3090 );
3091
3092 let updated = plane
3093 .set_image_version_status("arn:image", "2.0", ops::VersionStatus::Inactive)
3094 .await
3095 .expect("the retire is accepted");
3096 assert_eq!(updated.status, "INACTIVE");
3097 assert!(!updated.is_active());
3098 assert_eq!(
3099 updated.state, "SUCCESSFUL",
3100 "a retire does not change the build state: the version still built fine, it is \
3101 just no longer launchable"
3102 );
3103
3104 let calls = fake.calls();
3105 assert_eq!(calls[0].method, super::super::transport::Method::Patch);
3106 assert_eq!(
3107 calls[0].path,
3108 "/2025-09-09/microvm-images/arn%3Aimage/versions/2.0"
3109 );
3110 assert!(
3111 !calls[0].path.contains('?'),
3112 "the version path must carry no query member: {}",
3113 calls[0].path
3114 );
3115
3116 let body = fake.first_body("UpdateMicrovmImageVersion");
3117 assert_eq!(body["status"], "INACTIVE");
3118 assert_eq!(
3119 body.as_object()
3120 .expect("an object")
3121 .keys()
3122 .collect::<Vec<_>>(),
3123 vec!["status"],
3124 "imageIdentifier and imageVersion are uri-located: {body}"
3125 );
3126 }
3127
3128 /// **A 2xx whose readback disagrees with the request is a failure.**
3129 ///
3130 /// The same argument `ops::DeleteImageResponseWire` makes: a request the service accepts
3131 /// and does not apply is indistinguishable from one it applied, and here the consequence is
3132 /// a version the caller believes is retired while `RunMicrovm` still launches it. The
3133 /// readback is the only thing that can tell the two apart.
3134 ///
3135 /// **Falsification** — run 2026-08-16. Drop the `updated.status != status.as_str()` check
3136 /// from `set_image_version_status` and this reports `Ok` with an `ACTIVE` version, which is
3137 /// exactly the silent failure it exists to prevent.
3138 #[tokio::test]
3139 async fn a_retire_that_reads_back_active_is_reported_as_a_failure() {
3140 let (plane, fake, _) = planted();
3141 fake.answer(
3142 "UpdateMicrovmImageVersion",
3143 // A 200 whose body still says ACTIVE.
3144 Answer::ok(fake::get_image_version_response(
3145 "2.0",
3146 "SUCCESSFUL",
3147 "ACTIVE",
3148 )),
3149 );
3150
3151 let error = plane
3152 .set_image_version_status("arn:image", "2.0", ops::VersionStatus::Inactive)
3153 .await
3154 .expect_err("a version that is still ACTIVE is one RunMicrovm will still launch");
3155 assert_eq!(error.kind(), ErrorKind::Platform);
3156 let message = error.to_string();
3157 assert!(message.contains("read back status ACTIVE"), "{message}");
3158 assert!(message.contains("INACTIVE"), "{message}");
3159 assert!(
3160 message.contains("RunMicrovm will still launch"),
3161 "the consequence has to be nameable: {message}"
3162 );
3163 assert_eq!(fake.call_count("UpdateMicrovmImageVersion"), 1);
3164 }
3165
3166 /// A blank or whitespace-bearing version is refused **before** the call, on both the
3167 /// retire path and the launch path.
3168 ///
3169 /// The `Version` shape is `min 1, max 2048, pattern [^\s]+`, and issue #24 lists
3170 /// `NonBlankString` as the model's most-reused unguarded shape. The refusal is local
3171 /// because a retire is what someone does while rolling back, and a `ValidationException`
3172 /// about the request rather than about the version is the least useful failure available at
3173 /// that moment.
3174 ///
3175 /// **Falsification** — run 2026-08-16. Delete the `require_valid_version` call from
3176 /// `set_image_version_status` and the zero-call assertion goes red: the fake records a
3177 /// `PATCH` to `/versions/` with an empty segment, which addresses nothing.
3178 #[tokio::test]
3179 async fn a_blank_or_whitespace_version_reaches_no_control_plane_call() {
3180 for bad in ["", " ", "2.0\n", "a b", "\t2.0"] {
3181 let (plane, fake, _) = planted();
3182 let error = plane
3183 .set_image_version_status("arn:image", bad, ops::VersionStatus::Inactive)
3184 .await
3185 .expect_err(&format!("{bad:?} is not a legal Version"));
3186 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}: {error}");
3187 assert_eq!(
3188 fake.calls().len(),
3189 0,
3190 "{bad:?} must reach no control-plane call at all"
3191 );
3192 }
3193 }
3194
3195 /// The version guard's three cases each get their own message, and none reaches the wire.
3196 #[tokio::test]
3197 async fn each_invalid_version_is_refused_locally_with_its_own_message() {
3198 let (plane, fake, _) = planted();
3199
3200 let empty = plane
3201 .set_image_version_status("arn:image", "", ops::VersionStatus::Inactive)
3202 .await
3203 .expect_err("min is 1");
3204 assert_eq!(empty.kind(), ErrorKind::InvalidArg);
3205 assert!(
3206 empty.to_string().contains("Omit the member entirely"),
3207 "an absent version and a blank one are different requests: {empty}"
3208 );
3209
3210 let newline = plane
3211 .set_image_version_status("arn:image", "2.0\n", ops::VersionStatus::Inactive)
3212 .await
3213 .expect_err("the pattern forbids whitespace anywhere");
3214 let message = newline.to_string();
3215 assert!(message.contains("whitespace"), "{message}");
3216 assert!(
3217 message.contains("trailing newline"),
3218 "the plausible cause has to be named: {message}"
3219 );
3220
3221 let long = plane
3222 .set_image_version_status("arn:image", &"9".repeat(2049), ops::VersionStatus::Inactive)
3223 .await
3224 .expect_err("max is 2048");
3225 assert!(long.to_string().contains("2049 characters"), "{long}");
3226
3227 assert_eq!(
3228 fake.calls().len(),
3229 0,
3230 "no invalid version reaches the control plane"
3231 );
3232
3233 // And a legal one does reach it, so the guard is a comparison rather than a blanket
3234 // refusal.
3235 fake.answer(
3236 "UpdateMicrovmImageVersion",
3237 Answer::ok(fake::get_image_version_response(
3238 "2.0",
3239 "SUCCESSFUL",
3240 "INACTIVE",
3241 )),
3242 );
3243 plane
3244 .set_image_version_status("arn:image", "2.0", ops::VersionStatus::Inactive)
3245 .await
3246 .expect("2.0 is a legal Version");
3247 assert_eq!(fake.call_count("UpdateMicrovmImageVersion"), 1);
3248 }
3249
3250 /// **`ListManagedMicrovmImageVersions` reads every page**, and the second request carries
3251 /// the first page's cursor.
3252 ///
3253 /// Every page for the reason the other listings give: a caller pinning "the newest" from a
3254 /// truncated page pins the wrong one, and a base version is what a build's reproducibility
3255 /// rests on.
3256 ///
3257 /// **Falsification** — run 2026-08-16. Replace the `Some(token)` arm with
3258 /// `None => return Ok(items)` semantics (stop after page one) and this fails with 2 versions
3259 /// instead of 3, and the page-two assertion goes red.
3260 #[tokio::test]
3261 async fn the_managed_version_listing_follows_next_token_to_the_last_page() {
3262 let (plane, fake, _) = planted();
3263 fake.answer(
3264 "ListManagedMicrovmImageVersions",
3265 Answer::ok(fake::list_managed_versions_page(
3266 &["2", "1"],
3267 Some("managed-page-2"),
3268 )),
3269 )
3270 .answer(
3271 "ListManagedMicrovmImageVersions",
3272 Answer::ok(fake::list_managed_versions_page(&["0"], None)),
3273 );
3274
3275 let versions = plane
3276 .managed_base_versions("arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1")
3277 .await
3278 .expect("lists");
3279 assert_eq!(versions.len(), 3, "three versions across two pages");
3280 let ids: Vec<&str> = versions
3281 .iter()
3282 .map(|version| version.image_version.as_str())
3283 .collect();
3284 assert_eq!(ids, ["2", "1", "0"]);
3285 assert!(
3286 ids.contains(&"0"),
3287 "the page-two version is the oldest and would be the one a truncated read missed"
3288 );
3289
3290 let paths: Vec<String> = fake
3291 .calls()
3292 .into_iter()
3293 .filter(|call| call.operation == "ListManagedMicrovmImageVersions")
3294 .map(|call| call.path)
3295 .collect();
3296 assert_eq!(paths.len(), 2);
3297 assert!(
3298 paths[0].starts_with("/2025-09-09/managed-microvm-images/"),
3299 "the managed collection is its own route, not a filter on microvm-images: {}",
3300 paths[0]
3301 );
3302 assert!(!paths[0].contains('?'), "{}", paths[0]);
3303 assert!(
3304 paths[1].contains("nextToken=managed-page-2"),
3305 "the second request must carry the first page's token: {}",
3306 paths[1]
3307 );
3308 }
3309
3310 /// A bare base-image name is refused **before** the call, naming the ARN the service wants.
3311 ///
3312 /// Measured 2026-08-16: `ListManagedMicrovmImageVersions --image-identifier al2023-1`
3313 /// answers `ValidationException: Invalid ARN format: al2023-1`, which names the value
3314 /// without saying which member wanted an ARN or that `BaseImage::arn` produces one. The
3315 /// local refusal says both.
3316 ///
3317 /// **Falsification** — run 2026-08-16. Delete the `starts_with("arn:")` guard and the
3318 /// zero-call assertion goes red: the request goes out and the service answers the message
3319 /// above.
3320 #[tokio::test]
3321 async fn a_bare_managed_base_name_is_refused_before_the_call_naming_the_arn() {
3322 let (plane, fake, _) = planted();
3323 let error = plane
3324 .managed_base_versions("al2023-1")
3325 .await
3326 .expect_err("the service wants a full ARN");
3327 assert_eq!(error.kind(), ErrorKind::Precondition);
3328 let message = error.to_string();
3329 assert!(message.contains("Invalid ARN format"), "{message}");
3330 assert!(
3331 message.contains("arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"),
3332 "the remedy has to be the ARN itself: {message}"
3333 );
3334 assert!(message.contains("BaseImage::al2023().arn"), "{message}");
3335 assert_eq!(fake.calls().len(), 0, "nothing reached the control plane");
3336
3337 // And the ARN form does reach it.
3338 fake.answer(
3339 "ListManagedMicrovmImageVersions",
3340 Answer::ok(fake::list_managed_versions_page(&["1", "0"], None)),
3341 );
3342 let versions = plane
3343 .managed_base_versions(
3344 &super::super::BaseImage::al2023().arn(&crate::region::Region::UsEast1),
3345 )
3346 .await
3347 .expect("an ARN is accepted");
3348 assert_eq!(versions.len(), 2);
3349 }
3350
3351 /// `ListManagedMicrovmImages` reads every page too, and its items carry no registry
3352 /// reference — which is why they are informational only.
3353 ///
3354 /// The absence is asserted through [`super::super::BaseImage`]: a discovered base has an
3355 /// ARN and nothing that could fill `docker_ref` or `working_dir`, so
3356 /// `require_matching_from` and `require_workdir` would both have to be skipped for one.
3357 /// That is the comment in `ops::ManagedMicrovmImageSummaryWire`, made a test.
3358 #[tokio::test]
3359 async fn the_managed_image_listing_pages_and_its_items_cannot_build_a_base_image() {
3360 let (plane, fake, _) = planted();
3361 fake.answer(
3362 "ListManagedMicrovmImages",
3363 Answer::ok(fake::list_managed_images_page(
3364 &["al2023-1"],
3365 Some("managed-images-page-2"),
3366 )),
3367 )
3368 .answer(
3369 "ListManagedMicrovmImages",
3370 Answer::ok(fake::list_managed_images_page(&["al2024-1"], None)),
3371 );
3372
3373 let images = plane.managed_base_images().await.expect("lists");
3374 assert_eq!(images.len(), 2, "both pages were read");
3375 assert!(
3376 images[1].image_arn.ends_with("al2024-1"),
3377 "the page-two base is the one a caller checking for something new would want: {}",
3378 images[1].image_arn
3379 );
3380
3381 // No registry reference anywhere in what the service sent, so the pairing a
3382 // `BaseImage` needs is not derivable from a discovered base.
3383 let known = super::super::BaseImage::al2023();
3384 for image in &images {
3385 assert!(
3386 !image.image_arn.contains("public.ecr.aws"),
3387 "the ARN carries no registry ref: {}",
3388 image.image_arn
3389 );
3390 }
3391 assert_eq!(
3392 known.docker_ref, "public.ecr.aws/amazonlinux/amazonlinux:2023-minimal",
3393 "the paired ref is a compile-time constant precisely because discovery cannot \
3394 supply it"
3395 );
3396
3397 let paths = fake.paths();
3398 assert_eq!(paths[0], "/2025-09-09/managed-microvm-images");
3399 assert!(
3400 paths[1].contains("nextToken=managed-images-page-2"),
3401 "{}",
3402 paths[1]
3403 );
3404 }
3405
3406 /// **`CreateMicrovmImage.baseImageVersion` reaches the wire when pinned**, and is absent
3407 /// when it is not.
3408 ///
3409 /// Read off the emitted body rather than off the request struct, which is the assertion
3410 /// that matters: a field on `CreateImageRequest` proves nothing about what got sent, and
3411 /// this member had a field-shaped hole in the wire struct until now.
3412 ///
3413 /// **Falsification** — run 2026-08-16. Drop the assignment in `create_image` and the
3414 /// pinned assertion goes red with `baseImageVersion` absent from the body.
3415 #[tokio::test]
3416 async fn a_pinned_base_image_version_reaches_the_create_body() {
3417 let (plane, fake, _) = planted();
3418 fake.answer(
3419 "CreateMicrovmImage",
3420 Answer::created(fake::create_image_response("img")),
3421 );
3422 let mut request = a_request();
3423 request.base_image_version = Some("1".to_string());
3424 plane.create_image(request).await.expect("creates");
3425
3426 let body = fake.first_body("CreateMicrovmImage");
3427 assert_eq!(
3428 body["baseImageVersion"], "1",
3429 "the pinned base version has to reach the wire, or a build still floats: {body}"
3430 );
3431 // The ARN and the version are both sent: pinning does not replace the base.
3432 assert_eq!(
3433 body["baseImageArn"],
3434 "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
3435 );
3436
3437 // Unpinned emits nothing, so a caller who never touches the field sends what this
3438 // client always sent.
3439 let (plane, fake, _) = planted();
3440 fake.answer(
3441 "CreateMicrovmImage",
3442 Answer::created(fake::create_image_response("img")),
3443 );
3444 plane.create_image(a_request()).await.expect("creates");
3445 assert!(
3446 fake.first_body("CreateMicrovmImage")
3447 .get("baseImageVersion")
3448 .is_none(),
3449 "an absent member takes the service default; a blank one is refused"
3450 );
3451 }
3452
3453 /// An invalid `baseImageVersion` is refused **before** the artifact reaches the wire.
3454 ///
3455 /// The same ordering argument `require_valid_image_name` makes and the reason this guard is
3456 /// local: the create call happens after the caller's artifact upload, so the service's
3457 /// rejection costs them the upload first.
3458 ///
3459 /// **Falsification** — run 2026-08-16. Delete the `require_valid_version` call from
3460 /// `create_image` and the zero-call assertion goes red.
3461 #[tokio::test]
3462 async fn an_invalid_base_image_version_is_refused_before_any_call() {
3463 let (plane, fake, _) = planted();
3464 let mut request = a_request();
3465 request.base_image_version = Some("1 ".to_string());
3466
3467 let error = plane
3468 .create_image(request)
3469 .await
3470 .expect_err("the Version pattern forbids whitespace");
3471 assert_eq!(error.kind(), ErrorKind::InvalidArg);
3472 assert!(error.to_string().contains("baseImageVersion"), "{error}");
3473 assert_eq!(
3474 fake.calls().len(),
3475 0,
3476 "nothing reached the control plane, so the caller did not pay for the upload first"
3477 );
3478 }
3479
3480 /// The reuse hash derives the same Dockerfile a build of the request would, so the
3481 /// two agree — and a request whose Dockerfile differs hashes differently.
3482 #[test]
3483 fn the_reuse_hash_matches_the_build_paths_own_dockerfile_derivation() {
3484 let (plane, fake, _) = planted();
3485 let request = a_request();
3486 let hash = plane.artifact_content_hash_for(&request);
3487 assert_eq!(hash.len(), 64);
3488 assert_eq!(
3489 hash,
3490 plane.artifact_content_hash_for(&request),
3491 "deterministic"
3492 );
3493
3494 let mut custom = a_request();
3495 custom.dockerfile = Some(artifact::default_dockerfile(
3496 9000,
3497 Some("/workspace"),
3498 &custom.base_image,
3499 None,
3500 ));
3501 assert_ne!(
3502 hash,
3503 plane.artifact_content_hash_for(&custom),
3504 "a different Dockerfile is a different image identity"
3505 );
3506 assert_eq!(fake.calls().len(), 0, "hashing is local");
3507 }
3508
3509 // ── issue #24's guards on the create path, proved by the call count ──────
3510
3511 /// **Every constrained member of `CreateMicrovmImage` is refused with zero calls.**
3512 ///
3513 /// One table rather than five tests, because the assertion is identical for each and the
3514 /// thing worth reading is the *list*: these are the members issue #24 named as reachable with
3515 /// no guard, and the create call is the one that happens after the caller's artifact upload.
3516 /// A row here is a member whose rejection no longer costs an upload.
3517 ///
3518 /// The zero-call assertion is the load-bearing one. `expect_err` alone would pass for a
3519 /// request the *service* refused, which is what was happening before; `fake.calls().len() ==
3520 /// 0` is what says the refusal was local.
3521 ///
3522 /// **Guard proof** — run 2026-08-16, one row at a time. Delete
3523 /// `require_valid_role_arn("buildRoleArn", …)` from `create_image` and the buildRoleArn rows
3524 /// go red on the call count (the fake answers `CreateMicrovmImage`, so the launch *succeeds*
3525 /// and the count is 1 rather than 0). Same for `require_non_blank` and the two URI rows,
3526 /// `require_valid_tags` and the four tag rows, and `require_valid_port` and the port row.
3527 #[tokio::test]
3528 async fn every_constrained_create_member_is_refused_with_zero_control_plane_calls() {
3529 /// What to break, and the substring the message must carry.
3530 type Mutate = fn(&mut CreateImageRequest);
3531 let rows: [(&str, Mutate, &str); 12] = [
3532 // `buildRoleArn` — the member issue #24 called out, because its rejection lands
3533 // after the upload.
3534 //
3535 // Two bare-name rows on either side of the 20-character minimum, which is where the
3536 // guard's two messages divide: a short name gets the "you probably meant this ARN"
3537 // one, and a *long* bare name — which the account's real role names are, at 26
3538 // characters — falls through to the pattern message instead. Both are the same
3539 // mistake, so both are covered.
3540 (
3541 "a short build role name",
3542 |request| {
3543 request.build_role_arn = "build-role".to_string();
3544 },
3545 "role *name*",
3546 ),
3547 (
3548 "a long build role name, past the 20-character floor",
3549 |request| {
3550 request.build_role_arn = "bonk-sandbox-microvm-build".to_string();
3551 },
3552 "a role name passed as an ARN",
3553 ),
3554 (
3555 "a build role with eleven account digits",
3556 |request| {
3557 request.build_role_arn = "arn:aws:iam::12345678901:role/build".to_string();
3558 },
3559 "exactly twelve digits",
3560 ),
3561 (
3562 "a build role that is a function ARN",
3563 |request| {
3564 request.build_role_arn =
3565 "arn:aws:lambda:us-east-1:123456789012:function:handler".to_string();
3566 },
3567 "RoleArn pattern",
3568 ),
3569 (
3570 "an empty build role",
3571 |request| {
3572 request.build_role_arn = String::new();
3573 },
3574 "RoleArn minimum",
3575 ),
3576 // `codeArtifact.uri` — `NonBlankString`.
3577 (
3578 "a blank artifact URI",
3579 |request| {
3580 request.code_artifact_uri = String::new();
3581 },
3582 "codeArtifact.uri is empty",
3583 ),
3584 (
3585 "an artifact URI with a trailing newline",
3586 |request| {
3587 request.code_artifact_uri = "s3://bucket/agentd.zip\n".to_string();
3588 },
3589 "contains whitespace",
3590 ),
3591 // `baseImageArn` — derived from `BaseImage` and the region, and `BaseImage`'s fields
3592 // are `pub`. Whitespace rather than an empty name, and the difference is the point:
3593 // an *empty* name still renders `arn:aws:lambda:us-east-1:aws:microvm-image:`, which
3594 // is non-blank, so `require_non_blank`'s min-1 branch is structurally unreachable for
3595 // this member. The whitespace branch is the one that fires, and it fires on the case
3596 // that actually happens — a base image name with a space in it, from a config file or
3597 // a copy-paste.
3598 (
3599 "a base image name with a space in it",
3600 |request| {
3601 request.base_image.name = "al2023 1".to_string();
3602 },
3603 "baseImageArn",
3604 ),
3605 // `tags` — sent since tags existed, checked by nothing.
3606 (
3607 "an empty tag key",
3608 |request| {
3609 request
3610 .tags
3611 .insert(String::new(), "conformance".to_string());
3612 },
3613 "TagKey requires at least 1 character",
3614 ),
3615 (
3616 "a 129-character tag key",
3617 |request| {
3618 request.tags.insert("k".repeat(129), "v".to_string());
3619 },
3620 "over the TagKey ceiling",
3621 ),
3622 (
3623 "a tag key with a comma",
3624 |request| {
3625 request
3626 .tags
3627 .insert("cost,centre".to_string(), "v".to_string());
3628 },
3629 "outside the TagKey pattern",
3630 ),
3631 (
3632 "a 257-character tag value",
3633 |request| {
3634 request.tags.insert("owner".to_string(), "v".repeat(257));
3635 },
3636 "over the TagValue ceiling",
3637 ),
3638 ];
3639
3640 for (label, mutate, expected) in rows {
3641 let (plane, fake, _) = planted();
3642 // Answered on purpose: if the guard were missing, the call would *succeed* and the
3643 // count would be 1. A fake with nothing queued would fail the call for the wrong
3644 // reason and the test would pass while proving nothing.
3645 fake.answer(
3646 "CreateMicrovmImage",
3647 Answer::created(fake::create_image_response("agentd-conformance")),
3648 );
3649
3650 let mut request = a_request();
3651 mutate(&mut request);
3652 let error = plane
3653 .create_image(request)
3654 .await
3655 .expect_err(&format!("{label} must be refused"));
3656 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
3657 assert!(
3658 error.to_string().contains(expected),
3659 "{label}: the message must contain {expected:?}, got {error}"
3660 );
3661 assert_eq!(
3662 fake.calls().len(),
3663 0,
3664 "{label}: the request was refused locally, so the caller did not pay for the \
3665 artifact upload first — and the fake had an answer queued, so a missing guard \
3666 would show as calls: 1 rather than as a different failure"
3667 );
3668 }
3669
3670 // The control case, and it is not decoration: every row above would also pass if
3671 // `create_image` refused *everything*, and a guard that refuses every request is a
3672 // guard nobody notices is wrong until a build fails.
3673 let (plane, fake, _) = planted();
3674 fake.answer(
3675 "CreateMicrovmImage",
3676 Answer::created(fake::create_image_response("agentd-conformance")),
3677 );
3678 let mut good = a_request();
3679 good.tags
3680 .insert("cost centre".to_string(), "team/agents".to_string());
3681 good.tags.insert("empty".to_string(), String::new());
3682 good.build_role_arn =
3683 "arn:aws:iam::392583147479:role/bonk-sandbox-microvm-build".to_string();
3684 plane
3685 .create_image(good)
3686 .await
3687 .expect("a realistic request with a space in a tag key and an empty tag value");
3688 assert_eq!(fake.call_count("CreateMicrovmImage"), 1);
3689 }
3690
3691 /// The `hooks.port` bound is enforced at [`ControlPlane::with_port`] and **nowhere on the
3692 /// create path**, and that is a deliberate absence rather than a gap.
3693 ///
3694 /// `port` is a private field and `with_port` is its only setter, so a `ControlPlane` whose
3695 /// port is 0 cannot exist — [`ControlPlane::with_transport`] takes the default. A
3696 /// `require_valid_port("hooks.port", self.port)` inside `create_image` would therefore be a
3697 /// branch no input can reach, which is worse than no branch: it reads as a guard, it appears
3698 /// in a coverage report as a guard, and no test can make it fire. The discipline this repo
3699 /// holds is that a guard must be falsifiable, so the check lives at the one place the value
3700 /// can be wrong.
3701 ///
3702 /// What this test asserts is the property that makes the absence safe: the port on the plane
3703 /// is legal by construction, so the `hooks.port` that reaches the wire is too.
3704 ///
3705 /// **Guard proof.** Make `with_port` infallible again — drop the `require_valid_port` call and
3706 /// return `Self` — and the first assertion here goes red, as does
3707 /// `the_port_guard_refuses_zero_and_says_what_zero_means` in `control/mod.rs`.
3708 #[tokio::test]
3709 async fn the_hooks_port_that_reaches_the_wire_is_legal_by_construction() {
3710 let (plane, fake, _) = planted();
3711 assert!(
3712 plane.with_port(0).is_err(),
3713 "with_port is the only setter for the field hooks.port is read from, so refusing 0 \
3714 here is what makes a zero hooks.port unrepresentable"
3715 );
3716
3717 let (plane, fake2, _) = planted();
3718 let _ = fake;
3719 fake2.answer(
3720 "CreateMicrovmImage",
3721 Answer::created(fake::create_image_response("agentd-conformance")),
3722 );
3723 plane
3724 .with_port(9000)
3725 .expect("9000 is legal")
3726 .create_image(a_request())
3727 .await
3728 .expect("a legal port builds");
3729 assert_eq!(
3730 fake2.first_body("CreateMicrovmImage")["hooks"]["port"],
3731 9000,
3732 "and the port that was set is the port on the wire"
3733 );
3734 }
3735
3736 /// **The identifier guard on every image operation, proved by the call count.**
3737 ///
3738 /// Ten of the twelve identifier members are URI parameters, and an empty one does not fail as
3739 /// a blank field — it collapses the path onto the collection. So `GetMicrovmImage` with an
3740 /// empty identifier is a request against the *listing*, and `DeleteMicrovmImage` with one is
3741 /// a delete against it. Those are the requests this proves are never sent.
3742 ///
3743 /// Every operation in one test, because the assertion is the same and the value is the
3744 /// coverage: issue #24 counted six implemented operations per identifier shape, and this is
3745 /// the enumeration.
3746 ///
3747 /// **Guard proof** — run 2026-08-16. Delete `require_valid_identifier` from any one of these
3748 /// and its assertion goes red with `calls: 1` (or, for `wait_for_image`, a poll loop against
3749 /// the listing).
3750 #[tokio::test]
3751 async fn no_image_operation_sends_an_empty_or_over_long_identifier() {
3752 for bad in ["", &"a".repeat(257)] {
3753 let (plane, fake, _) = planted();
3754
3755 plane
3756 .get_image_version(bad, "1.0")
3757 .await
3758 .expect_err("GetMicrovmImageVersion");
3759 plane
3760 .get_image_build(bad, "1.0", "build-1")
3761 .await
3762 .expect_err("GetMicrovmImageBuild");
3763 plane
3764 .set_image_version_status(bad, "1.0", ops::VersionStatus::Inactive)
3765 .await
3766 .expect_err("UpdateMicrovmImageVersion");
3767 plane
3768 .list_image_versions(bad)
3769 .await
3770 .expect_err("ListMicrovmImageVersions");
3771 plane
3772 .list_image_builds(bad, "1.0")
3773 .await
3774 .expect_err("ListMicrovmImageBuilds");
3775 plane
3776 .wait_for_image(bad, SizeClass::DEFAULT, WaitOpts::default())
3777 .await
3778 .expect_err("GetMicrovmImage, in a loop");
3779 plane
3780 .managed_base_versions(bad)
3781 .await
3782 .expect_err("ListManagedMicrovmImageVersions");
3783 assert!(
3784 !plane.delete_image(bad, 20, Duration::from_secs(15)).await,
3785 "delete_image answers false rather than raising, because it is a teardown path"
3786 );
3787
3788 assert_eq!(
3789 fake.calls().len(),
3790 0,
3791 "eight operations refused {bad:?} locally; an empty URI parameter would have \
3792 addressed the collection instead of the resource, and the service can answer \
3793 200 to that"
3794 );
3795 }
3796 }
3797
3798 /// The `NonBlankString` URI and querystring members on the image operations, same proof.
3799 ///
3800 /// `nameFilter` is the one that is not a URI parameter, and it is the one worth having: it
3801 /// goes out as `?nameFilter=` and either 400s or filters differently from what was meant,
3802 /// and `find_image_by_name` is the resolver a launch depends on.
3803 ///
3804 /// **Guard proof.** Delete `require_non_blank("nameFilter", name)` and the `find_image_by_name`
3805 /// row goes red with `calls: 1` — a listing was read with a blank filter.
3806 #[tokio::test]
3807 async fn no_image_operation_sends_a_blank_version_build_id_or_name_filter() {
3808 for bad in ["", " ", "1.0\n", &"9".repeat(2049)] {
3809 let (plane, fake, _) = planted();
3810
3811 plane
3812 .get_image_version("arn:image", bad)
3813 .await
3814 .expect_err("imageVersion");
3815 plane
3816 .get_image_build("arn:image", bad, "build-1")
3817 .await
3818 .expect_err("imageVersion");
3819 plane
3820 .get_image_build("arn:image", "1.0", bad)
3821 .await
3822 .expect_err("buildId");
3823 plane
3824 .list_image_builds("arn:image", bad)
3825 .await
3826 .expect_err("imageVersion");
3827 plane.find_image_by_name(bad).await.expect_err("nameFilter");
3828
3829 assert_eq!(
3830 fake.calls().len(),
3831 0,
3832 "five NonBlankString members refused {bad:?} locally"
3833 );
3834 }
3835 }
3836}