use shep_client::{Client, TRIGGER_DEADLINE};
use shep_core::dogs::{is_reserved_action, manifest::Action};
use shep_core::paths::ShepPaths;
use shep_core::protocol::{Request, Response};
use crate::cli::{Format, TriggerArgs};
use crate::commands::dogs::{ManifestRead, VERSION_BUDGET, read_manifest};
use crate::commands::rpc::request_and_render;
use crate::commands::selector::parse_selector_spec;
use crate::commands::shep_toml::ShepToml;
use crate::exit::ExitCode;
use crate::output::{DogActionList, Streams, TriggeredRows, emit, write_outcome};
pub async fn trigger(client: &Client, streams: &mut Streams<'_>, args: &TriggerArgs) -> ExitCode {
let Some(action) = args.action.clone() else {
return streams.fail(
ExitCode::Usage,
"listing a dog's actions reads manifests on this machine; \
name an action to trigger one on a host",
);
};
if is_reserved_action(&action) {
return streams.fail(
ExitCode::Usage,
&format!("`{action}` is an action shep sends itself; `shep trigger` cannot send it"),
);
}
let selector = match parse_selector_spec(streams, &args.selector) {
Ok(selector) => selector,
Err(code) => return code,
};
let body = Request::Trigger {
selector,
action,
params: args.params.clone(),
};
let deadline = Some(TRIGGER_DEADLINE);
request_and_render(
client,
streams,
"trigger",
body,
deadline,
|response| match response {
Response::Triggered(replies) => Some(TriggeredRows(replies)),
_ => None,
},
)
.await
}
pub fn list_actions(streams: &mut Streams<'_>, paths: &ShepPaths, name: &str) -> ExitCode {
let cfg = match ShepToml::read_only(&paths.daemon_config) {
Ok(cfg) => cfg,
Err(err) => return streams.fail(ExitCode::InvalidConfig, &err.to_string()),
};
let manifest = match cfg.adopted_dog_path(name) {
Some(binary) => match read_manifest(&binary, &paths.home, name, VERSION_BUDGET) {
Ok(ManifestRead::Read(manifest)) => Some(manifest),
Ok(ManifestRead::Absent) => None,
Ok(ManifestRead::Unreadable) => {
return streams.fail(
ExitCode::InvalidConfig,
&format!(
"the manifest recorded for dog `{name}` cannot be read; \
`shep adopt {name}` asks the dog again"
),
);
}
Err(refusal) => {
let message = format!("{}: {refusal}", binary.display());
return streams.fail(ExitCode::InvalidConfig, &message);
}
},
None => match crate::dog::builtin_manifest(name) {
Some(manifest) => Some(manifest),
None => {
return streams.fail(
ExitCode::NotFound,
&format!(
"`{name}` is not a dog; `shep trigger <dog>` lists a dog's actions, \
and `shep trigger <sheep> <action>` triggers one"
),
);
}
},
};
let actions: Vec<Action> = manifest
.map(|manifest| manifest.actions)
.unwrap_or_default()
.into_iter()
.map(|action| {
let (name, _) = crate::terminal_safe::sanitise(&action.name);
let (description, _) = crate::terminal_safe::sanitise(&action.description);
Action::new(name, description)
})
.collect();
if actions.is_empty() && streams.fmt == Format::Table {
streams.note("no_actions", &format!("dog `{name}` declares no actions"));
return ExitCode::Success;
}
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"trigger",
DogActionList(actions),
streams.style,
))
}
#[cfg(test)]
mod tests {
use shep_client::testing::{fake_client_capturing_envelopes, fake_client_replying_err};
use shep_core::protocol::RpcErrorCode;
use shep_core::protocol::SelectorSpec;
use super::*;
use crate::cli::Format;
fn args(selector: &str, action: &str, params: Option<&str>) -> TriggerArgs {
TriggerArgs {
selector: selector.to_string(),
action: Some(action.to_string()),
params: params.map(str::to_string),
}
}
async fn run(
args: &TriggerArgs,
) -> (
ExitCode,
Vec<u8>,
Vec<u8>,
tokio::sync::mpsc::Receiver<shep_core::protocol::Envelope>,
) {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, 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,
};
trigger(&client, &mut streams, args).await
};
(code, out, err, envelopes)
}
#[cfg(unix)]
const DECLARING_BODY: &str = r#"if [ "$1" = --shep-manifest ]; then
echo '{"manifest":1,"name":"shep-janitor","version":"0.1.0","protocol":12,"channel":true,"actions":[{"name":"sweep","description":"Delete finished scratch files"},{"name":"pause","description":"Stop sweeping"}]}'
fi"#;
#[cfg(unix)]
const SILENT_ACTIONS_BODY: &str = r#"if [ "$1" = --shep-manifest ]; then
echo '{"manifest":1,"name":"shep-janitor","version":"0.1.0","protocol":12}'
fi"#;
#[cfg(unix)]
fn a_home_with_janitor(dir: &std::path::Path, body: &str) -> ShepPaths {
use std::os::unix::fs::PermissionsExt as _;
let paths = crate::home::resolve_paths(&crate::cli::GlobalArgs {
host: None,
home: Some(dir.join(".shep")),
format: Format::Table,
quiet: false,
style: None,
})
.expect("an absolute --home resolves");
let binary = dir.join("janitor");
std::fs::write(&binary, format!("#!/bin/sh\n{body}\nexit 0\n")).unwrap();
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();
ShepToml::edit(&paths.daemon_config, |cfg| {
cfg.adopt_dog("janitor", &binary, false).unwrap();
})
.unwrap();
paths
}
#[cfg(unix)]
fn listed(paths: &ShepPaths, name: &str, fmt: Format) -> (ExitCode, String, String) {
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,
};
list_actions(&mut streams, paths, name)
};
(
code,
String::from_utf8(out).unwrap(),
String::from_utf8(err).unwrap(),
)
}
#[cfg(unix)]
#[test]
fn a_dog_with_no_action_lists_the_actions_its_manifest_declares() {
let dir = tempfile::tempdir().unwrap();
let paths = a_home_with_janitor(dir.path(), DECLARING_BODY);
let (code, out, err) = listed(&paths, "janitor", Format::Table);
assert_eq!(code, ExitCode::Success, "{err}");
assert!(out.contains("sweep"), "{out}");
assert!(out.contains("Delete finished scratch files"), "{out}");
assert!(out.contains("pause"), "{out}");
assert!(out.contains("Stop sweeping"), "{out}");
}
#[cfg(unix)]
#[test]
fn the_json_listing_is_the_manifests_actions() {
let dir = tempfile::tempdir().unwrap();
let paths = a_home_with_janitor(dir.path(), DECLARING_BODY);
let (code, out, _err) = listed(&paths, "janitor", Format::Json);
assert_eq!(code, ExitCode::Success);
let value: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(
value["data"],
serde_json::json!([
{"name": "sweep", "description": "Delete finished scratch files"},
{"name": "pause", "description": "Stop sweeping"},
])
);
}
#[cfg(unix)]
#[test]
fn a_dog_that_declares_no_actions_says_so() {
let dir = tempfile::tempdir().unwrap();
let paths = a_home_with_janitor(dir.path(), SILENT_ACTIONS_BODY);
let (code, out, _err) = listed(&paths, "janitor", Format::Table);
assert_eq!(code, ExitCode::Success);
assert!(out.contains("dog `janitor` declares no actions"), "{out}");
}
#[cfg(unix)]
#[test]
fn a_name_that_is_not_a_dog_is_refused_with_nothing_listed() {
let dir = tempfile::tempdir().unwrap();
let paths = a_home_with_janitor(dir.path(), DECLARING_BODY);
let (code, out, err) = listed(&paths, "web", Format::Table);
assert_eq!(code, ExitCode::NotFound);
assert!(out.is_empty(), "{out}");
assert!(err.contains("`web` is not a dog"), "{err}");
}
#[tokio::test]
async fn a_malformed_selector_exits_usage_without_a_round_trip() {
let (code, _out, _err, mut envelopes) = run(&args("/[/", "ping", None)).await;
assert_eq!(code, ExitCode::Usage);
assert!(
envelopes.try_recv().is_err(),
"a malformed selector must fail locally, never reach the wire"
);
}
#[tokio::test]
async fn a_not_found_reply_exits_not_found_rather_than_being_swallowed() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
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 code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
trigger(&client, &mut streams, &args("nonexistent", "ping", None)).await
};
assert_eq!(code, ExitCode::NotFound);
assert!(out.is_empty());
}
#[tokio::test]
async fn a_reserved_action_is_refused_before_sending() {
for action in ["shep.validate", "shep.pane", "shep."] {
let (code, out, err, mut envelopes) = run(&args("janitor", action, None)).await;
let err = String::from_utf8(err).unwrap();
assert_eq!(code, ExitCode::Usage, "{err}");
assert!(out.is_empty());
assert!(err.contains(action), "the refusal names `{action}`: {err}");
assert!(
err.contains("shep sends itself"),
"the refusal says why: {err}"
);
assert!(
envelopes.try_recv().is_err(),
"a reserved action must fail locally, never reach the wire"
);
}
}
#[tokio::test]
async fn the_request_carries_the_selector_action_params_and_trigger_deadline() {
let (_code, _out, _err, mut envelopes) = run(&args("web", "gc", Some("--force"))).await;
let envelope = envelopes.recv().await.unwrap();
assert_eq!(
envelope.body,
Request::Trigger {
selector: SelectorSpec::Name("web".to_string()),
action: "gc".to_string(),
params: Some("--force".to_string()),
}
);
assert_eq!(
envelope.deadline_ms,
Some(u64::try_from(TRIGGER_DEADLINE.as_millis()).unwrap()),
"a trigger sent with the client's plain default would abandon a reply the daemon \
is still honestly building"
);
}
#[tokio::test]
async fn an_unrecognised_response_exits_internal() {
let (code, out, err, _envelopes) = run(&args("web", "ping", None)).await;
assert_eq!(code, ExitCode::Internal);
assert!(out.is_empty());
assert!(!err.is_empty());
}
}