Skip to main content

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}