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