Skip to main content

isb_core/stack/
secrets.rs

1//! A stack's secrets: references into its org's store, by name and version.
2//!
3//! A deployed stack never holds a value. Each top-level secret its services
4//! use becomes a [`SecretBinding`]: the name in the org's store (or a
5//! driver's reference), the driver, and the version deployed. Values are read
6//! from the store when they are delivered, and a new version is a new
7//! revision, so the services using it roll.
8//!
9//! - `external: true` names an existing secret in the org.
10//! - `file:` and `environment:` are read by the deploying client, and `age:`
11//!   is decrypted with the daemon's key; all three are stored as `local`
12//!   secrets named `<stack>_<key>`, as swarm does, and removed with the
13//!   stack.
14//! - `driver: X, name: REF` is read through driver X.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::time::{Duration, Instant};
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::{Error, Result};
22use crate::org::OrgId;
23use crate::secrets::Secrets;
24use crate::spec::{ComposeFile, OnChange, SecretDef};
25
26/// One top-level secret a stack uses, as deployed.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct SecretBinding {
29    /// The name in the org's store, or the driver's reference.
30    pub name: String,
31    /// The driver holding it (`local`, ...).
32    pub driver: String,
33    /// The version deployed; a new one rolls the services using it.
34    pub version: u64,
35    /// The stack made it from a `file:`, `environment:` or `age:` source,
36    /// and removes it with the stack.
37    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
38    pub owned: bool,
39}
40
41impl SecretBinding {
42    /// Held outside isb's own store (an external vault): polled for new
43    /// versions. A `local` secret changes only through isb, which rolls its
44    /// users there and then.
45    pub fn is_driver_backed(&self) -> bool {
46        self.driver != crate::secrets::local::DRIVER
47    }
48}
49
50/// `<stack>_<key>`: where a stack keeps a secret it was given a value for.
51pub fn owned_name(stack: &str, key: &str) -> Result<String> {
52    let n = format!("{stack}_{key}");
53    crate::secrets::validate_name(&n)
54        .map_err(|e| Error::invalid(format!("secret {key:?} of stack {stack}: {e}")))?;
55    Ok(n)
56}
57
58/// The top-level secrets a file's services use (as files or variables).
59pub fn used_keys(file: &ComposeFile) -> BTreeSet<String> {
60    file.services
61        .values()
62        .flat_map(|s| s.secret_keys())
63        .map(String::from)
64        .collect()
65}
66
67fn declared<'a>(file: &'a ComposeFile, key: &str) -> Result<&'a SecretDef> {
68    file.secrets.get(key).ok_or_else(|| {
69        Error::invalid(format!(
70            "secret {key:?} is not declared under top-level secrets"
71        ))
72    })
73}
74
75/// Bind every secret `file`'s services use, for deploying it as `stack` in
76/// `org`. `given` holds the values of `file:`/`environment:` secrets, read by
77/// the client. Values the stack owns are stored now, and only when changed,
78/// so an unchanged value keeps its version. With `dry_run` nothing is
79/// written; the versions are what a deploy would produce.
80pub fn bind(
81    secrets: &Secrets,
82    org: &OrgId,
83    stack: &str,
84    file: &ComposeFile,
85    given: &BTreeMap<String, Vec<u8>>,
86    dry_run: bool,
87) -> Result<BTreeMap<String, SecretBinding>> {
88    let mut out = BTreeMap::new();
89    for key in used_keys(file) {
90        let def = declared(file, &key)?;
91        let b = if let Some(store) = def.store_name(&key) {
92            let m = secrets.inspect(org, store).map_err(|e| match e {
93                Error::NotFound(_) => Error::invalid(format!(
94                    "secret {key:?}: external secret {store} does not exist in org {org}; create it with `isb secret create {store} --org {org}`"
95                )),
96                e => e,
97            })?;
98            SecretBinding {
99                name: store.to_string(),
100                driver: m.driver,
101                version: m.version,
102                owned: false,
103            }
104        } else if let Some(driver) = &def.driver {
105            let r = def.name.clone().unwrap_or_default();
106            let version = secrets
107                .version_in(driver, org, &r)
108                .map_err(|e| Error::invalid(format!("secret {key:?} ({driver} {r}): {e}")))?;
109            SecretBinding {
110                name: r,
111                driver: driver.clone(),
112                version,
113                owned: false,
114            }
115        } else {
116            let value = if let Some(text) = &def.age {
117                secrets
118                    .decrypt_inline(text)
119                    .map_err(|e| Error::invalid(format!("secret {key:?}: {e}")))?
120            } else {
121                given.get(&key).cloned().ok_or_else(|| {
122                    Error::invalid(format!("no value for secret {key:?}: pass it in `secrets`"))
123                })?
124            };
125            let name = owned_name(stack, &key)?;
126            let m = if dry_run {
127                would_put(secrets, org, &name, &value)?
128            } else {
129                let m = secrets.put(org, &name, &value)?;
130                (m.driver, m.version)
131            };
132            SecretBinding {
133                name,
134                driver: m.0,
135                version: m.1,
136                owned: true,
137            }
138        };
139        out.insert(key, b);
140    }
141    Ok(out)
142}
143
144/// The driver and version `put` would leave.
145fn would_put(secrets: &Secrets, org: &OrgId, name: &str, value: &[u8]) -> Result<(String, u64)> {
146    match secrets.get(org, name) {
147        Ok((v, m)) if v == value => Ok((m.driver, m.version)),
148        Ok((_, m)) => Ok((m.driver, m.version + 1)),
149        Err(Error::NotFound(_)) => Ok((crate::secrets::local::DRIVER.into(), 1)),
150        Err(e) => Err(e),
151    }
152}
153
154impl SecretBinding {
155    /// The value now in the store (its version may be newer than this
156    /// binding's; the controller rolls to it on its next check).
157    pub fn read(&self, secrets: &Secrets, org: &OrgId) -> Result<Vec<u8>> {
158        secrets
159            .get_in(&self.driver, org, &self.name)
160            .map(|(v, _)| v)
161            .map_err(|e| Error::invalid(format!("secret {}: {e}", self.name)))
162    }
163}
164
165/// The values of the given keys, read from the store through the stack's
166/// bindings.
167pub fn values<'a>(
168    secrets: &Secrets,
169    org: &OrgId,
170    bindings: &BTreeMap<String, SecretBinding>,
171    keys: impl IntoIterator<Item = &'a str>,
172) -> Result<BTreeMap<String, Vec<u8>>> {
173    let mut out = BTreeMap::new();
174    for key in keys {
175        let b = bindings.get(key).ok_or_else(|| {
176            Error::invalid(format!(
177                "secret {key:?} is not bound in this deployment; deploy the stack again"
178            ))
179        })?;
180        out.insert(key.to_string(), b.read(secrets, org)?);
181    }
182    Ok(out)
183}
184
185/// The values of a file's store-backed secrets (`external`, `age`,
186/// `driver`), for `isb up`, which runs no stack. `file:` and `environment:`
187/// secrets are skipped: the client reads those itself.
188pub fn resolve(
189    secrets: &Secrets,
190    org: &OrgId,
191    defs: &BTreeMap<String, SecretDef>,
192) -> Result<BTreeMap<String, Vec<u8>>> {
193    let mut out = BTreeMap::new();
194    for (key, def) in defs {
195        def.validate()
196            .map_err(|e| Error::invalid(format!("secret {key:?}: {e}")))?;
197        let v = if let Some(store) = def.store_name(key) {
198            secrets.get(org, store).map(|(v, _)| v)
199        } else if let Some(text) = &def.age {
200            secrets.decrypt_inline(text)
201        } else if let Some(driver) = &def.driver {
202            secrets
203                .get_in(driver, org, def.name.as_deref().unwrap_or_default())
204                .map(|(v, _)| v)
205        } else {
206            continue;
207        };
208        out.insert(
209            key.clone(),
210            v.map_err(|e| Error::invalid(format!("secret {key:?}: {e}")))?,
211        );
212    }
213    Ok(out)
214}
215
216/// What a new version of a secret did to one service of a stack.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct Cycle {
219    /// The stack's name in its org.
220    pub stack: String,
221    pub service: String,
222    /// The top-level secret key in the stack's file.
223    pub key: String,
224    /// The store name, or the driver's reference.
225    pub secret: String,
226    pub from: u64,
227    pub to: u64,
228    /// `roll`: a rolling update replaces the replicas. `restart`: each
229    /// replica gets the value and its app is restarted in place. `none`:
230    /// the value is delivered where it can be, nothing restarts, and the
231    /// replicas are stale until they next start.
232    pub action: OnChange,
233    /// The secret's `rotate` command failed: the stack keeps the version it
234    /// had, and nothing cycles.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub error: Option<String>,
237}
238
239impl Cycle {
240    /// The replicas run the new value once this is done.
241    pub fn cycles(&self) -> bool {
242        self.error.is_none() && self.action != OnChange::None
243    }
244}
245
246/// Where a secret's `rotate` command made a new value take effect.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct Applied {
249    pub stack: String,
250    pub service: String,
251    pub instance: String,
252}
253
254/// The stacks with a service that cycles (rolls or restarts), by name.
255pub fn cycled_stacks(cycles: &[Cycle]) -> Vec<String> {
256    let mut v: Vec<String> = cycles
257        .iter()
258        .filter(|c| c.cycles())
259        .map(|c| c.stack.clone())
260        .collect();
261    v.sort();
262    v.dedup();
263    v
264}
265
266/// A forced refresh: the (driver, version) of every binding to the name, and
267/// what the new version did, per service.
268pub type Refreshed = (Vec<(String, u64)>, Vec<Cycle>);
269
270/// Instance config key (without `user.`) holding the versions of the
271/// `restart`/`none` secrets its app last started with, as a JSON object.
272/// A replica whose versions are behind the stack's is stale.
273pub const LABEL_SECRETS: &str = "isb.secrets";
274
275/// [`LABEL_SECRETS`]'s value.
276pub fn versions_label(v: &BTreeMap<String, u64>) -> String {
277    serde_json::to_string(v).unwrap_or_default()
278}
279
280/// [`LABEL_SECRETS`] read back; `None` when absent or unreadable.
281pub fn parse_versions_label(s: Option<&str>) -> Option<BTreeMap<String, u64>> {
282    serde_json::from_str(s?).ok()
283}
284
285/// A secret a replica runs an older version of (`on_change: none`, or a
286/// restart still to come).
287#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
288pub struct StaleSecret {
289    pub key: String,
290    /// The version its app started with.
291    pub running: u64,
292    /// The version bound now, delivered to its files.
293    pub current: u64,
294}
295
296/// Which of `want`'s secrets a replica that started with `have` runs an
297/// older version of. A secret missing from `have` is not stale: the replica
298/// predates the setting, and is taken to run what is bound.
299pub fn stale(have: &BTreeMap<String, u64>, want: &BTreeMap<String, u64>) -> Vec<StaleSecret> {
300    want.iter()
301        .filter_map(|(k, cur)| {
302            let run = *have.get(k)?;
303            (run != *cur).then(|| StaleSecret {
304                key: k.clone(),
305                running: run,
306                current: *cur,
307            })
308        })
309        .collect()
310}
311
312/// One polling round's answers: the version of each `(org, driver, name)`,
313/// or why it could not be read.
314pub type Polled = BTreeMap<(OrgId, String, String), std::result::Result<u64, String>>;
315
316/// The current version of every `(org, driver, name)`, one polling round:
317/// each driver is asked once per org for all its names, so it can answer
318/// names that share a version (fields of one 1Password item) with one
319/// lookup.
320pub fn poll_versions(
321    secrets: &Secrets,
322    refs: impl IntoIterator<Item = (OrgId, String, String)>,
323) -> Polled {
324    let mut groups: BTreeMap<(OrgId, String), BTreeSet<String>> = BTreeMap::new();
325    for (org, driver, name) in refs {
326        groups.entry((org, driver)).or_default().insert(name);
327    }
328    let mut out = BTreeMap::new();
329    for ((org, driver), names) in groups {
330        let names: Vec<&str> = names.iter().map(String::as_str).collect();
331        let got = secrets.versions_in(&driver, &org, &names);
332        for (n, v) in names.iter().zip(got) {
333            out.insert(
334                (org.clone(), driver.clone(), (*n).to_string()),
335                v.map_err(|e| e.to_string()),
336            );
337        }
338    }
339    out
340}
341
342/// When each driver-backed binding is next due for a version check.
343#[derive(Debug, Default)]
344pub struct RefreshSchedule {
345    next: BTreeMap<(String, String), Instant>,
346}
347
348impl RefreshSchedule {
349    /// The `(stack, key)` pairs due at `now`, given each stack's bindings
350    /// and declared refresh intervals; each one returned is rescheduled. A
351    /// binding seen for the first time is due one interval after `now`: it
352    /// was just read at deploy.
353    pub fn due<'a>(
354        &mut self,
355        stacks: impl IntoIterator<Item = (&'a str, &'a super::StackDef)>,
356        now: Instant,
357    ) -> Vec<(String, String)> {
358        let mut seen = BTreeSet::new();
359        let mut out = Vec::new();
360        for (q, def) in stacks {
361            for (key, b) in &def.secrets {
362                if !b.is_driver_backed() {
363                    continue;
364                }
365                let decl = def.file.secrets.get(key);
366                let every = decl
367                    .map(SecretDef::refresh_interval)
368                    .unwrap_or(crate::spec::DEFAULT_SECRET_REFRESH);
369                let id = (q.to_string(), key.clone());
370                seen.insert(id.clone());
371                let next = self.next.entry(id.clone()).or_insert(now + every);
372                if now >= *next {
373                    *next = now + every;
374                    out.push(id);
375                }
376            }
377        }
378        // Forget stacks and keys that went away.
379        self.next.retain(|k, _| seen.contains(k));
380        out
381    }
382
383    /// Check sooner than scheduled (a forced refresh).
384    pub fn reset(&mut self, q: &str, key: &str, every: Duration, now: Instant) {
385        self.next
386            .insert((q.to_string(), key.to_string()), now + every);
387    }
388}
389
390#[cfg(test)]
391pub(crate) mod tests_support {
392    use super::*;
393    use crate::secrets::{Driver, Keyring, LocalDriver, SecretMeta};
394    use std::sync::{Arc, Mutex};
395
396    /// An external vault whose version the test moves.
397    pub(crate) struct Vault(pub Mutex<u64>);
398    impl Driver for Vault {
399        fn name(&self) -> &str {
400            "vault"
401        }
402        fn get(&self, org: &OrgId, name: &str) -> Result<(Vec<u8>, u64)> {
403            let v = self.version(org, name)?;
404            Ok((format!("{name}@{v}").into_bytes(), v))
405        }
406        fn version(&self, org: &OrgId, name: &str) -> Result<u64> {
407            if name.starts_with("op://") {
408                Ok(*self.0.lock().unwrap())
409            } else {
410                Err(crate::secrets::not_found(org, name))
411            }
412        }
413        fn inspect(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
414            Err(crate::secrets::not_found(org, name))
415        }
416        fn list(&self, _org: &OrgId) -> Result<Vec<SecretMeta>> {
417            Ok(vec![])
418        }
419    }
420
421    pub(crate) fn store(dir: &std::path::Path) -> (Secrets, Arc<Vault>) {
422        let k = Keyring::new(age::x25519::Identity::generate(), vec![]);
423        let vault = Arc::new(Vault(Mutex::new(3)));
424        let s = Secrets::new(LocalDriver::new(dir, Arc::new(k)))
425            .with_driver(vault.clone())
426            .unwrap();
427        (s, vault)
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::tests_support::store;
434    use super::*;
435    use crate::secrets::Keyring;
436
437    fn file(y: &str) -> ComposeFile {
438        serde_yaml_ng::from_str(y).unwrap()
439    }
440
441    const FILE: &str = concat!(
442        "secrets:\n",
443        "  db: {external: true, name: db.password}\n",
444        "  tok: {environment: TOK}\n",
445        "  cert: {file: ./cert.pem}\n",
446        "  api: {driver: vault, name: 'op://v/api/key', refresh: 30m}\n",
447        "  unused: {external: true}\n",
448        "services:\n",
449        "  web:\n",
450        "    image: docker:busybox\n",
451        "    secrets: [db, cert]\n",
452        "    environment: {TOKEN: {secret: tok}, API: {secret: api}, PLAIN: x}\n",
453    );
454
455    #[test]
456    fn binds_every_source_by_name_and_version() {
457        let dir = tempfile::tempdir().unwrap();
458        let (s, _) = store(dir.path());
459        let org = OrgId::default_org();
460        let f = file(FILE);
461        assert_eq!(
462            used_keys(&f).into_iter().collect::<Vec<_>>(),
463            ["api", "cert", "db", "tok"]
464        );
465        let given = BTreeMap::from([
466            ("tok".to_string(), b"t0k".to_vec()),
467            ("cert".to_string(), b"PEM".to_vec()),
468        ]);
469        // The external secret must exist.
470        let e = bind(&s, &org, "app", &f, &given, false).unwrap_err();
471        assert!(
472            e.to_string().contains("isb secret create db.password"),
473            "{e}"
474        );
475        s.create(&org, "db.password", None, b"pw", &BTreeMap::new())
476            .unwrap();
477        // A dry run writes nothing.
478        let dry = bind(&s, &org, "app", &f, &given, true).unwrap();
479        assert_eq!(dry["tok"].version, 1);
480        assert!(s.inspect(&org, "app_tok").is_err());
481        let b = bind(&s, &org, "app", &f, &given, false).unwrap();
482        assert_eq!(dry, b);
483        assert_eq!(
484            b["db"],
485            SecretBinding {
486                name: "db.password".into(),
487                driver: "local".into(),
488                version: 1,
489                owned: false
490            }
491        );
492        assert_eq!(
493            (b["tok"].name.as_str(), b["tok"].version, b["tok"].owned),
494            ("app_tok", 1, true)
495        );
496        assert_eq!(b["cert"].name, "app_cert");
497        assert_eq!((b["api"].driver.as_str(), b["api"].version), ("vault", 3));
498        assert!(!b.contains_key("unused"));
499        // Values come from the store, never from the binding.
500        let v = values(&s, &org, &b, ["tok", "db", "api"]).unwrap();
501        assert_eq!(v["tok"], b"t0k");
502        assert_eq!(v["db"], b"pw");
503        assert_eq!(v["api"], b"op://v/api/key@3");
504        assert!(values(&s, &org, &b, ["nope"]).is_err());
505        // The same value again keeps the version; a new one bumps it.
506        let again = bind(&s, &org, "app", &f, &given, false).unwrap();
507        assert_eq!(again["tok"].version, 1);
508        let mut given2 = given.clone();
509        given2.insert("tok".into(), b"new".to_vec());
510        assert_eq!(
511            bind(&s, &org, "app", &f, &given2, true).unwrap()["tok"].version,
512            2
513        );
514        assert_eq!(
515            bind(&s, &org, "app", &f, &given2, false).unwrap()["tok"].version,
516            2
517        );
518        // A missing client value is an error naming the secret.
519        let e = bind(&s, &org, "app", &f, &BTreeMap::new(), false).unwrap_err();
520        assert!(e.to_string().contains("no value for secret"), "{e}");
521    }
522
523    #[test]
524    fn inline_age_is_decrypted_and_stored() {
525        let dir = tempfile::tempdir().unwrap();
526        let (s, _) = store(dir.path());
527        let org = OrgId::new("alpha").unwrap();
528        let armored = s.encrypt_inline(b"inline-value").unwrap();
529        let mut f = file("services:\n  web: {image: x, secrets: [k]}\n");
530        f.secrets.insert(
531            "k".into(),
532            SecretDef {
533                age: Some(armored.clone()),
534                ..Default::default()
535            },
536        );
537        let b = bind(&s, &org, "web", &f, &BTreeMap::new(), false).unwrap();
538        assert_eq!((b["k"].name.as_str(), b["k"].version), ("web_k", 1));
539        assert_eq!(s.get(&org, "web_k").unwrap().0, b"inline-value");
540        // Re-encrypting the same value (new ciphertext) is no new version.
541        f.secrets.get_mut("k").unwrap().age = Some(s.encrypt_inline(b"inline-value").unwrap());
542        assert_eq!(
543            bind(&s, &org, "web", &f, &BTreeMap::new(), false).unwrap()["k"].version,
544            1
545        );
546        // Ciphertext for another key fails, naming the secret.
547        let other = Keyring::new(age::x25519::Identity::generate(), vec![]);
548        let foreign = crate::secrets::encrypt_inline(b"x", other.recipients()).unwrap();
549        f.secrets.get_mut("k").unwrap().age = Some(foreign);
550        let e = bind(&s, &org, "web", &f, &BTreeMap::new(), false).unwrap_err();
551        assert!(e.to_string().contains("secret \"k\""), "{e}");
552        // isb up's resolution reads the same sources.
553        f.secrets.get_mut("k").unwrap().age = Some(armored);
554        let r = resolve(&s, &org, &f.secrets).unwrap();
555        assert_eq!(r["k"], b"inline-value");
556    }
557
558    #[test]
559    fn resolve_reads_store_backed_sources_only() {
560        let dir = tempfile::tempdir().unwrap();
561        let (s, _) = store(dir.path());
562        let org = OrgId::default_org();
563        s.create(&org, "db.password", None, b"pw", &BTreeMap::new())
564            .unwrap();
565        let f = file(FILE);
566        let r = resolve(&s, &org, &f.secrets).unwrap_err();
567        assert!(r.to_string().contains("unused"), "{r}");
568        let mut defs = f.secrets.clone();
569        defs.remove("unused");
570        let r = resolve(&s, &org, &defs).unwrap();
571        assert_eq!(
572            r.keys().map(String::as_str).collect::<Vec<_>>(),
573            ["api", "db"]
574        );
575        assert_eq!(r["db"], b"pw");
576    }
577
578    #[test]
579    fn refresh_schedule() {
580        let mut def = super::super::StackDef {
581            name: "app".into(),
582            org: OrgId::default_org(),
583            file: file(FILE),
584            base_dir: "/".into(),
585            secrets: BTreeMap::new(),
586            force: BTreeMap::new(),
587            images: BTreeMap::new(),
588            deployed_at: 0,
589            deployed_by: String::new(),
590            previous: None,
591        };
592        let bind = |name: &str, driver: &str| SecretBinding {
593            name: name.into(),
594            driver: driver.into(),
595            version: 1,
596            owned: false,
597        };
598        def.secrets
599            .insert("db".into(), bind("db.password", "local"));
600        def.secrets
601            .insert("api".into(), bind("op://v/api/key", "vault"));
602        // A driver binding without a declared refresh uses the default.
603        let mut f2 = def.file.clone();
604        f2.secrets.get_mut("api").unwrap().refresh = None;
605        let mut def2 = def.clone();
606        def2.name = "two".into();
607        def2.file = f2;
608
609        let mut sch = RefreshSchedule::default();
610        let t0 = Instant::now();
611        let stacks = |a: &super::super::StackDef, b: &super::super::StackDef| {
612            vec![
613                ("app".to_string(), a.clone()),
614                ("two".to_string(), b.clone()),
615            ]
616        };
617        let list = stacks(&def, &def2);
618        let it = || list.iter().map(|(q, d)| (q.as_str(), d));
619        // Nothing is due right after deploy; local bindings never are.
620        assert!(sch.due(it(), t0).is_empty());
621        assert!(sch.due(it(), t0 + Duration::from_secs(29 * 60)).is_empty());
622        let d = sch.due(it(), t0 + Duration::from_secs(30 * 60));
623        assert_eq!(d, [("app".to_string(), "api".to_string())]);
624        // Rescheduled: not due again until another 30m.
625        assert!(sch.due(it(), t0 + Duration::from_secs(31 * 60)).is_empty());
626        let d = sch.due(it(), t0 + Duration::from_secs(60 * 60));
627        assert_eq!(
628            d,
629            [
630                ("app".to_string(), "api".to_string()),
631                ("two".to_string(), "api".to_string())
632            ]
633        );
634        // A forced check moves the next one.
635        sch.reset("app", "api", Duration::from_secs(30 * 60), t0);
636        assert_eq!(sch.due(it(), t0 + Duration::from_secs(30 * 60)).len(), 1);
637        // A stack that went away is forgotten.
638        let only = [("app".to_string(), def.clone())];
639        sch.due(only.iter().map(|(q, d)| (q.as_str(), d)), t0);
640        assert_eq!(sch.next.len(), 1);
641    }
642
643    #[test]
644    fn owned_names() {
645        assert_eq!(owned_name("app", "db").unwrap(), "app_db");
646        assert!(owned_name("app", "a/b").is_err());
647    }
648}