use std::path::Path;
use std::time::Duration;
use tokio::time::{Instant, sleep};
use shep_client::RequestError;
use shep_client::shep_core::config::AppConfig;
use crate::daemon::Daemon;
use crate::error::Error;
use crate::state::Verify;
use crate::verify;
use crate::verify::Generation;
use crate::window::{budget, dwell_for};
pub(super) enum Landed {
Verified,
NotStarted(Error),
NotVerified {
why: String,
patience: Duration,
},
Failed {
source: Error,
patience: Duration,
},
}
pub(super) async fn land<D: Daemon>(
daemon: &D,
sheep: &str,
app: &AppConfig,
mode: Verify,
) -> Landed {
let before = match Generation::of(daemon, sheep).await {
Ok(before) => before,
Err(source) => return Landed::NotStarted(source),
};
let patience = budget(app, before.instances());
if let Err(source) = daemon.reload(sheep).await {
return if never_reached_the_shepherd(&source) {
Landed::NotStarted(source)
} else {
Landed::Failed { source, patience }
};
}
let reloaded_at = Instant::now();
match verify::wait(daemon, sheep, mode, &before, patience, dwell_for(app)).await {
Ok(true) => Landed::Verified,
Ok(false) => Landed::NotVerified {
why: format!(
"it did not come up and stay up, {}s after the reload",
reloaded_at.elapsed().as_secs()
),
patience,
},
Err(source) => Landed::Failed { source, patience },
}
}
fn never_reached_the_shepherd(err: &Error) -> bool {
matches!(
err,
Error::Request(RequestError::Rpc(_) | RequestError::Wire(_))
)
}
const RETRY_EVERY: Duration = Duration::from_millis(500);
pub(super) async fn reload_until<D: Daemon>(
daemon: &D,
sheep: &str,
patience: Duration,
) -> Result<Result<Generation, Error>, Error> {
let deadline = Instant::now() + patience;
loop {
let before = Generation::of(daemon, sheep).await;
match daemon.reload(sheep).await {
Ok(()) => return Ok(before),
Err(err) => {
if !err.is_retryable() || Instant::now() >= deadline {
return Err(err);
}
sleep(RETRY_EVERY).await;
}
}
}
}
pub(super) fn named(release: Option<&Path>) -> String {
release.map_or_else(|| "nothing".to_owned(), sha_of)
}
pub(super) fn sha_of(release: &Path) -> String {
release.file_name().map_or_else(
|| release.display().to_string(),
|sha| sha.to_string_lossy().into_owned(),
)
}
#[cfg(test)]
mod tests {
use core::error::Error as _;
use std::time::Duration;
use shep_client::shep_core::protocol::RpcErrorCode;
use shep_client::shep_core::protocol::wire::WireError;
use shep_client::shep_core::protocol::{ProcessInfo, RpcError};
use shep_client::shep_core::status::ProcStatus;
use crate::deploy::test_support::{Shepherd, commit_on_origin, fixture_with_previous_release};
use crate::deploy::{Outcome, deploy};
use crate::error::Error;
use crate::fixtures;
use crate::swap;
use super::*;
#[tokio::test(start_paused = true)]
async fn a_reload_that_keeps_the_old_instance_is_rolled_back() {
let mut fixture = fixture_with_previous_release();
let previous = fixture.state.deployed.clone().expect("a previous release");
commit_on_origin(&fixture, "second.txt");
let outcome = deploy(
&Shepherd::keeps_the_old_instance(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert!(matches!(outcome, Outcome::RolledBack { .. }), "{outcome:?}");
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
#[tokio::test(start_paused = true)]
async fn the_window_follows_the_running_flock_not_the_flockfile() {
let mut fixture = fixture_with_previous_release();
let second = commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::ready_after(200).running(2);
let outcome = deploy(
&daemon,
&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 a_rollback_retries_a_reload_the_shepherd_is_too_busy_for() {
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::busy_for(3);
let outcome = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
assert!(matches!(outcome, Outcome::RolledBack { .. }), "{outcome:?}");
assert_eq!(daemon.attempt_count(), 5);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
#[tokio::test(start_paused = true)]
async fn the_reason_quotes_what_elapsed_not_what_was_allowed() {
let mut fixture = fixture_with_previous_release();
fixture.state.verify = crate::state::Verify::Alive;
commit_on_origin(&fixture, "second.txt");
let outcome = deploy(
&Shepherd::flapping(),
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect("completes");
let Outcome::RolledBack { why, .. } = outcome else {
panic!("a release that will not stay up must roll back: {outcome:?}");
};
assert!(why.contains("10s"), "{why}");
assert!(!why.contains("16s"), "{why}");
}
#[tokio::test(start_paused = true)]
async fn a_reload_that_can_never_succeed_is_not_retried_or_called_a_split() {
let mut fixture = fixture_with_previous_release();
commit_on_origin(&fixture, "second.txt");
let daemon = Shepherd::unregistered();
let err = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("the rollback cannot reload");
assert!(!matches!(err, Error::Split { .. }), "{err:?}");
assert!(matches!(err, Error::RollbackFailed { .. }), "{err:?}");
assert!(err.to_string().contains("NotFound"), "{err}");
assert_eq!(daemon.attempt_count(), 2);
}
#[tokio::test(start_paused = true)]
async fn a_reload_whose_reply_is_lost_is_rolled_back() {
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::losing_replies(1);
let err = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("no reply came back");
assert!(matches!(err, Error::RolledBack { .. }), "{err:?}");
assert!(
matches!(
err.source()
.and_then(<dyn core::error::Error>::downcast_ref),
Some(Error::Request(RequestError::Timeout { .. }))
),
"{err:?}"
);
assert_eq!(daemon.attempt_count(), 2);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
#[test]
fn only_a_definite_refusal_counts_as_never_started() {
let rpc = Error::Request(RequestError::Rpc(RpcError {
code: RpcErrorCode::Internal,
message: "web is already being reloaded".to_owned(),
daemon_version: None,
}));
assert!(never_reached_the_shepherd(&rpc));
assert!(never_reached_the_shepherd(&Error::Request(
RequestError::Wire(WireError::FrameTooLarge(1 << 30))
)));
assert!(!never_reached_the_shepherd(&Error::Request(
RequestError::Timeout {
after: Duration::from_secs(7)
}
)));
assert!(!never_reached_the_shepherd(&Error::Request(
RequestError::Closed
)));
assert!(!never_reached_the_shepherd(&Error::Protocol(
"a Flock in answer to Reload".to_owned()
)));
}
#[tokio::test(start_paused = true)]
async fn a_refused_reload_is_not_a_split_state() {
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::too_busy_to_start();
let err = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("the shepherd would not take the reload");
assert!(!matches!(err, Error::Split { .. }), "{err:?}");
assert!(err.to_string().contains("already being reloaded"), "{err}");
assert_eq!(daemon.attempt_count(), 1);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
#[tokio::test]
async fn a_verify_error_after_the_reload_still_reloads_the_rollback() {
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::describe_fails();
let err = deploy(
&daemon,
&fixture.tree,
&mut fixture.state,
&fixtures::dog_config(),
)
.await
.expect_err("describe fails");
let Error::RollbackUnverified { why, found, .. } = &err else {
panic!("a rollback nothing could confirm is unverified: {err:?}");
};
assert!(why.contains("the shepherd stopped answering"), "{why}");
assert!(found.contains("the shepherd stopped answering"), "{found}");
assert_eq!(
daemon.reload_count(),
2,
"one reload onto the new release, one onto the old"
);
assert_eq!(
swap::resolve(&fixture.tree.current()).unwrap(),
Some(fixture.tree.release(&previous))
);
assert_eq!(fixture.state.deployed.as_deref(), Some(previous.as_str()));
}
struct Drifting {
looks: std::cell::Cell<u32>,
refusals: std::cell::Cell<u32>,
}
impl Daemon for Drifting {
async fn describe(&self, sheep: &str) -> Result<Vec<ProcessInfo>, Error> {
self.looks.set(self.looks.get() + 1);
Ok(vec![
ProcessInfo::builder(0, sheep, ProcStatus::Online)
.pid(Some(1000 + self.looks.get()))
.build(),
])
}
async fn reload(&self, sheep: &str) -> Result<(), Error> {
let refusals = self.refusals.get();
if refusals == 0 {
return Ok(());
}
self.refusals.set(refusals - 1);
Err(Error::Request(RequestError::Rpc(RpcError {
code: RpcErrorCode::Internal,
message: format!("{sheep} is already being reloaded"),
daemon_version: None,
})))
}
crate::fixtures::daemon_methods!(unimplemented;
dog_config, list_flock, start, delete, restart, save_roll, set_smit,
);
}
#[tokio::test(start_paused = true)]
async fn the_generation_is_taken_before_the_attempt_the_shepherd_accepts() {
let daemon = Drifting {
looks: std::cell::Cell::new(0),
refusals: std::cell::Cell::new(2),
};
let before = reload_until(&daemon, "web", Duration::from_secs(30))
.await
.expect("the third attempt is accepted")
.expect("every look was answered");
let at_acceptance = ProcessInfo::builder(0, "web", ProcStatus::Online)
.pid(Some(1003))
.build();
assert_eq!(before, Generation::of_infos(&[&at_acceptance]));
}
}