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)]
60pub struct EnvReport {
61 pub vars: Vec<EnvVarReport>,
63}
64
65impl EnvReport {
66 #[must_use]
68 pub fn is_empty(&self) -> bool {
69 self.vars.is_empty()
70 }
71
72 #[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 #[must_use]
87 pub fn has_missing(&self) -> bool {
88 self.vars.iter().any(|v| v.status == EnvVarStatus::Missing)
89 }
90}
91
92#[derive(Default)]
94struct Aggregate {
95 required: bool,
97 defaults: BTreeSet<String>,
99}
100
101impl LifecyclePlan {
102 #[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
147fn 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
169fn 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 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 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}