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