use crate::deploy::Outcome;
use crate::error::Error;
use crate::paths::Tree;
use crate::state::{State, Watch};
use crate::{lock, retention, shared};
pub(super) async fn record(
tree: &Tree,
state: &mut State,
head: &str,
outcome: &Result<Outcome, Error>,
keep: usize,
) {
let sheep = tree.sheep();
let landed = matches!(outcome, Ok(Outcome::Deployed { .. }));
let failed = (!landed).then(|| head.to_owned());
if state.failed != failed {
match lock::hold_record(tree) {
Err(err) => {
eprintln!("shep-deploy: {sheep}: could not record what {head} came to: {err}");
}
Ok(_record) => match landed_elsewhere(tree, head, failed.as_deref()) {
Some(fresher) => *state = fresher,
None => {
state.failed = failed;
adopt_operator_fields(tree, state);
if let Err(err) = state.write(&tree.state_file()) {
eprintln!(
"shep-deploy: {sheep}: could not record what {head} came to: {err}"
);
}
}
},
}
}
let on_disk = State::read(&tree.state_file())
.ok()
.and_then(|recorded| recorded.deployed);
let spared: Vec<&str> = state
.deployed
.as_deref()
.into_iter()
.chain(on_disk.as_deref())
.collect();
let pruned = {
let (tree, spared): (Tree, Vec<String>) = (
tree.clone(),
spared.iter().map(|sha| (*sha).to_owned()).collect(),
);
shared::off_thread(move || {
let spared: Vec<&str> = spared.iter().map(String::as_str).collect();
retention::prune(&tree, keep, &spared)
})
.await
};
if let Err(err) = pruned {
eprintln!("shep-deploy: {sheep}: could not reclaim old releases: {err}");
}
}
fn adopt_operator_fields(tree: &Tree, state: &mut State) {
if let Ok(on_disk) = State::read(&tree.state_file()) {
state.watch = on_disk.watch;
state.verify = on_disk.verify;
}
}
pub(super) fn record_landing(tree: &Tree, state: &mut State, head: &str) -> Result<(), Error> {
state.deployed = Some(head.to_owned());
let held = state.failed.take();
let written = write_record(tree, state);
if written.is_err() {
state.failed = held;
}
written
}
pub(super) fn write_record(tree: &Tree, state: &mut State) -> Result<(), Error> {
let _record = lock::hold_record(tree)?;
adopt_operator_fields(tree, state);
state.write(&tree.state_file())
}
fn landed_elsewhere(tree: &Tree, head: &str, failing: Option<&str>) -> Option<State> {
failing?;
let on_disk = State::read(&tree.state_file()).ok()?;
(on_disk.deployed.as_deref() == Some(head)).then_some(on_disk)
}
pub fn set_watch(tree: &Tree, watch: Watch) -> Result<(Watch, State), Error> {
let _record = lock::hold_record(tree)?;
let mut state = State::read(&tree.state_file())?;
let was = state.watch;
if watch == Watch::Auto && state.deployed.is_none() {
return Err(Error::Config(format!(
"{} has a deploy tree but was never cut over to it, so there is nothing for the \
poll loop to deploy: its record names no released sha, and nothing has ever been \
served from that tree. Watching it would deploy at every interval and each of \
those would be refused. Finish the cutover instead - remove {} and run \
`shep-deploy setup {}`.",
tree.sheep(),
tree.root().display(),
tree.sheep()
)));
}
state.watch = watch;
state.write(&tree.state_file())?;
Ok((was, state))
}
#[cfg(test)]
mod tests {
use std::fs;
use std::time::Duration;
use crate::deploy::test_support::{
Shepherd, commit_on_origin, fixture_before_any_release, fixture_with_previous_release,
install_release, test_config_keeping,
};
use crate::deploy::{Outcome, deploy, unattended};
use crate::error::Error;
use crate::paths::Tree;
use crate::state::{State, Watch};
use crate::{fixtures, lock, swap};
use super::*;
#[tokio::test(start_paused = true)]
async fn a_straggler_cannot_fail_a_sha_another_process_deployed() {
let mut fixture = fixture_with_previous_release();
let second = commit_on_origin(&fixture, "second.txt");
let mut straggler = fixture.state.clone();
deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("the winner lands");
record(
&fixture.tree,
&mut straggler,
&second,
&Err(Error::Protocol("lost the checkout race".to_owned())),
fixtures::dog_config().retention,
)
.await;
let written = State::read(&fixture.tree.state_file()).expect("still readable");
assert_eq!(
written.deployed.as_deref(),
Some(second.as_str()),
"the record must still name the sha that is actually running"
);
assert_eq!(
written.failed, None,
"a sha another process deployed must not be recorded as failed"
);
}
#[tokio::test(start_paused = true)]
async fn a_sha_that_did_not_land_is_written_down() {
let mut fixture = fixture_with_previous_release();
let second = commit_on_origin(&fixture, "second.txt");
let outcome = unattended(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("rolls back");
assert!(matches!(outcome, Outcome::RolledBack { .. }), "{outcome:?}");
assert_eq!(fixture.state.failed.as_deref(), Some(second.as_str()));
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("deploy.toml was written")
.failed
.as_deref(),
Some(second.as_str()),
"and it survives a restart of the dog"
);
}
#[tokio::test(start_paused = true)]
async fn failed_releases_are_reclaimed_like_any_other() {
let mut fixture = fixture_with_previous_release();
let live = fixture.state.deployed.clone().expect("a previous release");
let mut failed = Vec::new();
for name in ["second.txt", "third.txt", "fourth.txt"] {
failed.push(commit_on_origin(&fixture, name));
unattended(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&test_config_keeping(2),
)
.await
.expect("rolls back");
}
assert!(fixture.tree.release(&live).exists(), "current is spared");
assert!(
!fixture.tree.release(&failed[0]).exists(),
"the oldest failure is reclaimed"
);
assert!(fixture.tree.release(&failed[2]).exists(), "the newest kept");
let count = fs::read_dir(fixture.tree.releases())
.expect("reads")
.count();
assert_eq!(count, 3, "the newest two, plus what current names");
}
#[test]
fn a_watch_set_while_a_deploy_writes_the_record_loses_nothing() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "web");
fs::create_dir_all(tree.root()).expect("tree");
let mut state = State {
deployed: Some(fixtures::SHA.to_owned()),
..fixtures::state()
};
state.write(&tree.state_file()).expect("the record before");
let held = lock::hold_record(&tree).expect("the deploy's hold");
let (tx, rx) = std::sync::mpsc::channel();
let operator = {
let home = home.path().to_owned();
std::thread::spawn(move || {
let result = set_watch(&Tree::for_sheep(&home, "web"), Watch::Manual);
tx.send(()).expect("report");
result
})
};
assert!(
rx.recv_timeout(Duration::from_millis(300)).is_err(),
"set_watch must wait for the record lock"
);
state.deployed = Some(fixtures::OTHER_SHA.to_owned());
state.write(&tree.state_file()).expect("the deploy's write");
drop(held);
rx.recv_timeout(Duration::from_secs(5))
.expect("set_watch lands");
operator.join().expect("thread").expect("sets");
let after = State::read(&tree.state_file()).expect("reads");
assert_eq!(after.deployed.as_deref(), Some(fixtures::OTHER_SHA));
assert_eq!(after.watch, Watch::Manual);
}
#[tokio::test]
async fn a_raced_deploy_is_held_like_any_other_failure() {
let fixture = fixture_with_previous_release();
let mut state = fixture.state.clone();
let raced: Result<Outcome, Error> = Err(Error::Raced {
sheep: "web".to_owned(),
started: "a".to_owned(),
found: "b".to_owned(),
});
record(&fixture.tree, &mut state, fixtures::OTHER_SHA, &raced, 5).await;
assert_eq!(state.failed.as_deref(), Some(fixtures::OTHER_SHA));
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("reads")
.failed
.as_deref(),
Some(fixtures::OTHER_SHA)
);
}
#[tokio::test(start_paused = true)]
async fn a_landed_deploy_keeps_the_watch_the_operator_set_meanwhile() {
let mut fixture = fixture_with_previous_release();
fixture.state.watch = Watch::Auto;
fixture
.state
.write(&fixture.tree.state_file())
.expect("watched");
let second = commit_on_origin(&fixture, "second.txt");
let outcome = deploy(
&Shepherd::flipping_watch(fixture.tree.state_file()),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("lands");
assert!(matches!(outcome, Outcome::Deployed { .. }), "{outcome:?}");
let written = State::read(&fixture.tree.state_file()).expect("reads");
assert_eq!(
written.watch,
Watch::Manual,
"the operator's setting survives"
);
assert_eq!(
written.deployed.as_deref(),
Some(second.as_str()),
"and the deploy is recorded by the same write"
);
}
#[tokio::test]
async fn a_failed_deploys_record_write_keeps_the_watch_the_operator_set_meanwhile() {
let fixture = fixture_with_previous_release();
let mut in_flight = fixture.state.clone();
in_flight.watch = Watch::Auto;
let mut on_disk = fixture.state.clone();
on_disk.watch = Watch::Manual;
on_disk
.write(&fixture.tree.state_file())
.expect("--watch manual");
let failed: Result<Outcome, Error> = Err(Error::Build { status: Some(1) });
record(
&fixture.tree,
&mut in_flight,
fixtures::OTHER_SHA,
&failed,
5,
)
.await;
let written = State::read(&fixture.tree.state_file()).expect("reads");
assert_eq!(
written.watch,
Watch::Manual,
"the operator's setting survives"
);
assert_eq!(
written.failed.as_deref(),
Some(fixtures::OTHER_SHA),
"and the deploy's own fact is recorded"
);
}
#[tokio::test]
async fn the_release_the_disk_record_names_is_spared_after_a_lost_write() {
let mut fixture = fixture_with_previous_release();
let old = fixture.state.deployed.clone().expect("a previous release");
let mut newest = String::new();
for n in 0..3 {
newest = commit_on_origin(&fixture, &format!("release-{n}.txt"));
crate::git::fetch(
&fixture.tree.git(),
fixture.origin.path().to_str().expect("utf-8"),
fixtures::TEST_BUDGET,
)
.expect("fetch");
install_release(&fixture, &newest);
}
fixture.state.deployed = Some(newest.clone());
let landed: Result<Outcome, Error> = Ok(Outcome::Deployed { sha: newest });
record(&fixture.tree, &mut fixture.state, "unused", &landed, 2).await;
assert!(
fixture.tree.release(&old).is_dir(),
"the release the disk record names must survive"
);
}
#[tokio::test(start_paused = true)]
async fn landing_a_held_sha_records_it_and_lifts_the_hold() {
let mut fixture = fixture_with_previous_release();
let head = commit_on_origin(&fixture, "second.txt");
fixture.state.failed = Some(head.clone());
fixture
.state
.write(&fixture.tree.state_file())
.expect("the hold is on disk");
record_landing(&fixture.tree, &mut fixture.state, &head)
.expect("the landing is recorded at the first attempt");
let written = State::read(&fixture.tree.state_file()).expect("reads");
assert_eq!(written.deployed.as_deref(), Some(head.as_str()));
assert_eq!(written.failed, None, "a landed sha is no longer held");
}
#[tokio::test(start_paused = true)]
async fn a_lost_landing_write_keeps_the_hold_in_memory_for_record_to_retry() {
let mut fixture = fixture_with_previous_release();
let head = commit_on_origin(&fixture, "second.txt");
fixture.state.failed = Some(head.clone());
let record = fixture.tree.state_file();
fs::remove_file(&record).expect("drop the record");
fs::create_dir(&record).expect("a directory where the record goes");
record_landing(&fixture.tree, &mut fixture.state, &head)
.expect_err("the record cannot be written");
assert_eq!(
fixture.state.failed.as_deref(),
Some(head.as_str()),
"the hold still stands on disk, so memory still says so"
);
}
#[tokio::test(start_paused = true)]
async fn a_record_that_cannot_be_written_still_reports_deployed() {
let mut fixture = fixture_with_previous_release();
let head = commit_on_origin(&fixture, "second.txt");
let record = fixture.tree.state_file();
fs::remove_file(&record).expect("drop the record");
fs::create_dir(&record).expect("a directory where the record goes");
let outcome = deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("the deploy landed whatever the record did");
assert_eq!(outcome, Outcome::Deployed { sha: head.clone() });
assert_eq!(fixture.state.deployed.as_deref(), Some(head.as_str()));
assert_eq!(fixture.state.failed, None, "a landed sha is never held");
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&head))
);
}
#[tokio::test]
async fn setting_the_watch_mode_does_not_deploy() {
let fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
let second = commit_on_origin(&fixture, "second.txt");
let (was, after) = set_watch(&fixture.tree, Watch::Manual).expect("sets");
assert_eq!(was, fixture.state.watch, "what the record carried before");
assert_eq!(after.watch, Watch::Manual);
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("deploy.toml was written")
.watch,
Watch::Manual
);
assert!(
!fixture.tree.release(&second).exists(),
"no release may be built"
);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
assert_eq!(after.deployed.as_deref(), Some(previous.as_str()));
}
#[tokio::test]
async fn a_tree_the_cutover_never_landed_on_cannot_be_watched() {
let fixture = fixture_before_any_release();
let err = set_watch(&fixture.tree, Watch::Auto).expect_err("refuses");
let shown = err.to_string();
assert!(shown.contains("never cut over"), "{shown}");
assert!(shown.contains("shep-deploy setup web"), "{shown}");
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("deploy.toml still reads")
.watch,
Watch::Manual,
"unchanged on disk"
);
set_watch(&fixture.tree, Watch::Manual).expect("manual is allowed");
}
#[tokio::test(start_paused = true)]
async fn a_prune_failure_does_not_fail_the_deploy() {
let mut fixture = fixture_with_previous_release();
let first = fixture.state.deployed.clone().expect("a previous release");
commit_on_origin(&fixture, "second.txt");
deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("second release verifies");
fs::remove_dir_all(fixture.tree.git().join("worktrees").join(&first))
.expect("corrupt first's worktree bookkeeping");
commit_on_origin(&fixture, "third.txt");
let outcome = deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&test_config_keeping(2),
)
.await
.expect("a prune failure must not fail the deploy");
assert!(matches!(outcome, Outcome::Deployed { .. }), "{outcome:?}");
}
}