Skip to main content

lightshuttle_runtime/lifecycle/
env_report.rs

1//! Classification of `${env.*}` references found in a [`LifecyclePlan`].
2//!
3//! Both `lightshuttle up` (its fail-fast preflight,
4//! [`LifecycleManager::check_required_env`]) and `lightshuttle secrets
5//! check` consume the report produced here, so the diagnostic command
6//! predicts what the runtime will do, exactly. Only environment values and
7//! command arguments are scanned, matching the sites the runtime actually
8//! interpolates; a reference in an image tag or working directory is never
9//! resolved and therefore never reported.
10//!
11//! [`LifecycleManager::check_required_env`]: crate::LifecycleManager::check_required_env
12
13use std::collections::{BTreeMap, BTreeSet, HashMap};
14
15use lightshuttle_manifest::{InterpolationContext, Interpolator, Reference};
16
17use crate::lifecycle::plan::LifecyclePlan;
18
19/// Where a resolved variable's effective value comes from.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum EnvSource {
22    /// Supplied by the loaded `.env` file, which takes precedence over the
23    /// ambient process environment.
24    EnvFile,
25    /// Inherited from the ambient process environment.
26    Process,
27}
28
29/// Resolution status of a single referenced environment variable.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum EnvVarStatus {
32    /// Set to a non-empty value, resolved from the carried source.
33    Resolved(EnvSource),
34    /// Unset, but every reference supplies a default fallback.
35    Defaulted {
36        /// Distinct default fallbacks declared across references, sorted.
37        defaults: Vec<String>,
38    },
39    /// Unset (or empty) and at least one reference has no default.
40    Missing,
41}
42
43/// One referenced variable together with its resolution status.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct EnvVarReport {
46    /// Variable name as written inside `${env.NAME}`.
47    pub name: String,
48    /// Whether it resolves, falls back to a default, or is missing.
49    pub status: EnvVarStatus,
50}
51
52/// Report over every `${env.*}` reference found in a plan's environment values
53/// and command arguments.
54///
55/// Built by [`LifecyclePlan::env_report`] and consumed by
56/// [`crate::LifecycleManager::check_required_env`] (fail-fast preflight) and
57/// the `lightshuttle secrets check` subcommand (interactive diagnostic). There
58/// is one entry per distinct variable name, sorted alphabetically.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct EnvReport {
61    /// One entry per distinct referenced variable, sorted alphabetically by name.
62    pub vars: Vec<EnvVarReport>,
63}
64
65impl EnvReport {
66    /// Returns `true` when no `${env.*}` reference was found.
67    #[must_use]
68    pub fn is_empty(&self) -> bool {
69        self.vars.is_empty()
70    }
71
72    /// Names of every variable whose status is [`EnvVarStatus::Missing`].
73    ///
74    /// The result is sorted and free of duplicates because the report holds
75    /// at most one entry per name, kept in name order.
76    #[must_use]
77    pub fn missing(&self) -> Vec<String> {
78        self.vars
79            .iter()
80            .filter(|v| v.status == EnvVarStatus::Missing)
81            .map(|v| v.name.clone())
82            .collect()
83    }
84
85    /// Returns `true` when at least one referenced variable is missing.
86    #[must_use]
87    pub fn has_missing(&self) -> bool {
88        self.vars.iter().any(|v| v.status == EnvVarStatus::Missing)
89    }
90}
91
92/// Aggregated facts about every reference to one variable name.
93#[derive(Default)]
94struct Aggregate {
95    /// `true` when at least one reference omits a default fallback.
96    required: bool,
97    /// Distinct default fallbacks seen across references, sorted.
98    defaults: BTreeSet<String>,
99}
100
101impl LifecyclePlan {
102    /// Classify every `${env.*}` reference in this plan against the ambient
103    /// process environment plus `extra_env` (which takes precedence).
104    ///
105    /// Only environment values and command arguments are scanned because those
106    /// are the only sites the runtime interpolates at start time. References in
107    /// image tags or working directories are intentionally excluded.
108    ///
109    /// The resolution logic delegates to the same [`Interpolator`] the runtime
110    /// uses, so an empty value counts as unset and the report mirrors what a
111    /// real `start_all` call would do. This makes the report suitable as both a
112    /// preflight check (called by [`crate::LifecycleManager::check_required_env`])
113    /// and a diagnostic tool (called by `lightshuttle secrets check`).
114    ///
115    /// The `extra_env` argument carries the contents of the loaded `.env` file.
116    /// Entries in `extra_env` with an empty string value are treated as unset.
117    #[must_use]
118    pub fn env_report(&self, extra_env: &HashMap<String, String>) -> EnvReport {
119        let ctx = InterpolationContext::from_env()
120            .with_env(extra_env.iter().map(|(k, v)| (k.clone(), v.clone())));
121        let interpolator = Interpolator::new(&ctx);
122
123        let mut by_name: BTreeMap<String, Aggregate> = BTreeMap::new();
124        for node in self.nodes() {
125            for value in node.spec.env.values() {
126                collect_env_refs(&interpolator, value, &mut by_name);
127            }
128            if let Some(args) = &node.spec.command {
129                for arg in args {
130                    collect_env_refs(&interpolator, arg, &mut by_name);
131                }
132            }
133        }
134
135        let vars = by_name
136            .into_iter()
137            .map(|(name, agg)| {
138                let status = classify(&interpolator, &name, &agg, extra_env);
139                EnvVarReport { name, status }
140            })
141            .collect();
142
143        EnvReport { vars }
144    }
145}
146
147/// Scan `value` for `${env.*}` references and fold them into `by_name`.
148fn collect_env_refs(
149    interpolator: &Interpolator<'_>,
150    value: &str,
151    by_name: &mut BTreeMap<String, Aggregate>,
152) {
153    let Ok(refs) = interpolator.scan(value) else {
154        return;
155    };
156    for reference in refs {
157        if let Reference::Env { name, default } = reference {
158            let agg = by_name.entry(name).or_default();
159            match default {
160                None => agg.required = true,
161                Some(d) => {
162                    agg.defaults.insert(d);
163                }
164            }
165        }
166    }
167}
168
169/// Decide the status of one variable, deferring the resolved-or-not call to
170/// the interpolator so it never diverges from runtime resolution.
171fn classify(
172    interpolator: &Interpolator<'_>,
173    name: &str,
174    agg: &Aggregate,
175    extra_env: &HashMap<String, String>,
176) -> EnvVarStatus {
177    let probe = format!("${{env.{name}}}");
178    if interpolator.resolve(&probe).is_ok() {
179        // `extra_env` overrides the ambient environment, so a non-empty
180        // entry there is the value actually used; otherwise resolution can
181        // only have come from the process environment.
182        let source = if extra_env.get(name).is_some_and(|v| !v.is_empty()) {
183            EnvSource::EnvFile
184        } else {
185            EnvSource::Process
186        };
187        EnvVarStatus::Resolved(source)
188    } else if agg.required {
189        EnvVarStatus::Missing
190    } else {
191        EnvVarStatus::Defaulted {
192            defaults: agg.defaults.iter().cloned().collect(),
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use lightshuttle_manifest::Manifest;
200
201    use super::*;
202
203    fn plan_with_env(token: &str, level: &str) -> LifecyclePlan {
204        let yaml = format!(
205            "project:\n  name: app\nresources:\n  app:\n    container:\n      image: myapp:latest\n      env:\n        API_TOKEN: \"{token}\"\n        LOG_LEVEL: \"{level}\"\n"
206        );
207        let manifest = Manifest::parse(&yaml).expect("valid manifest");
208        LifecyclePlan::from_manifest(&manifest).expect("valid plan")
209    }
210
211    fn plan_with_raw_env(env_block: &str) -> LifecyclePlan {
212        let yaml = format!(
213            "project:\n  name: app\nresources:\n  app:\n    container:\n      image: myapp:latest\n      env:\n{env_block}"
214        );
215        let manifest = Manifest::parse(&yaml).expect("valid manifest");
216        LifecyclePlan::from_manifest(&manifest).expect("valid plan")
217    }
218
219    fn status_of<'a>(report: &'a EnvReport, name: &str) -> &'a EnvVarStatus {
220        &report
221            .vars
222            .iter()
223            .find(|v| v.name == name)
224            .expect("variable present")
225            .status
226    }
227
228    #[test]
229    fn env_file_value_resolves_with_env_file_source() {
230        let plan = plan_with_env("${env.API_TOKEN}", "${env.LOG_LEVEL:-info}");
231        let mut env = HashMap::new();
232        env.insert("API_TOKEN".to_owned(), "secret".to_owned());
233        let report = plan.env_report(&env);
234        assert_eq!(
235            status_of(&report, "API_TOKEN"),
236            &EnvVarStatus::Resolved(EnvSource::EnvFile)
237        );
238    }
239
240    #[test]
241    fn unset_with_default_is_defaulted() {
242        let plan = plan_with_env("${env.API_TOKEN}", "${env.LOG_LEVEL:-info}");
243        let mut env = HashMap::new();
244        env.insert("API_TOKEN".to_owned(), "secret".to_owned());
245        let report = plan.env_report(&env);
246        assert_eq!(
247            status_of(&report, "LOG_LEVEL"),
248            &EnvVarStatus::Defaulted {
249                defaults: vec!["info".to_owned()]
250            }
251        );
252    }
253
254    #[test]
255    fn empty_env_file_value_counts_as_missing() {
256        let plan = plan_with_env("${env.API_TOKEN}", "${env.LOG_LEVEL:-info}");
257        let mut env = HashMap::new();
258        // Empty value overrides the ambient environment and is treated as
259        // unset by the interpolator, so the required var stays missing.
260        env.insert("API_TOKEN".to_owned(), String::new());
261        let report = plan.env_report(&env);
262        assert_eq!(status_of(&report, "API_TOKEN"), &EnvVarStatus::Missing);
263        assert!(report.has_missing());
264        assert_eq!(report.missing(), vec!["API_TOKEN".to_owned()]);
265    }
266
267    #[test]
268    fn divergent_defaults_are_all_reported_sorted() {
269        let plan = plan_with_raw_env(
270            "        LOG_A: \"${env.LOG_LEVEL:-info}\"\n        LOG_B: \"${env.LOG_LEVEL:-debug}\"\n",
271        );
272        let report = plan.env_report(&HashMap::new());
273        assert_eq!(
274            status_of(&report, "LOG_LEVEL"),
275            &EnvVarStatus::Defaulted {
276                defaults: vec!["debug".to_owned(), "info".to_owned()]
277            }
278        );
279    }
280}