use std::collections::BTreeMap;
use std::io::{self, Write as _};
use std::time::Duration;
use crossterm::QueueableCommand as _;
use crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::terminal::{Clear, ClearType};
use shep_client::Client;
use shep_core::paths::ShepPaths;
use shep_core::protocol::{HostUsage, ProcessInfo, Request, Response, SelectorSpec};
#[cfg(test)]
use shep_core::secrets::Resolution;
use shep_core::secrets::{self, SecretRef, SecretView};
use shep_core::status::ProcStatus;
use shep_daemon::snapshot::FlockSnapshot;
use crate::cli::{DogsArgs, FoldArgs, Format, SelectorArgs};
use crate::commands::rpc::{client_error, request_and_render, unexpected_response};
use crate::commands::selector::parse_selector_spec;
use crate::dog_index::{self, AvailableDog, DogSourceKind};
use crate::exit::ExitCode;
use crate::fetch;
use crate::flourish;
use crate::host;
use crate::lookout::term;
use crate::output::width;
use crate::output::{
AvailableDogRows, DescribedSecret, DogRows, RolledSheep, RolledSheepRows, SecretStatus,
Streams, emit, emit_described, emit_flock, write_outcome,
};
use crate::shutdown::Interrupt;
use crate::style::Presentation;
async fn describe_selector(
client: &Client,
streams: &mut Streams<'_>,
paths: &ShepPaths,
command: &str,
include_secrets: bool,
selector: SelectorSpec,
) -> ExitCode {
match client.request(Request::Describe { selector }).await {
Ok(Response::Described(procs)) => {
let secrets = if include_secrets {
let (rows, unreadable) = gather_secrets(paths, &procs);
if let Some(error) = unreadable {
let message = format!(
"the secret store at {} could not be read ({error}), so every \
reference below reads as missing whether or not the store holds \
a value for it",
paths.secrets.display()
);
streams.aside(SECRET_STORE_UNREADABLE_NOTICE, &message);
}
rows
} else {
Vec::new()
};
let result = emit_described(
&mut *streams.out,
streams.fmt,
command,
procs,
streams.style,
&secrets,
);
write_outcome(result)
}
Ok(_unrecognised) => unexpected_response(streams),
Err(err) => client_error(streams, &err),
}
}
#[cfg(test)]
fn render_describe_secrets(entries: &[(&str, &str, Resolution<'_>)]) -> String {
let mut rendered = String::new();
for (reference, environment, resolution) in entries {
let verdict = SecretStatus::from_resolution(resolution).as_table_word();
rendered.push_str(&format!(" {reference} ({environment}): {verdict}\n"));
}
rendered
}
fn gather_secrets(
paths: &ShepPaths,
procs: &[ProcessInfo],
) -> (Vec<DescribedSecret>, Option<secrets::SecretError>) {
let (store, unreadable) = match secrets::all(&paths.secrets) {
Ok(store) => (store, None),
Err(error) => (BTreeMap::new(), Some(error)),
};
let providers = secrets::provider_cache_on_disk(&paths.secrets_cache);
let mut json = Vec::new();
for namer in crate::secret_readers::namers(paths, procs) {
let view = SecretView::new(namer.environment.clone(), store.clone(), providers.clone());
for reference in &namer.references {
let Some(parsed) = SecretRef::parse(reference) else {
continue;
};
json.push(DescribedSecret {
name: namer.name.clone(),
reference: reference.clone(),
environment: namer.environment.clone(),
status: SecretStatus::from_resolution(&view.resolve(&parsed)),
});
}
}
(json, unreadable)
}
const SECRET_STORE_UNREADABLE_NOTICE: &str = "secret_store_unreadable";
pub(crate) fn read_roll(paths: &ShepPaths) -> Option<FlockSnapshot> {
std::fs::read(&paths.snapshot)
.ok()
.and_then(|bytes| serde_json::from_slice::<FlockSnapshot>(&bytes).ok())
}
pub fn flock_from_roll(streams: &mut Streams<'_>, paths: &ShepPaths) -> ExitCode {
let saved = read_roll(paths);
let mut sheep: Vec<RolledSheep> = saved
.map(|roll| {
roll.apps
.into_iter()
.map(|entry| RolledSheep {
name: entry.app.name.clone(),
instances: entry.instances_running,
status: "stopped",
})
.collect()
})
.unwrap_or_default();
sheep.sort_unstable_by(|a, b| a.name.cmp(&b.name));
if streams.fmt == Format::Table {
let _ = writeln!(
streams.err,
"no shepherd running. {}",
if sheep.is_empty() {
"nothing in the saved roll either.".to_owned()
} else {
format!(
"{} in the saved roll at {}:",
match sheep.len() {
1 => "1 sheep".to_owned(),
n => format!("{n} sheep"),
},
paths.snapshot.display()
)
}
);
}
let empty = sheep.is_empty();
if !(empty && streams.fmt == Format::Table) {
let _ = emit(
&mut *streams.out,
streams.fmt,
"flock",
RolledSheepRows(sheep),
streams.style,
);
}
if streams.fmt == Format::Table && !empty {
let _ = writeln!(streams.err, "`shep muster` brings them back.");
}
ExitCode::DaemonUnreachable
}
pub async fn flock(client: &Client, streams: &mut Streams<'_>) -> ExitCode {
match client.request(Request::ListFlock).await {
Ok(Response::Flock(procs)) => {
let art = (streams.fmt == Format::Table && streams.style.level.sheep())
.then(|| sheep_flourish(&procs))
.flatten();
if let Some(art) = &art {
let _ = write!(streams.out, "{art}");
}
let host = host_usage(client).await;
if streams.fmt == Format::Table
&& let Some(usage) = host
{
let strip = host::strip(usage, streams.style, streams.style.width);
let _ = writeln!(streams.out, "{strip}\n");
}
write_outcome(emit_flock(
&mut *streams.out,
streams.fmt,
"flock",
procs,
host,
streams.style,
))
}
Ok(_unrecognised) => unexpected_response(streams),
Err(err) => client_error(streams, &err),
}
}
async fn host_usage(client: &Client) -> Option<Option<HostUsage>> {
match client.request(Request::HostUsage).await {
Ok(Response::HostUsage(usage)) => Some(usage),
Ok(_unrecognised) => None,
Err(_unsupported) => None,
}
}
fn sheep_flourish(listing: &[ProcessInfo]) -> Option<String> {
let sheep: Vec<&ProcessInfo> = listing.iter().filter(|p| p.dog.is_none()).collect();
if sheep.is_empty() {
return Some(flourish::empty_flock());
}
sheep
.iter()
.all(|p| p.status == ProcStatus::Stopped)
.then(|| flourish::all_asleep(sheep.len()))
}
pub(crate) async fn flock_follow(
client: &Client,
streams: &mut Streams<'_>,
interval: Duration,
) -> ExitCode {
let mut interrupt = match Interrupt::install() {
Ok(interrupt) => interrupt,
Err(err) => {
let message = format!("listening for an interrupt: {err}");
return streams.fail(ExitCode::Failure, &message);
}
};
term::install_panic_hook();
let _cursor = term::RestoreGuard::with_action(|| {
let _ = crossterm::execute!(io::stdout(), Show);
});
let _ = streams.out.queue(Hide);
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = ticker.tick() => {}
_ = interrupt.recv() => return ExitCode::Success,
}
let listing = tokio::select! {
listing = client.request(Request::ListFlock) => listing,
_ = interrupt.recv() => return ExitCode::Success,
};
let procs = match listing {
Ok(Response::Flock(procs)) => procs,
Ok(_unrecognised) => return unexpected_response(streams),
Err(err) => return client_error(streams, &err),
};
let host = tokio::select! {
host = host_usage(client) => host,
_ = interrupt.recv() => return ExitCode::Success,
};
let size = crossterm::terminal::size().ok();
let columns = size
.filter(|&(columns, _)| columns > 0)
.map_or(streams.style.width, |(columns, _)| usize::from(columns));
let frame = follow_frame(
procs,
streams.style,
host.map(|usage| host::strip(usage, streams.style, columns)),
);
let frame = match size {
Some((columns, rows)) => fit_rows(&frame, columns, rows),
None => frame,
};
let painted = paint(streams.out, &frame);
if painted.is_err() {
return write_outcome(painted);
}
}
}
fn paint(out: &mut dyn io::Write, frame: &str) -> io::Result<()> {
out.queue(MoveTo(0, 0))?;
out.queue(Clear(ClearType::FromCursorDown))?;
write!(out, "{frame}")?;
out.flush()
}
fn follow_frame(listing: Vec<ProcessInfo>, style: Presentation, strip: Option<String>) -> String {
let mut frame = Vec::new();
if let Some(strip) = strip {
let _ = writeln!(frame, "{strip}\n");
}
let _ = emit_flock(&mut frame, Format::Table, "flock", listing, None, style);
String::from_utf8_lossy(&frame).into_owned()
}
fn fit_rows(frame: &str, columns: u16, rows: u16) -> String {
let (columns, budget) = (usize::from(columns), usize::from(rows).saturating_sub(1));
if columns == 0 || budget == 0 {
return frame.to_owned();
}
let lines: Vec<&str> = frame.lines().collect();
let reserve = line_rows(¬ice(lines.len()), columns);
let fill = budget.saturating_sub(reserve);
let mut used = 0;
let mut kept = 0;
for line in &lines {
let height = line_rows(line, columns);
if used + height > fill {
break;
}
used += height;
kept += 1;
}
if kept == lines.len() {
return frame.to_owned();
}
let mut fitted = lines[..kept].join("\n");
if kept > 0 {
fitted.push('\n');
}
fitted.push_str(&clip_rows(
¬ice(lines.len() - kept),
columns,
budget.saturating_sub(used),
));
fitted.push('\n');
fitted
}
fn clip_rows(text: &str, columns: usize, rows: usize) -> String {
let ceiling = rows.saturating_mul(columns);
if width::visible_width(text) <= ceiling {
return text.to_owned();
}
text.chars().take(ceiling).collect()
}
fn notice(dropped: usize) -> String {
let lines = if dropped == 1 { "line" } else { "lines" };
format!("{dropped} more {lines} than this terminal shows")
}
fn line_rows(line: &str, columns: usize) -> usize {
width::visible_width(line).div_ceil(columns).max(1)
}
pub async fn dogs(client: &Client, streams: &mut Streams<'_>, args: &DogsArgs) -> ExitCode {
let filter = args.filter.as_deref();
request_and_render(
client,
streams,
"dogs",
Request::ListFlock,
None,
|response| match response {
Response::Flock(procs) => Some(DogRows(
procs
.into_iter()
.filter(|p| p.dog.is_some())
.filter(|p| filter.is_none_or(|f| matches_filter(f, &[&p.name])))
.collect(),
)),
_ => None,
},
)
.await
}
fn matches_filter(filter: &str, haystacks: &[&str]) -> bool {
let filter = filter.to_lowercase();
haystacks.iter().any(|h| h.to_lowercase().contains(&filter))
}
pub async fn available_dogs(streams: &mut Streams<'_>, args: &DogsArgs) -> ExitCode {
let url = dog_index::index_url();
let index = match dog_index::fetch_index(&url).await {
Ok(index) => index,
Err(err) => {
let message = format!(
"reading the dog index from {}: {err}",
fetch::url_for_message(&url)
);
return streams.fail(ExitCode::Failure, &message);
}
};
let (skipped, sanitised) = (index.skipped, index.sanitised);
let filter = args.filter.as_deref();
let matched: Vec<AvailableDog> = index
.dogs
.into_iter()
.filter(|dog| {
filter.is_none_or(|f| matches_filter(f, &[&dog.name, &dog.package, &dog.description]))
})
.collect();
let code = if streams.fmt == Format::Table
&& matched.is_empty()
&& let Some(filter) = filter
{
let _ = writeln!(streams.out, "no dog matches {filter:?}");
ExitCode::Success
} else if streams.fmt == Format::Table
&& let [only] = matched.as_slice()
{
write_outcome(render_detail(&mut *streams.out, only))
} else {
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"dogs",
AvailableDogRows(matched),
streams.style,
))
};
note_index_costs(streams, skipped, sanitised);
code
}
const INDEX_WIDE: &str = ", across the whole index rather than this listing";
fn note_index_costs(streams: &mut Streams<'_>, skipped: usize, sanitised: usize) {
if skipped > 0 {
streams.aside(
"dogs_skipped",
&format!(
"{skipped} entr{} skipped{INDEX_WIDE}",
if skipped == 1 { "y" } else { "ies" }
),
);
}
if sanitised > 0 {
streams.aside(
"dogs_sanitised",
&format!(
"{sanitised} entr{} contained control characters{INDEX_WIDE}",
if sanitised == 1 { "y" } else { "ies" }
),
);
}
}
fn render_detail(out: &mut dyn std::io::Write, dog: &AvailableDog) -> std::io::Result<()> {
writeln!(out, "{} . {} . {}", dog.name, dog.package, dog.category)?;
writeln!(out, "{}", dog.description)?;
writeln!(out, "{} . {}", dog.license, dog.repo)?;
writeln!(out)?;
writeln!(out, "{}", install_line(&dog.source, &dog.package))?;
writeln!(
out,
"{}",
adopt_line(&dog.source, &dog.adopt_as, &dog.package)
)
}
fn install_line(source: &DogSourceKind, package: &str) -> String {
match source {
DogSourceKind::Cargo {
version: Some(version),
} => {
format!(" $ cargo install {package} --version {version}")
}
DogSourceKind::Cargo { version: None } => format!(" $ cargo install {package}"),
DogSourceKind::CargoGit { url } => format!(" $ cargo install --git {url}"),
DogSourceKind::GoInstall { module } => format!(" $ go install {module}@latest"),
DogSourceKind::Manual { instructions } => format!(" {instructions}"),
}
}
fn adopt_line(source: &DogSourceKind, adopt_as: &str, package: &str) -> String {
match source {
DogSourceKind::Cargo { .. } | DogSourceKind::CargoGit { .. } => {
format!(" $ shep adopt ~/.cargo/bin/{package} --name {adopt_as}")
}
DogSourceKind::GoInstall { .. } => {
format!(" $ shep adopt $(go env GOPATH)/bin/{package} --name {adopt_as}")
}
DogSourceKind::Manual { .. } => {
format!(" $ shep adopt <path to the binary> --name {adopt_as}")
}
}
}
pub async fn describe(
client: &Client,
streams: &mut Streams<'_>,
paths: &ShepPaths,
args: &SelectorArgs,
) -> ExitCode {
let mut failure: Option<ExitCode> = None;
for raw in &args.selectors {
let selector = match parse_selector_spec(streams, raw) {
Ok(selector) => selector,
Err(code) => return code,
};
let code = describe_selector(client, streams, paths, "describe", true, selector).await;
if code != ExitCode::Success {
failure = failure.or(Some(code));
}
}
failure.unwrap_or(ExitCode::Success)
}
pub async fn fold(
client: &Client,
streams: &mut Streams<'_>,
paths: &ShepPaths,
args: &FoldArgs,
) -> ExitCode {
describe_selector(
client,
streams,
paths,
"fold",
false,
SelectorSpec::Fold(args.name.clone()),
)
.await
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use shep_client::testing::{
fake_client_answering, fake_client_capturing_envelopes, fake_client_on,
fake_client_replying_err, fake_client_with_ack, sample_ack, sample_info,
};
use shep_core::protocol::{DogSource, RpcErrorCode};
use super::*;
const RECV_TIMEOUT: Duration = Duration::from_secs(5);
#[test]
fn a_trimmed_frame_never_overruns_the_rows_it_was_given() {
let frame: String = (0..40)
.map(|n| format!("sheep-{n:02} online 1234 0.5% 12.3 MB 0d 0h 1m\n"))
.collect();
for columns in [12u16, 16, 20, 24, 30, 38, 40, 60, 80, 120] {
for rows in [3u16, 4, 5, 8, 12, 24, 40] {
let out = fit_rows(&frame, columns, rows);
let used: usize = out
.lines()
.map(|line| line_rows(line, usize::from(columns)))
.sum();
let budget = usize::from(rows).saturating_sub(1);
assert!(
used <= budget,
"{columns}x{rows}: used {used} rows against a budget of {budget}"
);
}
}
}
#[test]
fn the_notice_counts_one_dropped_line_in_the_singular() {
let frame = "one\ntwo\nthree\n";
assert_eq!(
fit_rows(frame, 80, 4),
"one\ntwo\n1 more line than this terminal shows\n"
);
assert_eq!(
fit_rows(frame, 80, 3),
"one\n2 more lines than this terminal shows\n"
);
}
#[test]
fn a_frame_that_fits_is_left_alone() {
let frame = "one\ntwo\nthree\n";
assert_eq!(fit_rows(frame, 80, 24), frame);
}
#[test]
fn a_wrapped_line_costs_more_than_one_row() {
let wide = "0123456789012345678901234";
let frame = format!("{wide}\n{wide}\n{wide}\n{wide}\n");
let fitted = fit_rows(&frame, 10, 8);
assert_eq!(
fitted, "0123456789012345678901234\n3 more lines than this terminal shows\n",
"the notice reserves its own four rows at ten columns, leaving three of the seven \
for content, which is one wrapped line"
);
}
#[test]
fn a_frame_too_tall_says_how_much_it_dropped() {
let frame = (0..10)
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join("\n");
let fitted = fit_rows(&frame, 80, 6);
assert_eq!(
fitted,
"0\n1\n2\n3\n6 more lines than this terminal shows\n"
);
}
#[test]
fn a_terminal_reporting_no_size_gets_the_frame_whole() {
let frame = "a\nb\nc\n";
assert_eq!(fit_rows(frame, 0, 0), frame, "no size at all");
assert_eq!(fit_rows(frame, 80, 0), frame, "no rows");
assert_eq!(fit_rows(frame, 0, 24), frame, "no columns");
assert_eq!(
fit_rows(frame, 80, 1),
frame,
"one row leaves nothing to trim to once the cursor has its own"
);
}
#[test]
fn a_followed_frame_leads_with_the_host_strip() {
let usage = HostUsage {
cpu_percent: Some(11.0),
memory_used_bytes: 39_963_869_184,
memory_total_bytes: 51_539_607_552,
disk_bytes_per_second: Some((0, 0)),
network_bytes_per_second: Some((0, 0)),
};
let strip = host::strip(Some(usage), Presentation::BARE, 200);
let frame = follow_frame(vec![sample_info()], Presentation::BARE, Some(strip));
let mut lines = frame.lines();
assert!(lines.next().unwrap().starts_with("host cpu "));
assert_eq!(lines.next().unwrap(), "");
assert!(frame.contains("web"), "the table still follows: {frame}");
}
#[test]
fn a_frame_without_a_host_strip_is_the_tables_alone() {
let frame = follow_frame(vec![sample_info()], Presentation::BARE, None);
assert!(!frame.contains("host cpu"), "{frame}");
assert!(frame.contains("web"), "{frame}");
}
#[test]
fn a_followed_frame_of_an_empty_flock_carries_no_flourish() {
let frame = follow_frame(Vec::new(), Presentation::BARE, None);
assert!(!frame.contains("no sheep in the flock yet"), "{frame}");
}
fn sample_host() -> HostUsage {
HostUsage {
cpu_percent: Some(11.459_433),
memory_used_bytes: 39_963_869_184,
memory_total_bytes: 51_539_607_552,
disk_bytes_per_second: Some((1_258_291, 491_520)),
network_bytes_per_second: Some((24_594, 9_260)),
}
}
async fn shepherd_answering(
path: &std::path::Path,
host: Option<HostUsage>,
) -> (
Client,
tokio::sync::mpsc::UnboundedReceiver<shep_core::protocol::Envelope>,
) {
fake_client_answering(path, move |request| match request {
Request::HostUsage => Response::HostUsage(host),
_ => Response::Flock(vec![sample_info()]),
})
.await
}
async fn listing(client: &Client, fmt: Format) -> (ExitCode, 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,
};
flock(client, &mut streams).await
};
(code, String::from_utf8(out).unwrap())
}
#[tokio::test]
async fn a_shepherd_that_refuses_the_host_request_answers_for_no_strip() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, _daemon) = fake_client_replying_err(
&path,
RpcErrorCode::Unsupported,
"this shepherd does not implement the request the client sent",
)
.await;
assert_eq!(host_usage(&client).await, None);
}
#[tokio::test]
async fn a_shepherd_answering_something_else_answers_for_no_strip() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, _envelopes) = fake_client_capturing_envelopes(&path).await;
assert_eq!(host_usage(&client).await, None);
}
#[tokio::test]
async fn flock_asks_for_the_machine_as_well_as_the_flock() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, mut envelopes) = shepherd_answering(&path, Some(sample_host())).await;
let _ = listing(&client, Format::Table).await;
let mut sent = Vec::new();
for _ in 0..2 {
let envelope = tokio::time::timeout(RECV_TIMEOUT, envelopes.recv())
.await
.expect("flock must reach the wire; it hung instead of sending a request")
.unwrap();
sent.push(envelope.body);
}
assert_eq!(sent, vec![Request::ListFlock, Request::HostUsage]);
}
#[tokio::test]
async fn a_one_shot_listing_leads_with_the_host_strip() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, _envelopes) = shepherd_answering(&path, Some(sample_host())).await;
let (code, text) = listing(&client, Format::Table).await;
assert_eq!(code, ExitCode::Success);
let mut lines = text.lines();
assert!(
lines.next().unwrap().starts_with("host cpu "),
"got {text:?}"
);
assert_eq!(lines.next().unwrap(), "");
assert!(text.contains("web"), "the table still follows: {text:?}");
}
#[tokio::test]
async fn the_json_surface_carries_the_host_beside_the_flock() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, _envelopes) = shepherd_answering(&path, Some(sample_host())).await;
let (_code, text) = listing(&client, Format::Json).await;
let json: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(json["data"][0]["name"], "web");
assert_eq!(json["host"]["memory_used_bytes"], 39_963_869_184u64);
assert_eq!(json["host"]["cpu_percent"], 11.459_433);
assert!(!text.contains("host cpu"), "got {text:?}");
}
#[tokio::test]
async fn the_json_host_key_spells_its_three_answers_apart() {
let json = async |host: Option<HostUsage>| {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, _envelopes) = shepherd_answering(&path, host).await;
let (_code, text) = listing(&client, Format::Json).await;
serde_json::from_str::<serde_json::Value>(&text).unwrap()
};
let read = json(Some(sample_host())).await;
assert_eq!(read["host"]["cpu_percent"], 11.459_433);
let unreadable = json(None).await;
assert!(unreadable["host"].is_null(), "got {unreadable:?}");
let unmeasured = json(Some(HostUsage {
cpu_percent: None,
disk_bytes_per_second: None,
network_bytes_per_second: None,
..sample_host()
}))
.await;
assert!(unmeasured["host"].is_object(), "got {unmeasured:?}");
assert!(unmeasured["host"]["cpu_percent"].is_null());
assert_eq!(unmeasured["host"]["memory_used_bytes"], 39_963_869_184u64);
}
#[tokio::test]
async fn a_refused_host_request_leaves_no_json_key_at_all() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, daemon) = fake_client_with_ack(&path, sample_ack()).await;
daemon.reply_to_list(vec![sample_info()]);
let (code, text) = listing(&client, Format::Json).await;
assert_eq!(code, ExitCode::Success);
let json: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(json["data"][0]["name"], "web");
assert!(json.get("host").is_none(), "got {json:?}");
}
#[tokio::test]
async fn flock_asks_the_daemon_to_list_the_whole_flock() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
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 _ = flock(&client, &mut streams).await;
let sent = tokio::time::timeout(RECV_TIMEOUT, envelopes.recv())
.await
.expect("flock must reach the wire; it hung instead of sending a request")
.unwrap();
assert_eq!(sent.body, Request::ListFlock);
}
#[tokio::test]
async fn describe_sends_the_parsed_selector_in_its_compiled_form() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let paths = ShepPaths::resolve(&|_| None, dir.path());
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 _ = describe(&client, &mut streams, &paths, &args).await;
let sent = tokio::time::timeout(RECV_TIMEOUT, envelopes.recv())
.await
.unwrap_or_else(|_| {
panic!("describe({input}) must reach the wire; it hung instead of sending a request")
})
.unwrap();
assert_eq!(
sent.body,
Request::Describe { selector: expected },
"{input}"
);
}
}
#[tokio::test]
async fn a_malformed_selector_exits_usage_without_a_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let paths = ShepPaths::resolve(&|_| None, dir.path());
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,
};
describe(
&client,
&mut streams,
&paths,
&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 fold_asks_the_daemon_for_that_fold_and_nothing_wider() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let paths = ShepPaths::resolve(&|_| None, dir.path());
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 _ = fold(
&client,
&mut streams,
&paths,
&FoldArgs { name: "api".into() },
)
.await;
let sent = tokio::time::timeout(RECV_TIMEOUT, envelopes.recv())
.await
.expect("fold must reach the wire; it hung instead of sending a request")
.unwrap();
assert_eq!(
sent.body,
Request::Describe {
selector: SelectorSpec::Fold("api".into())
}
);
}
#[tokio::test]
async fn flock_response_round_trips_into_rendered_flock_rows() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, daemon) = fake_client_with_ack(&path, sample_ack()).await;
daemon.reply_to_list(vec![sample_info()]);
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::Json,
};
flock(&client, &mut streams).await
};
assert_eq!(code, ExitCode::Success);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(json["command"], "flock");
assert_eq!(json["data"][0]["name"], "web");
}
#[tokio::test]
async fn describe_response_round_trips_into_rendered_flock_rows() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, daemon) = fake_client_with_ack(&path, sample_ack()).await;
daemon.reply_to_describe(vec![sample_info()]);
let paths = ShepPaths::resolve(&|_| None, dir.path());
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::Json,
};
describe(
&client,
&mut streams,
&paths,
&SelectorArgs {
selectors: vec!["all".into()],
},
)
.await
};
assert_eq!(code, ExitCode::Success);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(json["command"], "describe");
assert_eq!(json["data"][0]["name"], "web");
}
#[test]
fn describe_lists_secret_references_with_a_verdict_and_no_values() {
let rendered = render_describe_secrets(&[
("DB_PASSWORD", "production", Resolution::Found("hunter2")),
("vercel/API_KEY", "production", Resolution::MissingNamespace),
("ABSENT", "production", Resolution::MissingKey),
]);
assert!(rendered.contains("DB_PASSWORD"));
assert!(rendered.contains("vercel/API_KEY"));
assert!(!rendered.contains("hunter2"), "never a value");
}
async fn describe_with_a_seeded_web(
fmt: Format,
) -> (ExitCode, shep_core::paths::ShepPaths, Vec<u8>) {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, daemon) = fake_client_with_ack(&path, sample_ack()).await;
daemon.reply_to_describe(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
]);
let home = dir.path().display().to_string();
let paths = ShepPaths::resolve(
&move |key| (key == "SHEP_HOME").then(|| home.clone()),
dir.path(),
);
let mut config = shep_core::config::AppConfig::minimal("web", "./srv");
config
.env
.insert("A".into(), "{{secret:DB_PASSWORD}}".into());
config
.env
.insert("B".into(), "{{secret:vercel/API_KEY}}".into());
let roll = FlockSnapshot {
version: 1,
saved_at_ms: 0,
apps: vec![shep_daemon::snapshot::SavedApp {
app: config,
instances_running: 1,
}],
};
std::fs::write(&paths.snapshot, serde_json::to_vec(&roll).unwrap()).unwrap();
secrets::set(&paths.secrets, "DB_PASSWORD", "production", "hunter2").unwrap();
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,
};
describe(
&client,
&mut streams,
&paths,
&SelectorArgs {
selectors: vec!["all".into()],
},
)
.await
};
(code, paths, out)
}
#[tokio::test]
async fn describe_prints_real_secret_verdicts_in_the_table() {
let (code, _paths, out) = describe_with_a_seeded_web(Format::Table).await;
assert_eq!(code, ExitCode::Success);
let rendered = String::from_utf8(out).unwrap();
assert_eq!(rendered.matches("Secrets for web").count(), 1, "{rendered}");
assert!(
rendered.contains("DB_PASSWORD (production): resolved"),
"{rendered}"
);
assert!(
rendered
.contains("vercel/API_KEY (production): not cached; a provider may still have it"),
"{rendered}"
);
assert!(!rendered.contains("hunter2"), "never a value: {rendered}");
}
#[tokio::test]
async fn describe_json_carries_the_same_verdicts_as_an_additive_field() {
let (code, _paths, out) = describe_with_a_seeded_web(Format::Json).await;
assert_eq!(code, ExitCode::Success);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(json["command"], "describe");
assert_eq!(json["schema_version"], 1, "SCHEMA_VERSION must not move");
assert_eq!(json["data"][0]["name"], "web");
let entries = json["secrets"].as_array().unwrap();
assert_eq!(entries.len(), 2);
assert!(
entries
.iter()
.any(|e| e["reference"] == "DB_PASSWORD" && e["status"] == "resolved"),
"{entries:?}"
);
assert!(
entries
.iter()
.any(|e| e["reference"] == "vercel/API_KEY" && e["status"] == "uncached"),
"{entries:?}"
);
assert!(!out_contains(&json, "hunter2"), "never a value");
}
#[tokio::test]
async fn describe_reads_a_namespace_pushed_for_another_environment_as_uncached() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, daemon) = fake_client_with_ack(&path, sample_ack()).await;
daemon.reply_to_describe(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
]);
let home = dir.path().display().to_string();
let paths = ShepPaths::resolve(
&move |key| (key == "SHEP_HOME").then(|| home.clone()),
dir.path(),
);
let mut config = shep_core::config::AppConfig::minimal("web", "./srv");
config.environment = Some("staging".into());
config
.env
.insert("B".into(), "{{secret:vercel/API_KEY}}".into());
let roll = FlockSnapshot {
version: 1,
saved_at_ms: 0,
apps: vec![shep_daemon::snapshot::SavedApp {
app: config,
instances_running: 1,
}],
};
std::fs::write(&paths.snapshot, serde_json::to_vec(&roll).unwrap()).unwrap();
std::fs::write(
&paths.secrets_cache,
r#"{"version":2,"namespaces":{"vercel":{"API_KEY":{"production":"sk_live"}}},"pushed":{"vercel":["production"]}}"#,
)
.unwrap();
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::Json,
};
describe(
&client,
&mut streams,
&paths,
&SelectorArgs {
selectors: vec!["all".into()],
},
)
.await
};
assert_eq!(code, ExitCode::Success);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let entries = json["secrets"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["environment"], "staging");
assert_eq!(entries[0]["status"], "uncached", "{entries:?}");
assert!(!out_contains(&json, "sk_live"), "never a value");
}
#[tokio::test]
async fn describe_says_the_store_would_not_read_rather_than_calling_keys_missing() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, daemon) = fake_client_with_ack(&path, sample_ack()).await;
daemon.reply_to_describe(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
]);
let home = dir.path().display().to_string();
let paths = ShepPaths::resolve(
&move |key| (key == "SHEP_HOME").then(|| home.clone()),
dir.path(),
);
let mut config = shep_core::config::AppConfig::minimal("web", "./srv");
config
.env
.insert("A".into(), "{{secret:DB_PASSWORD}}".into());
let roll = FlockSnapshot {
version: 1,
saved_at_ms: 0,
apps: vec![shep_daemon::snapshot::SavedApp {
app: config,
instances_running: 1,
}],
};
std::fs::write(&paths.snapshot, serde_json::to_vec(&roll).unwrap()).unwrap();
std::fs::write(&paths.secrets, b"{not json").unwrap();
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,
};
describe(
&client,
&mut streams,
&paths,
&SelectorArgs {
selectors: vec!["all".into()],
},
)
.await
};
assert_eq!(code, ExitCode::Success);
let warning = String::from_utf8(err).unwrap();
assert!(
warning.contains("could not be read"),
"the notice has to say the store failed: {warning}"
);
assert!(
warning.contains(&paths.secrets.display().to_string()),
"and name the file: {warning}"
);
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("DB_PASSWORD"), "{rendered}");
}
fn out_contains(value: &serde_json::Value, needle: &str) -> bool {
value.to_string().contains(needle)
}
#[tokio::test]
async fn fold_never_computes_or_prints_a_secrets_section() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, daemon) = fake_client_with_ack(&path, sample_ack()).await;
daemon.reply_to_describe(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
]);
let paths = ShepPaths::resolve(&|_| None, dir.path());
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::Json,
};
let code = fold(
&client,
&mut streams,
&paths,
&FoldArgs { name: "api".into() },
)
.await;
assert_eq!(code, ExitCode::Success);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(json.get("secrets").is_none(), "{json}");
}
fn sheep(id: u32, status: ProcStatus) -> ProcessInfo {
ProcessInfo::builder(id, format!("s{id}"), status).build()
}
fn dog(id: u32) -> ProcessInfo {
ProcessInfo::builder(id, format!("d{id}"), ProcStatus::Online)
.dog(Some(DogSource::BuiltIn))
.build()
}
#[test]
fn sheep_flourish_fires_empty_flock_on_a_truly_empty_listing() {
let art = sheep_flourish(&[]).expect("an empty listing must flourish");
assert!(art.contains("no sheep in the flock yet"), "{art}");
}
#[test]
fn sheep_flourish_treats_dogs_only_as_an_empty_flock() {
let art =
sheep_flourish(&[dog(1), dog(2)]).expect("dogs alone must read as an empty flock");
assert!(art.contains("no sheep in the flock yet"), "{art}");
}
#[test]
fn sheep_flourish_fires_all_asleep_when_every_sheep_is_stopped() {
let listing = [
sheep(1, ProcStatus::Stopped),
sheep(2, ProcStatus::Stopped),
dog(3),
];
let art = sheep_flourish(&listing).expect("an all-stopped flock must flourish");
assert!(art.contains("2 in the flock, all asleep"), "{art}");
}
#[test]
fn a_live_dog_does_not_block_all_asleep() {
let listing = [sheep(1, ProcStatus::Stopped), dog(2)];
let art = sheep_flourish(&listing).expect("a live dog must not suppress all_asleep");
assert!(art.contains("1 in the flock, all asleep"), "{art}");
}
#[test]
fn sheep_flourish_is_silent_on_a_mixed_flock() {
let listing = [sheep(1, ProcStatus::Online), sheep(2, ProcStatus::Stopped)];
assert_eq!(
sheep_flourish(&listing),
None,
"a mixed flock is not a flourish moment"
);
}
#[test]
fn stopping_does_not_count_as_asleep() {
let listing = [
sheep(1, ProcStatus::Stopping),
sheep(2, ProcStatus::Stopping),
];
assert_eq!(
sheep_flourish(&listing),
None,
"Stopping is a transient, not rest"
);
}
#[tokio::test]
async fn the_flourish_only_prints_under_table_format_and_a_sheep_drawing_level() {
use crate::style::{Presentation, StyleLevel};
for (fmt, level, expect_art) in [
(Format::Table, StyleLevel::Full, true),
(Format::Json, StyleLevel::Full, false),
(Format::Table, StyleLevel::Plain, false),
(Format::Table, StyleLevel::Bare, false),
] {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, _daemon) = fake_client_on(&path).await;
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: Presentation::new(level, None, None, None, 80),
fmt,
};
let _ = flock(&client, &mut streams).await;
let printed = String::from_utf8_lossy(&out);
assert_eq!(
printed.contains("no sheep in the flock yet"),
expect_art,
"fmt={fmt:?} level={level:?}: {printed}"
);
}
}
}