Skip to main content

isb_core/stack/
source.rs

1//! A compose stack's source and what the daemon adds to it at deploy: the
2//! stack's environment (variables `${VAR}` resolves against, kept apart
3//! from the file) and its managed domains (domain records kept apart from
4//! the file, per service). The file is stored as written; these are merged
5//! into the copy that runs.
6//!
7//! A variable whose value is a secret reaches a service only as the whole
8//! value of one of its environment variables (`KEY: ${VAR}`), which is
9//! delivered as a store secret (`KEY: {secret: ...}`), so the value never
10//! enters the stored file. Used anywhere else it is refused.
11
12use std::collections::{BTreeMap, BTreeSet};
13
14use serde_yaml_ng::{Mapping, Value};
15
16use crate::error::{Error, Result};
17use crate::spec::{ComposeFile, DomainSpec};
18
19/// The top-level secret key an environment variable's secret `name`
20/// renders to.
21pub fn env_secret_key(name: &str) -> String {
22    format!("env.{name}")
23}
24
25/// The variable `s` is exactly a reference to (`${VAR}` or `$VAR`).
26fn 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
37/// The services' environment-like lists (`environment`, `exec.env`).
38fn 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
75/// Every variable a compose document refers to: `${VAR}` anywhere, and a
76/// bare `VAR` in an environment list (which takes the variable's value).
77pub 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
95/// Deliver the variables in `secret_vars` (variable to store secret name)
96/// as secrets: a service environment variable whose whole value is one of
97/// them becomes `{secret: env.<name>}`, with that top-level secret
98/// declared (external, from the store). `bare` gives a bare list entry's
99/// value when its variable is no secret. A secret variable used anywhere
100/// else is an error naming it. Returns whether `v` changed.
101pub 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        // A list is made a map when it has a secret in it.
112        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    // Anything left would put the value into the file.
159    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
203/// A domain's identity for clashes: its host, lowercased.
204fn host_of(d: &DomainSpec) -> String {
205    d.host.trim().to_ascii_lowercase()
206}
207
208/// Add the stack's managed domains to the services they are for. A
209/// hostname the file already gives any service, or that the managed
210/// records claim twice (same host and path), is an error naming it.
211/// Records for a service the file does not have are kept but unused.
212pub 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
250/// The domains the file itself gives each service: what is deployed less
251/// what was merged in from the managed records.
252pub 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        // Plain references are left for interpolation.
300        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        // It loads, and the value is nowhere in the file.
310        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        // No secret variables: nothing to do.
338        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        // In both.
378        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        // Claimed twice.
387        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}