1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct SecretBinding {
29 pub name: String,
31 pub driver: String,
33 pub version: u64,
35 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
38 pub owned: bool,
39}
40
41impl SecretBinding {
42 pub fn is_driver_backed(&self) -> bool {
46 self.driver != crate::secrets::local::DRIVER
47 }
48}
49
50pub 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
58pub 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#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Reused {
79 pub key: String,
81 pub version: u64,
83 pub stored_at: u64,
85}
86
87#[derive(Debug, Clone, Default)]
89pub struct Bound {
90 pub bindings: BTreeMap<String, SecretBinding>,
91 pub reused: Vec<Reused>,
92}
93
94pub 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
111pub 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 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
203fn 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 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
224pub 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
244pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct Cycle {
278 pub stack: String,
280 pub service: String,
281 pub key: String,
283 pub secret: String,
285 pub from: u64,
286 pub to: u64,
287 pub action: OnChange,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub error: Option<String>,
296}
297
298impl Cycle {
299 pub fn cycles(&self) -> bool {
301 self.error.is_none() && self.action != OnChange::None
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct Applied {
308 pub stack: String,
309 pub service: String,
310 pub instance: String,
311}
312
313pub 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
325pub type Refreshed = (Vec<(String, u64)>, Vec<Cycle>);
328
329pub const LABEL_SECRETS: &str = "isb.secrets";
333
334pub fn versions_label(v: &BTreeMap<String, u64>) -> String {
336 serde_json::to_string(v).unwrap_or_default()
337}
338
339pub fn parse_versions_label(s: Option<&str>) -> Option<BTreeMap<String, u64>> {
341 serde_json::from_str(s?).ok()
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
347pub struct StaleSecret {
348 pub key: String,
349 pub running: u64,
351 pub current: u64,
353}
354
355pub 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
371pub type Polled = BTreeMap<(OrgId, String, String), std::result::Result<u64, String>>;
374
375pub 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#[derive(Debug, Default)]
403pub struct RefreshSchedule {
404 next: BTreeMap<(String, String), Instant>,
405}
406
407impl RefreshSchedule {
408 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 self.next.retain(|k, _| seen.contains(k));
439 out
440 }
441
442 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 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 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 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 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 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 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 let e = bind(&s, &org, "other", &f, &BTreeMap::new(), false).unwrap_err();
590 assert!(e.to_string().contains("no value for secret"), "{e}");
591 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 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 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 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 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 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 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 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 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 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}