mod prepare;
mod record;
mod reload;
mod rollback;
#[cfg(test)]
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, lock, shared, swap};
use prepare::{head_of, refuse_ungated_verification};
use record::{record, record_landing};
use reload::{Landed, land, named};
use rollback::{roll_back, undo_swap};
pub(crate) use prepare::stage_release;
pub use record::set_watch;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
UpToDate,
Deployed {
sha: String,
},
RolledBack {
to: String,
why: 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, budget) = (tree.git(), state.remote.clone(), config.git_timeout);
shared::off_thread(move || git::fetch(&git_dir, &remote, 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,
});
}
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 (app, spec) = {
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, &app, state.verify)?;
build::run(
sheep,
&release,
&spec,
app.user.as_deref(),
&config.passthrough,
&tree.cache_target(),
config.build_timeout,
)
.await?;
let previous = swap::resolve(&tree.current())?;
if previous != started_at {
return Err(Error::Raced {
sheep: sheep.to_owned(),
started: named(started_at.as_deref()),
found: named(previous.as_deref()),
});
}
swap::point_at(&tree.current(), &release)?;
match land(daemon, sheep, &app, state.verify).await {
Landed::Verified => {
if let Err(err) = record_landing(tree, state, head) {
eprintln!("shep-deploy: {sheep}: deployed {head}, but could not record it: {err}");
}
Ok(Outcome::Deployed {
sha: head.to_owned(),
})
}
Landed::NotStarted(source) => {
undo_swap(tree, state, previous.as_deref(), head, &source)?;
Err(source)
}
Landed::NotVerified { why, patience } => {
let to = roll_back(
daemon,
tree,
state,
&app,
previous.as_deref(),
head,
&why,
patience,
)
.await?;
Ok(Outcome::RolledBack { to, why })
}
Landed::Failed { source, patience } => {
let to = roll_back(
daemon,
tree,
state,
&app,
previous.as_deref(),
head,
&source.to_string(),
patience,
)
.await?;
Err(Error::RolledBack {
to,
source: Box::new(source),
})
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use crate::deploy::test_support::{
FLOCKFILE, Shepherd, commit_on_origin, fixture_before_any_release,
fixture_with_previous_release,
};
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 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()));
}
}