Skip to main content

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