use std::time::Duration;
use shep_client::shep_core::config::AppConfig;
use shep_client::shep_core::protocol::ProcessInfo;
use tokio::time::{Instant, sleep};
use crate::daemon::Daemon;
use crate::error::Error;
use crate::verify::{self, DWELL, Generation, POLL, is_alive};
use crate::window::RELOAD_DEADLINE_SLACK;
pub(super) enum CutOver {
Done,
NotStarted(Error),
NotVerified(String),
Failed(Error),
}
fn died(info: &ProcessInfo) -> bool {
!is_alive(info)
}
const PORT_COLLISION: &str = "The first cutover is the one deploy that runs two instances at \
once, so an app that does not bind with SO_REUSEPORT cannot take its own port while the \
original still holds it. Every deploy after the first replaces the instance rather than \
joining it, and does not meet this. If the app cannot set SO_REUSEPORT, `shep stop` it, \
remove the tree named above, and run setup again: with the port free the newcomer binds, \
and the cutover is the one deploy allowed to be down for a moment anyway.";
pub(super) fn cutover_budget(app: &AppConfig) -> Duration {
verify::bounded(
app.listen_timeout
.as_duration()
.saturating_add(RELOAD_DEADLINE_SLACK),
)
}
pub(super) async fn attempt<D: Daemon>(
daemon: &D,
sheep: &str,
app: AppConfig,
before: &Generation,
previous: &[u32],
) -> CutOver {
let patience = cutover_budget(&app);
let wanted = usize::try_from(app.instances.max(1)).unwrap_or(1);
if let Err(source) = daemon.start(vec![app]).await {
return CutOver::NotStarted(source);
}
let started_at = Instant::now();
let deadline = started_at + patience;
let arrived = loop {
let flock = match daemon.describe(sheep).await {
Ok(flock) => flock,
Err(source) => return CutOver::Failed(source),
};
let newcomers: Vec<&ProcessInfo> = flock
.iter()
.filter(|info| !previous.contains(&info.id))
.collect();
if newcomers.iter().any(|info| died(info)) {
return CutOver::NotVerified(format!(
"The new instance failed before it finished starting. {PORT_COLLISION}"
));
}
let up: Vec<&ProcessInfo> = newcomers
.iter()
.copied()
.filter(|info| before.is_new(info))
.collect();
if up.len() >= wanted {
break Generation::of_infos(&up);
}
if Instant::now() >= deadline {
return CutOver::NotVerified(if up.is_empty() {
format!(
"No new instance appeared within {}s, although the shepherd accepted the \
start.",
started_at.elapsed().as_secs()
)
} else {
format!(
"Only {} of the {wanted} instances the start asked for appeared within \
{}s. {PORT_COLLISION}",
up.len(),
started_at.elapsed().as_secs()
)
});
}
sleep(POLL).await;
};
sleep(DWELL).await;
let flock = match daemon.describe(sheep).await {
Ok(flock) => flock,
Err(source) => return CutOver::Failed(source),
};
let survivors: Vec<&ProcessInfo> = flock
.iter()
.filter(|info| arrived.holds(info) && is_alive(info))
.collect();
if survivors.len() != arrived.instances() as usize {
return CutOver::NotVerified(format!(
"The new instance did not stay up for {}s after starting. {PORT_COLLISION}",
DWELL.as_secs()
));
}
if survivors.iter().any(|info| info.restarts > 0) {
return CutOver::NotVerified(format!(
"The instance this cutover adopted had already restarted {}s later, so what is \
running is not the process the start spawned. {PORT_COLLISION}",
DWELL.as_secs()
));
}
CutOver::Done
}
#[cfg(test)]
mod tests {
use shep_client::shep_core::status::ProcStatus;
use super::*;
use crate::optin::cut_over;
use crate::optin::test_support::{
NEWCOMER_ID, checkout_declaring, comes_up, cutover_fixture_crash_looping,
cutover_fixture_dies_during_dwell, cutover_fixture_from, cutover_fixture_never_appears,
cutover_fixture_online_then_gone, cutover_fixture_original_respawns,
};
#[test]
fn a_pidless_newcomer_is_dead_only_when_its_status_says_so() {
let row = |status| ProcessInfo::builder(99, "bpm", status).build();
for alive in [ProcStatus::Starting, ProcStatus::Online] {
assert!(
!died(&row(alive)),
"{alive:?} without a pid is not yet a verdict"
);
}
for dead in [
ProcStatus::Errored,
ProcStatus::WaitingRestart,
ProcStatus::Stopped,
ProcStatus::Stopping,
] {
assert!(died(&row(dead)), "{dead:?} without a pid is dead");
}
let with_pid = ProcessInfo::builder(99, "bpm", ProcStatus::Stopping)
.pid(Some(4242))
.build();
assert!(
died(&with_pid),
"with a pid the status decides the same way"
);
}
#[tokio::test(start_paused = true)]
async fn a_scaled_cutover_waits_for_every_instance_the_start_asked_for() {
let checkout = checkout_declaring(
"[[app]]\nname = \"bpm\"\nscript = \"./run.sh\"\nwait_ready = true\ninstances = 2\n",
);
let (daemon, prepared, _dirs) =
cutover_fixture_from(checkout, &[7, 8], comes_up(), None).await;
let daemon = daemon.spawning_at_most(1);
let err = cut_over(&daemon, prepared)
.await
.expect_err("one of two is not a cutover");
let shown = err.to_string();
assert!(shown.contains("Only 1 of the 2"), "{shown}");
assert!(
!daemon.deleted().contains(&7) && !daemon.deleted().contains(&8),
"the originals are kept: {:?}",
daemon.deleted()
);
}
#[tokio::test(start_paused = true)]
async fn a_scaled_cutover_lands_when_every_instance_appears() {
let checkout = checkout_declaring(
"[[app]]\nname = \"bpm\"\nscript = \"./run.sh\"\nwait_ready = true\ninstances = 2\n",
);
let (daemon, prepared, _dirs) =
cutover_fixture_from(checkout, &[7, 8], comes_up(), None).await;
cut_over(&daemon, prepared).await.expect("cuts over");
assert_eq!(daemon.deleted(), vec![7, 8]);
}
#[tokio::test(start_paused = true)]
async fn a_newcomer_that_dies_during_the_dwell_is_deleted_and_the_old_one_kept() {
let (daemon, prepared, _dirs) = cutover_fixture_dies_during_dwell().await;
let err = cut_over(&daemon, prepared).await.expect_err("gives up");
assert!(
daemon.deleted().contains(&NEWCOMER_ID),
"the newcomer is removed"
);
assert!(!daemon.deleted().contains(&7), "the old instance is kept");
let shown = err.to_string();
assert!(shown.contains("SO_REUSEPORT"), "{shown}");
}
#[tokio::test(start_paused = true)]
async fn a_newcomer_that_went_online_without_serving_is_still_rejected() {
let (daemon, prepared, _dirs) = cutover_fixture_online_then_gone().await;
let err = cut_over(&daemon, prepared)
.await
.expect_err("the dwell catches it");
assert!(matches!(err, Error::CutOver { .. }), "{err}");
assert!(err.to_string().contains("did not stay up"), "{err}");
}
#[tokio::test(start_paused = true)]
async fn a_newcomer_that_crash_loops_through_the_dwell_is_rejected() {
let (daemon, prepared, _dirs) = cutover_fixture_crash_looping().await;
let err = cut_over(&daemon, prepared)
.await
.expect_err("the pids moved");
assert!(matches!(err, Error::CutOver { .. }), "{err}");
assert!(err.to_string().contains("did not stay up"), "{err}");
}
#[tokio::test(start_paused = true)]
async fn a_cutover_that_spawned_nothing_does_not_blame_the_port() {
let (daemon, prepared, _dirs) = cutover_fixture_never_appears().await;
let err = cut_over(&daemon, prepared).await.expect_err("gives up");
let shown = err.to_string();
assert!(!shown.contains("SO_REUSEPORT"), "{shown}");
assert!(shown.contains("No new instance appeared"), "{shown}");
}
#[tokio::test(start_paused = true)]
async fn an_original_that_respawns_is_not_mistaken_for_the_newcomer() {
let (daemon, prepared, _dirs) = cutover_fixture_original_respawns().await;
let err = cut_over(&daemon, prepared).await.expect_err("gives up");
assert!(matches!(err, Error::CutOver { .. }), "{err}");
assert!(
err.to_string().contains("No new instance appeared"),
"{err}"
);
assert!(
!daemon.deleted().contains(&7),
"the healthy original is kept: {:?}",
daemon.deleted()
);
}
}