use std::any::Any;
use std::collections::BTreeMap;
use std::future::Future;
use std::io::{self, Write};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::Path;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::time::sleep;
use crate::config::{DogConfig, Reader};
use crate::daemon::Daemon;
use crate::deploy::{self, Outcome};
use crate::error::Error;
use crate::paths::{self, Tree};
use crate::smit;
use crate::state::{State, Watch};
const fn due(state: &State) -> bool {
matches!(state.watch, Watch::Auto)
}
async fn tick<D: Daemon>(daemon: &D, shep_home: &Path, config: &DogConfig) -> Tick {
let found = match paths::targets(shep_home) {
Ok(found) => found,
Err(err) => {
return Tick {
targets: None,
results: vec![(shep_home.join("deploy").display().to_string(), Err(err))],
};
}
};
let mut results = Vec::new();
let targets = Some(found.named.len() + found.unnamed.len());
for dir in found.unnamed {
results.push((
format!("{dir:?}"),
Err(Error::Config(
"holds a deploy.toml, and its name cannot be a sheep's (not valid UTF-8, or \
carrying a character that would rewrite a log line), so it is neither polled \
nor restored. `shep flock` lists every sheep and `shep describe <sheep>` its \
cwd: check none runs from inside it, then rename the directory"
.to_owned(),
)),
));
}
for name in found.named {
let tree = Tree::for_sheep(shep_home, &name);
let mut state = match State::read(&tree.state_file()) {
Ok(state) => state,
Err(err) => {
results.push((name, Err(err)));
continue;
}
};
if let Err(err) = smit::publish(daemon, &name, &state).await {
results.push((format!("{name}/smit"), Err(err)));
}
if !due(&state) {
continue;
}
let deploy = Box::pin(deploy::unattended(daemon, &tree, &mut state, config));
let outcome = match CatchUnwind(deploy).await {
Ok(outcome) => outcome,
Err(payload) => Err(Error::Panicked {
sheep: name.clone(),
what: panic_message(payload.as_ref()),
}),
};
results.push((name, outcome));
}
Tick { targets, results }
}
struct Tick {
targets: Option<usize>,
results: Vec<(String, Result<Outcome, Error>)>,
}
struct CatchUnwind<F>(Pin<Box<F>>);
impl<F: Future> Future for CatchUnwind<F> {
type Output = Result<F::Output, Box<dyn Any + Send>>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let inner = &mut self.get_mut().0;
match catch_unwind(AssertUnwindSafe(|| inner.as_mut().poll(cx))) {
Ok(Poll::Pending) => Poll::Pending,
Ok(Poll::Ready(output)) => Poll::Ready(Ok(output)),
Err(payload) => Poll::Ready(Err(payload)),
}
}
}
fn panic_message(payload: &(dyn Any + Send)) -> String {
if let Some(text) = payload.downcast_ref::<&str>() {
(*text).to_owned()
} else if let Some(text) = payload.downcast_ref::<String>() {
text.clone()
} else {
"a panic with no message".to_owned()
}
}
#[derive(Debug, PartialEq, Eq)]
enum Said {
Note(String),
Complaint(String),
}
impl Said {
fn text(&self) -> &str {
match self {
Self::Note(text) | Self::Complaint(text) => text,
}
}
}
fn report(sheep: &str, outcome: &Result<Outcome, Error>) -> Option<Said> {
let sheep = &crate::shared::printable(sheep);
match outcome {
Ok(Outcome::UpToDate) => None,
Ok(Outcome::Deployed { sha }) => Some(Said::Note(format!("{sheep} deployed {sha}"))),
Ok(Outcome::RolledBack { to, why }) => Some(Said::Complaint(format!(
"{sheep} rolled back to {to}: {why}"
))),
Err(err) => Some(Said::Complaint(format!("{sheep}: {err}"))),
}
}
const RESAY: u32 = 120;
struct Repeat {
line: String,
muted: u32,
}
fn worth_saying(previous: &mut BTreeMap<String, Repeat>, sheep: &str, line: &str) -> Option<u32> {
let mut swallowed = 0;
if let Some(seen) = previous.get_mut(sheep)
&& seen.line == line
{
seen.muted += 1;
if seen.muted < RESAY {
return None;
}
swallowed = seen.muted - 1;
}
previous.insert(
sheep.to_owned(),
Repeat {
line: line.to_owned(),
muted: 0,
},
);
Some(swallowed)
}
pub async fn run<D: Daemon>(daemon: &D, shep_home: &Path, config: Reader) -> Result<(), Error> {
run_with(
daemon,
shep_home,
config,
&mut io::stdout(),
&mut io::stderr(),
)
.await
}
async fn run_with<D: Daemon, O: Write, E: Write>(
daemon: &D,
shep_home: &Path,
mut config: Reader,
out: &mut O,
err: &mut E,
) -> Result<(), Error> {
let mut previous: BTreeMap<String, Repeat> = BTreeMap::new();
let deploy_dir = shep_home.join("deploy").display().to_string();
let dogs_file = shep_home.join("dogs.toml").display().to_string();
loop {
let stale = config.refresh(daemon).await;
let Tick {
targets,
mut results,
} = tick(daemon, shep_home, config.current()).await;
if let Some(why) = stale {
results.push((dogs_file.clone(), Err(stale_section(&why))));
}
if targets == Some(0) {
results.push((
deploy_dir.clone(),
Err(Error::Config(format!(
"no deploy targets under {deploy_dir}: nothing to poll until one is set up \
with `shep-deploy setup <sheep>`"
))),
));
}
previous.retain(|name, _| results.iter().any(|(seen, _)| seen == name));
for (sheep, outcome) in results {
let Some(said) = report(&sheep, &outcome) else {
previous.remove(&sheep);
continue;
};
let Some(swallowed) = worth_saying(&mut previous, &sheep, said.text()) else {
continue;
};
let repeats = if swallowed == 0 {
String::new()
} else {
format!(" (repeated {swallowed} times since it was last said)")
};
let _ = match &said {
Said::Note(text) => writeln!(out, "{text}{repeats}"),
Said::Complaint(text) => writeln!(err, "{text}{repeats}"),
};
}
sleep(config.current().interval).await;
}
}
fn stale_section(why: &Error) -> Error {
let what = match why {
Error::Config(what) => what.clone(),
other => other.to_string(),
};
Error::Config(format!(
"{what}. The dog is still polling on the section it last read, so it is not yet \
running what this file now says"
))
}
#[cfg(test)]
mod tests {
use crate::fixtures;
use super::*;
#[tokio::test]
async fn an_unnameable_target_is_a_row_that_cannot_forge_a_line() {
let home = tempfile::tempdir().expect("tempdir");
let dir = home.path().join("deploy").join("bad\nname");
fs::create_dir_all(&dir).expect("dir");
fs::write(dir.join("deploy.toml"), "").expect("record");
let results = tick(&Ready::new(), home.path(), &fixtures::dog_config())
.await
.results;
assert_eq!(results.len(), 1, "{results:?}");
let (key, outcome) = &results[0];
assert_eq!(key, &format!("{dir:?}"));
let said = report(key, outcome).expect("a complaint");
assert!(!said.text().contains('\n'), "{:?}", said.text());
assert!(
said.text().contains("rename the directory"),
"{}",
said.text()
);
}
#[tokio::test]
async fn a_panic_becomes_an_err_rather_than_ending_the_loop() {
let caught = CatchUnwind(Box::pin(async {
tokio::task::yield_now().await;
panic!("the deploy fell over");
}))
.await;
let payload = caught.expect_err("the panic must be caught");
assert_eq!(panic_message(payload.as_ref()), "the deploy fell over");
let fine = CatchUnwind(Box::pin(async { 7 })).await;
assert_eq!(fine.ok(), Some(7));
}
#[test]
fn a_panic_message_is_read_from_either_payload_shape() {
assert_eq!(panic_message(&"literal"), "literal");
assert_eq!(panic_message(&String::from("formatted")), "formatted");
assert_eq!(panic_message(&42_u8), "a panic with no message");
}
use core::time::Duration;
use std::cell::{Cell, RefCell};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use shep_client::RequestError;
use shep_client::shep_core::config::AppConfig;
use shep_client::shep_core::protocol::{ProcessInfo, RpcError, RpcErrorCode};
use shep_client::shep_core::status::ProcStatus;
use tempfile::TempDir;
use crate::state::Verify;
use crate::swap;
fn flockfile(sheep: &str) -> String {
format!(
"[[app]]\nname = '{sheep}'\nscript = './run.sh'\n\n\
[app.readiness_probe]\nkind = 'http'\ntarget = 'http://127.0.0.1:1/health'\n"
)
}
fn target(watch: Watch) -> State {
State {
deployed: Some(fixtures::OTHER_SHA.to_owned()),
watch,
..fixtures::state()
}
}
fn write_target(home: &Path, sheep: &str, watch: Watch, sha: Option<&str>) {
let tree = Tree::for_sheep(home, sheep);
fs::create_dir_all(tree.root()).expect("create the tree");
let mut state = target(watch);
state.deployed = sha.map(str::to_owned);
state.write(&tree.state_file()).expect("write deploy.toml");
}
fn write_target_ready(home: &Path, sheep: &str, watch: Watch) -> TempDir {
let origin = fixtures::checkout(&[("Flockfile.toml", &flockfile(sheep))]);
let tree = Tree::for_sheep(home, sheep);
fs::create_dir_all(tree.git()).expect("create the git dir");
fixtures::run_git(&tree.git(), &["init", "-q", "--bare"]);
let remote = origin.path().to_str().expect("utf-8 path").to_owned();
crate::git::fetch(&tree.git(), &remote, fixtures::TEST_BUDGET).expect("fetch");
let first = crate::git::remote_head(&tree.git(), "main").expect("head");
crate::git::worktree_add(&tree.git(), &tree.release(&first), &first).expect("worktree");
swap::point_at(&tree.current(), &tree.release(&first)).expect("swap");
let state = State {
remote,
deployed: Some(first),
verify: Verify::Probed,
watch,
checkout: origin.path().to_owned(),
..fixtures::state()
};
state.write(&tree.state_file()).expect("write deploy.toml");
fs::write(origin.path().join("second.txt"), "x").expect("write");
fixtures::run_git(origin.path(), &["add", "."]);
fixtures::run_git(origin.path(), &["commit", "-q", "-m", "second"]);
origin
}
const FIRST_PID: u32 = 12835;
struct Ready {
reloads: Cell<u32>,
}
impl Ready {
const fn new() -> Self {
Self {
reloads: Cell::new(0),
}
}
}
impl Daemon for Ready {
async fn describe(&self, sheep: &str) -> Result<Vec<ProcessInfo>, Error> {
Ok(vec![
ProcessInfo::builder(0, sheep, ProcStatus::Online)
.pid(Some(FIRST_PID + self.reloads.get() * 100))
.build(),
])
}
async fn reload(&self, _sheep: &str) -> Result<(), Error> {
self.reloads.set(self.reloads.get() + 1);
Ok(())
}
async fn set_smit(&self, _sheep: &str, _text: &str) -> Result<(), Error> {
Ok(())
}
crate::fixtures::daemon_methods!(unimplemented;
dog_config, list_flock, start, delete, restart, save_roll,
);
}
struct Counting {
describes: Cell<u32>,
origin: PathBuf,
}
impl Counting {
fn new(origin: &Path) -> Self {
Self {
describes: Cell::new(0),
origin: origin.to_owned(),
}
}
fn ticks(&self) -> u32 {
self.describes.get()
}
}
impl Daemon for Counting {
async fn describe(&self, _sheep: &str) -> Result<Vec<ProcessInfo>, Error> {
let asked = self.describes.get() + 1;
self.describes.set(asked);
assert!(
asked <= 60,
"the loop ticked far more than the interval allows: it is not sleeping"
);
fs::write(self.origin.join(format!("{asked}.txt")), "x").expect("write");
fixtures::run_git(&self.origin, &["add", "."]);
fixtures::run_git(&self.origin, &["commit", "-q", "-m", "another"]);
Err(Error::Protocol("the shepherd stopped answering".to_owned()))
}
async fn set_smit(&self, _sheep: &str, _text: &str) -> Result<(), Error> {
Ok(())
}
crate::fixtures::daemon_methods!(unimplemented;
dog_config, list_flock, start, delete, reload, restart, save_roll,
);
}
#[tokio::test]
async fn a_manual_target_is_never_polled() {
assert!(!due(&target(Watch::Manual)));
assert!(due(&target(Watch::Auto)));
}
#[tokio::test(start_paused = true)]
async fn a_manual_target_is_left_alone_by_a_whole_tick() {
let home = tempfile::tempdir().expect("tempdir");
let _origin = write_target_ready(home.path(), "paused", Watch::Manual);
let before = State::read(&Tree::for_sheep(home.path(), "paused").state_file())
.expect("reads")
.deployed;
assert!(
tick(&Ready::new(), home.path(), &fixtures::dog_config())
.await
.results
.is_empty()
);
assert_eq!(
State::read(&Tree::for_sheep(home.path(), "paused").state_file())
.expect("reads")
.deployed,
before,
"still on the release it was on"
);
}
#[tokio::test(start_paused = true)]
async fn one_targets_failure_does_not_stop_the_others() {
let home = tempfile::tempdir().expect("tempdir");
write_target(home.path(), "broken", Watch::Auto, Some(fixtures::SHA));
let _origin = write_target_ready(home.path(), "fine", Watch::Auto);
let results = tick(&Ready::new(), home.path(), &fixtures::dog_config())
.await
.results;
assert_eq!(results.len(), 2);
assert_eq!(results[0].0, "broken");
assert!(results[0].1.is_err(), "broken failed");
assert_eq!(results[1].0, "fine");
assert!(
matches!(results[1].1, Ok(Outcome::Deployed { .. })),
"fine still ran: {:?}",
results[1].1
);
}
#[tokio::test(start_paused = true)]
async fn a_tick_holds_a_sha_that_already_failed() {
let home = tempfile::tempdir().expect("tempdir");
let origin = write_target_ready(home.path(), "held", Watch::Auto);
let tree = Tree::for_sheep(home.path(), "held");
let mut state = State::read(&tree.state_file()).expect("reads");
state.failed = Some(fixtures::head_of(origin.path()));
state.write(&tree.state_file()).expect("writes");
let daemon = Ready::new();
let results = tick(&daemon, home.path(), &fixtures::dog_config())
.await
.results;
assert_eq!(results.len(), 1);
assert!(
matches!(results[0].1, Err(Error::Held { .. })),
"{:?}",
results[0].1
);
assert_eq!(daemon.reloads.get(), 0, "nothing was reloaded");
}
#[tokio::test]
async fn a_dog_with_no_targets_ticks_quietly() {
let home = tempfile::tempdir().expect("tempdir");
assert!(
tick(&Ready::new(), home.path(), &fixtures::dog_config())
.await
.results
.is_empty()
);
}
#[tokio::test]
async fn a_deploy_directory_that_cannot_be_listed_is_reported() {
let home = tempfile::tempdir().expect("tempdir");
let root = home.path().join("deploy");
fs::create_dir_all(&root).expect("create the deploy dir");
fs::set_permissions(&root, fs::Permissions::from_mode(0o000)).expect("chmod");
let results = tick(&Ready::new(), home.path(), &fixtures::dog_config())
.await
.results;
let listable = fs::set_permissions(&root, fs::Permissions::from_mode(0o700));
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, root.display().to_string());
assert!(results[0].1.is_err());
listable.expect("chmod back");
}
#[tokio::test]
async fn a_record_that_cannot_be_read_is_reported_rather_than_skipped() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "garbled");
fs::create_dir_all(tree.root()).expect("create the tree");
fs::write(tree.state_file(), "this is not toml").expect("write deploy.toml");
let results = tick(&Ready::new(), home.path(), &fixtures::dog_config())
.await
.results;
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, "garbled");
assert!(results[0].1.is_err());
}
#[tokio::test(start_paused = true)]
async fn ticks_are_spaced_by_the_configured_interval() {
let home = tempfile::tempdir().expect("tempdir");
let origin = write_target_ready(home.path(), "quiet", Watch::Auto);
let counter = Counting::new(origin.path());
let began = tokio::time::Instant::now();
let _ = tokio::time::timeout(
Duration::from_secs(305),
run(
&counter,
home.path(),
Reader::primed(
None,
DogConfig {
interval: Duration::from_secs(150),
..fixtures::dog_config()
},
),
),
)
.await;
assert_eq!(counter.ticks(), 3, "one at t=0, then every 150s");
assert!(began.elapsed() >= Duration::from_secs(300));
}
#[tokio::test(start_paused = true)]
async fn the_first_tick_happens_at_once() {
let home = tempfile::tempdir().expect("tempdir");
let origin = write_target_ready(home.path(), "quiet", Watch::Auto);
let counter = Counting::new(origin.path());
let _ = tokio::time::timeout(
Duration::from_secs(1),
run(
&counter,
home.path(),
Reader::primed(
None,
DogConfig {
interval: Duration::from_secs(600),
..fixtures::dog_config()
},
),
),
)
.await;
assert_eq!(counter.ticks(), 1);
}
#[test]
fn a_line_that_repeats_is_said_once() {
let mut previous = BTreeMap::new();
assert!(worth_saying(&mut previous, "web", "web: the remote is gone").is_some());
assert!(worth_saying(&mut previous, "web", "web: the remote is gone").is_none());
assert!(worth_saying(&mut previous, "web", "web: the remote is gone").is_none());
assert!(worth_saying(&mut previous, "koji", "koji: the remote is gone").is_some());
}
#[test]
fn every_repeated_line_is_muted_not_one_chosen_kind() {
let mut previous = BTreeMap::new();
let lines = [
"web: bad deploy configuration: /srv/deploy/web/deploy.toml: expected an equals",
"web: `git fetch origin` exited with status 128: could not resolve host",
"web: the build command exited with status 1",
];
for line in lines {
assert!(worth_saying(&mut previous, "web", line).is_some(), "{line}");
assert!(worth_saying(&mut previous, "web", line).is_none(), "{line}");
}
}
#[test]
fn a_line_that_changes_is_said_again() {
let mut previous = BTreeMap::new();
let broken = "web: the remote is gone";
assert!(worth_saying(&mut previous, "web", broken).is_some());
assert!(worth_saying(&mut previous, "web", broken).is_none());
assert!(worth_saying(&mut previous, "web", "web deployed abc1234").is_some());
assert!(worth_saying(&mut previous, "web", broken).is_some());
}
#[test]
fn a_line_said_again_counts_only_what_was_swallowed() {
let mut previous = BTreeMap::new();
let line = "web: the remote is gone";
assert_eq!(worth_saying(&mut previous, "web", line), Some(0), "new");
for _ in 1..RESAY {
assert_eq!(worth_saying(&mut previous, "web", line), None);
}
assert_eq!(
worth_saying(&mut previous, "web", line),
Some(RESAY - 1),
"the one being said is not swallowed"
);
}
#[test]
fn a_muted_line_is_said_again_eventually() {
let mut previous = BTreeMap::new();
let line = "web: the remote is gone";
assert!(worth_saying(&mut previous, "web", line).is_some());
let said = (0..RESAY * 2)
.filter(|_| worth_saying(&mut previous, "web", line).is_some())
.count();
assert_eq!(said, 2, "once every RESAY ticks, not once ever");
}
#[test]
fn each_outcome_says_what_it_did_and_where() {
assert_eq!(report("web", &Ok(Outcome::UpToDate)), None, "the heartbeat");
assert_eq!(
report(
"web",
&Ok(Outcome::Deployed {
sha: "a1b2c3".to_owned()
})
),
Some(Said::Note("web deployed a1b2c3".to_owned()))
);
assert_eq!(
report(
"web",
&Ok(Outcome::RolledBack {
to: "old".to_owned(),
why: "it did not come up".to_owned()
})
),
Some(Said::Complaint(
"web rolled back to old: it did not come up".to_owned()
))
);
let err = report("web", &Err(Error::Build { status: Some(1) }));
let Some(Said::Complaint(text)) = err else {
panic!("a failure is a complaint: {err:?}");
};
assert!(text.starts_with("web: "), "{text}");
}
#[tokio::test(start_paused = true)]
async fn a_deploy_is_written_to_the_log_it_belongs_in() {
let home = tempfile::tempdir().expect("tempdir");
let origin = write_target_ready(home.path(), "fine", Watch::Auto);
let head = fixtures::head_of(origin.path());
let (mut out, mut err) = (Vec::new(), Vec::new());
let _ = tokio::time::timeout(
Duration::from_secs(120),
run_with(
&Ready::new(),
home.path(),
Reader::primed(
None,
DogConfig {
interval: Duration::from_secs(600),
..fixtures::dog_config()
},
),
&mut out,
&mut err,
),
)
.await;
assert_eq!(
String::from_utf8(out).expect("utf-8"),
format!("fine deployed {head}\n")
);
assert!(err.is_empty(), "nothing failed");
}
#[tokio::test(start_paused = true)]
async fn a_failure_is_written_once_however_many_ticks_repeat_it() {
let home = tempfile::tempdir().expect("tempdir");
write_target(home.path(), "broken", Watch::Auto, Some(fixtures::SHA));
let (mut out, mut err) = (Vec::new(), Vec::new());
let _ = tokio::time::timeout(
Duration::from_secs(305),
run_with(
&Ready::new(),
home.path(),
Reader::primed(
None,
DogConfig {
interval: Duration::from_secs(150),
..fixtures::dog_config()
},
),
&mut out,
&mut err,
),
)
.await;
let complained = String::from_utf8(err).expect("utf-8");
assert_eq!(complained.lines().count(), 1, "{complained}");
assert!(complained.starts_with("broken: "), "{complained}");
assert!(out.is_empty(), "nothing deployed");
}
const ADOPTED: &str = "deploy";
fn reading() -> Reader {
Reader::primed(Some(ADOPTED.to_owned()), fixtures::dog_config())
}
#[tokio::test(start_paused = true)]
async fn a_changed_interval_reaches_the_dog_without_a_restart() {
let home = fixtures::tempdir();
let daemon = fixtures::Sections::of("interval = \"600s\"", &["interval = \"60s\""]);
let (mut out, mut err) = (Vec::new(), Vec::new());
let _ = tokio::time::timeout(
Duration::from_secs(700),
run_with(&daemon, home.path(), reading(), &mut out, &mut err),
)
.await;
assert_eq!(daemon.asked(), 3, "t=0 at 600s, t=600 at 60s, t=660");
}
#[tokio::test(start_paused = true)]
async fn a_section_that_stops_parsing_keeps_the_one_that_worked() {
let home = fixtures::tempdir();
let daemon = fixtures::Sections::of("interval = \"60s\"", &["retention = 1"]);
let (mut out, mut err) = (Vec::new(), Vec::new());
let _ = tokio::time::timeout(
Duration::from_secs(200),
run_with(&daemon, home.path(), reading(), &mut out, &mut err),
)
.await;
assert_eq!(daemon.asked(), 4, "still a minute apart");
let complained = String::from_utf8(err).expect("utf-8");
let about_the_section: Vec<&str> = complained
.lines()
.filter(|line| line.contains("dogs.toml"))
.collect();
assert_eq!(about_the_section.len(), 1, "said once, not per tick");
let line = about_the_section[0];
assert!(line.contains("keeps too few releases"), "{line}");
assert!(
line.contains("still polling on the section it last read"),
"{line}"
);
}
struct SmitRecording {
ready: Ready,
smits: RefCell<Vec<(String, String)>>,
}
impl Default for SmitRecording {
fn default() -> Self {
Self {
ready: Ready::new(),
smits: RefCell::new(Vec::new()),
}
}
}
impl SmitRecording {
fn smits(&self) -> Vec<(String, String)> {
self.smits.borrow().clone()
}
}
impl Daemon for SmitRecording {
async fn dog_config(&self, name: &str) -> Result<String, Error> {
self.ready.dog_config(name).await
}
async fn list_flock(&self) -> Result<Vec<ProcessInfo>, Error> {
self.ready.list_flock().await
}
async fn describe(&self, sheep: &str) -> Result<Vec<ProcessInfo>, Error> {
self.ready.describe(sheep).await
}
async fn start(&self, apps: Vec<AppConfig>) -> Result<Vec<u32>, Error> {
self.ready.start(apps).await
}
async fn delete(&self, id: u32) -> Result<(), Error> {
self.ready.delete(id).await
}
async fn reload(&self, sheep: &str) -> Result<(), Error> {
self.ready.reload(sheep).await
}
async fn restart(&self, sheep: &str) -> Result<(), Error> {
self.ready.restart(sheep).await
}
async fn save_roll(&self) -> Result<PathBuf, Error> {
self.ready.save_roll().await
}
async fn set_smit(&self, sheep: &str, text: &str) -> Result<(), Error> {
self.smits
.borrow_mut()
.push((sheep.to_owned(), text.to_owned()));
Ok(())
}
}
struct RefusingSmits;
impl Daemon for RefusingSmits {
async fn describe(&self, sheep: &str) -> Result<Vec<ProcessInfo>, Error> {
Ok(vec![
ProcessInfo::builder(0, sheep, ProcStatus::Online)
.pid(Some(FIRST_PID))
.build(),
])
}
async fn reload(&self, _sheep: &str) -> Result<(), Error> {
Ok(())
}
async fn set_smit(&self, _sheep: &str, _text: &str) -> Result<(), Error> {
Err(Error::Request(RequestError::Rpc(RpcError {
code: RpcErrorCode::Internal,
message: "smits are not accepted right now".to_owned(),
daemon_version: None,
})))
}
crate::fixtures::daemon_methods!(unimplemented;
dog_config, list_flock, start, delete, restart, save_roll,
);
}
#[tokio::test]
async fn every_tick_republishes_every_targets_smit() {
let home = tempfile::tempdir().expect("tempdir");
write_target(
home.path(),
"bpm",
Watch::Auto,
Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"),
);
write_target(
home.path(),
"ctm",
Watch::Manual,
Some("f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5"),
);
let daemon = SmitRecording::default();
tick(&daemon, home.path(), &fixtures::dog_config()).await;
tick(&daemon, home.path(), &fixtures::dog_config()).await;
assert_eq!(
daemon.smits(),
vec![
("bpm".to_owned(), "▲ main@a1b2c3".to_owned()),
("ctm".to_owned(), "⏸ main@f6e5d4".to_owned()),
("bpm".to_owned(), "▲ main@a1b2c3".to_owned()),
("ctm".to_owned(), "⏸ main@f6e5d4".to_owned()),
]
);
}
#[tokio::test]
async fn a_manual_target_still_gets_a_smit() {
let home = tempfile::tempdir().expect("tempdir");
write_target(
home.path(),
"ctm",
Watch::Manual,
Some("f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5"),
);
let daemon = SmitRecording::default();
tick(&daemon, home.path(), &fixtures::dog_config()).await;
assert_eq!(daemon.smits().len(), 1);
}
#[tokio::test]
async fn a_refused_smit_does_not_stop_the_tick() {
let home = tempfile::tempdir().expect("tempdir");
let _origin = write_target_ready(home.path(), "fine", Watch::Auto);
let results = tick(&RefusingSmits, home.path(), &fixtures::dog_config())
.await
.results;
let row = |name: &str| {
results
.iter()
.find(|(sheep, _)| sheep == name)
.unwrap_or_else(|| panic!("no row for {name}: {results:?}"))
};
assert!(row("fine").1.is_ok(), "the deploy still ran");
assert!(
row("fine/smit").1.is_err(),
"the refusal is reported as its own row"
);
}
#[tokio::test]
async fn a_too_long_branch_name_does_not_stop_the_tick() {
let home = tempfile::tempdir().expect("tempdir");
write_target(
home.path(),
"long",
Watch::Auto,
Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"),
);
let tree = Tree::for_sheep(home.path(), "long");
let mut state = State::read(&tree.state_file()).expect("reads");
state.branch = "x".repeat(100);
state.write(&tree.state_file()).expect("writes");
let daemon = SmitRecording::default();
let results = tick(&daemon, home.path(), &fixtures::dog_config())
.await
.results;
assert!(
!daemon.smits().is_empty(),
"a shortened smit was still sent"
);
let sent = &daemon.smits()[0].1;
assert!(sent.chars().count() <= 48, "{sent}");
assert!(results[0].1.is_err(), "the fetch is what fails here");
}
}