Skip to main content

microvms_domain/
region.rs

1// SPDX-License-Identifier: Apache-2.0
2//! An AWS region: any well-formed name, plus the regions AWS was measured running MicroVMs
3//! in (TRAP-6).
4//!
5//! # Why any name
6//!
7//! AWS adds MicroVMs regions faster than a client release can follow: it launched in five
8//! regions on 2026-06-22, five more on 2026-08-19 and four more on 2026-10-22. A client that
9//! refuses a region AWS has launched in is wrong for every caller there until its next
10//! release, so since 1.3.0 [`Region`] parses any well-formed region name, as `aws-types`'
11//! `Region` does, and keeps the measured set as data in [`MEASURED_REGIONS`].
12//!
13//! # What the measured set is for
14//!
15//! In a region without MicroVMs, the first control-plane call can answer
16//! `AccessDeniedException` with the message field **null**. A denied implied IAM action,
17//! such as tagging an image on creation, returns the same response (measured 2026-10-17,
18//! us-east-1, API 2025-09-09; docs/PLATFORM.md). Nothing before that call can tell the two
19//! apart: the service model's `endpointPrefix` is `lambda`, so an endpoint resolves for
20//! every region, and a `ListMicrovms` that answers is not evidence either, since regions
21//! AWS lists as planned, such as `ca-central-1`, answered it on 2026-10-24. So the client
22//! reports instead of refusing. When that response arrives for a region outside
23//! [`MEASURED_REGIONS`], the error names the region as a cause to verify alongside the IAM
24//! policy (TRAP-6), and `preflight`'s region line reports the region as unmeasured, as an
25//! advisory.
26//!
27//! [`MEASURED_REGIONS`] is the 14 regions the Pricing API priced MicroVMs in on 2026-10-24,
28//! which are the 14 AWS's launch posts name. No SDK call states the set:
29//! `get_available_endpoints` returns an empty list while `get_available_regions` returns all
30//! 34 Lambda regions.
31//!
32//! # The variants are deprecated
33//!
34//! Until 1.3.0 this enum was closed over the five regions of the first launch, plus
35//! [`Region::Unlisted`] for a name a caller opted into. Its variants still work in 1.x, and a
36//! name outside the five still parses to `Unlisted`, but they're deprecated: the constants
37//! [`Region::US_EAST_1`] and four more replace the five, and [`Region::as_str`] reads any
38//! region. `docs/STABILITY.md` keeps a deprecated item working for at least six calendar
39//! months and two minor releases, so 2.0.0 at the earliest makes `Region` a name with no
40//! variants (#743).
41//!
42//! # What parsing checks
43//!
44//! The shape only. A region name becomes the middle of the endpoint's host name
45//! (`lambda.<region>.amazonaws.com`), so [`FromStr`] accepts lowercase ASCII letters, digits
46//! and hyphens, starting with a letter and ending with a digit, with at least one hyphen and
47//! no two in a row, at most 63 characters. That refuses the typos an environment variable
48//! carries, a case change or a trailing space, and anything that would change the host.
49
50use std::fmt;
51use std::str::FromStr;
52
53use crate::error::Error;
54
55/// An AWS region, by its name.
56///
57/// Built by parsing (`"eu-central-1".parse::<Region>()`), from the environment
58/// ([`Region::from_env`]), or from a constant ([`Region::US_EAST_1`]). Equality is by name,
59/// so a parsed `"us-east-1"` equals the constant. Read a region with [`Region::as_str`]
60/// rather than matching on its variants, which are deprecated (see the module docs).
61#[derive(Clone, Debug, Eq, Hash, PartialEq)]
62pub enum Region {
63    #[deprecated(
64        since = "1.3.0",
65        note = "use `Region::US_EAST_1`; `Region`'s variants go no earlier than 2.0.0 (#743)"
66    )]
67    UsEast1,
68    #[deprecated(
69        since = "1.3.0",
70        note = "use `Region::US_EAST_2`; `Region`'s variants go no earlier than 2.0.0 (#743)"
71    )]
72    UsEast2,
73    #[deprecated(
74        since = "1.3.0",
75        note = "use `Region::US_WEST_2`; `Region`'s variants go no earlier than 2.0.0 (#743)"
76    )]
77    UsWest2,
78    #[deprecated(
79        since = "1.3.0",
80        note = "use `Region::EU_WEST_1`; `Region`'s variants go no earlier than 2.0.0 (#743)"
81    )]
82    EuWest1,
83    #[deprecated(
84        since = "1.3.0",
85        note = "use `Region::AP_NORTHEAST_1`; `Region`'s variants go no earlier than 2.0.0 \
86                (#743)"
87    )]
88    ApNortheast1,
89    /// Any region outside the five above, by name.
90    ///
91    /// Since 1.3.0 every well-formed name outside the five parses to this, whether or not
92    /// AWS was measured running MicroVMs there; [`Region::is_measured`] says which.
93    #[deprecated(
94        since = "1.3.0",
95        note = "parse the name with `name.parse::<Region>()` and read it with `as_str`; \
96                `Region`'s variants go no earlier than 2.0.0 (#743)"
97    )]
98    Unlisted(String),
99}
100
101// The constants are the variants' replacements, so they are the one place that names them.
102#[allow(deprecated)]
103impl Region {
104    pub const US_EAST_1: Region = Region::UsEast1;
105    pub const US_EAST_2: Region = Region::UsEast2;
106    pub const US_WEST_2: Region = Region::UsWest2;
107    pub const EU_WEST_1: Region = Region::EuWest1;
108    pub const AP_NORTHEAST_1: Region = Region::ApNortheast1;
109}
110
111/// The regions AWS was measured running MicroVMs in: the 14 the Pricing API priced on
112/// 2026-10-24, in the order AWS launched them (2026-06-22, 2026-08-19, 2026-10-22).
113///
114/// Names rather than [`Region`] values, because a region outside the five constants holds
115/// its name in a `String`, which a constant can't build; parse a name for its `Region`.
116///
117/// Not model-backed and it cannot be: see the module docs. `constants::as_json` publishes
118/// this set so `tools/check-model-drift.py` can compare it with the list
119/// `tools/check-live-rates.py` keeps, which is the only check available for a value no
120/// service model states.
121pub const MEASURED_REGIONS: [&str; 14] = [
122    "us-east-1",
123    "us-east-2",
124    "us-west-2",
125    "eu-west-1",
126    "ap-northeast-1",
127    "ap-south-1",
128    "ap-southeast-1",
129    "ap-southeast-2",
130    "eu-central-1",
131    "eu-north-1",
132    "ap-east-1",
133    "ap-southeast-5",
134    "ap-southeast-7",
135    "eu-south-2",
136];
137
138/// The five regions of AWS's first MicroVMs launch (2026-06-22), the ones with constants.
139#[deprecated(
140    since = "1.3.0",
141    note = "AWS runs MicroVMs in more regions than these five: use `MEASURED_REGIONS` and \
142            `Region::is_measured` (#743)"
143)]
144pub const MICROVM_REGIONS: [Region; 5] = [
145    Region::US_EAST_1,
146    Region::US_EAST_2,
147    Region::US_WEST_2,
148    Region::EU_WEST_1,
149    Region::AP_NORTHEAST_1,
150];
151
152/// The five named regions, which the parse and the deprecated escape hatch both read.
153const NAMED: [Region; 5] = [
154    Region::US_EAST_1,
155    Region::US_EAST_2,
156    Region::US_WEST_2,
157    Region::EU_WEST_1,
158    Region::AP_NORTHEAST_1,
159];
160
161impl Region {
162    /// The wire spelling, which is also the endpoint's middle segment.
163    #[allow(deprecated)]
164    pub fn as_str(&self) -> &str {
165        match self {
166            Region::UsEast1 => "us-east-1",
167            Region::UsEast2 => "us-east-2",
168            Region::UsWest2 => "us-west-2",
169            Region::EuWest1 => "eu-west-1",
170            Region::ApNortheast1 => "ap-northeast-1",
171            Region::Unlisted(name) => name,
172        }
173    }
174
175    /// Whether AWS was measured running MicroVMs here: membership in [`MEASURED_REGIONS`].
176    ///
177    /// False means unmeasured, not refused. The region still works when AWS runs MicroVMs
178    /// there; what it changes is what a null-message `AccessDeniedException` there most
179    /// likely means.
180    pub fn is_measured(&self) -> bool {
181        MEASURED_REGIONS.contains(&self.as_str())
182    }
183
184    /// Whether this is one of the five named regions.
185    #[deprecated(
186        since = "1.3.0",
187        note = "AWS runs MicroVMs in more regions than the five named ones: use \
188                `is_measured` (#743)"
189    )]
190    pub fn is_supported(&self) -> bool {
191        NAMED.contains(self)
192    }
193
194    /// A region by name, without the shape check.
195    ///
196    /// Every well-formed name parses since 1.3.0, so this is [`str::parse`] without its
197    /// refusal of a malformed name. A named region's name comes back as its constant, so
198    /// `unlisted("us-east-1") == Region::US_EAST_1`.
199    #[deprecated(
200        since = "1.3.0",
201        note = "every well-formed region name parses: use `name.parse::<Region>()`"
202    )]
203    pub fn unlisted(name: impl Into<String>) -> Region {
204        Region::named(name.into())
205    }
206
207    /// A region name this client wrote into a record, taken as written.
208    ///
209    /// A name record's region came from a [`Region`], so it needs no second check, and a
210    /// record a 1.2 client wrote through `unlisted` still reads back.
211    pub(crate) fn as_written(name: &str) -> Region {
212        Region::named(name.to_string())
213    }
214
215    /// The constant for a named region, or `Unlisted` for any other name. The single place
216    /// a name becomes a value, so a parsed region and a constant are one spelling.
217    #[allow(deprecated)]
218    fn named(name: String) -> Region {
219        NAMED
220            .into_iter()
221            .find(|region| region.as_str() == name)
222            .unwrap_or(Region::Unlisted(name))
223    }
224
225    /// The region the environment names: `$AWS_REGION`, then `$AWS_DEFAULT_REGION`, then
226    /// `us-east-1`.
227    ///
228    /// Parsed rather than accepted, because an environment variable is where a stray space
229    /// or a case change arrives. `env` is a lookup rather than `std::env::var` so a caller
230    /// (and a test) decides where the variables come from. The CLI's region resolution and
231    /// `microvms_core::preflight::preflight` both read it here.
232    pub fn from_env(env: &dyn Fn(&str) -> Option<String>) -> Result<Region, Error> {
233        match env("AWS_REGION").or_else(|| env("AWS_DEFAULT_REGION")) {
234            Some(name) => name.parse::<Region>().map_err(|error| {
235                Error::invalid_arg(format!(
236                    "{error} It arrived from $AWS_REGION or $AWS_DEFAULT_REGION."
237                ))
238            }),
239            None => Ok(Region::US_EAST_1),
240        }
241    }
242}
243
244/// Whether `name` has the shape of an AWS region name; see the module docs.
245fn is_region_shaped(name: &str) -> bool {
246    let bytes = name.as_bytes();
247    let (Some(first), Some(last)) = (bytes.first(), bytes.last()) else {
248        return false;
249    };
250    bytes.len() <= 63
251        && first.is_ascii_lowercase()
252        && last.is_ascii_digit()
253        && bytes.contains(&b'-')
254        && !name.contains("--")
255        && bytes
256            .iter()
257            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
258}
259
260impl fmt::Display for Region {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        f.write_str(self.as_str())
263    }
264}
265
266impl FromStr for Region {
267    type Err = Error;
268
269    /// Parses any well-formed region name, and refuses a malformed one.
270    ///
271    /// A named region's name comes back equal to its constant.
272    fn from_str(name: &str) -> Result<Region, Error> {
273        if is_region_shaped(name) {
274            return Ok(Region::named(name.to_string()));
275        }
276        Err(Error::invalid_arg(format!(
277            "{name:?} is not a region name. A region name is lowercase letters, digits and \
278             hyphens, starts with a letter and ends with a digit, such as us-east-1."
279        )))
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::error::ErrorKind;
287
288    /// The 14 spellings, written out. A generated list would agree with a typo, and a typo
289    /// here marks a region measured that never was.
290    #[test]
291    fn the_measured_regions_are_the_fourteen_aws_prices() {
292        assert_eq!(
293            MEASURED_REGIONS,
294            [
295                "us-east-1",
296                "us-east-2",
297                "us-west-2",
298                "eu-west-1",
299                "ap-northeast-1",
300                "ap-south-1",
301                "ap-southeast-1",
302                "ap-southeast-2",
303                "eu-central-1",
304                "eu-north-1",
305                "ap-east-1",
306                "ap-southeast-5",
307                "ap-southeast-7",
308                "eu-south-2",
309            ]
310        );
311    }
312
313    /// Every measured name parses back to a region that names it, and the five named ones
314    /// parse to their constants, so a parsed region and a constant are one spelling, not two.
315    #[test]
316    fn each_measured_region_round_trips_through_its_wire_name() {
317        for name in MEASURED_REGIONS {
318            let parsed: Region = name.parse().expect("a measured name parses");
319            assert_eq!(parsed.as_str(), name);
320            assert!(parsed.is_measured());
321        }
322        for region in NAMED {
323            assert_eq!(region.as_str().parse::<Region>().unwrap(), region);
324        }
325    }
326
327    /// A region AWS has not launched MicroVMs in still parses, and says it is unmeasured.
328    /// `us-west-1` answered `ListMicrovms` with the null-message denial on 2026-10-24.
329    #[test]
330    fn an_unmeasured_region_parses_and_reports_it() {
331        for name in ["us-west-1", "ca-central-1", "us-gov-west-1", "us-east-3"] {
332            let region: Region = name.parse().expect("a well-formed name parses");
333            assert_eq!(region.as_str(), name);
334            assert!(!region.is_measured(), "{name}");
335        }
336    }
337
338    /// The shapes a typo or an injected host takes are refused before they reach a URL: a
339    /// case change, a stray space, the empty string, a name with no hyphen or no trailing
340    /// digit, a doubled hyphen, and characters that would change the host.
341    #[test]
342    fn malformed_names_are_refused_naming_the_shape() {
343        for name in [
344            "US-EAST-1",
345            "us-east-1 ",
346            " us-east-1",
347            "",
348            "useast1",
349            "us-east",
350            "1us-east-1",
351            "us--east-1",
352            "-us-east-1",
353            "us-east-1.example.com",
354            "evil.com/us-east-1",
355            "us_east_1",
356        ] {
357            let error = name
358                .parse::<Region>()
359                .expect_err("a malformed name is refused");
360            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{name:?}");
361            assert!(
362                error.to_string().contains("not a region name"),
363                "{name:?}: {error}"
364            );
365        }
366        let too_long = format!("{}-1", "a".repeat(62));
367        assert!(too_long.parse::<Region>().is_err());
368    }
369
370    /// The environment's region is parsed with the same check, and the error says where the
371    /// name came from.
372    #[test]
373    fn the_environment_region_is_parsed_and_defaults_to_us_east_1() {
374        let none = |_: &str| None;
375        assert_eq!(Region::from_env(&none).unwrap(), Region::US_EAST_1);
376
377        let default_only =
378            |key: &str| (key == "AWS_DEFAULT_REGION").then(|| "eu-central-1".to_string());
379        assert_eq!(
380            Region::from_env(&default_only).unwrap().as_str(),
381            "eu-central-1"
382        );
383
384        let both = |key: &str| match key {
385            "AWS_REGION" => Some("us-west-1".to_string()),
386            _ => Some("eu-central-1".to_string()),
387        };
388        assert_eq!(Region::from_env(&both).unwrap().as_str(), "us-west-1");
389
390        let typo = |key: &str| (key == "AWS_REGION").then(|| "us-east-1 ".to_string());
391        let error = Region::from_env(&typo).expect_err("a stray space is refused");
392        assert!(error.to_string().contains("$AWS_REGION"), "{error}");
393    }
394
395    /// The deprecated 1.x surface keeps its 1.2 behavior: the escape hatch normalizes a named
396    /// region to its constant, a name beyond the five is `Unlisted`, the old list is the five,
397    /// and `is_supported` is true for the five alone.
398    #[test]
399    #[allow(deprecated)]
400    fn the_deprecated_surface_keeps_its_one_two_behavior() {
401        assert_eq!(Region::unlisted("us-east-1"), Region::US_EAST_1);
402        assert_eq!(Region::US_EAST_1, Region::UsEast1);
403        let parsed: Region = "eu-central-1".parse().unwrap();
404        assert_eq!(parsed, Region::Unlisted("eu-central-1".to_string()));
405        assert_eq!(Region::unlisted("eu-central-1"), parsed);
406        assert_eq!(MICROVM_REGIONS, NAMED);
407        assert!(Region::AP_NORTHEAST_1.is_supported());
408        assert!(!parsed.is_supported());
409        assert!(parsed.is_measured());
410    }
411}