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