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