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}