use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::Path;
use tokio::time::sleep;
use crate::config::DogConfig;
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,
) -> Vec<(String, Result<Outcome, Error>)> {
let names = match paths::targets(shep_home) {
Ok(names) => names,
Err(err) => return vec![(shep_home.join("deploy").display().to_string(), Err(err))],
};
let mut results = Vec::new();
for name in names {
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 {
eprintln!("{name}: could not publish its smit: {err}");
}
if !due(&state) {
continue;
}
let outcome = deploy::unattended(daemon, &tree, &mut state, config).await;
results.push((name, outcome));
}
results
}
#[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> {
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) -> bool {
if let Some(seen) = previous.get_mut(sheep)
&& seen.line == line
{
seen.muted += 1;
if seen.muted < RESAY {
return false;
}
}
previous.insert(
sheep.to_owned(),
Repeat {
line: line.to_owned(),
muted: 0,
},
);
true
}
pub async fn run<D: Daemon>(daemon: &D, shep_home: &Path, config: &DogConfig) -> 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,
config: &DogConfig,
out: &mut O,
err: &mut E,
) -> Result<(), Error> {
let mut previous: BTreeMap<String, Repeat> = BTreeMap::new();
loop {
let results = tick(daemon, shep_home, config).await;
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;
};
if !worth_saying(&mut previous, &sheep, said.text()) {
continue;
}
let _ = match &said {
Said::Note(text) => writeln!(out, "{text}"),
Said::Complaint(text) => writeln!(err, "{text}"),
};
}
sleep(config.interval).await;
}
}
#[cfg(test)]
mod tests {
use crate::fixtures;
use super::*;
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 = 'exec'\ntarget = 'true'\n"
)
}
const fn config() -> DogConfig {
DogConfig {
interval: Duration::from_secs(30),
retention: 5,
git_timeout: std::time::Duration::from_secs(60),
build_timeout: std::time::Duration::from_secs(60),
passthrough: Vec::new(),
}
}
fn target(watch: Watch) -> State {
State {
remote: "https://example.com/x".to_owned(),
branch: "main".to_owned(),
deployed: Some("a1b2c3".to_owned()),
failed: None,
verify: Verify::default(),
watch,
origin_cwd: None,
origin_script: None,
checkout: PathBuf::from("/srv/x"),
}
}
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 = tempfile::tempdir().expect("tempdir");
fixtures::run_git(origin.path(), &["init", "-q", "-b", "main"]);
fixtures::run_git(origin.path(), &["config", "user.email", "test@example.com"]);
fixtures::run_git(origin.path(), &["config", "user.name", "test"]);
fs::write(origin.path().join("Flockfile.toml"), flockfile(sheep)).expect("Flockfile");
fixtures::run_git(origin.path(), &["add", "."]);
fixtures::run_git(origin.path(), &["commit", "-q", "-m", "first"]);
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,
branch: "main".to_owned(),
deployed: Some(first),
failed: None,
verify: Verify::Probed,
watch,
origin_cwd: None,
origin_script: None,
checkout: origin.path().to_owned(),
};
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 dog_config(&self, _name: &str) -> Result<String, Error> {
unimplemented!()
}
async fn list_flock(&self) -> Result<Vec<ProcessInfo>, Error> {
unimplemented!()
}
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 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> {
self.reloads.set(self.reloads.get() + 1);
Ok(())
}
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> {
Ok(())
}
}
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 dog_config(&self, _name: &str) -> Result<String, Error> {
unimplemented!()
}
async fn list_flock(&self) -> Result<Vec<ProcessInfo>, Error> {
unimplemented!()
}
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 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> {
Ok(())
}
}
#[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(), &config()).await.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("old"));
let _origin = write_target_ready(home.path(), "fine", Watch::Auto);
let results = tick(&Ready::new(), home.path(), &config()).await;
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(), &config()).await;
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(), &config()).await.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(), &config()).await;
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(), &config()).await;
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(),
&DogConfig {
interval: Duration::from_secs(150),
retention: 5,
git_timeout: std::time::Duration::from_secs(60),
build_timeout: std::time::Duration::from_secs(60),
passthrough: Vec::new(),
},
),
)
.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(),
&DogConfig {
interval: Duration::from_secs(600),
retention: 5,
git_timeout: std::time::Duration::from_secs(60),
build_timeout: std::time::Duration::from_secs(60),
passthrough: Vec::new(),
},
),
)
.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"
));
assert!(!worth_saying(
&mut previous,
"web",
"web: the remote is gone"
));
assert!(!worth_saying(
&mut previous,
"web",
"web: the remote is gone"
));
assert!(worth_saying(
&mut previous,
"koji",
"koji: the remote is gone"
));
}
#[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), "{line}");
assert!(!worth_saying(&mut previous, "web", line), "{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));
assert!(!worth_saying(&mut previous, "web", broken));
assert!(worth_saying(&mut previous, "web", "web deployed abc1234"));
assert!(worth_saying(&mut previous, "web", broken));
}
#[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));
let said = (0..RESAY * 2)
.filter(|_| worth_saying(&mut previous, "web", line))
.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(1),
run_with(
&Ready::new(),
home.path(),
&DogConfig {
interval: Duration::from_secs(600),
retention: 5,
git_timeout: std::time::Duration::from_secs(60),
build_timeout: std::time::Duration::from_secs(60),
passthrough: Vec::new(),
},
&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("old"));
let (mut out, mut err) = (Vec::new(), Vec::new());
let _ = tokio::time::timeout(
Duration::from_secs(305),
run_with(
&Ready::new(),
home.path(),
&DogConfig {
interval: Duration::from_secs(150),
retention: 5,
git_timeout: std::time::Duration::from_secs(60),
build_timeout: std::time::Duration::from_secs(60),
passthrough: Vec::new(),
},
&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");
}
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<(), 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 dog_config(&self, _name: &str) -> Result<String, Error> {
unimplemented!()
}
async fn list_flock(&self) -> Result<Vec<ProcessInfo>, Error> {
unimplemented!()
}
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 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> {
Ok(())
}
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> {
Err(Error::Request(RequestError::Rpc(RpcError {
code: RpcErrorCode::Internal,
message: "smits are not accepted right now".to_owned(),
daemon_version: None,
})))
}
}
#[tokio::test]
async fn every_tick_republishes_every_targets_smit() {
let home = tempfile::tempdir().expect("tempdir");
write_target(home.path(), "bpm", Watch::Auto, Some("a1b2c3d4e5f6"));
write_target(home.path(), "ctm", Watch::Manual, Some("f6e5d4c3b2a1"));
let daemon = SmitRecording::default();
tick(&daemon, home.path(), &config()).await;
tick(&daemon, home.path(), &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("f6e5d4c3b2a1"));
let daemon = SmitRecording::default();
tick(&daemon, home.path(), &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(), &config()).await;
assert!(results[0].1.is_ok(), "the deploy still ran");
}
#[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("a1b2c3d4e5f6"));
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(), &config()).await;
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");
}
}