mod landing;
mod prepare;
mod record;
mod reload;
mod rollback;
#[cfg(test)]
pub(crate) mod test_support;
use std::path::PathBuf;
use crate::build;
use crate::config::DogConfig;
use crate::daemon::Daemon;
use crate::error::Error;
use crate::paths::Tree;
use crate::state::State;
use crate::{git, linkcheck, lock, shared, skip, swap};
use record::record_landing;
pub(crate) use landing::{run_release_hook, swap_and_land};
pub(crate) use prepare::{
Staged, head_of, read_staged, refuse_ungated_at_setup, refuse_ungated_verification,
stage_release,
};
pub(crate) use record::record;
pub use record::set_watch;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
UpToDate,
Deployed {
sha: String,
},
RolledBack {
to: String,
why: String,
},
Skipped {
sha: String,
},
}
pub async fn deploy<D: Daemon>(
daemon: &D,
tree: &Tree,
state: &mut State,
config: &DogConfig,
) -> Result<Outcome, Error> {
go(daemon, tree, state, config, Held::Retry).await
}
pub async fn unattended<D: Daemon>(
daemon: &D,
tree: &Tree,
state: &mut State,
config: &DogConfig,
) -> Result<Outcome, Error> {
go(daemon, tree, state, config, Held::Hold).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Held {
Retry,
Hold,
}
async fn go<D: Daemon>(
daemon: &D,
tree: &Tree,
state: &mut State,
config: &DogConfig,
held: Held,
) -> Result<Outcome, Error> {
let sheep = tree.sheep();
let _deploying = lock::hold(tree)?;
if state.deployed.is_none() {
return Err(Error::NotCutOver {
sheep: sheep.to_owned(),
tree: tree.root().to_owned(),
});
}
let started_at = swap::resolve(&tree.current())?;
let (git_dir, remote, tracked, budget) = (
tree.git(),
state.remote.clone(),
state.branch.clone(),
config.git_timeout,
);
shared::off_thread(move || git::fetch(&git_dir, &remote, &tracked, budget)).await?;
let head = head_of(tree, &state.branch)?;
if state.deployed.as_deref() == Some(head.as_str()) {
return Ok(Outcome::UpToDate);
}
if held == Held::Hold && state.failed.as_deref() == Some(head.as_str()) {
return Err(Error::Held {
sheep: sheep.to_owned(),
sha: head,
});
}
if held == Held::Hold
&& let Some(live) = &started_at
&& skip::nothing_changed(tree, state, live, &head).await
{
return Ok(Outcome::Skipped { sha: head });
}
let outcome = attempt(daemon, tree, state, &head, started_at, config).await;
record(tree, state, &head, &outcome, config.retention).await;
outcome
}
async fn attempt<D: Daemon>(
daemon: &D,
tree: &Tree,
state: &mut State,
head: &str,
started_at: Option<PathBuf>,
config: &DogConfig,
) -> Result<Outcome, Error> {
let sheep = tree.sheep();
let release = tree.release(head);
let staged = {
let (tree, checkout, head) = (tree.clone(), state.checkout.clone(), head.to_owned());
shared::off_thread(move || stage_release(&tree, &checkout, &head)).await?
};
refuse_ungated_verification(sheep, &staged.app, state.verify)?;
let snapshot = linkcheck::Snapshot::before(sheep, &release, &staged.linked);
let built = build::run(
sheep,
&release,
&staged.spec,
staged.app.user.as_deref(),
&config.passthrough,
&tree.cache_target(),
config.build_timeout,
)
.await;
for warning in snapshot.after() {
eprintln!("{warning}");
}
built?;
run_release_hook(tree, head, &staged, config).await?;
swap_and_land(daemon, tree, state, head, started_at, &staged, config).await
}
#[cfg(test)]
mod tests {
mod hooks;
mod retarget;
mod skip;
use std::fs;
use crate::deploy::test_support::{
FLOCKFILE, Shepherd, commit_on_origin, fixture_before_any_release,
fixture_with_previous_release, test_config_keeping,
};
use crate::error::Error;
use crate::state::State;
use crate::{fixtures, swap};
use super::*;
#[tokio::test(start_paused = true)]
async fn a_release_that_never_comes_up_is_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 outcome = deploy(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert!(matches!(outcome, Outcome::RolledBack { .. }));
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]
async fn attempt_behaves_as_before_after_the_split() {
let mut fixture = fixture_with_previous_release();
fs::write(
fixture.origin.path().join("Flockfile.toml"),
format!("{FLOCKFILE}\n[dog.deploy.build]\ncommand = 'echo build >> order.txt'\n"),
)
.expect("write Flockfile");
fs::write(
fixture.origin.path().join(".gitignore"),
format!("{}\n", crate::flockfile::OVERRIDE),
)
.expect("write .gitignore");
fs::write(
fixture.origin.path().join(crate::flockfile::OVERRIDE),
"[dog.deploy.release]\ncommand = 'echo release >> order.txt'\n\
[dog.deploy.after]\ncommand = 'echo after >> order.txt'\n",
)
.expect("write override");
let second = commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::ready();
let outcome = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert_eq!(
outcome,
Outcome::Deployed {
sha: second.clone()
}
);
let release = fixture.tree.release(&second);
assert_eq!(
fs::read_to_string(release.join("order.txt")).expect("the steps ran"),
"build\nrelease\nafter\n"
);
assert_eq!(daemon.reload_count(), 1);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(release)
);
let written = State::read(&fixture.tree.state_file()).expect("deploy.toml");
assert_eq!(written.deployed.as_deref(), Some(second.as_str()));
assert_eq!(written.failed, None);
}
#[tokio::test]
async fn an_unchanged_head_does_nothing() {
let mut fixture = fixture_with_previous_release();
let outcome = deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert!(matches!(outcome, Outcome::UpToDate));
}
#[tokio::test(start_paused = true)]
async fn a_release_that_comes_up_is_deployed_and_recorded() {
let mut fixture = fixture_with_previous_release();
let second = commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::ready();
let outcome = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert_eq!(daemon.reload_count(), 1, "the sheep must be reloaded once");
assert_eq!(
outcome,
Outcome::Deployed {
sha: second.clone()
}
);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&second))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(second.as_str()));
let written = State::read(&fixture.tree.state_file()).expect("deploy.toml was written");
assert_eq!(written.deployed.as_deref(), Some(second.as_str()));
}
#[tokio::test]
async fn a_deploy_is_refused_while_another_process_holds_the_tree() {
let mut fixture = fixture_with_previous_release();
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::ready();
let held = crate::lock::hold(&fixture.tree).expect("the other process");
let err = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("must refuse while the tree is held");
assert!(
matches!(&err, Error::AlreadyDeploying { sheep } if sheep == "web"),
"must name the sheep rather than fail somewhere inside git: {err:?}"
);
assert_eq!(daemon.reload_count(), 0, "nothing may have been attempted");
drop(held);
}
#[tokio::test(start_paused = true)]
async fn a_failed_sha_is_held_until_the_branch_moves() {
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 daemon = Shepherd::never_ready();
unattended(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("rolls back");
let reloads = daemon.reload_count();
let err = unattended(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("holds");
assert!(matches!(err, Error::Held { .. }), "{err:?}");
let shown = err.to_string();
assert!(shown.contains(&second), "{shown}");
assert_eq!(daemon.reload_count(), reloads, "nothing was reloaded");
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous)),
"and nothing was swapped"
);
}
#[tokio::test(start_paused = true)]
async fn an_operator_asking_by_name_retries_a_held_sha() {
let mut fixture = fixture_with_previous_release();
commit_on_origin(&fixture, "second.txt");
unattended(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("rolls back");
let daemon = Shepherd::ready();
let outcome = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("tries again");
assert!(matches!(outcome, Outcome::Deployed { .. }), "{outcome:?}");
assert_eq!(daemon.reload_count(), 1);
}
#[tokio::test(start_paused = true)]
async fn a_new_commit_clears_the_hold() {
let mut fixture = fixture_with_previous_release();
commit_on_origin(&fixture, "second.txt");
unattended(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("rolls back");
let third = commit_on_origin(&fixture, "third.txt");
let outcome = unattended(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("deploys the new commit");
assert_eq!(outcome, Outcome::Deployed { sha: third });
assert_eq!(fixture.state.failed, None, "the hold is cleared");
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("reads")
.failed,
None
);
}
#[tokio::test]
async fn a_failing_build_never_moves_current() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
fs::write(
fixture.origin.path().join("Flockfile.toml"),
format!("{FLOCKFILE}\n[dog.deploy.build]\ncommand = 'exit 3'\n"),
)
.expect("write Flockfile");
commit_on_origin(&fixture, "second.txt");
let err = deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("the build fails");
assert!(matches!(err, Error::Build { status: Some(3) }), "{err:?}");
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_tree_the_cutover_never_landed_on_is_refused_before_anything_happens() {
let mut fixture = fixture_before_any_release();
let err = deploy(
&Shepherd::never_ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("refuses");
assert!(matches!(err, Error::NotCutOver { .. }), "{err:?}");
let shown = err.to_string();
assert!(shown.contains("never cut over"), "{shown}");
assert!(shown.contains("shep-deploy setup web"), "{shown}");
assert_eq!(fixture.state.deployed, None, "nothing was recorded");
assert_eq!(
swap::resolve(&fixture.tree.current()).expect("reads"),
None,
"and nothing was swapped: the refusal is before any of it"
);
}
#[tokio::test]
async fn a_current_that_moved_during_the_build_is_not_swapped_over() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
fs::write(
fixture.origin.path().join("Flockfile.toml"),
format!(
"{FLOCKFILE}\n[dog.deploy.build]\ncommand = 'ln -sfn \"$PWD\" ../../current'\n"
),
)
.expect("write Flockfile");
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::never_ready();
let err = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("current moved under it");
assert!(matches!(err, Error::Raced { .. }), "{err:?}");
assert_eq!(daemon.reload_count(), 0, "no reload may be sent at all");
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
#[tokio::test(start_paused = true)]
async fn retention_spares_the_staged_release() {
let mut fixture = fixture_with_previous_release();
let staged = commit_on_origin(&fixture, "second.txt");
crate::git::fetch(
&fixture.tree.git(),
&fixture.state.remote,
"main",
fixtures::TEST_BUDGET,
)
.expect("fetch");
crate::git::worktree_add(&fixture.tree.git(), &fixture.tree.release(&staged), &staged)
.expect("the staged release");
fixture.state.staged = Some(staged.clone());
for name in ["third.txt", "fourth.txt", "fifth.txt"] {
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(&staged).exists(),
"the staged release outlives the newest two"
);
}
#[tokio::test(start_paused = true)]
async fn a_landed_deploy_clears_the_staged_sha() {
let mut fixture = fixture_with_previous_release();
let second = commit_on_origin(&fixture, "second.txt");
fixture.state.staged = Some(second.clone());
let outcome = deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert_eq!(outcome, Outcome::Deployed { sha: second });
assert_eq!(fixture.state.staged, None);
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("reads")
.staged,
None
);
}
}