microvms_core/control/ops.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The wire shapes, transcribed member-for-member from the pinned service model.
3//!
4//! # The honest-fake rule
5//!
6//! Every field name here is copied from `service-2.json` for API version
7//! [`crate::constants::MODEL_API_VERSION`], and every deserializer test in this file
8//! parses **literal JSON in the model's spelling** rather than the output of this
9//! module's own serializer. That is not a style preference; it is the fix for a specific
10//! bug that shipped.
11//!
12//! `MicrovmImageBuildSummary` carries a member called `buildState`. It has no member
13//! called `state`. The Python client read `b.get("state")`, which returned `None` from
14//! every real response — so the stall probe that is the only thing separating a wedged
15//! image from a slow build was **dead against live AWS while passing its unit test**,
16//! because the test's fake returned `{"state": "PENDING"}`. The fake shared the client's
17//! own misreading, so the two agreed with each other and with nothing else. Found
18//! 2026-08-07 by diffing against the service model, after a ~15-hour wedge that this
19//! guard existed to catch and did not.
20//!
21//! A round-trip test through this module's `Serialize` would have exactly the same
22//! blind spot. So the tests below hold JSON.
23//!
24//! # What is `Option` and what is not
25//!
26//! The model's `required` list, not a guess about what the service usually sends. A
27//! member the model marks required is a bare field; everything else is `Option`. Two
28//! consequences worth stating:
29//!
30//! * `stateReason` is optional everywhere it appears, which is why TRAP-8's error says
31//! "no stateReason" rather than printing an empty string — the absence is information.
32//! * `RunMicrovmRequest` requires only `imageIdentifier`. `executionRoleArn` is
33//! optional *on the wire* even though every real launch needs one.
34//!
35//! # Unknown members are dropped, deliberately
36//!
37//! No `#[serde(deny_unknown_fields)]` and no catch-all map. A member AWS adds to a
38//! response must not fail a deserialization — that would turn an additive service change
39//! into a broken client — and this crate has no consumer for a field it does not know
40//! about. The drift gate (TRAP-12) is where a new member gets noticed.
41
42use serde::{Deserialize, Serialize};
43
44// ── requests ────────────────────────────────────────────────────────────────
45
46/// `CreateMicrovmImageRequest`.
47///
48/// `clientToken` is `String` rather than `Option<String>` because this client always
49/// sends one and it is always minted here (TRAP-1) — see [`crate::control::token`].
50#[derive(Clone, Debug, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub struct CreateMicrovmImageWire {
53 pub name: String,
54 pub base_image_arn: String,
55 /// `Version`, absent unless the caller pinned one.
56 ///
57 /// Omitted rather than sent empty when nobody pinned, because that is the difference
58 /// between "whatever the service defaults to" and a `ValidationException` on a blank
59 /// `Version` (min 1, pattern `[^\s]+`). The default has already moved once — the managed
60 /// base carries versions `"0"` and `"1"` (docs/PLATFORM.md, 'The managed base image has
61 /// two versions') — so an unpinned build is not reproducible, which is what this member
62 /// exists to fix.
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub base_image_version: Option<String>,
65 pub build_role_arn: String,
66 pub code_artifact: CodeArtifact,
67 pub cpu_configurations: Vec<CpuConfiguration>,
68 pub resources: Vec<Resources>,
69 pub hooks: Hooks,
70 /// `CapabilityList`. Omitted entirely rather than sent empty when identity repair
71 /// was not asked for: the enum's only value is `ALL`, and an empty list is a
72 /// different request from an absent member.
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub additional_os_capabilities: Option<Vec<String>>,
75 /// `Logging`, absent unless the caller configured build logging.
76 ///
77 /// Omitted rather than sent as `null` or `{}` when nothing was configured: an absent
78 /// member takes the service default (a service-created log group under
79 /// `/aws/lambda-microvms/<image-name>` with random stream names), and the union's own
80 /// documentation says to specify exactly one member — an empty object is a malformed
81 /// union, not a default.
82 ///
83 /// The `logStream` this carries is never a caller's value verbatim; see
84 /// [`CloudWatchLogging::log_stream`] and the discriminator rule in
85 /// [`crate::control::ControlPlane::create_image`].
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub logging: Option<Logging>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub tags: Option<std::collections::BTreeMap<String, String>>,
90 /// The minted idempotency token.
91 ///
92 /// `pub(crate)` while every sibling field is `pub`, and that asymmetry is TRAP-1's last
93 /// mile. `CreateImageRequest` has no token field, but this type is the wire shape behind
94 /// it — and a `pub` field here would let a caller outside this module build the request
95 /// body directly with a token of their choosing, which is the wedge with one extra step.
96 /// Narrowing the visibility means the only way to populate it is
97 /// [`crate::control::token::create_token`], from inside the crate.
98 pub(crate) client_token: String,
99}
100
101/// `CodeArtifact`, a union whose only member this client uses is `uri`.
102#[derive(Clone, Debug, Deserialize, Serialize)]
103#[serde(rename_all = "camelCase")]
104pub struct CodeArtifact {
105 pub uri: String,
106}
107
108/// `Logging`, the model's tagged union: exactly one of `disabled` or `cloudWatch`.
109///
110/// The same wire shape as [`PortSpecification`] and modelled the same way: a Smithy union
111/// serialises as **one key** — `{"disabled": {}}` or `{"cloudWatch": {...}}` — not a
112/// discriminator field, so `#[serde(untagged)]` plus a `rename` per variant is what keeps
113/// serde's default `{"Disabled": ...}` spelling off the wire.
114///
115/// Both `CreateMicrovmImageRequest` and `RunMicrovmRequest` carry a `logging` member of
116/// this shape in the model; this client binds only the image-build side (issue #98 — build
117/// logs are where the three-VM/three-stream topology and the exact-name collision live).
118#[derive(Clone, Debug, PartialEq, Serialize)]
119#[serde(untagged)]
120pub enum Logging {
121 /// `LoggingDisabled`: no logs at all. An empty struct rather than a unit variant for
122 /// [`AllPorts`]'s reason — the wire form is `{}` and a unit serialises to `null`,
123 /// which the service rejects as a malformed union member.
124 Disabled {
125 #[serde(rename = "disabled")]
126 disabled: LoggingDisabled,
127 },
128 /// `CloudWatchLogging`: a caller-named group and, optionally, stream.
129 CloudWatch {
130 #[serde(rename = "cloudWatch")]
131 cloud_watch: CloudWatchLogging,
132 },
133}
134
135impl Logging {
136 /// Logging off.
137 pub fn disabled() -> Self {
138 Self::Disabled {
139 disabled: LoggingDisabled {},
140 }
141 }
142
143 /// Logs to `log_group`, with the service naming the streams inside it.
144 pub fn cloud_watch(log_group: impl Into<String>, log_stream: Option<String>) -> Self {
145 Self::CloudWatch {
146 cloud_watch: CloudWatchLogging {
147 log_group: Some(log_group.into()),
148 log_stream,
149 },
150 }
151 }
152
153 /// The resolved `logStream` this configuration carries, when it carries one.
154 pub fn log_stream(&self) -> Option<&str> {
155 match self {
156 Logging::Disabled { .. } => None,
157 Logging::CloudWatch { cloud_watch } => cloud_watch.log_stream.as_deref(),
158 }
159 }
160}
161
162/// `LoggingDisabled`, which the model declares with no members. See [`AllPorts`] for why
163/// this is an empty struct rather than a unit one.
164#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
165pub struct LoggingDisabled {}
166
167/// `CloudWatchLogging`: the group and stream names, both optional in the model.
168///
169/// # `logStream` is an EXACT stream name, and the client never sends a caller's verbatim
170///
171/// Measured 2026-08 (docs/PLATFORM.md, 'An image build is three VMs and three log
172/// streams'): the member names one stream exactly — prefixes are unsupported — and one
173/// image build runs three VMs that each want their own stream, so a fixed configured name
174/// collapses all three (and every successive build's three) into one indistinguishable
175/// stream. [`crate::control::ControlPlane::create_image`] therefore appends `/<16 hex>`
176/// of fresh CSPRNG per create attempt before this struct reaches the wire, the same
177/// mechanism as the `clientToken` nonce (TRAP-1).
178#[derive(Clone, Debug, PartialEq, Serialize)]
179#[serde(rename_all = "camelCase")]
180pub struct CloudWatchLogging {
181 /// `CloudWatchLoggingLogGroupString`: 1..=512, pattern `[a-zA-Z0-9_\-/.#]+`.
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub log_group: Option<String>,
184 /// `CloudWatchLoggingLogStreamString`: 1..=512, pattern `[^:*]*`. Always carries the
185 /// per-build discriminator by the time it serialises — see the type docs.
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub log_stream: Option<String>,
188}
189
190/// `CpuConfiguration`. One required member, whose enum has one value (`ARM_64`).
191#[derive(Clone, Debug, Deserialize, Serialize)]
192#[serde(rename_all = "camelCase")]
193pub struct CpuConfiguration {
194 pub architecture: String,
195}
196
197/// `Resources` — note the plural. The model's `ResourcesList` member shape is
198/// `Resources`, not `Resource`; there is no shape called `Resource` at all.
199///
200/// `minimumMemoryInMiB` selects a size class, it does not size the VM (TRAP-10, and
201/// `crate::sizing`). The member name's `MiB` capitalisation is the model's.
202#[derive(Clone, Debug, Deserialize, Serialize)]
203pub struct Resources {
204 #[serde(rename = "minimumMemoryInMiB")]
205 pub minimum_memory_in_mib: u32,
206}
207
208/// `Hooks`.
209#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
210#[serde(rename_all = "camelCase")]
211pub struct Hooks {
212 /// `HooksPortInteger`, `min: 1`. Checked by [`crate::control::require_valid_port`] before the
213 /// block is built — a `hooks.port` of 0 is an image with no reachable hook endpoint.
214 pub port: u16,
215 pub microvm_hooks: MicrovmHooks,
216 pub microvm_image_hooks: MicrovmImageHooks,
217}
218
219/// `HookState`, the model's two values.
220///
221/// # A typed enum rather than a validated `String`, and why this one and not the response states
222///
223/// This is the same trade [`VersionStatus`] makes and the reasoning is the same shape. A
224/// response enum must stay a `String` — a third value AWS adds has to parse rather than fail,
225/// which is what [`MicrovmImageBuildSummaryWire::architecture`] documents. `HookState` on the
226/// request side is only ever **serialized**, so narrowing it costs nothing and buys the thing a
227/// two-value enum buys: `"ENABLE"`, `"Enabled"`, and `"ACTIVE"` become compile errors instead of
228/// a `ValidationException`.
229///
230/// It is worth more here than on any other member, because it appears **six times** on one
231/// request. Issue #24 measured what was there before: six `String` fields with `"ENABLED"`
232/// hardcoded as a `const &str` local to `hooks_block`, so no constant named either value and a
233/// typo in one of the six was a rejected `CreateMicrovmImage` — after the artifact upload.
234///
235/// # `Deserialize` too, unlike [`VersionStatus`]
236///
237/// Because a `MicrovmHooks` block is a **request** shape in this module and the tests parse
238/// model-spelled JSON into it (the honest-fake rule). Deriving `Deserialize` on a two-variant
239/// enum means an unknown state fails that parse, which is correct for a shape nothing reads off
240/// a real response — and `MicrovmImageVersionSummaryWire` deliberately does not read a hooks
241/// block at all, for measured reasons that type records.
242#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
243pub enum HookState {
244 /// The platform does not call this hook.
245 #[serde(rename = "DISABLED")]
246 Disabled,
247 /// The platform calls this hook and waits for it. What this client always sends.
248 #[serde(rename = "ENABLED")]
249 Enabled,
250}
251
252impl HookState {
253 /// The wire spelling, which is also what a diagnostic prints.
254 pub fn as_str(self) -> &'static str {
255 match self {
256 HookState::Disabled => "DISABLED",
257 HookState::Enabled => "ENABLED",
258 }
259 }
260}
261
262impl std::fmt::Display for HookState {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 f.write_str(self.as_str())
265 }
266}
267
268/// `MicrovmHooks` — the family that caps at 60 seconds.
269///
270/// The state members are [`HookState`], so neither value can be misspelled at a call site; the
271/// timeouts are built from [`crate::hooks::RunHookTimeout`] before they get here, which is where
272/// the ceiling is enforced.
273#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
274#[serde(rename_all = "camelCase")]
275pub struct MicrovmHooks {
276 pub run: HookState,
277 pub run_timeout_in_seconds: u32,
278 pub resume: HookState,
279 pub resume_timeout_in_seconds: u32,
280 pub suspend: HookState,
281 pub suspend_timeout_in_seconds: u32,
282 pub terminate: HookState,
283 pub terminate_timeout_in_seconds: u32,
284}
285
286/// `MicrovmImageHooks` — the build family, capping at 3600 seconds.
287#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
288#[serde(rename_all = "camelCase")]
289pub struct MicrovmImageHooks {
290 pub ready: HookState,
291 pub ready_timeout_in_seconds: u32,
292 pub validate: HookState,
293 pub validate_timeout_in_seconds: u32,
294}
295
296/// `RunMicrovmRequest`.
297///
298/// `runHookPayload` is the only per-VM secret channel the platform offers and is checked
299/// against the 4096-byte ceiling before this struct is built (TRAP-5).
300#[derive(Clone, Debug, Serialize)]
301#[serde(rename_all = "camelCase")]
302pub struct RunMicrovmWire {
303 pub image_identifier: String,
304 /// `Version`, absent unless the caller pinned one.
305 ///
306 /// Absent means "whatever the image's `latestActiveImageVersion` is", which is what this
307 /// client always sent. Pinning it is the launch half of the blue/green story: a canary
308 /// launches against exactly the version it means to test, and a rollback re-pins to the
309 /// previous one rather than hoping the image's notion of latest has moved back.
310 ///
311 /// A version set INACTIVE by `UpdateMicrovmImageVersion` refuses to launch when pinned
312 /// here — measured, see [`crate::control::ControlPlane::set_image_version_status`].
313 #[serde(skip_serializing_if = "Option::is_none")]
314 pub image_version: Option<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 pub execution_role_arn: Option<String>,
317 /// Derived ARNs, never caller strings (TRAP-4).
318 pub ingress_network_connectors: Vec<String>,
319 /// Absent rather than empty when egress was not asked for. Omitting it is the request
320 /// the docs call sealed; measured 2026-09-12 the platform still gave the VM outbound
321 /// network (`docs/PLATFORM.md`), which the live suite pins.
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub egress_network_connectors: Option<Vec<String>>,
324 pub idle_policy: IdlePolicy,
325 pub maximum_duration_in_seconds: u32,
326 pub run_hook_payload: String,
327 /// The minted idempotency token. `pub(crate)` for the reason
328 /// [`CreateMicrovmImageWire::client_token`] gives.
329 pub(crate) client_token: String,
330}
331
332/// `IdlePolicy`. All three members are required by the model, in **both** directions.
333///
334/// # `suspendedDurationSeconds` comes back, measured
335///
336/// This comment used to say the member "exists only in the request". That was wrong twice
337/// over. The model has one `IdlePolicy` shape, used by `RunMicrovmRequest`,
338/// `RunMicrovmResponse`, and `GetMicrovmResponse` alike, and it marks all three members
339/// required — so the model says the opposite. And the service agrees with the model:
340/// `GetMicrovm` on a RUNNING VM answers
341/// `"idlePolicy": {"maxIdleDurationSeconds": 1800, "suspendedDurationSeconds": 600,
342/// "autoResumeEnabled": false}` (measured 2026-08-15, us-east-1, one read-only
343/// `GetMicrovm`).
344///
345/// The claim mattered because STATE-12 was resting on it: if only the client could name the
346/// suspended window, the client's own record is the only authority on it. The readback
347/// means there is a second one, and [`crate::control::Microvm::idle_policy`] carries it —
348/// so a suspended window that disagrees with what was asked for is now observable rather
349/// than assumed.
350#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
351#[serde(rename_all = "camelCase")]
352pub struct IdlePolicy {
353 /// `min: 60`, checked by [`crate::control::require_idle_duration`] before the launch.
354 ///
355 /// The comment here used to say botocore enforced this one locally, so no guard was needed.
356 /// Botocore does; this client does not use it. See that function for the whole account.
357 pub max_idle_duration_seconds: u32,
358 pub suspended_duration_seconds: u32,
359 pub auto_resume_enabled: bool,
360}
361
362/// `UpdateMicrovmImageVersionRequest`'s **one** body member.
363///
364/// `imageIdentifier` and `imageVersion` are both URI parameters, so the body is a single
365/// key. That is the whole request, and it is the model's only non-destructive retire
366/// primitive: setting a version `INACTIVE` makes `RunMicrovm` refuse to launch it while
367/// existing VMs keep running and the version stays readable through
368/// `GetMicrovmImageVersion`. `DeleteMicrovmImageVersion` is the alternative and it is
369/// irreversible.
370///
371/// `status` is a [`VersionStatus`] rather than a `String`, unlike the `HookState` members
372/// above. The difference is that this enum is the *entire* request: a typo in it is a
373/// `ValidationException` on the only field there is, spent on a call that was going to
374/// change a version's availability. `MicrovmImageVersionStatus` has exactly two values and
375/// naming them in the type means a caller cannot spell either wrong.
376#[derive(Clone, Debug, Serialize)]
377#[serde(rename_all = "camelCase")]
378pub struct UpdateImageVersionWire {
379 pub status: VersionStatus,
380}
381
382/// `MicrovmImageVersionStatus`, the model's two values.
383///
384/// # Why this one is an enum where `HookState` is a bare `String`
385///
386/// A response is not a request (see [`MicrovmImageBuildSummaryWire::architecture`]): a value
387/// AWS adds to a *response* enum must parse rather than fail, which is why the state fields
388/// on every wire struct in this module are `String`. This type is only ever **serialized** —
389/// the readback carries `status` as a `String` for exactly that reason — so narrowing it
390/// costs nothing and buys the one thing a two-value enum can buy: `"INACTIVATE"`,
391/// `"Inactive"`, and `"DISABLED"` are all compile errors rather than a rejected call.
392///
393/// The `Display`/`as_str` spelling is the model's own casing, asserted against a literal in
394/// the tests below rather than derived from the variant name.
395#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
396pub enum VersionStatus {
397 /// `RunMicrovm` may launch this version.
398 #[serde(rename = "ACTIVE")]
399 Active,
400 /// `RunMicrovm` refuses to launch this version. Running VMs are untouched.
401 #[serde(rename = "INACTIVE")]
402 Inactive,
403}
404
405impl VersionStatus {
406 /// The wire spelling, which is also what a diagnostic prints.
407 pub fn as_str(self) -> &'static str {
408 match self {
409 VersionStatus::Active => "ACTIVE",
410 VersionStatus::Inactive => "INACTIVE",
411 }
412 }
413}
414
415impl std::fmt::Display for VersionStatus {
416 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417 f.write_str(self.as_str())
418 }
419}
420
421/// `CreateMicrovmAuthTokenRequest`'s body members. `microvmIdentifier` is a URI
422/// parameter and so is not in the body.
423#[derive(Clone, Debug, Serialize)]
424#[serde(rename_all = "camelCase")]
425pub struct CreateAuthTokenWire {
426 /// Maximum 60 minutes, the service ceiling. TRAP-9 refreshes below it.
427 pub expiration_in_minutes: u32,
428 pub allowed_ports: Vec<PortSpecification>,
429}
430
431/// `CreateMicrovmShellAuthTokenRequest`'s body members. `microvmIdentifier` is a URI
432/// parameter and so is not in the body.
433///
434/// # There is no `allowedPorts` here, and its absence is the model's
435///
436/// The request declares exactly two members and one of them is in the URI. The shell is
437/// not a port: the token it mints opens the endpoint's single PTY session, not a route to
438/// a listener, which is why nothing in this shape can name a port (`docs/PLATFORM.md`,
439/// measured 2026-08-15).
440#[derive(Clone, Debug, Serialize)]
441#[serde(rename_all = "camelCase")]
442pub struct CreateShellAuthTokenWire {
443 /// Maximum 60 minutes, the same service ceiling the ordinary proxy token has —
444 /// the two JWEs carry the identical protected header and differ only by payload.
445 pub expiration_in_minutes: u32,
446}
447
448/// `PortSpecification`, the model's tagged union: one port, a range, or all of them.
449///
450/// A union rather than the single-field struct this was, and the change is a defect fix
451/// rather than completeness for its own sake. `Session::connect_headers(port)` and
452/// `connect_subprotocols(port)` exist to reach *another* port on the same VM, and they were
453/// built over a token the control plane had minted with `allowedPorts: [{port: 9000}]` — so
454/// they returned a correct-looking port header behind a credential that did not authorize
455/// it. Measured 2026-08-15 on one VM with a listener on 8080, varying only the mint:
456///
457/// | `allowedPorts` | `GET :8080` through the endpoint |
458/// | --- | --- |
459/// | `[{port: 9000}]` | **403 `Access to port denied`** |
460/// | `[{port: 9000}, {port: 8080}]` | 200, the guest answered |
461/// | `[{allPorts: {}}]` | 200 |
462/// | `[{range: {startPort: 8000, endPort: 9100}}]` | 200 |
463///
464/// One variable, four outcomes, so the token's scope is the whole of it. On the WebSocket
465/// path the same rejection is close code 1006 with no reason, which is why this was
466/// invisible to a client-side test: the strings were right and the credential behind them
467/// was not.
468///
469/// `#[serde(untagged)]` because the wire form is the *member name* as the key —
470/// `{"port": 9000}`, `{"range": {..}}`, `{"allPorts": {}}` — not a discriminator field, and
471/// serde's default enum representation would emit `{"Port": 9000}`. Each variant names its
472/// own key through `rename`.
473#[derive(Clone, Copy, Debug, Serialize)]
474#[serde(untagged)]
475pub enum PortSpecification {
476 /// Exactly one port. What a session built for the agent port asks for.
477 One {
478 #[serde(rename = "port")]
479 port: u16,
480 },
481 /// An inclusive range. Both members are required by the model.
482 Range {
483 #[serde(rename = "range")]
484 range: PortRange,
485 },
486 /// Every port on the VM.
487 ///
488 /// Deliberately **not** what this client mints by default. A token good for every port
489 /// is one whose leak is worth more, and the ports a caller actually needs are knowable:
490 /// the agent port plus whatever it asked for. Present because the model has it and a
491 /// caller building a general proxy needs it, reached only through an explicit scope.
492 All {
493 #[serde(rename = "allPorts")]
494 all_ports: AllPorts,
495 },
496}
497
498impl PortSpecification {
499 /// One port.
500 pub fn port(port: u16) -> Self {
501 Self::One { port }
502 }
503
504 /// An inclusive range of ports.
505 pub fn range(start: u16, end: u16) -> Self {
506 Self::Range {
507 range: PortRange {
508 start_port: start,
509 end_port: end,
510 },
511 }
512 }
513
514 /// Every port. See [`PortSpecification::All`] for why this is not a default.
515 pub fn all() -> Self {
516 Self::All {
517 all_ports: AllPorts {},
518 }
519 }
520}
521
522/// `PortRange`. Inclusive at both ends, per the model's `min: 1, max: 65535` on each.
523#[derive(Clone, Copy, Debug, Serialize)]
524#[serde(rename_all = "camelCase")]
525pub struct PortRange {
526 pub start_port: u16,
527 pub end_port: u16,
528}
529
530/// `AllPorts`, which the model declares with no members.
531///
532/// An empty struct rather than a unit one, because the wire form is `{}` and a unit struct
533/// serialises to `null` — which the service rejects as a malformed union member.
534#[derive(Clone, Copy, Debug, Serialize)]
535pub struct AllPorts {}
536
537// ── responses ───────────────────────────────────────────────────────────────
538
539/// `CreateMicrovmImageResponse`, narrowed to the members this client reads.
540///
541/// The model marks eight members required; the four below are the ones anything
542/// downstream uses. Deserializing a subset is safe — serde ignores the rest — and
543/// listing all twenty would be twenty more chances to misspell one.
544#[derive(Clone, Debug, Deserialize)]
545#[serde(rename_all = "camelCase")]
546pub struct CreateMicrovmImageResponseWire {
547 pub image_arn: String,
548 pub name: String,
549 pub state: String,
550 pub image_version: String,
551}
552
553/// `GetMicrovmImageOutput`.
554///
555/// `tags` is here rather than left to a `ListTags` this client does not implement: the
556/// service already sends the map on every `GetMicrovmImage`, so reading it costs nothing
557/// and a separate operation would cost a call. `createdAt` is one of the model's four
558/// required members and was the only one missing.
559#[derive(Clone, Debug, Deserialize)]
560#[serde(rename_all = "camelCase")]
561pub struct GetMicrovmImageResponseWire {
562 pub image_arn: String,
563 pub name: String,
564 pub state: String,
565 pub latest_active_image_version: Option<String>,
566 pub latest_failed_image_version: Option<String>,
567 /// Required by the model. Epoch seconds, possibly fractional.
568 pub created_at: f64,
569 /// Optional: the model marks only `createdAt` required, so an image never updated
570 /// carries no `updatedAt` and the absence is not a parse failure.
571 pub updated_at: Option<f64>,
572 /// `Tags`, absent rather than empty when the image carries none — measured 2026-08-15
573 /// as `"tags": {}` on a real `GetMicrovmImage`, but `Option` because the model does not
574 /// require the member and an absent map must not fail the parse.
575 pub tags: Option<std::collections::BTreeMap<String, String>>,
576}
577
578/// `ListMicrovmImageVersionsOutput`.
579#[derive(Clone, Debug, Deserialize)]
580#[serde(rename_all = "camelCase")]
581pub struct ListImageVersionsResponseWire {
582 pub items: Vec<MicrovmImageVersionSummaryWire>,
583 pub next_token: Option<String>,
584}
585
586/// `MicrovmImageVersionSummary`, which is **also** `GetMicrovmImageVersionOutput` and
587/// `UpdateMicrovmImageVersionResponse` — the three shapes are member-for-member identical
588/// in the model, so one type serves all of them.
589///
590/// One type rather than three for the reason [`MicrovmResponseWire`] gives: a second copy
591/// is a second place to keep twenty member spellings right, and the model itself declares
592/// them as one set. Checked member-by-member against `service-2.json` on 2026-08-16 —
593/// `GetMicrovmImageVersionOutput`, `UpdateMicrovmImageVersionResponse`, and
594/// `MicrovmImageVersionSummary` list the same twenty members with the same eight required.
595///
596/// Note `state` here **is** the member's name — unlike the build summary below, and that
597/// asymmetry is the whole trap.
598///
599/// # All eight required members, not the four this crate reads
600///
601/// The same argument [`MicrovmImageBuildSummaryWire`] records: the model marking a member
602/// required is the strongest promise it makes about a response, and a required member absent
603/// from the struct is one no drift check can notice going missing. `status` in particular was
604/// `Option<String>` while the model requires it — so a service that stopped sending the
605/// availability status would have read as `None`, which is exactly how a version blocked from
606/// launching would look identical to one nobody had ever set. Confirmed present on all 22
607/// versions in the conformance account (2026-08-16), so requiring it is a measurement rather
608/// than a reading of the model alone.
609///
610/// # `hooks` is deliberately **not** here, and the reason is a measurement
611///
612/// The model marks every member of `Hooks`, `MicrovmHooks`, and `MicrovmImageHooks` optional,
613/// while this module's request-side [`Hooks`] declares them all as bare fields — correct for
614/// a request, since this client always sends all six. A real response is not so generous:
615/// `GetMicrovmImageVersion` on an image built by another tool answered
616/// `"microvmHooks": {"run": "ENABLED", "runTimeoutInSeconds": 30}` and nothing else
617/// (measured 2026-08-16, us-east-1, `omnigent-host-vpc` version 3.0), so five of
618/// `MicrovmHooks`'s eight members were absent. Reusing [`Hooks`] here would fail that parse
619/// and turn a readable version into a client error. A response-shaped hooks type is the fix
620/// if a caller ever needs it; until then the absence is the honest option, and the test
621/// `a_versions_hooks_block_is_not_read_because_a_real_one_omits_members` pins the reason.
622#[derive(Clone, Debug, Deserialize)]
623#[serde(rename_all = "camelCase")]
624pub struct MicrovmImageVersionSummaryWire {
625 // ── the model's eight required members ────────────────────────────────────
626 pub base_image_arn: String,
627 pub build_role_arn: String,
628 pub code_artifact: CodeArtifact,
629 pub image_arn: String,
630 pub image_version: String,
631 pub state: String,
632 /// `MicrovmImageVersionStatus`: `ACTIVE` or `INACTIVE`.
633 ///
634 /// A `String` rather than a [`VersionStatus`], because this is a response and a third
635 /// value AWS adds must parse rather than fail — the same rule
636 /// [`MicrovmImageBuildSummaryWire::architecture`] follows. [`VersionStatus`] is for the
637 /// request, where narrowing turns a typo into a compile error.
638 pub status: String,
639 /// Epoch seconds, possibly fractional.
640 pub created_at: f64,
641
642 // ── the optional ones, which are the config readback ─────────────────────
643 /// The base version this version was pinned to, when one was.
644 ///
645 /// The readback for `CreateMicrovmImage.baseImageVersion`, and the reason pinning is
646 /// worth doing: an unpinned build still reports one here, and the value the service
647 /// echoes is spelled differently from anything the base's own listing offers
648 /// (`"1.0"` against the managed base's `"0"`/`"1"` — docs/PLATFORM.md). So this is the
649 /// record of what was built, not a value to feed back into a request.
650 pub base_image_version: Option<String>,
651 pub description: Option<String>,
652 /// `ResourcesList`, max 1. The only place a built image's size class is observable:
653 /// `GetMicrovm` carries no memory figure at all.
654 pub resources: Option<Vec<Resources>>,
655 pub cpu_configurations: Option<Vec<CpuConfiguration>>,
656 /// The image-level egress list. **Max 1** in the model, not the 10 the VM-level
657 /// `NetworkConnectorList` allows — see [`crate::constants::MAX_IMAGE_EGRESS_CONNECTORS`].
658 pub egress_network_connectors: Option<Vec<String>>,
659 pub additional_os_capabilities: Option<Vec<String>>,
660 pub environment_variables: Option<std::collections::BTreeMap<String, String>>,
661 pub updated_at: Option<f64>,
662 /// The version-level failure reason. Measured **null** on real failures while the
663 /// build-level one is populated — docs/PLATFORM.md, "A failed build's `stateReason`
664 /// lives on the build".
665 pub state_reason: Option<String>,
666 pub tags: Option<std::collections::BTreeMap<String, String>>,
667}
668
669impl MicrovmImageVersionSummaryWire {
670 /// Whether `RunMicrovm` will launch this version.
671 ///
672 /// A comparison against the model's spelling through [`VersionStatus`] rather than
673 /// against a literal here, so the two cannot drift: the request type and the readback
674 /// agree on what `"ACTIVE"` is spelled like or neither compiles.
675 pub fn is_active(&self) -> bool {
676 self.status == VersionStatus::Active.as_str()
677 }
678
679 /// The version, its state, its availability status, and the reason when the service gave
680 /// one — the line a retire or a rollback prints.
681 ///
682 /// An absent reason renders nothing rather than an empty parenthesis, for the reason
683 /// [`MicrovmImageBuildSummaryWire::describe`] gives.
684 pub fn describe(&self) -> String {
685 match self.state_reason.as_deref() {
686 Some(reason) => format!(
687 "{} {} / {} ({reason})",
688 self.image_version, self.state, self.status
689 ),
690 None => format!("{} {} / {}", self.image_version, self.state, self.status),
691 }
692 }
693}
694
695/// `ListMicrovmImageBuildsOutput`.
696#[derive(Clone, Debug, Deserialize)]
697#[serde(rename_all = "camelCase")]
698pub struct ListImageBuildsResponseWire {
699 pub items: Vec<MicrovmImageBuildSummaryWire>,
700 pub next_token: Option<String>,
701}
702
703/// `MicrovmImageBuildSummary`.
704///
705/// **`buildState`, not `state`.** The model has no `state` member on this shape. A
706/// `#[serde(rename)]` to `state` here, or a field named `state` relying on
707/// `rename_all`, reproduces the bug described in this module's docs: the stall probe
708/// reads `None` from every real response and TRAP-2 becomes unfalsifiable. The
709/// deserializer test for this shape uses literal model-spelled JSON for that reason,
710/// and asserts that `{"state": ...}` **fails** to deserialize.
711/// Every member the model marks required is a bare field here, plus the optional
712/// `stateReason`. All eight required ones rather than the four this crate reads today,
713/// because the model requiring a member is the strongest promise it makes about a
714/// response, and a required member absent from the struct is a member no drift check can
715/// notice going missing.
716#[derive(Clone, Debug, Deserialize)]
717#[serde(rename_all = "camelCase")]
718pub struct MicrovmImageBuildSummaryWire {
719 pub image_arn: String,
720 pub image_version: String,
721 pub build_id: String,
722 pub build_state: String,
723 /// `ARM_64`, the enum's only value — carried as `String` rather than narrowed, because
724 /// a response is not a request and a second value AWS adds must parse rather than fail.
725 pub architecture: String,
726 pub chipset: String,
727 pub chipset_generation: String,
728 /// A `Timestamp`, which rest-json sends as epoch seconds — possibly fractional, hence
729 /// `f64` rather than `i64`.
730 pub created_at: f64,
731 pub state_reason: Option<String>,
732}
733
734impl MicrovmImageBuildSummaryWire {
735 /// The build, as a stall diagnosis wants to read it: the state, the build id, and the
736 /// reason when the service gave one.
737 ///
738 /// The `buildId` is in here because it is the identifier `GetMicrovmImageBuild` takes,
739 /// so a reader handed a wedge verdict can go ask about a named build rather than about
740 /// "one of the builds". The reason is included because the model documents it as "the
741 /// reason for the build state, **if applicable**" — so it is populated on states other
742 /// than a failure, and a probe that dropped it would be discarding the service's own
743 /// account of why nothing was scheduled.
744 ///
745 /// An absent reason renders nothing at all rather than an empty parenthesis, for the
746 /// reason [`MicrovmResponseWire::state_reason`] gives: "the service said nothing" and
747 /// "the service said nothing useful" are different diagnoses.
748 pub fn describe(&self) -> String {
749 match self.state_reason.as_deref() {
750 Some(reason) => format!("{} {} ({reason})", self.build_id, self.build_state),
751 None => format!("{} {}", self.build_id, self.build_state),
752 }
753 }
754}
755
756/// `GetMicrovmImageBuildOutput`, which is `MicrovmImageBuildSummary` plus one member.
757///
758/// # Why this is not the same type as the summary
759///
760/// It differs by exactly one member — `snapshotBuild` — and that member is the whole reason
761/// to make the call: it is the **only** place any API reports a size for anything this
762/// platform builds. Nothing on `GetMicrovmImage`, `GetMicrovm`, or either listing carries a
763/// byte count, and the three figures here are the quantities the snapshot read, write, and
764/// storage line items bill on (`crate::cost`). A separate type rather than an `Option` field
765/// on the summary, because the summary is a *listing* shape that never carries one, and an
766/// `Option` there would read as "the service sometimes omits the sizes" rather than "the
767/// listing does not have this member".
768///
769/// Every other member is required and identical to the summary's, so the same eight are bare
770/// fields here.
771#[derive(Clone, Debug, Deserialize)]
772#[serde(rename_all = "camelCase")]
773pub struct GetImageBuildResponseWire {
774 pub image_arn: String,
775 pub image_version: String,
776 pub build_id: String,
777 pub build_state: String,
778 pub architecture: String,
779 pub chipset: String,
780 /// The Graviton generation this build targeted. One `CreateMicrovmImage` fans out into
781 /// one build per generation, so a version's builds differ only in this member — and a
782 /// partially failed image, one generation succeeding and the other not, is a state a
783 /// caller should expect (docs/PLATFORM.md).
784 pub chipset_generation: String,
785 pub created_at: f64,
786 pub state_reason: Option<String>,
787 /// The three snapshot sizes, when the service reports them.
788 ///
789 /// `Option` because the model does not mark it required, and because a **failed** build
790 /// answers a partial one: measured 2026-08-16 on a real `FAILED` build, `snapshotBuild`
791 /// carried `codeInstallSizeInBytes` alone with no memory and no disk snapshot — which is
792 /// exactly the shape of a build that installed the code and then never produced a
793 /// snapshot.
794 pub snapshot_build: Option<SnapshotBuild>,
795}
796
797impl GetImageBuildResponseWire {
798 /// The build as a diagnosis reads it: the generation, the state, the reason when there is
799 /// one, and the sizes when the service reported them.
800 ///
801 /// The generation is in the line because a version has one build per Graviton generation
802 /// and they can disagree — "the build failed" is ambiguous where "generation 4 failed" is
803 /// not. Sizes are appended only when present, for the reason the absent-reason rule
804 /// gives: a rendered `0 bytes` would claim a measurement the service did not make.
805 pub fn describe(&self) -> String {
806 let mut line = match self.state_reason.as_deref() {
807 Some(reason) => format!(
808 "{} {} (chipset generation {}) — {reason}",
809 self.build_id, self.build_state, self.chipset_generation
810 ),
811 None => format!(
812 "{} {} (chipset generation {})",
813 self.build_id, self.build_state, self.chipset_generation
814 ),
815 };
816 if let Some(sizes) = &self.snapshot_build
817 && let Some(rendered) = sizes.describe()
818 {
819 line.push_str(&format!(", {rendered}"));
820 }
821 line
822 }
823}
824
825/// `SnapshotBuild`: the three byte counts, each optional in the model.
826///
827/// `u64` from the model's `Long`, and every one `Option` — the model marks none required, and
828/// a failed build really does answer with a subset (see
829/// [`GetImageBuildResponseWire::snapshot_build`]). A defaulted `0` would be a size claim
830/// nobody made, which is the same distinction `stateReason`'s `None` carries.
831#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
832#[serde(rename_all = "camelCase")]
833pub struct SnapshotBuild {
834 pub memory_snapshot_size_in_bytes: Option<u64>,
835 pub code_install_size_in_bytes: Option<u64>,
836 pub disk_snapshot_size_in_bytes: Option<u64>,
837}
838
839impl SnapshotBuild {
840 /// The sizes the service reported, or `None` when it reported none of them.
841 ///
842 /// `None` rather than an empty string so a caller can tell "no sizes" from "sizes that
843 /// render blank", and so [`GetImageBuildResponseWire::describe`] does not append a
844 /// dangling comma.
845 pub fn describe(&self) -> Option<String> {
846 let parts: Vec<String> = [
847 ("memory", self.memory_snapshot_size_in_bytes),
848 ("code", self.code_install_size_in_bytes),
849 ("disk", self.disk_snapshot_size_in_bytes),
850 ]
851 .into_iter()
852 .filter_map(|(label, bytes)| bytes.map(|bytes| format!("{label} {bytes} bytes")))
853 .collect();
854 (!parts.is_empty()).then(|| parts.join(", "))
855 }
856}
857
858/// `ListManagedMicrovmImageVersionsOutput`.
859///
860/// The versions of an AWS-managed base image, which is what closes the reproducibility hole:
861/// every build this client made floated on whatever `baseImageVersion` the service defaulted
862/// to, and that default has already moved once (`al2023-1` carries `"0"` and `"1"`).
863#[derive(Clone, Debug, Deserialize)]
864#[serde(rename_all = "camelCase")]
865pub struct ListManagedVersionsResponseWire {
866 pub items: Vec<ManagedMicrovmImageVersionWire>,
867 pub next_token: Option<String>,
868}
869
870/// `ManagedMicrovmImageVersion`. Three required members and one optional.
871///
872/// Note how much thinner this is than [`MicrovmImageVersionSummaryWire`]: a managed base's
873/// version carries no state, no status, no config — just an ARN, a version string, and two
874/// timestamps. So there is nothing here to check before pinning one, which is why
875/// [`crate::control::ControlPlane::managed_base_versions`] hands back the strings and lets
876/// the caller choose.
877#[derive(Clone, Debug, Deserialize)]
878#[serde(rename_all = "camelCase")]
879pub struct ManagedMicrovmImageVersionWire {
880 pub image_arn: String,
881 /// A **bare integer** for the managed base (`"0"`, `"1"`), where a custom image's
882 /// versions are `"1.0"`. The two spellings are not comparable and code that parses one
883 /// will not parse the other (docs/PLATFORM.md).
884 pub image_version: String,
885 pub created_at: f64,
886 pub updated_at: Option<f64>,
887}
888
889/// `ListManagedMicrovmImagesOutput`.
890#[derive(Clone, Debug, Deserialize)]
891#[serde(rename_all = "camelCase")]
892pub struct ListManagedImagesResponseWire {
893 pub items: Vec<ManagedMicrovmImageSummaryWire>,
894 pub next_token: Option<String>,
895}
896
897/// `ManagedMicrovmImageSummary`: an ARN and two timestamps, and **that is all the model
898/// declares**.
899///
900/// # Why a discovered base cannot construct a [`crate::control::BaseImage`]
901///
902/// [`crate::control::BaseImage`] pairs three things that must agree: the `baseImageArn`, the
903/// Dockerfile `FROM` that goes with it, and whether the image declares a `WORKDIR`. This
904/// shape carries the first and says nothing about the other two — no registry reference, no
905/// architecture, no working directory. So `require_matching_from` would have no `FROM` to
906/// compare a caller's Dockerfile against, and `require_workdir` would have no answer to the
907/// question it exists to ask; both guards would have to be skipped for a discovered base,
908/// which is worse than not discovering one. The registry ref in particular is not derivable:
909/// `al2023-1` pairs with `public.ecr.aws/amazonlinux/amazonlinux:2023-minimal`, and nothing
910/// in the ARN says so.
911///
912/// So this is **informational only** — surfaced by `microvm doctor` so a caller can see
913/// whether AWS has published a base this client does not know about, and never fed into a
914/// create request. Measured 2026-08-16: the listing returns exactly one item, so a client
915/// hardcoding `al2023-1` is currently missing nothing.
916#[derive(Clone, Debug, Deserialize)]
917#[serde(rename_all = "camelCase")]
918pub struct ManagedMicrovmImageSummaryWire {
919 pub image_arn: String,
920 pub created_at: f64,
921 pub updated_at: Option<f64>,
922}
923
924/// `RunMicrovmResponse` and `GetMicrovmResponse` — identical in every member this client
925/// reads, so one type serves both.
926///
927/// One type rather than two because the two shapes agree on all seven required members
928/// and both carry `state`, `endpoint`, and `stateReason`; a second type would be a
929/// second place to keep the spelling right.
930#[derive(Clone, Debug, Deserialize)]
931#[serde(rename_all = "camelCase")]
932pub struct MicrovmResponseWire {
933 pub microvm_id: String,
934 pub state: String,
935 pub endpoint: String,
936 pub image_arn: String,
937 pub image_version: String,
938 /// Optional in the model, and the absence is information: TRAP-8's message says so
939 /// rather than printing an empty string.
940 pub state_reason: Option<String>,
941 pub idle_policy: Option<IdlePolicy>,
942}
943
944/// `CreateMicrovmAuthTokenResponse`.
945///
946/// `authToken` is a `TokenParts` **map**, not a string (TRAP-7). Reading it as a string
947/// is a deserialization failure rather than a wrong value, which is the good direction —
948/// but the map's `X-aws-proxy-auth` key is what the endpoint header takes, and a caller
949/// who sends the map's `Debug` rendering gets a rejection that reads like a bad token.
950#[derive(Clone, Debug, Deserialize)]
951#[serde(rename_all = "camelCase")]
952pub struct CreateAuthTokenResponseWire {
953 pub auth_token: std::collections::BTreeMap<String, String>,
954}
955
956/// `CreateMicrovmShellAuthTokenResponse`.
957///
958/// Its own transcription rather than a reuse of [`CreateAuthTokenResponseWire`], because
959/// the model declares it as its own shape — the two happen to carry one identical member
960/// (`authToken`, a `TokenParts` map whose `X-aws-proxy-auth` key is the credential), and a
961/// shared struct would silently bind this client to the coincidence holding forever.
962#[derive(Clone, Debug, Deserialize)]
963#[serde(rename_all = "camelCase")]
964pub struct CreateShellAuthTokenResponseWire {
965 pub auth_token: std::collections::BTreeMap<String, String>,
966}
967
968/// `ListMicrovmsResponse`.
969#[derive(Clone, Debug, Deserialize)]
970#[serde(rename_all = "camelCase")]
971pub struct ListMicrovmsResponseWire {
972 pub items: Vec<MicrovmItemWire>,
973 pub next_token: Option<String>,
974}
975
976/// `MicrovmItem` — the list shape, which is narrower than `GetMicrovmResponse`: it
977/// carries no `endpoint` and no `stateReason`.
978#[derive(Clone, Debug, Deserialize)]
979#[serde(rename_all = "camelCase")]
980pub struct MicrovmItemWire {
981 pub microvm_id: String,
982 pub state: String,
983 pub image_arn: String,
984 pub image_version: String,
985}
986
987/// `DeleteMicrovmImageOutput`.
988///
989/// # Kept and read, rather than deleted as dead
990///
991/// This struct was parsed nowhere: `try_delete_image` discarded the reply, so the shape
992/// existed and no code could observe drift in it. The choice was to delete it or to read
993/// it, and reading it wins on one specific case — a 2xx whose `state` is a `*_FAILED`
994/// spelling. That is the service accepting the delete request and refusing the work, and
995/// without the readback it is indistinguishable from a successful delete: `delete_image`
996/// answers `true`, teardown reports clean, and an image keeps billing. This project has
997/// had exactly that failure once already, in `scripts/verify-clean.py`, which is why the
998/// account is now asked what leaked rather than trusted to have been cleaned.
999///
1000/// **`DELETING` and `DELETED` are both success** and the call site treats them alike. The
1001/// deletion is asynchronous, so `DELETING` is the ordinary answer; treating it as
1002/// incomplete would make the retry loop re-issue a delete that is already in progress and
1003/// then report failure on the `ConflictException` that comes back.
1004#[derive(Clone, Debug, Deserialize)]
1005#[serde(rename_all = "camelCase")]
1006pub struct DeleteImageResponseWire {
1007 pub image_identifier: String,
1008 pub state: String,
1009}
1010
1011/// `ListMicrovmImagesResponse`.
1012///
1013/// `nextToken` is how the account's listing paginates, and the name resolver **must**
1014/// follow it: an image on page two of a paginated listing is still an image, and a
1015/// resolver that read only the first page would report "no image named X" for a name
1016/// that exists — the confident wrong answer, which is worse than a failure.
1017#[derive(Clone, Debug, Deserialize)]
1018#[serde(rename_all = "camelCase")]
1019pub struct ListImagesResponseWire {
1020 pub items: Vec<MicrovmImageSummaryWire>,
1021 pub next_token: Option<String>,
1022}
1023
1024/// `MicrovmImageSummary`, narrowed to the members name resolution reads.
1025///
1026/// The model marks `imageArn`, `name`, `state`, and `createdAt` required; the first
1027/// three are the ones anything downstream uses.
1028#[derive(Clone, Debug, Deserialize)]
1029#[serde(rename_all = "camelCase")]
1030pub struct MicrovmImageSummaryWire {
1031 pub image_arn: String,
1032 pub name: String,
1033 pub state: String,
1034}
1035
1036/// The modeled error shapes, which all carry a `message` and several add fields.
1037///
1038/// One type for all seven because every one has `message` as its only member this client
1039/// reads, and the status code is what distinguishes them.
1040///
1041/// `message` is `Option` for a reason that is itself a finding: an unsupported region
1042/// answers `AccessDeniedException` with the message field **null** (TRAP-6). A required
1043/// `String` here would fail to deserialize and report a parse error instead of the
1044/// denial, hiding the one detail that identifies the trap.
1045#[derive(Clone, Debug, Default, Deserialize)]
1046#[serde(rename_all = "camelCase")]
1047pub struct ServiceErrorWire {
1048 pub message: Option<String>,
1049 /// `ThrottlingException`/`ServiceQuotaExceededException`.
1050 pub quota_code: Option<String>,
1051 /// `ThrottlingException`/`ServiceQuotaExceededException`.
1052 pub service_code: Option<String>,
1053 /// `ConflictException`/`ResourceNotFoundException`/`ServiceQuotaExceededException`.
1054 pub resource_type: Option<String>,
1055 /// As above.
1056 pub resource_id: Option<String>,
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061 use super::*;
1062
1063 /// The build summary, from JSON spelled exactly as `MicrovmImageBuildSummary`
1064 /// declares it — every required member present, in the model's casing.
1065 ///
1066 /// **This is the honest-fake test.** It parses text, not this module's own output, so
1067 /// it cannot agree with a misreading the way the Python fake did.
1068 #[test]
1069 fn a_build_summary_deserialises_from_the_models_own_spelling() {
1070 let json = r#"{
1071 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1072 "imageVersion": "1",
1073 "buildId": "build-abc",
1074 "buildState": "PENDING",
1075 "architecture": "ARM_64",
1076 "chipset": "GRAVITON",
1077 "chipsetGeneration": "1",
1078 "createdAt": 1754524800
1079 }"#;
1080 let parsed: MicrovmImageBuildSummaryWire =
1081 serde_json::from_str(json).expect("the model's own spelling parses");
1082 assert_eq!(parsed.build_state, "PENDING");
1083 assert_eq!(parsed.build_id, "build-abc");
1084 assert_eq!(parsed.image_version, "1");
1085 assert_eq!(parsed.state_reason, None);
1086
1087 // The five members the struct used to drop, all of them model-required. A required
1088 // member absent from the struct is a member no drift check can notice going missing.
1089 assert_eq!(
1090 parsed.image_arn,
1091 "arn:aws:lambda:us-east-1:123456789012:microvm-image:img"
1092 );
1093 assert_eq!(parsed.architecture, "ARM_64");
1094 assert_eq!(parsed.chipset, "GRAVITON");
1095 assert_eq!(parsed.chipset_generation, "1");
1096 assert_eq!(parsed.created_at, 1_754_524_800.0);
1097 }
1098
1099 /// A summary missing a member the model marks **required** fails to deserialize, which
1100 /// is what makes the five added members load-bearing rather than decorative.
1101 ///
1102 /// One case per member, because a single omission would prove only that *some* member is
1103 /// required. `stateReason` is checked in the other direction: it is the one optional
1104 /// member, so omitting it must still parse.
1105 #[test]
1106 fn a_build_summary_missing_a_required_member_fails_to_deserialise() {
1107 let members = [
1108 (
1109 "imageArn",
1110 r#""arn:aws:lambda:us-east-1:1:microvm-image:i""#,
1111 ),
1112 ("imageVersion", r#""1""#),
1113 ("buildId", r#""build-abc""#),
1114 ("buildState", r#""PENDING""#),
1115 ("architecture", r#""ARM_64""#),
1116 ("chipset", r#""GRAVITON""#),
1117 ("chipsetGeneration", r#""1""#),
1118 ("createdAt", "1754524800"),
1119 ];
1120
1121 for omitted in members.iter().map(|(name, _)| *name) {
1122 let body: Vec<String> = members
1123 .iter()
1124 .filter(|(name, _)| *name != omitted)
1125 .map(|(name, value)| format!(r#""{name}": {value}"#))
1126 .collect();
1127 let json = format!("{{{}}}", body.join(", "));
1128 let error = serde_json::from_str::<MicrovmImageBuildSummaryWire>(&json)
1129 .expect_err(&format!("{omitted} is required by the model"));
1130 assert!(
1131 error.to_string().contains(omitted),
1132 "the error must name the member that is missing ({omitted}): {error}"
1133 );
1134 }
1135
1136 // The one optional member: omitting `stateReason` parses, and the absence is `None`
1137 // rather than an empty string.
1138 let all: Vec<String> = members
1139 .iter()
1140 .map(|(name, value)| format!(r#""{name}": {value}"#))
1141 .collect();
1142 let parsed: MicrovmImageBuildSummaryWire =
1143 serde_json::from_str(&format!("{{{}}}", all.join(", ")))
1144 .expect("stateReason is optional");
1145 assert_eq!(parsed.state_reason, None);
1146 }
1147
1148 /// `describe` names the build id, the state, and the reason when there is one — and
1149 /// renders **nothing** rather than an empty parenthesis when there is not.
1150 ///
1151 /// The build id is in there because it is what `GetMicrovmImageBuild` takes, so a reader
1152 /// handed a wedge verdict can ask about a named build rather than about "one of the
1153 /// builds".
1154 #[test]
1155 fn a_build_describes_itself_with_its_id_and_only_a_reason_it_has() {
1156 let with_reason: MicrovmImageBuildSummaryWire = serde_json::from_str(
1157 r#"{
1158 "imageArn": "arn:aws:lambda:us-east-1:1:microvm-image:i",
1159 "imageVersion": "1",
1160 "buildId": "build-abc",
1161 "buildState": "FAILED",
1162 "architecture": "ARM_64",
1163 "chipset": "GRAVITON",
1164 "chipsetGeneration": "1",
1165 "createdAt": 1754524800,
1166 "stateReason": "no space left on device"
1167 }"#,
1168 )
1169 .expect("parses");
1170 assert_eq!(
1171 with_reason.describe(),
1172 "build-abc FAILED (no space left on device)"
1173 );
1174
1175 let without: MicrovmImageBuildSummaryWire = serde_json::from_str(
1176 r#"{
1177 "imageArn": "arn:aws:lambda:us-east-1:1:microvm-image:i",
1178 "imageVersion": "1",
1179 "buildId": "build-abc",
1180 "buildState": "PENDING",
1181 "architecture": "ARM_64",
1182 "chipset": "GRAVITON",
1183 "chipsetGeneration": "1",
1184 "createdAt": 1754524800
1185 }"#,
1186 )
1187 .expect("parses");
1188 assert_eq!(without.describe(), "build-abc PENDING");
1189 assert!(
1190 !without.describe().contains("()"),
1191 "an absent reason renders nothing, not an empty parenthesis: {}",
1192 without.describe()
1193 );
1194 }
1195
1196 /// The bug, made a compile-and-test-time fact: a response carrying `state` instead of
1197 /// `buildState` **fails to deserialize** rather than yielding a summary whose state is
1198 /// silently absent.
1199 ///
1200 /// This is the falsification for the whole `buildState` discipline. Rename the field
1201 /// to `state` (or add a `#[serde(alias = "state")]` "for compatibility") and this
1202 /// test goes red — which is what the Python client had no equivalent of, and why its
1203 /// dead guard passed for a review round.
1204 #[test]
1205 fn a_summary_spelled_state_instead_of_build_state_fails_to_deserialise() {
1206 let json = r#"{
1207 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1208 "imageVersion": "1",
1209 "buildId": "build-abc",
1210 "state": "PENDING",
1211 "architecture": "ARM_64",
1212 "chipset": "GRAVITON",
1213 "chipsetGeneration": "1",
1214 "createdAt": 1754524800
1215 }"#;
1216 let parsed: Result<MicrovmImageBuildSummaryWire, _> = serde_json::from_str(json);
1217 let error = parsed.expect_err("MicrovmImageBuildSummary has no member called state");
1218 assert!(
1219 error.to_string().contains("buildState"),
1220 "the error must name the member that is actually missing: {error}"
1221 );
1222 }
1223
1224 /// The asymmetry that makes the trap a trap: the *version* summary really does have a
1225 /// member called `state`, so "these shapes use `state`" is true of one and false of
1226 /// its neighbour. Both spellings are asserted here, together, so a reader sees why
1227 /// the two cannot be handled by one habit.
1228 #[test]
1229 fn a_version_summary_does_use_state_which_is_why_the_build_one_confuses() {
1230 let json = r#"{
1231 "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1232 "buildRoleArn": "arn:aws:iam::123456789012:role/build",
1233 "codeArtifact": {"uri": "s3://bucket/img.zip"},
1234 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1235 "imageVersion": "1",
1236 "state": "SUCCESSFUL",
1237 "status": "ACTIVE",
1238 "createdAt": 1754524800
1239 }"#;
1240 let parsed: MicrovmImageVersionSummaryWire =
1241 serde_json::from_str(json).expect("the version summary's member is state");
1242 assert_eq!(parsed.state, "SUCCESSFUL");
1243 assert_eq!(parsed.status, "ACTIVE");
1244 assert!(parsed.is_active(), "ACTIVE is what RunMicrovm will launch");
1245 }
1246
1247 /// The version summary's `stateReason`, which is the **version-level** failure reason
1248 /// `GetMicrovmImage` structurally cannot provide — that shape has no such member.
1249 ///
1250 /// It was parsed and never read, so a build failure could only be reported with a log
1251 /// group and a guess while the service's own account of the cause sat in hand.
1252 #[test]
1253 fn a_version_summary_carries_the_state_reason_get_image_cannot() {
1254 let json = r#"{
1255 "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1256 "buildRoleArn": "arn:aws:iam::123456789012:role/build",
1257 "codeArtifact": {"uri": "s3://bucket/img.zip"},
1258 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1259 "imageVersion": "2",
1260 "state": "FAILED",
1261 "status": "INACTIVE",
1262 "createdAt": 1754524800,
1263 "stateReason": "one or more builds failed"
1264 }"#;
1265 let parsed: MicrovmImageVersionSummaryWire = serde_json::from_str(json).expect("parses");
1266 assert_eq!(parsed.state, "FAILED");
1267 assert_eq!(
1268 parsed.state_reason.as_deref(),
1269 Some("one or more builds failed")
1270 );
1271
1272 // `GetMicrovmImageOutput` has no `stateReason` member at all, which is the whole
1273 // reason the version-level one is worth reading. A response carrying one parses and
1274 // the member is simply dropped, so this is asserted by there being no field to read
1275 // rather than by a failure.
1276 let image: GetMicrovmImageResponseWire = serde_json::from_str(
1277 r#"{"imageArn": "a", "name": "n", "state": "CREATE_FAILED",
1278 "createdAt": 1754524800, "stateReason": "ignored"}"#,
1279 )
1280 .expect("an unknown member is dropped");
1281 assert_eq!(image.state, "CREATE_FAILED");
1282 }
1283
1284 /// `GetMicrovmImageOutput` carries `tags`, `createdAt`, and `updatedAt`.
1285 ///
1286 /// Reading `tags` here is a cheaper answer to "what are this image's tags" than
1287 /// implementing `ListTags`: the service already sends the map on every call. The literal
1288 /// below is the shape a real response has — measured 2026-08-15 against a live
1289 /// `GetMicrovmImage`, which answered `"tags": {}` and an `updatedAt` on an untagged image.
1290 #[test]
1291 fn a_get_image_response_carries_its_tags_and_timestamps() {
1292 let json = r#"{
1293 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1294 "name": "img",
1295 "state": "CREATED",
1296 "latestActiveImageVersion": "1.0",
1297 "createdAt": 1754524800,
1298 "updatedAt": 1754528400,
1299 "tags": {"owner": "conformance", "cost-centre": "agents"}
1300 }"#;
1301 let parsed: GetMicrovmImageResponseWire = serde_json::from_str(json).expect("parses");
1302 assert_eq!(parsed.created_at, 1_754_524_800.0);
1303 assert_eq!(parsed.updated_at, Some(1_754_528_400.0));
1304 let tags = parsed.tags.expect("the map was sent");
1305 assert_eq!(tags.get("owner").map(String::as_str), Some("conformance"));
1306 assert_eq!(tags.len(), 2);
1307
1308 // An untagged image answers with an empty map, which is different from an absent
1309 // one: `Some({})` says the service answered and the image has no tags.
1310 let empty: GetMicrovmImageResponseWire = serde_json::from_str(
1311 r#"{"imageArn": "a", "name": "n", "state": "CREATED",
1312 "createdAt": 1754524800, "updatedAt": 1754528400, "tags": {}}"#,
1313 )
1314 .expect("parses");
1315 assert_eq!(empty.tags, Some(std::collections::BTreeMap::new()));
1316
1317 // And an absent map is `None` rather than a parse failure, since the model does not
1318 // mark `tags` required.
1319 let absent: GetMicrovmImageResponseWire = serde_json::from_str(
1320 r#"{"imageArn": "a", "name": "n", "state": "CREATED", "createdAt": 1754524800}"#,
1321 )
1322 .expect("tags is optional");
1323 assert_eq!(absent.tags, None);
1324 assert_eq!(absent.updated_at, None);
1325 }
1326
1327 /// `createdAt` is required by the model, so a `GetMicrovmImage` response without it
1328 /// fails rather than yielding an image whose creation time is silently zero.
1329 #[test]
1330 fn a_get_image_response_without_created_at_fails_to_deserialise() {
1331 let error = serde_json::from_str::<GetMicrovmImageResponseWire>(
1332 r#"{"imageArn": "a", "name": "n", "state": "CREATED"}"#,
1333 )
1334 .expect_err("createdAt is one of the model's four required members");
1335 assert!(error.to_string().contains("createdAt"), "{error}");
1336 }
1337
1338 /// `IdlePolicy` round-trips through a **response** body, all three members present.
1339 ///
1340 /// The comment on the shape used to say `suspendedDurationSeconds` "exists only in the
1341 /// request". The JSON below is what a live `GetMicrovm` answered on 2026-08-15, so this
1342 /// test is the measurement rather than a restatement of the model.
1343 #[test]
1344 fn an_idle_policy_comes_back_on_a_response_with_all_three_members() {
1345 let json = r#"{
1346 "microvmId": "mvm-1",
1347 "state": "RUNNING",
1348 "endpoint": "https://e",
1349 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1350 "imageVersion": "1.0",
1351 "idlePolicy": {
1352 "maxIdleDurationSeconds": 1800,
1353 "suspendedDurationSeconds": 600,
1354 "autoResumeEnabled": false
1355 },
1356 "maximumDurationInSeconds": 10800
1357 }"#;
1358 let parsed: MicrovmResponseWire = serde_json::from_str(json).expect("parses");
1359 let policy = parsed.idle_policy.expect("the service sent one");
1360 assert_eq!(policy.max_idle_duration_seconds, 1800);
1361 assert_eq!(
1362 policy.suspended_duration_seconds, 600,
1363 "the member the comment claimed the response omits"
1364 );
1365 assert!(!policy.auto_resume_enabled);
1366 }
1367
1368 /// A `DeleteMicrovmImage` readback parses, and the failure spelling is distinguishable
1369 /// from the two success ones — which is the whole reason this shape is read rather than
1370 /// deleted as dead.
1371 #[test]
1372 fn a_delete_readback_distinguishes_failure_from_deleting_and_deleted() {
1373 for state in ["DELETING", "DELETED", "DELETE_FAILED"] {
1374 let json = format!(
1375 r#"{{"imageIdentifier": "arn:aws:lambda:us-east-1:1:microvm-image:img",
1376 "state": "{state}"}}"#
1377 );
1378 let parsed: DeleteImageResponseWire = serde_json::from_str(&json).expect("parses");
1379 assert_eq!(parsed.state, state);
1380 assert_eq!(
1381 parsed.image_identifier,
1382 "arn:aws:lambda:us-east-1:1:microvm-image:img"
1383 );
1384 }
1385 }
1386
1387 /// `ListMicrovmImagesResponse` from the model's own spelling, `nextToken` included.
1388 ///
1389 /// The honest-fake rule again: this JSON is text transcribed from `service-2.json`,
1390 /// not a round trip through this module's serializer, so a misspelled member here
1391 /// cannot agree with a misspelled member in the struct.
1392 #[test]
1393 fn an_image_listing_deserialises_from_the_models_own_spelling() {
1394 let json = r#"{
1395 "items": [{
1396 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1397 "name": "img",
1398 "state": "ACTIVE",
1399 "latestActiveImageVersion": "1",
1400 "createdAt": 1754524800
1401 }],
1402 "nextToken": "opaque-page-2-token"
1403 }"#;
1404 let parsed: ListImagesResponseWire =
1405 serde_json::from_str(json).expect("the model's own spelling parses");
1406 assert_eq!(parsed.items.len(), 1);
1407 assert_eq!(parsed.items[0].name, "img");
1408 assert_eq!(
1409 parsed.items[0].image_arn,
1410 "arn:aws:lambda:us-east-1:123456789012:microvm-image:img"
1411 );
1412 assert_eq!(parsed.items[0].state, "ACTIVE");
1413 assert_eq!(parsed.next_token.as_deref(), Some("opaque-page-2-token"));
1414
1415 // The final page: nextToken absent means None, which is what stops the loop.
1416 let last: ListImagesResponseWire =
1417 serde_json::from_str(r#"{"items": []}"#).expect("an absent nextToken parses");
1418 assert!(last.items.is_empty());
1419 assert_eq!(last.next_token, None);
1420 }
1421
1422 /// `RunMicrovmResponse` from the model's spelling, including the optional
1423 /// `stateReason` that TRAP-8 reports.
1424 #[test]
1425 fn a_run_response_deserialises_with_its_state_reason() {
1426 let json = r#"{
1427 "microvmId": "mvm-123",
1428 "state": "TERMINATED",
1429 "endpoint": "https://mvm-123.microvm.amazonaws.com",
1430 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1431 "imageVersion": "1",
1432 "maximumDurationInSeconds": 3600,
1433 "startedAt": 1754524800,
1434 "stateReason": "run hook returned 500"
1435 }"#;
1436 let parsed: MicrovmResponseWire = serde_json::from_str(json).expect("parses");
1437 assert_eq!(parsed.state, "TERMINATED");
1438 assert_eq!(
1439 parsed.state_reason.as_deref(),
1440 Some("run hook returned 500")
1441 );
1442 assert_eq!(parsed.microvm_id, "mvm-123");
1443 }
1444
1445 /// The same shape without `stateReason`, which the model permits. The field must be
1446 /// `None` rather than an empty string, because "the service said nothing" and "the
1447 /// service said nothing useful" are different diagnoses.
1448 #[test]
1449 fn an_absent_state_reason_is_none_rather_than_an_empty_string() {
1450 let json = r#"{
1451 "microvmId": "mvm-123",
1452 "state": "RUNNING",
1453 "endpoint": "https://mvm-123.microvm.amazonaws.com",
1454 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1455 "imageVersion": "1",
1456 "maximumDurationInSeconds": 3600,
1457 "startedAt": 1754524800
1458 }"#;
1459 let parsed: MicrovmResponseWire = serde_json::from_str(json).expect("parses");
1460 assert_eq!(parsed.state_reason, None);
1461 }
1462
1463 /// `authToken` is a map (TRAP-7). The key is the header name, and reading the map's
1464 /// one interesting key is what the proxy needs.
1465 #[test]
1466 fn an_auth_token_response_is_a_header_map_not_a_string() {
1467 let json = r#"{"authToken": {"X-aws-proxy-auth": "opaque-token-value"}}"#;
1468 let parsed: CreateAuthTokenResponseWire = serde_json::from_str(json).expect("parses");
1469 assert_eq!(
1470 parsed
1471 .auth_token
1472 .get("X-aws-proxy-auth")
1473 .map(String::as_str),
1474 Some("opaque-token-value")
1475 );
1476 }
1477
1478 /// A string `authToken` fails rather than being coerced. The failure is the good
1479 /// direction: a client that accepted both would send a stringified map as a header
1480 /// value and get a rejection that reads like a bad token.
1481 #[test]
1482 fn a_string_auth_token_fails_to_deserialise() {
1483 let json = r#"{"authToken": "opaque-token-value"}"#;
1484 let parsed: Result<CreateAuthTokenResponseWire, _> = serde_json::from_str(json);
1485 assert!(parsed.is_err(), "TokenParts is a map shape");
1486 }
1487
1488 /// TRAP-6's null message survives deserialization. A required `String` here would
1489 /// fail to parse and report a parse error, which hides the one detail — the null —
1490 /// that tells an unsupported region apart from a real IAM denial.
1491 #[test]
1492 fn an_access_denied_with_a_null_message_still_deserialises() {
1493 let parsed: ServiceErrorWire =
1494 serde_json::from_str(r#"{"message": null}"#).expect("a null message must parse");
1495 assert_eq!(parsed.message, None);
1496
1497 let empty: ServiceErrorWire = serde_json::from_str("{}").expect("an absent message parses");
1498 assert_eq!(empty.message, None);
1499 }
1500
1501 /// An unknown member is dropped rather than failing, so a member AWS adds does not
1502 /// break the client. This is the deliberate absence of `deny_unknown_fields`.
1503 #[test]
1504 fn an_unknown_response_member_is_ignored() {
1505 let json = r#"{
1506 "microvmId": "mvm-123",
1507 "state": "RUNNING",
1508 "endpoint": "https://e",
1509 "imageArn": "arn",
1510 "imageVersion": "1",
1511 "somethingAwsAddedLater": {"nested": [1, 2, 3]}
1512 }"#;
1513 let parsed: MicrovmResponseWire =
1514 serde_json::from_str(json).expect("additive change is safe");
1515 assert_eq!(parsed.microvm_id, "mvm-123");
1516 }
1517
1518 /// The create-image request serialises to the model's member names. Asserted against
1519 /// literal keys rather than a round trip, for the same reason the response tests hold
1520 /// JSON: `minimumMemoryInMiB` in particular is a casing `rename_all = "camelCase"`
1521 /// gets wrong on its own (it produces `minimumMemoryInMib`).
1522 #[test]
1523 fn the_create_request_serialises_to_the_models_member_names() {
1524 let wire = CreateMicrovmImageWire {
1525 name: "img".to_string(),
1526 base_image_arn: "arn:base".to_string(),
1527 base_image_version: None,
1528 build_role_arn: "arn:role".to_string(),
1529 code_artifact: CodeArtifact {
1530 uri: "s3://bucket/img.zip".to_string(),
1531 },
1532 cpu_configurations: vec![CpuConfiguration {
1533 architecture: "ARM_64".to_string(),
1534 }],
1535 resources: vec![Resources {
1536 minimum_memory_in_mib: 2048,
1537 }],
1538 hooks: Hooks {
1539 port: 9000,
1540 microvm_hooks: MicrovmHooks {
1541 run: HookState::Enabled,
1542 run_timeout_in_seconds: 30,
1543 resume: HookState::Enabled,
1544 resume_timeout_in_seconds: 30,
1545 suspend: HookState::Enabled,
1546 suspend_timeout_in_seconds: 30,
1547 terminate: HookState::Enabled,
1548 terminate_timeout_in_seconds: 30,
1549 },
1550 microvm_image_hooks: MicrovmImageHooks {
1551 ready: HookState::Enabled,
1552 ready_timeout_in_seconds: 300,
1553 validate: HookState::Enabled,
1554 validate_timeout_in_seconds: 300,
1555 },
1556 },
1557 additional_os_capabilities: Some(vec!["ALL".to_string()]),
1558 logging: None,
1559 tags: None,
1560 client_token: "create-img-0011223344556677".to_string(),
1561 };
1562
1563 let value = serde_json::to_value(&wire).expect("serialises");
1564 let object = value.as_object().expect("an object");
1565
1566 // The exact member names, from the model. `minimumMemoryInMiB` is the one
1567 // camelCase alone would misspell.
1568 assert_eq!(value["resources"][0]["minimumMemoryInMiB"], 2048);
1569 assert_eq!(value["baseImageArn"], "arn:base");
1570 assert_eq!(value["buildRoleArn"], "arn:role");
1571 assert_eq!(value["codeArtifact"]["uri"], "s3://bucket/img.zip");
1572 assert_eq!(value["cpuConfigurations"][0]["architecture"], "ARM_64");
1573 assert_eq!(
1574 value["hooks"]["microvmImageHooks"]["readyTimeoutInSeconds"],
1575 300
1576 );
1577 assert_eq!(value["hooks"]["microvmHooks"]["runTimeoutInSeconds"], 30);
1578 assert_eq!(value["additionalOsCapabilities"][0], "ALL");
1579 assert_eq!(value["clientToken"], "create-img-0011223344556677");
1580 assert!(!object.contains_key("tags"), "an absent tag map is omitted");
1581
1582 // **Issue #24's wrong-by-10x hazard is closed by absence on this shape.** The model gives
1583 // `CreateMicrovmImageRequest` an `egressNetworkConnectors` with `max: 1` — a tenth of the
1584 // VM-level list's — and this wire type has no such field, so the client cannot send one at
1585 // all. That is why `MAX_IMAGE_EGRESS_CONNECTORS` guards nothing: there is nothing to guard.
1586 //
1587 // Asserted here, on the serialized object, rather than left as a comment. The hazard is a
1588 // future edit that adds the member and reuses `MAX_NETWORK_CONNECTORS` — permissive by an
1589 // order of magnitude, in a request whose rejection lands after the artifact upload. This
1590 // line is what makes adding the member a visible diff against a test that says the bound is
1591 // 1: whoever adds it has to delete this assertion and will read why while doing it.
1592 assert!(
1593 !object.contains_key("egressNetworkConnectors"),
1594 "the client sends no image-level egress list. If you are adding one, the ceiling is \
1595 MAX_IMAGE_EGRESS_CONNECTORS (1), NOT MAX_NETWORK_CONNECTORS (10) — the two differ by \
1596 10x and the permissive direction is the one that reaches the wire."
1597 );
1598 }
1599
1600 /// `additionalOsCapabilities` is **absent** rather than `[]` when identity repair was
1601 /// not requested. An empty list is a different request, and the model's `CapabilityList`
1602 /// has no documented meaning for one.
1603 #[test]
1604 fn an_unrequested_capability_list_is_omitted_not_empty() {
1605 let mut wire = minimal_create_wire();
1606 wire.additional_os_capabilities = None;
1607 let value = serde_json::to_value(&wire).expect("serialises");
1608 assert!(
1609 !value
1610 .as_object()
1611 .expect("object")
1612 .contains_key("additionalOsCapabilities"),
1613 "omitted, not empty: {value}"
1614 );
1615 }
1616
1617 /// The run request's member names, including the two connector lists and the payload.
1618 #[test]
1619 fn the_run_request_serialises_to_the_models_member_names() {
1620 let wire = RunMicrovmWire {
1621 image_identifier: "arn:image".to_string(),
1622 image_version: None,
1623 execution_role_arn: Some("arn:aws:iam::123456789012:role/exec".to_string()),
1624 ingress_network_connectors: vec!["arn:connector:ALL_INGRESS".to_string()],
1625 egress_network_connectors: None,
1626 idle_policy: IdlePolicy {
1627 max_idle_duration_seconds: 600,
1628 suspended_duration_seconds: 600,
1629 auto_resume_enabled: false,
1630 },
1631 maximum_duration_in_seconds: 3600,
1632 run_hook_payload: r#"{"agent_token":"t"}"#.to_string(),
1633 client_token: "run-arn-0011223344556677".to_string(),
1634 };
1635
1636 let value = serde_json::to_value(&wire).expect("serialises");
1637 assert_eq!(value["imageIdentifier"], "arn:image");
1638 assert_eq!(
1639 value["ingressNetworkConnectors"][0],
1640 "arn:connector:ALL_INGRESS"
1641 );
1642 assert_eq!(value["idlePolicy"]["maxIdleDurationSeconds"], 600);
1643 assert_eq!(value["idlePolicy"]["suspendedDurationSeconds"], 600);
1644 assert_eq!(value["idlePolicy"]["autoResumeEnabled"], false);
1645 assert_eq!(value["maximumDurationInSeconds"], 3600);
1646 assert_eq!(value["runHookPayload"], r#"{"agent_token":"t"}"#);
1647 assert!(
1648 !value
1649 .as_object()
1650 .expect("object")
1651 .contains_key("egressNetworkConnectors"),
1652 "omitting egress must omit the connector list, not send an empty one"
1653 );
1654 }
1655
1656 /// The auth-token request's body. `microvmIdentifier` is a **URI** parameter and must
1657 /// not appear in the body — a serialized copy of it there is a member the shape does
1658 /// not declare.
1659 #[test]
1660 fn the_auth_token_request_body_carries_no_uri_parameter() {
1661 let wire = CreateAuthTokenWire {
1662 expiration_in_minutes: 60,
1663 allowed_ports: vec![PortSpecification::port(9000)],
1664 };
1665 let value = serde_json::to_value(&wire).expect("serialises");
1666 assert_eq!(value["expirationInMinutes"], 60);
1667 assert_eq!(value["allowedPorts"][0]["port"], 9000);
1668 assert!(
1669 !value
1670 .as_object()
1671 .expect("object")
1672 .contains_key("microvmIdentifier"),
1673 "it is a uri-located member: {value}"
1674 );
1675 }
1676
1677 /// **Each union variant serialises as the member name the model declares, with nothing
1678 /// else alongside it.**
1679 ///
1680 /// The wire form of a Smithy union is one key, and serde's default enum representation is
1681 /// `{"One": {"port": 9000}}` — a shape the service rejects as an undeclared member. So
1682 /// `untagged` plus a `rename` per field is not a style choice, and this test is what says
1683 /// so: it asserts the *exact* key set, because a variant that serialised as two keys or as
1684 /// the wrong one would still be valid JSON and would fail only against real AWS.
1685 ///
1686 /// The three variants together are what the port-scope fix rests on. Measured 2026-08-15:
1687 /// a token minted with only `{"port": 9000}` answers 403 `Access to port denied` for a
1688 /// `GET :8080`, and all three of the forms below answer 200.
1689 ///
1690 /// **Guard proof.** Dropping `#[serde(untagged)]` makes every case fail with the variant
1691 /// name as the key (`{"One": ..}`). Renaming `all_ports`'s field to snake_case makes the
1692 /// `allPorts` case fail. Making `AllPorts` a unit struct serialises it as `null` and the
1693 /// object assertion fails. Each applied and observed.
1694 #[test]
1695 fn each_port_specification_variant_serialises_as_its_model_member() {
1696 let cases = [
1697 (
1698 PortSpecification::port(8080),
1699 "port",
1700 serde_json::json!(8080),
1701 ),
1702 (
1703 PortSpecification::range(8000, 9100),
1704 "range",
1705 serde_json::json!({ "startPort": 8000, "endPort": 9100 }),
1706 ),
1707 (PortSpecification::all(), "allPorts", serde_json::json!({})),
1708 ];
1709 for (spec, key, expected) in cases {
1710 let value = serde_json::to_value(spec).expect("serialises");
1711 let object = value.as_object().expect("a union member is an object");
1712 assert_eq!(
1713 object.keys().collect::<Vec<_>>(),
1714 vec![key],
1715 "a union serialises as exactly one member named by the model: {value}"
1716 );
1717 assert_eq!(object[key], expected, "{key} carries the model's shape");
1718 }
1719 }
1720
1721 // ── GetMicrovmImageBuild ─────────────────────────────────────────────────
1722
1723 /// `GetMicrovmImageBuildOutput` from the model's own spelling, `snapshotBuild` included.
1724 ///
1725 /// The literal is a **real response**, copied from a live `GetMicrovmImageBuild` on
1726 /// 2026-08-16 against `coding-agents-on-bedrock` version 1.0 — so the three byte counts
1727 /// and the `chipsetGeneration: "4"` are measurements rather than plausible numbers. That
1728 /// matters for `chipsetGeneration` in particular: it is a `NonBlankString` in the model,
1729 /// so it comes back as `"4"` and not `4`, and a struct typing it `u32` would fail to parse
1730 /// every real response.
1731 ///
1732 /// **Guard proof.** Type `chipset_generation` as `u32` and this fails with "invalid type:
1733 /// string". Drop `snapshot_build` and the size assertions do not compile — which is the
1734 /// point, since those sizes are the only reason this call exists.
1735 #[test]
1736 fn a_build_get_response_carries_the_snapshot_sizes_the_listing_has_no_member_for() {
1737 let json = r#"{
1738 "imageArn": "arn:aws:lambda:us-east-1:392583147479:microvm-image:coding-agents-on-bedrock",
1739 "imageVersion": "1.0",
1740 "buildId": "d39847fa-4c6b-43c6-ba69-e25e3b55197b",
1741 "buildState": "SUCCESSFUL",
1742 "architecture": "ARM_64",
1743 "chipset": "GRAVITON",
1744 "chipsetGeneration": "4",
1745 "createdAt": 1755150457.035,
1746 "snapshotBuild": {
1747 "memorySnapshotSizeInBytes": 582238208,
1748 "codeInstallSizeInBytes": 2355486720,
1749 "diskSnapshotSizeInBytes": 23760896
1750 }
1751 }"#;
1752 let parsed: GetImageBuildResponseWire =
1753 serde_json::from_str(json).expect("the model's own spelling parses");
1754 assert_eq!(parsed.build_state, "SUCCESSFUL");
1755 assert_eq!(parsed.build_id, "d39847fa-4c6b-43c6-ba69-e25e3b55197b");
1756 // A string, because the model types it NonBlankString. A `u32` field here would fail
1757 // on every real response.
1758 assert_eq!(parsed.chipset_generation, "4");
1759 assert_eq!(parsed.state_reason, None);
1760
1761 let sizes = parsed.snapshot_build.expect("a successful build has sizes");
1762 assert_eq!(sizes.memory_snapshot_size_in_bytes, Some(582_238_208));
1763 assert_eq!(sizes.code_install_size_in_bytes, Some(2_355_486_720));
1764 assert_eq!(sizes.disk_snapshot_size_in_bytes, Some(23_760_896));
1765 assert_eq!(
1766 sizes.describe().as_deref(),
1767 Some("memory 582238208 bytes, code 2355486720 bytes, disk 23760896 bytes")
1768 );
1769 }
1770
1771 /// **A failed build's `snapshotBuild` is partial, and the partial shape is the diagnosis.**
1772 ///
1773 /// Measured 2026-08-16 against a real `FAILED` build (`bonk-sandbox-v4`, ready hook timed
1774 /// out): `snapshotBuild` carried `codeInstallSizeInBytes` alone, with no memory and no disk
1775 /// snapshot. That is a build which installed 1.7 GB of code and then never produced a
1776 /// snapshot, which is a different failure from a Dockerfile that broke before installing
1777 /// anything — so the two absent members carry information and a defaulted `0` would erase
1778 /// it.
1779 ///
1780 /// **Guard proof.** Change the three `Option<u64>` fields to bare `u64` and this fails to
1781 /// deserialize on the two missing members. Make `describe` render absent sizes as `0
1782 /// bytes` and the assertion that the line names only `code` goes red.
1783 #[test]
1784 fn a_failed_builds_snapshot_sizes_are_partial_rather_than_zeroed() {
1785 let json = r#"{
1786 "imageArn": "arn:aws:lambda:us-east-1:392583147479:microvm-image:bonk-sandbox-v4",
1787 "imageVersion": "1.0",
1788 "buildId": "4a4c5e30-811f-47fa-9893-260ea6a37a8f",
1789 "buildState": "FAILED",
1790 "architecture": "ARM_64",
1791 "chipset": "GRAVITON",
1792 "chipsetGeneration": "4",
1793 "stateReason": "Ready hook invocation timed out after PT5M",
1794 "createdAt": 1751042593.476,
1795 "snapshotBuild": {"codeInstallSizeInBytes": 1724940288}
1796 }"#;
1797 let parsed: GetImageBuildResponseWire = serde_json::from_str(json).expect("parses");
1798 let sizes = parsed
1799 .snapshot_build
1800 .expect("the service sent a partial breakdown");
1801 assert_eq!(sizes.code_install_size_in_bytes, Some(1_724_940_288));
1802 assert_eq!(
1803 sizes.memory_snapshot_size_in_bytes, None,
1804 "no memory snapshot was produced, which is the finding — not a size of zero"
1805 );
1806 assert_eq!(sizes.disk_snapshot_size_in_bytes, None);
1807
1808 let described = parsed.describe();
1809 assert!(
1810 described.contains("Ready hook invocation timed out"),
1811 "{described}"
1812 );
1813 assert!(
1814 described.contains("chipset generation 4"),
1815 "one CreateMicrovmImage fans out per generation, so the line has to name which: \
1816 {described}"
1817 );
1818 assert!(described.contains("code 1724940288 bytes"), "{described}");
1819 assert!(
1820 !described.contains("memory"),
1821 "an absent size must not render at all: {described}"
1822 );
1823 }
1824
1825 /// A build the service reports with **no** `snapshotBuild` at all renders no sizes and no
1826 /// dangling comma.
1827 ///
1828 /// The model marks the member optional, and a `PENDING` build has nothing to report — so
1829 /// `None` has to render as absence rather than as an empty clause.
1830 #[test]
1831 fn a_build_with_no_snapshot_block_renders_no_size_clause() {
1832 let json = r#"{
1833 "imageArn": "arn:aws:lambda:us-east-1:1:microvm-image:img",
1834 "imageVersion": "1",
1835 "buildId": "build-abc",
1836 "buildState": "PENDING",
1837 "architecture": "ARM_64",
1838 "chipset": "GRAVITON",
1839 "chipsetGeneration": "3",
1840 "createdAt": 1754524800
1841 }"#;
1842 let parsed: GetImageBuildResponseWire = serde_json::from_str(json).expect("parses");
1843 assert_eq!(parsed.snapshot_build, None);
1844 assert_eq!(
1845 parsed.describe(),
1846 "build-abc PENDING (chipset generation 3)"
1847 );
1848 assert!(!parsed.describe().ends_with(", "), "{}", parsed.describe());
1849
1850 // And an *empty* snapshot block is `Some` with three `None`s, which is different from
1851 // an absent one: the service answered and reported nothing.
1852 let empty: GetImageBuildResponseWire = serde_json::from_str(
1853 r#"{"imageArn": "a", "imageVersion": "1", "buildId": "b", "buildState": "PENDING",
1854 "architecture": "ARM_64", "chipset": "GRAVITON", "chipsetGeneration": "3",
1855 "createdAt": 1754524800, "snapshotBuild": {}}"#,
1856 )
1857 .expect("an empty block parses");
1858 assert_eq!(empty.snapshot_build.expect("Some").describe(), None);
1859 assert!(!empty.describe().contains("bytes"), "{}", empty.describe());
1860 }
1861
1862 /// The build get response requires the same eight members the summary does.
1863 ///
1864 /// One case per member, for the reason the summary's own test gives: a single omission
1865 /// would prove only that *some* member is required.
1866 #[test]
1867 fn a_build_get_response_missing_a_required_member_fails_to_deserialise() {
1868 let members = [
1869 (
1870 "imageArn",
1871 r#""arn:aws:lambda:us-east-1:1:microvm-image:i""#,
1872 ),
1873 ("imageVersion", r#""1""#),
1874 ("buildId", r#""build-abc""#),
1875 ("buildState", r#""SUCCESSFUL""#),
1876 ("architecture", r#""ARM_64""#),
1877 ("chipset", r#""GRAVITON""#),
1878 ("chipsetGeneration", r#""4""#),
1879 ("createdAt", "1754524800"),
1880 ];
1881 for omitted in members.iter().map(|(name, _)| *name) {
1882 let body: Vec<String> = members
1883 .iter()
1884 .filter(|(name, _)| *name != omitted)
1885 .map(|(name, value)| format!(r#""{name}": {value}"#))
1886 .collect();
1887 let error = serde_json::from_str::<GetImageBuildResponseWire>(&format!(
1888 "{{{}}}",
1889 body.join(", ")
1890 ))
1891 .expect_err(&format!("{omitted} is required by the model"));
1892 assert!(error.to_string().contains(omitted), "{omitted}: {error}");
1893 }
1894 }
1895
1896 // ── GetMicrovmImageVersion / UpdateMicrovmImageVersion ───────────────────
1897
1898 /// **One struct for three shapes.** A real `GetMicrovmImageVersion` response parses as
1899 /// `MicrovmImageVersionSummaryWire`, config readback and all.
1900 ///
1901 /// The literal is a real response, copied from a live call on 2026-08-16 against
1902 /// `coding-agents-on-bedrock` version 1.0 — including the detail that
1903 /// `baseImageVersion` reads `"1.0"` while the managed base's own listing offers `"0"` and
1904 /// `"1"`. The two spellings are not comparable and this test records that they are not.
1905 ///
1906 /// `hooks` is present in the JSON and **not** read, deliberately: see the type's docs and
1907 /// the sibling test below.
1908 ///
1909 /// **Guard proof.** Make `status` an `Option<String>` and the `is_active` assertion still
1910 /// passes, which is why the *requiredness* test below exists instead. Remove `resources`
1911 /// and the size-class assertion does not compile.
1912 #[test]
1913 fn a_get_image_version_response_parses_as_the_summary_with_its_whole_config() {
1914 let json = r#"{
1915 "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1916 "baseImageVersion": "1.0",
1917 "buildRoleArn": "arn:aws:iam::392583147479:role/agentd-conformance-build-b2111c56",
1918 "codeArtifact": {"uri": "s3://agentd-conformance-392583147479-b2111c56/coding-agents-on-bedrock.zip"},
1919 "egressNetworkConnectors": [
1920 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
1921 ],
1922 "cpuConfigurations": [{"architecture": "ARM_64"}],
1923 "resources": [{"minimumMemoryInMiB": 2048}],
1924 "hooks": {
1925 "port": 9000,
1926 "microvmHooks": {
1927 "run": "ENABLED", "runTimeoutInSeconds": 30,
1928 "resume": "ENABLED", "resumeTimeoutInSeconds": 30,
1929 "suspend": "ENABLED", "suspendTimeoutInSeconds": 30,
1930 "terminate": "ENABLED", "terminateTimeoutInSeconds": 30
1931 },
1932 "microvmImageHooks": {
1933 "ready": "ENABLED", "readyTimeoutInSeconds": 30,
1934 "validate": "ENABLED", "validateTimeoutInSeconds": 30
1935 }
1936 },
1937 "imageArn": "arn:aws:lambda:us-east-1:392583147479:microvm-image:coding-agents-on-bedrock",
1938 "imageVersion": "1.0",
1939 "state": "SUCCESSFUL",
1940 "status": "ACTIVE",
1941 "createdAt": 1755150457.035,
1942 "updatedAt": 1755150629.117
1943 }"#;
1944 let parsed: MicrovmImageVersionSummaryWire =
1945 serde_json::from_str(json).expect("a real GetMicrovmImageVersion response parses");
1946
1947 assert_eq!(parsed.image_version, "1.0");
1948 assert_eq!(parsed.state, "SUCCESSFUL");
1949 assert_eq!(parsed.status, "ACTIVE");
1950 assert!(parsed.is_active());
1951 assert_eq!(
1952 parsed.base_image_arn,
1953 "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
1954 );
1955 // The spelling that is not comparable with the base's own listing: `"1.0"` here,
1956 // against `"0"` and `"1"` from ListManagedMicrovmImageVersions.
1957 assert_eq!(parsed.base_image_version.as_deref(), Some("1.0"));
1958 assert!(
1959 parsed
1960 .code_artifact
1961 .uri
1962 .ends_with("coding-agents-on-bedrock.zip")
1963 );
1964
1965 // `resources` is the only place a built image's size class is observable — GetMicrovm
1966 // reports no memory figure at all.
1967 let resources = parsed
1968 .resources
1969 .as_ref()
1970 .expect("the readback carries the resources");
1971 assert_eq!(resources.len(), 1);
1972 assert_eq!(resources[0].minimum_memory_in_mib, 2048);
1973
1974 // The image-level egress list, whose ceiling is 1 and not the VM-level 10.
1975 let egress = parsed
1976 .egress_network_connectors
1977 .as_ref()
1978 .expect("one connector");
1979 assert_eq!(egress.len(), 1);
1980 assert!(egress.len() <= crate::constants::MAX_IMAGE_EGRESS_CONNECTORS);
1981 assert!(
1982 egress.len() < crate::constants::MAX_NETWORK_CONNECTORS,
1983 "the two ceilings are different numbers; see the constants test that holds the \
1984 inequality"
1985 );
1986
1987 assert_eq!(parsed.state_reason, None, "a successful version has none");
1988 assert_eq!(parsed.updated_at, Some(1_755_150_629.117));
1989 assert_eq!(parsed.describe(), "1.0 SUCCESSFUL / ACTIVE");
1990 }
1991
1992 /// The version summary requires all eight members the model marks required, `status`
1993 /// included.
1994 ///
1995 /// `status` is the one worth the test. It was `Option<String>` while the model requires it,
1996 /// so a service that stopped sending it would have read as `None` — and a version blocked
1997 /// from launching would have looked identical to one nobody had ever set. Confirmed present
1998 /// on all 22 versions in the conformance account (2026-08-16).
1999 ///
2000 /// **Guard proof.** Restore `pub status: Option<String>` and the `status` case here passes
2001 /// where it should fail; `is_active` then cannot be written at all without a default, which
2002 /// is the second half of why the field is bare.
2003 #[test]
2004 fn a_version_summary_missing_a_required_member_fails_to_deserialise() {
2005 let members = [
2006 (
2007 "baseImageArn",
2008 r#""arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1""#,
2009 ),
2010 ("buildRoleArn", r#""arn:aws:iam::1:role/build""#),
2011 ("codeArtifact", r#"{"uri": "s3://b/k"}"#),
2012 (
2013 "imageArn",
2014 r#""arn:aws:lambda:us-east-1:1:microvm-image:i""#,
2015 ),
2016 ("imageVersion", r#""1.0""#),
2017 ("state", r#""SUCCESSFUL""#),
2018 ("status", r#""ACTIVE""#),
2019 ("createdAt", "1754524800"),
2020 ];
2021 for omitted in members.iter().map(|(name, _)| *name) {
2022 let body: Vec<String> = members
2023 .iter()
2024 .filter(|(name, _)| *name != omitted)
2025 .map(|(name, value)| format!(r#""{name}": {value}"#))
2026 .collect();
2027 let error = serde_json::from_str::<MicrovmImageVersionSummaryWire>(&format!(
2028 "{{{}}}",
2029 body.join(", ")
2030 ))
2031 .expect_err(&format!("{omitted} is required by the model"));
2032 assert!(error.to_string().contains(omitted), "{omitted}: {error}");
2033 }
2034 }
2035
2036 /// **`hooks` is not read, and a real response is why.**
2037 ///
2038 /// The model marks every member of `MicrovmHooks` optional, and a real
2039 /// `GetMicrovmImageVersion` on an image built by another tool answered
2040 /// `"microvmHooks": {"run": "ENABLED", "runTimeoutInSeconds": 30}` and nothing else —
2041 /// measured 2026-08-16 against `omnigent-host-vpc` version 3.0, five of eight members
2042 /// absent. This module's request-side [`Hooks`] declares all of them as bare fields, which
2043 /// is correct for a request this client always fills, so reusing it on the response would
2044 /// fail that parse and turn a readable version into a client error.
2045 ///
2046 /// So this test is the record of a deliberate omission: the sparse block parses fine
2047 /// *because* nothing reads it, and reusing `Hooks` here is the change that breaks it.
2048 ///
2049 /// **Guard proof.** Add `pub hooks: Option<Hooks>` to the summary and this test fails with
2050 /// "missing field `resume`".
2051 #[test]
2052 fn a_versions_hooks_block_is_not_read_because_a_real_one_omits_members() {
2053 let json = r#"{
2054 "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
2055 "baseImageVersion": "0.0",
2056 "buildRoleArn": "arn:aws:iam::392583147479:role/bonk-sandbox-microvm-build",
2057 "codeArtifact": {"uri": "s3://bucket/omnigent-host-overlay.zip"},
2058 "resources": [{"minimumMemoryInMiB": 2048}],
2059 "hooks": {
2060 "port": 9000,
2061 "microvmHooks": {"run": "ENABLED", "runTimeoutInSeconds": 30},
2062 "microvmImageHooks": {"ready": "ENABLED", "readyTimeoutInSeconds": 120}
2063 },
2064 "imageArn": "arn:aws:lambda:us-east-1:392583147479:microvm-image:omnigent-host-vpc",
2065 "imageVersion": "3.0",
2066 "state": "SUCCESSFUL",
2067 "status": "ACTIVE",
2068 "createdAt": 1752205430.83
2069 }"#;
2070 let parsed: MicrovmImageVersionSummaryWire = serde_json::from_str(json)
2071 .expect("a sparse hooks block parses precisely because nothing reads it");
2072 assert_eq!(parsed.image_version, "3.0");
2073
2074 // And the request-side type really would refuse that block, which is the fact this
2075 // test exists to hold: the two directions need two shapes.
2076 let refused = serde_json::from_str::<Hooks>(
2077 r#"{"port": 9000,
2078 "microvmHooks": {"run": "ENABLED", "runTimeoutInSeconds": 30},
2079 "microvmImageHooks": {"ready": "ENABLED", "readyTimeoutInSeconds": 120}}"#,
2080 )
2081 .expect_err("the request-side Hooks declares all six enabled flags as required");
2082 assert!(refused.to_string().contains("resume"), "{refused}");
2083 }
2084
2085 /// An `INACTIVE` version reads back as not launchable, and `describe` says so.
2086 ///
2087 /// The pair with the `ACTIVE` case above, so `is_active` is a comparison rather than a
2088 /// function that returns true.
2089 ///
2090 /// **Guard proof.** Invert `is_active` and this fails; compare against a bare `"Active"`
2091 /// literal instead of `VersionStatus::Active.as_str()` and it fails too.
2092 #[test]
2093 fn an_inactive_version_reads_back_as_not_launchable() {
2094 let json = r#"{
2095 "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
2096 "buildRoleArn": "arn:aws:iam::1:role/build",
2097 "codeArtifact": {"uri": "s3://b/k"},
2098 "imageArn": "arn:aws:lambda:us-east-1:1:microvm-image:img",
2099 "imageVersion": "2.0",
2100 "state": "SUCCESSFUL",
2101 "status": "INACTIVE",
2102 "createdAt": 1754524800,
2103 "updatedAt": 1754528400
2104 }"#;
2105 let parsed: MicrovmImageVersionSummaryWire = serde_json::from_str(json).expect("parses");
2106 assert!(
2107 !parsed.is_active(),
2108 "INACTIVE is the version RunMicrovm refuses to launch"
2109 );
2110 assert_eq!(parsed.describe(), "2.0 SUCCESSFUL / INACTIVE");
2111 assert_eq!(
2112 parsed.state, "SUCCESSFUL",
2113 "the state and the status are different questions: a SUCCESSFUL build can be \
2114 retired, which is the whole point of the status member"
2115 );
2116 }
2117
2118 /// The version summary's `describe` carries a reason when there is one, and nothing when
2119 /// there is not.
2120 #[test]
2121 fn a_version_describes_itself_with_only_a_reason_it_has() {
2122 let json = r#"{
2123 "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
2124 "buildRoleArn": "arn:aws:iam::1:role/build",
2125 "codeArtifact": {"uri": "s3://b/k"},
2126 "imageArn": "arn:aws:lambda:us-east-1:1:microvm-image:img",
2127 "imageVersion": "2.0",
2128 "state": "FAILED",
2129 "status": "INACTIVE",
2130 "createdAt": 1754524800,
2131 "stateReason": "one or more builds failed"
2132 }"#;
2133 let parsed: MicrovmImageVersionSummaryWire = serde_json::from_str(json).expect("parses");
2134 assert_eq!(
2135 parsed.describe(),
2136 "2.0 FAILED / INACTIVE (one or more builds failed)"
2137 );
2138 assert!(!parsed.describe().contains("()"), "{}", parsed.describe());
2139 }
2140
2141 /// The update request serialises to **one** member, `status`, spelled as the model spells
2142 /// it — and both enum values are asserted.
2143 ///
2144 /// The key set is asserted exactly, because `imageIdentifier` and `imageVersion` are URI
2145 /// parameters and a serialized copy of either in the body is a member the shape does not
2146 /// declare.
2147 ///
2148 /// **Guard proof.** Drop the `#[serde(rename = "ACTIVE")]` attributes and the values
2149 /// serialise as `"Active"`/`"Inactive"`, which the service refuses as an enum violation —
2150 /// both assertions go red.
2151 #[test]
2152 fn the_update_version_request_is_one_member_spelled_as_the_model_spells_it() {
2153 for (status, expected) in [
2154 (VersionStatus::Active, "ACTIVE"),
2155 (VersionStatus::Inactive, "INACTIVE"),
2156 ] {
2157 let value =
2158 serde_json::to_value(UpdateImageVersionWire { status }).expect("serialises");
2159 let object = value.as_object().expect("an object");
2160 assert_eq!(
2161 object.keys().collect::<Vec<_>>(),
2162 vec!["status"],
2163 "imageIdentifier and imageVersion are uri-located: {value}"
2164 );
2165 assert_eq!(object["status"], expected);
2166 assert_eq!(status.as_str(), expected);
2167 assert_eq!(status.to_string(), expected);
2168 }
2169 }
2170
2171 /// The typed request spelling and the drift gate's published array are the same two
2172 /// values.
2173 ///
2174 /// The coupling worth pinning: `constants::IMAGE_VERSION_STATUSES` is what the gate holds
2175 /// against the model's shape, and [`VersionStatus`] is what a call site can construct. If
2176 /// they disagree the gate passes while the client sends a value the model does not declare.
2177 #[test]
2178 fn the_typed_statuses_are_exactly_the_published_ones() {
2179 let typed: Vec<&str> = [VersionStatus::Active, VersionStatus::Inactive]
2180 .iter()
2181 .map(|status| status.as_str())
2182 .collect();
2183 assert_eq!(typed, crate::constants::IMAGE_VERSION_STATUSES);
2184 }
2185
2186 // ── the managed listings ─────────────────────────────────────────────────
2187
2188 /// `ListManagedMicrovmImageVersionsOutput` from a **real** response.
2189 ///
2190 /// Copied from a live call on 2026-08-16: `al2023-1` answers two versions, `"1"` and
2191 /// `"0"`, newest first. The bare-integer spelling is the finding — a custom image's
2192 /// versions are `"1.0"` — and it is why a version string cannot be parsed by one rule.
2193 #[test]
2194 fn a_managed_version_listing_carries_bare_integer_versions_newest_first() {
2195 let json = r#"{
2196 "items": [
2197 {
2198 "imageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
2199 "imageVersion": "1",
2200 "createdAt": 1753119464.231,
2201 "updatedAt": 1753119644.78
2202 },
2203 {
2204 "imageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
2205 "imageVersion": "0",
2206 "createdAt": 1750180266.531,
2207 "updatedAt": 1750180537.679
2208 }
2209 ]
2210 }"#;
2211 let parsed: ListManagedVersionsResponseWire =
2212 serde_json::from_str(json).expect("the model's own spelling parses");
2213 assert_eq!(parsed.items.len(), 2);
2214 assert_eq!(parsed.items[0].image_version, "1");
2215 assert_eq!(parsed.items[1].image_version, "0");
2216 assert_eq!(parsed.next_token, None, "one page, so the loop stops");
2217 assert_eq!(
2218 parsed.items[0].image_arn,
2219 "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
2220 );
2221 assert_eq!(parsed.items[0].updated_at, Some(1_753_119_644.78));
2222
2223 // Bare integers, not the `major.minor` a custom image uses. A client that parsed one
2224 // format would not parse the other, and neither can be compared with the `"1.0"` that
2225 // GetMicrovmImageVersion echoes as `baseImageVersion`.
2226 for item in &parsed.items {
2227 assert!(
2228 !item.image_version.contains('.'),
2229 "a managed base's versions are bare integers: {}",
2230 item.image_version
2231 );
2232 }
2233 }
2234
2235 /// `ManagedMicrovmImageSummary` is an ARN and two timestamps, and **that is all**.
2236 ///
2237 /// Asserted as an absence, which is the reason this shape cannot construct a
2238 /// [`crate::control::BaseImage`]: no registry reference for `require_matching_from` to
2239 /// compare a Dockerfile against, and no working directory for `require_workdir` to check.
2240 /// The absence is checked by deserializing a response that *does* carry extra members and
2241 /// showing there is no field to read them into — the same technique
2242 /// `a_version_summary_carries_the_state_reason_get_image_cannot` uses.
2243 #[test]
2244 fn a_managed_image_summary_carries_no_registry_ref_so_it_cannot_build_a_base_image() {
2245 let json = r#"{
2246 "items": [{
2247 "imageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
2248 "createdAt": 1750180266.531,
2249 "updatedAt": 1753119644.78
2250 }]
2251 }"#;
2252 let parsed: ListManagedImagesResponseWire =
2253 serde_json::from_str(json).expect("the model's own spelling parses");
2254 assert_eq!(parsed.items.len(), 1, "measured 2026-08-16: exactly one");
2255 assert_eq!(
2256 parsed.items[0].image_arn,
2257 "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
2258 );
2259
2260 // The whole struct, as JSON, is three keys. There is nothing here a BaseImage's
2261 // `docker_ref` or `working_dir` could come from, and the ARN does not imply either:
2262 // `al2023-1` pairs with public.ecr.aws/amazonlinux/amazonlinux:2023-minimal, and
2263 // nothing in the ARN says so.
2264 let base = crate::control::BaseImage::al2023();
2265 assert_eq!(
2266 base.docker_ref, "public.ecr.aws/amazonlinux/amazonlinux:2023-minimal",
2267 "the pairing a discovered base cannot supply"
2268 );
2269 assert!(
2270 !parsed.items[0].image_arn.contains(&base.docker_ref),
2271 "the ARN does not carry the registry ref, so discovery cannot derive it"
2272 );
2273
2274 // A required member missing still fails, so the three that *are* there are load-bearing.
2275 let error = serde_json::from_str::<ManagedMicrovmImageSummaryWire>(
2276 r#"{"createdAt": 1750180266.531}"#,
2277 )
2278 .expect_err("imageArn is required");
2279 assert!(error.to_string().contains("imageArn"), "{error}");
2280 }
2281
2282 // ── the two unsent request members ───────────────────────────────────────
2283
2284 /// `CreateMicrovmImage.baseImageVersion` reaches the wire when pinned and is **absent**
2285 /// when it is not.
2286 ///
2287 /// Absent rather than null or empty, because an absent member means "whatever the service
2288 /// defaults to" while a blank one is a `ValidationException` on the `Version` shape's
2289 /// `min: 1`. The two are different requests and only one of them is legal.
2290 ///
2291 /// **Guard proof.** Remove `skip_serializing_if` and the absence assertion fails with a
2292 /// `"baseImageVersion": null` on every unpinned build — which the service rejects.
2293 #[test]
2294 fn a_pinned_base_image_version_reaches_the_wire_and_an_unpinned_one_is_omitted() {
2295 let mut wire = minimal_create_wire();
2296 wire.base_image_version = Some("1".to_string());
2297 let value = serde_json::to_value(&wire).expect("serialises");
2298 assert_eq!(value["baseImageVersion"], "1");
2299
2300 let mut unpinned = minimal_create_wire();
2301 unpinned.base_image_version = None;
2302 let value = serde_json::to_value(&unpinned).expect("serialises");
2303 assert!(
2304 !value
2305 .as_object()
2306 .expect("object")
2307 .contains_key("baseImageVersion"),
2308 "omitted, not null: an absent member takes the service default, and a blank one \
2309 is a ValidationException: {value}"
2310 );
2311 }
2312
2313 /// `RunMicrovm.imageVersion` reaches the wire when pinned and is **absent** when it is not.
2314 ///
2315 /// The absence is what this client always sent, so the field existing must not change an
2316 /// unpinned launch by a byte — a `"imageVersion": null` on every launch would be a new
2317 /// member on a request that has worked for months.
2318 ///
2319 /// **Guard proof.** Remove `skip_serializing_if` and the absence assertion fails.
2320 #[test]
2321 fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_is_omitted() {
2322 let mut wire = minimal_run_wire();
2323 wire.image_version = Some("2.0".to_string());
2324 let value = serde_json::to_value(&wire).expect("serialises");
2325 assert_eq!(value["imageVersion"], "2.0");
2326 assert_eq!(
2327 value["imageIdentifier"], "arn:image",
2328 "the version does not replace the identifier; both are sent"
2329 );
2330
2331 let value = serde_json::to_value(minimal_run_wire()).expect("serialises");
2332 assert!(
2333 !value
2334 .as_object()
2335 .expect("object")
2336 .contains_key("imageVersion"),
2337 "an unpinned launch must emit byte-for-byte what this client always sent: {value}"
2338 );
2339 }
2340
2341 fn minimal_run_wire() -> RunMicrovmWire {
2342 RunMicrovmWire {
2343 image_identifier: "arn:image".to_string(),
2344 image_version: None,
2345 execution_role_arn: None,
2346 ingress_network_connectors: Vec::new(),
2347 egress_network_connectors: None,
2348 idle_policy: IdlePolicy {
2349 max_idle_duration_seconds: 600,
2350 suspended_duration_seconds: 600,
2351 auto_resume_enabled: false,
2352 },
2353 maximum_duration_in_seconds: 3_600,
2354 run_hook_payload: String::new(),
2355 client_token: "run-arn-0011223344556677".to_string(),
2356 }
2357 }
2358
2359 fn minimal_create_wire() -> CreateMicrovmImageWire {
2360 CreateMicrovmImageWire {
2361 name: "img".to_string(),
2362 base_image_arn: "arn:base".to_string(),
2363 base_image_version: None,
2364 build_role_arn: "arn:role".to_string(),
2365 code_artifact: CodeArtifact {
2366 uri: "s3://b/k".to_string(),
2367 },
2368 cpu_configurations: vec![CpuConfiguration {
2369 architecture: "ARM_64".to_string(),
2370 }],
2371 resources: vec![Resources {
2372 minimum_memory_in_mib: 2048,
2373 }],
2374 hooks: Hooks {
2375 port: 9000,
2376 microvm_hooks: MicrovmHooks {
2377 run: HookState::Enabled,
2378 run_timeout_in_seconds: 30,
2379 resume: HookState::Enabled,
2380 resume_timeout_in_seconds: 30,
2381 suspend: HookState::Enabled,
2382 suspend_timeout_in_seconds: 30,
2383 terminate: HookState::Enabled,
2384 terminate_timeout_in_seconds: 30,
2385 },
2386 microvm_image_hooks: MicrovmImageHooks {
2387 ready: HookState::Enabled,
2388 ready_timeout_in_seconds: 300,
2389 validate: HookState::Enabled,
2390 validate_timeout_in_seconds: 300,
2391 },
2392 },
2393 additional_os_capabilities: None,
2394 logging: None,
2395 tags: None,
2396 client_token: "create-img-0011223344556677".to_string(),
2397 }
2398 }
2399
2400 // ── the logging union ─────────────────────────────────────────────────────
2401
2402 /// **Each `Logging` variant serialises as the member name the model declares, with
2403 /// nothing else alongside it** — the same claim
2404 /// [`each_port_specification_variant_serialises_as_its_model_member`] holds for the
2405 /// other union this client sends.
2406 ///
2407 /// **Guard proof.** Dropping `#[serde(untagged)]` makes both cases fail with the
2408 /// variant name as the key (`{"Disabled": ..}`); making `LoggingDisabled` a unit
2409 /// struct serialises it as `null` and the object assertion fails. Both applied and
2410 /// observed.
2411 #[test]
2412 fn each_logging_variant_serialises_as_its_model_member() {
2413 let cases = [
2414 (Logging::disabled(), "disabled", serde_json::json!({})),
2415 (
2416 Logging::cloud_watch("/aws/lambda-microvms/builds", None),
2417 "cloudWatch",
2418 serde_json::json!({"logGroup": "/aws/lambda-microvms/builds"}),
2419 ),
2420 (
2421 Logging::cloud_watch(
2422 "/aws/lambda-microvms/builds",
2423 Some("img/deadbeef00010203".to_string()),
2424 ),
2425 "cloudWatch",
2426 serde_json::json!({
2427 "logGroup": "/aws/lambda-microvms/builds",
2428 "logStream": "img/deadbeef00010203"
2429 }),
2430 ),
2431 ];
2432 for (logging, key, expected) in cases {
2433 let value = serde_json::to_value(&logging).expect("serialises");
2434 let object = value.as_object().expect("a union member is an object");
2435 assert_eq!(
2436 object.keys().collect::<Vec<_>>(),
2437 vec![key],
2438 "a union serialises as exactly one member named by the model: {value}"
2439 );
2440 assert_eq!(object[key], expected, "{key} carries the model's shape");
2441 }
2442 }
2443
2444 /// A configured `logging` reaches the wire under the model's member name, and an
2445 /// unconfigured one is **absent** — not `null`, not `{}`.
2446 ///
2447 /// The absence half is the compatibility claim: a create with no logging config must
2448 /// emit byte-for-byte the request this client always sent, because an absent member
2449 /// takes the service default and a `"logging": null` is a new member on a request
2450 /// that has worked for months.
2451 ///
2452 /// **Guard proof.** Remove `skip_serializing_if` from `logging` and the absence
2453 /// assertion fails with `"logging": null` on every unconfigured create.
2454 #[test]
2455 fn a_configured_logging_reaches_the_wire_and_an_unconfigured_one_is_omitted() {
2456 let mut wire = minimal_create_wire();
2457 wire.logging = Some(Logging::cloud_watch(
2458 "/aws/lambda-microvms/builds",
2459 Some("img/deadbeef00010203".to_string()),
2460 ));
2461 let value = serde_json::to_value(&wire).expect("serialises");
2462 assert_eq!(
2463 value["logging"]["cloudWatch"]["logGroup"],
2464 "/aws/lambda-microvms/builds"
2465 );
2466 assert_eq!(
2467 value["logging"]["cloudWatch"]["logStream"],
2468 "img/deadbeef00010203"
2469 );
2470
2471 let value = serde_json::to_value(minimal_create_wire()).expect("serialises");
2472 assert!(
2473 !value.as_object().expect("object").contains_key("logging"),
2474 "omitted, not null: an absent member takes the service default: {value}"
2475 );
2476 }
2477
2478 /// A group with no stream serialises without a `logStream` key at all: the member is
2479 /// optional in the model, and an absent stream means "the service names the streams"
2480 /// while an empty one is a `ValidationException` on the shape's `min: 1`.
2481 #[test]
2482 fn a_group_only_logging_config_omits_the_stream_member() {
2483 let value = serde_json::to_value(Logging::cloud_watch("/aws/lambda-microvms/b", None))
2484 .expect("serialises");
2485 let cloud_watch = value["cloudWatch"].as_object().expect("an object");
2486 assert_eq!(
2487 cloud_watch.keys().collect::<Vec<_>>(),
2488 vec!["logGroup"],
2489 "an absent stream is omitted, not empty: {value}"
2490 );
2491 }
2492}