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::shared::run_git;
use crate::state::Verify;
use crate::{flockfile, 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) -> Result<Staged, 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 staged = read_release(tree, &release, shared_paths)?;
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) -> Result<Staged, Error> {
let release = tree.release(sha);
let shared_paths = shared::to_link(checkout)?;
shared::link_into(&release, checkout, &shared_paths)?;
refuse_rewritten(tree, sha, &shared_paths)?;
read_release(tree, &release, shared_paths)
}
fn refuse_rewritten(
tree: &Tree,
sha: &str,
shared_paths: &shared::FromCheckout,
) -> Result<(), Error> {
let release = tree.release(sha);
let git_dir = tree.git();
let mut names = vec!["Flockfile.toml"];
if !shared_paths.includes_override() {
names.push(flockfile::OVERRIDE);
}
for name in names {
let path = release.join(name);
let on_disk = match fs::symlink_metadata(&path) {
Ok(meta) if meta.file_type().is_file() => Some(
run_git(
&git_dir,
&["hash-object", "--no-filters", "--", &path.to_string_lossy()],
)?
.trim()
.to_owned(),
),
Ok(_) => Some(String::new()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => return Err(Error::at(&path)(err)),
};
let committed = run_git(
&git_dir,
&["rev-parse", "--verify", "-q", &format!("{sha}:{name}")],
)
.ok()
.map(|hash| hash.trim().to_owned());
if committed != on_disk {
return Err(Error::Config(format!(
"{name} in {} is not what {sha} commits: a build can rewrite it after the stage, \
and a promote reads the app and the hooks from it. Remove the release and run \
`shep-deploy deploy {} --stage` again.",
release.display(),
tree.sheep()
)));
}
}
Ok(())
}
fn read_release(
tree: &Tree,
release: &Path,
shared_paths: shared::FromCheckout,
) -> Result<Staged, Error> {
let flockfiles = flockfile::read(release, &shared_paths)?;
let app = flockfiles.app_config(tree.sheep())?;
let spec = flockfiles.build_spec()?;
let hooks = flockfiles.hooks()?;
Ok(Staged {
app,
spec,
hooks,
linked: shared_paths,
})
}
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,
"change the verify line in deploy.toml to \"alive\"",
)
}
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 Flockfile, 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::{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 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("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}");
}
}