use std::collections::BTreeSet;
use shep_client::Client;
use shep_core::config::{DaemonConfig, ResolvedApp};
use shep_core::paths::ShepPaths;
use shep_core::protocol::{DogSource, ProcessInfo, Request, Response};
use shep_core::status::ProcStatus;
use shep_daemon::dogs::dog_app;
use crate::commands::daemon::dog_spec;
pub(crate) const DOG_ENTRY_KEPT_NOTICE: &str = "dog_entry_kept";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct KeptEntry {
pub(crate) name: String,
pub(crate) path: bool,
pub(crate) env: bool,
}
pub(crate) async fn kept_entries(client: &Client, paths: &ShepPaths) -> Vec<KeptEntry> {
if current_entries(paths).is_empty() {
return Vec::new();
}
let Ok(flock) = client.list_flock().await else {
return Vec::new();
};
kept_entries_among(client, paths, &flock).await
}
pub(crate) async fn kept_entries_among(
client: &Client,
paths: &ShepPaths,
rows: &[ProcessInfo],
) -> Vec<KeptEntry> {
let running: BTreeSet<&str> = rows
.iter()
.filter(|info| matches!(info.dog, Some(DogSource::Adopted { .. })))
.filter(|info| !matches!(info.status, ProcStatus::Stopped | ProcStatus::Errored))
.map(|info| info.name.as_str())
.collect();
let apps: Vec<_> = current_entries(paths)
.into_iter()
.filter(|app| running.contains(app.config().name.as_str()))
.map(ResolvedApp::into_config)
.collect();
if apps.is_empty() {
return Vec::new();
}
let Ok(Response::Drifted(drifted)) = client.request(Request::ConfigDrift { apps }).await else {
return Vec::new();
};
drifted
.into_iter()
.map(|drift| KeptEntry {
path: drift.fields.iter().any(|field| field == "script"),
env: drift.fields.iter().any(|field| field == "env"),
name: drift.name,
})
.filter(|kept| kept.path || kept.env)
.collect()
}
fn current_entries(paths: &ShepPaths) -> Vec<ResolvedApp> {
let Ok(source) = std::fs::read_to_string(&paths.daemon_config) else {
return Vec::new();
};
let Ok(config) = DaemonConfig::load(Some(&source), &|_| None) else {
return Vec::new();
};
config
.daemon
.adopted_dogs
.keys()
.filter_map(|name| dog_app(&dog_spec(&config, name), paths).ok())
.collect()
}
pub(crate) fn kept_entry_report(kept: &KeptEntry) -> String {
let name = &kept.name;
let fields = match (kept.path, kept.env) {
(true, true) => "`path` and `env`",
(true, false) => "`path`",
_ => "`env`",
};
format!(
"the `{name}` dog is running with the {fields} it was spawned with, and `shep.toml` now \
holds a different entry; it keeps the old one until `shep disable {name}` then \
`shep enable {name}`"
)
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use shep_core::config::AppConfig;
use shep_core::protocol::SheepDrift;
pub(crate) fn shepherd_running(
spawned: AppConfig,
status: ProcStatus,
) -> impl Fn(&Request) -> Response {
move |req| match req {
Request::ListFlock => Response::Flock(vec![
ProcessInfo::builder(1, "kelpie", status)
.dog(Some(DogSource::Adopted {
path: spawned.script.clone(),
channel: false,
grants: None,
events: Vec::new(),
}))
.build(),
]),
Request::ConfigDrift { apps } => Response::Drifted(
apps.iter()
.filter(|app| app.name == spawned.name)
.filter_map(|app| {
let fields = spawned.drifted_fields(app);
(!fields.is_empty()).then(|| SheepDrift::new(&app.name, fields))
})
.collect(),
),
_ => Response::Flock(Vec::new()),
}
}
pub(crate) fn home_and_spawned(
dir: &std::path::Path,
entry_env: &str,
spawned_env: &[(&str, &str)],
) -> (ShepPaths, AppConfig) {
let paths = ShepPaths::resolve(&|_| None, dir);
std::fs::create_dir_all(paths.daemon_config.parent().unwrap()).unwrap();
std::fs::write(
&paths.daemon_config,
format!(
"[daemon]\nenabled_dogs = [\"kelpie\"]\n\n[daemon.adopted_dogs.kelpie]\n\
path = \"/usr/local/bin/shep-kelpie\"\nenv = {{ {entry_env} }}\n"
),
)
.unwrap();
let spawned = dog_app(
&shep_daemon::dogs::DogSpec {
name: "kelpie".to_string(),
source: DogSource::Adopted {
path: "/usr/local/bin/shep-kelpie".to_string(),
channel: false,
grants: None,
events: Vec::new(),
},
env: spawned_env
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
},
&paths,
)
.unwrap()
.into_config();
(paths, spawned)
}
}
#[cfg(test)]
mod tests {
use super::test_support::{home_and_spawned, shepherd_running};
use super::*;
use shep_core::config::AppConfig;
async fn ask(
paths: &ShepPaths,
dir: &std::path::Path,
spawned: AppConfig,
status: ProcStatus,
) -> Vec<KeptEntry> {
let addr = shep_client::testing::control_address(dir);
let (client, _envelopes) =
shep_client::testing::fake_client_answering(&addr, shepherd_running(spawned, status))
.await;
kept_entries(&client, paths).await
}
#[tokio::test]
async fn an_env_that_gained_a_key_since_the_spawn_is_kept_and_named() {
let dir = tempfile::tempdir().unwrap();
let (paths, spawned) =
home_and_spawned(dir.path(), "PADDOCK_KEY = \"{{secret:paddock-key}}\"", &[]);
let kept = ask(&paths, dir.path(), spawned, ProcStatus::Online).await;
assert_eq!(
kept,
vec![KeptEntry {
name: "kelpie".to_string(),
path: false,
env: true,
}]
);
}
#[tokio::test]
async fn an_entry_unchanged_since_the_spawn_is_not_kept() {
let dir = tempfile::tempdir().unwrap();
let (paths, spawned) = home_and_spawned(
dir.path(),
"PADDOCK_KEY = \"{{secret:paddock-key}}\"",
&[("PADDOCK_KEY", "{{secret:paddock-key}}")],
);
let kept = ask(&paths, dir.path(), spawned, ProcStatus::Online).await;
assert_eq!(kept, Vec::new());
}
#[tokio::test]
async fn a_dog_at_rest_holds_no_entry_anyone_is_waiting_on() {
let dir = tempfile::tempdir().unwrap();
let (paths, spawned) = home_and_spawned(dir.path(), "K = \"v\"", &[]);
let kept = ask(&paths, dir.path(), spawned, ProcStatus::Stopped).await;
assert_eq!(kept, Vec::new());
}
#[tokio::test]
async fn rows_in_hand_are_judged_without_listing_the_flock() {
let dir = tempfile::tempdir().unwrap();
let (paths, spawned) = home_and_spawned(dir.path(), "K = \"v\"", &[]);
let addr = shep_client::testing::control_address(dir.path());
let rows = [ProcessInfo::builder(1, "kelpie", ProcStatus::Online)
.dog(Some(DogSource::Adopted {
path: spawned.script.clone(),
channel: false,
grants: None,
events: Vec::new(),
}))
.build()];
let (client, mut envelopes) = shep_client::testing::fake_client_answering(
&addr,
shepherd_running(spawned, ProcStatus::Online),
)
.await;
let kept = kept_entries_among(&client, &paths, &rows).await;
assert_eq!(kept.len(), 1);
assert!(matches!(
envelopes.recv().await.unwrap().body,
Request::ConfigDrift { .. }
));
assert!(envelopes.try_recv().is_err(), "no flock was listed");
}
#[tokio::test]
async fn a_home_with_no_adopted_dog_asks_the_shepherd_nothing() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let addr = shep_client::testing::control_address(dir.path());
let (client, mut envelopes) =
shep_client::testing::fake_client_answering(&addr, |_| Response::Pong).await;
let kept = kept_entries(&client, &paths).await;
assert_eq!(kept, Vec::new());
assert!(
envelopes.try_recv().is_err(),
"nothing to compare, nothing to ask"
);
}
#[test]
fn the_report_names_the_dog_and_the_remedy_and_never_a_value() {
let kept = |path, env| KeptEntry {
name: "kelpie".to_string(),
path,
env,
};
assert_eq!(
kept_entry_report(&kept(false, true)),
"the `kelpie` dog is running with the `env` it was spawned with, and `shep.toml` \
now holds a different entry; it keeps the old one until `shep disable kelpie` \
then `shep enable kelpie`"
);
assert!(kept_entry_report(&kept(true, false)).contains("with the `path` it was"));
assert!(kept_entry_report(&kept(true, true)).contains("with the `path` and `env` it was"));
}
}