use std::fs;
use std::path::Path;
use shep_client::shep_core::config::AppConfig;
use crate::build::BuildSpec;
use crate::error::Error;
use crate::hook::Hooks;
use crate::paths::Tree;
use crate::settings::Settings;
use crate::state::Verify;
use crate::{git, linkcheck, shared};
pub(crate) struct Staged {
pub(crate) app: AppConfig,
pub(crate) spec: BuildSpec,
pub(crate) hooks: Hooks,
pub(crate) linked: shared::FromCheckout,
}
pub(crate) fn stage_release(
tree: &Tree,
checkout: &Path,
sha: &str,
app: &AppConfig,
settings: &Settings,
) -> Result<Staged, Error> {
let release = tree.release(sha);
checkout_release(tree, sha)?;
shared::link_cache(&release, &tree.cache_target())?;
let linked = shared::to_link(checkout)?;
shared::link_into(&release, checkout, &linked)?;
let staged = staged(app, settings, linked);
for warning in linkcheck::warnings(tree.sheep(), &staged.linked, &staged.spec.artifacts) {
eprintln!("{warning}");
}
Ok(staged)
}
pub(crate) fn read_staged(
tree: &Tree,
checkout: &Path,
sha: &str,
app: &AppConfig,
settings: &Settings,
) -> Result<Staged, Error> {
let release = tree.release(sha);
let linked = shared::to_link(checkout)?;
shared::link_into(&release, checkout, &linked)?;
Ok(staged(app, settings, linked))
}
fn staged(app: &AppConfig, settings: &Settings, linked: shared::FromCheckout) -> Staged {
Staged {
app: app.clone(),
spec: settings.build_spec(),
hooks: settings.hooks(),
linked,
}
}
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(crate) fn head_of(tree: &Tree, branch: &str) -> Result<String, Error> {
let sheep = tree.sheep();
match git::remote_head(&tree.git(), branch) {
Ok(sha) => Ok(sha),
Err(Error::Git {
command,
status,
stderr,
}) if stderr.contains("Needed a single revision") => {
let kind = if branch.starts_with("refs/") {
"ref"
} else {
"branch"
};
Err(Error::Git {
command,
status,
stderr: format!(
"the fetch succeeded but the remote has no {kind} named {branch:?} - it was \
deleted or renamed upstream, or never existed - so there is nothing to \
deploy (to follow another branch, run `shep-deploy deploy {sheep} --branch \
<name>`): {stderr}"
),
})
}
Err(other) => Err(other),
}
}
pub(crate) fn refuse_ungated_verification(
sheep: &str,
app: &AppConfig,
mode: Verify,
) -> Result<(), Error> {
refuse_ungated(
sheep,
app,
mode,
"set `verify = \"alive\"` in its `[app.dogs.deploy]` table",
)
}
pub(crate) fn refuse_ungated_at_setup(
sheep: &str,
app: &AppConfig,
mode: Verify,
) -> Result<(), Error> {
refuse_ungated(
sheep,
app,
mode,
&format!("run `shep-deploy setup {sheep} --verify alive`"),
)
}
fn refuse_ungated(
sheep: &str,
app: &AppConfig,
mode: Verify,
accept_alive: &str,
) -> 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 app config, or set wait_ready if the app \
announces itself on the channel, or {accept_alive} to accept the weaker check \
deliberately."
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs;
use crate::deploy::test_support::{
FIXTURE_TABLE, 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,
"main",
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,
"main",
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 the_staged_app_is_the_stored_one_whatever_the_release_carries() {
let fixture = fixture_with_previous_release();
fs::write(
fixture.origin.path().join("Flockfile.toml"),
"[[app]]\nname = 'web'\nscript = './elsewhere.sh'\nuser = 'root'\n",
)
.expect("write Flockfile");
let second = commit_on_origin(&fixture, "second.txt");
crate::git::fetch(
&fixture.tree.git(),
&fixture.state.remote,
"main",
fixtures::TEST_BUDGET,
)
.expect("fetch");
let stored = fixtures::gated_app("web", FIXTURE_TABLE);
let settings = crate::settings::from_app("web", &stored).expect("settings");
let staged = super::stage_release(
&fixture.tree,
&fixture.state.checkout,
&second,
&stored,
&settings,
)
.expect("staged");
let shipped = fs::read_to_string(fixture.tree.release(&second).join("Flockfile.toml"))
.expect("the release carries its Flockfile");
assert!(shipped.contains("elsewhere.sh"), "{shipped}");
assert_eq!(staged.app, stored);
assert_eq!(staged.app.script, "./run.sh");
assert_eq!(staged.app.user, None);
}
#[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");
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::ready().with_app(fixtures::app_with_settings("web", FIXTURE_TABLE));
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();
let mut app = fixtures::app_with_settings("web", FIXTURE_TABLE);
app.wait_ready = true;
let second = commit_on_origin(&fixture, "second.txt");
let outcome = deploy(
&Shepherd::ready().with_app(app),
&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;
let second = commit_on_origin(&fixture, "second.txt");
let outcome = deploy(
&Shepherd::ready().with_app(fixtures::app_with_settings(
"web",
"branch = 'main'\nwatch = 'manual'\nverify = 'alive'\n",
)),
&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("shep-deploy deploy web --branch <name>"),
"{shown}"
);
assert!(!shown.contains('\\'), "{shown}");
}
#[tokio::test]
async fn a_ref_that_is_not_on_the_remote_says_so() {
let mut fixture = fixture_with_previous_release();
fixture.state.branch = "refs/heads/gone".to_owned();
let err = deploy(
&Shepherd::ready(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("no such ref");
let shown = err.to_string();
assert!(shown.contains("refs/heads/gone"), "{shown}");
assert!(shown.contains("no ref named"), "{shown}");
assert!(!shown.contains("no branch named"), "{shown}");
assert!(shown.contains("nothing to deploy"), "{shown}");
}
}