Skip to main content

microvms_core/
preflight.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `preflight`: whether a harness can launch here, checked before it queues work (#223).
3//!
4//! Three checks, in order, each a [`Check`] the CLI's `doctor` renders too (it shares
5//! [`region_check`] and [`credentials_check`] and prints the same lines):
6//!
7//! 1. **region**: the region resolves, from the caller or from `$AWS_REGION` /
8//!    `$AWS_DEFAULT_REGION` ([`Region::from_env`]). Fatal when it does not resolve. Advisory
9//!    when it is [`Region::unlisted`]: AWS adds regions faster than a constant is re-read, and
10//!    a caller who wrote `unlisted` opted in at the call site; the service check below decides.
11//! 2. **credentials**: the default credential chain resolves credentials
12//!    ([`ControlPlane::resolve_credentials`]). No AWS API call; the chain may read an SSO
13//!    cache, run a credential process, or ask the instance metadata service.
14//! 3. **service**: one `ListManagedMicrovmImages` page in that region
15//!    ([`ControlPlane::answers_listing`]): the endpoint resolves, the signature and the IAM
16//!    permission are accepted, and the region runs MicroVMs. An unsupported region answers
17//!    `AccessDeniedException` with a null message (TRAP-6).
18//!
19//! A check whose precondition failed is reported as not run ([`Check::ran`] false), fatal, and
20//! makes no call. [`PreflightReport::ok`] is true exactly when no fatal check failed or was
21//! skipped. The Stateright model in `model/src/preflight.rs` specifies this (BIND-15, BIND-16).
22//!
23//! # Nothing billable, and what is not checked
24//!
25//! The one AWS operation is a free read (`send_with_retry` may repeat it on a throttle); no
26//! call creates, changes, or bills anything. The Python harvester's check that boto3 knows the
27//! `lambda-microvms` service has no counterpart: this client is Rust and speaks the service
28//! model it was built against ([`crate::constants::MODEL_API_VERSION`]), so the listing is the
29//! equivalent evidence that the endpoint accepts that API version. A harness that also calls
30//! the service through boto3 checks its own boto3. Preflight does not check the execution or
31//! build role, the S3 artifact bucket, image quotas, or VPC connectors: each needs a call this
32//! module does not make, and `doctor` reports the infrastructure it can see.
33
34use std::future::Future;
35
36use crate::control::ControlPlane;
37use crate::error::Error;
38use crate::region::{MICROVM_REGIONS, Region};
39
40/// One line of a preflight or `doctor` report.
41#[derive(Clone, Debug, PartialEq)]
42pub struct Check {
43    pub name: &'static str,
44    pub ok: bool,
45    /// Whether a failure decides the outcome. An advisory failure is reported and ignored.
46    pub fatal: bool,
47    /// False when an earlier check's failure kept this one from running.
48    pub ran: bool,
49    pub detail: String,
50    /// What to do about a failure; empty on a pass.
51    pub remedy: String,
52}
53
54impl Check {
55    pub fn pass(name: &'static str, detail: impl Into<String>) -> Self {
56        Self {
57            name,
58            ok: true,
59            fatal: true,
60            ran: true,
61            detail: detail.into(),
62            remedy: String::new(),
63        }
64    }
65
66    pub fn fail(name: &'static str, detail: impl Into<String>, remedy: impl Into<String>) -> Self {
67        Self {
68            name,
69            ok: false,
70            fatal: true,
71            ran: true,
72            detail: detail.into(),
73            remedy: remedy.into(),
74        }
75    }
76
77    /// A check that did not run because `reason`; it fails, fatally, and made no call.
78    pub fn not_run(name: &'static str, reason: impl Into<String>) -> Self {
79        Self {
80            ran: false,
81            ..Self::fail(
82                name,
83                format!("not run: {}", reason.into()),
84                "fix the check above",
85            )
86        }
87    }
88
89    /// A non-fatal finding: reported, but it does not decide the outcome.
90    #[must_use]
91    pub fn advisory(mut self) -> Self {
92        self.fatal = false;
93        self
94    }
95}
96
97/// Whether every fatal check passed.
98pub fn healthy(checks: &[Check]) -> bool {
99    checks
100        .iter()
101        .filter(|check| check.fatal)
102        .all(|check| check.ok)
103}
104
105/// What [`preflight`] found.
106#[derive(Clone, Debug)]
107pub struct PreflightReport {
108    /// The region checked, or `None` when none resolved.
109    pub region: Option<Region>,
110    /// `region`, `credentials`, and `service`, in that order, always all three.
111    pub checks: Vec<Check>,
112}
113
114impl PreflightReport {
115    /// True exactly when no fatal check failed or was skipped: a launch could proceed.
116    pub fn ok(&self) -> bool {
117        healthy(&self.checks)
118    }
119
120    /// The check named `name`, if the report has one.
121    pub fn check(&self, name: &str) -> Option<&Check> {
122        self.checks.iter().find(|check| check.name == name)
123    }
124}
125
126fn known_regions() -> String {
127    MICROVM_REGIONS
128        .iter()
129        .map(Region::as_str)
130        .collect::<Vec<_>>()
131        .join(", ")
132}
133
134/// The region line: pass for a supported region, advisory for an unlisted one, fatal when
135/// none resolved. `doctor` marks the last advisory too, because it must run in a broken
136/// environment and report everything else.
137pub fn region_check(resolved: &Result<Region, Error>) -> Check {
138    match resolved {
139        Ok(region) if region.is_supported() => {
140            Check::pass("region", format!("{region} is a known MicroVMs region"))
141        }
142        Ok(region) => Check::fail(
143            "region",
144            format!("{region} is not in this client's list of MicroVMs regions"),
145            format!(
146                "known: {}; the service check below says whether it runs MicroVMs",
147                known_regions()
148            ),
149        )
150        .advisory(),
151        Err(error) => Check::fail(
152            "region",
153            error.to_string(),
154            format!("known: {}", known_regions()),
155        ),
156    }
157}
158
159/// The credentials line, from a control plane that may not have been built.
160pub async fn credentials_check(plane: &Result<ControlPlane, Error>, region: &Region) -> Check {
161    let failure = match plane {
162        Ok(plane) => plane
163            .resolve_credentials()
164            .await
165            .err()
166            .map(|e| e.to_string()),
167        Err(error) => Some(error.to_string()),
168    };
169    match failure {
170        None => Check::pass(
171            "credentials",
172            format!("the default chain resolved credentials for {region}"),
173        ),
174        Some(detail) => Check::fail(
175            "credentials",
176            detail,
177            "`aws sso login`, or set AWS_PROFILE / AWS_ACCESS_KEY_ID",
178        ),
179    }
180}
181
182/// The service line: one free, read-only listing page.
183pub async fn service_check(plane: &ControlPlane) -> Check {
184    let region = plane.region();
185    match plane.answers_listing().await {
186        Ok(bases) => Check::pass(
187            "service",
188            format!(
189                "ListManagedMicrovmImages answered in {region} ({bases} managed base(s)); API \
190                 version {}",
191                crate::constants::MODEL_API_VERSION
192            ),
193        ),
194        Err(error) => Check::fail(
195            "service",
196            format!("ListManagedMicrovmImages in {region} failed: {error}"),
197            "an AccessDeniedException with no message is also what a region without MicroVMs \
198             answers; otherwise allow lambda-microvms:ListManagedMicrovmImages",
199        ),
200    }
201}
202
203/// Runs the three checks for `region`, or for the region the environment names.
204///
205/// The bindings' `preflight`. See the module docs for what it checks, what it does not, and
206/// why nothing it does bills.
207pub async fn preflight(region: Option<Region>) -> PreflightReport {
208    let resolved = match region {
209        Some(region) => Ok(region),
210        None => Region::from_env(&|name| std::env::var(name).ok()),
211    };
212    preflight_with(resolved, ControlPlane::new).await
213}
214
215/// [`preflight`] over a resolved region and a control-plane constructor, for a caller (and a
216/// test) that supplies the transport. `connect` is not called when no region resolved.
217pub async fn preflight_with<F, Fut>(resolved: Result<Region, Error>, connect: F) -> PreflightReport
218where
219    F: FnOnce(Region) -> Fut,
220    Fut: Future<Output = Result<ControlPlane, Error>>,
221{
222    let region_line = region_check(&resolved);
223    let Ok(region) = resolved else {
224        return PreflightReport {
225            region: None,
226            checks: vec![
227                region_line,
228                Check::not_run("credentials", "no region resolved"),
229                Check::not_run("service", "no region resolved"),
230            ],
231        };
232    };
233    let plane = connect(region.clone()).await;
234    let credentials = credentials_check(&plane, &region).await;
235    let service = match (&plane, credentials.ok) {
236        (Ok(plane), true) => service_check(plane).await,
237        _ => Check::not_run("service", "credentials did not resolve"),
238    };
239    PreflightReport {
240        region: Some(region),
241        checks: vec![region_line, credentials, service],
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use std::sync::{Arc, Mutex};
248
249    use super::*;
250    use crate::control::fake::{Answer, FakeControlPlane, TestClock};
251    use crate::control::transport::{Call, Reply, Transport};
252    use crate::error::ErrorKind;
253
254    /// A recorder whose credential chain fails.
255    struct NoCredentials(Mutex<Vec<String>>);
256
257    impl Transport for NoCredentials {
258        fn send(
259            &self,
260            call: Call,
261        ) -> std::pin::Pin<Box<dyn Future<Output = Result<Reply, Error>> + Send + '_>> {
262            self.0
263                .lock()
264                .expect("not poisoned")
265                .push(call.operation.into());
266            Box::pin(async { panic!("no call may follow a failed credential chain") })
267        }
268
269        fn resolve_credentials(
270            &self,
271        ) -> std::pin::Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
272            Box::pin(async { Err(Error::new(ErrorKind::Credentials, "nothing in the chain")) })
273        }
274    }
275
276    fn plane(transport: Arc<dyn Transport>, region: Region) -> ControlPlane {
277        ControlPlane::with_transport(transport, region, Arc::new(TestClock::new()))
278    }
279
280    /// **BIND-15, the model's table**: `ok` is true exactly when no line blocks, and an
281    /// unlisted region's advisory line does not.
282    #[tokio::test]
283    async fn the_outcome_is_the_models_table() {
284        for (region, listing, ok) in [
285            (Region::UsEast1, Answer::ok(r#"{"items": []}"#), true),
286            (
287                Region::unlisted("ap-south-1"),
288                Answer::ok(r#"{"items": []}"#),
289                true,
290            ),
291            (
292                Region::unlisted("ap-south-1"),
293                Answer::failure(403, "AccessDeniedException"),
294                false,
295            ),
296        ] {
297            let recorder = Arc::new(FakeControlPlane::new());
298            recorder.answer("ListManagedMicrovmImages", listing);
299            let transport = Arc::clone(&recorder) as Arc<dyn Transport>;
300            let report = preflight_with(
301                Ok(region.clone()),
302                |r| async move { Ok(plane(transport, r)) },
303            )
304            .await;
305            assert_eq!(report.ok(), ok, "{region}: {report:#?}");
306            assert_eq!(
307                recorder.operations(),
308                ["ListManagedMicrovmImages"],
309                "BIND-16: one free read"
310            );
311            assert_eq!(report.checks.len(), 3);
312        }
313    }
314
315    /// **BIND-16: no call after the credentials fail**, and the service line says it did not
316    /// run rather than passing or vanishing.
317    ///
318    /// **Falsification** — 2026-09-24. Run `service_check` whatever the credentials line says
319    /// and `NoCredentials::send` panics; restored.
320    #[tokio::test]
321    async fn failed_credentials_stop_the_preflight_before_any_call() {
322        let transport = Arc::new(NoCredentials(Mutex::new(Vec::new())));
323        let shared = Arc::clone(&transport) as Arc<dyn Transport>;
324        let report =
325            preflight_with(Ok(Region::UsEast1), |r| async move { Ok(plane(shared, r)) }).await;
326        assert!(!report.ok());
327        let credentials = report.check("credentials").expect("reported");
328        assert!(!credentials.ok && credentials.ran && credentials.fatal);
329        let service = report.check("service").expect("reported");
330        assert!(!service.ok && !service.ran && service.fatal, "{service:?}");
331        assert!(transport.0.lock().expect("not poisoned").is_empty());
332    }
333
334    /// **BIND-16: no region, no control plane, no call.**
335    #[tokio::test]
336    async fn an_unresolved_region_builds_no_control_plane() {
337        let refused =
338            Region::from_env(&|name| (name == "AWS_REGION").then(|| "eu-central-1".into()));
339        let report = preflight_with(refused, |_| async {
340            panic!("no control plane may be built without a region")
341        })
342        .await;
343        assert!(!report.ok());
344        assert_eq!(report.region, None);
345        assert!(
346            report
347                .check("region")
348                .is_some_and(|check| check.fatal && !check.ok)
349        );
350        for name in ["credentials", "service"] {
351            assert!(report.check(name).is_some_and(|check| !check.ran), "{name}");
352        }
353    }
354
355    /// A control plane that could not be built is a failed credentials line, and the service
356    /// is not asked.
357    #[tokio::test]
358    async fn a_control_plane_that_fails_to_build_is_a_credentials_failure() {
359        let report = preflight_with(Ok(Region::UsEast1), |_| async {
360            Err(Error::new(ErrorKind::Credentials, "no provider"))
361        })
362        .await;
363        assert!(!report.ok());
364        assert!(
365            report
366                .check("credentials")
367                .is_some_and(|check| check.ran && !check.ok)
368        );
369        assert!(report.check("service").is_some_and(|check| !check.ran));
370    }
371
372    /// The region line's three cases, which `doctor` shares.
373    #[test]
374    fn the_region_line_is_pass_advisory_or_fatal() {
375        assert!(region_check(&Ok(Region::UsEast1)).ok);
376        let unlisted = region_check(&Ok(Region::unlisted("ap-south-1")));
377        assert!(!unlisted.ok && !unlisted.fatal);
378        let refused = region_check(&Err(Error::invalid_arg("nope")));
379        assert!(!refused.ok && refused.fatal);
380    }
381}