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