Skip to main content

microvms_core/
region.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The five regions that carry MicroVMs, as a closed enum (TRAP-6).
3//!
4//! The Python client checks a region against a frozenset at construction time
5//! (`sandbox.py:314` `require_supported_region`) — strength S2 on the spec's
6//! ladder: the mistake can be written down, and a runtime guard rejects it. Here it
7//! is S1 for the five names: `Region::UsEast1` and its four siblings are the only
8//! values a caller can *type*, so a typo'd region is a compile error rather than a
9//! test that has to run.
10//!
11//! # Why refusing matters more than it looks
12//!
13//! Measured 2026-08-07: a region that does not carry MicroVMs answers
14//! `AccessDeniedException` with the message field **null**, which is
15//! indistinguishable from a genuine IAM denial except that a real denial names the
16//! principal and the action. Nothing between the caller and that answer objects —
17//! the service model's `endpointPrefix` is `lambda`, so an endpoint resolves for any
18//! region and a client constructs happily. The first API call is the only reporter
19//! and it reports the wrong cause, sending someone to audit a policy that is fine.
20//!
21//! No API answers the question either, and the two that look like they might
22//! disagree with each other: `get_available_endpoints` returns an empty list while
23//! `get_available_regions` returns all 34 Lambda regions. So the list is kept by
24//! hand, and keeping it right is the whole correctness condition — in both
25//! directions. A *missing* region refuses a launch AWS would have accepted, which is
26//! the safer direction and still wrong; that is what [`Region::unlisted`] is for. An
27//! *extra* region is worse, because it re-opens the null-message trap for a name
28//! nothing will reject.
29//!
30//! `eu-central-1` was on this list until 2026-08-07 and does **not** carry MicroVMs:
31//! it was one of three regions measured returning the null-message denial.
32
33use std::fmt;
34use std::str::FromStr;
35
36use crate::error::Error;
37
38/// An AWS region, closed over the five that run MicroVMs plus a named escape hatch.
39///
40/// See the module docs for why the five are an enum. [`Region::Unlisted`] is
41/// deliberately a variant rather than a hidden flag: a reader of a call site can
42/// see that someone opted into the trap, and a `match` over regions cannot forget
43/// the case exists.
44#[derive(Clone, Debug, Eq, Hash, PartialEq)]
45pub enum Region {
46    UsEast1,
47    UsEast2,
48    UsWest2,
49    EuWest1,
50    ApNortheast1,
51    /// A region this client has not seen carry MicroVMs.
52    ///
53    /// **You are opting into the null-message trap.** If this region does not run
54    /// MicroVMs, the first control-plane call answers `AccessDeniedException` with a
55    /// null message and you will spend the next hour reading an IAM policy that is
56    /// correct. Constructible only through [`Region::unlisted`], which says so at
57    /// the call site.
58    ///
59    /// It exists because AWS adds regions faster than this list is re-read, and a
60    /// client that refuses a region AWS has just launched in is its own kind of
61    /// wrong. The override costs exactly the diagnostic above.
62    Unlisted(String),
63}
64
65/// The five regions that answered `ListMicrovms` when this was measured
66/// (2026-08-07), smallest-latency-first order not implied — this is the order
67/// `MICROVM_REGIONS` is documented in.
68///
69/// Not model-backed and it cannot be: see the module docs. `constants::as_json`
70/// publishes this set so `scripts/check-model-drift.py` can compare the Rust and
71/// Python clients against each other, which is the only check available for a value
72/// no service model states.
73pub const MICROVM_REGIONS: [Region; 5] = [
74    Region::UsEast1,
75    Region::UsEast2,
76    Region::UsWest2,
77    Region::EuWest1,
78    Region::ApNortheast1,
79];
80
81impl Region {
82    /// The wire spelling, which is also the endpoint's middle segment.
83    pub fn as_str(&self) -> &str {
84        match self {
85            Region::UsEast1 => "us-east-1",
86            Region::UsEast2 => "us-east-2",
87            Region::UsWest2 => "us-west-2",
88            Region::EuWest1 => "eu-west-1",
89            Region::ApNortheast1 => "ap-northeast-1",
90            Region::Unlisted(name) => name,
91        }
92    }
93
94    /// Opts into a region this client has not seen carry MicroVMs.
95    ///
96    /// **This is the escape hatch, and it costs you the diagnostic.** If the region
97    /// does not run MicroVMs, the first control-plane call answers
98    /// `AccessDeniedException` with a null message field, which reads as a genuine
99    /// IAM denial (`docs/PLATFORM.md`, "Calling an unpriced region returns
100    /// `AccessDeniedException` with a null message"). Use it when AWS has launched
101    /// MicroVMs somewhere new, and add the region to [`MICROVM_REGIONS`] in the same
102    /// change.
103    ///
104    /// A supported name passed here comes back as its proper variant rather than as
105    /// [`Region::Unlisted`], so `unlisted("us-east-1") == Region::UsEast1` and
106    /// nothing downstream has to handle two spellings of one region.
107    pub fn unlisted(name: impl Into<String>) -> Region {
108        let name = name.into();
109        match Self::supported(&name) {
110            Some(known) => known,
111            None => Region::Unlisted(name),
112        }
113    }
114
115    /// The variant for a supported name, or `None` for anything else.
116    ///
117    /// The single reader of the five spellings: [`FromStr`] and [`Region::unlisted`]
118    /// both come through here, so there is no second table to drift from the first.
119    fn supported(name: &str) -> Option<Region> {
120        MICROVM_REGIONS
121            .into_iter()
122            .find(|region| region.as_str() == name)
123    }
124
125    /// Whether this is one of the five, rather than an opted-into unlisted name.
126    pub fn is_supported(&self) -> bool {
127        !matches!(self, Region::Unlisted(_))
128    }
129}
130
131impl fmt::Display for Region {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137impl FromStr for Region {
138    type Err = Error;
139
140    /// Parses one of the five, and refuses everything else.
141    ///
142    /// The refusal is the point: this is the boundary a region name arrives at from
143    /// a CLI flag or an environment variable, where it is still a string and the
144    /// enum cannot help. A caller who genuinely wants a sixth region reaches for
145    /// [`Region::unlisted`] and says so.
146    fn from_str(name: &str) -> Result<Region, Error> {
147        if let Some(known) = Region::supported(name) {
148            return Ok(known);
149        }
150        let offered = MICROVM_REGIONS
151            .iter()
152            .map(Region::as_str)
153            .collect::<Vec<_>>()
154            .join(", ");
155        Err(Error::invalid_arg(format!(
156            "region {name:?} is not one this client has seen carry MicroVMs ({offered}). \
157             Refused here because the first API call is where the evidence disappears: an \
158             unsupported region answers AccessDeniedException with a null message, which is \
159             indistinguishable from a real IAM denial (docs/PLATFORM.md, 'Calling an unpriced \
160             region returns AccessDeniedException with a null message'). If AWS has since \
161             launched MicroVMs here, use Region::unlisted({name:?}) and add the region to \
162             MICROVM_REGIONS."
163        )))
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::error::ErrorKind;
171
172    /// The five spellings, written out. A generated list would agree with a typo,
173    /// and a typo here is the *extra region* direction of the correctness condition
174    /// — the direction that re-opens the null-message trap.
175    #[test]
176    fn the_five_supported_regions_are_the_measured_ones() {
177        let names: Vec<&str> = MICROVM_REGIONS.iter().map(Region::as_str).collect();
178        assert_eq!(
179            names,
180            [
181                "us-east-1",
182                "us-east-2",
183                "us-west-2",
184                "eu-west-1",
185                "ap-northeast-1"
186            ]
187        );
188    }
189
190    /// TRAP-6, the falsification case. `eu-central-1` is the specific region that
191    /// was on this list and does not carry MicroVMs, so it is the one a regression
192    /// would most plausibly re-add. The message must name the trap — both halves,
193    /// because "AccessDeniedException" alone reads as an IAM problem and it is the
194    /// word *null* that says otherwise.
195    #[test]
196    fn eu_central_one_is_refused_naming_the_null_message_trap() {
197        let err = "eu-central-1"
198            .parse::<Region>()
199            .expect_err("eu-central-1 does not carry MicroVMs");
200        assert_eq!(err.kind(), ErrorKind::InvalidArg);
201        let message = err.to_string();
202        assert!(
203            message.contains("AccessDeniedException"),
204            "must name the exception: {message}"
205        );
206        assert!(
207            message.contains("null"),
208            "must say the message field is null: {message}"
209        );
210        assert!(
211            message.contains("eu-central-1"),
212            "must name the rejected region: {message}"
213        );
214    }
215
216    /// Every supported name parses back to its own variant. The round trip is what
217    /// makes `as_str` and the parse table one table rather than two.
218    #[test]
219    fn each_supported_region_round_trips_through_its_wire_name() {
220        for region in MICROVM_REGIONS {
221            let parsed: Region = region.as_str().parse().expect("supported name parses");
222            assert_eq!(parsed, region);
223            assert!(parsed.is_supported());
224        }
225    }
226
227    /// The escape hatch produces a distinguishable value rather than a silent pass:
228    /// `is_supported` is false, so a caller — or a cost table that has no rates for
229    /// the region — can still tell the difference.
230    #[test]
231    fn the_escape_hatch_is_visible_in_the_value_it_produces() {
232        let region = Region::unlisted("eu-central-1");
233        assert_eq!(region, Region::Unlisted("eu-central-1".to_string()));
234        assert_eq!(region.as_str(), "eu-central-1");
235        assert!(!region.is_supported());
236    }
237
238    /// A supported name handed to the escape hatch comes back as its proper variant.
239    /// Otherwise `unlisted("us-east-1")` would be a second, unequal spelling of a
240    /// region the client fully supports, and every `match` downstream would need to
241    /// handle both.
242    #[test]
243    fn the_escape_hatch_normalises_a_supported_name_to_its_variant() {
244        assert_eq!(Region::unlisted("us-east-1"), Region::UsEast1);
245        assert!(Region::unlisted("ap-northeast-1").is_supported());
246    }
247
248    /// Nothing plausible-looking sneaks through. These are the shapes a typo takes:
249    /// a real AWS region that lacks MicroVMs, a near-miss on a supported name, a
250    /// case change, and the empty string.
251    #[test]
252    fn near_misses_and_other_aws_regions_are_all_refused() {
253        for name in [
254            "eu-central-1",
255            "ap-southeast-2",
256            "sa-east-1",
257            "us-east-3",
258            "us-west-1",
259            "US-EAST-1",
260            "us-east-1 ",
261            "",
262        ] {
263            let err = name
264                .parse::<Region>()
265                .expect_err("only the five measured regions parse");
266            assert_eq!(err.kind(), ErrorKind::InvalidArg, "{name}");
267        }
268    }
269}