use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
use shep_core::config::AppConfig;
use shep_core::paths::ShepPaths;
use shep_core::protocol::ProcessInfo;
use shep_core::secrets;
use shep_core::status::ProcStatus;
use shep_daemon::snapshot::FlockSnapshot;
use crate::commands::secret::daemon_config;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SecretNamer {
pub name: String,
pub environment: String,
pub references: BTreeSet<String>,
}
pub(crate) fn namers(
host_environment: &str,
roll: Option<&FlockSnapshot>,
procs: &[ProcessInfo],
) -> Vec<SecretNamer> {
let Some(roll) = roll else {
return Vec::new();
};
let mut configs: BTreeMap<&str, &AppConfig> = BTreeMap::new();
for app in &roll.apps {
configs.entry(app.app.name.as_str()).or_insert(&app.app);
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut found = Vec::new();
for proc in procs {
if !seen.insert(proc.name.as_str()) {
continue;
}
let Some(config) = configs.get(proc.name.as_str()).copied() else {
continue;
};
let references = secrets::references(config);
if references.is_empty() {
continue;
}
found.push(SecretNamer {
name: proc.name.clone(),
environment: config
.environment
.clone()
.unwrap_or_else(|| host_environment.to_string()),
references,
});
}
found
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Reader {
pub name: String,
pub environment: String,
pub online: bool,
}
pub(crate) fn by_reference(
paths: &ShepPaths,
roll: Option<&FlockSnapshot>,
procs: &[ProcessInfo],
) -> BTreeMap<String, Vec<Reader>> {
let online: BTreeSet<&str> = procs
.iter()
.filter(|proc| proc.status == ProcStatus::Online)
.map(|proc| proc.name.as_str())
.collect();
let mut map: BTreeMap<String, Vec<Reader>> = BTreeMap::new();
let host_environment = daemon_config(paths).daemon.environment;
for namer in namers(&host_environment, roll, procs) {
for reference in &namer.references {
map.entry(reference.clone()).or_default().push(Reader {
name: namer.name.clone(),
environment: namer.environment.clone(),
online: online.contains(namer.name.as_str()),
});
}
}
for readers in map.values_mut() {
readers.sort_by(|a, b| a.name.cmp(&b.name));
}
map
}
pub(crate) fn roll_age(roll: &FlockSnapshot) -> Duration {
let now = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_millis().try_into().unwrap_or(u64::MAX));
Duration::from_millis(now.saturating_sub(roll.saved_at_ms))
}
#[cfg(test)]
pub(crate) mod test_support {
use std::path::Path;
use shep_core::config::AppConfig;
use shep_core::paths::ShepPaths;
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use shep_daemon::snapshot::{FlockSnapshot, SavedApp};
pub(crate) fn paths_under(dir: &Path) -> ShepPaths {
let home = dir.display().to_string();
ShepPaths::resolve(&move |key| (key == "SHEP_HOME").then(|| home.clone()), dir)
}
pub(crate) fn write_roll(paths: &ShepPaths, apps: &[AppConfig]) {
let roll = FlockSnapshot::with_apps(
apps.iter()
.cloned()
.map(|app| SavedApp {
app,
instances_running: 1,
disabled: false,
})
.collect(),
);
std::fs::write(&paths.snapshot, serde_json::to_vec(&roll).unwrap()).unwrap();
}
pub(crate) fn online(name: &str) -> ProcessInfo {
ProcessInfo::builder(1, name, ProcStatus::Online).build()
}
pub(crate) fn stopped(name: &str) -> ProcessInfo {
ProcessInfo::builder(1, name, ProcStatus::Stopped).build()
}
}
#[cfg(test)]
mod tests {
use crate::commands::query::read_roll;
use shep_core::config::AppConfig;
use shep_daemon::snapshot::SavedApp;
use super::test_support::{online, paths_under, stopped, write_roll};
use super::*;
#[test]
fn namers_with_no_roll_names_nothing() {
let found = namers("production", None, &[online("web")]);
assert!(
found.is_empty(),
"no roll on disk: nothing to name from: {found:?}"
);
}
#[test]
fn by_reference_with_no_roll_is_empty() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
let map = by_reference(&paths, None, &[online("web")]);
assert!(
map.is_empty(),
"no roll on disk: no reference has readers: {map:?}"
);
}
#[test]
fn an_app_naming_no_reference_is_left_out() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
let mut plain = AppConfig::minimal("plain", "./srv");
plain.env.insert("PORT".into(), "8080".into());
let mut secretive = AppConfig::minimal("secretive", "./srv");
secretive
.env
.insert("PW".into(), "{{secret:DB_PASSWORD}}".into());
write_roll(&paths, &[plain, secretive]);
let roll = read_roll(&paths);
let found = namers(
&daemon_config(&paths).daemon.environment,
roll.as_ref(),
&[online("plain"), online("secretive")],
);
assert_eq!(found.len(), 1, "only the app with a reference: {found:?}");
assert_eq!(found[0].name, "secretive");
assert!(found[0].references.contains("DB_PASSWORD"));
}
#[test]
fn an_apps_own_environment_beats_the_daemon_default() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
let mut pinned = AppConfig::minimal("pinned", "./srv");
pinned.environment = Some("staging".into());
pinned.env.insert("PW".into(), "{{secret:K}}".into());
let mut floating = AppConfig::minimal("floating", "./srv");
floating.env.insert("PW".into(), "{{secret:K}}".into());
write_roll(&paths, &[pinned, floating]);
let roll = read_roll(&paths);
let found = namers(
&daemon_config(&paths).daemon.environment,
roll.as_ref(),
&[online("pinned"), online("floating")],
);
let pinned = found.iter().find(|n| n.name == "pinned").unwrap();
let floating = found.iter().find(|n| n.name == "floating").unwrap();
assert_eq!(pinned.environment, "staging");
assert_eq!(
floating.environment,
daemon_config(&paths).daemon.environment,
"no environment of its own falls back to the daemon's"
);
}
#[test]
fn one_entry_per_app_however_many_instances_are_running() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
let mut web = AppConfig::minimal("web", "./srv");
web.env.insert("PW".into(), "{{secret:K}}".into());
write_roll(&paths, &[web]);
let roll = read_roll(&paths);
let found = namers(
&daemon_config(&paths).daemon.environment,
roll.as_ref(),
&[online("web"), online("web"), online("web")],
);
assert_eq!(found.len(), 1, "three instances, one config: {found:?}");
}
#[test]
fn two_roll_entries_under_one_name_report_the_first_ones_environment_and_references() {
let mut first = AppConfig::minimal("web", "./srv");
first.environment = Some("staging".into());
first.env.insert("PW".into(), "{{secret:FIRST}}".into());
let mut second = AppConfig::minimal("web", "./srv");
second.environment = Some("production".into());
second.env.insert("PW".into(), "{{secret:SECOND}}".into());
let roll = FlockSnapshot::with_apps(vec![
SavedApp {
app: first,
instances_running: 1,
disabled: false,
},
SavedApp {
app: second,
instances_running: 1,
disabled: false,
},
]);
let found = namers("production", Some(&roll), &[online("web")]);
assert_eq!(found.len(), 1, "one entry per name: {found:?}");
assert_eq!(
found[0].environment, "staging",
"the first entry's environment wins"
);
assert_eq!(
found[0].references,
BTreeSet::from(["FIRST".to_string()]),
"only the first entry's references are read"
);
}
#[test]
fn two_apps_naming_one_key_both_appear_under_it() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
let mut catcher = AppConfig::minimal("catcher", "./srv");
catcher
.env
.insert("A".into(), "{{secret:SENTRY_DSN}}".into());
let mut web = AppConfig::minimal("web", "./srv");
web.env.insert("B".into(), "{{secret:SENTRY_DSN}}".into());
write_roll(&paths, &[catcher, web]);
let roll = read_roll(&paths);
let map = by_reference(&paths, roll.as_ref(), &[online("catcher"), stopped("web")]);
let readers = map.get("SENTRY_DSN").expect("the key has readers");
assert_eq!(readers.len(), 2);
assert!(readers.iter().any(|r| r.name == "catcher" && r.online));
assert!(readers.iter().any(|r| r.name == "web" && !r.online));
}
#[test]
fn readers_are_in_name_order_so_the_panel_does_not_reshuffle() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
let mut zeta = AppConfig::minimal("zeta", "./srv");
zeta.env.insert("A".into(), "{{secret:K}}".into());
let mut alpha = AppConfig::minimal("alpha", "./srv");
alpha.env.insert("A".into(), "{{secret:K}}".into());
write_roll(&paths, &[zeta, alpha]);
let roll = read_roll(&paths);
let map = by_reference(&paths, roll.as_ref(), &[online("zeta"), online("alpha")]);
let names: Vec<&str> = map["K"].iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, ["alpha", "zeta"]);
}
#[test]
fn each_readers_environment_is_its_own_apps_not_the_others() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
let mut pinned = AppConfig::minimal("pinned", "./srv");
pinned.environment = Some("staging".into());
pinned.env.insert("A".into(), "{{secret:K}}".into());
let mut floating = AppConfig::minimal("floating", "./srv");
floating.env.insert("A".into(), "{{secret:K}}".into());
write_roll(&paths, &[pinned, floating]);
let roll = read_roll(&paths);
let map = by_reference(
&paths,
roll.as_ref(),
&[online("pinned"), online("floating")],
);
let readers = &map["K"];
let pinned = readers.iter().find(|r| r.name == "pinned").unwrap();
let floating = readers.iter().find(|r| r.name == "floating").unwrap();
assert_eq!(pinned.environment, "staging");
assert_eq!(
floating.environment,
daemon_config(&paths).daemon.environment,
"each reader keeps its own environment, not its neighbor's"
);
}
#[test]
fn a_roll_stamped_in_the_future_is_zero_old() {
let mut roll = FlockSnapshot::with_apps(Vec::new());
roll.saved_at_ms = u64::MAX;
assert_eq!(roll_age(&roll), Duration::ZERO);
}
#[test]
fn a_roll_nothing_has_written_is_as_old_as_the_clock() {
let roll = FlockSnapshot::with_apps(Vec::new());
assert_eq!(roll.saved_at_ms, 0, "with_apps leaves it unwritten");
assert!(
roll_age(&roll) > Duration::from_secs(50 * 365 * 24 * 60 * 60),
"an epoch-stamped roll is decades old, not moments"
);
}
}