use std::collections::{BTreeMap, BTreeSet};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::org::OrgId;
use crate::secrets::Secrets;
use crate::spec::{ComposeFile, OnChange, SecretDef};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretBinding {
pub name: String,
pub driver: String,
pub version: u64,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub owned: bool,
}
impl SecretBinding {
pub fn is_driver_backed(&self) -> bool {
self.driver != crate::secrets::local::DRIVER
}
}
pub fn owned_name(stack: &str, key: &str) -> Result<String> {
let n = format!("{stack}_{key}");
crate::secrets::validate_name(&n)
.map_err(|e| Error::invalid(format!("secret {key:?} of stack {stack}: {e}")))?;
Ok(n)
}
pub fn used_keys(file: &ComposeFile) -> BTreeSet<String> {
file.services
.values()
.flat_map(|s| s.secret_keys())
.map(String::from)
.collect()
}
pub fn env_exposure_warning(file: &ComposeFile) -> Option<String> {
let exposed: Vec<String> = file
.services
.iter()
.filter(|(_, s)| crate::plan::ImageSource::parse(&s.image).is_ok_and(|i| i.is_oci()))
.flat_map(|(svc, s)| s.env.secrets.keys().map(move |var| format!("{svc}.{var}")))
.collect();
if exposed.is_empty() {
return None;
}
Some(format!(
"{} 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",
exposed.len(),
if exposed.len() == 1 { "" } else { "s" },
exposed.join(", "),
if exposed.len() == 1 { "is" } else { "are" },
))
}
fn declared<'a>(file: &'a ComposeFile, key: &str) -> Result<&'a SecretDef> {
file.secrets.get(key).ok_or_else(|| {
Error::invalid(format!(
"secret {key:?} is not declared under top-level secrets"
))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reused {
pub key: String,
pub version: u64,
pub stored_at: u64,
}
#[derive(Debug, Clone, Default)]
pub struct Bound {
pub bindings: BTreeMap<String, SecretBinding>,
pub reused: Vec<Reused>,
}
pub fn bind(
secrets: &Secrets,
org: &OrgId,
stack: &str,
file: &ComposeFile,
given: &BTreeMap<String, Vec<u8>>,
dry_run: bool,
) -> Result<BTreeMap<String, SecretBinding>> {
bind_reporting(secrets, org, stack, file, given, dry_run, true).map(|b| b.bindings)
}
pub fn bind_reporting(
secrets: &Secrets,
org: &OrgId,
stack: &str,
file: &ComposeFile,
given: &BTreeMap<String, Vec<u8>>,
dry_run: bool,
reuse: bool,
) -> Result<Bound> {
let mut out = BTreeMap::new();
let mut reused = Vec::new();
for key in used_keys(file) {
let def = declared(file, &key)?;
let b = if let Some(store) = def.store_name(&key) {
let m = secrets.inspect(org, store).map_err(|e| match e {
Error::NotFound(_) => Error::invalid(format!(
"secret {key:?}: external secret {store} does not exist in org {org}; create it with `isb secret create {store} --org {org}`"
)),
e => e,
})?;
SecretBinding {
name: store.to_string(),
driver: m.driver,
version: m.version,
owned: false,
}
} else if let Some(driver) = &def.driver {
let r = def.name.clone().unwrap_or_default();
let version = secrets
.version_in(driver, org, &r)
.map_err(|e| Error::invalid(format!("secret {key:?} ({driver} {r}): {e}")))?;
SecretBinding {
name: r,
driver: driver.clone(),
version,
owned: false,
}
} else if reuse && def.age.is_none() && !given.contains_key(&key) {
let name = owned_name(stack, &key)?;
let m = secrets.inspect(org, &name).map_err(|e| match e {
Error::NotFound(_) => {
Error::invalid(format!("no value for secret {key:?}: pass it in `secrets`"))
}
e => e,
})?;
reused.push(Reused {
key: key.clone(),
version: m.version,
stored_at: m.updated_at,
});
SecretBinding {
name,
driver: m.driver,
version: m.version,
owned: true,
}
} else {
let value = if let Some(text) = &def.age {
secrets
.decrypt_inline(text)
.map_err(|e| Error::invalid(format!("secret {key:?}: {e}")))?
} else {
given.get(&key).cloned().ok_or_else(|| {
Error::invalid(format!("no value for secret {key:?}: pass it in `secrets`"))
})?
};
let name = owned_name(stack, &key)?;
let m = if dry_run {
would_put(secrets, org, &name, &value)?
} else {
let m = secrets.put(org, &name, &value)?;
(m.driver, m.version)
};
SecretBinding {
name,
driver: m.0,
version: m.1,
owned: true,
}
};
out.insert(key, b);
}
Ok(Bound {
bindings: out,
reused,
})
}
fn would_put(secrets: &Secrets, org: &OrgId, name: &str, value: &[u8]) -> Result<(String, u64)> {
match secrets.get(org, name) {
Ok((v, m)) if v == value => Ok((m.driver, m.version)),
Ok((_, m)) => Ok((m.driver, m.version + 1)),
Err(Error::NotFound(_)) => Ok((crate::secrets::local::DRIVER.into(), 1)),
Err(e) => Err(e),
}
}
impl SecretBinding {
pub fn read(&self, secrets: &Secrets, org: &OrgId) -> Result<Vec<u8>> {
secrets
.get_in(&self.driver, org, &self.name)
.map(|(v, _)| v)
.map_err(|e| Error::invalid(format!("secret {}: {e}", self.name)))
}
}
pub fn values<'a>(
secrets: &Secrets,
org: &OrgId,
bindings: &BTreeMap<String, SecretBinding>,
keys: impl IntoIterator<Item = &'a str>,
) -> Result<BTreeMap<String, Vec<u8>>> {
let mut out = BTreeMap::new();
for key in keys {
let b = bindings.get(key).ok_or_else(|| {
Error::invalid(format!(
"secret {key:?} is not bound in this deployment; deploy the stack again"
))
})?;
out.insert(key.to_string(), b.read(secrets, org)?);
}
Ok(out)
}
pub fn resolve(
secrets: &Secrets,
org: &OrgId,
defs: &BTreeMap<String, SecretDef>,
) -> Result<BTreeMap<String, Vec<u8>>> {
let mut out = BTreeMap::new();
for (key, def) in defs {
def.validate()
.map_err(|e| Error::invalid(format!("secret {key:?}: {e}")))?;
let v = if let Some(store) = def.store_name(key) {
secrets.get(org, store).map(|(v, _)| v)
} else if let Some(text) = &def.age {
secrets.decrypt_inline(text)
} else if let Some(driver) = &def.driver {
secrets
.get_in(driver, org, def.name.as_deref().unwrap_or_default())
.map(|(v, _)| v)
} else {
continue;
};
out.insert(
key.clone(),
v.map_err(|e| Error::invalid(format!("secret {key:?}: {e}")))?,
);
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cycle {
pub stack: String,
pub service: String,
pub key: String,
pub secret: String,
pub from: u64,
pub to: u64,
pub action: OnChange,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl Cycle {
pub fn cycles(&self) -> bool {
self.error.is_none() && self.action != OnChange::None
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Applied {
pub stack: String,
pub service: String,
pub instance: String,
}
pub fn cycled_stacks(cycles: &[Cycle]) -> Vec<String> {
let mut v: Vec<String> = cycles
.iter()
.filter(|c| c.cycles())
.map(|c| c.stack.clone())
.collect();
v.sort();
v.dedup();
v
}
pub type Refreshed = (Vec<(String, u64)>, Vec<Cycle>);
pub const LABEL_SECRETS: &str = "isb.secrets";
pub fn versions_label(v: &BTreeMap<String, u64>) -> String {
serde_json::to_string(v).unwrap_or_default()
}
pub fn parse_versions_label(s: Option<&str>) -> Option<BTreeMap<String, u64>> {
serde_json::from_str(s?).ok()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct StaleSecret {
pub key: String,
pub running: u64,
pub current: u64,
}
pub fn stale(have: &BTreeMap<String, u64>, want: &BTreeMap<String, u64>) -> Vec<StaleSecret> {
want.iter()
.filter_map(|(k, cur)| {
let run = *have.get(k)?;
(run != *cur).then(|| StaleSecret {
key: k.clone(),
running: run,
current: *cur,
})
})
.collect()
}
pub type Polled = BTreeMap<(OrgId, String, String), std::result::Result<u64, String>>;
pub fn poll_versions(
secrets: &Secrets,
refs: impl IntoIterator<Item = (OrgId, String, String)>,
) -> Polled {
let mut groups: BTreeMap<(OrgId, String), BTreeSet<String>> = BTreeMap::new();
for (org, driver, name) in refs {
groups.entry((org, driver)).or_default().insert(name);
}
let mut out = BTreeMap::new();
for ((org, driver), names) in groups {
let names: Vec<&str> = names.iter().map(String::as_str).collect();
let got = secrets.versions_in(&driver, &org, &names);
for (n, v) in names.iter().zip(got) {
out.insert(
(org.clone(), driver.clone(), (*n).to_string()),
v.map_err(|e| e.to_string()),
);
}
}
out
}
#[derive(Debug, Default)]
pub struct RefreshSchedule {
next: BTreeMap<(String, String), Instant>,
}
impl RefreshSchedule {
pub fn due<'a>(
&mut self,
stacks: impl IntoIterator<Item = (&'a str, &'a super::StackDef)>,
now: Instant,
) -> Vec<(String, String)> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for (q, def) in stacks {
for (key, b) in &def.secrets {
if !b.is_driver_backed() {
continue;
}
let decl = def.file.secrets.get(key);
let every = decl
.map(SecretDef::refresh_interval)
.unwrap_or(crate::spec::DEFAULT_SECRET_REFRESH);
let id = (q.to_string(), key.clone());
seen.insert(id.clone());
let next = self.next.entry(id.clone()).or_insert(now + every);
if now >= *next {
*next = now + every;
out.push(id);
}
}
}
self.next.retain(|k, _| seen.contains(k));
out
}
pub fn reset(&mut self, q: &str, key: &str, every: Duration, now: Instant) {
self.next
.insert((q.to_string(), key.to_string()), now + every);
}
}
#[cfg(test)]
pub(crate) mod tests_support {
use super::*;
use crate::secrets::{Driver, Keyring, LocalDriver, SecretMeta};
use std::sync::{Arc, Mutex};
pub(crate) struct Vault(pub Mutex<u64>);
impl Driver for Vault {
fn name(&self) -> &str {
"vault"
}
fn get(&self, org: &OrgId, name: &str) -> Result<(Vec<u8>, u64)> {
let v = self.version(org, name)?;
Ok((format!("{name}@{v}").into_bytes(), v))
}
fn version(&self, org: &OrgId, name: &str) -> Result<u64> {
if name.starts_with("op://") {
Ok(*self.0.lock().unwrap())
} else {
Err(crate::secrets::not_found(org, name))
}
}
fn inspect(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
Err(crate::secrets::not_found(org, name))
}
fn list(&self, _org: &OrgId) -> Result<Vec<SecretMeta>> {
Ok(vec![])
}
}
pub(crate) fn store(dir: &std::path::Path) -> (Secrets, Arc<Vault>) {
let k = Keyring::new(age::x25519::Identity::generate(), vec![]);
let vault = Arc::new(Vault(Mutex::new(3)));
let s = Secrets::new(LocalDriver::new(dir, Arc::new(k)))
.with_driver(vault.clone())
.unwrap();
(s, vault)
}
}
#[cfg(test)]
mod tests {
use super::tests_support::store;
use super::*;
use crate::secrets::Keyring;
fn file(y: &str) -> ComposeFile {
serde_yaml_ng::from_str(y).unwrap()
}
const FILE: &str = concat!(
"secrets:\n",
" db: {external: true, name: db.password}\n",
" tok: {environment: TOK}\n",
" cert: {file: ./cert.pem}\n",
" api: {driver: vault, name: 'op://v/api/key', refresh: 30m}\n",
" unused: {external: true}\n",
"services:\n",
" web:\n",
" image: docker:busybox\n",
" secrets: [db, cert]\n",
" environment: {TOKEN: {secret: tok}, API: {secret: api}, PLAIN: x}\n",
);
#[test]
fn binds_every_source_by_name_and_version() {
let dir = tempfile::tempdir().unwrap();
let (s, _) = store(dir.path());
let org = OrgId::default_org();
let f = file(FILE);
assert_eq!(
used_keys(&f).into_iter().collect::<Vec<_>>(),
["api", "cert", "db", "tok"]
);
let given = BTreeMap::from([
("tok".to_string(), b"t0k".to_vec()),
("cert".to_string(), b"PEM".to_vec()),
]);
let e = bind(&s, &org, "app", &f, &given, false).unwrap_err();
assert!(
e.to_string().contains("isb secret create db.password"),
"{e}"
);
s.create(&org, "db.password", None, b"pw", &BTreeMap::new())
.unwrap();
let dry = bind(&s, &org, "app", &f, &given, true).unwrap();
assert_eq!(dry["tok"].version, 1);
assert!(s.inspect(&org, "app_tok").is_err());
let b = bind(&s, &org, "app", &f, &given, false).unwrap();
assert_eq!(dry, b);
assert_eq!(
b["db"],
SecretBinding {
name: "db.password".into(),
driver: "local".into(),
version: 1,
owned: false
}
);
assert_eq!(
(b["tok"].name.as_str(), b["tok"].version, b["tok"].owned),
("app_tok", 1, true)
);
assert_eq!(b["cert"].name, "app_cert");
assert_eq!((b["api"].driver.as_str(), b["api"].version), ("vault", 3));
assert!(!b.contains_key("unused"));
let v = values(&s, &org, &b, ["tok", "db", "api"]).unwrap();
assert_eq!(v["tok"], b"t0k");
assert_eq!(v["db"], b"pw");
assert_eq!(v["api"], b"op://v/api/key@3");
assert!(values(&s, &org, &b, ["nope"]).is_err());
let again = bind(&s, &org, "app", &f, &given, false).unwrap();
assert_eq!(again["tok"].version, 1);
let mut given2 = given.clone();
given2.insert("tok".into(), b"new".to_vec());
assert_eq!(
bind(&s, &org, "app", &f, &given2, true).unwrap()["tok"].version,
2
);
assert_eq!(
bind(&s, &org, "app", &f, &given2, false).unwrap()["tok"].version,
2
);
let kept = bind(&s, &org, "app", &f, &BTreeMap::new(), false).unwrap();
assert_eq!(
(
kept["tok"].name.as_str(),
kept["tok"].version,
kept["tok"].owned
),
("app_tok", 2, true)
);
let e = bind(&s, &org, "other", &f, &BTreeMap::new(), false).unwrap_err();
assert!(e.to_string().contains("no value for secret"), "{e}");
let only_tok = BTreeMap::from([("tok".to_string(), b"new".to_vec())]);
let r = bind_reporting(&s, &org, "app", &f, &only_tok, true, true).unwrap();
let stored = s.inspect(&org, "app_cert").unwrap();
assert_eq!(
r.reused,
vec![Reused {
key: "cert".into(),
version: stored.version,
stored_at: stored.updated_at,
}]
);
assert!(
bind_reporting(&s, &org, "app", &f, &given2, false, true)
.unwrap()
.reused
.is_empty()
);
let e = bind_reporting(&s, &org, "app", &f, &only_tok, false, false).unwrap_err();
assert!(
e.to_string().contains("no value for secret \"cert\""),
"{e}"
);
assert_eq!(s.inspect(&org, "app_cert").unwrap().version, stored.version);
}
#[test]
fn inline_age_is_decrypted_and_stored() {
let dir = tempfile::tempdir().unwrap();
let (s, _) = store(dir.path());
let org = OrgId::new("alpha").unwrap();
let armored = s.encrypt_inline(b"inline-value").unwrap();
let mut f = file("services:\n web: {image: x, secrets: [k]}\n");
f.secrets.insert(
"k".into(),
SecretDef {
age: Some(armored.clone()),
..Default::default()
},
);
let b = bind(&s, &org, "web", &f, &BTreeMap::new(), false).unwrap();
assert_eq!((b["k"].name.as_str(), b["k"].version), ("web_k", 1));
assert_eq!(s.get(&org, "web_k").unwrap().0, b"inline-value");
f.secrets.get_mut("k").unwrap().age = Some(s.encrypt_inline(b"inline-value").unwrap());
assert_eq!(
bind(&s, &org, "web", &f, &BTreeMap::new(), false).unwrap()["k"].version,
1
);
let other = Keyring::new(age::x25519::Identity::generate(), vec![]);
let foreign = crate::secrets::encrypt_inline(b"x", other.recipients()).unwrap();
f.secrets.get_mut("k").unwrap().age = Some(foreign);
let e = bind(&s, &org, "web", &f, &BTreeMap::new(), false).unwrap_err();
assert!(e.to_string().contains("secret \"k\""), "{e}");
f.secrets.get_mut("k").unwrap().age = Some(armored);
let r = resolve(&s, &org, &f.secrets).unwrap();
assert_eq!(r["k"], b"inline-value");
}
#[test]
fn resolve_reads_store_backed_sources_only() {
let dir = tempfile::tempdir().unwrap();
let (s, _) = store(dir.path());
let org = OrgId::default_org();
s.create(&org, "db.password", None, b"pw", &BTreeMap::new())
.unwrap();
let f = file(FILE);
let r = resolve(&s, &org, &f.secrets).unwrap_err();
assert!(r.to_string().contains("unused"), "{r}");
let mut defs = f.secrets.clone();
defs.remove("unused");
let r = resolve(&s, &org, &defs).unwrap();
assert_eq!(
r.keys().map(String::as_str).collect::<Vec<_>>(),
["api", "db"]
);
assert_eq!(r["db"], b"pw");
}
#[test]
fn refresh_schedule() {
let mut def = super::super::StackDef {
source: None,
domains: Default::default(),
name: "app".into(),
org: OrgId::default_org(),
file: file(FILE),
base_dir: "/".into(),
secrets: BTreeMap::new(),
force: BTreeMap::new(),
images: BTreeMap::new(),
deployed_at: 0,
deployed_by: String::new(),
previous: None,
};
let bind = |name: &str, driver: &str| SecretBinding {
name: name.into(),
driver: driver.into(),
version: 1,
owned: false,
};
def.secrets
.insert("db".into(), bind("db.password", "local"));
def.secrets
.insert("api".into(), bind("op://v/api/key", "vault"));
let mut f2 = def.file.clone();
f2.secrets.get_mut("api").unwrap().refresh = None;
let mut def2 = def.clone();
def2.name = "two".into();
def2.file = f2;
let mut sch = RefreshSchedule::default();
let t0 = Instant::now();
let stacks = |a: &super::super::StackDef, b: &super::super::StackDef| {
vec![
("app".to_string(), a.clone()),
("two".to_string(), b.clone()),
]
};
let list = stacks(&def, &def2);
let it = || list.iter().map(|(q, d)| (q.as_str(), d));
assert!(sch.due(it(), t0).is_empty());
assert!(sch.due(it(), t0 + Duration::from_secs(29 * 60)).is_empty());
let d = sch.due(it(), t0 + Duration::from_secs(30 * 60));
assert_eq!(d, [("app".to_string(), "api".to_string())]);
assert!(sch.due(it(), t0 + Duration::from_secs(31 * 60)).is_empty());
let d = sch.due(it(), t0 + Duration::from_secs(60 * 60));
assert_eq!(
d,
[
("app".to_string(), "api".to_string()),
("two".to_string(), "api".to_string())
]
);
sch.reset("app", "api", Duration::from_secs(30 * 60), t0);
assert_eq!(sch.due(it(), t0 + Duration::from_secs(30 * 60)).len(), 1);
let only = [("app".to_string(), def.clone())];
sch.due(only.iter().map(|(q, d)| (q.as_str(), d)), t0);
assert_eq!(sch.next.len(), 1);
}
#[test]
fn owned_names() {
assert_eq!(owned_name("app", "db").unwrap(), "app_db");
assert!(owned_name("app", "a/b").is_err());
}
}