use std::path::{Path, PathBuf};
use shep_client::shep_core::config::AppConfig;
use crate::build;
use crate::config::DogConfig;
use crate::daemon::Daemon;
use crate::deploy::refuse_ungated_at_setup;
use crate::error::Error;
use crate::git;
use crate::lock;
use crate::paths::Tree;
use crate::roll;
use crate::shared;
use crate::state::{State, Verify, Watch};
use crate::swap;
#[derive(Debug)]
pub struct Prepared {
hold: lock::Deploying,
pub tree: Tree,
pub state: State,
pub sha: String,
pub app: AppConfig,
pub previous_config: AppConfig,
}
impl Prepared {
pub(super) fn into_parts(self) -> (lock::Deploying, Tree, State, String, AppConfig, AppConfig) {
(
self.hold,
self.tree,
self.state,
self.sha,
self.app,
self.previous_config,
)
}
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "setup calls prepare_with; tests keep the default")
)]
pub async fn prepare<D: Daemon>(
daemon: &D,
shep_home: &Path,
sheep: &str,
config: &DogConfig,
) -> Result<Prepared, Error> {
prepare_with(daemon, shep_home, sheep, config, Verify::default()).await
}
pub async fn prepare_with<D: Daemon>(
daemon: &D,
shep_home: &Path,
sheep: &str,
config: &DogConfig,
verify: Verify,
) -> Result<Prepared, Error> {
let tree = Tree::for_sheep(shep_home, sheep);
let registered = roll::registered(daemon).await?;
let previous_config = registered.get(sheep).cloned().ok_or_else(|| {
Error::Config(format!(
"the shepherd has no sheep named {sheep:?} registered, so there is nothing to take \
over: `shep-deploy survey` lists every registered sheep and where it stands"
))
})?;
let checkout = PathBuf::from(previous_config.cwd.as_deref().ok_or_else(|| {
Error::Config(format!(
"shep records no working directory for {sheep}, so there is no checkout to deploy \
from"
))
})?);
if shared::same_path(&checkout, &tree.current())
&& let Some(release) = swap::resolve(&tree.current())?
&& let Some(sha) = release.file_name().and_then(|name| name.to_str())
&& crate::state::is_sha(sha)
&& release.is_dir()
{
let _deploying = lock::hold(&tree)?;
let _record = lock::hold_record(&tree)?;
let next = format!(
"Deploy it with `shep deploy {sheep}`, or change how it is watched with `shep \
deploy {sheep} --watch auto|manual`."
);
let record = match State::read(&tree.state_file()) {
Ok(mut state @ State { deployed: None, .. }) => {
state.deployed = Some(sha.to_owned());
state.write(&tree.state_file())?;
format!(
"Its record named no deployed release and has been brought up to date \
with that. {next}"
)
}
Ok(state) if state.deployed.as_deref() == Some(sha) => {
format!("Its record agrees. {next}")
}
Ok(State {
deployed: Some(other),
..
}) => format!(
"Its record names {} instead, which is what a deploy whose record could not \
be written leaves; the next deploy corrects it. {next}",
&other[..7.min(other.len())]
),
Err(Error::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
"Its record is missing, and this dog cannot rebuild the origin fields removal \
needs: write a deploy.toml by hand from another target's as a model, with \
`deployed` set to that sha, before deploying."
.to_owned()
}
Err(_) => "Its record cannot be read: repair it before deploying, and do NOT \
remove the tree, the service runs from inside it."
.to_owned(),
};
return Err(Error::Config(format!(
"{sheep} is already a deploy target: the shepherd runs it from {}, which names {}. \
{record}",
tree.current().display(),
&sha[..7]
)));
}
if shared::resolved(&checkout).starts_with(shared::resolved(tree.root())) {
return Err(Error::Config(format!(
"{sheep} is registered with its working directory inside its own deploy tree ({}), \
so there is no operator checkout to take it over from. That is what an abandoned \
cutover leaves in the shepherd's record: re-register {sheep} from its own \
Flockfile in the checkout it should deploy from, then run setup again",
checkout.display()
)));
}
if tree.state_file().is_file() {
let state = State::read(&tree.state_file()).map_err(|source| {
Error::Config(format!(
"{sheep} has a deploy tree at {} and its record at {} cannot be read. \
Refusing rather than guessing. That record is the only thing that says \
whether {sheep} was ever cut over, and the two answers need opposite \
handling. Do NOT remove the tree until you know which it is: if {sheep} WAS \
cut over, its working directory is inside it and removing it takes a running \
service's cwd with it. Restore or repair that file first - `shep describe \
{sheep}` shows whether the sheep is running from inside this tree. Reading it \
failed with: {source}",
tree.root().display(),
tree.state_file().display()
))
})?;
return Err(Error::Config(if state.deployed.is_some() {
format!(
"{sheep} is already a deploy target: its tree is at {}. Deploy it with \
`shep deploy {sheep}`, or change how it is watched with \
`shep deploy {sheep} --watch auto|manual`.",
tree.releases().display()
)
} else {
format!(
"{sheep} has a deploy tree at {} but was never cut over to it: its record names \
no deployed release, so nothing has ever been served from that tree. An \
abandoned first cutover leaves exactly this. Do NOT run `shep deploy {sheep}` \
against it - that would reload the sheep at its own checkout and report success \
for a release nothing served. Remove {} and run `shep-deploy setup {sheep}` \
again once the cause of the first failure is fixed.",
tree.releases().display(),
tree.root().display()
)
}));
}
let remote = git::remote_url(&checkout)?;
let branch = git::current_branch(&checkout)?;
let state = State {
remote,
branch,
deployed: None,
failed: None,
verify,
watch: Watch::Manual,
origin_cwd: Some(checkout.clone()),
origin_script: Some(previous_config.script.clone()),
checkout,
origin: Some(previous_config.clone()),
};
state.validate(Path::new("deploy.toml")).map_err(|err| {
Error::Config(format!(
"{sheep} cannot be recorded as it is registered ({err}). `remote` is the \
checkout's `origin`, `branch` is the checkout's HEAD, and `checkout` is the cwd \
shep has for {sheep}; fix whichever is named and run setup again"
))
})?;
let hold = lock::hold(&tree)?;
std::fs::create_dir_all(tree.releases()).map_err(Error::at(tree.releases()))?;
git::init_bare(&tree.git())?;
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 sha = git::remote_head(&tree.git(), &state.branch)?;
let release = tree.release(&sha);
let (app, spec) = {
let (tree, checkout, sha) = (tree.clone(), state.checkout.clone(), sha.clone());
shared::off_thread(move || crate::deploy::stage_release(&tree, &checkout, &sha)).await?
};
refuse_ungated_at_setup(sheep, &app, verify)?;
build::run(
sheep,
&release,
&spec,
app.user.as_deref(),
&config.passthrough,
&tree.cache_target(),
config.build_timeout,
)
.await?;
swap::point_at(&tree.current(), &release)?;
lock::hold_record(&tree).and_then(|_record| state.write(&tree.state_file()))?;
Ok(Prepared {
hold,
tree,
state,
sha,
app,
previous_config,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures;
use crate::optin::test_support::{
RollOf, checkout_declaring, checkout_with_commit, write_target,
};
#[tokio::test]
async fn opting_in_twice_is_refused_by_name() {
let home = tempfile::tempdir().expect("tempdir");
let checkout = checkout_with_commit();
write_target(
home.path(),
"bpm",
Watch::Auto,
Some("0123456789abcdef0123456789abcdef01234567"),
);
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let err = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect_err("refuses");
let shown = err.to_string();
assert!(shown.contains("bpm"), "{shown}");
assert!(shown.contains("already"), "{shown}");
}
#[tokio::test]
async fn the_pre_adoption_cwd_and_script_are_recorded() {
let home = tempfile::tempdir().expect("tempdir");
let checkout = checkout_with_commit();
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let prepared = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect("prepares");
assert_eq!(prepared.state.origin_cwd.as_deref(), Some(checkout.path()));
assert_eq!(prepared.state.origin_script.as_deref(), Some("./run.sh"));
assert_eq!(prepared.state.checkout, checkout.path());
}
#[tokio::test]
async fn the_branch_comes_from_the_checkouts_own_head() {
let home = tempfile::tempdir().expect("tempdir");
let checkout = checkout_with_commit();
fixtures::run_git(checkout.path(), &["checkout", "-q", "-b", "stable"]);
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let prepared = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect("prepares");
assert_eq!(prepared.state.branch, "stable");
}
#[tokio::test]
async fn a_prepare_that_died_before_writing_its_record_runs_again() {
let home = tempfile::tempdir().expect("tempdir");
let checkout = checkout_with_commit();
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let first = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect("prepares");
let (first_sha, state_file) = (first.sha.clone(), first.tree.state_file());
drop(first);
std::fs::remove_file(&state_file).expect("the record a kill never wrote");
let again = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect("a tree with no record must be resumable, as the doc says");
assert_eq!(again.sha, first_sha);
assert!(
state_file.is_file(),
"the second run must leave the record the first one never wrote"
);
}
#[tokio::test]
async fn current_ends_up_on_a_release_carrying_the_shared_files() {
let home = tempfile::tempdir().expect("tempdir");
let checkout = checkout_with_commit();
std::fs::write(checkout.path().join(".gitignore"), "local.json\n").expect("write");
std::fs::write(checkout.path().join("local.json"), "{}").expect("write");
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let prepared = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect("prepares");
let current = swap::resolve(&prepared.tree.current())
.expect("reads")
.expect("current is set");
assert_eq!(current, prepared.tree.release(&prepared.sha));
assert_eq!(
std::fs::read_to_string(current.join("local.json")).expect("reads through the link"),
"{}"
);
}
#[tokio::test]
async fn a_detached_checkout_is_refused() {
let home = tempfile::tempdir().expect("tempdir");
let checkout = checkout_with_commit();
let head = fixtures::head_of(checkout.path());
fixtures::run_git(checkout.path(), &["checkout", "-q", &head]);
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let err = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect_err("refuses");
assert!(err.to_string().contains("detached"), "{err}");
}
#[tokio::test]
async fn the_record_carries_the_whole_pre_adoption_definition() {
let home = tempfile::tempdir().expect("tempdir");
let checkout = checkout_with_commit();
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let prepared = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect("prepares");
let origin = prepared.state.origin.as_ref().expect("recorded");
assert_eq!(origin, &prepared.previous_config);
let on_disk = State::read(&prepared.tree.state_file()).expect("reads");
assert_eq!(on_disk.origin.as_ref(), Some(&prepared.previous_config));
}
#[tokio::test]
async fn a_sheep_already_running_from_current_has_its_record_repaired() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "bpm");
let release = tree.release(fixtures::SHA);
std::fs::create_dir_all(&release).expect("a release directory");
swap::point_at(&tree.current(), &release).expect("current");
write_target(home.path(), "bpm", Watch::Manual, None);
let current = tree.current();
let entries = [("bpm", current.as_path())];
let daemon = RollOf(&entries);
let err = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect_err("already a target");
let shown = err.to_string();
assert!(shown.contains("already a deploy target"), "{shown}");
assert!(shown.contains("brought up to date"), "{shown}");
assert!(
!shown.contains("Remove"),
"must never say to remove the tree: {shown}"
);
assert_eq!(
State::read(&tree.state_file())
.expect("reads")
.deployed
.as_deref(),
Some(fixtures::SHA),
"the record now names what current names"
);
}
#[tokio::test]
async fn a_sheep_running_from_current_spelled_through_a_link_is_still_recognised() {
let root = tempfile::tempdir().expect("tempdir");
let home = root.path().join("home");
std::fs::create_dir_all(&home).expect("home");
let link = root.path().join("link");
std::os::unix::fs::symlink(&home, &link).expect("a second spelling");
let tree = Tree::for_sheep(&home, "bpm");
let release = tree.release(fixtures::SHA);
std::fs::create_dir_all(&release).expect("a release directory");
swap::point_at(&tree.current(), &release).expect("current");
write_target(&home, "bpm", Watch::Manual, None);
let spelled_through_link = link.join("deploy/bpm/current");
let entries = [("bpm", spelled_through_link.as_path())];
let daemon = RollOf(&entries);
let err = prepare(&daemon, &home, "bpm", &fixtures::dog_config())
.await
.expect_err("already a target");
let shown = err.to_string();
assert!(shown.contains("already a deploy target"), "{shown}");
assert!(
!shown.contains("Remove"),
"must never say to remove the tree: {shown}"
);
}
#[tokio::test]
async fn a_sheep_running_from_current_with_no_record_is_told_to_write_one() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "bpm");
let release = tree.release(fixtures::SHA);
std::fs::create_dir_all(&release).expect("a release directory");
swap::point_at(&tree.current(), &release).expect("current");
let current = tree.current();
let entries = [("bpm", current.as_path())];
let daemon = RollOf(&entries);
let err = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect_err("already a target");
let shown = err.to_string();
assert!(shown.contains("record is missing"), "{shown}");
assert!(!shown.contains("Remove"), "{shown}");
}
#[tokio::test]
async fn a_record_that_cannot_be_read_refuses_rather_than_guessing() {
let home = tempfile::tempdir().expect("tempdir");
let checkout = checkout_with_commit();
write_target(
home.path(),
"bpm",
Watch::Auto,
Some("0123456789abcdef0123456789abcdef01234567"),
);
let tree = Tree::for_sheep(home.path(), "bpm");
std::fs::write(tree.state_file(), "this is not toml = = =").expect("corrupt the record");
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let err = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect_err("refuses");
let shown = err.to_string();
assert!(shown.contains("cannot be read"), "{shown}");
assert!(
shown.contains("Do NOT remove the tree"),
"the irreversible instruction is withheld: {shown}"
);
assert!(
!shown.contains("was never cut over"),
"it must not claim the cutover was abandoned: {shown}"
);
}
#[tokio::test]
async fn an_abandoned_target_is_not_pointed_at_shep_deploy() {
let home = tempfile::tempdir().expect("tempdir");
let checkout = checkout_with_commit();
write_target(home.path(), "bpm", Watch::Auto, None);
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let err = prepare(&daemon, home.path(), "bpm", &fixtures::dog_config())
.await
.expect_err("refuses");
let shown = err.to_string();
assert!(shown.contains("never cut over"), "{shown}");
assert!(shown.contains("Do NOT run `shep deploy bpm`"), "{shown}");
assert!(
shown.contains("shep-deploy setup bpm"),
"it says how to try again: {shown}"
);
}
fn ungated_checkout_building(marker: &Path) -> tempfile::TempDir {
checkout_declaring(&format!(
"[[app]]\nname = \"bpm\"\nscript = \"./run.sh\"\n\n[dog.deploy.build]\ncommand = \"touch {}\"\n",
marker.display()
))
}
#[tokio::test]
async fn an_ungated_app_under_probed_is_refused_before_it_is_built_or_recorded() {
let home = tempfile::tempdir().expect("tempdir");
let scratch = tempfile::tempdir().expect("tempdir");
let marker = scratch.path().join("built");
let checkout = ungated_checkout_building(&marker);
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let err = prepare_with(
&daemon,
home.path(),
"bpm",
&fixtures::dog_config(),
Verify::Probed,
)
.await
.expect_err("refuses");
assert!(matches!(err, Error::Config(_)), "{err}");
assert!(
err.to_string().contains("--verify alive"),
"it names the way out: {err}"
);
assert!(
!err.to_string().contains("deploy.toml"),
"no record exists to edit: {err}"
);
let tree = Tree::for_sheep(home.path(), "bpm");
assert!(!marker.exists(), "the build ran");
assert!(!tree.state_file().exists(), "a record was written");
assert_eq!(swap::resolve(&tree.current()).expect("reads"), None);
}
#[tokio::test]
async fn a_refused_setup_can_be_retried_with_alive() {
let home = tempfile::tempdir().expect("tempdir");
let scratch = tempfile::tempdir().expect("tempdir");
let checkout = ungated_checkout_building(&scratch.path().join("built"));
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let config = fixtures::dog_config();
prepare_with(&daemon, home.path(), "bpm", &config, Verify::Probed)
.await
.expect_err("refuses");
let again = prepare_with(&daemon, home.path(), "bpm", &config, Verify::Alive).await;
assert!(again.is_ok(), "{:?}", again.err());
}
#[tokio::test]
async fn an_ungated_app_under_alive_is_prepared_and_recorded_as_alive() {
let home = tempfile::tempdir().expect("tempdir");
let scratch = tempfile::tempdir().expect("tempdir");
let checkout = ungated_checkout_building(&scratch.path().join("built"));
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let prepared = prepare_with(
&daemon,
home.path(),
"bpm",
&fixtures::dog_config(),
Verify::Alive,
)
.await
.expect("prepares");
assert_eq!(prepared.state.verify, Verify::Alive);
let on_disk = State::read(&prepared.tree.state_file()).expect("reads");
assert_eq!(on_disk.verify, Verify::Alive);
}
#[tokio::test]
async fn an_app_with_wait_ready_is_prepared_under_probed() {
let home = tempfile::tempdir().expect("tempdir");
let checkout = checkout_declaring(
"[[app]]\nname = \"bpm\"\nscript = \"./run.sh\"\nwait_ready = true\n",
);
let entries = [("bpm", checkout.path())];
let daemon = RollOf(&entries);
let prepared = prepare_with(
&daemon,
home.path(),
"bpm",
&fixtures::dog_config(),
Verify::Probed,
)
.await
.expect("prepares");
assert_eq!(prepared.state.verify, Verify::Probed);
}
}