#![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;
mod exit;
#[cfg(test)]
mod fixtures;
mod flockfile;
mod git;
mod hook;
mod linkcheck;
mod lock;
mod optin;
mod ownership;
mod paths;
mod poll;
mod restore;
mod retarget;
mod retention;
mod roll;
mod route;
mod shared;
mod smit;
mod state;
mod survey;
mod swap;
mod verify;
mod window;
use std::path::PathBuf;
use std::process::ExitCode;
use shep_client::shep_core::paths::ShepPaths;
use shep_client::{Client, LinkState, RECONNECT_MAX_DELAY, ReconnectingClient};
use tokio::signal::unix::{Signal, SignalKind, signal};
use crate::daemon::Live;
use crate::deploy::Outcome;
use crate::error::Error;
use crate::exit::{code_for, code_for_outcome};
use crate::paths::Tree;
use crate::route::{Route, USAGE, route};
use crate::state::{State, Verify, Watch};
const DRAIN: std::time::Duration = std::time::Duration::from_secs(1);
fn main() -> ExitCode {
shep_client::dogs::probe::<config::Section>(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
let args: Vec<String> = std::env::args().skip(1).collect();
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(err) => {
eprintln!("shep-deploy: cannot start a runtime: {err}");
return ExitCode::from(1);
}
};
let code = runtime.block_on(run(&args));
runtime.shutdown_timeout(DRAIN);
code
}
async fn run(args: &[&str]) -> ExitCode {
let dog = adopted_as(&|key| std::env::var(key).ok());
let outcome = match route(args, dog.is_some()) {
Route::Poll => match &dog {
Some(name) => poll_forever(name).await,
None => return usage(),
},
Route::Help => {
println!("{USAGE}");
return ExitCode::SUCCESS;
}
Route::OnRemove => return on_remove().await,
Route::Survey => survey_once().await,
Route::Deploy(sheep) => deploy_once(sheep).await,
Route::Setup(sheep) => setup_once(sheep, Verify::default()).await,
Route::SetupVerify { sheep, mode } => match parse_verify(mode) {
Ok(verify) => setup_once(sheep, verify).await,
Err(err) => Err(err),
},
Route::Watch { sheep, mode } => set_watch(sheep, mode),
Route::Branch { sheep, name } => {
shep_home().and_then(|home| retarget::branch_once(&Tree::for_sheep(&home, sheep), name))
}
Route::Usage => return usage(),
};
match outcome {
Ok(code) => ExitCode::from(code),
Err(err) => {
eprintln!("shep-deploy: {err}");
ExitCode::from(code_for(&err))
}
}
}
fn usage() -> ExitCode {
eprintln!("{USAGE}");
ExitCode::from(2)
}
const DOG_NAME: &str = "SHEP_DOG_NAME";
async fn poll_forever(name: &str) -> Result<u8, Error> {
let home = shep_home()?;
let mut stop = Stop::install();
let socket = socket()?;
let client = ReconnectingClient::connect_as_dog(&socket, name).await?;
let daemon = Live::dog(client);
let config = config::Reader::open(&daemon).await?;
tokio::select! {
result = poll::run(&daemon, &home, config) => result.map(|()| 0),
refusal = refused(&daemon) => Err(refusal),
() = stop.arrives() => {
println!("shep-deploy: stopping");
Ok(0)
}
}
}
fn adopted_as(env: &dyn Fn(&str) -> Option<String>) -> Option<String> {
env(DOG_NAME).filter(|name| !name.trim().is_empty())
}
async fn refused(daemon: &Live) -> Error {
loop {
if let Some(LinkState::Refused {
daemon_version,
message,
}) = daemon.link()
{
return Error::Refused {
daemon_version,
message,
};
}
tokio::time::sleep(RECONNECT_MAX_DELAY).await;
}
}
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
}
}
}
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::command(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, verify: Verify) -> Result<u8, Error> {
let client = Client::connect(&socket()?).await?;
let daemon = Live::command(client);
let config = config::read(&daemon).await?;
let prepared = optin::prepare_with(&daemon, &shep_home()?, sheep, &config, verify).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),
}
}
fn parse_verify(mode: &str) -> Result<Verify, Error> {
match mode {
"probed" => Ok(Verify::Probed),
"alive" => Ok(Verify::Alive),
other => Err(Error::Config(format!(
"--verify takes probed or alive, not {other:?}"
))),
}
}
async fn survey_once() -> Result<u8, Error> {
let client = Client::connect(&socket()?).await?;
let daemon = Live::command(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::command(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 (was, state) = deploy::set_watch(&tree, 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_supervised_dog_announces_the_name_shep_gave_it() {
let env = |key: &str| (key == DOG_NAME).then(|| "log-rotate".to_owned());
assert_eq!(adopted_as(&env).as_deref(), Some("log-rotate"));
}
#[test]
fn a_hand_run_announces_no_name_at_all() {
assert_eq!(adopted_as(&|_| None), None);
assert_eq!(adopted_as(&|_| Some(String::new())), None);
assert_eq!(adopted_as(&|_| Some(" ".to_owned())), None);
}
#[test]
fn only_shep_dog_name_is_read() {
let env = |key: &str| (key == "SHEP_NAME").then(|| "web".to_owned());
assert_eq!(adopted_as(&env), None);
}
#[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 an_unknown_verify_mode_is_refused_by_name() {
assert_eq!(parse_verify("alive").expect("alive"), Verify::Alive);
assert_eq!(parse_verify("probed").expect("probed"), Verify::Probed);
let err = parse_verify("Alive").expect_err("case matters");
assert!(matches!(err, Error::Config(_)), "{err}");
assert!(err.to_string().contains("--verify"), "{err}");
}
}