use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::daemon::Daemon;
use crate::deploy::record::write_record;
use crate::deploy::reload::{reload_until, sha_of};
use crate::error::Error;
use crate::paths::Tree;
use crate::state::State;
use crate::swap;
pub(super) fn undo_swap(
tree: &Tree,
state: &State,
previous: Option<&Path>,
attempted: &str,
why: &Error,
) -> Result<(), Error> {
let wrap = |source| Error::RollbackFailed {
why: why.to_string(),
source: Box::new(source),
};
let Some(target) = rollback_target(tree, state, previous, attempted) else {
return Err(wrap(Error::Config(format!(
"current names {attempted}, which nothing verified, and neither the link it \
replaced nor deploy.toml names an earlier release that is still on disk. Point {} \
at a release by hand before deploying again",
tree.current().display()
))));
};
swap::point_at(&tree.current(), &target).map_err(wrap)
}
pub(super) async fn roll_back<D: Daemon>(
daemon: &D,
tree: &Tree,
state: &mut State,
previous: Option<&Path>,
attempted: &str,
why: &str,
patience: Duration,
) -> Result<String, Error> {
let Some(previous) = rollback_target(tree, state, previous, attempted) else {
return Err(Error::Unverified {
sheep: tree.sheep().to_owned(),
sha: attempted.to_owned(),
why: why.to_owned(),
});
};
let to = sha_of(&previous);
restore(daemon, tree, state, &previous, attempted, why, patience)
.await
.map_err(|source| match source {
split @ Error::Split { .. } => split,
source => Error::RollbackFailed {
why: why.to_owned(),
source: Box::new(source),
},
})?;
Ok(to)
}
fn rollback_target(
tree: &Tree,
state: &State,
previous: Option<&Path>,
attempted: &str,
) -> Option<PathBuf> {
let usable = |release: PathBuf| {
let sha = sha_of(&release);
(crate::state::is_sha(&sha) && sha != attempted && release.exists()).then_some(release)
};
if let Some(release) = previous.map(Path::to_path_buf).and_then(usable) {
return Some(release);
}
let recorded = state.deployed.as_deref()?;
usable(tree.release(recorded))
}
async fn restore<D: Daemon>(
daemon: &D,
tree: &Tree,
state: &mut State,
previous: &Path,
attempted: &str,
why: &str,
patience: Duration,
) -> Result<(), Error> {
let sheep = tree.sheep();
let to = sha_of(previous);
swap::point_at(&tree.current(), previous)?;
if state.deployed.as_deref() != Some(to.as_str()) {
state.deployed = Some(to.clone());
write_record(tree, state)?;
}
match reload_until(daemon, sheep, patience).await {
Ok(()) => Ok(()),
Err(source) if source.is_retryable() => Err(Error::Split {
sheep: sheep.to_owned(),
on: to,
running: attempted.to_owned(),
why: why.to_owned(),
source: Box::new(source),
}),
Err(source) => Err(source),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use crate::deploy::test_support::{
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_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}");
}
}