microvms_domain/constants.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Every hardcoded service constraint, and the JSON the build gate checks them with.
3//!
4//! Each number and pattern below is transcribed from the botocore service model for
5//! `lambda-microvms`, API version [`MODEL_API_VERSION`]. The model is a
6//! machine-readable statement of the service's own request validation, and restating
7//! it by hand is how this project published a 16 KB `runHookPayload` ceiling against
8//! a real ceiling of 4096 — wrong by 4x in the dangerous direction, since it told a
9//! caller four times as much secret material fits as actually does.
10//!
11//! # Why they are checked locally at all
12//!
13//! Because the SDK does not check them. Measured 2026-08-07 by reading botocore's
14//! `validate.py`: `VALIDATED_METADATA_ATTRS` is `{'required', 'min', 'document',
15//! 'union'}`, so a `min` violation is caught before the wire while `max`, `pattern`,
16//! and `enum` violations are serialized, sent, and answered with a
17//! `ValidationException` — confirmed empirically for `max` (runHookPayload 4097,
18//! maximumDurationInSeconds 28801, ImageName 65 chars, NetworkConnectorList 11 items,
19//! clientToken 129 chars), for `pattern` (ImageName `"a b!"`), and for `enum`
20//! (architecture X86_64, additionalOsCapabilities CAP_SYS_ADMIN). Every one reached
21//! the wire. So the guards built on these values are load-bearing rather than
22//! belt-and-braces, and the obvious future simplification — "the SDK validates the
23//! model already, delete these" — silently reopens all of them.
24//!
25//! `IdlePolicy.maxIdleDurationSeconds` used to be listed here as the counter-example — the
26//! one constraint with no constant, on the grounds that its `min: 60` is a `min` and
27//! botocore enforces those locally. **That reasoning does not transfer to this client and
28//! the exemption is gone** ([`MIN_IDLE_DURATION_SEC`]). It was true of the deleted Python
29//! client, which called through botocore; this one signs with `aws-sigv4` and sends with
30//! `reqwest`, and `validate.py` is never on the path. Every constraint here is enforced by
31//! this crate or by nothing, `min` included.
32//!
33//! # The drift gate (TRAP-12)
34//!
35//! [`as_json`] emits every value in this module as one object. The names are the
36//! keys consumed by `tools/check-model-drift.py`, which compares each value with
37//! the pinned model. `MEASURED_REGIONS`, the deprecated `MICROVM_REGIONS` and
38//! `SIZE_CLASSES` have no model source; the gate compares them with independently pinned
39//! literals.
40//!
41//! The names are therefore a contract with a script, not a style choice. Renaming
42//! `MAX_RUN_HOOK_PAYLOAD_BYTES` here without renaming it there does not fail
43//! compilation; it makes a check silently stop comparing. The test at the bottom of
44//! this file pins the key set for that reason.
45
46use serde_json::{Value, json};
47
48use crate::region::MEASURED_REGIONS;
49
50/// The model version every constraint here was read from.
51///
52/// The drift gate hard-fails when this disagrees with the service directory it
53/// resolves, rather than skipping: a constraint checked against a different API
54/// version is a constraint that was not checked.
55pub const MODEL_API_VERSION: &str = "2025-09-09";
56
57/// `RunMicrovmRequestRunHookPayloadString.max`.
58///
59/// **Inclusive**: 4096 bytes passes the length check and 4097 is rejected, bracketed
60/// 2026-08-07 by calling `RunMicrovm` with a deliberately bogus `imageIdentifier` so
61/// nothing could be created or billed. This is the only per-VM secret channel the
62/// platform offers — one bearer token fits, a cloud credential set does not.
63///
64/// # The model itself says 16,384, and it is wrong
65///
66/// Not only this repo's prose. `service-2.json`'s **documentation string** on
67/// `RunMicrovmRequest.runHookPayload` reads, verbatim for API version 2025-09-09:
68///
69/// > Per-MicroVM initialization data delivered as the request body of the /run lifecycle
70/// > hook. Use to pass tenant-specific configuration such as session IDs or secret
71/// > references. Maximum: 16,384 bytes.
72///
73/// while the shape that member names — `RunMicrovmRequestRunHookPayloadString` — declares
74/// `{"max": 4096, "min": 0}`. The shape is what the service validates against, and 4096 was
75/// measured inclusive against the real service. **So a reader who "corrects" this constant
76/// from the model's prose reintroduces the bug**, and they will be able to cite the model
77/// while doing it: that 16 KB figure is where this project's own 16 KB claim came from, and
78/// it survived several review passes because prose has nothing to disagree with. Issue #24
79/// named this trap specifically. The drift gate compares against the *shape*, which is why it
80/// stays green while the prose beside it says something else.
81pub const MAX_RUN_HOOK_PAYLOAD_BYTES: usize = 4096;
82
83/// The figure the model's own `runHookPayload` documentation string claims, which is **not**
84/// the ceiling.
85///
86/// Pinned as a constant so the wrong number has a name and a comparison rather than only a
87/// mention. [`MAX_RUN_HOOK_PAYLOAD_BYTES`] carries the full account; what this exists for is
88/// the drift gate, which reads the model's documentation string and asserts the claim is
89/// still 16,384 — because the day AWS fixes their prose is the day this constant and its
90/// warning should be deleted, and nothing else would say so.
91///
92/// It is deliberately **never** used as a bound. The test below asserts it is 4x the real
93/// ceiling, which is the shape of the hazard: wrong in the permissive direction, telling a
94/// caller four times as much secret material fits as actually does.
95pub const DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES: usize = 16_384;
96
97/// `ImageName.max`.
98///
99/// The minimum is 1 and this comment used to say botocore enforces that one. It does, and this
100/// client does not use botocore — so the min is checked by
101/// `microvms_core::control::require_valid_image_name` like everything else, and always was.
102pub const MAX_IMAGE_NAME_LEN: usize = 64;
103
104/// `ImageName.pattern`, as the model spells it.
105///
106/// No dots and no slashes, which rules out the two separators a caller reaching for a
107/// namespaced name writes first. Published as a string for the drift gate; the
108/// matcher is [`is_valid_image_name`], a direct byte check over the four ranges.
109pub const IMAGE_NAME_PATTERN: &str = "[a-zA-Z0-9-_]+";
110
111/// `Version.max`, which is also `NonBlankString.max`.
112///
113/// Checked by `microvms_core::control::require_valid_version` on the two members this client sends
114/// as a `Version` — `CreateMicrovmImage.baseImageVersion` and `RunMicrovm.imageVersion`. Issue
115/// #24 named `NonBlankString` as the model's most-reused unguarded shape; these are the two
116/// places it is now guarded, and both are sent at a moment where the service's own rejection
117/// is expensive (after an artifact upload, or in the middle of a rollback).
118pub const MAX_VERSION_LEN: usize = 2048;
119
120/// `Version.pattern` — **no whitespace anywhere**, not merely "not blank".
121///
122/// Published as a string for the drift gate; the check is hand-rolled beside it for the reason
123/// [`IMAGE_NAME_PATTERN`] gives. A version copied out of a terminal carries a trailing newline
124/// and satisfies "non-empty" while failing this, which is why the refusal names the character.
125pub const VERSION_PATTERN: &str = "[^\\s]+";
126
127/// `NonBlankString.max`, which today equals [`MAX_VERSION_LEN`] and is a different shape.
128///
129/// The model's most-reused constrained shape — 45 members name it — and the ones this client
130/// *sends* are `CodeArtifact.uri`, `CreateMicrovmImage.baseImageArn`, and
131/// `ListMicrovmImages.nameFilter`. Checked by `microvms_core::control::require_non_blank`.
132///
133/// A separate constant from [`MAX_VERSION_LEN`] rather than a reuse of it, and the reason is
134/// the same one the two connector ceilings give: `Version` and `NonBlankString` are two shapes
135/// in the model with two independent futures, and AWS can move one without moving the other.
136/// One constant serving both would be a comparison that silently stops being about one of
137/// them. The drift gate holds each against its own shape.
138pub const MAX_NON_BLANK_LEN: usize = 2048;
139
140/// `NonBlankString.pattern` — the same `[^\s]+` [`VERSION_PATTERN`] carries, held separately
141/// for the reason [`MAX_NON_BLANK_LEN`] gives.
142pub const NON_BLANK_PATTERN: &str = "[^\\s]+";
143
144/// `MicrovmIdentifier.max` and `MicrovmImageIdentifier.max`, which are the same number.
145///
146/// Both shapes are `min: 1, max: 256`, and between them they cover **twelve** URI, body, and
147/// querystring members across every implemented operation — every `GetMicrovm`, `Suspend`,
148/// `Resume`, `Terminate`, `CreateMicrovmAuthToken`, `GetMicrovmImage`, `Delete*`, `List*`, and
149/// `RunMicrovm.imageIdentifier`. Checked by `microvms_core::control::require_valid_identifier`.
150///
151/// # The model contradicts itself here, and the client resolves it toward 256
152///
153/// `MicrovmImageArn` is `min: 20, max: 2048`, and it is the shape the service **answers** with
154/// on `GetMicrovmResponse.imageArn`, `MicrovmItem.imageArn`, and `RunMicrovmResponse.imageArn`.
155/// So the model permits the service to return a 2048-character image ARN that is then illegal
156/// to pass back as a `MicrovmImageIdentifier`. That is a real contradiction, not a reading
157/// error, and only one side of it can be guarded: the request side.
158///
159/// This client refuses above 256 on the way out, because that is the bound the *request*
160/// shapes state and a request over it is rejected by the service regardless of where the value
161/// came from. Refusing there names the contradiction — see
162/// `microvms_core::control::require_valid_identifier`, whose message says an over-long identifier
163/// that came from a response is a service-side inconsistency to report rather than a caller
164/// mistake. The alternative, accepting up to 2048 because a response might carry one, would
165/// send a request that cannot succeed and report the service's `ValidationException` as though
166/// the caller had chosen the length.
167///
168/// In practice the gap is theoretical: a real image ARN in the longest-named MicroVM region is
169/// about 90 characters, and the test below pins that arithmetic so "it fits" is measured rather
170/// than assumed.
171pub const MAX_IDENTIFIER_LEN: usize = 256;
172
173/// `MicrovmImageArn.max` — 2048, the number that contradicts [`MAX_IDENTIFIER_LEN`].
174///
175/// Pinned so the contradiction is a comparison rather than a comment: this is a
176/// **response**-side shape, so nothing guards a value against it, and its only job here is to
177/// be the second half of the assertion that the two disagree. If AWS ever narrows this to 256
178/// the contradiction is gone and the paragraph in [`MAX_IDENTIFIER_LEN`] should go with it —
179/// the drift gate is what would notice.
180pub const MAX_IMAGE_ARN_LEN: usize = 2048;
181
182/// `TagKey.max`. Minimum is 1, so a blank key is not a tag.
183pub const MAX_TAG_KEY_LEN: usize = 128;
184
185/// `TagValue.max`. Minimum is **0** — an empty tag value is legal, unlike an empty key.
186pub const MAX_TAG_VALUE_LEN: usize = 256;
187
188/// The `TagKey`/`TagValue` pattern, which both shapes share verbatim.
189///
190/// Unicode property classes — letters, separators, numbers, and
191/// `_ . : / = + - @` — so this is the one pattern in this module that is **not** ASCII ranges,
192/// and the matcher [`is_valid_tag_component`] is written against `char::is_alphabetic` and
193/// friends rather than against byte ranges for that reason.
194///
195/// # The pattern is `*` and therefore matches everything, which is a trap
196///
197/// The whole thing is a `*`-quantified group, so the empty string matches and so does any
198/// prefix — an unanchored `*` pattern rejects nothing at all. What actually bites a caller is
199/// the character *set*: a tag key with a comma, a hash, or a percent in it is outside it. The
200/// matcher therefore checks that **every** character is in the set rather than trying to
201/// implement the regex, which is the useful reading of the constraint and the one the service
202/// enforces. Published as a string for the drift gate.
203pub const TAG_COMPONENT_PATTERN: &str = "([\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*)";
204
205/// `CloudWatchLoggingLogGroupString.max` — the ceiling on `logging.cloudWatch.logGroup`.
206///
207/// Checked by `microvms_core::control::require_valid_log_group` before the create call, which
208/// happens after the artifact upload — the same ordering argument every other create-side
209/// guard makes.
210pub const MAX_LOG_GROUP_LEN: usize = 512;
211
212/// `CloudWatchLoggingLogGroupString.pattern`, as the model spells it.
213///
214/// Letters, digits, and `_ - / . #` — no colon, no space. Published as a string for the
215/// drift gate; the matcher is [`is_valid_log_group`], a direct byte check for the reason
216/// [`IMAGE_NAME_PATTERN`] gives.
217pub const LOG_GROUP_PATTERN: &str = "[a-zA-Z0-9_\\-/.#]+";
218
219/// `CloudWatchLoggingLogStreamString.max` — the ceiling on `logging.cloudWatch.logStream`.
220///
221/// This bounds the value **on the wire**, which is never the caller's value verbatim: the
222/// client always appends `/<16 hex>` (see [`MAX_USER_LOG_STREAM_LEN`] for why, and
223/// `microvms_core::control::image` for the mechanism), so the caller-facing ceiling is lower.
224pub const MAX_LOG_STREAM_LEN: usize = 512;
225
226/// `CloudWatchLoggingLogStreamString.pattern` — anything but `:` and `*`.
227///
228/// The two excluded characters are CloudWatch's own stream-name reserved set. Published as
229/// a string for the drift gate; `microvms_core::control::require_valid_log_stream` names it in
230/// its refusal.
231pub const LOG_STREAM_PATTERN: &str = "[^:*]*";
232
233/// The ceiling on a **caller-supplied** log stream name: [`MAX_LOG_STREAM_LEN`] minus the
234/// 17 characters of per-build discriminator this client always appends (`/` + 16 hex).
235///
236/// # Why the client never sends a caller's stream name verbatim
237///
238/// The `logStream` member is an **exact** stream name, not a prefix (prefixes are
239/// unsupported — 2026-08 platform finding, docs/PLATFORM.md 'An image build is three VMs
240/// and three log streams'). One build emits three log streams, and a fixed configured name
241/// collapses all three — and every *successive* build's three — into one stream, making
242/// concurrent builds of different images indistinguishable. Appending a fresh CSPRNG
243/// discriminator per create attempt keeps a configured name useful as a *family* prefix
244/// while every attempt stays tellable apart, which is the same shape as TRAP-1's token
245/// nonce and reuses its mechanism.
246///
247/// The test beside these constants pins the arithmetic, so a change to the nonce width
248/// fails here rather than as a `ValidationException` after the artifact upload.
249pub const MAX_USER_LOG_STREAM_LEN: usize = 495;
250
251/// `RoleArn.min` — 20 characters, which is the shortest thing that can be an IAM role ARN.
252pub const MIN_ROLE_ARN_LEN: usize = 20;
253
254/// `RoleArn.max`.
255pub const MAX_ROLE_ARN_LEN: usize = 2048;
256
257/// `RoleArn.pattern`, as the model spells it.
258///
259/// Checked by `microvms_core::control::require_valid_role_arn` on `CreateMicrovmImage.buildRoleArn`
260/// and `RunMicrovm.executionRoleArn`. The build role is the one that matters most: the create
261/// call happens **after** the artifact upload, so the service's rejection of a malformed role
262/// ARN costs the caller the upload — the exact ordering `create_image` is arranged to prevent,
263/// and the reason issue #24 called this one out by name.
264///
265/// Hand-matched rather than compiled, for the reason [`IMAGE_NAME_PATTERN`] gives, and the
266/// matcher is deliberately a **structural** check rather than a full regex implementation: see
267/// [`is_valid_role_arn`] on what it does and does not decide.
268pub const ROLE_ARN_PATTERN: &str = "arn:aws[a-z\\-]*:iam::[0-9]{12}:role/?[a-zA-Z_0-9+=,.@\\-_/]+";
269
270/// `PortNumber.min`, and also `HooksPortInteger.min`. **1, not 0.**
271///
272/// Port 0 is what "let the kernel choose" means to a listener and it is not a port a proxy
273/// token or a hooks block can name. `with_port(0)` was representable and sent `{"port": 0}`
274/// (issue #24); `microvms_core::control::require_valid_port` is what refuses it now.
275pub const MIN_PORT: u16 = 1;
276
277/// `PortNumber.max`, which equals [`MAX_HOOK_PORT`] and is a different shape.
278///
279/// Held apart for the reason [`MAX_NON_BLANK_LEN`] gives: `PortNumber` bounds
280/// `PortSpecification.port` and both ends of a `PortRange`, `HooksPortInteger` bounds
281/// `Hooks.port`, and they are two shapes AWS can move independently.
282pub const MAX_PORT: u16 = 65_535;
283
284/// `IdlePolicyMaxIdleDurationSecondsInteger.min` — 60 seconds, with no maximum.
285///
286/// # Why this has a constant now when it deliberately did not
287///
288/// The old comment here and on `microvms_core::control::RunMicrovmRequest::max_idle_sec` justified
289/// having no guard on the grounds that `min` is one of the four keys in botocore's
290/// `VALIDATED_METADATA_ATTRS`, so botocore refuses it before the wire with a clear message.
291/// That is true of botocore and **false of this client**: `microvms-core` signs with
292/// `aws-sigv4` and sends with `reqwest`, and nothing in the dependency set imports
293/// `botocore/validate.py`. The reasoning was inherited from the deleted Python client, where it
294/// held, and it did not survive the port. Issue #24 measured the consequence:
295/// `max_idle_sec: 59` reached the wire.
296///
297/// Checked by `microvms_core::control::require_idle_duration`. There is no maximum in the model, and
298/// the client adds none: a caller who wants a VM that never auto-suspends within its
299/// `maximumDurationInSeconds` says so with a large number, and the eight-hour ceiling on the
300/// VM's life is the real bound.
301pub const MIN_IDLE_DURATION_SEC: u32 = 60;
302
303/// `RunMicrovmRequestMaximumDurationInSecondsInteger.max` — eight hours, and the hard
304/// ceiling on any single VM's life. A longer session needs a second VM, not a larger
305/// number.
306pub const MAX_DURATION_SEC: u32 = 28_800;
307
308/// `MicrovmHooks*TimeoutInSecondsInteger.max` (run, resume, suspend, terminate).
309///
310/// See [`crate::hooks`] for the 60x gap and why it is two types.
311pub const MAX_MICROVM_HOOK_TIMEOUT_SEC: u32 = 60;
312
313/// `MicrovmImageHooks*TimeoutInSecondsInteger.max` (ready, validate).
314pub const MAX_IMAGE_HOOK_TIMEOUT_SEC: u32 = 3_600;
315
316/// `HooksPortInteger.max`.
317pub const MAX_HOOK_PORT: u32 = 65_535;
318
319/// The `Capability` enum, which is exactly this one value.
320///
321/// Which is why guest identity repair is a boolean intent flag rather than a
322/// capability list (TRAP-3): there is no way to ask for `CAP_SYS_ADMIN` alone.
323pub const CAPABILITIES: [&str; 1] = ["ALL"];
324
325/// The `Architecture` enum, which is exactly this one value: a MicroVM cannot be x86.
326///
327/// Load-bearing for cost (COST-9) as well as for requests — the Pricing API returns
328/// both ARM and non-ARM compute rates 17.9% apart, and only the ARM line can ever
329/// apply.
330pub const ARCHITECTURES: [&str; 1] = ["ARM_64"];
331
332/// `NetworkConnectorList.max` — the **VM-level** list, which `RunMicrovm` sends.
333pub const MAX_NETWORK_CONNECTORS: usize = 10;
334
335/// `NetworkConnector.max` — the maximum ARN length accepted by `RunMicrovm`.
336pub const MAX_NETWORK_CONNECTOR_LEN: usize = 2048;
337
338/// The **image-level** egress list's max, which is `1` and not `10`.
339///
340/// Six shapes in the model declare an image-level `egressNetworkConnectors` with
341/// `min: 0, max: 1` — `CreateMicrovmImageRequest`, `MicrovmImageVersionSummary`,
342/// `GetMicrovmImageVersionOutput`, `UpdateMicrovmImageVersionResponse`, and two more. The
343/// VM-level `NetworkConnectorList` above allows 10. Issue #24 named the hazard: reusing
344/// [`MAX_NETWORK_CONNECTORS`] for an image-level list is wrong by an order of magnitude, and
345/// wrong in the permissive direction, so the rejection would arrive from the service.
346///
347/// Pinned here rather than left as a comment because the version readback now deserializes
348/// that list (`microvms_core::control::ops::MicrovmImageVersionSummaryWire`), so the two ceilings
349/// are both live in this crate and the drift gate can hold each against its own shape.
350pub const MAX_IMAGE_EGRESS_CONNECTORS: usize = 1;
351
352/// The `MicrovmImageVersionStatus` enum, in the model's order.
353///
354/// The values `UpdateMicrovmImageVersion` accepts and `GetMicrovmImageVersion` answers.
355/// Published so the drift gate compares them against the shape rather than against a literal
356/// inside the script — which is the gap the script's own comment admits for the five state
357/// enums, and this one is a request member, so a value the model dropped would be a call this
358/// client makes and the service refuses.
359///
360/// `microvms_core::control::ops::VersionStatus` is the typed spelling; this array is what the gate
361/// reads, and the test at the bottom of that module asserts the two agree.
362pub const IMAGE_VERSION_STATUSES: [&str; 2] = ["ACTIVE", "INACTIVE"];
363
364/// The `HookState` enum, in the model's order. `DISABLED` first.
365///
366/// The two values every one of the six hook flags takes. Published so the gate compares them
367/// against the shape, and because this is a **request** enum on six members of every
368/// `CreateMicrovmImage`: a value the model dropped is a call this client makes and the service
369/// refuses, on six fields at once.
370///
371/// `microvms_core::control::ops::HookState` is the typed spelling and is what the request carries.
372/// This array is what the gate reads, and the test in that module asserts the two agree — the
373/// same arrangement [`IMAGE_VERSION_STATUSES`] has with `ops::VersionStatus`.
374pub const HOOK_STATES: [&str; 2] = ["DISABLED", "ENABLED"];
375
376/// The `MicrovmState` enum, in the model's order.
377///
378/// # Why the state enums live here now
379///
380/// They were pinned against literals inside `tools/check-model-drift.py`, which the script's own
381/// comment admitted: the gate verified the model against the *script*, with no reader in the
382/// client at all. Issue #24 named the consequence — a `MicrovmState` AWS adds fails the gate
383/// with no compile-time consequence for the polling loops in
384/// `microvms_core::control::microvm` that branch on states, so the gate's failure and the loop's
385/// blindness were unrelated facts.
386///
387/// Moving the set here does not make a new state a compile error — a wire string cannot be
388/// exhaustively matched, and narrowing a *response* enum is the mistake
389/// `microvms_core::control::ops::MicrovmImageBuildSummaryWire::architecture` documents at length. What
390/// it does buy is a reader in the crate that the loops' own sets are checked against: the tests
391/// below assert [`TERMINAL_STATES`] and [`DEAD_STATES`] are subsets of this, and
392/// `microvms_core::control::microvm::SUSPEND_WANTED` is checked against it too. So a state removed or
393/// respelled by AWS fails the gate *and* fails a test naming the loop that branches on it.
394pub const MICROVM_STATES: [&str; 6] = [
395 "PENDING",
396 "RUNNING",
397 "SUSPENDING",
398 "SUSPENDED",
399 "TERMINATING",
400 "TERMINATED",
401];
402
403/// The `MicrovmImageState` enum, in the model's order.
404///
405/// What `microvms_core::control::image::Image::is_ready` and `is_failed` are decided against. The
406/// three `*_FAILED` spellings are why `is_failed` is a substring test rather than a set: a
407/// fourth added later is still recognised as a failure rather than polled to the deadline, and
408/// the test below asserts that reading is still true of every member here.
409pub const IMAGE_STATES: [&str; 9] = [
410 "CREATING",
411 "CREATED",
412 "CREATE_FAILED",
413 "UPDATING",
414 "UPDATED",
415 "UPDATE_FAILED",
416 "DELETING",
417 "DELETE_FAILED",
418 "DELETED",
419];
420
421/// The `MicrovmImageVersionState` enum, in the model's order.
422///
423/// What `MicrovmImageVersionSummary.state` carries. Note it is **not** the same set as
424/// [`IMAGE_STATES`] and not the same as [`BUILD_STATES`] either, which is the reason all three
425/// are here: a habit that works for one is wrong for the others.
426pub const IMAGE_VERSION_STATES: [&str; 7] = [
427 "PENDING",
428 "IN_PROGRESS",
429 "SUCCESSFUL",
430 "FAILED",
431 "DELETING",
432 "DELETED",
433 "DELETE_FAILED",
434];
435
436/// The `BuildState` enum, in the model's order.
437///
438/// `PENDING` is the one TRAP-2's stall probe reads: **all** builds still `PENDING` past the
439/// stall grace is the `clientToken` replay signature. The probe compares against a literal
440/// today; the test below asserts that literal is a member of this set, which is the check that
441/// notices a respelling — the same class of bug as `buildState`/`state`, which was dead against
442/// live AWS for a review round.
443pub const BUILD_STATES: [&str; 4] = ["PENDING", "IN_PROGRESS", "SUCCESSFUL", "FAILED"];
444
445/// The `Chipset` enum, which is exactly this one value.
446///
447/// Now deserialized: `microvms_core::control::ops::MicrovmImageBuildSummaryWire::chipset` and
448/// `GetImageBuildResponseWire::chipset` both read it, so the drift-gate observation in issue #24
449/// — "`Chipset` is drift-checked but deserialized nowhere" — was closed by the
450/// build-introspection work that added the model's five missing required members to those two
451/// shapes. Pinned here so the gate reads the value from the crate rather than from a literal in
452/// its own file.
453///
454/// Carried as a `String` on the wire types rather than narrowed to this set, for the reason
455/// those types state: a second chipset AWS adds must parse rather than fail a build readback.
456pub const CHIPSETS: [&str; 1] = ["GRAVITON"];
457
458/// `ResourcesList.max`.
459///
460/// Only one entry is accepted, so "give the VM two memory floors" is not a thing that
461/// can be asked.
462pub const MAX_RESOURCES: usize = 1;
463
464/// `Create/Run/UpdateMicrovmImageRequestClientTokenString.max`, all three 128.
465///
466/// Not a cosmetic cap. Run tokens fold in the image identifier, which is a full ARN,
467/// and an ap-northeast-1 ARN is long: the drift gate mints a token for the worst legal
468/// scope and asserts it fits, which is a check that found a real pre-launch bug.
469pub const MAX_CLIENT_TOKEN_LEN: usize = 128;
470
471/// The `MicrovmImageState` values that mean "built and usable", as the model spells
472/// them.
473///
474/// `UPDATED` is here even though this client never calls `UpdateMicrovmImage`: an
475/// image someone else updated is usable, and treating it as still-building is a
476/// 45-minute wait on a state that will never change.
477pub const MODEL_IMAGE_READY_STATES: [&str; 2] = ["CREATED", "UPDATED"];
478
479/// Spellings that are *not* in the 2025-09-09 `MicrovmImageState` enum, tolerated
480/// when the service answers with one.
481///
482/// Held apart from [`MODEL_IMAGE_READY_STATES`] so the drift gate can check the
483/// model-derived set *exactly* instead of being told that two of the three values it
484/// cannot find are fine. If a future model adds either, move it up. Kept at all
485/// because the service has answered differently across API versions, and a hard
486/// equality check on one spelling is how a working build looks like a stalled one.
487pub const TOLERATED_IMAGE_READY_STATES: [&str; 2] = ["ACTIVE", "AVAILABLE"];
488
489/// Terminal `MicrovmState` values.
490///
491/// Reaching any of them *before* RUNNING means the VM died during startup, which for a
492/// hook-serving daemon almost always means a lifecycle hook failed — and `stateReason`
493/// is where the answer is (TRAP-8).
494pub const TERMINAL_STATES: [&str; 4] = ["TERMINATED", "TERMINATING", "SUSPENDED", "SUSPENDING"];
495
496/// The subset of [`TERMINAL_STATES`] from which nothing comes back.
497///
498/// Separate because SUSPENDED is a death *before* RUNNING and an ordinary waypoint on
499/// the resume path — a resume that failed fast on SUSPENDED would fail on every
500/// resume, since that is the state the VM is in when the call is made.
501pub const DEAD_STATES: [&str; 2] = ["TERMINATED", "TERMINATING"];
502
503/// Whether `name` satisfies [`IMAGE_NAME_PATTERN`] and [`MAX_IMAGE_NAME_LEN`].
504///
505/// A direct byte check: the pattern is `[a-zA-Z0-9-_]+`, four character ranges, which
506/// `is_ascii_alphanumeric` states without a second place the pattern is written down.
507/// The length check is here too because a caller asking "is
508/// this name legal" means both constraints — they arrive from the same model shape and
509/// the service rejects on either.
510pub fn is_valid_image_name(name: &str) -> bool {
511 !name.is_empty()
512 && name.len() <= MAX_IMAGE_NAME_LEN
513 && name
514 .bytes()
515 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
516}
517
518/// Whether `group` satisfies [`LOG_GROUP_PATTERN`] and [`MAX_LOG_GROUP_LEN`].
519///
520/// A direct byte check over the pattern's character set — letters, digits, `_ - / . #` —
521/// for the reason [`is_valid_image_name`] gives. The length is checked here too, because a
522/// caller asking "is this group name legal" means both constraints: they arrive from the
523/// same model shape and the service rejects on either.
524pub fn is_valid_log_group(group: &str) -> bool {
525 !group.is_empty()
526 && group.len() <= MAX_LOG_GROUP_LEN
527 && group
528 .bytes()
529 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'/' | b'.' | b'#'))
530}
531
532/// Whether every character of `component` is in [`TAG_COMPONENT_PATTERN`]'s set.
533///
534/// **The length is not checked here**, unlike [`is_valid_image_name`], and the asymmetry is
535/// deliberate: a key and a value share this one pattern and have *different* ceilings (128
536/// against 256) and different minima (1 against 0). A combined check would need to know which
537/// side it was looking at, which is what `microvms_core::control::require_valid_tags` does — it names
538/// the key or the value in its message, so a caller with twenty tags is told which one and
539/// which half.
540///
541/// # Why the character set and not the regex
542///
543/// The model's pattern is one `*`-quantified group, and an unanchored `*` matches the empty
544/// string and every prefix — as a predicate it rejects nothing. The service nonetheless refuses
545/// a key with a comma or a percent in it, so the constraint that has any content is the
546/// character *set*, and that is what this decides. Unicode classes rather than byte ranges,
547/// because `\p{L}` includes `é` and `日` and a tag key in a non-Latin script is a legitimate
548/// tag: a byte-range matcher would refuse it.
549///
550/// `\p{Z}` is the separator class, which includes the ordinary space — so `"cost centre"` is a
551/// legal tag key, unlike an image name.
552pub fn is_valid_tag_component(component: &str) -> bool {
553 component.chars().all(|c| {
554 c.is_alphabetic()
555 || c.is_numeric()
556 // `\p{Z}`: separators. `char::is_whitespace` also covers `\n` and `\t`, which are
557 // `\p{Cc}` control characters and outside the class — so they are excluded here
558 // rather than folded in, and a tag key with a newline in it is refused.
559 || matches!(c, ' ' | '\u{00a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}'
560 | '\u{2028}' | '\u{2029}' | '\u{202f}' | '\u{205f}' | '\u{3000}')
561 || matches!(c, '_' | '.' | ':' | '/' | '=' | '+' | '-' | '@')
562 })
563}
564
565/// Whether `arn` is structurally an IAM role ARN, per [`ROLE_ARN_PATTERN`].
566///
567/// # What this decides, and what it deliberately does not
568///
569/// The pattern is `arn:aws[a-z\-]*:iam::[0-9]{12}:role/?[a-zA-Z_0-9+=,.@\-_/]+`, and this checks
570/// every part of it: the `arn:aws` prefix with its optional partition suffix, the literal
571/// `:iam::`, **exactly twelve digits** of account id, and a `:role` segment followed by a
572/// non-empty name in the permitted character set.
573///
574/// The twelve digits are the part worth having. Every real mistake this catches is one of three:
575/// a role *name* passed where an ARN was wanted, an ARN for the wrong service (a Lambda function
576/// ARN, say), or an account id with a digit dropped — and the third is the one no eyeball catches
577/// and no other check would. It is also the reason this is not a `starts_with("arn:")` test the
578/// way `microvms_core::control::ControlPlane::managed_base_versions`'s is: that one distinguishes an
579/// ARN from a bare name, and this one has a specific ARN grammar to hold a value to.
580///
581/// What it does not decide is whether the role **exists**, whether it is assumable, or whether it
582/// grants the `logs:*` on `/aws/lambda-microvms/*` that a build role needs. Those are the failures
583/// that actually happen most often, and none of them is knowable locally — IAM is not in this
584/// crate's dependency set. So this guard is narrow on purpose: it converts a class of malformed
585/// values into a local refusal and leaves the semantic failures to the service, which is the only
586/// party that can answer them.
587pub fn is_valid_role_arn(arn: &str) -> bool {
588 // `arn:aws` then the optional partition suffix, `[a-z\-]*`, up to the next `:`.
589 let Some(rest) = arn.strip_prefix("arn:aws") else {
590 return false;
591 };
592 let Some(colon) = rest.find(':') else {
593 return false;
594 };
595 let (partition_suffix, rest) = rest.split_at(colon);
596 if !partition_suffix
597 .bytes()
598 .all(|b| b.is_ascii_lowercase() || b == b'-')
599 {
600 return false;
601 }
602
603 // `:iam::` then exactly twelve digits then `:role`.
604 let Some(rest) = rest.strip_prefix(":iam::") else {
605 return false;
606 };
607 let Some(colon) = rest.find(':') else {
608 return false;
609 };
610 let (account, rest) = rest.split_at(colon);
611 if account.len() != 12 || !account.bytes().all(|b| b.is_ascii_digit()) {
612 return false;
613 }
614 let Some(name) = rest.strip_prefix(":role") else {
615 return false;
616 };
617 // `role/?` — the slash is optional in the pattern, and the name that follows is `+`, so it
618 // must be non-empty either way. `role/admin` and `roleadmin` both match the model's regex;
619 // only the first is a real ARN, and refusing the second would be this client being stricter
620 // than the service on a value it has no other reason to inspect.
621 let name = name.strip_prefix('/').unwrap_or(name);
622 !name.is_empty()
623 && name.bytes().all(|b| {
624 b.is_ascii_alphanumeric()
625 || matches!(b, b'_' | b'+' | b'=' | b',' | b'.' | b'@' | b'-' | b'/')
626 })
627}
628
629/// The deprecated `MICROVM_REGIONS`' names, for the key `microvm constants` printed before 1.3.0.
630#[allow(deprecated)]
631fn first_launch_regions() -> Vec<String> {
632 crate::region::MICROVM_REGIONS
633 .iter()
634 .map(|region| region.as_str().to_string())
635 .collect()
636}
637
638/// Every constant in this module as one JSON object, keyed for the model drift gate.
639///
640/// The drift gate's second source (TRAP-12). Emitted through `serde_json` rather than
641/// formatted by hand so the object is a function of the values above, which is the
642/// only version of this that cannot drift from them.
643///
644/// `MEASURED_REGIONS` is in here and is explicitly **not** model-backed — no service
645/// model states it and no API answers the question (see [`crate::region`]). It is
646/// published so the gate can compare it with its independently pinned region list.
647/// `MICROVM_REGIONS`, the five regions with constants, stays a key for 1.x because
648/// `microvm constants` printed it before 1.3.0; it is deprecated with the Rust constant.
649///
650/// # A list of pairs rather than one `json!({...})` literal, and that is forced
651///
652/// It was one object literal until issue #24's constants took the count past 40, at which
653/// point `json!` fails to compile: "recursion limit reached while expanding
654/// `$crate::json_internal!`", because the macro recurses once per entry against the crate's
655/// 128-deep default. The two ways out were a crate-wide `#![recursion_limit]` bump — a global
656/// knob turned for one macro in one function — and this. Each entry is still `json!(CONST)`, so
657/// every value is a function of the `pub const` above it, which is the property the gate rests
658/// on.
659pub fn as_json() -> Value {
660 let entries: Vec<(&str, Value)> = vec![
661 ("MODEL_API_VERSION", json!(MODEL_API_VERSION)),
662 (
663 "MAX_RUN_HOOK_PAYLOAD_BYTES",
664 json!(MAX_RUN_HOOK_PAYLOAD_BYTES),
665 ),
666 (
667 "DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES",
668 json!(DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES),
669 ),
670 ("MAX_IMAGE_NAME_LEN", json!(MAX_IMAGE_NAME_LEN)),
671 ("IMAGE_NAME_PATTERN", json!(IMAGE_NAME_PATTERN)),
672 ("MAX_VERSION_LEN", json!(MAX_VERSION_LEN)),
673 ("VERSION_PATTERN", json!(VERSION_PATTERN)),
674 ("MAX_NON_BLANK_LEN", json!(MAX_NON_BLANK_LEN)),
675 ("NON_BLANK_PATTERN", json!(NON_BLANK_PATTERN)),
676 ("MAX_IDENTIFIER_LEN", json!(MAX_IDENTIFIER_LEN)),
677 ("MAX_IMAGE_ARN_LEN", json!(MAX_IMAGE_ARN_LEN)),
678 ("MAX_TAG_KEY_LEN", json!(MAX_TAG_KEY_LEN)),
679 ("MAX_TAG_VALUE_LEN", json!(MAX_TAG_VALUE_LEN)),
680 ("TAG_COMPONENT_PATTERN", json!(TAG_COMPONENT_PATTERN)),
681 ("MAX_LOG_GROUP_LEN", json!(MAX_LOG_GROUP_LEN)),
682 ("LOG_GROUP_PATTERN", json!(LOG_GROUP_PATTERN)),
683 ("MAX_LOG_STREAM_LEN", json!(MAX_LOG_STREAM_LEN)),
684 ("LOG_STREAM_PATTERN", json!(LOG_STREAM_PATTERN)),
685 ("MAX_USER_LOG_STREAM_LEN", json!(MAX_USER_LOG_STREAM_LEN)),
686 ("MIN_ROLE_ARN_LEN", json!(MIN_ROLE_ARN_LEN)),
687 ("MAX_ROLE_ARN_LEN", json!(MAX_ROLE_ARN_LEN)),
688 ("ROLE_ARN_PATTERN", json!(ROLE_ARN_PATTERN)),
689 ("MIN_PORT", json!(MIN_PORT)),
690 ("MAX_PORT", json!(MAX_PORT)),
691 ("MIN_IDLE_DURATION_SEC", json!(MIN_IDLE_DURATION_SEC)),
692 ("MAX_DURATION_SEC", json!(MAX_DURATION_SEC)),
693 (
694 "MAX_MICROVM_HOOK_TIMEOUT_SEC",
695 json!(MAX_MICROVM_HOOK_TIMEOUT_SEC),
696 ),
697 (
698 "MAX_IMAGE_HOOK_TIMEOUT_SEC",
699 json!(MAX_IMAGE_HOOK_TIMEOUT_SEC),
700 ),
701 ("MAX_HOOK_PORT", json!(MAX_HOOK_PORT)),
702 ("CAPABILITIES", json!(CAPABILITIES)),
703 ("ARCHITECTURES", json!(ARCHITECTURES)),
704 ("MAX_NETWORK_CONNECTORS", json!(MAX_NETWORK_CONNECTORS)),
705 (
706 "MAX_NETWORK_CONNECTOR_LEN",
707 json!(MAX_NETWORK_CONNECTOR_LEN),
708 ),
709 (
710 "MAX_IMAGE_EGRESS_CONNECTORS",
711 json!(MAX_IMAGE_EGRESS_CONNECTORS),
712 ),
713 ("IMAGE_VERSION_STATUSES", json!(IMAGE_VERSION_STATUSES)),
714 ("HOOK_STATES", json!(HOOK_STATES)),
715 ("MICROVM_STATES", json!(MICROVM_STATES)),
716 ("IMAGE_STATES", json!(IMAGE_STATES)),
717 ("IMAGE_VERSION_STATES", json!(IMAGE_VERSION_STATES)),
718 ("BUILD_STATES", json!(BUILD_STATES)),
719 ("CHIPSETS", json!(CHIPSETS)),
720 ("MAX_RESOURCES", json!(MAX_RESOURCES)),
721 ("MAX_CLIENT_TOKEN_LEN", json!(MAX_CLIENT_TOKEN_LEN)),
722 ("MODEL_IMAGE_READY_STATES", json!(MODEL_IMAGE_READY_STATES)),
723 (
724 "TOLERATED_IMAGE_READY_STATES",
725 json!(TOLERATED_IMAGE_READY_STATES),
726 ),
727 ("TERMINAL_STATES", json!(TERMINAL_STATES)),
728 ("DEAD_STATES", json!(DEAD_STATES)),
729 ("MEASURED_REGIONS", json!(MEASURED_REGIONS)),
730 ("MICROVM_REGIONS", json!(first_launch_regions())),
731 (
732 "SIZE_CLASSES",
733 json!(
734 crate::sizing::SIZE_CLASSES
735 .iter()
736 .map(|row| json!({
737 "baseline_mib": row.baseline_mib,
738 "baseline_vcpu": row.baseline_vcpu,
739 "peak_mib": row.peak_mib,
740 "peak_vcpu": row.peak_vcpu,
741 }))
742 .collect::<Vec<_>>()
743 ),
744 ),
745 ];
746 Value::Object(
747 entries
748 .into_iter()
749 .map(|(key, value)| (key.to_string(), value))
750 .collect(),
751 )
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 /// **TRAP-12.** The key set the drift gate reads, pinned.
759 ///
760 /// This is the guard for the one failure mode a rename here produces: the script
761 /// looks a key up, does not find it, and either crashes or — worse, depending on
762 /// how it is written — reports nothing disagreed. Compilation cannot catch that,
763 /// because the coupling is a string in another language's file.
764 #[test]
765 fn as_json_carries_every_key_the_drift_gate_reads() {
766 let emitted = as_json();
767 let object = emitted.as_object().expect("an object");
768 let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
769 keys.sort_unstable();
770 assert_eq!(
771 keys,
772 [
773 "ARCHITECTURES",
774 "BUILD_STATES",
775 "CAPABILITIES",
776 "CHIPSETS",
777 "DEAD_STATES",
778 "DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES",
779 "HOOK_STATES",
780 "IMAGE_NAME_PATTERN",
781 "IMAGE_STATES",
782 "IMAGE_VERSION_STATES",
783 "IMAGE_VERSION_STATUSES",
784 "LOG_GROUP_PATTERN",
785 "LOG_STREAM_PATTERN",
786 "MAX_CLIENT_TOKEN_LEN",
787 "MAX_DURATION_SEC",
788 "MAX_HOOK_PORT",
789 "MAX_IDENTIFIER_LEN",
790 "MAX_IMAGE_ARN_LEN",
791 "MAX_IMAGE_EGRESS_CONNECTORS",
792 "MAX_IMAGE_HOOK_TIMEOUT_SEC",
793 "MAX_IMAGE_NAME_LEN",
794 "MAX_LOG_GROUP_LEN",
795 "MAX_LOG_STREAM_LEN",
796 "MAX_MICROVM_HOOK_TIMEOUT_SEC",
797 "MAX_NETWORK_CONNECTORS",
798 "MAX_NETWORK_CONNECTOR_LEN",
799 "MAX_NON_BLANK_LEN",
800 "MAX_PORT",
801 "MAX_RESOURCES",
802 "MAX_ROLE_ARN_LEN",
803 "MAX_RUN_HOOK_PAYLOAD_BYTES",
804 "MAX_TAG_KEY_LEN",
805 "MAX_TAG_VALUE_LEN",
806 "MAX_USER_LOG_STREAM_LEN",
807 "MAX_VERSION_LEN",
808 "MEASURED_REGIONS",
809 "MICROVM_REGIONS",
810 "MICROVM_STATES",
811 "MIN_IDLE_DURATION_SEC",
812 "MIN_PORT",
813 "MIN_ROLE_ARN_LEN",
814 "MODEL_API_VERSION",
815 "MODEL_IMAGE_READY_STATES",
816 "NON_BLANK_PATTERN",
817 "ROLE_ARN_PATTERN",
818 "SIZE_CLASSES",
819 "TAG_COMPONENT_PATTERN",
820 "TERMINAL_STATES",
821 "TOLERATED_IMAGE_READY_STATES",
822 "VERSION_PATTERN",
823 ]
824 );
825 }
826
827 /// The values against independent expected literals. Written
828 /// out rather than compared against the constants, so this test disagrees with a
829 /// changed constant instead of following it.
830 #[test]
831 fn the_emitted_values_are_the_measured_ones() {
832 let emitted = as_json();
833 assert_eq!(emitted["MODEL_API_VERSION"], "2025-09-09");
834 assert_eq!(emitted["MAX_RUN_HOOK_PAYLOAD_BYTES"], 4096);
835 assert_eq!(emitted["MAX_IMAGE_NAME_LEN"], 64);
836 assert_eq!(emitted["IMAGE_NAME_PATTERN"], "[a-zA-Z0-9-_]+");
837 assert_eq!(emitted["MAX_VERSION_LEN"], 2048);
838 assert_eq!(emitted["VERSION_PATTERN"], "[^\\s]+");
839 assert_eq!(emitted["MAX_NON_BLANK_LEN"], 2048);
840 assert_eq!(emitted["NON_BLANK_PATTERN"], "[^\\s]+");
841 assert_eq!(emitted["MAX_IDENTIFIER_LEN"], 256);
842 assert_eq!(emitted["MAX_IMAGE_ARN_LEN"], 2048);
843 assert_eq!(emitted["MAX_TAG_KEY_LEN"], 128);
844 assert_eq!(emitted["MAX_TAG_VALUE_LEN"], 256);
845 assert_eq!(
846 emitted["TAG_COMPONENT_PATTERN"],
847 "([\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*)"
848 );
849 assert_eq!(emitted["MAX_LOG_GROUP_LEN"], 512);
850 assert_eq!(emitted["LOG_GROUP_PATTERN"], "[a-zA-Z0-9_\\-/.#]+");
851 assert_eq!(emitted["MAX_LOG_STREAM_LEN"], 512);
852 assert_eq!(emitted["LOG_STREAM_PATTERN"], "[^:*]*");
853 assert_eq!(emitted["MAX_USER_LOG_STREAM_LEN"], 495);
854 assert_eq!(emitted["MIN_ROLE_ARN_LEN"], 20);
855 assert_eq!(emitted["MAX_ROLE_ARN_LEN"], 2048);
856 assert_eq!(
857 emitted["ROLE_ARN_PATTERN"],
858 "arn:aws[a-z\\-]*:iam::[0-9]{12}:role/?[a-zA-Z_0-9+=,.@\\-_/]+"
859 );
860 assert_eq!(emitted["MIN_PORT"], 1);
861 assert_eq!(emitted["MAX_PORT"], 65_535);
862 assert_eq!(emitted["MIN_IDLE_DURATION_SEC"], 60);
863 assert_eq!(emitted["MAX_DURATION_SEC"], 28_800);
864 assert_eq!(emitted["MAX_MICROVM_HOOK_TIMEOUT_SEC"], 60);
865 assert_eq!(emitted["MAX_IMAGE_HOOK_TIMEOUT_SEC"], 3_600);
866 assert_eq!(emitted["MAX_HOOK_PORT"], 65_535);
867 assert_eq!(emitted["CAPABILITIES"], json!(["ALL"]));
868 assert_eq!(emitted["ARCHITECTURES"], json!(["ARM_64"]));
869 assert_eq!(emitted["MAX_NETWORK_CONNECTORS"], 10);
870 // 1, not 10. The image-level list and the VM-level one differ by an order of
871 // magnitude, and this is the assertion that says so in one place.
872 assert_eq!(emitted["MAX_IMAGE_EGRESS_CONNECTORS"], 1);
873 assert_eq!(
874 emitted["IMAGE_VERSION_STATUSES"],
875 json!(["ACTIVE", "INACTIVE"])
876 );
877 assert_eq!(emitted["HOOK_STATES"], json!(["DISABLED", "ENABLED"]));
878 assert_eq!(
879 emitted["MICROVM_STATES"],
880 json!([
881 "PENDING",
882 "RUNNING",
883 "SUSPENDING",
884 "SUSPENDED",
885 "TERMINATING",
886 "TERMINATED"
887 ])
888 );
889 assert_eq!(
890 emitted["IMAGE_STATES"],
891 json!([
892 "CREATING",
893 "CREATED",
894 "CREATE_FAILED",
895 "UPDATING",
896 "UPDATED",
897 "UPDATE_FAILED",
898 "DELETING",
899 "DELETE_FAILED",
900 "DELETED"
901 ])
902 );
903 assert_eq!(
904 emitted["IMAGE_VERSION_STATES"],
905 json!([
906 "PENDING",
907 "IN_PROGRESS",
908 "SUCCESSFUL",
909 "FAILED",
910 "DELETING",
911 "DELETED",
912 "DELETE_FAILED"
913 ])
914 );
915 assert_eq!(
916 emitted["BUILD_STATES"],
917 json!(["PENDING", "IN_PROGRESS", "SUCCESSFUL", "FAILED"])
918 );
919 assert_eq!(emitted["CHIPSETS"], json!(["GRAVITON"]));
920 assert_eq!(emitted["MAX_RESOURCES"], 1);
921 assert_eq!(emitted["MAX_CLIENT_TOKEN_LEN"], 128);
922 assert_eq!(
923 emitted["TERMINAL_STATES"],
924 json!(["TERMINATED", "TERMINATING", "SUSPENDED", "SUSPENDING"])
925 );
926 assert_eq!(emitted["DEAD_STATES"], json!(["TERMINATED", "TERMINATING"]));
927 assert_eq!(
928 emitted["MODEL_IMAGE_READY_STATES"],
929 json!(["CREATED", "UPDATED"])
930 );
931 assert_eq!(
932 emitted["TOLERATED_IMAGE_READY_STATES"],
933 json!(["ACTIVE", "AVAILABLE"])
934 );
935 assert_eq!(
936 emitted["MEASURED_REGIONS"],
937 json!([
938 "us-east-1",
939 "us-east-2",
940 "us-west-2",
941 "eu-west-1",
942 "ap-northeast-1",
943 "ap-south-1",
944 "ap-southeast-1",
945 "ap-southeast-2",
946 "eu-central-1",
947 "eu-north-1",
948 "ap-east-1",
949 "ap-southeast-5",
950 "ap-southeast-7",
951 "eu-south-2"
952 ])
953 );
954 assert_eq!(
955 emitted["MICROVM_REGIONS"],
956 json!([
957 "us-east-1",
958 "us-east-2",
959 "us-west-2",
960 "eu-west-1",
961 "ap-northeast-1"
962 ])
963 );
964 }
965
966 /// The object survives a serialize/parse cycle unchanged, which is what the gate
967 /// does to it: the CLI prints it and the script calls `json.loads`. A value that
968 /// only compares equal in memory is a value the gate never sees.
969 #[test]
970 fn the_emitted_object_round_trips_through_a_string() {
971 let emitted = as_json();
972 let text = serde_json::to_string(&emitted).expect("serializes");
973 let parsed: Value = serde_json::from_str(&text).expect("parses");
974 assert_eq!(parsed, emitted);
975 }
976
977 /// The dead states are a subset of the terminal ones, which is the relationship
978 /// the two lists exist to express. A DEAD_STATES entry that is not terminal would
979 /// make the launch guard and the resume guard disagree about what SUSPENDED means.
980 #[test]
981 fn every_dead_state_is_also_a_terminal_state() {
982 for state in DEAD_STATES {
983 assert!(TERMINAL_STATES.contains(&state), "{state} is not terminal");
984 }
985 assert!(
986 !DEAD_STATES.contains(&"SUSPENDED"),
987 "SUSPENDED is terminal but not dead: it is the state a resume is called from"
988 );
989 }
990
991 /// **Issue #24's wrong-by-10x hazard.** The two connector ceilings are different
992 /// numbers, and the image-level one is the smaller.
993 ///
994 /// Stated as an inequality rather than only as two literals above, because the failure
995 /// mode is a future edit that *unifies* them — "there is only one connector limit, delete
996 /// the duplicate" — and the permissive direction is the one that reaches the wire. A
997 /// caller who applied `MAX_NETWORK_CONNECTORS` to an image's `egressNetworkConnectors`
998 /// would accept ten and have the service refuse two.
999 #[test]
1000 fn the_image_level_connector_ceiling_is_one_and_not_the_vm_levels_ten() {
1001 assert_eq!(MAX_IMAGE_EGRESS_CONNECTORS, 1);
1002 assert_eq!(MAX_NETWORK_CONNECTORS, 10);
1003 // Read out of `as_json()` rather than compared as two `const`s, because clippy's
1004 // `assertions_on_constants` is right that a `const < const` assertion is decided at
1005 // compile time and proves nothing at runtime. Going through the emitted object makes it
1006 // a real comparison — and it is the object the drift gate reads, so this asserts the
1007 // published pair rather than two literals a reader could unify.
1008 let emitted = as_json();
1009 let image = emitted["MAX_IMAGE_EGRESS_CONNECTORS"]
1010 .as_u64()
1011 .expect("a number");
1012 let vm = emitted["MAX_NETWORK_CONNECTORS"]
1013 .as_u64()
1014 .expect("a number");
1015 assert!(
1016 image < vm,
1017 "collapsing the two would be permissive by 10x on the image-level list: \
1018 {image} against {vm}"
1019 );
1020 }
1021
1022 /// The version statuses are exactly the two the model declares, spelled uppercase.
1023 ///
1024 /// The typed request-side spelling is checked against this array in
1025 /// `microvms_core::control::ops`, so the two cannot drift; here what is pinned is the pair itself
1026 /// and the fact that neither is a lowercase or a past-tense variant of the other.
1027 #[test]
1028 fn the_two_version_statuses_are_active_and_inactive() {
1029 assert_eq!(IMAGE_VERSION_STATUSES, ["ACTIVE", "INACTIVE"]);
1030 for status in IMAGE_VERSION_STATUSES {
1031 assert_eq!(status, status.to_uppercase(), "{status}");
1032 }
1033 assert!(
1034 !IMAGE_VERSION_STATUSES.contains(&"DISABLED"),
1035 "DISABLED is HookState's spelling, not this enum's — mixing them is a \
1036 ValidationException on the only member the update request has"
1037 );
1038 }
1039
1040 /// The ready-state sets are disjoint, so the gate can check the model-derived one
1041 /// exactly. An overlap would mean a tolerated spelling was being presented as
1042 /// model-backed.
1043 #[test]
1044 fn the_model_and_tolerated_ready_states_do_not_overlap() {
1045 for state in TOLERATED_IMAGE_READY_STATES {
1046 assert!(
1047 !MODEL_IMAGE_READY_STATES.contains(&state),
1048 "{state} is claimed as both model-backed and tolerated"
1049 );
1050 }
1051 }
1052
1053 /// The pattern's four ranges and nothing else. The two rejected characters are the
1054 /// separators a caller reaching for a namespaced name writes first, which is why
1055 /// they are named.
1056 #[test]
1057 fn an_image_name_takes_letters_digits_hyphen_and_underscore_only() {
1058 assert!(is_valid_image_name("agentd-conformance_01"));
1059 assert!(is_valid_image_name("A"));
1060 assert!(is_valid_image_name(&"a".repeat(MAX_IMAGE_NAME_LEN)));
1061
1062 assert!(!is_valid_image_name(""), "min is 1");
1063 assert!(
1064 !is_valid_image_name(&"a".repeat(MAX_IMAGE_NAME_LEN + 1)),
1065 "65 characters is one past the ceiling"
1066 );
1067 for rejected in [
1068 "my.image",
1069 "team/image",
1070 "my image",
1071 "img!",
1072 "imagé",
1073 "img\n",
1074 ] {
1075 assert!(!is_valid_image_name(rejected), "{rejected}");
1076 }
1077 }
1078
1079 /// A multi-byte character cannot slip past the length check by being counted as
1080 /// one: the model's max is on bytes, and the character class rejects it anyway.
1081 #[test]
1082 fn a_multibyte_name_is_refused_by_the_character_class_not_by_luck() {
1083 assert!(!is_valid_image_name("é"));
1084 assert!(!is_valid_image_name("日本語"));
1085 }
1086
1087 /// The log-group matcher takes exactly the model's set — letters, digits, `_ - / . #` —
1088 /// and both length bounds.
1089 ///
1090 /// `/aws/lambda-microvms/img` is the shape the service itself creates, so it has to
1091 /// pass; a colon is the character a caller pasting an ARN brings in first, so it is the
1092 /// named rejection.
1093 #[test]
1094 fn a_log_group_takes_the_models_character_set_and_bounds() {
1095 assert!(is_valid_log_group("/aws/lambda-microvms/img"));
1096 assert!(is_valid_log_group("builds_2026.08#a"));
1097 assert!(is_valid_log_group(&"a".repeat(MAX_LOG_GROUP_LEN)));
1098
1099 assert!(!is_valid_log_group(""), "min is 1");
1100 assert!(
1101 !is_valid_log_group(&"a".repeat(MAX_LOG_GROUP_LEN + 1)),
1102 "513 characters is one past the ceiling"
1103 );
1104 for rejected in ["arn:aws:logs", "a group", "grp*", "grp\n", "imagé"] {
1105 assert!(!is_valid_log_group(rejected), "{rejected}");
1106 }
1107 }
1108
1109 /// The user-facing stream ceiling leaves exactly the discriminator's room under the
1110 /// wire ceiling: `/` plus sixteen hex characters.
1111 ///
1112 /// Pinned as arithmetic so a change to the nonce width (`token.rs`'s eight bytes) or to
1113 /// either ceiling fails here rather than as a `ValidationException` after the artifact
1114 /// upload — the same shape as `token.rs`'s own cap test.
1115 #[test]
1116 fn the_user_stream_ceiling_leaves_room_for_the_slash_and_sixteen_hex() {
1117 assert_eq!(MAX_USER_LOG_STREAM_LEN + 1 + 16, MAX_LOG_STREAM_LEN);
1118 }
1119
1120 /// **Issue #24's documentation trap, pinned as a comparison.** The model's own prose says
1121 /// 16,384 and its shape says 4096, and the shape is the one that is enforced.
1122 ///
1123 /// The pair is asserted rather than only commented because the failure mode is a future
1124 /// reader "correcting" [`MAX_RUN_HOOK_PAYLOAD_BYTES`] from the model's documentation string —
1125 /// which they would be able to cite while doing it. This test is what makes that a red build
1126 /// rather than a plausible-looking commit, and the 4x relationship is asserted so the shape of
1127 /// the hazard is in the test and not only in a doc comment.
1128 ///
1129 /// **Guard proof.** Set `MAX_RUN_HOOK_PAYLOAD_BYTES` to 16_384 and this fails on the
1130 /// inequality *and* on the ratio; delete `DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES` and it does not
1131 /// compile.
1132 #[test]
1133 fn the_models_own_prose_claims_four_times_the_ceiling_its_shape_states() {
1134 let emitted = as_json();
1135 let real = emitted["MAX_RUN_HOOK_PAYLOAD_BYTES"]
1136 .as_u64()
1137 .expect("a number");
1138 let documented = emitted["DOCUMENTED_RUN_HOOK_PAYLOAD_BYTES"]
1139 .as_u64()
1140 .expect("a number");
1141 assert_eq!(real, 4096, "the shape, which is what the service enforces");
1142 assert_eq!(
1143 documented, 16_384,
1144 "the model's documentation string on RunMicrovmRequest.runHookPayload"
1145 );
1146 assert!(
1147 documented > real,
1148 "the model's prose is permissive against its own shape: {documented} against {real}"
1149 );
1150 assert_eq!(
1151 documented / real,
1152 4,
1153 "wrong by 4x, in the direction that tells a caller four times as much secret \
1154 material fits as actually does"
1155 );
1156 }
1157
1158 /// **The model's identifier contradiction, pinned.** A legal `MicrovmImageArn` can be longer
1159 /// than a legal `MicrovmImageIdentifier`, so the service may answer with a value it would
1160 /// refuse as a request.
1161 ///
1162 /// Held as an inequality because the fact is a *relationship* between two shapes and no
1163 /// single shape states it. If AWS ever narrows `MicrovmImageArn` to 256 this goes red, which
1164 /// is the signal that the paragraph in [`MAX_IDENTIFIER_LEN`] about resolving the
1165 /// contradiction should be deleted.
1166 ///
1167 /// The second half is the reason it is theoretical in practice: a real ARN in the
1168 /// longest-named MicroVM region is nowhere near 256, and that arithmetic is measured here
1169 /// rather than asserted as a habit.
1170 #[test]
1171 fn a_legal_image_arn_can_be_longer_than_a_legal_identifier() {
1172 let emitted = as_json();
1173 let identifier = emitted["MAX_IDENTIFIER_LEN"].as_u64().expect("a number");
1174 let arn = emitted["MAX_IMAGE_ARN_LEN"].as_u64().expect("a number");
1175 assert!(
1176 arn > identifier,
1177 "the model permits a {arn}-character image ARN and a {identifier}-character \
1178 identifier, so a legal response value can be an illegal request value"
1179 );
1180
1181 // The worst legal real ARN: the longest name of a region AWS runs MicroVMs in, a
1182 // twelve-digit account, and a
1183 // 64-character image name. Built rather than written as a literal, so the arithmetic is
1184 // the test's rather than mine.
1185 let longest_region = MEASURED_REGIONS
1186 .iter()
1187 .map(|region| region.len())
1188 .max()
1189 .expect("fourteen regions");
1190 let worst = format!(
1191 "arn:aws:lambda:{}:{}:microvm-image:{}",
1192 "x".repeat(longest_region),
1193 "9".repeat(12),
1194 "a".repeat(MAX_IMAGE_NAME_LEN),
1195 );
1196 assert!(
1197 worst.len() <= MAX_IDENTIFIER_LEN,
1198 "the worst legal image ARN is {} characters, which must fit the {MAX_IDENTIFIER_LEN} \
1199 identifier bound or the contradiction would be reachable rather than theoretical",
1200 worst.len(),
1201 );
1202 }
1203
1204 /// Every state set the client's loops branch on is a subset of the model's enum for it.
1205 ///
1206 /// **This is what moving the five state enums out of the drift script buys.** They were
1207 /// pinned against literals inside `tools/check-model-drift.py`, so a `MicrovmState` AWS
1208 /// respelled failed the gate with no consequence for the code that branches on it (issue
1209 /// #24). With the sets here, the same respelling fails this test as well, and this test names
1210 /// the loop.
1211 ///
1212 /// `microvms_core::control::microvm::SUSPEND_WANTED` is checked against the same enum in
1213 /// that module's tests, since this crate can't see it.
1214 ///
1215 /// **Guard proof.** Respell one member of `MICROVM_STATES` — `"SUSPENDED"` to
1216 /// `"SUSPEND_PENDING"`, say — and this fails naming `TERMINAL_STATES` and `DEAD_STATES` in
1217 /// turn, and core's test fails naming `SUSPEND_WANTED`. Drop `"PENDING"` from `BUILD_STATES` and the stall-probe
1218 /// assertion fails.
1219 #[test]
1220 fn every_state_set_the_polling_loops_use_is_a_subset_of_the_models_enum() {
1221 for state in TERMINAL_STATES {
1222 assert!(
1223 MICROVM_STATES.contains(&state),
1224 "TERMINAL_STATES has {state}, which is not a MicrovmState — \
1225 ControlPlane::wait_for_running fails fast on it and would now never fire"
1226 );
1227 }
1228 for state in DEAD_STATES {
1229 assert!(
1230 MICROVM_STATES.contains(&state),
1231 "DEAD_STATES has {state}, which is not a MicrovmState — the resume path passes \
1232 this set as its fail-fast list"
1233 );
1234 }
1235 for state in MODEL_IMAGE_READY_STATES {
1236 assert!(
1237 IMAGE_STATES.contains(&state),
1238 "MODEL_IMAGE_READY_STATES has {state}, which is not a MicrovmImageState — \
1239 Image::is_ready would never answer true for it and a built image would poll to \
1240 the 45-minute deadline"
1241 );
1242 }
1243 for state in TOLERATED_IMAGE_READY_STATES {
1244 assert!(
1245 !IMAGE_STATES.contains(&state),
1246 "{state} is in the model's enum now, so it is no longer a tolerated legacy \
1247 spelling — promote it to MODEL_IMAGE_READY_STATES"
1248 );
1249 }
1250 // TRAP-2's stall probe compares `build_state == "PENDING"` against a literal. This is the
1251 // check that notices the literal going stale, which is the same class of bug as
1252 // `buildState`/`state` — dead against live AWS while its unit test passed.
1253 assert!(
1254 BUILD_STATES.contains(&"PENDING"),
1255 "the stall probe's replay signature is 'every build still PENDING'"
1256 );
1257 // `Image::is_failed` is a substring test on FAILED rather than a set, so that a fourth
1258 // failure spelling is still recognised. That reading has to stay true of the enum.
1259 let failed: Vec<&str> = IMAGE_STATES
1260 .iter()
1261 .copied()
1262 .filter(|state| state.contains("FAILED"))
1263 .collect();
1264 assert_eq!(
1265 failed,
1266 ["CREATE_FAILED", "UPDATE_FAILED", "DELETE_FAILED"],
1267 "the three spellings the substring test covers"
1268 );
1269 }
1270
1271 /// The tag character set: letters and digits in any script, spaces, and eight punctuation
1272 /// marks. Commas, `#`, `%`, and newlines are out.
1273 ///
1274 /// The non-Latin cases are the ones a byte-range matcher would have got wrong, and they are
1275 /// legitimate tags — `\p{L}` is every letter, not every ASCII letter. The newline case is the
1276 /// one `char::is_whitespace` would have got wrong in the other direction: `\n` is `\p{Cc}`,
1277 /// a control character, not the `\p{Z}` separator class the pattern names.
1278 #[test]
1279 fn a_tag_component_takes_any_script_and_eight_punctuation_marks() {
1280 for accepted in [
1281 "owner",
1282 "cost-centre",
1283 "cost centre",
1284 "team/agents",
1285 "a.b:c=d+e-f@g_h",
1286 "日本語",
1287 "café",
1288 "123",
1289 "",
1290 ] {
1291 assert!(is_valid_tag_component(accepted), "{accepted:?}");
1292 }
1293 for rejected in [
1294 "a,b",
1295 "a#b",
1296 "50%",
1297 "line\nbreak",
1298 "tab\there",
1299 "a!b",
1300 "a(b",
1301 ] {
1302 assert!(!is_valid_tag_component(rejected), "{rejected:?}");
1303 }
1304 }
1305
1306 /// The role-ARN matcher, and the twelve-digit account id is the part that earns it.
1307 ///
1308 /// Three real mistakes and one near-miss: a role name where an ARN was wanted, an ARN for
1309 /// another service, an account id with a digit dropped, and a `roleadmin` with no slash —
1310 /// which the model's regex accepts, so this does too rather than being stricter than the
1311 /// service on a value it has no other reason to inspect.
1312 #[test]
1313 fn a_role_arn_needs_twelve_account_digits_and_an_iam_role_path() {
1314 for accepted in [
1315 "arn:aws:iam::123456789012:role/build",
1316 "arn:aws:iam::123456789012:role/service-role/path/to/role",
1317 "arn:aws-us-gov:iam::123456789012:role/build",
1318 "arn:aws-cn:iam::123456789012:role/build",
1319 "arn:aws:iam::123456789012:role/name+with=every,allowed.char@here_x-y",
1320 // No slash: the model's `role/?` makes it optional, so this matches the pattern.
1321 "arn:aws:iam::123456789012:roleadmin",
1322 ] {
1323 assert!(is_valid_role_arn(accepted), "{accepted}");
1324 }
1325
1326 for rejected in [
1327 // A role *name*, which is the most common mistake.
1328 "bonk-sandbox-microvm-build",
1329 // The right shape for the wrong service.
1330 "arn:aws:lambda:us-east-1:123456789012:function:handler",
1331 // Eleven digits. The one nothing else catches.
1332 "arn:aws:iam::12345678901:role/build",
1333 // Thirteen.
1334 "arn:aws:iam::1234567890123:role/build",
1335 // Non-digits where the account goes.
1336 "arn:aws:iam::abcdefghijkl:role/build",
1337 // A region where IAM has none, which is what a copied Lambda ARN looks like.
1338 "arn:aws:iam:us-east-1:123456789012:role/build",
1339 // Nothing after `:role`.
1340 "arn:aws:iam::123456789012:role",
1341 "arn:aws:iam::123456789012:role/",
1342 // A user, not a role.
1343 "arn:aws:iam::123456789012:user/laith",
1344 // Uppercase partition, which the pattern's `[a-z\-]*` forbids.
1345 "arn:AWS:iam::123456789012:role/build",
1346 "",
1347 "arn:",
1348 ] {
1349 assert!(!is_valid_role_arn(rejected), "{rejected}");
1350 }
1351 }
1352
1353 /// The real conformance-account role ARNs match, which is what stops this guard from being a
1354 /// pattern that refuses every legitimate value.
1355 ///
1356 /// Written out as the account's actual ARNs rather than as a synthetic `role/build`: a
1357 /// matcher that accepts a fixture and refuses production is the failure mode a guard like this
1358 /// has, and it fires after the artifact upload.
1359 #[test]
1360 fn the_conformance_accounts_own_role_arns_are_accepted() {
1361 for real in [
1362 "arn:aws:iam::392583147479:role/bonk-sandbox-microvm-build",
1363 "arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution",
1364 ] {
1365 assert!(is_valid_role_arn(real), "{real}");
1366 assert!(real.len() >= MIN_ROLE_ARN_LEN, "{real}");
1367 assert!(real.len() <= MAX_ROLE_ARN_LEN, "{real}");
1368 }
1369 }
1370
1371 /// The two port constants bracket what a `u16` can hold, with the floor at 1 and not 0.
1372 ///
1373 /// `MAX_PORT` equalling `u16::MAX` is the fact that makes the ceiling closed by the type and
1374 /// the floor the only live half — which is what the guard's docs claim, asserted rather than
1375 /// stated.
1376 #[test]
1377 fn the_port_floor_is_one_and_the_ceiling_is_what_a_u16_holds() {
1378 assert_eq!(MIN_PORT, 1, "0 is 'let the kernel choose', not a port");
1379 assert_eq!(MAX_PORT, u16::MAX);
1380 assert_eq!(
1381 u32::from(MAX_PORT),
1382 MAX_HOOK_PORT,
1383 "PortNumber and HooksPortInteger agree today; they are two shapes and this is where \
1384 it is noticed if they stop"
1385 );
1386 }
1387
1388 /// The two `[^\s]+` shapes agree today, and the point of two constants is that this test is
1389 /// what says so rather than one constant making it unaskable.
1390 #[test]
1391 fn the_version_and_non_blank_shapes_agree_today_and_are_still_two_constants() {
1392 assert_eq!(MAX_VERSION_LEN, MAX_NON_BLANK_LEN);
1393 assert_eq!(VERSION_PATTERN, NON_BLANK_PATTERN);
1394 // Read out of the emitted object rather than compared as two `const`s, for the reason
1395 // the connector-ceiling test gives: a `const == const` assertion is decided at compile
1396 // time and proves nothing about the values the gate reads.
1397 let emitted = as_json();
1398 assert_eq!(emitted["MAX_VERSION_LEN"], emitted["MAX_NON_BLANK_LEN"]);
1399 assert_eq!(emitted["VERSION_PATTERN"], emitted["NON_BLANK_PATTERN"]);
1400 }
1401}