#![forbid(unsafe_code)]
#[cfg(not(unix))]
compile_error!(
"shep-deploy is Unix only. This is deliberate rather than an oversight: the deploy model is \
rename(2) over a symlink, the build's privilege drop is a uid and a gid, and both are Unix \
concepts this crate uses directly rather than through a portability layer. Windows support \
is planned and will be scoped separately."
);
mod build;
mod config;
mod daemon;
mod deploy;
mod error;
#[cfg(test)]
mod fixtures;
mod flockfile;
mod git;
mod lock;
mod optin;
mod paths;
mod poll;
mod restore;
mod retention;
mod roll;
mod shared;
mod smit;
mod state;
mod survey;
mod swap;
mod verify;
use std::path::PathBuf;
use std::process::ExitCode;
use shep_client::Client;
use shep_client::shep_core::paths::ShepPaths;
use tokio::signal::unix::{Signal, SignalKind, signal};
use crate::daemon::Live;
use crate::deploy::Outcome;
use crate::error::Error;
use crate::paths::Tree;
use crate::state::{State, Watch};
const USAGE: &str = "\
usage: shep-deploy <verb> [args]
deploy <sheep> [--watch auto|manual] deploy one sheep, or set how it is watched
setup <sheep> take a sheep over
survey report where every registered sheep stands
on-remove lifecycle hook; shep runs this itself
Adopted as `deploy`, the same verbs run as `shep deploy <verb> [args]`, and
`shep deploy <sheep>` deploys one sheep directly. A sheep whose name is one of
the verbs above is reached with the verb spelled out: `shep deploy deploy survey`.";
const ROLLED_BACK: u8 = 12;
const STRANDED: u8 = 13;
#[derive(Debug, PartialEq, Eq)]
enum Route<'a> {
Poll,
OnRemove,
Survey,
Deploy(&'a str),
Setup(&'a str),
Watch { sheep: &'a str, mode: &'a str },
Usage,
}
fn route<'a>(args: &[&'a str]) -> Route<'a> {
let route = match args {
[] => Route::Poll,
["on-remove"] => Route::OnRemove,
["survey"] => Route::Survey,
["setup" | "deploy"] => Route::Usage,
["setup", sheep] => Route::Setup(sheep),
["deploy", sheep] => Route::Deploy(sheep),
["deploy", sheep, "--watch", mode] => Route::Watch { sheep, mode },
[sheep] => Route::Deploy(sheep),
[sheep, "--watch", mode] => Route::Watch { sheep, mode },
_ => Route::Usage,
};
match route {
Route::Setup(sheep) | Route::Deploy(sheep) if !paths::is_sheep_name(sheep) => Route::Usage,
Route::Watch { sheep, .. } if !paths::is_sheep_name(sheep) => Route::Usage,
named => named,
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let outcome = match route(&args) {
Route::Poll => poll_forever().await,
Route::OnRemove => return on_remove().await,
Route::Survey => survey_once().await,
Route::Deploy(sheep) => deploy_once(sheep).await,
Route::Setup(sheep) => setup_once(sheep).await,
Route::Watch { sheep, mode } => set_watch(sheep, mode),
Route::Usage => {
eprintln!("{USAGE}");
return ExitCode::from(2);
}
};
match outcome {
Ok(code) => ExitCode::from(code),
Err(err) => {
eprintln!("shep-deploy: {err}");
ExitCode::from(code_for(&err))
}
}
}
async fn poll_forever() -> Result<u8, Error> {
let home = shep_home()?;
let mut stop = Stop::install();
let client = Client::connect(&socket()?).await?;
let daemon = Live::new(client);
let config = config::read(&daemon).await?;
tokio::select! {
result = poll::run(&daemon, &home, &config) => result.map(|()| 0),
() = stop.arrives() => {
println!("shep-deploy: stopping");
Ok(0)
}
}
}
struct Stop {
term: Option<Signal>,
interrupt: Option<Signal>,
}
impl Stop {
fn install() -> Self {
Self {
term: listen(SignalKind::terminate()),
interrupt: listen(SignalKind::interrupt()),
}
}
async fn arrives(&mut self) {
let asked = match (&mut self.term, &mut self.interrupt) {
(Some(term), Some(interrupt)) => tokio::select! {
arrived = term.recv() => arrived,
arrived = interrupt.recv() => arrived,
},
(Some(only), None) | (None, Some(only)) => only.recv().await,
(None, None) => None,
};
if asked.is_none() {
core::future::pending().await
}
}
}
fn listen(kind: SignalKind) -> Option<Signal> {
match signal(kind) {
Ok(stream) => Some(stream),
Err(err) => {
eprintln!("shep-deploy: cannot listen for {kind:?}: {err}");
None
}
}
}
const fn code_for_outcome(outcome: &Outcome) -> u8 {
match outcome {
Outcome::UpToDate | Outcome::Deployed { .. } => 0,
Outcome::RolledBack { .. } => ROLLED_BACK,
}
}
fn code_for(err: &Error) -> u8 {
match err {
Error::RolledBack { .. } => ROLLED_BACK,
Error::Stranded { .. } => STRANDED,
Error::Config(_) | Error::NotCutOver { .. } => 4,
Error::Connect(_) => 5,
_ => 1,
}
}
async fn deploy_once(sheep: &str) -> Result<u8, Error> {
let tree = Tree::for_sheep(&shep_home()?, sheep);
let mut state = State::read(&tree.state_file())?;
let client = Client::connect(&socket()?).await?;
let daemon = Live::new(client);
let config = config::read(&daemon).await?;
let outcome = deploy::deploy(&daemon, &tree, &mut state, &config).await?;
match &outcome {
Outcome::UpToDate => println!("{sheep} is up to date at {}", deployed(&state)),
Outcome::Deployed { sha } => println!("{sheep} deployed {sha}"),
Outcome::RolledBack { to, why } => println!("{sheep} rolled back to {to}: {why}"),
}
Ok(code_for_outcome(&outcome))
}
async fn setup_once(sheep: &str) -> Result<u8, Error> {
let client = Client::connect(&socket()?).await?;
let daemon = Live::new(client);
let config = config::read(&daemon).await?;
let prepared = optin::prepare(&daemon, &shep_home()?, sheep, &config).await?;
let current = prepared.tree.current();
match optin::cut_over(&daemon, prepared).await {
Ok(sha) => {
println!("{sheep} now deploys from {}, at {sha}", current.display());
Ok(0)
}
Err(err @ Error::Stranded { .. }) => {
println!("{sheep} now deploys from {}", current.display());
Err(err)
}
Err(err) => Err(err),
}
}
async fn survey_once() -> Result<u8, Error> {
let client = Client::connect(&socket()?).await?;
let daemon = Live::new(client);
print!("{}", survey::survey(&daemon, &shep_home()?).await?);
Ok(0)
}
async fn on_remove() -> ExitCode {
let Ok(home) = shep_home() else {
return ExitCode::SUCCESS;
};
let Ok(socket) = socket() else {
return ExitCode::SUCCESS;
};
match Client::connect(&socket).await {
Ok(client) => {
let daemon = Live::new(client);
print!("{}", restore::report(&restore::all(&daemon, &home).await));
}
Err(err) => println!(
"no sheep were restored: the shepherd could not be reached ({err}). Any sheep this \
dog took over is still running from its deploy tree under {}.",
home.join("deploy").display()
),
}
ExitCode::SUCCESS
}
fn set_watch(sheep: &str, mode: &str) -> Result<u8, Error> {
let watch = match mode {
"auto" => Watch::Auto,
"manual" => Watch::Manual,
other => {
return Err(Error::Config(format!(
"--watch takes auto or manual, not {other:?}"
)));
}
};
let tree = Tree::for_sheep(&shep_home()?, sheep);
let mut state = State::read(&tree.state_file())?;
let was = state.watch;
deploy::set_watch(&tree, &mut state, watch)?;
if was == watch {
println!("{sheep} was already watch = {}", named(watch));
} else {
println!(
"{sheep} watch: {} -> {}, still deployed at {}",
named(was),
named(watch),
deployed(&state)
);
}
Ok(0)
}
fn shep_home() -> Result<PathBuf, Error> {
absolute(resolved().home)
}
fn socket() -> Result<PathBuf, Error> {
absolute(resolved().socket)
}
fn resolved() -> ShepPaths {
let home = std::env::home_dir().unwrap_or_default();
ShepPaths::resolve(&|key| std::env::var(key).ok(), &home)
}
fn absolute(path: PathBuf) -> Result<PathBuf, Error> {
std::path::absolute(&path).map_err(|source| Error::Io { path, source })
}
fn deployed(state: &State) -> &str {
state.deployed.as_deref().unwrap_or("nothing yet")
}
const fn named(watch: Watch) -> &'static str {
match watch {
Watch::Auto => "auto",
Watch::Manual => "manual",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rollback_has_its_own_code_distinct_from_failure_and_success() {
let rolled_back = Error::RolledBack {
to: "old-sha".to_owned(),
source: Box::new(Error::Build { status: Some(1) }),
};
assert_eq!(code_for(&rolled_back), ROLLED_BACK);
assert_ne!(ROLLED_BACK, 0);
assert_ne!(
code_for(&rolled_back),
code_for(&Error::Build { status: Some(1) })
);
}
#[test]
fn a_stranded_cutover_is_neither_a_success_nor_an_ordinary_failure() {
let stranded = Error::Stranded {
sheep: "web".to_owned(),
sha: "abc1234".to_owned(),
ids: vec![3],
};
assert_eq!(code_for(&stranded), STRANDED);
assert_ne!(STRANDED, 0);
assert_ne!(
code_for(&stranded),
code_for(&Error::Build { status: Some(1) })
);
assert_ne!(code_for(&stranded), ROLLED_BACK);
}
#[test]
fn the_shared_causes_use_sheps_own_numbers() {
assert_eq!(code_for(&Error::Config("bad".to_owned())), 4);
assert_eq!(code_for(&Error::Protocol("odd".to_owned())), 1);
assert_eq!(
code_for(&Error::Git {
command: "git fetch".to_owned(),
status: Some(128),
stderr: String::new(),
}),
1
);
assert_eq!(code_for(&Error::Build { status: Some(3) }), 1);
assert_eq!(
code_for(&Error::Connect(shep_client::ConnectError::HandshakeClosed)),
5
);
assert_eq!(
code_for(&Error::NotCutOver {
sheep: "web".to_owned(),
tree: PathBuf::from("/srv/shep/deploy/web"),
}),
4
);
}
#[test]
fn the_ok_rollback_path_reports_the_same_code() {
let outcome = Outcome::RolledBack {
to: "old-sha".to_owned(),
why: "it did not come up".to_owned(),
};
assert_eq!(code_for_outcome(&outcome), ROLLED_BACK);
assert_eq!(code_for_outcome(&Outcome::UpToDate), 0);
assert_eq!(
code_for_outcome(&Outcome::Deployed {
sha: "new".to_owned()
}),
0
);
}
#[test]
fn a_bare_name_is_a_deploy_and_a_verb_still_wins() {
assert_eq!(route(&["koji"]), Route::Deploy("koji"));
assert_eq!(route(&["deploy", "koji"]), Route::Deploy("koji"));
assert_eq!(route(&["survey"]), Route::Survey);
assert_eq!(route(&["setup", "koji"]), Route::Setup("koji"));
assert_eq!(route(&["deploy", "survey"]), Route::Deploy("survey"));
assert_eq!(route(&[]), Route::Poll);
}
#[tokio::test]
async fn both_stop_handlers_are_installed_up_front() {
let stop = Stop::install();
assert!(stop.term.is_some(), "SIGTERM");
assert!(stop.interrupt.is_some(), "SIGINT");
}
#[test]
fn on_remove_routes_to_its_own_hook() {
assert_eq!(route(&["on-remove"]), Route::OnRemove);
assert_eq!(route(&["deploy", "on-remove"]), Route::Deploy("on-remove"));
}
#[test]
fn a_verb_without_its_sheep_is_a_usage_error() {
assert_eq!(route(&["setup"]), Route::Usage);
assert_eq!(route(&["deploy"]), Route::Usage);
assert_eq!(route(&["deploy", "setup"]), Route::Deploy("setup"));
assert_eq!(route(&["deploy", "deploy"]), Route::Deploy("deploy"));
}
#[test]
fn a_sheep_name_that_is_a_path_is_refused() {
for name in ["/tmp/anywhere", "../sibling", "a/b", "", ".", ".."] {
assert_eq!(route(&[name]), Route::Usage, "bare: {name}");
assert_eq!(route(&["deploy", name]), Route::Usage, "deploy: {name}");
assert_eq!(route(&["setup", name]), Route::Usage, "setup: {name}");
assert_eq!(
route(&[name, "--watch", "auto"]),
Route::Usage,
"watch: {name}"
);
}
}
}