use std::fs;
use std::path::Path;
use shep_client::shep_core::config::AppConfig;
use crate::build::BuildSpec;
use crate::error::Error;
use crate::paths::Tree;
use crate::state::Verify;
use crate::{flockfile, git, shared};
pub(crate) fn stage_release(
tree: &Tree,
checkout: &Path,
sha: &str,
) -> Result<(AppConfig, BuildSpec), Error> {
let release = tree.release(sha);
checkout_release(tree, sha)?;
shared::link_cache(&release, &tree.cache_target())?;
let shared_paths = shared::to_link(checkout)?;
shared::link_into(&release, checkout, &shared_paths)?;
let flockfiles = flockfile::read(&release, &shared_paths)?;
let app = flockfiles.app_config(tree.sheep())?;
let spec = flockfiles.build_spec()?;
Ok((app, spec))
}
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(Error::at(&release))?;
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(Error::at(parent))?;
}
fs::write(&marker, b"").map_err(Error::at(marker))
}
pub(super) 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),
}
}
pub(super) 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 change the verify line in deploy.toml to \
\"alive\" to accept the weaker check deliberately."
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs;
use crate::deploy::test_support::{Shepherd, commit_on_origin, fixture_with_previous_release};
use crate::deploy::{Outcome, deploy};
use crate::fixtures;
use crate::swap;
#[tokio::test(start_paused = true)]
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,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert!(
release.join("second.txt").exists(),
"a marker the repository wrote must not vouch for a partial checkout"
);
}
#[tokio::test(start_paused = true)]
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,
&fixtures::dog_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]
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,
&fixtures::dog_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,
&fixtures::dog_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,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert_eq!(outcome, Outcome::Deployed { sha: second });
}
#[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,
&fixtures::dog_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}");
}
}