use crate::fixtures;
use super::*;
mod smit;
#[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()
);
}
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);
}
#[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}"
);
}