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
67pub 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#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Reused {
101 pub key: String,
103 pub version: u64,
105 pub stored_at: u64,
107}
108
109#[derive(Debug, Clone, Default)]
111pub struct Bound {
112 pub bindings: BTreeMap<String, SecretBinding>,
113 pub reused: Vec<Reused>,
114}
115
116pub 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
133pub 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 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
225fn 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 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
246pub 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
266pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct Cycle {
300 pub stack: String,
302 pub service: String,
303 pub key: String,
305 pub secret: String,
307 pub from: u64,
308 pub to: u64,
309 pub action: OnChange,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub error: Option<String>,
318}
319
320impl Cycle {
321 pub fn cycles(&self) -> bool {
323 self.error.is_none() && self.action != OnChange::None
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub struct Applied {
330 pub stack: String,
331 pub service: String,
332 pub instance: String,
333}
334
335pub 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
347pub type Refreshed = (Vec<(String, u64)>, Vec<Cycle>);
350
351pub const LABEL_SECRETS: &str = "isb.secrets";
355
356pub fn versions_label(v: &BTreeMap<String, u64>) -> String {
358 serde_json::to_string(v).unwrap_or_default()
359}
360
361pub fn parse_versions_label(s: Option<&str>) -> Option<BTreeMap<String, u64>> {
363 serde_json::from_str(s?).ok()
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
369pub struct StaleSecret {
370 pub key: String,
371 pub running: u64,
373 pub current: u64,
375}
376
377pub 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
393pub type Polled = BTreeMap<(OrgId, String, String), std::result::Result<u64, String>>;
396
397pub 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#[derive(Debug, Default)]
425pub struct RefreshSchedule {
426 next: BTreeMap<(String, String), Instant>,
427}
428
429impl RefreshSchedule {
430 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 self.next.retain(|k, _| seen.contains(k));
461 out
462 }
463
464 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 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 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 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 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 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 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 let e = bind(&s, &org, "other", &f, &BTreeMap::new(), false).unwrap_err();
612 assert!(e.to_string().contains("no value for secret"), "{e}");
613 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 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 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 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 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 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 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 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 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 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}