1use std::collections::{BTreeMap, BTreeSet};
13
14use serde_yaml_ng::{Mapping, Value};
15
16use crate::error::{Error, Result};
17use crate::spec::{ComposeFile, DomainSpec};
18
19pub fn env_secret_key(name: &str) -> String {
22 format!("env.{name}")
23}
24
25fn whole_ref(s: &str) -> Option<&str> {
27 let name = s
28 .strip_prefix("${")
29 .and_then(|r| r.strip_suffix('}'))
30 .or_else(|| s.strip_prefix('$'))?;
31 let ok = !name.is_empty()
32 && !name.starts_with(|c: char| c.is_ascii_digit())
33 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
34 ok.then_some(name)
35}
36
37fn env_blocks(v: &mut Value) -> Vec<&mut Value> {
39 let mut out = Vec::new();
40 let Some(Value::Mapping(services)) = v.get_mut("services") else {
41 return out;
42 };
43 for (_, svc) in services.iter_mut() {
44 let Value::Mapping(svc) = svc else { continue };
45 for (k, e) in svc.iter_mut() {
46 match (k.as_str(), e) {
47 (Some("environment"), e) => out.push(e),
48 (Some("exec"), Value::Mapping(exec)) => {
49 if let Some(e) = exec.get_mut("env") {
50 out.push(e);
51 }
52 }
53 _ => {}
54 }
55 }
56 }
57 out
58}
59
60fn walk_strings(v: &Value, f: &mut dyn FnMut(&str)) {
61 match v {
62 Value::String(s) => f(s),
63 Value::Sequence(seq) => seq.iter().for_each(|x| walk_strings(x, f)),
64 Value::Mapping(m) => {
65 for (k, x) in m {
66 walk_strings(k, f);
67 walk_strings(x, f);
68 }
69 }
70 Value::Tagged(t) => walk_strings(&t.value, f),
71 _ => {}
72 }
73}
74
75pub fn referenced_vars(v: &Value) -> BTreeSet<String> {
78 let mut out = BTreeSet::new();
79 walk_strings(v, &mut |s| out.extend(crate::interp::references(s)));
80 let mut v = v.clone();
81 for e in env_blocks(&mut v) {
82 if let Value::Sequence(items) = e {
83 out.extend(
84 items
85 .iter()
86 .filter_map(Value::as_str)
87 .filter(|s| !s.contains('='))
88 .map(String::from),
89 );
90 }
91 }
92 out
93}
94
95pub fn deliver_secret_vars(
102 v: &mut Value,
103 secret_vars: &BTreeMap<String, String>,
104 bare: &dyn Fn(&str) -> Option<String>,
105) -> Result<bool> {
106 if secret_vars.is_empty() {
107 return Ok(false);
108 }
109 let mut used: BTreeSet<String> = BTreeSet::new();
110 for e in env_blocks(v) {
111 if let Value::Sequence(items) = e {
113 let hit = items.iter().filter_map(Value::as_str).any(|s| {
114 let val = s.split_once('=').map_or(s, |(_, v)| v);
115 let var = if s.contains('=') {
116 whole_ref(val)
117 } else {
118 Some(s)
119 };
120 var.is_some_and(|x| secret_vars.contains_key(x))
121 });
122 if !hit {
123 continue;
124 }
125 let mut m = Mapping::new();
126 for s in items.iter().filter_map(Value::as_str) {
127 match s.split_once('=') {
128 Some((k, val)) => {
129 m.insert(k.into(), val.into());
130 }
131 None if secret_vars.contains_key(s) => {
132 m.insert(s.into(), format!("${{{s}}}").into());
133 }
134 None => {
135 if let Some(val) = bare(s) {
136 m.insert(s.into(), val.into());
137 }
138 }
139 }
140 }
141 *e = Value::Mapping(m);
142 }
143 let Value::Mapping(m) = e else { continue };
144 for (_, val) in m.iter_mut() {
145 let Some(var) = val.as_str().and_then(whole_ref) else {
146 continue;
147 };
148 let Some(name) = secret_vars.get(var) else {
149 continue;
150 };
151 let key = env_secret_key(name);
152 let mut sec = Mapping::new();
153 sec.insert("secret".into(), key.as_str().into());
154 *val = Value::Mapping(sec);
155 used.insert(name.clone());
156 }
157 }
158 let mut misused: Option<String> = None;
160 walk_strings(v, &mut |s| {
161 if misused.is_none() {
162 misused = crate::interp::references(s)
163 .into_iter()
164 .find(|r| secret_vars.contains_key(r));
165 }
166 });
167 if let Some(var) = misused {
168 return Err(Error::invalid(format!(
169 "variable {var} is the secret ${{{{secret.{}}}}}: a secret is only delivered as the whole value of a service's environment variable (KEY: ${{{var}}}), never written into the file",
170 secret_vars[&var]
171 )));
172 }
173 if used.is_empty() {
174 return Ok(false);
175 }
176 let Value::Mapping(top) = v else {
177 return Ok(true);
178 };
179 let secrets = top
180 .entry("secrets".into())
181 .or_insert_with(|| Value::Mapping(Mapping::new()));
182 if secrets.is_null() {
183 *secrets = Value::Mapping(Mapping::new());
184 }
185 let Value::Mapping(secrets) = secrets else {
186 return Err(Error::invalid("top-level secrets: expected a mapping"));
187 };
188 for name in used {
189 let key = env_secret_key(&name);
190 if secrets.contains_key(key.as_str()) {
191 return Err(Error::invalid(format!(
192 "top-level secret {key:?} is the stack environment's; name yours otherwise"
193 )));
194 }
195 let mut d = Mapping::new();
196 d.insert("external".into(), true.into());
197 d.insert("name".into(), name.as_str().into());
198 secrets.insert(key.into(), Value::Mapping(d));
199 }
200 Ok(true)
201}
202
203fn host_of(d: &DomainSpec) -> String {
205 d.host.trim().to_ascii_lowercase()
206}
207
208pub fn merge_domains(
213 file: &mut ComposeFile,
214 managed: &BTreeMap<String, Vec<DomainSpec>>,
215) -> Result<()> {
216 let in_file: BTreeMap<String, String> = file
217 .services
218 .iter()
219 .flat_map(|(svc, s)| s.domains.iter().map(move |d| (host_of(d), svc.clone())))
220 .collect();
221 let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
222 for (svc, domains) in managed {
223 let Some(spec) = file.services.get_mut(svc) else {
224 continue;
225 };
226 for d in domains {
227 let host = host_of(d);
228 if host != "auto" {
229 if let Some(owner) = in_file.get(&host) {
230 return Err(Error::invalid(format!(
231 "domain {host} is in the compose file (service {owner}) and in the stack's domains (service {svc}); keep one"
232 )));
233 }
234 }
235 let path = d.path.clone().unwrap_or_else(|| "/".into());
236 if !seen.insert((format!("{svc}\0{host}"), path.clone()))
237 || (host != "auto" && !seen.insert((host.clone(), path.clone())))
238 {
239 return Err(Error::invalid(format!(
240 "domain {host}{} is given twice in the stack's domains",
241 if path == "/" { "" } else { path.as_str() }
242 )));
243 }
244 spec.domains.push(d.clone());
245 }
246 }
247 Ok(())
248}
249
250pub fn file_domains(
253 file: &ComposeFile,
254 merged: &BTreeMap<String, Vec<DomainSpec>>,
255) -> BTreeMap<String, Vec<DomainSpec>> {
256 file.services
257 .iter()
258 .map(|(svc, s)| {
259 let mut ds = s.domains.clone();
260 for m in merged.get(svc).into_iter().flatten() {
261 if let Some(i) = ds.iter().rposition(|d| d == m) {
262 ds.remove(i);
263 }
264 }
265 (svc.clone(), ds)
266 })
267 .collect()
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn yaml(s: &str) -> Value {
275 serde_yaml_ng::from_str(s).unwrap()
276 }
277
278 #[test]
279 fn referenced_vars_cover_strings_keys_and_bare_entries() {
280 let v = yaml(
281 "services:\n a:\n image: ${IMG:-x}\n environment: [PLAIN, 'K=${V}']\n labels: {'${L}': '1'}\n",
282 );
283 let r: Vec<String> = referenced_vars(&v).into_iter().collect();
284 assert_eq!(r, ["IMG", "L", "PLAIN", "V"]);
285 }
286
287 #[test]
288 fn a_secret_variable_becomes_a_store_secret() {
289 let mut v = yaml(
290 "services:\n a:\n image: docker:nginx\n environment:\n DB: ${PW}\n HOST: ${HOST}\n b:\n image: docker:nginx\n environment: [PW, OTHER, K=v]\n",
291 );
292 let sv = BTreeMap::from([("PW".to_string(), "db.pw".to_string())]);
293 let bare = |k: &str| (k == "OTHER").then(|| "o".to_string());
294 assert!(deliver_secret_vars(&mut v, &sv, &bare).unwrap());
295 assert_eq!(
296 v["services"]["a"]["environment"]["DB"]["secret"],
297 "env.db.pw"
298 );
299 assert_eq!(v["services"]["a"]["environment"]["HOST"], "${HOST}");
301 assert_eq!(
302 v["services"]["b"]["environment"]["PW"]["secret"],
303 "env.db.pw"
304 );
305 assert_eq!(v["services"]["b"]["environment"]["OTHER"], "o");
306 assert_eq!(v["services"]["b"]["environment"]["K"], "v");
307 assert_eq!(v["secrets"]["env.db.pw"]["name"], "db.pw");
308 assert_eq!(v["secrets"]["env.db.pw"]["external"], true);
309 let p = crate::compose::load_docs(
311 &[("c.yaml".into(), serde_yaml_ng::to_string(&v).unwrap())],
312 std::path::Path::new("/srv"),
313 Some("s"),
314 &|k| (k == "HOST").then(|| "h".into()),
315 )
316 .unwrap();
317 assert_eq!(p.file.services["a"].env.secrets["DB"], "env.db.pw");
318 assert_eq!(p.file.services["a"].env["HOST"], "h");
319 }
320
321 #[test]
322 fn a_secret_variable_inside_a_value_is_refused() {
323 let sv = BTreeMap::from([("PW".to_string(), "pw".to_string())]);
324 for doc in [
325 "services:\n a:\n image: x\n environment: {URL: 'pg://u:${PW}@db'}\n",
326 "services:\n a:\n image: x\n command: [run, '${PW}']\n",
327 ] {
328 let mut v = yaml(doc);
329 let e = deliver_secret_vars(&mut v, &sv, &|_| None)
330 .unwrap_err()
331 .to_string();
332 assert!(
333 e.contains("variable PW is the secret ${{secret.pw}}"),
334 "{e}"
335 );
336 }
337 let mut v = yaml("services: {a: {image: '${PW}'}}\n");
339 assert!(!deliver_secret_vars(&mut v, &BTreeMap::new(), &|_| None).unwrap());
340 }
341
342 fn file(s: &str) -> ComposeFile {
343 crate::compose::load_docs(
344 &[("c.yaml".into(), s.to_string())],
345 std::path::Path::new("/srv"),
346 Some("s"),
347 &|_| None,
348 )
349 .unwrap()
350 .file
351 }
352
353 fn dom(host: &str) -> DomainSpec {
354 DomainSpec {
355 host: host.into(),
356 port: Some(80),
357 ..Default::default()
358 }
359 }
360
361 #[test]
362 fn managed_domains_merge_and_clashes_are_named() {
363 let f = file(
364 "services:\n web:\n image: x\n domains: [{host: a.example.com, port: 80}]\n api:\n image: x\n",
365 );
366 let managed = BTreeMap::from([
367 ("api".to_string(), vec![dom("b.example.com")]),
368 ("gone".to_string(), vec![dom("c.example.com")]),
369 ]);
370 let mut g = f.clone();
371 merge_domains(&mut g, &managed).unwrap();
372 assert_eq!(g.services["api"].domains, [dom("b.example.com")]);
373 assert_eq!(g.services["web"].domains.len(), 1);
374 let fd = file_domains(&g, &managed);
375 assert!(fd["api"].is_empty());
376 assert_eq!(fd["web"][0].host, "a.example.com");
377 let both = BTreeMap::from([("api".to_string(), vec![dom("A.example.com")])]);
379 let e = merge_domains(&mut f.clone(), &both)
380 .unwrap_err()
381 .to_string();
382 assert!(
383 e.contains("domain a.example.com is in the compose file (service web)"),
384 "{e}"
385 );
386 let twice = BTreeMap::from([
388 ("api".to_string(), vec![dom("d.example.com")]),
389 ("web".to_string(), vec![dom("d.example.com")]),
390 ]);
391 let e = merge_domains(&mut f.clone(), &twice)
392 .unwrap_err()
393 .to_string();
394 assert!(e.contains("domain d.example.com is given twice"), "{e}");
395 }
396}