use std::collections::BTreeMap;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use shep_client::{Client, START_DEADLINE};
use shep_core::config::{AppConfig, FlockFormat, Flockfile, FlockfileError};
use shep_core::protocol::{ProcessInfo, Request, Response, SelectorSpec};
use shep_core::selector::ProcessSelector;
use crate::cli::Format;
use crate::cli::{SelectorArgs, StartArgs, StockArgs};
use crate::commands::bounded::{Bounded, run_bounded};
use crate::commands::selector::parse_selector;
use crate::exit::ExitCode;
use crate::output::{DeletedIds, FlockRows, Render, Streams, emit, emit_flock, write_outcome};
#[derive(Debug)]
pub enum TargetError {
Stdin(std::io::Error),
Read {
path: PathBuf,
source: std::io::Error,
},
Flockfile(FlockfileError),
Unresolvable {
target: String,
},
UnknownFlockfileFormat {
path: PathBuf,
},
Js {
detail: String,
node_missing: bool,
},
}
impl std::fmt::Display for TargetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Stdin(err) => write!(f, "failed to read stdin: {err}"),
Self::Read { path, source } => {
write!(f, "failed to read {}: {source}", path.display())
}
Self::Flockfile(err) => write!(f, "{err}"),
Self::Unresolvable { target } => write!(
f,
"{target} is not a sheep, a fold, `-`, a recognised Flockfile, or an \
existing path"
),
Self::UnknownFlockfileFormat { path } => write!(
f,
"--flockfile needs a .toml, .yaml, .yml, .json, .json5 or .js file; {} is none of those",
path.display()
),
Self::Js { detail, .. } => f.write_str(detail),
}
}
}
impl core::error::Error for TargetError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Stdin(err) | Self::Read { source: err, .. } => Some(err),
Self::Flockfile(err) => Some(err),
Self::Unresolvable { .. } | Self::UnknownFlockfileFormat { .. } | Self::Js { .. } => {
None
}
}
}
}
impl From<FlockfileError> for TargetError {
fn from(source: FlockfileError) -> Self {
Self::Flockfile(source)
}
}
pub(crate) fn target_exit_code(err: &TargetError) -> ExitCode {
match err {
TargetError::Stdin(_) => ExitCode::Failure,
TargetError::Read { .. } | TargetError::Unresolvable { .. } => ExitCode::Usage,
TargetError::Flockfile(_) => ExitCode::InvalidConfig,
TargetError::UnknownFlockfileFormat { .. } => ExitCode::Usage,
TargetError::Js {
node_missing: true, ..
} => ExitCode::Failure,
TargetError::Js {
node_missing: false,
..
} => ExitCode::InvalidConfig,
}
}
const JS_EVAL_BUDGET: Duration = Duration::from_secs(30);
const JS_BRIDGE_SCRIPT: &str = "try { \
process.stdout.write(JSON.stringify(require(process.argv[1]))); \
} catch (err) { \
process.stderr.write(err && err.message ? String(err.message) : String(err)); \
process.exitCode = 1; \
}";
fn evaluate_js_flockfile(path: &Path, budget: Duration) -> Result<String, TargetError> {
let absolute = std::fs::canonicalize(path).map_err(|source| TargetError::Read {
path: path.to_path_buf(),
source,
})?;
let mut command = std::process::Command::new("node");
command
.arg("-e")
.arg(JS_BRIDGE_SCRIPT)
.arg(&absolute)
.stdin(std::process::Stdio::null());
let output = match run_bounded(&mut command, budget) {
Ok(Bounded::Exited(output)) => output,
Ok(Bounded::Killed) => {
return Err(TargetError::Js {
detail: format!(
"node was still running {} after {}s, so shep killed it; a Flockfile \
module has to export its config and let node exit, and one that leaves a \
server listening or a timer armed does not",
path.display(),
budget.as_secs_f32()
),
node_missing: false,
});
}
Ok(Bounded::OutputHeldOpen) => {
return Err(TargetError::Js {
detail: format!(
"node finished with {} within {}s, but a process it left behind still \
holds the output shep was reading, so shep gave up on it; a Flockfile \
module must not leave a child of its own on node's stdout or stderr",
path.display(),
budget.as_secs_f32()
),
node_missing: false,
});
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(TargetError::Js {
detail: format!(
"reading a .js Flockfile runs it through node, and node was not found on PATH; \
install node, or convert {} to a .toml Flockfile",
path.display()
),
node_missing: true,
});
}
Err(err) => {
return Err(TargetError::Js {
detail: format!("could not run node for {}: {err}", path.display()),
node_missing: false,
});
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let reason = stderr
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("node exited non-zero and said nothing");
return Err(TargetError::Js {
detail: format!("node could not evaluate {}: {reason}", path.display()),
node_missing: false,
});
}
String::from_utf8(output.stdout).map_err(|_utf8_error| TargetError::Js {
detail: format!("node printed non-UTF-8 output for {}", path.display()),
node_missing: false,
})
}
pub fn resolve_target(
target: &str,
name: Option<&str>,
stdin: &[u8],
as_flockfile: bool,
) -> Result<Vec<AppConfig>, TargetError> {
let path = Path::new(target);
match (target, FlockFormat::from_path(path)) {
("-", _) => {
let source = String::from_utf8(stdin.to_vec()).map_err(|_utf8_error| {
TargetError::Stdin(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"stdin is not UTF-8",
))
})?;
Ok(Flockfile::parse(&source, FlockFormat::Json)?.apps)
}
(_, format) if as_flockfile => match format {
Some(format) => {
let source = std::fs::read_to_string(path).map_err(|source| TargetError::Read {
path: path.to_path_buf(),
source,
})?;
let flockfile = Flockfile::parse(&source, format)?;
Ok(default_cwd_to_flockfile_dir(flockfile.apps, path))
}
None if path.extension().and_then(|e| e.to_str()) == Some("js") => {
let json = evaluate_js_flockfile(path, JS_EVAL_BUDGET)?;
let flockfile = Flockfile::parse(&json, FlockFormat::Json)?;
Ok(default_cwd_to_flockfile_dir(flockfile.apps, path))
}
None => Err(TargetError::UnknownFlockfileFormat {
path: path.to_path_buf(),
}),
},
(_, Some(format)) => {
let source = std::fs::read_to_string(path).map_err(|source| TargetError::Read {
path: path.to_path_buf(),
source,
})?;
let flockfile = Flockfile::parse(&source, format)?;
Ok(default_cwd_to_flockfile_dir(flockfile.apps, path))
}
_ if path.exists() => {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(target);
let script = if path.is_absolute() {
target.to_string()
} else {
std::fs::canonicalize(path)
.map(|abs| abs.to_string_lossy().into_owned())
.unwrap_or_else(|_| target.to_string())
};
let mut app = AppConfig::minimal(name.unwrap_or(stem), &script);
app.cwd = std::env::current_dir()
.ok()
.map(|dir| dir.to_string_lossy().into_owned());
Ok(vec![app])
}
_ => Err(TargetError::Unresolvable {
target: target.to_string(),
}),
}
}
async fn request_and_render<T, F>(
client: &Client,
streams: &mut Streams<'_>,
command: &str,
body: Request,
deadline: Option<Duration>,
extract: F,
) -> ExitCode
where
T: Render,
F: FnOnce(Response) -> Option<T>,
{
match client.request_with_deadline(body, deadline).await {
Ok(response) => match extract(response) {
Some(payload) => render_outcome(client, streams, command, payload).await,
None => {
let message = "the daemon answered with a response this client does not understand";
streams.fail(ExitCode::Internal, message)
}
},
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &err.to_string())
}
}
}
fn parse_selectors(
streams: &mut Streams<'_>,
raw: &[String],
) -> Result<Vec<SelectorSpec>, ExitCode> {
let mut parsed = Vec::with_capacity(raw.len());
for one in raw {
parsed.push(SelectorSpec::from(&parse_selector(streams, one)?));
}
Ok(parsed)
}
async fn request_each<I, B, F>(
client: &Client,
streams: &mut Streams<'_>,
selectors: &[SelectorSpec],
deadline: Option<Duration>,
body: B,
extract: F,
) -> (Vec<I>, Option<ExitCode>)
where
B: Fn(SelectorSpec) -> Request,
F: Fn(Response) -> Option<Vec<I>>,
{
let mut collected = Vec::new();
let mut failure: Option<ExitCode> = None;
for selector in selectors {
match client
.request_with_deadline(body(selector.clone()), deadline)
.await
{
Ok(response) => match extract(response) {
Some(mut rows) => collected.append(&mut rows),
None => {
let message =
"the daemon answered with a response this client does not understand";
failure = failure.or(Some(streams.fail(ExitCode::Internal, message)));
}
},
Err(err) => {
let code = ExitCode::from(&err);
failure = failure.or(Some(streams.fail(code, &err.to_string())));
}
}
}
(collected, failure)
}
fn flock_matches(selector: &ProcessSelector, flock: &[ProcessInfo]) -> Vec<ProcessInfo> {
let sheep_only = |flock: &[ProcessInfo], keep: &dyn Fn(&ProcessInfo) -> bool| {
flock
.iter()
.filter(|info| info.dog.is_none())
.filter(|info| keep(info))
.cloned()
.collect::<Vec<ProcessInfo>>()
};
match selector {
ProcessSelector::Name(wanted) => {
let named: Vec<ProcessInfo> = flock
.iter()
.filter(|info| &info.name == wanted)
.cloned()
.collect();
if !named.is_empty() {
return named;
}
sheep_only(flock, &|info| info.fold.as_deref() == Some(wanted.as_str()))
}
ProcessSelector::Id(wanted) => flock
.iter()
.filter(|info| info.id == *wanted)
.cloned()
.collect(),
other => sheep_only(flock, &|info| {
other.matches(&info.name, info.id, info.fold.as_deref())
}),
}
}
fn selector_miss(
target: &str,
selector: &ProcessSelector,
flock: &[ProcessInfo],
) -> Option<String> {
match selector {
ProcessSelector::All if flock.iter().any(|info| info.dog.is_some()) => Some(
"no sheep in the flock; there is nothing to start. The dogs listed \
by `shep dogs` are not sheep and `all` never reaches them"
.to_string(),
),
ProcessSelector::All => Some("the flock is empty; there is nothing to start".to_string()),
ProcessSelector::Fold(fold) => Some(format!("no sheep is in a fold called {fold}")),
ProcessSelector::Regex(_) => Some(format!("no sheep matched {target}")),
ProcessSelector::Name(_) | ProcessSelector::Id(_) => None,
}
}
fn is_reachable_as_a_name(selector: &ProcessSelector) -> bool {
match selector {
ProcessSelector::Name(name) => !name.contains(['/', '\\']) && name != "." && name != "..",
_ => true,
}
}
async fn render_outcome<T: Render>(
client: &Client,
streams: &mut Streams<'_>,
command: &str,
narrow: T,
) -> ExitCode {
if streams.fmt == Format::Json {
return write_outcome(emit(
&mut *streams.out,
streams.fmt,
command,
narrow,
streams.style,
));
}
let listing = flock_now(client).await;
write_outcome(emit_flock(
&mut *streams.out,
streams.fmt,
command,
listing,
streams.style,
))
}
fn fail_target(streams: &mut Streams<'_>, err: &TargetError) -> ExitCode {
let code = target_exit_code(err);
streams.fail(code, &err.to_string())
}
fn is_live(info: &ProcessInfo) -> bool {
use shep_core::status::ProcStatus;
matches!(
info.status,
ProcStatus::Online | ProcStatus::Starting | ProcStatus::Stopping
)
}
async fn resume_all(
client: &Client,
streams: &mut Streams<'_>,
selector: Option<&str>,
matched: &[ProcessInfo],
started: &mut Vec<ProcessInfo>,
) -> ExitCode {
let (live, asleep): (Vec<&ProcessInfo>, Vec<&ProcessInfo>) =
matched.iter().partition(|info| is_live(info));
match live.as_slice() {
[] => {}
[one] => {
let message = format!(
"{} is already {}; `shep restart {}` replaces it.",
one.name, one.status, one.name
);
streams.aside("start", &message);
}
several => {
let names: Vec<&str> = unique_names(several);
let retype = selector.map_or_else(|| names.join(" "), str::to_string);
let message = format!(
"{} are already running; `shep restart {retype}` replaces them.",
names.join(", ")
);
streams.aside("start", &message);
}
}
for name in unique_names(&asleep) {
let code = resume(client, streams, name, started).await;
if code != ExitCode::Success {
return code;
}
}
ExitCode::Success
}
fn unique_names<'a>(infos: &[&'a ProcessInfo]) -> Vec<&'a str> {
let mut names: Vec<&str> = Vec::with_capacity(infos.len());
for info in infos {
if !names.contains(&info.name.as_str()) {
names.push(&info.name);
}
}
names
}
async fn resume(
client: &Client,
streams: &mut Streams<'_>,
name: &str,
started: &mut Vec<shep_core::protocol::ProcessInfo>,
) -> ExitCode {
let (procs, failure) = request_each(
client,
streams,
&[SelectorSpec::Name(name.to_string())],
None,
|selector| Request::Restart { selector },
|response| match response {
Response::Restarted(procs) => Some(procs),
_ => None,
},
)
.await;
if any_restart_failed(&procs) {
let message = format!(
"{name} could not be started; see `shep bleats {name}` or its log files for why"
);
return streams.fail(ExitCode::SpawnFailed, &message);
}
started.extend(procs);
failure.unwrap_or(ExitCode::Success)
}
fn any_restart_failed(procs: &[shep_core::protocol::ProcessInfo]) -> bool {
procs
.iter()
.any(|info| info.status == shep_core::status::ProcStatus::Errored)
}
fn default_cwd_to_flockfile_dir(apps: Vec<AppConfig>, flockfile: &Path) -> Vec<AppConfig> {
let Some(dir) = std::fs::canonicalize(flockfile)
.ok()
.and_then(|abs| abs.parent().map(Path::to_path_buf))
else {
return apps;
};
let dir = dir.to_string_lossy().into_owned();
apps.into_iter()
.map(|mut app| {
if app.cwd.is_none() {
app.cwd = Some(dir.clone());
}
app
})
.collect()
}
async fn flock_now(client: &Client) -> Vec<shep_core::protocol::ProcessInfo> {
match client.request(Request::ListFlock).await {
Ok(Response::Flock(procs)) => procs,
_ => Vec::new(),
}
}
async fn report_config_drift(
client: &Client,
streams: &mut Streams<'_>,
resumed: &[(AppConfig, shep_core::protocol::ProcessInfo)],
) {
if resumed.is_empty() {
return;
}
let apps = resumed.iter().map(|(app, _)| app.clone()).collect();
let Ok(Response::Drifted(drifted)) = client.request(Request::ConfigDrift { apps }).await else {
return;
};
for drift in drifted {
let name = &drift.name;
let fields = drift.fields.join(", ");
let message = format!(
"{name} is registered with a different config ({fields}). `shep start` \
adds instances to a sheep the flock already has; it does not apply \
config edits, so the edit is not in effect. To apply it: `shep \
delete {name}`, then start again."
);
streams.aside("start", &message);
}
}
fn mapped_interpreter(script: &str, interpreters: &BTreeMap<String, String>) -> Option<String> {
let extension = Path::new(script).extension()?.to_str()?;
interpreters.get(extension).cloned()
}
fn apply_interpreters(
apps: &mut [AppConfig],
interpreters: &BTreeMap<String, String>,
flag: Option<&str>,
) {
if !interpreters.is_empty() {
for app in apps.iter_mut() {
if app.interpreter.is_none()
&& let Some(mapped) = mapped_interpreter(&app.script, interpreters)
{
app.interpreter = Some(mapped);
}
}
}
if let Some(interpreter) = flag {
for app in apps.iter_mut() {
app.interpreter = Some(interpreter.to_string());
}
}
}
pub async fn start(
client: &Client,
streams: &mut Streams<'_>,
args: &StartArgs,
discovered: Option<&Path>,
interpreters: &BTreeMap<String, String>,
) -> ExitCode {
if args.name.is_some() && args.targets.len() > 1 {
let message = "--name takes one target: a name belongs to one sheep";
return streams.fail(ExitCode::Usage, message);
}
if args.targets.is_empty() {
let mut started = Vec::new();
let code = start_one(
client,
streams,
args,
None,
discovered,
interpreters,
&mut started,
)
.await;
if !started.is_empty() || code == ExitCode::Success {
let wrote = render_outcome(client, streams, "start", FlockRows(started)).await;
if wrote != ExitCode::Success {
return wrote;
}
}
return code;
}
let mut failure: Option<ExitCode> = None;
let mut started = Vec::new();
for target in &args.targets {
let code = start_one(
client,
streams,
args,
Some(target),
discovered,
interpreters,
&mut started,
)
.await;
if code != ExitCode::Success {
failure = failure.or(Some(code));
}
}
if !started.is_empty() || failure.is_none() {
let wrote = render_outcome(client, streams, "start", FlockRows(started)).await;
if wrote != ExitCode::Success {
return wrote;
}
}
failure.unwrap_or(ExitCode::Success)
}
#[allow(clippy::too_many_arguments)]
async fn start_one(
client: &Client,
streams: &mut Streams<'_>,
args: &StartArgs,
target: Option<&str>,
discovered: Option<&Path>,
interpreters: &BTreeMap<String, String>,
started: &mut Vec<shep_core::protocol::ProcessInfo>,
) -> ExitCode {
let mut listing: Option<Vec<ProcessInfo>> = None;
let mut missed: Option<String> = None;
if let Some(token) = target
&& token != "-"
&& !args.flockfile
{
let selector = match parse_selector(streams, token) {
Ok(selector) => selector,
Err(code) => return code,
};
if is_reachable_as_a_name(&selector) {
let flock = flock_now(client).await;
let matched = flock_matches(&selector, &flock);
if !matched.is_empty() {
return resume_all(client, streams, Some(token), &matched, started).await;
}
missed = selector_miss(token, &selector, &flock);
listing = Some(flock);
}
}
let discovered = discovered.map(|p| p.to_string_lossy().into_owned());
let target: &str = match (target, discovered.as_deref()) {
(Some(target), _) => target,
(None, Some(found)) => found,
(None, None) => {
let message = "no target and no Flockfile in this directory";
return streams.fail(ExitCode::Usage, message);
}
};
let stdin = if target == "-" {
let mut buf = Vec::new();
if let Err(source) = std::io::stdin().lock().read_to_end(&mut buf) {
return fail_target(streams, &TargetError::Stdin(source));
}
buf
} else {
Vec::new()
};
let mut apps = match resolve_target(target, args.name.as_deref(), &stdin, args.flockfile) {
Ok(apps) => apps,
Err(TargetError::Unresolvable { .. }) if missed.is_some() => {
let message = missed.unwrap_or_default();
return streams.fail(ExitCode::NotFound, &message);
}
Err(err) => return fail_target(streams, &err),
};
if let Some(fold) = &args.fold {
for app in &mut apps {
app.fold = Some(fold.clone());
}
}
if let Some(cwd) = &args.cwd {
for app in &mut apps {
app.cwd = Some(cwd.clone());
}
}
apply_interpreters(&mut apps, interpreters, args.interpreter.as_deref());
let flock = match listing {
Some(flock) => flock,
None => flock_now(client).await,
};
let mut resumed = Vec::new();
let mut fresh = Vec::new();
for app in apps {
match flock.iter().find(|info| info.name == app.name) {
Some(existing) => resumed.push((app, existing.clone())),
None => fresh.push(app),
}
}
report_config_drift(client, streams, &resumed).await;
if !resumed.is_empty() {
let existing: Vec<ProcessInfo> = resumed.iter().map(|(_, info)| info.clone()).collect();
let code = resume_all(client, streams, None, &existing, started).await;
if code != ExitCode::Success {
return code;
}
}
if fresh.is_empty() {
return ExitCode::Success;
}
let apps = fresh;
let (procs, failure) = request_each(
client,
streams, &[SelectorSpec::All],
Some(START_DEADLINE),
|_| Request::Start { apps: apps.clone() },
|response| match response {
Response::Started(procs) => Some(procs),
_ => None,
},
)
.await;
started.extend(procs);
failure.unwrap_or(ExitCode::Success)
}
pub async fn stop(client: &Client, streams: &mut Streams<'_>, args: &SelectorArgs) -> ExitCode {
let selectors = match parse_selectors(streams, &args.selectors) {
Ok(selectors) => selectors,
Err(code) => return code,
};
let (procs, failure) = request_each(
client,
streams,
&selectors,
None,
|selector| Request::Stop { selector },
|response| match response {
Response::Stopped(procs) => Some(procs),
_ => None,
},
)
.await;
if !procs.is_empty() || failure.is_none() {
let wrote = render_outcome(client, streams, "stop", FlockRows(procs)).await;
if wrote != ExitCode::Success {
return wrote;
}
}
failure.unwrap_or(ExitCode::Success)
}
pub async fn restart(client: &Client, streams: &mut Streams<'_>, args: &SelectorArgs) -> ExitCode {
let selectors = match parse_selectors(streams, &args.selectors) {
Ok(selectors) => selectors,
Err(code) => return code,
};
let (procs, failure) = request_each(
client,
streams,
&selectors,
None,
|selector| Request::Restart { selector },
|response| match response {
Response::Restarted(procs) => Some(procs),
_ => None,
},
)
.await;
let failed: Vec<String> = procs
.iter()
.filter(|info| info.status == shep_core::status::ProcStatus::Errored)
.map(|info| info.name.clone())
.collect();
if (!procs.is_empty() || failure.is_none()) && failed.is_empty() {
let wrote = render_outcome(client, streams, "restart", FlockRows(procs)).await;
if wrote != ExitCode::Success {
return wrote;
}
}
if !failed.is_empty() {
let names = failed.join(", ");
let message = format!(
"{names} did not come back up; see `shep bleats {}` or its log files for why",
failed[0]
);
return streams.fail(ExitCode::SpawnFailed, &message);
}
failure.unwrap_or(ExitCode::Success)
}
pub async fn reload(client: &Client, streams: &mut Streams<'_>, args: &SelectorArgs) -> ExitCode {
let selectors = match parse_selectors(streams, &args.selectors) {
Ok(selectors) => selectors,
Err(code) => return code,
};
let (procs, failure) = request_each(
client,
streams,
&selectors,
None,
|selector| Request::Reload { selector },
|response| match response {
Response::Reloading(procs) => Some(procs),
_ => None,
},
)
.await;
if !procs.is_empty() || failure.is_none() {
let wrote = render_outcome(client, streams, "reload", FlockRows(procs)).await;
if wrote != ExitCode::Success {
return wrote;
}
}
failure.unwrap_or(ExitCode::Success)
}
pub async fn delete(client: &Client, streams: &mut Streams<'_>, args: &SelectorArgs) -> ExitCode {
let selectors = match parse_selectors(streams, &args.selectors) {
Ok(selectors) => selectors,
Err(code) => return code,
};
let (ids, failure) = request_each(
client,
streams,
&selectors,
None,
|selector| Request::Delete { selector },
|response| match response {
Response::Deleted(ids) => Some(ids),
_ => None,
},
)
.await;
if !ids.is_empty() || failure.is_none() {
let count = ids.len();
let listed = ids
.iter()
.map(u32::to_string)
.collect::<Vec<String>>()
.join(", ");
if count > 0 && streams.fmt != Format::Json {
let message = match count {
1 => format!("deleted 1 sheep, id {listed}"),
n => format!("deleted {n} sheep, ids {listed}"),
};
streams.aside("delete", &message);
}
let wrote = render_outcome(client, streams, "delete", DeletedIds(ids)).await;
if wrote != ExitCode::Success {
return wrote;
}
}
failure.unwrap_or(ExitCode::Success)
}
pub async fn stock(client: &Client, streams: &mut Streams<'_>, args: &StockArgs) -> ExitCode {
request_and_render(
client,
streams,
"stock",
Request::Scale {
name: args.name.clone(),
count: args.count,
},
Some(START_DEADLINE),
|response| match response {
Response::Scaled(procs) => Some(FlockRows(procs)),
_ => None,
},
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Format;
use shep_client::DEFAULT_DEADLINE;
use shep_client::testing::{fake_client_capturing_envelopes, fake_client_replying_err};
use shep_core::protocol::RpcErrorCode;
#[tokio::test]
async fn a_discovered_flockfile_is_started_when_no_target_was_given() {
let dir = tempfile::tempdir().unwrap();
let flockfile = dir.path().join("Flockfile.toml");
std::fs::write(
&flockfile,
"[[app]]\nname = \"demo\"\nscript = \"/bin/sleep\"\n",
)
.unwrap();
let sock = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&sock).await;
let mut out = Vec::new();
let mut err = Vec::new();
let args = StartArgs {
targets: Vec::new(),
name: None,
fold: None,
cwd: None,
interpreter: None,
flockfile: false,
};
{
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let _ = start(
&client,
&mut streams,
&args,
Some(flockfile.as_path()),
&BTreeMap::new(),
)
.await;
}
let sent = next_start(&mut envelopes).await;
match sent.body {
Request::Start { apps } => {
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].name, "demo");
}
other => panic!("expected a Start request, got {other:?}"),
}
}
#[test]
fn a_flockfile_app_without_a_cwd_runs_where_the_flockfile_lives() {
let dir = tempfile::tempdir().unwrap();
let flockfile = dir.path().join("Flockfile.toml");
std::fs::write(
&flockfile,
"[[app]]\nname = \"web\"\nscript = \"./sub/server\"\n",
)
.unwrap();
let apps = resolve_target(flockfile.to_str().unwrap(), None, &[], false)
.expect("the Flockfile parses");
let expected = std::fs::canonicalize(dir.path()).unwrap();
assert_eq!(
apps[0].cwd.as_deref(),
Some(expected.to_string_lossy().as_ref()),
"the app runs where its Flockfile lives"
);
}
#[test]
fn a_flockfile_app_that_sets_its_own_cwd_keeps_it() {
let dir = tempfile::tempdir().unwrap();
let flockfile = dir.path().join("Flockfile.toml");
std::fs::write(
&flockfile,
"[[app]]\nname = \"web\"\nscript = \"./server\"\ncwd = \"/srv/elsewhere\"\n",
)
.unwrap();
let apps = resolve_target(flockfile.to_str().unwrap(), None, &[], false)
.expect("the Flockfile parses");
assert_eq!(apps[0].cwd.as_deref(), Some("/srv/elsewhere"));
}
#[test]
fn a_relative_script_is_resolved_against_the_callers_cwd() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("bin");
std::fs::create_dir_all(&nested).unwrap();
let script = nested.join("thing");
std::fs::write(&script, b"#!/bin/sh\n").unwrap();
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let apps = resolve_target("./bin/thing", None, &[], false);
std::env::set_current_dir(previous).unwrap();
let apps = apps.expect("a script that exists must resolve");
assert_eq!(apps.len(), 1);
let sent = &apps[0].script;
assert!(
std::path::Path::new(sent).is_absolute(),
"the daemon resolves against its own cwd, so what crosses must be \
absolute: {sent}"
);
assert!(
std::path::Path::new(sent).exists(),
"and it must still name the real file: {sent}"
);
assert_eq!(apps[0].name, "thing", "the name still comes from the stem");
}
async fn next_start(
envelopes: &mut tokio::sync::mpsc::Receiver<shep_core::protocol::Envelope>,
) -> shep_core::protocol::Envelope {
loop {
let envelope = tokio::time::timeout(Duration::from_secs(5), envelopes.recv())
.await
.expect("start must reach the wire; it hung instead of sending a request")
.unwrap();
if envelope.body != Request::ListFlock {
return envelope;
}
}
}
fn start_args(target: &str) -> StartArgs {
StartArgs {
targets: vec![target.to_string()],
name: None,
fold: None,
cwd: None,
interpreter: None,
flockfile: false,
}
}
#[test]
fn a_dash_target_reads_a_flockfile_from_stdin_as_json() {
let apps = resolve_target(
"-",
None,
br#"{"app":[{"name":"web","script":"./srv"}]}"#,
false,
)
.unwrap();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].name, "web");
}
#[test]
fn a_recognised_extension_parses_as_a_flockfile() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flock.toml");
std::fs::write(&path, "[[app]]\nname = \"web\"\nscript = \"./srv\"\n").unwrap();
let apps = resolve_target(path.to_str().unwrap(), None, b"", false).unwrap();
assert_eq!(apps[0].name, "web");
}
#[test]
fn any_other_existing_path_becomes_one_minimal_app_named_for_its_stem() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.js");
std::fs::write(&path, "").unwrap();
let apps = resolve_target(path.to_str().unwrap(), None, b"", false).unwrap();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].name, "server");
assert_eq!(apps[0].script, path.to_str().unwrap());
}
#[test]
fn an_explicit_name_overrides_the_file_stem() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.js");
std::fs::write(&path, "").unwrap();
let apps = resolve_target(path.to_str().unwrap(), Some("api"), b"", false).unwrap();
assert_eq!(apps[0].name, "api");
}
#[test]
fn a_js_file_without_the_flag_is_still_a_script() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.js");
std::fs::write(&path, "throw new Error('this must never be evaluated')").unwrap();
let apps = resolve_target(path.to_str().unwrap(), None, b"", false).unwrap();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].name, "server");
assert_eq!(apps[0].script, path.to_str().unwrap());
}
#[test]
fn the_flag_does_not_change_a_toml_flockfile() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flock.toml");
std::fs::write(&path, "[[app]]\nname = \"web\"\nscript = \"./srv\"\n").unwrap();
let with = resolve_target(path.to_str().unwrap(), None, b"", true).unwrap();
let without = resolve_target(path.to_str().unwrap(), None, b"", false).unwrap();
assert_eq!(with, without);
}
#[test]
fn the_flag_refuses_an_extension_it_cannot_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flock.ini");
std::fs::write(&path, "").unwrap();
let err = resolve_target(path.to_str().unwrap(), None, b"", true).unwrap_err();
assert!(matches!(err, TargetError::UnknownFlockfileFormat { .. }));
assert_eq!(target_exit_code(&err), ExitCode::Usage);
}
fn node_available() -> bool {
let ok = std::process::Command::new("node")
.arg("--version")
.stdin(std::process::Stdio::null())
.output()
.is_ok_and(|o| o.status.success());
assert!(
ok || std::env::var_os("SHEP_REQUIRE_NODE").is_none(),
"SHEP_REQUIRE_NODE is set but node is not usable on PATH"
);
if !ok {
eprintln!("SKIPPED: node is not on PATH; the .js Flockfile cases did not run");
}
ok
}
#[test]
fn a_js_flockfile_under_the_flag_is_evaluated() {
if !node_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flock.js");
std::fs::write(
&path,
"module.exports = { app: [{ name: \"web\", script: \"./srv\" }] };",
)
.unwrap();
let apps = resolve_target(path.to_str().unwrap(), None, b"", true).unwrap();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].name, "web");
}
#[test]
fn a_js_flockfile_that_throws_is_an_invalid_config_quoting_node() {
if !node_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flock.js");
std::fs::write(&path, "throw new Error('sheep dip empty');").unwrap();
let err = resolve_target(path.to_str().unwrap(), None, b"", true).unwrap_err();
assert_eq!(target_exit_code(&err), ExitCode::InvalidConfig);
assert!(err.to_string().contains("sheep dip empty"), "got: {err}");
}
#[test]
fn a_js_flockfile_that_keeps_node_alive_is_killed_and_says_why() {
if !node_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flock.js");
std::fs::write(
&path,
"setInterval(() => {}, 1000); module.exports = { app: [] };",
)
.unwrap();
let started = std::time::Instant::now();
let err = evaluate_js_flockfile(&path, Duration::from_millis(200)).unwrap_err();
assert_eq!(target_exit_code(&err), ExitCode::InvalidConfig);
assert!(err.to_string().contains("still running"), "got: {err}");
assert!(
started.elapsed() < Duration::from_secs(10),
"node was waited out rather than killed, in {:?}",
started.elapsed()
);
}
#[test]
fn a_pm2_ecosystem_shape_is_refused_naming_the_right_key() {
if !node_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ecosystem.config.js");
std::fs::write(
&path,
"module.exports = { apps: [{ name: \"web\", script: \"./srv\" }] };",
)
.unwrap();
let err = resolve_target(path.to_str().unwrap(), None, b"", true).unwrap_err();
assert_eq!(target_exit_code(&err), ExitCode::InvalidConfig);
let msg = err.to_string();
assert!(msg.contains("apps"), "must name what was written: {msg}");
assert!(msg.contains("app"), "must name what was expected: {msg}");
}
#[tokio::test]
async fn a_target_naming_a_stopped_sheep_is_acted_on_not_resolved_as_a_path() {
use shep_client::testing::fake_client_on;
use shep_core::status::ProcStatus;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, daemon) = fake_client_on(&path).await;
daemon.reply_to_list(vec![
shep_core::protocol::ProcessInfo::builder(7, "zeus-auth", ProcStatus::Stopped).build(),
]);
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
start(
&client,
&mut streams,
&start_args("zeus-auth"),
None,
&BTreeMap::new(),
)
.await
};
assert_ne!(
code,
ExitCode::Usage,
"a known name must not fall through to the path arms: {}",
String::from_utf8_lossy(&err)
);
assert!(
!String::from_utf8_lossy(&err).contains("zeus-auth\" does not"),
"and must not be reported as an unresolvable target"
);
}
fn a_flock_with_a_dog() -> Vec<shep_core::protocol::ProcessInfo> {
use shep_core::protocol::{DogSource, ProcessInfo};
use shep_core::status::ProcStatus;
vec![
ProcessInfo::builder(0, "golbat", ProcStatus::Online).build(),
ProcessInfo::builder(1, "koji", ProcStatus::Stopped).build(),
ProcessInfo::builder(2, "rotom", ProcStatus::Online).build(),
ProcessInfo::builder(3, "log-rotate", ProcStatus::Online)
.dog(Some(DogSource::Adopted {
path: "/usr/local/bin/shep-log-rotate".to_string(),
}))
.build(),
]
}
#[tokio::test]
async fn a_lifecycle_verb_renders_the_whole_flock_as_a_table() {
use shep_client::testing::fake_client_on;
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let dir = tempfile::tempdir().unwrap();
let (client, daemon) = fake_client_on(&dir.path().join("s.sock")).await;
daemon.reply_to_list(a_flock_with_a_dog());
let mut out = Vec::new();
let mut err = Vec::new();
let touched = vec![ProcessInfo::builder(1, "koji", ProcStatus::Stopped).build()];
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
render_outcome(&client, &mut streams, "stop", FlockRows(touched)).await
};
assert_eq!(code, ExitCode::Success);
let printed = String::from_utf8(out).unwrap();
let (sheep, dogs) = printed
.split_once("\nDogs\n")
.unwrap_or_else(|| panic!("the dogs table needs its own caption: {printed}"));
let sheep_names: Vec<&str> = sheep
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.filter(|word| *word != "NAME")
.collect();
assert_eq!(
sheep_names,
vec!["golbat", "koji", "rotom"],
"every sheep, not only the one that was stopped, and no dog among \
them: {printed}"
);
let dog_names: Vec<&str> = dogs
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.filter(|word| *word != "NAME")
.collect();
assert_eq!(
dog_names,
vec!["log-rotate"],
"the dog renders through the dogs table: {printed}"
);
assert!(
dogs.contains("SOURCE") && dogs.contains("adopted"),
"with the SOURCE column the sheep table has not: {printed}"
);
}
#[tokio::test]
async fn the_json_surface_keeps_the_rows_the_verb_touched() {
use shep_client::testing::fake_client_on;
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let dir = tempfile::tempdir().unwrap();
let (client, daemon) = fake_client_on(&dir.path().join("s.sock")).await;
daemon.reply_to_list(a_flock_with_a_dog());
let mut out = Vec::new();
let mut err = Vec::new();
let touched = vec![ProcessInfo::builder(1, "koji", ProcStatus::Stopped).build()];
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Json,
};
render_outcome(&client, &mut streams, "stop", FlockRows(touched)).await
};
assert_eq!(code, ExitCode::Success);
let envelope: serde_json::Value = serde_json::from_slice(&out).unwrap();
let names: Vec<&str> = envelope["data"]
.as_array()
.unwrap()
.iter()
.map(|row| row["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
vec!["koji"],
"only what the verb touched: {envelope}"
);
assert_eq!(
daemon.list_flock_count(),
0,
"and no listing was fetched to build it"
);
}
fn a_foldable_flock() -> Vec<shep_core::protocol::ProcessInfo> {
use shep_core::protocol::{DogSource, ProcessInfo};
use shep_core::status::ProcStatus;
vec![
ProcessInfo::builder(0, "golbat", ProcStatus::Stopped)
.fold(Some("backed".to_string()))
.build(),
ProcessInfo::builder(1, "koji", ProcStatus::Stopped)
.fold(Some("backed".to_string()))
.build(),
ProcessInfo::builder(2, "rotom", ProcStatus::Stopped).build(),
ProcessInfo::builder(3, "log-rotate", ProcStatus::Online)
.dog(Some(DogSource::BuiltIn))
.build(),
]
}
fn matched_names(target: &str) -> Vec<String> {
let selector = ProcessSelector::parse(target).expect("the fixture uses valid selectors");
flock_matches(&selector, &a_foldable_flock())
.into_iter()
.map(|info| info.name)
.collect()
}
#[test]
fn a_start_target_walks_the_precedence() {
assert_eq!(
matched_names("koji"),
vec!["koji"],
"tier 1: a sheep by name"
);
assert_eq!(matched_names("1"), vec!["koji"], "tier 1: a sheep by id");
assert_eq!(
matched_names("fold:backed"),
vec!["golbat", "koji"],
"tier 2: a fold, named as one"
);
assert_eq!(
matched_names("backed"),
vec!["golbat", "koji"],
"tier 2: the same fold, named bare"
);
assert!(
matched_names("nosuchthing").is_empty(),
"and a token that is none of those falls through to the file tiers"
);
}
#[test]
fn a_sheep_outranks_a_fold_of_the_same_name() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let flock = vec![
ProcessInfo::builder(0, "backed", ProcStatus::Stopped).build(),
ProcessInfo::builder(1, "koji", ProcStatus::Stopped)
.fold(Some("backed".to_string()))
.build(),
];
let selector = ProcessSelector::parse("backed").unwrap();
let names: Vec<String> = flock_matches(&selector, &flock)
.into_iter()
.map(|info| info.name)
.collect();
assert_eq!(names, vec!["backed"], "the sheep, not the fold it names");
}
#[test]
fn a_wildcard_passes_a_dog_by_and_an_exact_name_reaches_it() {
assert_eq!(
matched_names("all"),
vec!["golbat", "koji", "rotom"],
"no dog in the sweep"
);
assert_eq!(
matched_names("log-rotate"),
vec!["log-rotate"],
"but naming it outright reaches it"
);
}
#[test]
fn a_token_with_a_path_separator_is_never_a_name() {
let path = ProcessSelector::parse("./backed").unwrap();
assert!(
!is_reachable_as_a_name(&path),
"./backed can only be a file"
);
let bare = ProcessSelector::parse("backed").unwrap();
assert!(is_reachable_as_a_name(&bare), "backed may be either");
let regex = ProcessSelector::parse("/web/").unwrap();
assert!(
is_reachable_as_a_name(®ex),
"a regex is not a name, so the separator rule does not apply to it"
);
}
#[test]
fn a_selector_that_matched_nothing_is_reported_as_a_selector() {
let miss = |target: &str, flock: &[shep_core::protocol::ProcessInfo]| {
selector_miss(target, &ProcessSelector::parse(target).unwrap(), flock)
};
let empty: [shep_core::protocol::ProcessInfo; 0] = [];
assert_eq!(
miss("fold:typo", &empty).as_deref(),
Some("no sheep is in a fold called typo")
);
assert_eq!(
miss("zz-*", &empty).as_deref(),
Some("no sheep matched zz-*")
);
assert_eq!(
miss("all", &empty).as_deref(),
Some("the flock is empty; there is nothing to start")
);
assert_eq!(
miss("koji", &empty),
None,
"a bare name may still be a file, so the unresolvable message stands"
);
assert_eq!(miss("11", &empty), None, "and so may a bare id");
}
#[test]
fn unique_names_drops_a_duplicate_that_is_not_adjacent() {
use shep_core::status::ProcStatus;
let rows = [
ProcessInfo::builder(0, "web", ProcStatus::Stopped).build(),
ProcessInfo::builder(1, "api", ProcStatus::Stopped).build(),
ProcessInfo::builder(2, "web", ProcStatus::Stopped).build(),
];
let borrowed: Vec<&ProcessInfo> = rows.iter().collect();
assert_eq!(
unique_names(&borrowed),
vec!["web", "api"],
"one entry per name, in the order each was first seen"
);
}
#[test]
fn an_all_that_matched_nothing_counts_sheep_and_not_dogs() {
use shep_core::protocol::{DogSource, ProcessInfo};
use shep_core::status::ProcStatus;
let all = ProcessSelector::parse("all").unwrap();
let dogs_only = [ProcessInfo::builder(0, "log-rotate", ProcStatus::Online)
.dog(Some(DogSource::BuiltIn))
.build()];
let said = selector_miss("all", &all, &dogs_only).expect("a miss is reported");
assert!(
said.starts_with("no sheep in the flock"),
"a flock holding only dogs is not empty: {said}"
);
assert!(
said.contains("`shep dogs`"),
"and it says where the rows an operator can see came from: {said}"
);
let empty: [ProcessInfo; 0] = [];
assert_eq!(
selector_miss("all", &all, &empty).as_deref(),
Some("the flock is empty; there is nothing to start"),
"with nothing registered at all, empty is the honest word"
);
}
#[tokio::test]
async fn a_start_on_an_empty_fold_exits_not_found_without_a_start_request() {
use shep_client::testing::fake_client_on;
let dir = tempfile::tempdir().unwrap();
let (client, daemon) = fake_client_on(&dir.path().join("s.sock")).await;
daemon.reply_to_list(a_foldable_flock());
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
start(
&client,
&mut streams,
&start_args("fold:typo"),
None,
&BTreeMap::new(),
)
.await
};
assert_eq!(code, ExitCode::NotFound);
let said = String::from_utf8(err).unwrap();
assert!(
said.contains("no sheep is in a fold called typo"),
"the refusal names the fold, not a file: {said}"
);
assert!(
!said.contains("existing path"),
"and never mentions a path nobody asked about: {said}"
);
assert!(out.is_empty(), "stdout stays empty on a failure");
}
#[tokio::test]
async fn a_target_naming_a_running_sheep_leaves_it_alone() {
use shep_client::testing::fake_client_on;
use shep_core::status::ProcStatus;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, daemon) = fake_client_on(&path).await;
daemon.reply_to_list(vec![
shep_core::protocol::ProcessInfo::builder(7, "zeus-auth", ProcStatus::Online).build(),
]);
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
start(
&client,
&mut streams,
&start_args("zeus-auth"),
None,
&BTreeMap::new(),
)
.await
};
assert_eq!(code, ExitCode::Success);
let said = String::from_utf8_lossy(&err);
assert!(said.contains("already"), "the operator is told: {said}");
assert!(
said.contains("shep restart zeus-auth"),
"and pointed at the verb that would replace it: {said}"
);
}
#[tokio::test]
async fn a_target_that_matches_nothing_is_a_usage_error_naming_what_was_tried() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
start(
&client,
&mut streams,
&start_args("./does-not-exist"),
None,
&BTreeMap::new(),
)
.await
};
assert_eq!(code, ExitCode::Usage);
assert!(
envelopes.try_recv().is_err(),
"a target that can only be a path costs no round trip, and an \
unresolvable one must never become a Start"
);
assert!(String::from_utf8(err).unwrap().contains("./does-not-exist"));
}
#[tokio::test]
async fn a_selector_reaches_the_wire_in_its_compiled_form() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
#[derive(Clone, Copy, Debug)]
enum Verb {
Stop,
Restart,
Reload,
Delete,
}
for verb in [Verb::Stop, Verb::Restart, Verb::Reload, Verb::Delete] {
for (input, expected) in [
("all", SelectorSpec::All),
("7", SelectorSpec::Id(7)),
("web", SelectorSpec::Name("web".into())),
("/^web-/", SelectorSpec::Regex("^web-".into())),
("fold:api", SelectorSpec::Fold("api".into())),
] {
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let args = SelectorArgs {
selectors: vec![input.into()],
};
let expected_body = match verb {
Verb::Stop => Request::Stop { selector: expected },
Verb::Restart => Request::Restart { selector: expected },
Verb::Reload => Request::Reload { selector: expected },
Verb::Delete => Request::Delete { selector: expected },
};
let _ = match verb {
Verb::Stop => stop(&client, &mut streams, &args).await,
Verb::Restart => restart(&client, &mut streams, &args).await,
Verb::Reload => reload(&client, &mut streams, &args).await,
Verb::Delete => delete(&client, &mut streams, &args).await,
};
let sent = envelopes.recv().await.unwrap();
assert_eq!(sent.body, expected_body, "verb={verb:?} input={input}");
assert_eq!(
sent.deadline_ms,
Some(u64::try_from(DEFAULT_DEADLINE.as_millis()).unwrap()),
"verb={verb:?} input={input} must defer to the client's default deadline"
);
}
}
}
#[tokio::test]
async fn a_malformed_selector_exits_usage_without_a_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
stop(
&client,
&mut streams,
&SelectorArgs {
selectors: vec!["/[/".into()],
},
)
.await
};
assert_eq!(code, ExitCode::Usage);
assert!(
envelopes.try_recv().is_err(),
"a malformed selector must fail locally"
);
}
#[tokio::test]
async fn a_not_found_reply_exits_not_found_rather_than_being_swallowed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, _served) =
fake_client_replying_err(&path, RpcErrorCode::NotFound, "no sheep matched").await;
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let code = stop(
&client,
&mut streams,
&SelectorArgs {
selectors: vec!["ghost".into()],
},
)
.await;
assert_eq!(code, ExitCode::NotFound);
}
#[tokio::test]
async fn start_asks_for_the_longer_deadline() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let srv = dir.path().join("srv");
std::fs::write(&srv, "").unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let _ = start(
&client,
&mut streams,
&start_args(srv.to_str().unwrap()),
None,
&BTreeMap::new(),
)
.await;
let sent = next_start(&mut envelopes).await;
assert_eq!(
sent.deadline_ms,
Some(u64::try_from(START_DEADLINE.as_millis()).unwrap())
);
let mut expected = AppConfig::minimal("srv", srv.to_str().unwrap());
expected.cwd = std::env::current_dir()
.ok()
.map(|dir| dir.to_string_lossy().into_owned());
assert_eq!(
sent.body,
Request::Start {
apps: vec![expected]
}
);
}
#[tokio::test]
async fn a_fold_flag_lands_on_the_resolved_app() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let srv = dir.path().join("srv");
std::fs::write(&srv, "").unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let mut args = start_args(srv.to_str().unwrap());
args.fold = Some("backend".to_string());
let _ = start(&client, &mut streams, &args, None, &BTreeMap::new()).await;
let sent = next_start(&mut envelopes).await;
match sent.body {
Request::Start { apps } => {
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].fold.as_deref(), Some("backend"));
}
other => panic!("expected Request::Start, got {other:?}"),
}
}
#[test]
fn mapped_interpreter_reads_the_extension_without_its_dot() {
let mut interpreters = BTreeMap::new();
interpreters.insert("js".to_string(), "node".to_string());
assert_eq!(
mapped_interpreter("server.js", &interpreters),
Some("node".to_string())
);
assert_eq!(mapped_interpreter("server", &interpreters), None);
assert_eq!(mapped_interpreter(".bashrc", &interpreters), None);
assert_eq!(mapped_interpreter("server.py", &interpreters), None);
}
#[tokio::test]
async fn a_shep_toml_mapping_fills_an_unset_interpreter() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let srv = dir.path().join("srv.js");
std::fs::write(&srv, "").unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let mut interpreters = BTreeMap::new();
interpreters.insert("js".to_string(), "node".to_string());
let _ = start(
&client,
&mut streams,
&start_args(srv.to_str().unwrap()),
None,
&interpreters,
)
.await;
let sent = next_start(&mut envelopes).await;
match sent.body {
Request::Start { apps } => {
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].interpreter.as_deref(), Some("node"));
}
other => panic!("expected Request::Start, got {other:?}"),
}
}
#[tokio::test]
async fn a_flockfile_interpreter_outranks_the_shep_toml_mapping() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let flockfile = dir.path().join("Flockfile.toml");
std::fs::write(
&flockfile,
"[[app]]\nname = \"demo\"\nscript = \"server.js\"\ninterpreter = \"bun\"\n",
)
.unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let mut interpreters = BTreeMap::new();
interpreters.insert("js".to_string(), "node".to_string());
let _ = start(
&client,
&mut streams,
&start_args(flockfile.to_str().unwrap()),
None,
&interpreters,
)
.await;
let sent = next_start(&mut envelopes).await;
match sent.body {
Request::Start { apps } => {
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].interpreter.as_deref(), Some("bun"));
}
other => panic!("expected Request::Start, got {other:?}"),
}
}
#[tokio::test]
async fn the_interpreter_flag_outranks_a_flockfiles_own_field() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let flockfile = dir.path().join("Flockfile.toml");
std::fs::write(
&flockfile,
"[[app]]\nname = \"demo\"\nscript = \"server.js\"\ninterpreter = \"bun\"\n",
)
.unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let mut args = start_args(flockfile.to_str().unwrap());
args.interpreter = Some("deno".to_string());
let mut interpreters = BTreeMap::new();
interpreters.insert("js".to_string(), "node".to_string());
let _ = start(&client, &mut streams, &args, None, &interpreters).await;
let sent = next_start(&mut envelopes).await;
match sent.body {
Request::Start { apps } => {
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].interpreter.as_deref(), Some("deno"));
}
other => panic!("expected Request::Start, got {other:?}"),
}
}
#[test]
fn any_restart_failed_is_true_only_for_an_errored_row() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let online = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
let errored = ProcessInfo::builder(2, "worker", ProcStatus::Errored).build();
assert!(!any_restart_failed(std::slice::from_ref(&online)));
assert!(any_restart_failed(&[online, errored]));
}
#[tokio::test]
async fn the_request_carries_the_app_name_and_the_count() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let _ = stock(
&client,
&mut streams,
&StockArgs {
name: "web".to_string(),
count: 4,
},
)
.await;
let envelope = envelopes.recv().await.unwrap();
assert_eq!(
envelope.body,
Request::Scale {
name: "web".to_string(),
count: 4,
}
);
}
#[tokio::test]
async fn an_invalid_stock_exits_invalid_config_and_prints_the_reason() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, _served) = fake_client_replying_err(
&path,
RpcErrorCode::InvalidConfig,
"an app runs at least one instance; use `shep delete web` to remove it",
)
.await;
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
stock(
&client,
&mut streams,
&StockArgs {
name: "web".to_string(),
count: 1,
},
)
.await
};
assert_eq!(code, ExitCode::InvalidConfig);
assert!(
String::from_utf8(err).unwrap().contains("shep delete web"),
"the daemon's own sentence has to reach the operator"
);
}
mod slow {
use super::*;
#[test]
fn a_js_flockfile_leaving_a_process_on_the_pipe_says_that_instead() {
if !node_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flock.js");
std::fs::write(
&path,
"require('child_process')\
.spawn('sleep', ['30'], { detached: true, stdio: 'inherit' })\
.unref(); \
module.exports = { app: [] };",
)
.unwrap();
let err = evaluate_js_flockfile(&path, Duration::from_secs(5)).unwrap_err();
assert_eq!(target_exit_code(&err), ExitCode::InvalidConfig);
let message = err.to_string();
assert!(
message.contains("left behind still holds the output"),
"got: {message}"
);
assert!(
!message.contains("killed"),
"node exited on its own, so nothing was killed: {message}"
);
}
}
}