lightshuttle_runtime/lifecycle/
env_report.rs1use std::collections::{BTreeMap, BTreeSet, HashMap};
14
15use lightshuttle_manifest::{InterpolationContext, Interpolator, Reference};
16
17use crate::lifecycle::plan::LifecyclePlan;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum EnvSource {
22 EnvFile,
25 Process,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum EnvVarStatus {
32 Resolved(EnvSource),
34 Defaulted {
36 defaults: Vec<String>,
38 },
39 Missing,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct EnvVarReport {
46 pub name: String,
48 pub status: EnvVarStatus,
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct EnvReport {
57 pub vars: Vec<EnvVarReport>,
59}
60
61impl EnvReport {
62 #[must_use]
64 pub fn is_empty(&self) -> bool {
65 self.vars.is_empty()
66 }
67
68 #[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 #[must_use]
83 pub fn has_missing(&self) -> bool {
84 self.vars.iter().any(|v| v.status == EnvVarStatus::Missing)
85 }
86}
87
88#[derive(Default)]
90struct Aggregate {
91 required: bool,
93 defaults: BTreeSet<String>,
95}
96
97impl LifecyclePlan {
98 #[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
135fn 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
157fn 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 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 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}