use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::{Instant, sleep};
use shep_client::RequestError;
use shep_client::shep_core::config::AppConfig;
use crate::config::DogConfig;
use crate::daemon::Daemon;
use crate::error::Error;
use crate::lock;
use crate::paths::Tree;
use crate::state::{State, Verify, Watch};
use crate::verify::Generation;
use crate::{build, flockfile, git, retention, shared, swap, verify};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
UpToDate,
Deployed {
sha: String,
},
RolledBack {
to: String,
why: String,
},
}
pub(crate) const RELOAD_DEADLINE_SLACK: Duration = Duration::from_secs(5);
fn budget(app: &AppConfig, instances: u32) -> Duration {
let per_instance = app.listen_timeout.as_duration()
+ app.graceful_timeout.as_duration()
+ RELOAD_DEADLINE_SLACK;
per_instance.saturating_mul(instances.max(1))
}
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())?;
git::fetch(&tree.git(), &state.remote, config.git_timeout)?;
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);
outcome
}
pub(crate) fn checkout_release(tree: &Tree, sha: &str) -> Result<(), Error> {
let git_dir = tree.git();
let release = tree.release(sha);
let marker = tree.completion(sha);
if marker.is_file() && release.exists() {
return Ok(());
}
if release.exists() {
fs::remove_dir_all(&release).map_err(|source| Error::Io {
path: release.clone(),
source,
})?;
git::worktree_prune(&git_dir)?;
}
git::worktree_add(&git_dir, &release, sha)?;
if let Some(parent) = marker.parent() {
fs::create_dir_all(parent).map_err(|source| Error::Io {
path: parent.to_owned(),
source,
})?;
}
fs::write(&marker, b"").map_err(|source| Error::Io {
path: marker,
source,
})
}
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);
checkout_release(tree, head)?;
shared::link_cache(&release, &tree.cache_target())?;
let shared_paths = shared::to_link(&state.checkout)?;
shared::link_into(&release, &state.checkout, &shared_paths)?;
let app = flockfile::app_config(&release, sheep, &shared_paths)?;
refuse_ungated_verification(sheep, &app, state.verify)?;
let spec = flockfile::build_spec(&release, &shared_paths)?;
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 => {
state.deployed = Some(head.to_owned());
state.write(&tree.state_file())?;
Ok(Outcome::Deployed {
sha: head.to_owned(),
})
}
Landed::NotStarted(source) => {
undo_swap(tree, previous.as_deref(), &source)?;
Err(source)
}
Landed::NotVerified { why, patience } => {
let to = roll_back(
daemon,
tree,
state,
previous.as_deref(),
head,
&why,
patience,
)
.await?;
Ok(Outcome::RolledBack { to, why })
}
Landed::Failed { source, patience } => {
let to = roll_back(
daemon,
tree,
state,
previous.as_deref(),
head,
&source.to_string(),
patience,
)
.await?;
Err(Error::RolledBack {
to,
source: Box::new(source),
})
}
}
}
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 landed_elsewhere(tree, head, failed.as_deref()) {
Some(fresher) => *state = fresher,
None => {
state.failed = failed;
if let Err(err) = state.write(&tree.state_file()) {
eprintln!("shep-deploy: {sheep}: could not record what {head} came to: {err}");
}
}
}
}
if let Err(err) = retention::prune(tree, keep) {
eprintln!("shep-deploy: {sheep}: could not reclaim old releases: {err}");
}
}
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)
}
enum Landed {
Verified,
NotStarted(Error),
NotVerified {
why: String,
patience: Duration,
},
Failed {
source: Error,
patience: Duration,
},
}
async fn land<D: Daemon>(daemon: &D, sheep: &str, app: &AppConfig, mode: Verify) -> Landed {
let before = match Generation::of(daemon, sheep).await {
Ok(before) => before,
Err(source) => return Landed::NotStarted(source),
};
let patience = budget(app, before.instances());
if let Err(source) = daemon.reload(sheep).await {
return if never_reached_the_shepherd(&source) {
Landed::NotStarted(source)
} else {
Landed::Failed { source, patience }
};
}
let reloaded_at = Instant::now();
match verify::wait(daemon, sheep, mode, &before, patience).await {
Ok(true) => Landed::Verified,
Ok(false) => Landed::NotVerified {
why: format!(
"it did not come up and stay up, {}s after the reload",
reloaded_at.elapsed().as_secs()
),
patience,
},
Err(source) => Landed::Failed { source, patience },
}
}
fn never_reached_the_shepherd(err: &Error) -> bool {
matches!(
err,
Error::Request(RequestError::Rpc(_) | RequestError::Wire(_))
)
}
fn undo_swap(tree: &Tree, previous: Option<&Path>, why: &Error) -> Result<(), Error> {
let Some(previous) = previous else {
return Ok(());
};
swap::point_at(&tree.current(), previous).map_err(|source| Error::RollbackFailed {
why: why.to_string(),
source: Box::new(source),
})
}
pub fn set_watch(tree: &Tree, state: &mut State, watch: Watch) -> Result<(), Error> {
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())
}
fn head_of(tree: &Tree, branch: &str) -> Result<String, Error> {
match git::remote_head(&tree.git(), branch) {
Ok(sha) => Ok(sha),
Err(Error::Git {
command,
status,
stderr,
}) if stderr.contains("Needed a single revision") => Err(Error::Git {
command,
status,
stderr: format!(
"the fetch succeeded but the remote has no branch named {branch:?} - it was \
deleted or renamed upstream, or never existed - so there is nothing to deploy: \
{stderr}"
),
}),
Err(other) => Err(other),
}
}
fn refuse_ungated_verification(sheep: &str, app: &AppConfig, mode: Verify) -> Result<(), Error> {
if mode == Verify::Probed && app.readiness_probe.is_none() && !app.wait_ready {
return Err(Error::Config(format!(
"{sheep} has verify = \"probed\" but neither a readiness_probe nor wait_ready, so \
there is nothing for a deploy to wait on: shep reports a sheep with no readiness \
gate Online as soon as it has not died, which would verify every release, including \
a broken one. Add a [readiness_probe] to its Flockfile, or set wait_ready if the app \
announces itself on the channel, or set verify = \"alive\" in deploy.toml to accept \
the weaker check deliberately."
)));
}
Ok(())
}
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| (sha_of(&release) != 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());
state.write(&tree.state_file())?;
}
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),
}
}
const RETRY_EVERY: Duration = Duration::from_millis(500);
async fn reload_until<D: Daemon>(daemon: &D, sheep: &str, patience: Duration) -> Result<(), Error> {
let deadline = Instant::now() + patience;
loop {
match daemon.reload(sheep).await {
Ok(()) => return Ok(()),
Err(err) => {
if !err.is_retryable() || Instant::now() >= deadline {
return Err(err);
}
sleep(RETRY_EVERY).await;
}
}
}
}
fn named(release: Option<&Path>) -> String {
release.map_or_else(|| "nothing".to_owned(), sha_of)
}
fn sha_of(release: &Path) -> String {
release.file_name().map_or_else(
|| release.display().to_string(),
|sha| sha.to_string_lossy().into_owned(),
)
}
#[cfg(test)]
mod tests {
use crate::fixtures;
use shep_client::shep_core::protocol::RpcErrorCode;
use shep_client::shep_core::protocol::wire::WireError;
fn test_config_keeping(retention: usize) -> crate::config::DogConfig {
crate::config::DogConfig {
retention,
..test_config()
}
}
fn test_config() -> crate::config::DogConfig {
crate::config::DogConfig {
interval: std::time::Duration::from_secs(30),
retention: 5,
git_timeout: std::time::Duration::from_secs(60),
build_timeout: std::time::Duration::from_secs(60),
passthrough: Vec::new(),
}
}
use super::*;
use core::error::Error as _;
use std::cell::Cell;
use std::fs;
use std::path::PathBuf;
use shep_client::shep_core::config::AppConfig;
use shep_client::shep_core::protocol::{ProcessInfo, RpcError};
use shep_client::shep_core::status::ProcStatus;
use shep_client::shep_core::values::UpDuration;
use tempfile::TempDir;
use crate::swap;
const FLOCKFILE: &str = "[[app]]\nname = 'web'\nscript = './run.sh'\n\n\
[app.readiness_probe]\nkind = 'exec'\ntarget = 'true'\n";
struct Fixture {
home: TempDir,
origin: TempDir,
tree: Tree,
state: State,
}
const RECLAIMED: &str = "0000000000000000000000000000000000000000";
fn fixture_before_any_release() -> Fixture {
let home = tempfile::tempdir().expect("tempdir");
let origin = tempfile::tempdir().expect("tempdir");
fixtures::run_git(origin.path(), &["init", "-q", "-b", "main"]);
fixtures::run_git(origin.path(), &["config", "user.email", "test@example.com"]);
fixtures::run_git(origin.path(), &["config", "user.name", "test"]);
fs::write(origin.path().join("Flockfile.toml"), FLOCKFILE).expect("write Flockfile");
fixtures::run_git(origin.path(), &["add", "."]);
fixtures::run_git(origin.path(), &["commit", "-q", "-m", "first"]);
let tree = Tree::for_sheep(home.path(), "web");
fs::create_dir_all(tree.git()).expect("create git dir");
fixtures::run_git(&tree.git(), &["init", "-q", "--bare"]);
let remote = origin.path().to_str().expect("utf-8 path").to_owned();
crate::git::fetch(&tree.git(), &remote, fixtures::TEST_BUDGET).expect("fetch");
let state = State {
remote,
branch: "main".to_owned(),
deployed: None,
failed: None,
verify: crate::state::Verify::Probed,
watch: crate::state::Watch::Manual,
origin_cwd: None,
origin_script: None,
checkout: origin.path().to_owned(),
};
state.write(&tree.state_file()).expect("write deploy.toml");
Fixture {
home,
origin,
tree,
state,
}
}
fn fixture_with_previous_release() -> Fixture {
let mut fixture = fixture_before_any_release();
let first = crate::git::remote_head(&fixture.tree.git(), "main").expect("head");
install_release(&fixture, &first);
fixture.state.deployed = Some(first);
fixture
.state
.write(&fixture.tree.state_file())
.expect("write deploy.toml");
fixture
}
fn install_release(fixture: &Fixture, sha: &str) {
let tree = &fixture.tree;
if !tree.release(sha).exists() {
crate::git::worktree_add(&tree.git(), &tree.release(sha), sha).expect("worktree");
}
swap::point_at(&tree.current(), &tree.release(sha)).expect("swap");
}
fn commit_on_origin(fixture: &Fixture, name: &str) -> String {
fs::write(fixture.origin.path().join(name), "x").expect("write");
fixtures::run_git(fixture.origin.path(), &["add", "."]);
fixtures::run_git(fixture.origin.path(), &["commit", "-q", "-m", name]);
fixtures::head_of(fixture.origin.path())
}
struct Shepherd {
settles_to: ProcStatus,
replaces: bool,
describe_fails_from: Option<u32>,
plant: Option<PathBuf>,
refusals: Cell<u32>,
refusal_code: RpcErrorCode,
refuse_from_the_first: Cell<bool>,
replies_lost: Cell<u32>,
attempts: Cell<u32>,
flapping: bool,
instances: u32,
turnover_after: u32,
describes: Cell<u32>,
reloads: Cell<u32>,
}
const FIRST_PID: u32 = 12835;
impl Shepherd {
fn ready() -> Self {
Self {
settles_to: ProcStatus::Online,
replaces: true,
describe_fails_from: None,
plant: None,
refusals: Cell::new(0),
refusal_code: RpcErrorCode::Internal,
refuse_from_the_first: Cell::new(false),
replies_lost: Cell::new(0),
attempts: Cell::new(0),
flapping: false,
instances: 1,
turnover_after: 0,
describes: Cell::new(0),
reloads: Cell::new(0),
}
}
fn never_ready() -> Self {
Self {
settles_to: ProcStatus::Starting,
..Self::ready()
}
}
fn keeps_the_old_instance() -> Self {
Self {
replaces: false,
..Self::ready()
}
}
fn describe_fails() -> Self {
Self {
describe_fails_from: Some(1),
..Self::ready()
}
}
fn unreachable() -> Self {
Self {
describe_fails_from: Some(0),
..Self::ready()
}
}
fn too_busy_to_start() -> Self {
let shepherd = Self::ready();
shepherd.refusals.set(u32::MAX);
shepherd.refuse_from_the_first.set(true);
shepherd
}
fn planting(plant: PathBuf) -> Self {
Self {
plant: Some(plant),
..Self::never_ready()
}
}
fn busy_for(times: u32) -> Self {
let shepherd = Self::never_ready();
shepherd.refusals.set(times);
shepherd
}
fn flapping() -> Self {
Self {
flapping: true,
..Self::ready()
}
}
fn ready_after(describes: u32) -> Self {
Self {
turnover_after: describes,
..Self::ready()
}
}
fn running(self, instances: u32) -> Self {
Self { instances, ..self }
}
fn losing_replies(count: u32) -> Self {
let shepherd = Self::ready();
shepherd.replies_lost.set(count);
shepherd
}
fn unregistered() -> Self {
Self {
refusal_code: RpcErrorCode::NotFound,
..Self::busy_for(u32::MAX)
}
}
fn attempt_count(&self) -> u32 {
self.attempts.get()
}
fn reload_count(&self) -> u32 {
self.reloads.get()
}
fn landed(&self) -> u32 {
if self.describes.get() > self.turnover_after {
self.reloads.get()
} else {
0
}
}
fn pid(&self, index: u32) -> u32 {
if !self.replaces {
return FIRST_PID + index;
}
let churn = if self.flapping {
self.describes.get()
} else {
0
};
FIRST_PID + (self.landed() + churn) * 100 + index
}
fn status(&self) -> ProcStatus {
if self.landed() == 0 {
ProcStatus::Online
} else {
self.settles_to
}
}
}
impl Daemon for Shepherd {
async fn dog_config(&self, _name: &str) -> Result<String, Error> {
unimplemented!()
}
async fn list_flock(&self) -> Result<Vec<ProcessInfo>, Error> {
unimplemented!()
}
async fn describe(&self, sheep: &str) -> Result<Vec<ProcessInfo>, Error> {
if let Some(plant) = &self.plant
&& plant.symlink_metadata().is_err()
{
std::os::unix::fs::symlink("somewhere", plant).expect("plant a stale tmp link");
}
self.describes.set(self.describes.get() + 1);
if self
.describe_fails_from
.is_some_and(|from| self.reloads.get() >= from)
{
return Err(Error::Protocol("the shepherd stopped answering".to_owned()));
}
Ok((0..self.instances)
.map(|index| {
ProcessInfo::builder(index, sheep, self.status())
.pid(Some(self.pid(index)))
.build()
})
.collect())
}
async fn start(&self, _apps: Vec<AppConfig>) -> Result<(), Error> {
unimplemented!()
}
async fn delete(&self, _id: u32) -> Result<(), Error> {
unimplemented!()
}
async fn reload(&self, sheep: &str) -> Result<(), Error> {
self.attempts.set(self.attempts.get() + 1);
let refusals = self.refusals.get();
if (self.reloads.get() > 0 || self.refuse_from_the_first.get()) && refusals > 0 {
if refusals != u32::MAX {
self.refusals.set(refusals - 1);
}
return Err(Error::Request(RequestError::Rpc(RpcError {
code: self.refusal_code,
message: format!("{sheep} is already being reloaded"),
})));
}
self.reloads.set(self.reloads.get() + 1);
let lost = self.replies_lost.get();
if lost > 0 {
if lost != u32::MAX {
self.replies_lost.set(lost - 1);
}
return Err(Error::Request(RequestError::Timeout {
after: Duration::from_secs(7),
}));
}
Ok(())
}
async fn restart(&self, _sheep: &str) -> Result<(), Error> {
unimplemented!()
}
async fn save_roll(&self) -> Result<PathBuf, Error> {
unimplemented!()
}
async fn set_smit(&self, _sheep: &str, _text: &str) -> Result<(), Error> {
unimplemented!()
}
}
#[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,
&test_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,
&test_config(),
)
.await
.expect("completes");
assert!(matches!(outcome, Outcome::UpToDate));
}
#[tokio::test]
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, &test_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, &test_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]
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,
&test_config(),
)
.await
.expect("the winner lands");
record(
&fixture.tree,
&mut straggler,
&second,
&Err(Error::Protocol("lost the checkout race".to_owned())),
test_config().retention,
);
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]
async fn a_completion_marker_committed_by_the_repository_is_not_trusted() {
let mut fixture = fixture_with_previous_release();
fs::write(fixture.origin.path().join(".shep-complete"), "")
.expect("the repository's own marker");
fixtures::run_git(fixture.origin.path(), &["add", "-A"]);
fixtures::run_git(
fixture.origin.path(),
&["commit", "-q", "-m", "a forged marker"],
);
let second = commit_on_origin(&fixture, "second.txt");
crate::git::fetch(
&fixture.tree.git(),
&fixture.state.remote,
fixtures::TEST_BUDGET,
)
.expect("fetch");
let release = fixture.tree.release(&second);
crate::git::worktree_add(&fixture.tree.git(), &release, &second).expect("worktree");
assert!(
release.join(".shep-complete").is_file(),
"the repository's marker must really be in the checkout, or this proves nothing"
);
fs::remove_file(release.join("second.txt")).expect("a checkout that did not finish");
deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&test_config(),
)
.await
.expect("completes");
assert!(
release.join("second.txt").exists(),
"a marker the repository wrote must not vouch for a partial checkout"
);
}
#[tokio::test]
async fn a_release_whose_checkout_did_not_finish_is_checked_out_again() {
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 so the sha is checkoutable");
let release = fixture.tree.release(&second);
crate::git::worktree_add(&fixture.tree.git(), &release, &second).expect("worktree");
fs::remove_file(release.join("second.txt")).expect("a checkout that did not finish");
let outcome = deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&test_config(),
)
.await
.expect("completes");
assert_eq!(
outcome,
Outcome::Deployed {
sha: second.clone()
}
);
assert!(
release.join("second.txt").exists(),
"a deployed release must hold every file of its commit"
);
}
#[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,
&test_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 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, &test_config())
.await
.expect("rolls back");
let reloads = daemon.reload_count();
let err = unattended(&daemon, &fixture.tree, &mut fixture.state, &test_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,
&test_config(),
)
.await
.expect("rolls back");
let daemon = Shepherd::ready();
let outcome = deploy(&daemon, &fixture.tree, &mut fixture.state, &test_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,
&test_config(),
)
.await
.expect("rolls back");
let third = commit_on_origin(&fixture, "third.txt");
let outcome = unattended(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&test_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(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");
}
#[tokio::test(start_paused = true)]
async fn a_reload_that_keeps_the_old_instance_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::keeps_the_old_instance(),
&fixture.tree,
&mut fixture.state,
&test_config(),
)
.await
.expect("completes");
assert!(matches!(outcome, Outcome::RolledBack { .. }), "{outcome:?}");
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
#[test]
fn the_window_matches_the_shepherds_own_reload_deadline() {
let mut app: AppConfig =
toml::from_str("name = 'web'\nscript = './run.sh'").expect("parses");
assert_eq!(app.listen_timeout.as_duration(), Duration::from_secs(3));
assert_eq!(app.graceful_timeout.as_duration(), Duration::from_secs(8));
assert_eq!(budget(&app, 1), Duration::from_secs(16));
assert_eq!(budget(&app, 2), Duration::from_secs(32));
app.instances = 1;
assert_eq!(budget(&app, 4), Duration::from_secs(64));
app.graceful_timeout = UpDuration::from_millis(20_000);
app.listen_timeout = UpDuration::from_millis(1_000);
assert_eq!(budget(&app, 3), Duration::from_secs(78));
assert_eq!(budget(&app, 0), budget(&app, 1));
}
#[tokio::test(start_paused = true)]
async fn the_window_follows_the_running_flock_not_the_flockfile() {
let mut fixture = fixture_with_previous_release();
let second = commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::ready_after(200).running(2);
let outcome = deploy(&daemon, &fixture.tree, &mut fixture.state, &test_config())
.await
.expect("completes");
assert_eq!(outcome, Outcome::Deployed { sha: second });
}
#[tokio::test(start_paused = true)]
async fn a_rollback_retries_a_reload_the_shepherd_is_too_busy_for() {
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::busy_for(3);
let outcome = deploy(&daemon, &fixture.tree, &mut fixture.state, &test_config())
.await
.expect("completes");
assert!(matches!(outcome, Outcome::RolledBack { .. }), "{outcome:?}");
assert_eq!(daemon.attempt_count(), 5);
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_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,
&test_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 the_reason_quotes_what_elapsed_not_what_was_allowed() {
let mut fixture = fixture_with_previous_release();
fixture.state.verify = crate::state::Verify::Alive;
commit_on_origin(&fixture, "second.txt");
let outcome = deploy(
&Shepherd::flapping(),
&fixture.tree,
&mut fixture.state,
&test_config(),
)
.await
.expect("completes");
let Outcome::RolledBack { why, .. } = outcome else {
panic!("a release that will not stay up must roll back: {outcome:?}");
};
assert!(why.contains("10s"), "{why}");
assert!(!why.contains("16s"), "{why}");
}
#[tokio::test(start_paused = true)]
async fn a_reload_that_can_never_succeed_is_not_retried_or_called_a_split() {
let mut fixture = fixture_with_previous_release();
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::unregistered();
let err = deploy(&daemon, &fixture.tree, &mut fixture.state, &test_config())
.await
.expect_err("the rollback cannot reload");
assert!(!matches!(err, Error::Split { .. }), "{err:?}");
assert!(matches!(err, Error::RollbackFailed { .. }), "{err:?}");
assert!(err.to_string().contains("NotFound"), "{err}");
assert_eq!(daemon.attempt_count(), 2);
}
#[tokio::test]
async fn a_probed_target_with_no_readiness_probe_is_refused() {
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"),
"[[app]]\nname = 'web'\nscript = './run.sh'\n",
)
.expect("write a probeless Flockfile");
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::ready();
let err = deploy(&daemon, &fixture.tree, &mut fixture.state, &test_config())
.await
.expect_err("no probe to wait on");
let shown = err.to_string();
assert!(shown.contains("readiness_probe"), "{shown}");
assert!(shown.contains("alive"), "{shown}");
assert_eq!(daemon.reload_count(), 0, "nothing may be reloaded");
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
}
#[tokio::test(start_paused = true)]
async fn a_probed_target_that_waits_on_its_channel_is_not_refused() {
let mut fixture = fixture_with_previous_release();
fs::write(
fixture.origin.path().join("Flockfile.toml"),
"[[app]]\nname = 'web'\nscript = './run.sh'\nwait_ready = true\n",
)
.expect("write a channel-gated Flockfile");
let second = commit_on_origin(&fixture, "second.txt");
let outcome = deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&test_config(),
)
.await
.expect("completes");
assert_eq!(outcome, Outcome::Deployed { sha: second });
}
#[tokio::test(start_paused = true)]
async fn an_alive_target_needs_no_readiness_probe() {
let mut fixture = fixture_with_previous_release();
fixture.state.verify = crate::state::Verify::Alive;
fs::write(
fixture.origin.path().join("Flockfile.toml"),
"[[app]]\nname = 'web'\nscript = './run.sh'\n",
)
.expect("write a probeless Flockfile");
let second = commit_on_origin(&fixture, "second.txt");
let outcome = deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&test_config(),
)
.await
.expect("completes");
assert_eq!(outcome, Outcome::Deployed { sha: second });
}
#[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,
&test_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]
async fn a_branch_that_is_not_on_the_remote_says_so() {
let mut fixture = fixture_with_previous_release();
fixture.state.branch = "gone".to_owned();
let err = deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&test_config(),
)
.await
.expect_err("no such branch");
let shown = err.to_string();
assert!(shown.contains("gone"), "{shown}");
assert!(shown.contains("no branch named"), "{shown}");
assert!(shown.contains("nothing to deploy"), "{shown}");
assert!(!shown.contains('\\'), "{shown}");
}
#[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, &test_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_reload_whose_reply_is_lost_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 daemon = Shepherd::losing_replies(1);
let err = deploy(&daemon, &fixture.tree, &mut fixture.state, &test_config())
.await
.expect_err("no reply came back");
assert!(matches!(err, Error::RolledBack { .. }), "{err:?}");
assert!(
matches!(
err.source()
.and_then(<dyn core::error::Error>::downcast_ref),
Some(Error::Request(RequestError::Timeout { .. }))
),
"{err:?}"
);
assert_eq!(daemon.attempt_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()));
}
#[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,
&test_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()));
}
#[test]
fn only_a_definite_refusal_counts_as_never_started() {
let rpc = Error::Request(RequestError::Rpc(RpcError {
code: RpcErrorCode::Internal,
message: "web is already being reloaded".to_owned(),
}));
assert!(never_reached_the_shepherd(&rpc));
assert!(never_reached_the_shepherd(&Error::Request(
RequestError::Wire(WireError::FrameTooLarge(1 << 30))
)));
assert!(!never_reached_the_shepherd(&Error::Request(
RequestError::Timeout {
after: Duration::from_secs(7)
}
)));
assert!(!never_reached_the_shepherd(&Error::Request(
RequestError::Closed
)));
assert!(!never_reached_the_shepherd(&Error::Protocol(
"a Flock in answer to Reload".to_owned()
)));
}
#[tokio::test(start_paused = true)]
async fn a_refused_reload_is_not_a_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 daemon = Shepherd::too_busy_to_start();
let err = deploy(&daemon, &fixture.tree, &mut fixture.state, &test_config())
.await
.expect_err("the shepherd would not take the reload");
assert!(!matches!(err, Error::Split { .. }), "{err:?}");
assert!(err.to_string().contains("already being reloaded"), "{err}");
assert_eq!(daemon.attempt_count(), 1);
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, &test_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]
async fn a_verify_error_after_the_reload_still_reloads_the_rollback() {
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::describe_fails();
let err = deploy(&daemon, &fixture.tree, &mut fixture.state, &test_config())
.await
.expect_err("describe fails");
assert!(matches!(err, Error::RolledBack { .. }), "{err:?}");
assert!(
err.to_string().contains("the shepherd stopped answering"),
"{err}"
);
assert!(
matches!(
err.source().and_then(|s| s.downcast_ref()),
Some(Error::Protocol(_))
),
"{err:?}"
);
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))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
#[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, &test_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,
&test_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,
&test_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_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,
&test_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(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,
&test_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,
&test_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,
&test_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,
&test_config(),
)
.await
.expect_err("the swap back collides with the stale tmp link");
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}");
}
#[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, &test_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]
async fn setting_the_watch_mode_does_not_deploy() {
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");
set_watch(&fixture.tree, &mut fixture.state, Watch::Manual).expect("sets");
assert_eq!(fixture.state.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!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
#[tokio::test]
async fn a_tree_the_cutover_never_landed_on_cannot_be_watched() {
let mut fixture = fixture_before_any_release();
let err = set_watch(&fixture.tree, &mut fixture.state, 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!(fixture.state.watch, Watch::Manual, "unchanged in memory");
assert_eq!(
State::read(&fixture.tree.state_file())
.expect("deploy.toml still reads")
.watch,
Watch::Manual,
"and unchanged on disk"
);
set_watch(&fixture.tree, &mut fixture.state, Watch::Manual).expect("manual is allowed");
}
#[tokio::test]
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,
&test_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:?}");
}
}