use std::path::PathBuf;
use shep_client::{
Client, LinkState, ReconnectingClient, RequestError,
shep_core::config::AppConfig,
shep_core::protocol::{ProcessInfo, Request, Response, SelectorSpec, Smit},
};
use crate::error::Error;
pub trait Daemon {
async fn dog_config(&self, name: &str) -> Result<String, Error>;
async fn list_flock(&self) -> Result<Vec<ProcessInfo>, Error>;
async fn describe(&self, sheep: &str) -> Result<Vec<ProcessInfo>, Error>;
async fn start(&self, apps: Vec<AppConfig>) -> Result<(), Error>;
async fn delete(&self, id: u32) -> Result<(), Error>;
async fn reload(&self, sheep: &str) -> Result<(), Error>;
#[expect(dead_code)]
async fn restart(&self, sheep: &str) -> Result<(), Error>;
async fn save_roll(&self) -> Result<PathBuf, Error>;
async fn set_smit(&self, sheep: &str, text: &str) -> Result<(), Error>;
}
#[derive(Debug)]
pub struct Live(Link);
#[derive(Debug)]
enum Link {
Dog(ReconnectingClient),
Command(Client),
}
impl Live {
#[must_use]
pub const fn dog(client: ReconnectingClient) -> Self {
Self(Link::Dog(client))
}
#[must_use]
pub const fn command(client: Client) -> Self {
Self(Link::Command(client))
}
#[must_use]
pub fn link(&self) -> Option<LinkState> {
match &self.0 {
Link::Dog(client) => Some(client.link()),
Link::Command(_) => None,
}
}
}
impl Link {
async fn request(&self, body: Request) -> Result<Response, RequestError> {
match self {
Self::Dog(client) => client.request(body).await,
Self::Command(client) => client.request(body).await,
}
}
}
impl Daemon for Live {
async fn dog_config(&self, name: &str) -> Result<String, Error> {
let asked = Request::DogConfig {
name: name.to_owned(),
};
match self.0.request(asked).await? {
Response::DogSection { toml } => Ok(toml.as_str().to_owned()),
other => Err(unexpected("DogConfig", &other)),
}
}
async fn list_flock(&self) -> Result<Vec<ProcessInfo>, Error> {
match self.0.request(Request::ListFlock).await? {
Response::Flock(flock) => Ok(flock),
other => Err(unexpected("ListFlock", &other)),
}
}
async fn describe(&self, sheep: &str) -> Result<Vec<ProcessInfo>, Error> {
let asked = Request::Describe {
selector: SelectorSpec::Name(sheep.to_owned()),
};
match self.0.request(asked).await? {
Response::Described(flock) => Ok(flock),
other => Err(unexpected("Describe", &other)),
}
}
async fn start(&self, apps: Vec<AppConfig>) -> Result<(), Error> {
match self.0.request(Request::Start { apps }).await? {
Response::Started(_) => Ok(()),
other => Err(unexpected("Start", &other)),
}
}
async fn delete(&self, id: u32) -> Result<(), Error> {
let asked = Request::Delete {
selector: SelectorSpec::Id(id),
};
match self.0.request(asked).await? {
Response::Deleted(_) => Ok(()),
other => Err(unexpected("Delete", &other)),
}
}
async fn reload(&self, sheep: &str) -> Result<(), Error> {
let asked = Request::Reload {
selector: SelectorSpec::Name(sheep.to_owned()),
};
match self.0.request(asked).await? {
Response::Reloading(_) => Ok(()),
other => Err(unexpected("Reload", &other)),
}
}
async fn restart(&self, sheep: &str) -> Result<(), Error> {
let asked = Request::Restart {
selector: SelectorSpec::Name(sheep.to_owned()),
};
match self.0.request(asked).await? {
Response::Restarted(_) => Ok(()),
other => Err(unexpected("Restart", &other)),
}
}
async fn save_roll(&self) -> Result<PathBuf, Error> {
match self.0.request(Request::SaveRoll).await? {
Response::RollSaved { path, .. } => Ok(PathBuf::from(path)),
other => Err(unexpected("SaveRoll", &other)),
}
}
async fn set_smit(&self, sheep: &str, text: &str) -> Result<(), Error> {
let smit: Smit = text.parse().map_err(Error::Smit)?;
let asked = Request::SetSmit {
sheep: sheep.to_owned(),
smit: Some(smit),
};
match self.0.request(asked).await? {
Response::SmitPainted(_) => Ok(()),
other => Err(unexpected("SetSmit", &other)),
}
}
}
fn unexpected(asked: &str, got: &Response) -> Error {
Error::Protocol(format!("{} in answer to {asked}", named(got)))
}
fn named(response: &Response) -> String {
match response {
Response::Flock(flock) => format!("a Flock of {}", flock.len()),
Response::Described(flock) => format!("a Described of {}", flock.len()),
Response::Started(flock) => format!("a Started of {}", flock.len()),
Response::Deleted(ids) => format!("a Deleted of {}", ids.len()),
Response::Reloading(flock) => format!("a Reloading of {}", flock.len()),
Response::Restarted(flock) => format!("a Restarted of {}", flock.len()),
Response::DogSection { .. } => "a DogSection".to_owned(),
Response::RollSaved { .. } => "a RollSaved".to_owned(),
Response::SmitPainted(flock) => format!("a SmitPainted of {}", flock.len()),
other => format!("{other:?}").chars().take(60).collect(),
}
}
pub async fn adopted_name<D: Daemon>(daemon: &D) -> Option<String> {
let me = std::process::id();
daemon
.list_flock()
.await
.ok()?
.into_iter()
.find(|info| info.dog.is_some() && info.pid == Some(me))
.map(|info| info.name)
}
#[cfg(test)]
mod tests {
use super::*;
use shep_client::shep_core::protocol::DogSource;
use shep_client::shep_core::status::ProcStatus;
fn sheep_named(name: &str, pid: Option<u32>) -> ProcessInfo {
ProcessInfo::builder(0, name, ProcStatus::Online)
.pid(pid)
.build()
}
fn dog_named(name: &str, pid: Option<u32>) -> ProcessInfo {
ProcessInfo::builder(0, name, ProcStatus::Online)
.pid(pid)
.dog(Some(DogSource::Adopted {
path: "/usr/local/bin/shep-deploy".to_owned(),
}))
.build()
}
struct Flock(Vec<ProcessInfo>);
impl Daemon for Flock {
async fn dog_config(&self, _name: &str) -> Result<String, Error> {
unimplemented!()
}
async fn list_flock(&self) -> Result<Vec<ProcessInfo>, Error> {
Ok(self.0.clone())
}
async fn describe(&self, _sheep: &str) -> Result<Vec<ProcessInfo>, Error> {
unimplemented!()
}
async fn start(&self, _apps: Vec<AppConfig>) -> Result<(), Error> {
unimplemented!()
}
async fn delete(&self, _id: u32) -> Result<(), Error> {
unimplemented!()
}
async fn reload(&self, _sheep: &str) -> Result<(), Error> {
unimplemented!()
}
async fn restart(&self, _sheep: &str) -> Result<(), Error> {
unimplemented!()
}
async fn save_roll(&self) -> Result<PathBuf, Error> {
unimplemented!()
}
async fn set_smit(&self, _sheep: &str, _text: &str) -> Result<(), Error> {
unimplemented!()
}
}
struct Unreachable;
impl Daemon for Unreachable {
async fn dog_config(&self, _name: &str) -> Result<String, Error> {
Err(Error::Protocol("no session".to_owned()))
}
async fn list_flock(&self) -> Result<Vec<ProcessInfo>, Error> {
Err(Error::Protocol("no session".to_owned()))
}
async fn describe(&self, _sheep: &str) -> Result<Vec<ProcessInfo>, Error> {
Err(Error::Protocol("no session".to_owned()))
}
async fn start(&self, _apps: Vec<AppConfig>) -> Result<(), Error> {
Err(Error::Protocol("no session".to_owned()))
}
async fn delete(&self, _id: u32) -> Result<(), Error> {
Err(Error::Protocol("no session".to_owned()))
}
async fn reload(&self, _sheep: &str) -> Result<(), Error> {
Err(Error::Protocol("no session".to_owned()))
}
async fn restart(&self, _sheep: &str) -> Result<(), Error> {
Err(Error::Protocol("no session".to_owned()))
}
async fn save_roll(&self) -> Result<PathBuf, Error> {
Err(Error::Protocol("no session".to_owned()))
}
async fn set_smit(&self, _sheep: &str, _text: &str) -> Result<(), Error> {
Err(Error::Protocol("no session".to_owned()))
}
}
#[test]
fn a_dog_section_is_named_and_never_printed() {
let secret = "url = \"https://hooks.example.com/T00/B11/xoxb-not-a-real-token\"\n";
let response = Response::DogSection {
toml: secret.to_owned().into(),
};
assert_eq!(named(&response), "a DogSection");
}
#[test]
fn a_roll_saved_is_named_and_never_printed() {
let response = Response::RollSaved {
path: "/srv/shep/flock.json".to_owned(),
apps: 3,
};
assert_eq!(named(&response), "a RollSaved");
}
#[test]
fn a_listing_is_named_with_its_length() {
let flock = vec![sheep_named("web", None), sheep_named("api", None)];
assert_eq!(named(&Response::Flock(flock.clone())), "a Flock of 2");
assert_eq!(
named(&Response::Described(flock.clone())),
"a Described of 2"
);
assert_eq!(named(&Response::Started(flock.clone())), "a Started of 2");
assert_eq!(
named(&Response::Reloading(flock.clone())),
"a Reloading of 2"
);
assert_eq!(named(&Response::Restarted(flock)), "a Restarted of 2");
assert_eq!(named(&Response::Deleted(vec![7, 8])), "a Deleted of 2");
assert_eq!(named(&Response::Flock(Vec::new())), "a Flock of 0");
}
#[test]
fn an_unknown_response_is_named_from_debug_and_truncated() {
let response = Response::DogStarted(sheep_named("metrics", Some(1)));
let shown = named(&response);
assert!(shown.starts_with("DogStarted"), "{shown}");
assert!(shown.chars().count() <= 60, "{shown}");
}
#[test]
fn an_unexpected_answer_names_the_request_it_answered() {
let err = unexpected("Reload", &Response::Flock(Vec::new()));
let shown = err.to_string();
assert!(shown.contains("in answer to Reload"), "{shown}");
assert!(shown.contains("a Flock of 0"), "{shown}");
}
#[tokio::test]
async fn the_dog_finds_its_own_name_by_pid() {
let me = std::process::id();
let flock = Flock(vec![
sheep_named("web", Some(me + 1)),
dog_named("deploy", Some(me)),
]);
assert_eq!(adopted_name(&flock).await.as_deref(), Some("deploy"));
}
#[tokio::test]
async fn a_sheep_sharing_the_pid_is_not_the_dog() {
let me = std::process::id();
let flock = Flock(vec![sheep_named("web", Some(me))]);
assert_eq!(adopted_name(&flock).await, None);
}
#[tokio::test]
async fn a_dog_at_another_pid_is_not_this_dog() {
let flock = Flock(vec![dog_named("metrics", Some(std::process::id() + 1))]);
assert_eq!(adopted_name(&flock).await, None);
}
#[tokio::test]
async fn a_shepherd_that_will_not_answer_yields_no_name() {
assert_eq!(adopted_name(&Unreachable).await, None);
}
}