use shep_client::shep_core::protocol::ProcessInfo;
use super::attempt::{CutOver, attempt};
use super::prepare::Prepared;
use super::undo::undo_start;
use crate::daemon::Daemon;
use crate::error::Error;
use crate::lock;
use crate::state::{State, Watch};
use crate::verify::Generation;
pub async fn cut_over<D: Daemon>(daemon: &D, prepared: Prepared) -> Result<String, Error> {
let (hold, tree, mut state, sha, app, previous_config) = prepared.into_parts();
let _hold = hold;
let sheep = tree.sheep().to_owned();
let before = daemon.describe(&sheep).await?;
let rows: Vec<&ProcessInfo> = before.iter().collect();
let previous: Vec<u32> = rows.iter().map(|info| info.id).collect();
let generation = Generation::of_infos(&rows);
let mut registering = app;
registering.cwd = Some(tree.current().display().to_string());
if registering.interpreter.is_none() {
registering.interpreter = previous_config.interpreter.clone();
}
match attempt(daemon, &sheep, registering, &generation, &previous).await {
CutOver::Done => {
let mut stranded = Vec::new();
for id in previous {
if daemon.delete(id).await.is_err() {
stranded.push(id);
}
}
state.deployed = Some(sha.clone());
if stranded.is_empty() {
state.watch = Watch::Auto;
}
let outcome = if stranded.is_empty() {
Ok(sha)
} else {
Err(Error::Stranded {
sheep: sheep.clone(),
sha,
ids: stranded,
})
};
let written = lock::hold_record(&tree).and_then(|_record| {
if let Ok(on_disk) = State::read(&tree.state_file()) {
state.verify = on_disk.verify;
}
state.write(&tree.state_file())
});
if let Err(err) = written {
if outcome.is_ok() {
return Err(err);
}
eprintln!("shep-deploy: {sheep}: could not record the cutover: {err}");
}
outcome
}
CutOver::NotStarted(source) => Err(source),
CutOver::NotVerified(why) => {
let undone = undo_start(daemon, &sheep, &previous, previous_config).await;
Err(Error::CutOver {
sheep,
why,
removed: undone.removed,
repaired: undone.recorded,
tree: tree.root().to_owned(),
source: None,
})
}
CutOver::Failed(source) => {
let undone = undo_start(daemon, &sheep, &previous, previous_config).await;
Err(Error::CutOver {
sheep,
why: SHEPHERD_QUIET.to_owned(),
removed: undone.removed,
repaired: undone.recorded,
tree: tree.root().to_owned(),
source: Some(Box::new(source)),
})
}
}
}
const SHEPHERD_QUIET: &str = "The shepherd stopped answering while the new instance was being \
watched, so nothing was established about it either way.";
#[cfg(test)]
mod tests {
use core::error::Error as _;
use super::*;
use crate::optin::test_support::{
NEWCOMER_ID, cutover_fixture, cutover_fixture_comes_up_but_refuses_deletes,
cutover_fixture_dies_during_dwell, cutover_fixture_never_appears,
cutover_fixture_refusing_start, cutover_fixture_shepherd_goes_quiet,
cutover_fixture_with_instances,
};
use crate::state::Verify;
#[tokio::test(start_paused = true)]
async fn the_new_registration_names_current_explicitly() {
let (daemon, prepared, _dirs) = cutover_fixture().await;
let current = prepared.tree.current();
cut_over(&daemon, prepared).await.expect("cuts over");
let started = daemon.started();
assert_eq!(started.len(), 1, "exactly one Start");
assert_eq!(
started[0].cwd.as_deref(),
current.to_str(),
"cwd must be the current symlink itself, not a release and not None"
);
}
#[tokio::test(start_paused = true)]
async fn a_verify_edited_during_the_cutover_survives_its_record_write() {
let (daemon, prepared, _dirs) = cutover_fixture().await;
let path = prepared.tree.state_file();
let mut edited = State::read(&path).expect("the record prepare wrote");
assert_eq!(edited.verify, Verify::Probed, "the default before the edit");
edited.verify = Verify::Alive;
edited.write(&path).expect("the operator's edit");
cut_over(&daemon, prepared).await.expect("cuts over");
let after = State::read(&path).expect("reads");
assert_eq!(after.verify, Verify::Alive, "the edit survives");
assert_eq!(after.watch, Watch::Auto, "and the promotion still lands");
assert!(after.deployed.is_some());
}
#[tokio::test(start_paused = true)]
async fn the_new_registration_keeps_the_interpreter_the_sheep_ran_under() {
let (daemon, mut prepared, _dirs) = cutover_fixture().await;
prepared.previous_config.interpreter = Some("node".to_owned());
prepared.app.interpreter = None;
cut_over(&daemon, prepared).await.expect("cuts over");
assert_eq!(daemon.started()[0].interpreter.as_deref(), Some("node"));
let (daemon, mut prepared, _dirs) = cutover_fixture().await;
prepared.previous_config.interpreter = Some("node".to_owned());
prepared.app.interpreter = Some("none".to_owned());
cut_over(&daemon, prepared).await.expect("cuts over");
assert_eq!(daemon.started()[0].interpreter.as_deref(), Some("none"));
}
#[tokio::test(start_paused = true)]
async fn only_the_old_instances_are_deleted_and_by_id() {
let (daemon, prepared, _dirs) = cutover_fixture().await;
cut_over(&daemon, prepared).await.expect("cuts over");
assert_eq!(daemon.deleted(), vec![7], "the pre-existing instance's id");
assert!(
!daemon.deleted().contains(&NEWCOMER_ID),
"99 is the newcomer"
);
}
#[tokio::test(start_paused = true)]
async fn every_replaced_instance_is_deleted() {
let (daemon, prepared, _dirs) = cutover_fixture_with_instances(&[7, 8]).await;
cut_over(&daemon, prepared).await.expect("cuts over");
assert_eq!(daemon.deleted(), vec![7, 8]);
}
#[tokio::test(start_paused = true)]
async fn a_refused_start_deletes_nothing() {
let (daemon, prepared, _dirs) = cutover_fixture_refusing_start().await;
let err = cut_over(&daemon, prepared).await.expect_err("refused");
assert!(matches!(err, Error::Request(_)), "{err}");
assert!(daemon.deleted().is_empty());
}
#[tokio::test(start_paused = true)]
async fn a_refused_delete_names_every_instance_it_left_behind() {
let (daemon, prepared, _dirs) = cutover_fixture_comes_up_but_refuses_deletes().await;
let path = prepared.tree.state_file();
let sha = prepared.sha.clone();
let err = cut_over(&daemon, prepared).await.expect_err("names them");
let shown = err.to_string();
assert!(shown.contains("shep delete 7"), "{shown}");
assert!(
shown.contains("shep delete 8"),
"not just the first one: {shown}"
);
assert_eq!(
State::read(&path).expect("reads").deployed,
Some(sha),
"the release is serving, so the record names it"
);
}
#[tokio::test(start_paused = true)]
async fn a_shepherd_that_goes_quiet_still_reports_the_repair_it_could_not_make() {
let (daemon, prepared, _dirs) = cutover_fixture_shepherd_goes_quiet().await;
let err = cut_over(&daemon, prepared).await.expect_err("gives up");
assert!(
matches!(
err,
Error::CutOver {
repaired: false,
..
}
),
"{err}"
);
assert!(err.source().is_some(), "the shepherd's own error is kept");
let shown = err.to_string();
assert!(shown.contains("stopped answering"), "{shown}");
assert!(
shown.contains("muster"),
"the roll paragraph fires: {shown}"
);
}
#[tokio::test(start_paused = true)]
async fn an_abandoned_cutover_says_the_target_is_not_deployable_yet() {
let (daemon, prepared, _dirs) = cutover_fixture_dies_during_dwell().await;
let tree = prepared.tree.root().to_owned();
let err = cut_over(&daemon, prepared).await.expect_err("gives up");
let shown = err.to_string();
assert!(shown.contains("is NOT a deploy target"), "{shown}");
assert!(shown.contains("Do NOT run `shep deploy bpm`"), "{shown}");
assert!(shown.contains("shep-deploy setup bpm"), "{shown}");
assert!(
shown.contains(&format!("remove {}", tree.display())),
"{shown}"
);
assert!(!shown.contains("$SHEP_HOME"), "{shown}");
}
#[tokio::test(start_paused = true)]
async fn the_record_advances_only_after_the_newcomer_is_online() {
let (daemon, prepared, _dirs) = cutover_fixture_never_appears().await;
let path = prepared.tree.state_file();
let err = cut_over(&daemon, prepared).await.expect_err("gives up");
assert!(matches!(err, Error::CutOver { .. }), "{err}");
assert_eq!(State::read(&path).expect("reads").deployed, None);
}
#[tokio::test(start_paused = true)]
async fn a_prepared_target_is_not_watched_until_the_cutover_lands() {
let (daemon, prepared, _dirs) = cutover_fixture().await;
let path = prepared.tree.state_file();
assert_eq!(
State::read(&path).expect("reads").watch,
Watch::Manual,
"prepared, and nothing has served from the tree yet"
);
cut_over(&daemon, prepared).await.expect("cuts over");
assert_eq!(
State::read(&path).expect("reads").watch,
Watch::Auto,
"the cutover landed, so the poll loop may have it"
);
}
#[tokio::test(start_paused = true)]
async fn an_abandoned_cutover_leaves_the_target_unwatched() {
let (daemon, prepared, _dirs) = cutover_fixture_never_appears().await;
let path = prepared.tree.state_file();
cut_over(&daemon, prepared).await.expect_err("gives up");
assert_eq!(State::read(&path).expect("reads").watch, Watch::Manual);
}
#[tokio::test(start_paused = true)]
async fn a_stranded_cutover_does_not_start_the_loop_off_on_its_own() {
let (daemon, prepared, _dirs) = cutover_fixture_comes_up_but_refuses_deletes().await;
let path = prepared.tree.state_file();
let err = cut_over(&daemon, prepared).await.expect_err("is stranded");
assert!(matches!(err, Error::Stranded { .. }), "{err}");
let state = State::read(&path).expect("reads");
assert!(state.deployed.is_some(), "the release is serving");
assert_eq!(state.watch, Watch::Manual);
}
#[tokio::test(start_paused = true)]
async fn the_record_and_the_answer_name_the_release_that_was_cut_over() {
let (daemon, prepared, _dirs) = cutover_fixture().await;
let path = prepared.tree.state_file();
let expected = prepared.sha.clone();
let sha = cut_over(&daemon, prepared).await.expect("cuts over");
assert_eq!(sha, expected, "the sha it answers with");
assert_eq!(State::read(&path).expect("reads").deployed, Some(expected));
}
}