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
53/// values and command arguments, with one entry per distinct variable,
54/// sorted by name.
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct EnvReport {
57    /// One entry per distinct referenced variable, sorted by name.
58    pub vars: Vec<EnvVarReport>,
59}
60
61impl EnvReport {
62    /// Returns `true` when no `${env.*}` reference was found.
63    #[must_use]
64    pub fn is_empty(&self) -> bool {
65        self.vars.is_empty()
66    }
67
68    /// Names of every variable whose status is [`EnvVarStatus::Missing`].
69    ///
70    /// The result is sorted and free of duplicates because the report holds
71    /// at most one entry per name, kept in name order.
72    #[must_use]
73    pub fn missing(&self) -> Vec<String> {
74        self.vars
75            .iter()
76            .filter(|v| v.status == EnvVarStatus::Missing)
77            .map(|v| v.name.clone())
78            .collect()
79    }
80
81    /// Returns `true` when at least one referenced variable is missing.
82    #[must_use]
83    pub fn has_missing(&self) -> bool {
84        self.vars.iter().any(|v| v.status == EnvVarStatus::Missing)
85    }
86}
87
88/// Aggregated facts about every reference to one variable name.
89#[derive(Default)]
90struct Aggregate {
91    /// `true` when at least one reference omits a default fallback.
92    required: bool,
93    /// Distinct default fallbacks seen across references, sorted.
94    defaults: BTreeSet<String>,
95}
96
97impl LifecyclePlan {
98    /// Classify every `${env.*}` reference in this plan against the ambient
99    /// process environment plus `extra_env` (which takes precedence).
100    ///
101    /// Only environment values and command arguments are scanned, matching
102    /// the sites resolved at start time. The resolved-or-missing decision
103    /// delegates to the same [`Interpolator`] the runtime uses, so an empty
104    /// value counts as unset and this report mirrors a real preflight.
105    #[must_use]
106    pub fn env_report(&self, extra_env: &HashMap<String, String>) -> EnvReport {
107        let ctx = InterpolationContext::from_env()
108            .with_env(extra_env.iter().map(|(k, v)| (k.clone(), v.clone())));
109        let interpolator = Interpolator::new(&ctx);
110
111        let mut by_name: BTreeMap<String, Aggregate> = BTreeMap::new();
112        for node in self.nodes() {
113            for value in node.spec.env.values() {
114                collect_env_refs(&interpolator, value, &mut by_name);
115            }
116            if let Some(args) = &node.spec.command {
117                for arg in args {
118                    collect_env_refs(&interpolator, arg, &mut by_name);
119                }
120            }
121        }
122
123        let vars = by_name
124            .into_iter()
125            .map(|(name, agg)| {
126                let status = classify(&interpolator, &name, &agg, extra_env);
127                EnvVarReport { name, status }
128            })
129            .collect();
130
131        EnvReport { vars }
132    }
133}
134
135/// Scan `value` for `${env.*}` references and fold them into `by_name`.
136fn collect_env_refs(
137    interpolator: &Interpolator<'_>,
138    value: &str,
139    by_name: &mut BTreeMap<String, Aggregate>,
140) {
141    let Ok(refs) = interpolator.scan(value) else {
142        return;
143    };
144    for reference in refs {
145        if let Reference::Env { name, default } = reference {
146            let agg = by_name.entry(name).or_default();
147            match default {
148                None => agg.required = true,
149                Some(d) => {
150                    agg.defaults.insert(d);
151                }
152            }
153        }
154    }
155}
156
157/// Decide the status of one variable, deferring the resolved-or-not call to
158/// the interpolator so it never diverges from runtime resolution.
159fn classify(
160    interpolator: &Interpolator<'_>,
161    name: &str,
162    agg: &Aggregate,
163    extra_env: &HashMap<String, String>,
164) -> EnvVarStatus {
165    let probe = format!("${{env.{name}}}");
166    if interpolator.resolve(&probe).is_ok() {
167        // `extra_env` overrides the ambient environment, so a non-empty
168        // entry there is the value actually used; otherwise resolution can
169        // only have come from the process environment.
170        let source = if extra_env.get(name).is_some_and(|v| !v.is_empty()) {
171            EnvSource::EnvFile
172        } else {
173            EnvSource::Process
174        };
175        EnvVarStatus::Resolved(source)
176    } else if agg.required {
177        EnvVarStatus::Missing
178    } else {
179        EnvVarStatus::Defaulted {
180            defaults: agg.defaults.iter().cloned().collect(),
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use lightshuttle_manifest::Manifest;
188
189    use super::*;
190
191    fn plan_with_env(token: &str, level: &str) -> LifecyclePlan {
192        let yaml = format!(
193            "project:\n  name: app\nresources:\n  app:\n    container:\n      image: myapp:latest\n      env:\n        API_TOKEN: \"{token}\"\n        LOG_LEVEL: \"{level}\"\n"
194        );
195        let manifest = Manifest::parse(&yaml).expect("valid manifest");
196        LifecyclePlan::from_manifest(&manifest).expect("valid plan")
197    }
198
199    fn plan_with_raw_env(env_block: &str) -> LifecyclePlan {
200        let yaml = format!(
201            "project:\n  name: app\nresources:\n  app:\n    container:\n      image: myapp:latest\n      env:\n{env_block}"
202        );
203        let manifest = Manifest::parse(&yaml).expect("valid manifest");
204        LifecyclePlan::from_manifest(&manifest).expect("valid plan")
205    }
206
207    fn status_of<'a>(report: &'a EnvReport, name: &str) -> &'a EnvVarStatus {
208        &report
209            .vars
210            .iter()
211            .find(|v| v.name == name)
212            .expect("variable present")
213            .status
214    }
215
216    #[test]
217    fn env_file_value_resolves_with_env_file_source() {
218        let plan = plan_with_env("${env.API_TOKEN}", "${env.LOG_LEVEL:-info}");
219        let mut env = HashMap::new();
220        env.insert("API_TOKEN".to_owned(), "secret".to_owned());
221        let report = plan.env_report(&env);
222        assert_eq!(
223            status_of(&report, "API_TOKEN"),
224            &EnvVarStatus::Resolved(EnvSource::EnvFile)
225        );
226    }
227
228    #[test]
229    fn unset_with_default_is_defaulted() {
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, "LOG_LEVEL"),
236            &EnvVarStatus::Defaulted {
237                defaults: vec!["info".to_owned()]
238            }
239        );
240    }
241
242    #[test]
243    fn empty_env_file_value_counts_as_missing() {
244        let plan = plan_with_env("${env.API_TOKEN}", "${env.LOG_LEVEL:-info}");
245        let mut env = HashMap::new();
246        // Empty value overrides the ambient environment and is treated as
247        // unset by the interpolator, so the required var stays missing.
248        env.insert("API_TOKEN".to_owned(), String::new());
249        let report = plan.env_report(&env);
250        assert_eq!(status_of(&report, "API_TOKEN"), &EnvVarStatus::Missing);
251        assert!(report.has_missing());
252        assert_eq!(report.missing(), vec!["API_TOKEN".to_owned()]);
253    }
254
255    #[test]
256    fn divergent_defaults_are_all_reported_sorted() {
257        let plan = plan_with_raw_env(
258            "        LOG_A: \"${env.LOG_LEVEL:-info}\"\n        LOG_B: \"${env.LOG_LEVEL:-debug}\"\n",
259        );
260        let report = plan.env_report(&HashMap::new());
261        assert_eq!(
262            status_of(&report, "LOG_LEVEL"),
263            &EnvVarStatus::Defaulted {
264                defaults: vec!["debug".to_owned(), "info".to_owned()]
265            }
266        );
267    }
268}