use std::fs;
use crate::deploy::test_support::{
FLOCKFILE, RECLAIMED, Shepherd, commit_on_origin, fixture_before_any_release,
fixture_with_previous_release, install_release,
};
use crate::deploy::{Outcome, deploy};
use crate::error::Error;
use crate::paths::Tree;
use crate::state::State;
use crate::{fixtures, swap};
#[tokio::test(start_paused = true)]
async fn a_rollback_the_shepherd_never_accepts_is_reported_as_a_split_state() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
let second = commit_on_origin(&fixture, "second.txt");
let err = deploy(
&Shepherd::busy_for(u32::MAX),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("the rollback cannot reload");
assert!(matches!(err, Error::Split { .. }), "{err:?}");
let shown = err.to_string();
assert!(!shown.contains("rolling back after"), "{shown}");
assert!(
shown.contains("no reload onto it could be confirmed"),
"{shown}"
);
assert!(shown.contains(&previous), "{shown}");
assert!(shown.contains(&second), "{shown}");
assert!(shown.contains("shep reload web"), "{shown}");
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("deploy.toml was written")
.deployed
.as_deref(),
Some(previous.as_str())
);
}
#[tokio::test(start_paused = true)]
async fn a_failure_before_the_reload_puts_current_back() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::unreachable();
let err = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("the shepherd cannot be asked anything");
assert!(matches!(err, Error::Protocol(_)), "{err:?}");
assert_eq!(daemon.attempt_count(), 0, "no reload may be sent");
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous)),
"current must not be left on a release nothing verified"
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
#[tokio::test(start_paused = true)]
async fn a_failure_before_the_reload_puts_current_back_from_the_record() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
let attempted = commit_on_origin(&fixture, "second.txt");
swap::point_at(&fixture.tree.current(), &fixture.tree.release(&attempted))
.expect("point current at the attempted release");
let err = deploy(
&Shepherd::unreachable(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("the shepherd cannot be asked anything");
assert!(matches!(err, Error::Protocol(_)), "{err:?}");
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous)),
"current goes back to the release the record names"
);
}
#[tokio::test(start_paused = true)]
async fn a_current_that_names_no_release_is_not_a_rollback_target() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
commit_on_origin(&fixture, "second.txt");
let elsewhere = fixture.tree.root().join("not-a-release");
fs::create_dir_all(&elsewhere).expect("a directory that is not a release");
swap::point_at(&fixture.tree.current(), &elsewhere).expect("repoint current by hand");
let outcome = deploy(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("rolled back onto the recorded release");
assert!(
matches!(&outcome, Outcome::RolledBack { to, .. } if *to == previous),
"{outcome:?}"
);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
State::read(&fixture.tree.state_file()).expect("the record is still readable");
}
#[tokio::test(start_paused = true)]
async fn a_rollback_that_is_never_confirmed_reports_the_split_state() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
commit_on_origin(&fixture, "second.txt");
let err = deploy(
&Shepherd::losing_replies(u32::MAX),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("no reload is ever confirmed");
assert!(matches!(err, Error::Split { .. }), "{err:?}");
let shown = err.to_string();
assert!(shown.contains("could be confirmed"), "{shown}");
assert!(shown.contains(&previous), "{shown}");
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
#[tokio::test(start_paused = true)]
async fn a_rollback_reloads_onto_the_previous_release() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::never_ready();
let outcome = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert!(matches!(outcome, Outcome::RolledBack { .. }));
assert_eq!(
daemon.reload_count(),
2,
"one reload onto the new release, one onto the old"
);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
}
#[tokio::test(start_paused = true)]
async fn a_rollback_reads_deploy_toml_when_current_is_already_the_new_release() {
let mut fixture = fixture_with_previous_release();
let live = fixture.state.deployed.clone().expect("a previous release");
let second = commit_on_origin(&fixture, "second.txt");
crate::git::fetch(
&fixture.tree.git(),
&fixture.state.remote,
fixtures::TEST_BUDGET,
)
.expect("fetch");
install_release(&fixture, &second);
assert_eq!(fixture.state.deployed.as_deref(), Some(live.as_str()));
let daemon = Shepherd::never_ready();
let outcome = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert_eq!(
outcome,
Outcome::RolledBack {
to: live.clone(),
why: "it did not come up and stay up, 16s after the reload".to_owned(),
}
);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&live))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(live.as_str()));
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("deploy.toml")
.deployed
.as_deref(),
Some(live.as_str())
);
assert_eq!(daemon.attempt_count(), 2);
}
#[tokio::test(start_paused = true)]
async fn a_current_naming_a_swept_release_is_not_a_rollback_target() {
let mut fixture = fixture_with_previous_release();
let live = fixture.state.deployed.clone().expect("a previous release");
commit_on_origin(&fixture, "second.txt");
crate::git::worktree_remove(&fixture.tree.git(), &fixture.tree.release(&live))
.expect("retention removes the worktree");
assert!(swap::resolve(&fixture.tree.current()).unwrap().is_some());
assert!(!fixture.tree.release(&live).exists());
let err = deploy(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("nothing usable to roll back to");
assert!(matches!(err, Error::Unverified { .. }), "{err:?}");
assert!(
swap::resolve(&fixture.tree.current())
.unwrap()
.is_some_and(|path| path.exists()),
"current must never be repointed at a release that is not there"
);
}
#[tokio::test(start_paused = true)]
async fn a_recorded_release_that_is_gone_is_not_a_rollback_target() {
let mut fixture = fixture_with_previous_release();
let live = fixture.state.deployed.clone().expect("a previous release");
let second = commit_on_origin(&fixture, "second.txt");
crate::git::fetch(
&fixture.tree.git(),
&fixture.state.remote,
fixtures::TEST_BUDGET,
)
.expect("fetch");
install_release(&fixture, &second);
crate::git::worktree_remove(&fixture.tree.git(), &fixture.tree.release(&live))
.expect("retention removes the old worktree");
assert!(!fixture.tree.release(&live).exists());
let err = deploy(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("nothing left to roll back to");
assert!(matches!(err, Error::Unverified { .. }), "{err:?}");
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&second)),
"current stays where it was rather than dangling"
);
}
#[tokio::test(start_paused = true)]
async fn a_deploy_with_nothing_left_to_fall_back_to_says_so_plainly() {
let mut fixture = fixture_before_any_release();
fixture.state.deployed = Some(RECLAIMED.to_owned());
let first = crate::git::remote_head(&fixture.tree.git(), "main").expect("head");
let err = deploy(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("nothing to roll back to");
assert!(matches!(err, Error::Unverified { .. }), "{err:?}");
let shown = err.to_string();
assert!(shown.contains("web"), "{shown}");
assert!(shown.contains(&first), "{shown}");
assert!(shown.contains("did not come up and stay up"), "{shown}");
assert!(shown.contains("after the reload"), "{shown}");
assert!(!shown.contains("rolling back after"), "{shown}");
assert_eq!(shown.matches("roll back").count(), 1, "{shown}");
assert!(shown.contains("current"), "{shown}");
assert!(shown.contains("deploy.toml"), "{shown}");
assert!(!shown.contains("first deploy"), "{shown}");
assert_eq!(fixture.state.deployed.as_deref(), Some(RECLAIMED));
}
#[tokio::test(start_paused = true)]
async fn a_previous_release_under_another_path_is_not_a_rollback_target() {
let mut fixture = fixture_with_previous_release();
let second = commit_on_origin(&fixture, "second.txt");
crate::git::fetch(
&fixture.tree.git(),
&fixture.state.remote,
fixtures::TEST_BUDGET,
)
.expect("fetch");
install_release(&fixture, &second);
fixture.state.deployed = Some(RECLAIMED.to_owned());
let elsewhere = tempfile::tempdir().expect("tempdir");
let link = elsewhere.path().join("home");
std::os::unix::fs::symlink(fixture.home.path(), &link).expect("symlink the home");
let same_tree = Tree::for_sheep(&link, "web");
let err = deploy(
&Shepherd::never_ready(),
&same_tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("there is no other release to go back to");
assert!(matches!(err, Error::Unverified { .. }), "{err:?}");
}
#[tokio::test(start_paused = true)]
async fn a_rollback_corrects_a_stale_deployed_record() {
let mut fixture = fixture_with_previous_release();
let live = fixture.state.deployed.clone().expect("a previous release");
fixture.state.deployed = Some(RECLAIMED.to_owned());
commit_on_origin(&fixture, "second.txt");
deploy(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert_eq!(fixture.state.deployed.as_deref(), Some(live.as_str()));
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("deploy.toml was written")
.deployed
.as_deref(),
Some(live.as_str())
);
}
#[tokio::test(start_paused = true)]
async fn a_rollback_onto_a_release_that_never_comes_up_is_reported_unverified() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::rollback_never_ready();
let err = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("neither release comes up");
let Error::RollbackUnverified {
sheep,
to,
why,
found,
} = &err
else {
panic!("expected an unverified rollback: {err:?}");
};
assert_eq!(sheep, "web");
assert_eq!(to, &previous);
assert!(why.contains("did not come up and stay up"), "{why}");
assert!(found.contains("did not come up and stay up"), "{found}");
assert_eq!(daemon.reload_count(), 2);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("deploy.toml")
.deployed
.as_deref(),
Some(previous.as_str())
);
}
#[tokio::test(start_paused = true)]
async fn a_rollback_onto_a_sheep_the_shepherd_dropped_is_reported_unverified() {
let mut fixture = fixture_with_previous_release();
commit_on_origin(&fixture, "second.txt");
let err = deploy(
&Shepherd::rollback_drops_the_sheep(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("the sheep is gone");
assert!(!matches!(err, Error::RollbackFailed { .. }), "{err:?}");
let Error::RollbackUnverified { found, .. } = &err else {
panic!("expected an unverified rollback: {err:?}");
};
assert!(found.contains("no sheep named web"), "{found}");
}
#[tokio::test(start_paused = true)]
async fn a_rollback_that_comes_up_is_still_reported_rolled_back() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::never_ready();
let outcome = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("a healthy rollback is an outcome");
assert!(
matches!(&outcome, Outcome::RolledBack { to, .. } if *to == previous),
"{outcome:?}"
);
assert_eq!(daemon.reload_count(), 2);
}
#[tokio::test(start_paused = true)]
async fn an_alive_rollback_that_never_turns_over_is_reported_unverified() {
let mut fixture = fixture_with_previous_release();
fixture.state.verify = crate::state::Verify::Alive;
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::never_turns_over();
let err = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("the rollback never turns the flock over");
assert!(matches!(err, Error::RollbackUnverified { .. }), "{err:?}");
assert_eq!(daemon.reload_count(), 2);
}
#[tokio::test(start_paused = true)]
async fn the_rollback_is_timed_by_the_release_being_restored() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
let impatient = FLOCKFILE.replace(
"script = './run.sh'\n",
"script = './run.sh'\nlisten_timeout = '1ms'\ngraceful_timeout = '1ms'\n",
);
assert_ne!(impatient, FLOCKFILE);
fs::write(fixture.origin.path().join("Flockfile.toml"), impatient).expect("write");
fixtures::run_git(fixture.origin.path(), &["add", "."]);
fixtures::run_git(fixture.origin.path(), &["commit", "-q", "-m", "impatient"]);
let daemon = Shepherd::never_ready().rollback_ready_after(80);
let outcome = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("the restored release comes up inside its own budget");
assert!(
matches!(&outcome, Outcome::RolledBack { to, .. } if *to == previous),
"{outcome:?}"
);
}
#[tokio::test(start_paused = true)]
async fn a_swap_back_that_fails_reports_both_failures() {
let mut fixture = fixture_with_previous_release();
commit_on_origin(&fixture, "second.txt");
let stale = fixture.tree.current().with_file_name("current.tmp");
let err = deploy(
&Shepherd::planting(stale),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("the swap back collides with the file in the way");
assert!(matches!(err, Error::RollbackFailed { .. }), "{err:?}");
let shown = err.to_string();
assert!(shown.contains("did not come up"), "{shown}");
assert!(shown.contains("current.tmp"), "{shown}");
}