Skip to main content

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