Skip to main content

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