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
75pub 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
144fn 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 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
165pub 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
185pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct Cycle {
219 pub stack: String,
221 pub service: String,
222 pub key: String,
224 pub secret: String,
226 pub from: u64,
227 pub to: u64,
228 pub action: OnChange,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub error: Option<String>,
237}
238
239impl Cycle {
240 pub fn cycles(&self) -> bool {
242 self.error.is_none() && self.action != OnChange::None
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct Applied {
249 pub stack: String,
250 pub service: String,
251 pub instance: String,
252}
253
254pub 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
266pub type Refreshed = (Vec<(String, u64)>, Vec<Cycle>);
269
270pub const LABEL_SECRETS: &str = "isb.secrets";
274
275pub fn versions_label(v: &BTreeMap<String, u64>) -> String {
277 serde_json::to_string(v).unwrap_or_default()
278}
279
280pub fn parse_versions_label(s: Option<&str>) -> Option<BTreeMap<String, u64>> {
282 serde_json::from_str(s?).ok()
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
288pub struct StaleSecret {
289 pub key: String,
290 pub running: u64,
292 pub current: u64,
294}
295
296pub 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
312pub type Polled = BTreeMap<(OrgId, String, String), std::result::Result<u64, String>>;
315
316pub 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#[derive(Debug, Default)]
344pub struct RefreshSchedule {
345 next: BTreeMap<(String, String), Instant>,
346}
347
348impl RefreshSchedule {
349 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 self.next.retain(|k, _| seen.contains(k));
380 out
381 }
382
383 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}