microvms_app/control/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The control-plane client: SigV4-signed rest-json, and every trap closed before the
3//! request leaves the process.
4//!
5//! # The shape of the thing
6//!
7//! [`ControlPlane`] is the whole surface. It holds a [`transport::Transport`] (signed and
8//! real, or a contract recorder in a test), a [`Region`], and a [`Clock`] — and every
9//! operation is a method on it. There is no builder, no config struct, and no partially
10//! constructed state: `ControlPlane::new` (`microvms_core::prelude::ControlPlaneExt`) resolves
11//! credentials and either yields a usable client or says why not.
12//!
13//! # What is closed here, and how strongly
14//!
15//! * **TRAP-1** — [`token`]: image tokens are always fresh. Run tokens are fresh unless
16//! explicitly persisted for recovery of one launch. Never reuse them for a new VM.
17//! * **TRAP-2** — [`image`]: a build stuck in `CREATING` past the stall grace with every
18//! build `PENDING` is rejected naming the replay signature. S2.
19//! * **TRAP-3** — [`CreateImageRequest::repair_guest_identity`] is a `bool`, and the
20//! request injects `["ALL"]` itself. There is no capability list to put
21//! `CAP_SYS_ADMIN` in. S1.
22//! * **TRAP-4** — [`connector::ConnectorIntent`]: connectors are derived ARNs from a
23//! closed intent enum. S1.
24//! * **TRAP-5** — [`RunHookPayload`]: 4096 bytes inclusive, checked before any call. S2,
25//! and the type means an unchecked string cannot reach the field.
26//! * **TRAP-8** — [`ControlPlane::wait_for_running`]: a terminal state before RUNNING is
27//! rejected with the state *and* `stateReason` attached. S2.
28//! * **TRAP-11, revised again** — `mint_shell_auth_token` exists now, and the guard
29//! changed ground with it: the closure is no longer the method's absence but the exec
30//! path's separation from it, which is why the test still counts the calls a full
31//! lifecycle made and asserts zero shell operations among them. The connector half is
32//! unchanged: `SHELL_INGRESS` is a [`connector::ConnectorIntent`] variant, and the
33//! lifecycle test asserts a launch carries exactly the connectors its caller asked
34//! for. See [`connector`].
35//!
36//! # TRAP-11 is the one that needs saying out loud
37//!
38//! `CreateMicrovmShellAuthToken` is implemented — issue #69 builds `microvm shell` on
39//! it, and [`ControlPlane::mint_shell_auth_token`] is its one door. The original reason
40//! for staying away entirely — that the shell gates a console-only debugging flow, not
41//! a programmatic exec path — did not survive measurement: `docs/PLATFORM.md`
42//! (2026-08-15) found a real PTY over a WebSocket, programmatically drivable. What
43//! holds is narrower: **one interactive session is not programmatic exec** — no exec
44//! ids, no idempotency, no separated stdout/stderr, no exit codes — so the exec path
45//! never requests the shell connector and never mints a shell token, and the shell is
46//! its own surface ([`crate::session::shell`]) rather than a method the exec path can
47//! wander into.
48//!
49//! # Nothing in this module reads the service model at runtime
50//!
51//! Every constraint is a constant in [`crate::constants`], checked by the build gate
52//! against the pinned model (TRAP-12). That matters because botocore's
53//! `VALIDATED_METADATA_ATTRS` is `{required, min, document, union}` — `max`, `pattern`,
54//! and `enum` violations go to the wire — so every guard in this module is load-bearing
55//! rather than belt-and-braces, and "the SDK validates the model already" was never true.
56//!
57//! # And "botocore validates `min`" was never true *of this client*
58//!
59//! The sentence above is about `max`, `pattern`, and `enum`, and for a while it left `min`
60//! implicitly exempt: `min` **is** in `VALIDATED_METADATA_ATTRS`, so a `min` violation really is
61//! refused before the wire — by botocore. This client does not use botocore. It signs with
62//! `aws-sigv4` and sends with `reqwest`, and `validate.py` is nowhere in the dependency graph.
63//! That reasoning came from the deleted Python client, where it held, and it did not survive the
64//! port; issue #24 measured the consequence, `maxIdleDurationSeconds: 59` on the wire.
65//!
66//! So the rule is simpler than it was: **every constraint the model states on a member this
67//! client sends is enforced by this module or by nothing.** The guards below are the whole of it:
68//!
69//! * [`require_valid_image_name`] — `ImageName` (1..=64, `[a-zA-Z0-9-_]+`).
70//! * [`require_duration_in_range`] — `maximumDurationInSeconds` (1..=28800).
71//! * [`require_valid_version`] — `Version` (1..=2048, `[^\s]+`).
72//! * [`require_non_blank`] — `NonBlankString`, the model's most-reused shape.
73//! * [`require_valid_identifier`] — `MicrovmIdentifier`/`MicrovmImageIdentifier` (1..=256).
74//! * [`require_valid_role_arn`] — `RoleArn` (20..=2048, plus the twelve-digit account).
75//! * [`require_valid_port`] — `PortNumber`/`HooksPortInteger` (`min: 1`; 0 is not a port).
76//! * [`require_idle_duration`] — `maxIdleDurationSeconds` (`min: 60`).
77//! * [`require_valid_tags`] — `TagKey`/`TagValue`, two ceilings and two minima.
78//!
79//! Three constraints are closed by a **type** rather than by a function, which is the stronger
80//! form: [`RunHookPayload`] cannot hold over 4096 bytes, [`ops::VersionStatus`] and
81//! [`ops::HookState`] cannot spell a value the enum does not have, and
82//! [`connector::ConnectorIntent`] cannot name a connector the platform does not publish. A
83//! constraint the type system enforces cannot be forgotten at a call site.
84
85pub mod artifact;
86pub mod connector;
87pub mod context;
88pub mod ensure;
89pub mod image;
90pub mod microvm;
91pub mod ops;
92pub mod project;
93pub mod services;
94pub mod sweep;
95pub mod token;
96pub mod transport;
97
98use std::sync::Arc;
99use std::time::Duration;
100
101pub use artifact::{
102 BaseImage, Ecosystem, ProjectFiles, WrapOptions, artifact_content_hash, build_artifact,
103 default_dockerfile, wrap_dockerfile, wrap_dockerfile_preserving_user,
104};
105pub use connector::{
106 ConnectorIntent, EgressPosture, PLATFORM_HONOURS_OMITTED_EGRESS, egress_posture_for,
107};
108pub use context::{BuildContext, ContextEntry};
109pub use ensure::{EnsureImageRequest, EnsuredImage};
110pub use image::{Image, WaitOpts};
111pub use microvm::{Microvm, MicrovmFilter, ProxyToken, RunHookPayload};
112pub use services::BuildServices;
113pub use sweep::{SweepFailure, SweepOptions, SweepReport};
114
115use crate::error::{Error, ErrorKind};
116
117/// A successful HTTP response retains its request telemetry through decoding.
118struct ReportedReply {
119 reply: transport::Reply,
120 details: transport::RetryDetails,
121}
122
123impl ReportedReply {
124 fn json<T: serde::de::DeserializeOwned>(&self, operation: &str) -> Result<T, Error> {
125 self.reply
126 .json(operation)
127 .map_err(|error| self.error(error))
128 }
129
130 fn error(&self, error: Error) -> Error {
131 error.with_retry_details(self.details.attempt_count, self.details.backoff)
132 }
133}
134
135use crate::hooks::{BuildHookTimeout, RunHookTimeout};
136use crate::region::Region;
137use crate::sizing::SizeClass;
138
139/// Compatibility alias for the shared daemon listener default.
140pub const DEFAULT_AGENT_PORT: u16 = protocol::DEFAULT_AGENT_PORT;
141
142use crate::adapters::Adapters;
143/// The clock every wait here measures its deadline on. It's the crate's one [`Clock`], so the
144/// plane and the sessions it hands out read the same time source.
145pub use crate::clock::Clock;
146use crate::entropy::Entropy;
147
148/// The control-plane client.
149///
150/// The transport, clock, entropy and adapters sit behind `Arc`, so a caller holding one across
151/// tasks does not need a second credential chain.
152pub struct ControlPlane {
153 transport: Arc<dyn transport::Transport>,
154 region: Region,
155 clock: Arc<dyn Clock>,
156 /// Where client-token nonces, agent tokens and identity seeds come from.
157 entropy: Arc<dyn Entropy>,
158 /// What a sandbox over this plane builds its session backend and its STS and S3 client
159 /// from.
160 adapters: Arc<dyn Adapters>,
161 /// The agent port, which the hooks block and the proxy token both need.
162 port: u16,
163 retry: Option<transport::RetryPolicy>,
164}
165
166impl std::fmt::Debug for ControlPlane {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.debug_struct("ControlPlane")
169 .field("region", &self.region)
170 .field("port", &self.port)
171 .finish_non_exhaustive()
172 }
173}
174
175impl ControlPlane {
176 /// A client over caller-supplied ports, on the default agent port.
177 ///
178 /// Every port is explicit, including the entropy. A plane over a scripted transport
179 /// still mints client tokens, and a deterministic default would mint repeating ones
180 /// (TRAP-1), so nothing here picks a source for the caller.
181 ///
182 /// `ControlPlane::new(region)` and `ControlPlane::with_transport(transport, region, clock)`
183 /// are in `microvms_core::prelude`: they wire the production transport, clock, entropy and
184 /// adapters, which is I/O this constructor leaves to its caller. A test builds one with
185 /// `testing::control_plane` instead.
186 pub fn from_ports(
187 transport: Arc<dyn transport::Transport>,
188 region: Region,
189 clock: Arc<dyn Clock>,
190 entropy: Arc<dyn Entropy>,
191 adapters: Arc<dyn Adapters>,
192 ) -> Self {
193 Self {
194 transport,
195 region,
196 clock,
197 entropy,
198 adapters,
199 port: DEFAULT_AGENT_PORT,
200 retry: None,
201 }
202 }
203
204 /// Uses a custom budget for throttles only; other retries retain their defaults.
205 #[must_use]
206 pub fn with_retry(mut self, retry: transport::RetryPolicy) -> Self {
207 self.retry = Some(retry);
208 self
209 }
210
211 async fn send(&self, call: transport::Call) -> Result<ReportedReply, Error> {
212 self.send_reported(call, &[]).await
213 }
214
215 async fn send_reported(
216 &self,
217 call: transport::Call,
218 accept: &[u16],
219 ) -> Result<ReportedReply, Error> {
220 let (reply, details) = transport::send_configured(
221 self.transport(),
222 call,
223 accept,
224 self.retry,
225 self.clock.as_ref(),
226 )
227 .await?;
228 Ok(ReportedReply { reply, details })
229 }
230
231 /// Replaces the source client-token nonces, agent tokens and identity seeds are drawn
232 /// from. The seam a test pins those values through.
233 #[must_use]
234 pub fn with_entropy(mut self, entropy: Arc<dyn Entropy>) -> Self {
235 self.entropy = entropy;
236 self
237 }
238
239 /// Sets the agent port the hooks block and the proxy token name.
240 ///
241 /// # Why this is fallible where every other builder method is not
242 ///
243 /// Because `port` is the one value on this type that the model bounds, and 0 is a value a
244 /// caller reaches for on purpose. `PortNumber` and `HooksPortInteger` are both `min: 1`;
245 /// `u16` closes the ceiling and nothing closed the floor, so before this returned a `Result`
246 /// the port set here landed in two places on the wire — `hooks.port` on every
247 /// `CreateMicrovmImage` built through the plane, and `allowedPorts: [{"port": 0}]` on every
248 /// token it minted (issue #24).
249 ///
250 /// Both of those are guarded at the call itself as well ([`require_valid_port`] in
251 /// [`ControlPlane::create_image`] and in [`ControlPlane::mint_auth_token_for`]), so the
252 /// constraint would be closed without this. It is fallible anyway because the two refusals
253 /// arrive a long way from the mistake: the create one after an artifact upload, and the mint
254 /// one at the moment a session needs a credential. Refusing here means `--port 0` fails
255 /// before a control plane is even usable, which is the difference between "that port is not
256 /// legal" and "your launch failed".
257 ///
258 /// Zero rather than the ceiling is what this actually catches, and it is not a typo case: 0
259 /// is what "let the kernel choose" means to a listener, so it is the value a caller passes
260 /// when they have a socket they have not bound yet.
261 pub fn with_port(mut self, port: u16) -> Result<Self, Error> {
262 require_valid_port("port", port)?;
263 self.port = port;
264 Ok(self)
265 }
266
267 /// The region every ARN in a request is derived for.
268 pub fn region(&self) -> &Region {
269 &self.region
270 }
271
272 /// The agent port.
273 pub fn port(&self) -> u16 {
274 self.port
275 }
276
277 pub(crate) fn transport(&self) -> &dyn transport::Transport {
278 self.transport.as_ref()
279 }
280
281 pub(crate) fn clock(&self) -> &dyn Clock {
282 self.clock.as_ref()
283 }
284
285 /// The source this plane draws client-token nonces, agent tokens and identity seeds from.
286 ///
287 /// Public so the composition root's wiring can be checked from outside this crate: a
288 /// production plane must carry the OS pool, never a deterministic source (TRAP-1).
289 pub fn entropy(&self) -> &dyn Entropy {
290 self.entropy.as_ref()
291 }
292
293 /// The clock itself, for a session that has to read the same one.
294 pub(crate) fn shared_clock(&self) -> Arc<dyn Clock> {
295 Arc::clone(&self.clock)
296 }
297
298 pub(crate) fn adapters(&self) -> &dyn Adapters {
299 self.adapters.as_ref()
300 }
301}
302
303/// Everything `CreateMicrovmImage` needs, with the traps closed in the type.
304///
305/// # What is deliberately not here
306///
307/// A `client_token` field. See [`token`] for the fifteen-hour wedge that absence prevents;
308/// [`CreateImageRequest::token_scope`] is a **label** folded in beside a fresh nonce and
309/// cannot become the token.
310///
311/// A `capabilities` list. See `repair_guest_identity`.
312///
313/// An `architecture` field. The model's enum has exactly one value, so the only thing a
314/// field could express is a rejected request — and the rejection arrives *after* the
315/// artifact upload, reported as a constraint on a field the caller thought was a choice.
316/// `ARM_64` is injected. A caller who built an x86 binary finds out from `doctor`'s ELF
317/// check, not from a build cycle.
318#[derive(Clone, Debug)]
319pub struct CreateImageRequest {
320 /// The image name. Validated against `ImageName` (1..=64, `[a-zA-Z0-9-_]+`) before the
321 /// artifact is uploaded, because AWS's rejection arrives *after* the upload.
322 pub name: String,
323 /// The base image, pairing the `baseImageArn` with the Dockerfile `FROM`.
324 pub base_image: BaseImage,
325 /// `baseImageVersion`, or `None` to take whatever the service currently defaults to.
326 ///
327 /// # Why pinning is worth a field
328 ///
329 /// Without it a build floats. The managed base's version list is not static — `al2023-1`
330 /// carried one version in June and two by July (`"0"` and `"1"`, measured 2026-08-16) — so
331 /// two builds of identical inputs weeks apart can sit on different bases, and neither
332 /// recorded which. That is a reproducibility hole with no local symptom: the build
333 /// succeeds either way, and the difference appears in the guest.
334 ///
335 /// `None` is the default and stays the default, because pinning without knowing the legal
336 /// values is how a caller pins to a version that has been withdrawn.
337 /// [`ControlPlane::managed_base_versions`] is what answers that, and the version strings
338 /// are **bare integers** for a managed base where a custom image's are `"1.0"` — the two
339 /// are not comparable, so a value from anywhere else does not belong here.
340 ///
341 /// Checked against the `Version` shape before the call, because the create happens after
342 /// the artifact upload.
343 pub base_image_version: Option<String>,
344 /// The Dockerfile, or `None` for the derived default.
345 ///
346 /// A caller-supplied one is checked against `base_image`'s `docker_ref`: the two
347 /// disagreeing builds against a base none of the measured platform behaviour applies
348 /// to.
349 pub dockerfile: Option<String>,
350 /// The daemon binary's bytes, zipped into the artifact.
351 pub binary: Vec<u8>,
352 /// The project's dependency files — the lockfile plus its manifest — zipped into the
353 /// artifact beside the Dockerfile, or `None` for an image with no environment layer.
354 ///
355 /// This is what lets the derived Dockerfile's install step (`uv sync --locked`,
356 /// `npm ci`, `cargo fetch` per [`Ecosystem`]) see the files it installs from (#74).
357 /// The entry names are fixed by the ecosystem; there is no way to put an arbitrary
358 /// file into the shared snapshot through this field, which is the TRAP-5 property
359 /// restated as a type.
360 pub project_files: Option<ProjectFiles>,
361 /// Where the artifact is uploaded to. `create_image` does not upload: the caller puts
362 /// the bytes there and passes the URI. [`crate::sandbox::Sandbox::ensure_image`] is the
363 /// path that uploads for itself ([`services`]).
364 pub code_artifact_uri: String,
365 /// The build role, which must grant logs on `/aws/lambda-microvms/*`.
366 pub build_role_arn: String,
367 /// The size class. Selects a documented baseline; it does not size the VM (TRAP-10).
368 pub size: SizeClass,
369 /// Whether to repair guest identity (TRAP-3).
370 ///
371 /// # Why a bool and not a capability list
372 ///
373 /// Measured 2026-08-06: without the capability, a guest running as root still gets
374 /// `EPERM` from `sethostname` and from a bind mount over
375 /// `/proc/sys/kernel/random/boot_id`, because the MicroVM drops `CAP_SYS_ADMIN` by
376 /// default. Writing `/etc/machine-id` needs no capability and succeeds either way,
377 /// which is what makes the gap easy to miss — identity repair looks like it works
378 /// until you check the two steps that need the kernel's permission rather than the
379 /// filesystem's.
380 ///
381 /// `Capability`'s enum is exactly `["ALL"]`, so there is no way to ask for
382 /// `CAP_SYS_ADMIN` alone. A list parameter would let a caller write
383 /// `["CAP_SYS_ADMIN"]` — the request AWS rejects *after* the artifact upload — so the
384 /// intent is a boolean and the request injects `["ALL"]` itself.
385 pub repair_guest_identity: bool,
386 /// Whether the daemon should inherit the image's `WORKDIR`.
387 ///
388 /// Rejected when nothing declares one: most public ARM64 bases leave it empty, so the
389 /// inheritance silently resolves to `/` and the symptom appears in the guest a build
390 /// cycle later.
391 pub inherit_workdir: bool,
392 /// The run-family hook timeout: `run`, `resume`, `suspend`, `terminate`. Caps at 60s,
393 /// and the type is what keeps a build-sized value out of it.
394 pub run_hook_timeout: RunHookTimeout,
395 /// The build-family hook timeout: `ready`, `validate`. Caps at 3600s.
396 pub build_hook_timeout: BuildHookTimeout,
397 /// Tags, or none.
398 pub tags: std::collections::BTreeMap<String, String>,
399 /// `logging.cloudWatch.logGroup`, or `None` for the service default — a
400 /// service-created group under `/aws/lambda-microvms/<image-name>` with random stream
401 /// names.
402 ///
403 /// Checked against `CloudWatchLoggingLogGroupString` (1..=512, `[a-zA-Z0-9_\-/.#]+`)
404 /// before the artifact upload. The build role must be able to write to whatever this
405 /// names: the conformance account's role grants logs only on
406 /// `/aws/lambda-microvms/*`, so a group outside that prefix builds with no logs at
407 /// all — the same failure mode as the wrong-prefix policy (docs/PLATFORM.md).
408 pub log_group: Option<String>,
409 /// `logging.cloudWatch.logStream` — a stream-name **prefix**, never the exact name.
410 ///
411 /// # The client always appends a per-build discriminator
412 ///
413 /// The wire member is an EXACT stream name (prefixes unsupported — measured 2026-08,
414 /// docs/PLATFORM.md 'An image build is three VMs and three log streams'), and one
415 /// build emits three streams. A fixed configured name collapses all three of every
416 /// build into one stream, so [`ControlPlane::create_image`] sends
417 /// `<this>/<16 hex>` with fresh CSPRNG per create attempt — the same mechanism as
418 /// the `clientToken` nonce (TRAP-1) — and the resolved name comes back on
419 /// [`image::Image::log_stream`] so the caller can find their logs.
420 ///
421 /// Capped at [`crate::constants::MAX_USER_LOG_STREAM_LEN`] (495) so the resolved name
422 /// fits the shape's 512, and refused when it carries `:` or `*` (the pattern is
423 /// `[^:*]*`). Requires [`Self::log_group`]: a stream inside a group the service
424 /// chose randomly is a stream nobody can predict the location of.
425 pub log_stream: Option<String>,
426 /// A **label** folded into the create token for CloudTrail readability, defaulting to
427 /// the image name. Not the token, and cannot become it (TRAP-1).
428 pub token_scope: Option<String>,
429}
430
431impl CreateImageRequest {
432 /// A request with the measured defaults: the al2023 base, the default size class, the
433 /// derived Dockerfile, identity repair off, and 30-second hook timeouts.
434 ///
435 /// The hook timeouts are `expect`ed rather than propagated because 30 is legal for both
436 /// families by construction — it is below the run family's 60s ceiling, which is the
437 /// lower of the two — and a `Result` here would put a `?` on every call site for a
438 /// value this function chose itself.
439 pub fn new(
440 name: impl Into<String>,
441 binary: Vec<u8>,
442 code_artifact_uri: impl Into<String>,
443 build_role_arn: impl Into<String>,
444 ) -> Self {
445 Self {
446 name: name.into(),
447 base_image: BaseImage::al2023(),
448 base_image_version: None,
449 dockerfile: None,
450 binary,
451 project_files: None,
452 code_artifact_uri: code_artifact_uri.into(),
453 build_role_arn: build_role_arn.into(),
454 size: SizeClass::DEFAULT,
455 repair_guest_identity: false,
456 inherit_workdir: false,
457 run_hook_timeout: RunHookTimeout::try_new(30).expect("30s is under the 60s ceiling"),
458 build_hook_timeout: BuildHookTimeout::try_new(30)
459 .expect("30s is under the 3600s ceiling"),
460 tags: std::collections::BTreeMap::new(),
461 log_group: None,
462 log_stream: None,
463 token_scope: None,
464 }
465 }
466}
467
468/// Everything `RunMicrovm` needs.
469///
470/// `run_hook_payload` is a [`RunHookPayload`], which cannot hold an over-ceiling value —
471/// so TRAP-5 is closed before this struct exists rather than checked when it is used.
472#[derive(Clone, Debug)]
473pub struct RunMicrovmRequest {
474 /// The image ARN or ID to launch.
475 pub image_identifier: String,
476 /// `imageVersion`, or `None` for the image's own latest active version.
477 ///
478 /// # The launch half of blue/green
479 ///
480 /// `None` is what this client always sent, and it means "whatever
481 /// `latestActiveImageVersion` is now" — which is right for the ordinary case and wrong for
482 /// the two that matter. A canary wants to launch against exactly the version it is
483 /// testing, not against whatever became latest while it was starting. And a rollback wants
484 /// to re-pin to the known-good version, which "latest" cannot express at all once a bad
485 /// version is the latest one.
486 ///
487 /// With [`ControlPlane::set_image_version_status`] the three moves compose: build v2,
488 /// canary-launch pinned to v2, and on failure set v2 INACTIVE and re-pin to v1. A version
489 /// set INACTIVE refuses to launch when pinned here, which is what makes the retire real
490 /// rather than advisory.
491 ///
492 /// Checked against the `Version` shape before the call — see
493 /// [`require_valid_version`] on why a rollback is the worst moment for a rejection about
494 /// the request.
495 pub image_version: Option<String>,
496 /// The execution role. Optional in the model; every real launch needs one.
497 pub execution_role_arn: Option<String>,
498 /// The connectors to request, as intents (TRAP-4). Ingress is required for a session
499 /// to work at all; omitting egress omits the connector from the request (measured
500 /// 2026-09-12: the platform gave the VM outbound network anyway, `docs/PLATFORM.md`).
501 pub connectors: Vec<ConnectorIntent>,
502 /// Customer-managed Lambda VPC egress connector ARNs, created through `lambda-core`.
503 /// Cannot be combined with [`ConnectorIntent::Egress`]. Internet isolation requires
504 /// a VPC without an internet gateway or NAT gateway; an ARN alone proves no isolation.
505 pub egress_network_connectors: Vec<String>,
506 /// The already-validated payload carrying the agent token.
507 pub run_hook_payload: RunHookPayload,
508 /// `maximumDurationInSeconds`, checked against 1..=28800 when the request is sent.
509 pub max_duration_sec: u32,
510 /// `idlePolicy.maxIdleDurationSeconds`, checked against the model's `min: 60` before the call.
511 ///
512 /// This comment used to say the guard was deliberately absent, because `min` is one of the four
513 /// keys botocore's `VALIDATED_METADATA_ATTRS` enforces locally. True of botocore, and this
514 /// client never touches it — see [`require_idle_duration`]. `59` reached the wire.
515 pub max_idle_sec: u32,
516 /// `idlePolicy.suspendedDurationSeconds`.
517 ///
518 /// The model's `min` is **0**, so every `u32` satisfies it and there is no guard — an absence
519 /// with a reason rather than an oversight, and the drift gate pins the 0 so a future floor is
520 /// noticed. Its ceiling is unstated.
521 ///
522 /// The requested value drives the local resume window. The service also reports its
523 /// active policy in [`Microvm::idle_policy`].
524 pub suspended_sec: u32,
525 /// `idlePolicy.autoResumeEnabled`.
526 pub auto_resume: bool,
527 /// A label for the run token, defaulting to the image identifier (TRAP-1).
528 pub token_scope: Option<String>,
529 /// Optional durable launch idempotency key. Reuse only for retries of one launch,
530 /// with identical parameters and run-hook payload. None preserves fresh defaults.
531 /// Never derive this from an image name or reuse it for a different VM.
532 pub client_token: Option<String>,
533 /// Per-VM `logging`: a CloudWatch group (and optional exact stream) or disabled.
534 /// `None` leaves the service's default destination in place (#201).
535 pub logging: Option<ops::Logging>,
536}
537
538impl RunMicrovmRequest {
539 /// A launch with the measured defaults: ingress only, ten-minute idle and suspended
540 /// windows, a one-hour maximum duration, no auto-resume.
541 pub fn new(image_identifier: impl Into<String>, run_hook_payload: RunHookPayload) -> Self {
542 Self {
543 image_identifier: image_identifier.into(),
544 image_version: None,
545 execution_role_arn: None,
546 connectors: vec![ConnectorIntent::AllIngress],
547 egress_network_connectors: Vec::new(),
548 run_hook_payload,
549 max_duration_sec: 3_600,
550 max_idle_sec: 600,
551 suspended_sec: 600,
552 auto_resume: false,
553 token_scope: None,
554 client_token: None,
555 logging: None,
556 }
557 }
558
559 /// Requests the managed internet egress connector. Omission does not block internet access.
560 #[must_use]
561 pub fn with_egress(mut self) -> Self {
562 if !self.connectors.contains(&ConnectorIntent::Egress) {
563 self.connectors.push(ConnectorIntent::Egress);
564 }
565 self
566 }
567
568 /// Routes egress through a customer-managed Lambda network connector.
569 #[must_use]
570 pub fn with_egress_network_connector(mut self, arn: impl Into<String>) -> Self {
571 self.egress_network_connectors.push(arn.into());
572 self
573 }
574
575 /// Requests a shell-capable launch: the ingress set becomes the measured pair,
576 /// `[HTTP_INGRESS, SHELL_INGRESS]`.
577 ///
578 /// **Replaces** `ALL_INGRESS` rather than adding beside it, because the platform
579 /// forbids the combination and says so only at token-mint time — the VM launches,
580 /// reaches RUNNING, and bills before the failure appears (`docs/PLATFORM.md`,
581 /// 2026-08-15). `[HTTP_INGRESS, SHELL_INGRESS]` is the set measured to both launch
582 /// and mint a shell token, and `HTTP_INGRESS` stays in the pair so the daemon
583 /// endpoint keeps working. Egress is untouched: shell access and outbound network
584 /// are separate questions.
585 #[must_use]
586 pub fn with_shell(mut self) -> Self {
587 self.connectors
588 .retain(|intent| *intent != ConnectorIntent::AllIngress);
589 for wanted in [ConnectorIntent::HttpIngress, ConnectorIntent::ShellIngress] {
590 if !self.connectors.contains(&wanted) {
591 self.connectors.push(wanted);
592 }
593 }
594 self
595 }
596
597 /// Pins the launch to one `imageVersion`. See the field for why that matters.
598 #[must_use]
599 pub fn with_image_version(mut self, version: impl Into<String>) -> Self {
600 self.image_version = Some(version.into());
601 self
602 }
603}
604
605/// Rejects a `maximumDurationInSeconds` outside the service range.
606///
607/// 28800 is eight hours and the hard ceiling on any single VM's life. A longer session
608/// needs a second VM, not a larger number — which is what the message says, because
609/// "outside the accepted range" alone invites someone to look for the flag that raises it.
610pub fn require_duration_in_range(seconds: u32) -> Result<u32, Error> {
611 if (1..=crate::constants::MAX_DURATION_SEC).contains(&seconds) {
612 return Ok(seconds);
613 }
614 Err(Error::invalid_arg(format!(
615 "maximumDurationInSeconds={seconds} is outside the accepted range 1..{} (service model \
616 {}) — {} seconds is eight hours, the hard ceiling on any one VM's life. A longer \
617 session needs a second VM, not a larger number.",
618 crate::constants::MAX_DURATION_SEC,
619 crate::constants::MODEL_API_VERSION,
620 crate::constants::MAX_DURATION_SEC,
621 )))
622}
623
624/// Rejects a `Version`/`NonBlankString` value the service would reject.
625///
626/// # Why this one is checked locally when so many are not
627///
628/// The model's `Version` shape is `min: 1, max: 2048, pattern: [^\s]+`, and issue #24 lists
629/// `NonBlankString` as its most-reused unguarded shape. Two of the three constraints are ones
630/// botocore would not catch even if this client used it (`max` and `pattern` are outside
631/// `VALIDATED_METADATA_ATTRS`), and this client uses `aws-sigv4` plus `reqwest` and never
632/// touches botocore's validator at all.
633///
634/// What makes it worth a guard rather than a comment is *where* the two callers sit.
635/// `CreateMicrovmImage.baseImageVersion` is sent **after** the artifact upload, so the
636/// service's rejection costs the caller the upload — the same argument
637/// [`require_valid_image_name`] makes. And `RunMicrovm.imageVersion` is the pinned-launch
638/// half of a rollback: a blank there is a `ValidationException` at the moment someone is
639/// trying to re-pin away from a bad version, which is the worst possible time for the failure
640/// to be about the request rather than about the version.
641///
642/// The pattern is `[^\s]+` — **no whitespace anywhere**, not merely "not blank". A version
643/// copied out of a terminal with a trailing newline satisfies "non-empty" and fails the
644/// pattern, so the message names the character rather than saying the value is invalid.
645pub fn require_valid_version(member: &str, version: &str) -> Result<(), Error> {
646 let model = crate::constants::MODEL_API_VERSION;
647 if version.is_empty() {
648 return Err(Error::invalid_arg(format!(
649 "{member} is empty, but the Version shape requires at least 1 character (service \
650 model {model}). Omit the member entirely to let the service choose — an absent \
651 version and a blank one are different requests, and only the first is legal."
652 )));
653 }
654 if version.len() > crate::constants::MAX_VERSION_LEN {
655 return Err(Error::invalid_arg(format!(
656 "{member} is {} characters, over the Version ceiling of {} (service model {model}).",
657 version.len(),
658 crate::constants::MAX_VERSION_LEN,
659 )));
660 }
661 if let Some(found) = version.chars().find(char::is_ascii_whitespace) {
662 return Err(Error::invalid_arg(format!(
663 "{member} {version:?} contains whitespace ({found:?}), which the Version pattern \
664 {:?} forbids anywhere in the value (service model {model}). A version pasted from \
665 a terminal carries a trailing newline and looks fine; this is that.",
666 crate::constants::VERSION_PATTERN,
667 )));
668 }
669 Ok(())
670}
671
672/// Rejects a `NonBlankString` value the service would reject.
673///
674/// # The model's most-reused shape, and the three members this client sends
675///
676/// `NonBlankString` is `min: 1, max: 2048, pattern: [^\s]+` and 45 members name it. Most are
677/// responses; the three this client puts on the wire are `CodeArtifact.uri`,
678/// `CreateMicrovmImage.baseImageArn`, and `ListMicrovmImages.nameFilter`, and issue #24 named
679/// all three as reachable with no guard.
680///
681/// Each of the three fails in its own expensive way. `codeArtifact.uri` and `baseImageArn` ride
682/// on the create call, which happens **after** the artifact upload — the ordering
683/// [`ControlPlane::create_image`] is arranged around, so a rejection about either costs the
684/// caller the upload. `nameFilter` is worse in a quieter way: it goes in the **query string**,
685/// so a blank one is a `nameFilter=` that either 400s or silently filters differently from what
686/// was meant, and [`ControlPlane::find_image_by_name`] is the resolver a launch depends on.
687///
688/// # `[^\s]+` forbids whitespace *anywhere*
689///
690/// Not merely "not blank". An S3 URI pasted from a console with a trailing newline satisfies
691/// "non-empty" and fails the pattern, which is why the message names the character it found
692/// rather than saying the value is invalid. The same reading [`require_valid_version`] documents
693/// for `Version`, which is the identical constraint triple on a different shape.
694///
695/// A separate function from [`require_valid_version`] even though the two shapes agree today,
696/// for the reason [`crate::constants::MAX_NON_BLANK_LEN`] gives: they are two shapes, AWS can
697/// move one, and one guard serving both would silently stop being about whichever moved.
698pub fn require_non_blank(member: &str, value: &str) -> Result<(), Error> {
699 let model = crate::constants::MODEL_API_VERSION;
700 if value.is_empty() {
701 return Err(Error::invalid_arg(format!(
702 "{member} is empty, but the NonBlankString shape requires at least 1 character \
703 (service model {model}). The shape's own documentation reads 'a string which is not \
704 empty or blank (only whitespace)'."
705 )));
706 }
707 if value.len() > crate::constants::MAX_NON_BLANK_LEN {
708 return Err(Error::invalid_arg(format!(
709 "{member} is {} characters, over the NonBlankString ceiling of {} (service model \
710 {model}).",
711 value.len(),
712 crate::constants::MAX_NON_BLANK_LEN,
713 )));
714 }
715 if let Some(found) = value.chars().find(char::is_ascii_whitespace) {
716 return Err(Error::invalid_arg(format!(
717 "{member} {value:?} contains whitespace ({found:?}), which the NonBlankString \
718 pattern {:?} forbids anywhere in the value (service model {model}). A URI pasted \
719 from a console carries a trailing newline and looks fine; this is that.",
720 crate::constants::NON_BLANK_PATTERN,
721 )));
722 }
723 Ok(())
724}
725
726/// Rejects a `MicrovmIdentifier`/`MicrovmImageIdentifier` the service would reject.
727///
728/// # Twelve members across every implemented operation
729///
730/// Both shapes are `min: 1, max: 256`, and between them they bound the identifier on
731/// `GetMicrovm`, `SuspendMicrovm`, `ResumeMicrovm`, `TerminateMicrovm`,
732/// `CreateMicrovmAuthToken`, `GetMicrovmImage`, `DeleteMicrovmImage`,
733/// `DeleteMicrovmImageVersion`, `GetMicrovmImageVersion`, `UpdateMicrovmImageVersion`,
734/// `GetMicrovmImageBuild`, the three listings, and `RunMicrovm.imageIdentifier`. Issue #24
735/// counted six implemented operations for each shape; every one of them is guarded here.
736///
737/// # An empty identifier is the case that pays for this
738///
739/// Ten of those members are **URI parameters**, and an empty one does not produce a validation
740/// error about a blank field: it collapses the path. `GET /2025-09-09/microvms/` addresses the
741/// *listing*, not a VM, so an empty id sent to `get_microvm` asks a different question and can
742/// get a 200 back for it — and `DELETE` on a collapsed path is worse. That failure mode is
743/// invisible in the service's answer, which is the strongest argument for a local refusal
744/// anywhere in this module.
745///
746/// # The model contradicts itself, and this is the side that can be guarded
747///
748/// `MicrovmImageArn` permits 2048 and is what the service *answers* with, so the model allows a
749/// legal response value that is an illegal request value — see
750/// [`crate::constants::MAX_IDENTIFIER_LEN`] for the whole account. The message says so, because
751/// a caller who hit it by echoing an ARN back is looking at a service inconsistency rather than
752/// at their own mistake, and telling them "shorten it" would be useless advice about a value
753/// they did not choose.
754pub fn require_valid_identifier(member: &str, identifier: &str) -> Result<(), Error> {
755 let model = crate::constants::MODEL_API_VERSION;
756 if identifier.is_empty() {
757 return Err(Error::invalid_arg(format!(
758 "{member} is empty, but the identifier shapes require at least 1 character (service \
759 model {model}). An empty identifier is not refused as a blank field — most of these \
760 are URI parameters, so it collapses the path onto the collection: \
761 `/microvms/` is the listing rather than one VM, and a request that addresses the \
762 wrong resource can succeed."
763 )));
764 }
765 if identifier.len() > crate::constants::MAX_IDENTIFIER_LEN {
766 return Err(Error::invalid_arg(format!(
767 "{member} is {} characters, over the {} the MicrovmIdentifier and \
768 MicrovmImageIdentifier shapes allow (service model {model}). Note the model \
769 disagrees with itself here: MicrovmImageArn permits {}, and that is the shape the \
770 service answers with on GetMicrovm's imageArn — so if this value came back from a \
771 response rather than being chosen here, it is a service-side inconsistency worth \
772 reporting rather than a length to shorten.",
773 identifier.len(),
774 crate::constants::MAX_IDENTIFIER_LEN,
775 crate::constants::MAX_IMAGE_ARN_LEN,
776 )));
777 }
778 Ok(())
779}
780
781/// Rejects a `RoleArn` the service would reject, before the artifact upload.
782///
783/// # Why the build role in particular
784///
785/// `CreateMicrovmImage.buildRoleArn` is sent on the create call, and the create call happens
786/// **after** the caller has uploaded the artifact. So the service's rejection of a malformed
787/// role ARN arrives having already cost the upload, which is the exact ordering
788/// [`ControlPlane::create_image`] exists to protect against and the reason issue #24 singled
789/// this member out. `RunMicrovm.executionRoleArn` is cheaper to get wrong but wrong in a
790/// confusing place: it is optional in the model, so a malformed one is a `ValidationException`
791/// on a member a caller may not know is being filled from their infra config.
792///
793/// # Three constraints, three messages
794///
795/// `min: 20`, `max: 2048`, and the pattern. They get separate messages because the pattern's
796/// wording ("this does not look like an IAM role ARN") is unhelpful for a value that is merely
797/// 3000 characters, and the length wording is unhelpful for a role *name* — which is the most
798/// common mistake and is short.
799///
800/// See [`crate::constants::is_valid_role_arn`] for what the structural check decides. It cannot
801/// tell you the role exists or grants the right log permissions; those are the failures that
802/// actually happen most and none of them is knowable without IAM.
803pub fn require_valid_role_arn(member: &str, arn: &str) -> Result<(), Error> {
804 let model = crate::constants::MODEL_API_VERSION;
805 if arn.len() < crate::constants::MIN_ROLE_ARN_LEN {
806 return Err(Error::invalid_arg(format!(
807 "{member} {arn:?} is {} characters, under the RoleArn minimum of {} (service model \
808 {model}). A value this short is almost always a role *name* where the ARN was \
809 wanted — `arn:aws:iam::<12-digit-account>:role/{arn}` is the shape.",
810 arn.len(),
811 crate::constants::MIN_ROLE_ARN_LEN,
812 )));
813 }
814 if arn.len() > crate::constants::MAX_ROLE_ARN_LEN {
815 return Err(Error::invalid_arg(format!(
816 "{member} is {} characters, over the RoleArn ceiling of {} (service model {model}).",
817 arn.len(),
818 crate::constants::MAX_ROLE_ARN_LEN,
819 )));
820 }
821 if !crate::constants::is_valid_role_arn(arn) {
822 return Err(Error::invalid_arg(format!(
823 "{member} {arn:?} does not match the RoleArn pattern {:?} (service model {model}). \
824 It wants `arn:aws:iam::` then **exactly twelve digits** of account id then `:role/` \
825 and a name — so the three things this catches are a role name passed as an ARN, an \
826 ARN for some other service, and an account id with a digit dropped. Rejected here \
827 rather than by AWS because for buildRoleArn the create call happens *after* the \
828 artifact upload, so the service's answer costs you the upload first. Whether the \
829 role exists and grants logs on {}/* is not checkable locally and is still the \
830 service's answer to give.",
831 crate::constants::ROLE_ARN_PATTERN,
832 image::BUILD_LOG_GROUP_PREFIX,
833 )));
834 }
835 Ok(())
836}
837
838/// Rejects a `logging.cloudWatch.logGroup` the service would reject, before the artifact
839/// upload.
840///
841/// The shape is `CloudWatchLoggingLogGroupString`: 1..=512, pattern `[a-zA-Z0-9_\-/.#]+`.
842/// The character the pattern excludes that a caller writes first is the colon — a group
843/// pasted as an ARN (`arn:aws:logs:...`) fails here with the pattern named, rather than as
844/// a `ValidationException` after the upload.
845pub fn require_valid_log_group(group: &str) -> Result<(), Error> {
846 let model = crate::constants::MODEL_API_VERSION;
847 if group.is_empty() {
848 return Err(Error::invalid_arg(format!(
849 "logging.cloudWatch.logGroup is empty, but the CloudWatchLoggingLogGroupString \
850 shape requires at least 1 character (service model {model}). Omit the setting \
851 entirely to take the service default — a service-created group under \
852 {}/<image-name>.",
853 image::BUILD_LOG_GROUP_PREFIX,
854 )));
855 }
856 if group.len() > crate::constants::MAX_LOG_GROUP_LEN {
857 return Err(Error::invalid_arg(format!(
858 "logging.cloudWatch.logGroup is {} characters, over the \
859 CloudWatchLoggingLogGroupString ceiling of {} (service model {model}).",
860 group.len(),
861 crate::constants::MAX_LOG_GROUP_LEN,
862 )));
863 }
864 if !crate::constants::is_valid_log_group(group) {
865 return Err(Error::invalid_arg(format!(
866 "logging.cloudWatch.logGroup {group:?} does not match the \
867 CloudWatchLoggingLogGroupString pattern {:?} (service model {model}) — letters, \
868 digits, and `_ - / . #` only. A colon usually means an ARN was pasted where the \
869 group *name* was wanted. Rejected here rather than by AWS because the create call \
870 happens after the artifact upload, so the service's answer costs you the upload \
871 first.",
872 crate::constants::LOG_GROUP_PATTERN,
873 )));
874 }
875 Ok(())
876}
877
878/// Rejects a caller-supplied `logging.cloudWatch.logStream` prefix the resolved name could
879/// not legally carry, before the artifact upload.
880///
881/// The shape is `CloudWatchLoggingLogStreamString`: 1..=512, pattern `[^:*]*` — anything
882/// but `:` and `*`. The caller's value is capped at
883/// [`crate::constants::MAX_USER_LOG_STREAM_LEN`] (495) rather than 512, because this
884/// client always appends `/<16 hex>` of per-build discriminator before the value reaches
885/// the wire — see [`CreateImageRequest::log_stream`] for why a verbatim stream name is
886/// never sent — and 495 + 17 is exactly the shape's ceiling.
887pub fn require_valid_log_stream(stream: &str) -> Result<(), Error> {
888 let model = crate::constants::MODEL_API_VERSION;
889 if stream.is_empty() {
890 return Err(Error::invalid_arg(format!(
891 "logging.cloudWatch.logStream is empty, but the CloudWatchLoggingLogStreamString \
892 shape requires at least 1 character (service model {model}). Omit the setting to \
893 let the service name the streams inside the configured group."
894 )));
895 }
896 if stream.len() > crate::constants::MAX_USER_LOG_STREAM_LEN {
897 return Err(Error::invalid_arg(format!(
898 "logging.cloudWatch.logStream is {} characters, over this client's ceiling of {}. \
899 The shape's own ceiling is {} (service model {model}), and the client always \
900 appends a 17-character per-build discriminator (`/` + 16 hex) — one image build \
901 is three VMs writing three streams, and the member is an exact stream name, so a \
902 fixed configured name would collapse every build's streams into one. The \
903 discriminator is what keeps them tellable apart, and it needs its room.",
904 stream.len(),
905 crate::constants::MAX_USER_LOG_STREAM_LEN,
906 crate::constants::MAX_LOG_STREAM_LEN,
907 )));
908 }
909 if let Some(found) = stream.chars().find(|c| matches!(c, ':' | '*')) {
910 return Err(Error::invalid_arg(format!(
911 "logging.cloudWatch.logStream {stream:?} contains {found:?}, which the \
912 CloudWatchLoggingLogStreamString pattern {:?} forbids anywhere in the value \
913 (service model {model}). A `*` usually means a prefix or glob was intended — the \
914 member is an exact stream name, prefixes are unsupported, and this client's own \
915 per-build suffix is how a configured name behaves like one.",
916 crate::constants::LOG_STREAM_PATTERN,
917 )));
918 }
919 Ok(())
920}
921
922/// Rejects a port the `PortNumber` or `HooksPortInteger` shapes would reject. **Minimum 1.**
923///
924/// # Port 0 is representable and means something else
925///
926/// Zero is what "let the kernel pick a port" means to a listener, so it is the value a caller
927/// reaches for when they do not have a port yet — and issue #24 measured that
928/// [`ControlPlane::with_port`]`(0)` was representable and produced `allowedPorts: [{"port": 0}]`
929/// on the wire, plus a `hooks.port` of 0 on every image built through that plane. Neither is a
930/// port the platform can forward to.
931///
932/// # There is no ceiling check here, and that is the type's doing rather than an omission
933///
934/// `PortNumber.max` and `HooksPortInteger.max` are both 65535, which is `u16::MAX` — so a `max`
935/// branch against the constant would be `port > u16::MAX`, a comparison that is false for every
936/// input. Clippy's `absurd_extreme_comparisons` says so, and it is right for the reason
937/// [`ControlPlane::create_image`] gives about the absent `hooks.port` check: a branch no input can
938/// reach reads as protection, appears in a coverage report as protection, and no test can make it
939/// fire.
940///
941/// The ceiling is still *pinned* — [`crate::constants::MAX_PORT`] is in the drift gate, and
942/// `the_port_floor_is_one_and_the_ceiling_is_what_a_u16_holds` asserts the equality with
943/// `u16::MAX` that makes this reasoning valid. If AWS ever lowers the ceiling below 65535 the gate
944/// goes red, that test goes red, and a real branch belongs here.
945pub fn require_valid_port(member: &str, port: u16) -> Result<(), Error> {
946 if port >= crate::constants::MIN_PORT {
947 return Ok(());
948 }
949 Err(Error::invalid_arg(format!(
950 "{member} is {port}, under the PortNumber minimum of {} (service model {}). Port 0 means \
951 'let the kernel choose' to a listener and is not a port the platform can forward to — a \
952 proxy token minted for it authorizes nothing, and an image whose hooks block names it has \
953 no reachable hook endpoint. Name the port the daemon actually listens on \
954 ({DEFAULT_AGENT_PORT} by default).",
955 crate::constants::MIN_PORT,
956 crate::constants::MODEL_API_VERSION,
957 )))
958}
959
960/// Rejects an `idlePolicy.maxIdleDurationSeconds` under the model's `min: 60`.
961///
962/// # The exemption this replaces was inherited from a client that no longer exists
963///
964/// [`RunMicrovmRequest::max_idle_sec`] and `constants.rs` both used to say there was
965/// deliberately no guard here, because `min` is one of the four keys in botocore's
966/// `VALIDATED_METADATA_ATTRS` and botocore therefore refuses it locally with a clear message.
967/// The premise is true and the conclusion does not apply: this client never touches botocore.
968/// `ControlPlane` signs with `aws-sigv4` and sends with `reqwest`, so `validate.py` is not on
969/// the path, and issue #24 measured `max_idle_sec: 59` reaching the wire.
970///
971/// It is the one place in this module where the *reason* for having no guard was wrong rather
972/// than the number, which is worth saying because the same reasoning would exempt every `min` in
973/// the model — `Version`'s, `NonBlankString`'s, the identifiers', `allowedPorts`'s. All of them
974/// are now guarded, and none of them by botocore.
975///
976/// No maximum, because the model states none. The bound that actually ends a VM's life is
977/// `maximumDurationInSeconds` ([`require_duration_in_range`]).
978pub fn require_idle_duration(seconds: u32) -> Result<(), Error> {
979 if seconds >= crate::constants::MIN_IDLE_DURATION_SEC {
980 return Ok(());
981 }
982 Err(Error::invalid_arg(format!(
983 "idlePolicy.maxIdleDurationSeconds={seconds} is under the minimum of {} (service model \
984 {}). This used to be documented as a constraint botocore enforced locally, which was \
985 true of the deleted Python client and never of this one — it signs with aws-sigv4 and \
986 sends with reqwest, so nothing validates a request before the wire except this crate.",
987 crate::constants::MIN_IDLE_DURATION_SEC,
988 crate::constants::MODEL_API_VERSION,
989 )))
990}
991
992/// Rejects a tag map the service would reject, naming the key and the half that failed.
993///
994/// # Sent since tags existed, checked by nothing
995///
996/// [`CreateImageRequest::tags`] goes straight onto `CreateMicrovmImage.tags` and was never
997/// validated (issue #24). Same ordering argument as everything else on that call: the rejection
998/// lands after the artifact upload.
999///
1000/// # The key and the value have different rules, so the message says which
1001///
1002/// `TagKey` is `min: 1, max: 128`; `TagValue` is `min: 0, max: 256`. So an empty **value** is
1003/// legal and an empty **key** is not, and the ceilings differ by 2x. They share one pattern.
1004/// Every message here names the offending key, because a caller applying a dozen tags from a
1005/// config file needs to know which one — a message that only said "a tag is invalid" would send
1006/// them to read all twelve.
1007///
1008/// The key is quoted in the message even when the key itself is the problem, which is the
1009/// deliberate choice: a key with a stray newline or a zero-width character in it is
1010/// indistinguishable from a good one until it is printed with `{:?}`.
1011pub fn require_valid_tags(tags: &std::collections::BTreeMap<String, String>) -> Result<(), Error> {
1012 let model = crate::constants::MODEL_API_VERSION;
1013 for (key, value) in tags {
1014 if key.is_empty() {
1015 return Err(Error::invalid_arg(format!(
1016 "a tag key is empty, but TagKey requires at least 1 character (service model \
1017 {model}). Note TagValue's minimum is 0, so an empty tag *value* is legal and an \
1018 empty key is not."
1019 )));
1020 }
1021 if key.len() > crate::constants::MAX_TAG_KEY_LEN {
1022 return Err(Error::invalid_arg(format!(
1023 "the tag key {key:?} is {} characters, over the TagKey ceiling of {} (service \
1024 model {model}). TagValue allows {}, so the two halves are not \
1025 interchangeable.",
1026 key.len(),
1027 crate::constants::MAX_TAG_KEY_LEN,
1028 crate::constants::MAX_TAG_VALUE_LEN,
1029 )));
1030 }
1031 if !crate::constants::is_valid_tag_component(key) {
1032 return Err(Error::invalid_arg(format!(
1033 "the tag key {key:?} has a character outside the TagKey pattern {:?} (service \
1034 model {model}). Letters, digits, separators, and `_ . : / = + - @` — spaces and \
1035 non-Latin scripts are fine, commas and `#` and `%` are not, and neither is a \
1036 newline (it is a control character, not a separator).",
1037 crate::constants::TAG_COMPONENT_PATTERN,
1038 )));
1039 }
1040 if value.len() > crate::constants::MAX_TAG_VALUE_LEN {
1041 return Err(Error::invalid_arg(format!(
1042 "the value of tag {key:?} is {} characters, over the TagValue ceiling of {} \
1043 (service model {model}).",
1044 value.len(),
1045 crate::constants::MAX_TAG_VALUE_LEN,
1046 )));
1047 }
1048 if !crate::constants::is_valid_tag_component(value) {
1049 return Err(Error::invalid_arg(format!(
1050 "the value of tag {key:?} has a character outside the TagValue pattern {:?} \
1051 (service model {model}). It is the same character set the key takes; the value \
1052 differs only in allowing an empty string.",
1053 crate::constants::TAG_COMPONENT_PATTERN,
1054 )));
1055 }
1056 }
1057 Ok(())
1058}
1059
1060/// Rejects an image name the service would reject, before the artifact upload.
1061///
1062/// The three cases get their own messages because the pattern message ("no dots, no
1063/// slashes") is actively misleading for a 70-character name that contains neither.
1064pub fn require_valid_image_name(name: &str) -> Result<(), Error> {
1065 let model = crate::constants::MODEL_API_VERSION;
1066 if name.is_empty() {
1067 return Err(Error::invalid_arg(format!(
1068 "the image name is empty, but ImageName requires at least 1 character (service \
1069 model {model})."
1070 )));
1071 }
1072 if name.len() > crate::constants::MAX_IMAGE_NAME_LEN {
1073 return Err(Error::invalid_arg(format!(
1074 "the image name is {} characters, over the ImageName ceiling of {} (service model \
1075 {model}). Rejected here rather than by AWS, because the create call happens \
1076 *after* the artifact upload — so the service's answer costs you the upload first.",
1077 name.len(),
1078 crate::constants::MAX_IMAGE_NAME_LEN,
1079 )));
1080 }
1081 if !crate::constants::is_valid_image_name(name) {
1082 return Err(Error::invalid_arg(format!(
1083 "the image name {name:?} does not match the ImageName pattern {:?} (service model \
1084 {model}). Letters, digits, hyphen, and underscore only — no dots and no slashes, \
1085 which are the two separators a namespaced name reaches for first.",
1086 crate::constants::IMAGE_NAME_PATTERN,
1087 )));
1088 }
1089 Ok(())
1090}
1091
1092/// The `Hooks` block with all six hooks enabled and both timeout families set.
1093///
1094/// All six because `ready` and `validate` are image-*build* hooks: the build calls them to
1095/// decide whether the snapshot it just produced is usable, before any instance exists and
1096/// therefore before any token has been delivered. Gating them on bootstrap state fails the
1097/// *build* rather than the run, which is a confusing place to discover the mistake.
1098///
1099/// The two timeouts are separate types, so the 60x gap cannot be crossed by passing one
1100/// number twice — see [`crate::hooks`].
1101/// `ENABLED` is [`ops::HookState::Enabled`] rather than a `&str` local, which is issue #24's
1102/// point about this function: the literal appeared six times with no constant naming either
1103/// value, so a typo in one of the six was a `ValidationException` on a create call made after
1104/// the artifact upload. It is now a compile error.
1105pub fn hooks_block(port: u16, run: RunHookTimeout, build: BuildHookTimeout) -> ops::Hooks {
1106 const ENABLED: ops::HookState = ops::HookState::Enabled;
1107 ops::Hooks {
1108 port,
1109 microvm_hooks: ops::MicrovmHooks {
1110 run: ENABLED,
1111 run_timeout_in_seconds: run.as_secs(),
1112 resume: ENABLED,
1113 resume_timeout_in_seconds: run.as_secs(),
1114 suspend: ENABLED,
1115 suspend_timeout_in_seconds: run.as_secs(),
1116 terminate: ENABLED,
1117 terminate_timeout_in_seconds: run.as_secs(),
1118 },
1119 microvm_image_hooks: ops::MicrovmImageHooks {
1120 ready: ENABLED,
1121 ready_timeout_in_seconds: build.as_secs(),
1122 validate: ENABLED,
1123 validate_timeout_in_seconds: build.as_secs(),
1124 },
1125 }
1126}
1127
1128/// A deadline elapsed on the client side. The remote resource is untouched.
1129pub(crate) fn timed_out(what: &str, waited: Duration) -> Error {
1130 Error::new(
1131 ErrorKind::Timeout,
1132 format!(
1133 "{what} within {:.0}s. This is a client-side deadline: nothing was cancelled, so \
1134 the resource is in whatever state the service last reported.",
1135 waited.as_secs_f64()
1136 ),
1137 )
1138}
1139
1140#[cfg(any(test, feature = "test-support"))]
1141pub(crate) mod fake;
1142
1143#[cfg(test)]
1144mod posture_fuzz;
1145
1146#[cfg(test)]
1147mod tests {
1148 use super::*;
1149
1150 /// The three image-name rejections each get their own message, because the pattern
1151 /// wording is misleading for a name that is merely long.
1152 #[test]
1153 fn each_image_name_rejection_explains_its_own_case() {
1154 let empty = require_valid_image_name("").expect_err("min is 1");
1155 assert!(
1156 empty.to_string().contains("at least 1 character"),
1157 "{empty}"
1158 );
1159
1160 let long = require_valid_image_name(&"a".repeat(65)).expect_err("max is 64");
1161 let message = long.to_string();
1162 assert!(message.contains("65 characters"), "{message}");
1163 assert!(
1164 message.contains("after* the artifact upload"),
1165 "the reason to check locally is the cost: {message}"
1166 );
1167 assert!(
1168 !message.contains("no dots"),
1169 "the pattern wording is misleading for a merely-long name: {message}"
1170 );
1171
1172 let dotted = require_valid_image_name("my.image").expect_err("dots are not in the pattern");
1173 let message = dotted.to_string();
1174 assert!(message.contains("no dots and no slashes"), "{message}");
1175 assert!(message.contains("[a-zA-Z0-9-_]+"), "{message}");
1176 }
1177
1178 /// The boundaries are inclusive on both sides, and every rejection is an `InvalidArg`
1179 /// so the CLI reports it as refused-locally rather than as a platform failure.
1180 #[test]
1181 fn an_image_name_at_the_ceiling_is_accepted_and_one_past_it_is_not() {
1182 require_valid_image_name(&"a".repeat(64)).expect("64 is the ceiling");
1183 require_valid_image_name("a").expect("1 is the minimum");
1184 for bad in ["", &"a".repeat(65), "my.image", "team/image", "a b"] {
1185 let error = require_valid_image_name(bad).expect_err("rejected");
1186 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1187 }
1188 }
1189
1190 /// The duration range, at both boundaries. 28801 is the value the botocore gap was
1191 /// confirmed with, and the message has to say a second VM rather than a bigger number.
1192 #[test]
1193 fn the_duration_range_is_inclusive_and_names_the_eight_hour_ceiling() {
1194 assert_eq!(require_duration_in_range(1).expect("1 fits"), 1);
1195 assert_eq!(
1196 require_duration_in_range(28_800).expect("the ceiling fits"),
1197 28_800
1198 );
1199
1200 for bad in [0, 28_801, u32::MAX] {
1201 let error = require_duration_in_range(bad).expect_err("outside 1..28800");
1202 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad}");
1203 let message = error.to_string();
1204 assert!(message.contains("eight hours"), "{message}");
1205 assert!(
1206 message.contains("a second VM, not a larger number"),
1207 "{message}"
1208 );
1209 }
1210 }
1211
1212 /// The hooks block enables all six hooks, and the two families carry their own
1213 /// timeouts — which is the whole reason they are two types.
1214 #[test]
1215 fn the_hooks_block_enables_all_six_with_the_two_families_kept_apart() {
1216 let hooks = hooks_block(
1217 9000,
1218 RunHookTimeout::try_new(45).expect("45s is a legal run timeout"),
1219 BuildHookTimeout::try_new(1800).expect("1800s is a legal build timeout"),
1220 );
1221
1222 assert_eq!(hooks.port, 9000);
1223 for state in [
1224 hooks.microvm_hooks.run,
1225 hooks.microvm_hooks.resume,
1226 hooks.microvm_hooks.suspend,
1227 hooks.microvm_hooks.terminate,
1228 hooks.microvm_image_hooks.ready,
1229 hooks.microvm_image_hooks.validate,
1230 ] {
1231 assert_eq!(state, ops::HookState::Enabled, "all six hooks are served");
1232 // The wire spelling too, against a literal: the variant name and the string the
1233 // service reads are two different things, and `HookState::Enabled` rendering as
1234 // anything but `ENABLED` is a rejected create on six fields at once.
1235 assert_eq!(state.as_str(), "ENABLED");
1236 }
1237
1238 assert_eq!(hooks.microvm_hooks.run_timeout_in_seconds, 45);
1239 assert_eq!(hooks.microvm_hooks.terminate_timeout_in_seconds, 45);
1240 assert_eq!(hooks.microvm_image_hooks.ready_timeout_in_seconds, 1800);
1241 assert_eq!(
1242 hooks.microvm_image_hooks.validate_timeout_in_seconds, 1800,
1243 "1800 is legal for the build family and illegal for the run one, which is why \
1244 one shared number cannot serve both"
1245 );
1246 }
1247
1248 /// The default request carries the measured defaults, and identity repair is **off** —
1249 /// a capability granted by default is a capability nobody asked for.
1250 #[test]
1251 fn the_default_create_request_repairs_no_identity_and_takes_the_default_size() {
1252 let request = CreateImageRequest::new("img", vec![1, 2, 3], "s3://b/k", "arn:role");
1253 assert!(!request.repair_guest_identity);
1254 assert!(!request.inherit_workdir);
1255 assert_eq!(request.size, SizeClass::DEFAULT);
1256 assert_eq!(request.base_image, BaseImage::al2023());
1257 assert_eq!(request.token_scope, None);
1258 assert_eq!(request.dockerfile, None);
1259 }
1260
1261 /// The default launch requests ingress and **not** egress: the right default for a
1262 /// daemon that needs none. What the omission buys is measured in `docs/PLATFORM.md`
1263 /// (2026-09-12: the platform gave the connector-less VM outbound network anyway).
1264 #[test]
1265 fn the_default_launch_requests_ingress_only() {
1266 let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
1267 let request = RunMicrovmRequest::new("arn:image", payload);
1268 assert_eq!(request.connectors, vec![ConnectorIntent::AllIngress]);
1269
1270 let with_egress = request.with_egress();
1271 assert_eq!(
1272 with_egress.connectors,
1273 vec![ConnectorIntent::AllIngress, ConnectorIntent::Egress]
1274 );
1275 }
1276
1277 /// Asking for egress twice does not send it twice. `NetworkConnectorList` caps at 10
1278 /// and a duplicate is a wasted slot at best.
1279 #[test]
1280 fn asking_for_egress_twice_adds_it_once() {
1281 let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
1282 let request = RunMicrovmRequest::new("arn:image", payload)
1283 .with_egress()
1284 .with_egress();
1285 assert_eq!(request.connectors.len(), 2);
1286 }
1287
1288 /// `with_shell` **replaces** `ALL_INGRESS` with the measured pair rather than adding
1289 /// beside it: the platform accepts `ALL_INGRESS` + finer ingress at launch and
1290 /// refuses it only when a shell token is minted, after the VM has run and billed —
1291 /// and `run_microvm` refuses the combination client-side, so a `with_shell` that
1292 /// merely appended would build a request this client's own validation rejects.
1293 ///
1294 /// **Falsification** — watched fail 2026-08-31: dropping the `retain` from
1295 /// `with_shell` fails the first assertion with `ALL_INGRESS` still in the set.
1296 #[test]
1297 fn with_shell_replaces_all_ingress_with_the_measured_pair() {
1298 let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
1299 let request = RunMicrovmRequest::new("arn:image", payload).with_shell();
1300 assert_eq!(
1301 request.connectors,
1302 vec![ConnectorIntent::HttpIngress, ConnectorIntent::ShellIngress]
1303 );
1304
1305 // Egress is a separate question and survives in either order.
1306 let payload = RunHookPayload::for_agent_token("token").expect("a token fits");
1307 let both = RunMicrovmRequest::new("arn:image", payload)
1308 .with_egress()
1309 .with_shell()
1310 .with_shell();
1311 assert_eq!(
1312 both.connectors,
1313 vec![
1314 ConnectorIntent::Egress,
1315 ConnectorIntent::HttpIngress,
1316 ConnectorIntent::ShellIngress
1317 ],
1318 "egress survives, and asking twice adds nothing"
1319 );
1320 }
1321
1322 /// A client-side deadline is an `ErrorKind::Timeout` and says the resource was not
1323 /// touched — because the caller's next question is whether to clean something up.
1324 #[test]
1325 fn a_client_side_deadline_says_the_resource_is_untouched() {
1326 let error = timed_out("the image did not become usable", Duration::from_secs(300));
1327 assert_eq!(error.kind(), ErrorKind::Timeout);
1328 let message = error.to_string();
1329 assert!(message.contains("300s"), "{message}");
1330 assert!(message.contains("nothing was cancelled"), "{message}");
1331 }
1332
1333 /// The default port matches the daemon's, and a caller can change it.
1334 #[test]
1335 fn the_agent_port_defaults_to_nine_thousand_and_is_overridable() {
1336 let plane = crate::testing::control_plane(
1337 Arc::new(fake::FakeControlPlane::new()),
1338 Region::UsEast1,
1339 Arc::new(fake::TestClock::new()),
1340 );
1341 assert_eq!(plane.port(), DEFAULT_AGENT_PORT);
1342 assert_eq!(plane.port(), 9000);
1343 assert_eq!(
1344 plane.with_port(8080).expect("8080 is a legal port").port(),
1345 8080
1346 );
1347 }
1348
1349 /// **TRAP-1, the compile surface.** Image requests cannot carry
1350 /// a caller-supplied token.
1351 ///
1352 /// Asserted by destructuring rather than by grep: a struct pattern must name every
1353 /// field, so an image `client_token` added later fails to compile this test. That is the
1354 /// closest a test can get to asserting an absence.
1355 #[test]
1356 fn image_requests_forbid_tokens_and_run_requests_require_explicit_opt_in() {
1357 let CreateImageRequest {
1358 name: _,
1359 base_image: _,
1360 base_image_version: _,
1361 dockerfile: _,
1362 binary: _,
1363 // Dependency-file *bytes* under ecosystem-fixed entry names — nothing
1364 // token-shaped, and no field a caller could name an arbitrary file into.
1365 project_files: _,
1366 code_artifact_uri: _,
1367 build_role_arn: _,
1368 size: _,
1369 repair_guest_identity: _,
1370 inherit_workdir: _,
1371 run_hook_timeout: _,
1372 build_hook_timeout: _,
1373 tags: _,
1374 // The logging pair. The stream is a *prefix* the create call suffixes with a
1375 // fresh nonce — the caller cannot name the exact wire stream, which is the
1376 // same closure shape as the token's.
1377 log_group: _,
1378 log_stream: _,
1379 // A *label*, folded in beside a fresh nonce. The only token-adjacent field,
1380 // and it cannot become the token.
1381 token_scope: _,
1382 } = CreateImageRequest::new("img", Vec::new(), "s3://b/k", "arn:role");
1383
1384 let RunMicrovmRequest {
1385 image_identifier: _,
1386 image_version: _,
1387 execution_role_arn: _,
1388 connectors: _,
1389 egress_network_connectors: _,
1390 run_hook_payload: _,
1391 max_duration_sec: _,
1392 max_idle_sec: _,
1393 suspended_sec: _,
1394 auto_resume: _,
1395 token_scope: _,
1396 // A per-VM log destination: a group name and an exact stream, never a token.
1397 logging: _,
1398 client_token,
1399 } = RunMicrovmRequest::new(
1400 "arn:image",
1401 RunHookPayload::for_agent_token("t").expect("fits"),
1402 );
1403 assert!(client_token.is_none());
1404 }
1405
1406 /// **TRAP-1's last mile.** The wire types' `client_token` fields are `pub(crate)`, so a
1407 /// caller outside this crate cannot build a request body with a token of their own.
1408 ///
1409 /// Worth its own test because the hole was real and I opened it: `ops` is a public module
1410 /// and the wire structs are public, so `pub client_token` on them meant an external
1411 /// caller could bypass `CreateImageRequest` entirely, hand-build a
1412 /// `CreateMicrovmImageWire` with a content digest, and serialize it — the wedge with one
1413 /// extra step, past a request type that has no such field.
1414 ///
1415 /// This test can only observe the *inside* of the boundary, since a compile failure
1416 /// outside the crate is not something an inline test can assert. What it pins is that the
1417 /// field is reachable from here and that the value in it came from the minter — so a
1418 /// later widening back to `pub` is at least a visible diff against a test that documents
1419 /// why it is narrow. The external half is enforced by the compiler.
1420 #[test]
1421 fn the_wire_types_token_field_is_crate_private_and_minted() {
1422 let entropy = crate::entropy::testing::SequenceEntropy::new();
1423 let wire = ops::RunMicrovmWire {
1424 image_identifier: "arn:image".to_string(),
1425 image_version: None,
1426 execution_role_arn: None,
1427 ingress_network_connectors: Vec::new(),
1428 egress_network_connectors: None,
1429 idle_policy: ops::IdlePolicy {
1430 max_idle_duration_seconds: 600,
1431 suspended_duration_seconds: 600,
1432 auto_resume_enabled: false,
1433 },
1434 maximum_duration_in_seconds: 3_600,
1435 run_hook_payload: String::new(),
1436 logging: None,
1437 // Reachable from inside the crate, and only ever populated this way.
1438 client_token: token::run_token_with("arn:image", &entropy).expect("scripted"),
1439 };
1440 assert!(wire.client_token.starts_with("run-"));
1441 assert!(wire.client_token.len() <= crate::constants::MAX_CLIENT_TOKEN_LEN);
1442 assert_ne!(
1443 wire.client_token,
1444 token::run_token_with("arn:image", &entropy).expect("scripted"),
1445 "two mints of the same scope differ, which is the property the narrowing protects"
1446 );
1447 }
1448
1449 // ── issue #24's guards, at the message level ─────────────────────────────
1450 //
1451 // Each of these asserts the *refusal and its wording*; the zero-call proofs live beside
1452 // the operations they protect, in `image.rs` and `microvm.rs`, because only there can a
1453 // test observe that no control-plane call happened.
1454
1455 /// The three `NonBlankString` rejections each explain their own case, and the pattern one
1456 /// names the character it found.
1457 ///
1458 /// The whitespace message has to name the character, because the whole class of failure this
1459 /// catches is a value that *looks* right: a URI or a filter pasted out of a console carries a
1460 /// trailing newline, satisfies "non-empty", and fails the pattern.
1461 ///
1462 /// **Guard proof.** Delete the `is_empty` branch and the blank case returns `Ok`; delete the
1463 /// whitespace branch and the `"s3://b/k\n"` case does.
1464 #[test]
1465 fn each_non_blank_rejection_explains_its_own_case() {
1466 let blank = require_non_blank("codeArtifact.uri", "").expect_err("min is 1");
1467 assert_eq!(blank.kind(), ErrorKind::InvalidArg);
1468 assert!(
1469 blank.to_string().contains("at least 1 character"),
1470 "{blank}"
1471 );
1472 assert!(
1473 blank.to_string().contains("NonBlankString"),
1474 "the shape has to be named: {blank}"
1475 );
1476
1477 let long = require_non_blank("baseImageArn", &"a".repeat(2049)).expect_err("max is 2048");
1478 let message = long.to_string();
1479 assert!(message.contains("2049 characters"), "{message}");
1480 assert!(message.contains("ceiling of 2048"), "{message}");
1481
1482 let newline = require_non_blank("codeArtifact.uri", "s3://bucket/key.zip\n")
1483 .expect_err("whitespace anywhere is refused");
1484 let message = newline.to_string();
1485 assert!(
1486 message.contains(r"'\n'"),
1487 "the message must name the character it found: {message}"
1488 );
1489 assert!(message.contains("trailing newline"), "{message}");
1490
1491 // Whitespace in the middle, not only at the end: `[^\s]+` forbids it anywhere.
1492 let inner =
1493 require_non_blank("nameFilter", "coding agents").expect_err("an inner space too");
1494 assert!(
1495 inner.to_string().contains("anywhere in the value"),
1496 "{inner}"
1497 );
1498
1499 // And the legal values pass, so the guard is not refusing everything.
1500 require_non_blank("codeArtifact.uri", "s3://bucket/agentd.zip").expect("a real URI");
1501 require_non_blank(
1502 "baseImageArn",
1503 "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1504 )
1505 .expect("a real base ARN");
1506 require_non_blank("nameFilter", "coding-agents").expect("a real filter");
1507 require_non_blank("imageVersion", &"9".repeat(2048)).expect("2048 is the ceiling");
1508 }
1509
1510 /// The identifier guard's two cases, and the over-long one names the model's own
1511 /// contradiction rather than telling the caller to shorten a value they may not have chosen.
1512 ///
1513 /// The empty message is the one that carries the reason this guard exists at all: ten of the
1514 /// twelve members are URI parameters, so an empty one collapses the path onto the collection
1515 /// and addresses the wrong resource — which the service can answer 200 to.
1516 ///
1517 /// **Guard proof.** Delete the `is_empty` branch and the blank case returns `Ok`; drop the
1518 /// `MicrovmImageArn` clause and the assertion on `2048` goes red.
1519 #[test]
1520 fn the_identifier_guard_names_the_collapsed_path_and_the_models_contradiction() {
1521 let blank = require_valid_identifier("microvmIdentifier", "").expect_err("min is 1");
1522 assert_eq!(blank.kind(), ErrorKind::InvalidArg);
1523 let message = blank.to_string();
1524 assert!(message.contains("at least 1 character"), "{message}");
1525 assert!(
1526 message.contains("collapses the path"),
1527 "the reason an empty URI parameter is dangerous has to be in the message: {message}"
1528 );
1529
1530 let long =
1531 require_valid_identifier("imageIdentifier", &"a".repeat(257)).expect_err("max is 256");
1532 let message = long.to_string();
1533 assert!(message.contains("257 characters"), "{message}");
1534 assert!(message.contains("256"), "{message}");
1535 assert!(
1536 message.contains("MicrovmImageArn permits 2048"),
1537 "the model's own contradiction has to be named, or the advice is 'shorten a value \
1538 the service gave you': {message}"
1539 );
1540
1541 require_valid_identifier(
1542 "imageIdentifier",
1543 "arn:aws:lambda:us-east-1:1:microvm-image:i",
1544 )
1545 .expect("a real ARN");
1546 require_valid_identifier("microvmIdentifier", "mvm-abc123").expect("a real id");
1547 require_valid_identifier("imageIdentifier", &"a".repeat(256)).expect("256 is the ceiling");
1548 }
1549
1550 /// The role-ARN guard's three cases, and the short one guesses the actual mistake.
1551 ///
1552 /// A value under 20 characters is almost always a role *name*, so the message renders the ARN
1553 /// the caller probably meant rather than restating the bound. That is the difference between
1554 /// a diagnostic and a validation error.
1555 ///
1556 /// **Guard proof.** Delete the `is_valid_role_arn` branch and the eleven-digit case returns
1557 /// `Ok` — which is the case nothing else catches.
1558 #[test]
1559 fn the_role_arn_guard_guesses_the_mistake_a_short_value_is() {
1560 let name = require_valid_role_arn("buildRoleArn", "build-role").expect_err("min is 20");
1561 assert_eq!(name.kind(), ErrorKind::InvalidArg);
1562 let message = name.to_string();
1563 assert!(message.contains("role *name*"), "{message}");
1564 assert!(
1565 message.contains("arn:aws:iam::<12-digit-account>:role/build-role"),
1566 "the message renders the ARN they probably meant: {message}"
1567 );
1568
1569 let long = require_valid_role_arn(
1570 "executionRoleArn",
1571 &format!("arn:aws:iam::123456789012:role/{}", "a".repeat(2048)),
1572 )
1573 .expect_err("max is 2048");
1574 assert!(long.to_string().contains("ceiling of 2048"), "{long}");
1575
1576 // Eleven digits, which is the case no eyeball and no other check catches.
1577 let eleven = require_valid_role_arn("buildRoleArn", "arn:aws:iam::12345678901:role/build")
1578 .expect_err("the account id is eleven digits");
1579 let message = eleven.to_string();
1580 assert!(
1581 message.contains("exactly twelve digits"),
1582 "the digit count is the whole value of this check: {message}"
1583 );
1584 assert!(
1585 message.contains("after* the artifact upload"),
1586 "the reason to check locally is the cost: {message}"
1587 );
1588 assert!(
1589 message.contains("/aws/lambda-microvms/*"),
1590 "and the message says what is still the service's answer to give: {message}"
1591 );
1592
1593 require_valid_role_arn(
1594 "buildRoleArn",
1595 "arn:aws:iam::392583147479:role/bonk-sandbox-microvm-build",
1596 )
1597 .expect("the conformance account's real build role");
1598 }
1599
1600 /// The port guard refuses 0 and says what 0 means, because 0 is not a typo.
1601 ///
1602 /// The ceiling branch cannot fire from a `u16` call site — `MAX_PORT` is `u16::MAX` — so what
1603 /// is asserted here is the floor and the two legal boundaries.
1604 ///
1605 /// **Guard proof.** Change the comparison to `port < 0` (or delete the branch) and the zero
1606 /// case returns `Ok`.
1607 #[test]
1608 fn the_port_guard_refuses_zero_and_says_what_zero_means() {
1609 let zero = require_valid_port("allowedPorts[].port", 0).expect_err("min is 1");
1610 assert_eq!(zero.kind(), ErrorKind::InvalidArg);
1611 let message = zero.to_string();
1612 assert!(
1613 message.contains("let the kernel choose"),
1614 "0 is a value a caller passes on purpose, so the message has to say why it is not a \
1615 port: {message}"
1616 );
1617 assert!(
1618 message.contains("authorizes nothing"),
1619 "the proxy-token consequence: {message}"
1620 );
1621 assert!(message.contains("9000"), "the default is named: {message}");
1622
1623 require_valid_port("port", 1).expect("1 is the minimum");
1624 require_valid_port("port", 9000).expect("the agent port");
1625 require_valid_port("port", 65_535).expect("65535 is the ceiling");
1626 }
1627
1628 /// The idle-duration guard refuses 59 and says the botocore reasoning did not transfer.
1629 ///
1630 /// The message names the wrong premise on purpose. The exemption was not a wrong number, it
1631 /// was a correct fact about a client that no longer exists — and a reader who deletes this
1632 /// guard will do it by reasoning that `min` is validated locally, which is exactly the
1633 /// sentence the message answers.
1634 ///
1635 /// **Guard proof.** Delete the call in `run_microvm` and the zero-call test in `microvm.rs`
1636 /// goes red; invert the comparison here and 600 is refused while 59 passes.
1637 #[test]
1638 fn the_idle_guard_refuses_fifty_nine_and_names_the_premise_that_did_not_transfer() {
1639 let under = require_idle_duration(59).expect_err("min is 60");
1640 assert_eq!(under.kind(), ErrorKind::InvalidArg);
1641 let message = under.to_string();
1642 assert!(message.contains("59"), "{message}");
1643 assert!(message.contains("minimum of 60"), "{message}");
1644 assert!(
1645 message.contains("botocore"),
1646 "the message has to name the premise, because the premise is what was wrong: \
1647 {message}"
1648 );
1649 assert!(
1650 message.contains("aws-sigv4"),
1651 "and say what this client actually does instead: {message}"
1652 );
1653
1654 require_idle_duration(60).expect("60 is the minimum");
1655 require_idle_duration(600).expect("the default");
1656 // No maximum in the model, and the client adds none.
1657 require_idle_duration(u32::MAX).expect("the model states no ceiling");
1658 }
1659
1660 /// Every tag rejection names the offending key, and the key and the value keep their
1661 /// different rules.
1662 ///
1663 /// The two asymmetries are the content of this test: an empty value is legal and an empty key
1664 /// is not, and the ceilings are 128 against 256. A guard that used one rule for both would
1665 /// pass a 200-character key or refuse a 200-character value, and neither is what the service
1666 /// does.
1667 ///
1668 /// **Guard proof.** Use `MAX_TAG_KEY_LEN` for the value branch and the 256-character-value
1669 /// case fails; drop the `key` from any message and the assertion naming it goes red.
1670 #[test]
1671 fn every_tag_rejection_names_the_key_and_the_two_halves_keep_their_own_rules() {
1672 use std::collections::BTreeMap;
1673
1674 let one = |key: &str, value: &str| {
1675 let mut tags = BTreeMap::new();
1676 tags.insert(key.to_string(), value.to_string());
1677 tags
1678 };
1679
1680 // An empty **value** is legal. TagValue's min is 0.
1681 require_valid_tags(&one("owner", "")).expect("an empty tag value is legal");
1682 // An empty **key** is not. TagKey's min is 1.
1683 let blank = require_valid_tags(&one("", "conformance")).expect_err("TagKey min is 1");
1684 assert_eq!(blank.kind(), ErrorKind::InvalidArg);
1685 assert!(
1686 blank.to_string().contains("TagValue's minimum is 0"),
1687 "the asymmetry is the thing to say: {blank}"
1688 );
1689
1690 // The two ceilings differ by 2x, and a 256-character *value* is legal.
1691 require_valid_tags(&one("owner", &"v".repeat(256))).expect("TagValue max is 256");
1692 let long_value = require_valid_tags(&one("owner", &"v".repeat(257)))
1693 .expect_err("257 is one past TagValue");
1694 assert!(long_value.to_string().contains("\"owner\""), "{long_value}");
1695 assert!(
1696 long_value.to_string().contains("257 characters"),
1697 "{long_value}"
1698 );
1699
1700 require_valid_tags(&one(&"k".repeat(128), "v")).expect("TagKey max is 128");
1701 let long_key =
1702 require_valid_tags(&one(&"k".repeat(129), "v")).expect_err("129 is one past TagKey");
1703 let message = long_key.to_string();
1704 assert!(message.contains("129 characters"), "{message}");
1705 assert!(
1706 message.contains("TagValue allows 256"),
1707 "the message says the two halves are not interchangeable: {message}"
1708 );
1709
1710 // The pattern, both halves, with the key named either way.
1711 let bad_key = require_valid_tags(&one("cost,centre", "x")).expect_err("a comma");
1712 let message = bad_key.to_string();
1713 assert!(message.contains("\"cost,centre\""), "{message}");
1714 assert!(
1715 message.contains("commas and `#` and `%` are not"),
1716 "{message}"
1717 );
1718
1719 let bad_value = require_valid_tags(&one("owner", "50%")).expect_err("a percent");
1720 let message = bad_value.to_string();
1721 assert!(
1722 message.contains("the value of tag \"owner\""),
1723 "the message says which half as well as which key: {message}"
1724 );
1725
1726 // A newline in a key: legal-looking, and the reason keys are printed with `{:?}`.
1727 let newline = require_valid_tags(&one("owner\n", "x")).expect_err("a control character");
1728 assert!(
1729 newline.to_string().contains(r#""owner\n""#),
1730 "a key with a stray newline is indistinguishable from a good one until it is quoted: \
1731 {newline}"
1732 );
1733
1734 // And a realistic tag set passes, including a space and a non-Latin key.
1735 let mut real = BTreeMap::new();
1736 real.insert("owner".to_string(), "conformance".to_string());
1737 real.insert("cost centre".to_string(), "team/agents".to_string());
1738 real.insert("所有者".to_string(), "らいす".to_string());
1739 real.insert("empty".to_string(), String::new());
1740 require_valid_tags(&real).expect("a realistic tag set");
1741
1742 // An empty map is not a tag problem, and `create_image` omits the member entirely.
1743 require_valid_tags(&BTreeMap::new()).expect("no tags is not an invalid tag");
1744 }
1745
1746 /// **Issue #24's `HookState` half.** The two values are named by a type, so neither can be
1747 /// misspelled, and the block this client sends is six `Enabled`s.
1748 ///
1749 /// The wire spellings are asserted against literals rather than derived from the variant
1750 /// names, because the variant name and the string the service reads are two different things
1751 /// — the same reason `ops::VersionStatus` does it that way.
1752 ///
1753 /// **Guard proof.** Change `HookState::Enabled`'s rename to `"ENABLE"` and this fails on the
1754 /// serialization assertion; add a third variant and the `HOOK_STATES` comparison fails.
1755 #[test]
1756 fn the_hook_state_enum_is_the_models_two_values_and_nothing_else() {
1757 assert_eq!(ops::HookState::Disabled.as_str(), "DISABLED");
1758 assert_eq!(ops::HookState::Enabled.as_str(), "ENABLED");
1759 assert_eq!(ops::HookState::Enabled.to_string(), "ENABLED");
1760
1761 // Through serde, which is what actually reaches the wire — `as_str` could be right while
1762 // the `rename` is wrong.
1763 assert_eq!(
1764 serde_json::to_value(ops::HookState::Enabled).expect("serialises"),
1765 serde_json::json!("ENABLED")
1766 );
1767 assert_eq!(
1768 serde_json::to_value(ops::HookState::Disabled).expect("serialises"),
1769 serde_json::json!("DISABLED")
1770 );
1771
1772 // The typed spelling and the drift gate's array agree, or one of them is checking
1773 // something the other does not send.
1774 let spelled = [ops::HookState::Disabled, ops::HookState::Enabled];
1775 let mut wire: Vec<&str> = spelled.iter().map(|state| state.as_str()).collect();
1776 wire.sort_unstable();
1777 let mut pinned = crate::constants::HOOK_STATES.to_vec();
1778 pinned.sort_unstable();
1779 assert_eq!(wire, pinned);
1780
1781 // And the misspellings a `String` field allowed are now parse failures on the way in,
1782 // which is the closest an inline test gets to asserting they are compile errors on the
1783 // way out.
1784 for typo in ["\"ENABLE\"", "\"Enabled\"", "\"ACTIVE\"", "\"enabled\""] {
1785 serde_json::from_str::<ops::HookState>(typo)
1786 .expect_err(&format!("{typo} is not a HookState"));
1787 }
1788 }
1789}