use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, PoisonError};
use chrono::{DateTime, Utc};
use engenho_config::{ConfigError, LeafPath, OverrideLayer};
use engenho_control_types::types::PrincipalView;
use engenho_substrate::{AtomicWriteError, write_atomic_mode};
use serde::{Deserialize, Serialize};
pub const FILE: &str = "overrides.yaml";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
enum Schema {
#[serde(rename = "engenho.control/overrides/v1")]
V1,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct OverrideFile {
schema: Schema,
generation: u64,
entries: BTreeMap<LeafPath, Stored>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Stored {
pub value: serde_json::Value,
pub set_by: PrincipalView,
pub set_at: DateTime<Utc>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Durability {
Persisted,
Ephemeral,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
pub path: LeafPath,
pub stored: Stored,
pub durability: Durability,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Change {
Set {
path: LeafPath,
stored: Stored,
durability: Durability,
},
Unset {
path: LeafPath,
},
Clear {
prefix: Option<String>,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct OverrideSet {
generation: u64,
persisted: BTreeMap<LeafPath, Stored>,
ephemeral: BTreeMap<LeafPath, Stored>,
unreadable: Option<String>,
}
impl OverrideSet {
fn empty() -> Self {
Self {
generation: 0,
persisted: BTreeMap::new(),
ephemeral: BTreeMap::new(),
unreadable: None,
}
}
#[must_use]
pub const fn generation(&self) -> u64 {
self.generation
}
#[must_use]
pub fn unreadable(&self) -> Option<&str> {
self.unreadable.as_deref()
}
#[must_use]
pub fn entries(&self) -> Vec<Entry> {
let mut by_path: BTreeMap<&LeafPath, Entry> = BTreeMap::new();
for (durability, map) in [
(Durability::Persisted, &self.persisted),
(Durability::Ephemeral, &self.ephemeral),
] {
for (path, stored) in map {
by_path.insert(
path,
Entry {
path: path.clone(),
stored: stored.clone(),
durability,
},
);
}
}
by_path.into_values().collect()
}
#[must_use]
pub fn get(&self, path: &LeafPath) -> Option<(&Stored, Durability)> {
self.ephemeral
.get(path)
.map(|s| (s, Durability::Ephemeral))
.or_else(|| self.persisted.get(path).map(|s| (s, Durability::Persisted)))
}
#[must_use]
pub fn len(&self) -> usize {
self.entries().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.persisted.is_empty() && self.ephemeral.is_empty()
}
pub fn layer(&self, path: &Path) -> Result<OverrideLayer, ConfigError> {
if let Some(why) = &self.unreadable {
return Err(ConfigError::Parse(format!(
"the override tier {} cannot be read ({why}); `engenho ctl config clear` replaces it",
path.display()
)));
}
Ok(OverrideLayer {
path: path.to_path_buf(),
values: self
.entries()
.into_iter()
.map(|entry| (entry.path, entry.stored.value))
.collect(),
})
}
#[must_use]
pub fn touched(&self, change: &Change) -> Vec<LeafPath> {
match change {
Change::Set { path, .. } | Change::Unset { path } => vec![path.clone()],
Change::Clear { prefix } => self
.entries()
.into_iter()
.map(|e| e.path)
.filter(|path| prefix.as_deref().is_none_or(|p| path.is_under(p)))
.collect(),
}
}
#[must_use]
pub fn with(&self, change: &Change) -> Self {
let mut next = self.clone();
next.generation = self.generation.saturating_add(1);
next.unreadable = None;
match change {
Change::Set {
path,
stored,
durability,
} => {
next.persisted.remove(path);
next.ephemeral.remove(path);
let map = match durability {
Durability::Persisted => &mut next.persisted,
Durability::Ephemeral => &mut next.ephemeral,
};
map.insert(path.clone(), stored.clone());
}
Change::Unset { path } => {
next.persisted.remove(path);
next.ephemeral.remove(path);
}
Change::Clear { prefix } => {
let keep = |path: &LeafPath| prefix.as_deref().is_some_and(|p| !path.is_under(p));
next.persisted.retain(|path, _| keep(path));
next.ephemeral.retain(|path, _| keep(path));
}
}
next
}
fn to_file(&self) -> OverrideFile {
OverrideFile {
schema: Schema::V1,
generation: self.generation,
entries: self.persisted.clone(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum OverrideWriteError {
#[error("serialize {FILE}: {0}")]
Serialize(#[from] serde_yaml::Error),
#[error("write {FILE}: {0}")]
Write(#[from] AtomicWriteError),
#[error("the override set is at generation {current}, not {planned}")]
Moved {
planned: u64,
current: u64,
},
}
#[derive(Debug)]
pub struct OverrideStore {
path: PathBuf,
set: Mutex<OverrideSet>,
}
impl OverrideStore {
#[must_use]
pub fn open(control_dir: &Path) -> Self {
let path = control_dir.join(FILE);
let set = match std::fs::read(&path) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => OverrideSet::empty(),
Err(err) => OverrideSet {
unreadable: Some(err.to_string()),
..OverrideSet::empty()
},
Ok(bytes) => match serde_yaml::from_slice::<OverrideFile>(&bytes) {
Ok(file) => OverrideSet {
generation: file.generation,
persisted: file.entries,
..OverrideSet::empty()
},
Err(err) => {
tracing::warn!(path = %path.display(), error = %err, "the override tier cannot be read");
OverrideSet {
unreadable: Some(err.to_string()),
..OverrideSet::empty()
}
}
},
};
Self {
path,
set: Mutex::new(set),
}
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn current(&self) -> OverrideSet {
self.lock().clone()
}
pub fn layer(&self) -> Result<OverrideLayer, ConfigError> {
self.lock().layer(&self.path)
}
pub fn commit(&self, next: OverrideSet) -> Result<u64, OverrideWriteError> {
let mut set = self.lock();
let planned = next.generation.saturating_sub(1);
if set.generation != planned {
return Err(OverrideWriteError::Moved {
planned,
current: set.generation,
});
}
let bytes = serde_yaml::to_string(&next.to_file())?;
write_atomic_mode(&self.path, bytes.as_bytes(), 0o600)?;
let generation = next.generation;
*set = next;
Ok(generation)
}
fn lock(&self) -> MutexGuard<'_, OverrideSet> {
self.set.lock().unwrap_or_else(PoisonError::into_inner)
}
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use engenho_control_types::types::{AttestedView, DeclaredView};
use super::*;
fn by() -> PrincipalView {
PrincipalView {
attested: AttestedView::LocalUid {
uid: 501,
gid: 20,
pid: 7,
},
declared: DeclaredView::Human,
}
}
fn set(path: &str, value: serde_json::Value, durability: Durability) -> Change {
Change::Set {
path: LeafPath::parse(path).unwrap(),
stored: Stored {
value,
set_by: by(),
set_at: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
},
durability,
}
}
fn leaf(path: &str) -> LeafPath {
LeafPath::parse(path).unwrap()
}
#[test]
fn a_persisted_override_survives_the_process_and_an_ephemeral_one_does_not() {
let dir = tempfile::tempdir().unwrap();
let store = OverrideStore::open(dir.path());
assert_eq!(store.current().generation(), 0);
let s = store.current();
let s = s.with(&set(
"scheduler.tick_interval_seconds",
9.into(),
Durability::Persisted,
));
store.commit(s).unwrap();
let s = store.current().with(&set(
"controllers.namespace",
"x".into(),
Durability::Ephemeral,
));
assert_eq!(store.commit(s).unwrap(), 2);
assert_eq!(store.current().len(), 2);
let mode = std::fs::metadata(store.path())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "the override tier is the daemon's alone");
let reopened = OverrideStore::open(dir.path()).current();
assert_eq!(
reopened.generation(),
2,
"the generation outlives the process"
);
let entries = reopened.entries();
assert_eq!(entries.len(), 1, "only the persisted one: {entries:?}");
assert_eq!(entries[0].path, leaf("scheduler.tick_interval_seconds"));
assert_eq!(entries[0].stored.set_by, by());
}
#[test]
fn a_stale_plan_does_not_commit() {
let dir = tempfile::tempdir().unwrap();
let store = OverrideStore::open(dir.path());
let base = store.current();
let a = base.with(&set(
"scheduler.namespace",
"a".into(),
Durability::Persisted,
));
let b = base.with(&set(
"scheduler.namespace",
"b".into(),
Durability::Persisted,
));
store.commit(a).unwrap();
assert!(matches!(
store.commit(b),
Err(OverrideWriteError::Moved {
planned: 0,
current: 1
})
));
let layer = store.layer().unwrap();
assert_eq!(layer.values[&leaf("scheduler.namespace")], "a");
}
#[test]
fn unset_and_clear_by_prefix() {
let s = OverrideSet::empty()
.with(&set(
"runtime.tls.enabled",
true.into(),
Durability::Persisted,
))
.with(&set(
"runtime.tls.extra_sans",
serde_json::json!(["a"]),
Durability::Ephemeral,
))
.with(&set(
"runtime.listen_addr",
"0.0.0.0:6443".into(),
Durability::Persisted,
));
let clear = Change::Clear {
prefix: Some("runtime.tls".into()),
};
assert_eq!(
s.touched(&clear),
[leaf("runtime.tls.enabled"), leaf("runtime.tls.extra_sans")]
);
let cleared = s.with(&clear);
assert_eq!(cleared.len(), 1);
assert!(cleared.get(&leaf("runtime.listen_addr")).is_some());
let unset = cleared.with(&Change::Unset {
path: leaf("runtime.listen_addr"),
});
assert!(unset.is_empty());
assert_eq!(unset.generation(), s.generation() + 2);
}
#[test]
fn an_unreadable_file_fails_resolution_until_replaced() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(FILE), "entries: [not, a, map]\n").unwrap();
let store = OverrideStore::open(dir.path());
let err = store.layer().unwrap_err();
assert!(err.to_string().contains("config clear"), "{err}");
let cleared = store.current().with(&Change::Clear { prefix: None });
store.commit(cleared).unwrap();
assert!(store.layer().unwrap().values.is_empty());
assert!(
OverrideStore::open(dir.path())
.current()
.unreadable()
.is_none()
);
}
}