pub(crate) mod address;
pub(crate) mod confirm;
pub(crate) mod env;
pub(crate) mod plan;
pub(crate) mod report;
use std::fmt;
use std::io::IsTerminal as _;
use shep_client::Client;
use shep_core::paths::ShepPaths;
use shep_core::protocol::{Request, Response};
use crate::cli::{CopyArgs, Format};
use crate::commands::rpc::{client_error, typed_request_error, unexpected_response};
use crate::commands::settings_write;
use crate::dog_settings::{AskFailure, ask_dog};
use crate::exit::ExitCode;
use crate::output::Streams;
use address::{Address, ConfigPath, FlockNames};
use confirm::{Answer, Context, Go, confirm, decide};
use plan::{Plan, Write};
use report::{ReportState, finish, print_changes, report_for, shows_changes_first};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CopyRefusal {
Usage(String),
UnknownSheep(String),
MissingSource(String),
Refused(String),
ByDog(String),
}
impl CopyRefusal {
pub(crate) fn message(&self) -> &str {
match self {
Self::Usage(message)
| Self::UnknownSheep(message)
| Self::MissingSource(message)
| Self::Refused(message)
| Self::ByDog(message) => message,
}
}
pub(crate) const fn code(&self) -> ExitCode {
match self {
Self::Usage(_) => ExitCode::Usage,
Self::UnknownSheep(_) | Self::MissingSource(_) => ExitCode::NotFound,
Self::Refused(_) | Self::ByDog(_) => ExitCode::InvalidConfig,
}
}
pub(crate) fn report(&self, streams: &mut Streams<'_>) -> ExitCode {
streams.fail(self.code(), self.message())
}
}
impl fmt::Display for CopyRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.message())
}
}
impl core::error::Error for CopyRefusal {}
pub(crate) async fn copy(
client: &Client,
streams: &mut Streams<'_>,
paths: &ShepPaths,
args: &CopyArgs,
) -> ExitCode {
let flock = match flock_names(client, streams).await {
Ok(names) => names,
Err(code) => return code,
};
for raw in [&args.from, &args.to] {
let segments: Vec<String> = raw.split('.').map(str::to_string).collect();
if let Some(defaults) = settings_write::defaults_in(&segments, &flock, paths) {
return CopyRefusal::Refused(address::retired_defaults(&defaults)).report(streams);
}
}
let from = match address::written(&args.from, &flock) {
Ok(written) => written,
Err(refusal) => return refusal.report(streams),
};
let to = match address::written(&args.to, &flock) {
Ok(written) => written,
Err(refusal) => return refusal.report(streams),
};
if let Some(refusal) = same_sheep(&from.sheep, &to.sheep) {
return refusal.report(streams);
}
let except = match plan::except_paths(&args.except) {
Ok(paths) => paths,
Err(refusal) => return refusal.report(streams),
};
let source = match read_config(client, streams, &from.sheep).await {
Ok(config) => config,
Err(code) => return code,
};
let target = match read_config(client, streams, &to.sheep).await {
Ok(config) => config,
Err(code) => return code,
};
let dogs = address::DogNames::new(&flock, &source, &target);
let from = match address::path_of(&from, &dogs, &source, &target) {
Ok(path) => Address {
sheep: from.sheep.clone(),
path,
},
Err(refusal) => return refusal.report(streams),
};
let to = match address::path_of(&to, &dogs, &source, &target) {
Ok(path) => Address {
sheep: to.sheep.clone(),
path,
},
Err(refusal) => return refusal.report(streams),
};
if let Err(refusal) = same_pair(&from, &to) {
return refusal.report(streams);
}
let plan = if from.path == ConfigPath::Env {
let sides = plan::Sides {
source: &from.sheep,
target: &to.sheep,
};
match env::plan_env(client, streams, sides, &args.except, args.replace).await {
Ok(plan) => plan,
Err(code) => return code,
}
} else {
match build_plan(&from, &to, &except, args.replace, &source, &target) {
Ok(plan) => plan,
Err(refusal) => return refusal.report(streams),
}
};
if let Some(refusal) = dog_wide::refusal(paths, &from, &to.sheep, &except, &source) {
return refusal.report(streams);
}
let path = from.path.dotted();
let mut report = report_for(&from, &to, &plan, ReportState::preview(args.dry_run));
if plan.changes.is_empty() {
let message = format!(
"nothing to copy: `{}` already holds the same value at `{path}`",
to.sheep
);
streams.note("copy", &message);
return finish(streams, &report);
}
let go = decide(Context {
dry_run: args.dry_run,
yes: args.yes,
stdin_is_terminal: std::io::stdin().is_terminal(),
has_changes: !plan.changes.is_empty(),
});
if shows_changes_first(streams.fmt, go)
&& let Err(code) = print_changes(streams, &report)
{
return code;
}
if go == Go::Refuse {
return streams.fail(
ExitCode::Usage,
"nothing was written: `shep copy` asks before it writes, and this run has no \
terminal to ask on. Pass --yes to write without asking, or --dry-run to see the \
changes and write nothing",
);
}
if go == Go::Ask {
let mut reader = std::io::stdin().lock();
match confirm(
streams,
&from.sheep,
&to.sheep,
&path,
plan.changes.len(),
&mut reader,
) {
Answer::Yes => {}
Answer::No => {
streams.note("copy", "nothing was written");
return finish(streams, &report);
}
Answer::Failed(reason) => {
return streams.fail(
ExitCode::Failure,
&format!("nothing was written: the question could not be read ({reason})"),
);
}
}
}
if go == Go::DryRun {
streams.note("dry_run", "nothing was written");
return finish(streams, &report);
}
let Some(write) = plan.write.clone() else {
let message = format!("the plan named {} changes and no write", plan.changes.len());
return streams.fail(ExitCode::Internal, &message);
};
let pending = match write_plan(client, streams, paths, &to.sheep, write).await {
Ok(pending) => pending,
Err(code) => return code,
};
report = report_for(&from, &to, &plan, ReportState::Written);
if !pending.is_empty() {
let message = format!(
"{} waiting on the next spawn (`shep reload {}` promotes them)",
pending.join(", "),
to.sheep
);
streams.note("parked", &message);
}
report.pending = pending;
if streams.fmt == Format::Table {
let message = format!(
"wrote {} change{} onto `{}`",
plan.changes.len(),
if plan.changes.len() == 1 { "" } else { "s" },
to.sheep
);
streams.note("written", &message);
}
finish(streams, &report)
}
pub(crate) async fn flock_names(
client: &Client,
streams: &mut Streams<'_>,
) -> Result<FlockNames, ExitCode> {
match client.list_flock().await {
Ok(info) => Ok(FlockNames {
sheep: info
.iter()
.filter(|info| info.dog.is_none())
.map(|info| info.name.clone())
.collect(),
dogs: info
.iter()
.filter(|info| info.dog.is_some())
.map(|info| info.name.clone())
.collect(),
}),
Err(err) => Err(typed_request_error(streams, &err)),
}
}
pub(crate) async fn read_config(
client: &Client,
streams: &mut Streams<'_>,
name: &str,
) -> Result<serde_json::Value, ExitCode> {
match client
.request(Request::SheepConfig {
name: name.to_string(),
})
.await
{
Ok(Response::SheepConfig(view)) => serde_json::to_value(&view.config).map_err(|_| {
streams.fail(
ExitCode::Internal,
"the shepherd's config answer did not serialize, so nothing was copied",
)
}),
Ok(_other) => Err(unexpected_response(streams)),
Err(err) => Err(client_error(streams, &err)),
}
}
fn same_sheep(from: &str, to: &str) -> Option<CopyRefusal> {
(from == to).then(|| {
CopyRefusal::Usage(format!(
"`{from}` is on both sides: a copy needs two different sheep"
))
})
}
fn same_pair(from: &Address, to: &Address) -> Result<(), CopyRefusal> {
if from.path != to.path {
return Err(CopyRefusal::Usage(format!(
"the path must be the same on both sides: `{}.{}` and `{}.{}`",
from.sheep,
from.path.dotted(),
to.sheep,
to.path.dotted()
)));
}
if let Some(refusal) = same_sheep(&from.sheep, &to.sheep) {
return Err(refusal);
}
Ok(())
}
fn build_plan(
from: &Address,
to: &Address,
except: &[Vec<String>],
replace: bool,
source: &serde_json::Value,
target: &serde_json::Value,
) -> Result<Plan, CopyRefusal> {
match &from.path {
ConfigPath::Field { name } => {
if !except.is_empty() {
return Err(CopyRefusal::Usage(format!(
"--except names a key inside a dog's table: `{name}` is one field, and a \
copy takes a field whole"
)));
}
Ok(plan::plan_field(name, source, target))
}
ConfigPath::Env => Err(CopyRefusal::Usage(
"the env of a sheep is planned by the shepherd, not from the configs read here"
.to_string(),
)),
ConfigPath::Dog { dog, keys } => plan::plan_dog(
plan::Sides {
source: &from.sheep,
target: &to.sheep,
},
dog,
keys,
except,
replace,
source,
target,
),
}
}
async fn write_plan(
client: &Client,
streams: &mut Streams<'_>,
paths: &ShepPaths,
target: &str,
write: Write,
) -> Result<Vec<String>, ExitCode> {
match write {
Write::Field { name, value } => {
let request = Request::SetSheepField {
name: target.to_string(),
key: name.clone(),
value,
};
match client.request(request).await {
Ok(Response::SheepFieldSet {
pending, warning, ..
}) => {
if let Some(warning) = warning {
streams.aside("warning", &warning);
}
Ok(pending.then_some(name).into_iter().collect())
}
Ok(_other) => Err(unexpected_response(streams)),
Err(err) => Err(client_error(streams, &err)),
}
}
Write::Env {
from,
replace,
except,
expect,
} => env::write_env(client, streams, target, &from, replace, &except, expect).await,
Write::DogTable { dog, table } => {
match ask_dog(client, paths, &dog, table.as_map(), Some(target)).await {
Ok(()) => {}
Err(AskFailure::Refused { reason }) => {
return Err(CopyRefusal::ByDog(format!(
"nothing was written: `{dog}` refused `{target}`'s table for `{dog}`: \
{reason}"
))
.report(streams));
}
Err(AskFailure::Unknown { reason }) => {
return Err(
CopyRefusal::Refused(format!("nothing was written: {reason}"))
.report(streams),
);
}
Err(AskFailure::Transport(err)) => return Err(client_error(streams, &err)),
}
let request = Request::SetSheepDogSettings {
name: target.to_string(),
dog,
table: Some(table),
};
match client.request(request).await {
Ok(Response::SheepDogSettingsSet { .. }) => Ok(Vec::new()),
Ok(_other) => Err(unexpected_response(streams)),
Err(err) => Err(client_error(streams, &err)),
}
}
}
}
mod dog_wide;
#[cfg(all(test, unix))]
mod dog_wide_tests;
#[cfg(test)]
mod validate_tests;
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::commands::copy::plan::ChangeKind;
fn address(raw: &str) -> Address {
let written = address::written(raw, &flock()).unwrap();
let dogs =
address::DogNames::new(&flock(), &serde_json::Value::Null, &serde_json::Value::Null);
Address {
sheep: written.sheep.clone(),
path: address::path_of(
&written,
&dogs,
&serde_json::Value::Null,
&serde_json::Value::Null,
)
.unwrap(),
}
}
fn flock() -> FlockNames {
FlockNames {
sheep: ["shep", "shep-kelpie", "web", "api", "we.b"]
.iter()
.map(|name| (*name).to_string())
.collect(),
dogs: ["metrics", "foo.bar"]
.iter()
.map(|name| (*name).to_string())
.collect(),
}
}
fn config_with_dog(dog: &str, table: serde_json::Value) -> serde_json::Value {
json!({ "name": "web", "dogs": { dog: table } })
}
fn resolve(
from: &str,
to: &str,
source: serde_json::Value,
target: serde_json::Value,
except: &[String],
replace: bool,
) -> Result<Plan, CopyRefusal> {
let flock = flock();
let from = address::written(from, &flock)?;
let to = address::written(to, &flock)?;
let dogs = address::DogNames::new(&flock, &source, &target);
let from = Address {
sheep: from.sheep.clone(),
path: address::path_of(&from, &dogs, &source, &target)?,
};
let to = Address {
sheep: to.sheep.clone(),
path: address::path_of(&to, &dogs, &source, &target)?,
};
same_pair(&from, &to)?;
let except = plan::except_paths(except)?;
build_plan(&from, &to, &except, replace, &source, &target)
}
fn written_table(plan: &Plan) -> serde_json::Map<String, serde_json::Value> {
let Write::DogTable { table, .. } = plan.write.clone().expect("a dog copy writes") else {
panic!("a dog copy writes a dog table");
};
table.into_map()
}
#[test]
fn a_dog_sub_table_merges_onto_the_target_and_keeps_its_other_keys() {
let source = config_with_dog(
"kelpie",
json!({ "rulings": { "decide": "panel", "report": "brief" }, "git": { "remote": "origin" } }),
);
let target = config_with_dog(
"kelpie",
json!({ "rulings": { "decide": "issue", "own": "keep" }, "watch": true }),
);
let plan = resolve(
"shep.dogs.kelpie.rulings",
"shep-kelpie.dogs.kelpie.rulings",
source,
target,
&[],
false,
)
.expect("the source has this table");
let kinds: Vec<(&str, &str)> = plan
.changes
.iter()
.map(|change| (change.key.as_str(), change.kind.as_str()))
.collect();
assert_eq!(
kinds,
[("decide", "changed"), ("report", "added"),],
"the copied sub-table is what is judged, and a key only the target has inside it stays"
);
let table = written_table(&plan);
assert_eq!(
table["rulings"],
json!({ "decide": "panel", "own": "keep", "report": "brief" }),
"the sub-table merges: the source's keys overwrite, the target's own key stays"
);
assert_eq!(table["watch"], json!(true), "a key outside the path stays");
assert!(
!table.contains_key("git"),
"the source's `git` sits outside the copied path, so it is not copied"
);
}
#[test]
fn replace_makes_the_target_table_exactly_the_source() {
let source = config_with_dog("kelpie", json!({ "rulings": { "decide": "panel" } }));
let target = config_with_dog(
"kelpie",
json!({ "rulings": { "decide": "issue", "own": "gone" }, "watch": true }),
);
let plan = resolve(
"shep.dogs.kelpie",
"shep-kelpie.dogs.kelpie",
source,
target,
&[],
true,
)
.expect("the source has this table");
let kinds: Vec<(&str, &str)> = plan
.changes
.iter()
.map(|change| (change.key.as_str(), change.kind.as_str()))
.collect();
assert_eq!(
kinds,
[
("rulings.decide", "changed"),
("rulings.own", "removed"),
("watch", "removed"),
]
);
assert_eq!(
serde_json::Value::Object(written_table(&plan)),
json!({ "rulings": { "decide": "panel" } })
);
}
#[test]
fn an_excepted_key_is_left_as_the_target_had_it() {
let source = config_with_dog(
"kelpie",
json!({ "git": { "checkout": "./src", "remote": "upstream" }, "rulings": { "decide": "panel" } }),
);
let target = config_with_dog(
"kelpie",
json!({ "git": { "checkout": "./mine", "branch": "main" } }),
);
let plan = resolve(
"shep.dogs.kelpie",
"shep-kelpie.dogs.kelpie",
source,
target,
&["git.checkout".to_string()],
false,
)
.expect("the source has this table");
let keys: Vec<&str> = plan.changes.iter().map(|c| c.key.as_str()).collect();
assert_eq!(keys, ["git.remote", "rulings.decide"]);
assert_eq!(
written_table(&plan)["git"],
json!({ "branch": "main", "checkout": "./mine", "remote": "upstream" }),
"`git.checkout` is the target's own value, and `git.branch` was never in question"
);
}
#[test]
fn a_field_copies_and_the_refused_fields_say_why() {
let source = json!({ "name": "web", "restart_delay": "3s", "env": {} });
let target = json!({ "name": "api", "restart_delay": "1s", "env": {} });
let plan = resolve(
"web.restart_delay",
"api.restart_delay",
source.clone(),
target.clone(),
&[],
false,
)
.expect("restart_delay is a field");
assert_eq!(plan.changes.len(), 1);
assert_eq!(plan.changes[0].kind, ChangeKind::Changed);
assert_eq!(plan.changes[0].old, Some(json!("1s")));
assert_eq!(
plan.write,
Some(Write::Field {
name: "restart_delay".to_string(),
value: json!("3s"),
}),
"the write is the request the shepherd already answers"
);
for (path, expected) in [
("web.name", "different sheep"),
("web.instances", "shep stock web"),
("web.dogs", "web.dogs.<dog>"),
] {
let err = resolve(
path,
&path.replace("web.", "api."),
source.clone(),
target.clone(),
&[],
false,
)
.expect_err("{path} is refused");
assert!(err.message().contains(expected), "{path}: {err}");
}
}
#[test]
fn different_paths_on_the_two_sides_are_refused() {
let source = config_with_dog("kelpie", json!({ "rulings": { "decide": "panel" } }));
let target = config_with_dog("jobs", json!({ "rulings": { "decide": "issue" } }));
let err = resolve(
"web.dogs.kelpie.rulings",
"api.dogs.jobs.rulings",
source.clone(),
target.clone(),
&[],
false,
)
.expect_err("a dog name is part of the path");
assert_eq!(
err,
CopyRefusal::Usage(
"the path must be the same on both sides: `web.dogs.kelpie.rulings` and \
`api.dogs.jobs.rulings`"
.to_string()
)
);
let err = resolve(
"web.restart_delay",
"api.kill_timeout",
source,
target,
&[],
false,
)
.expect_err("two fields are two paths");
assert!(err.message().contains("the path must be the same"), "{err}");
}
#[test]
fn dry_run_writes_nothing_and_a_pipe_needs_yes() {
assert_eq!(
decide(Context {
dry_run: true,
yes: false,
stdin_is_terminal: false,
has_changes: true
}),
Go::DryRun
);
assert_eq!(
decide(Context {
dry_run: true,
yes: true,
stdin_is_terminal: true,
has_changes: true
}),
Go::DryRun
);
assert_eq!(
decide(Context {
dry_run: false,
yes: false,
stdin_is_terminal: false,
has_changes: true
}),
Go::Refuse
);
assert_eq!(
decide(Context {
dry_run: false,
yes: true,
stdin_is_terminal: false,
has_changes: true
}),
Go::Write
);
assert_eq!(
decide(Context {
dry_run: false,
yes: false,
stdin_is_terminal: true,
has_changes: true
}),
Go::Ask
);
assert_eq!(
decide(Context {
dry_run: false,
yes: false,
stdin_is_terminal: true,
has_changes: false
}),
Go::Nothing
);
let source = config_with_dog("kelpie", json!({ "rulings": { "decide": "panel" } }));
let target = config_with_dog("kelpie", json!({ "rulings": { "decide": "issue" } }));
let plan = resolve(
"web.dogs.kelpie",
"api.dogs.kelpie",
source,
target,
&[],
false,
)
.expect("the source has this table");
let dry = report_for(
&address("web.dogs.kelpie"),
&address("api.dogs.kelpie"),
&plan,
ReportState::DryRun,
);
assert!(dry.dry_run);
assert!(
!dry.written,
"a dry run reports no write, whatever the plan holds"
);
assert_eq!(dry.changes.len(), 1);
assert_ne!(
decide(Context {
dry_run: true,
yes: false,
stdin_is_terminal: false,
has_changes: true
}),
Go::Write
);
assert_ne!(
decide(Context {
dry_run: false,
yes: false,
stdin_is_terminal: false,
has_changes: true
}),
Go::Write
);
}
#[test]
fn a_missing_source_is_named() {
let source = config_with_dog("kelpie", json!({ "rulings": { "decide": "panel" } }));
let target = config_with_dog("kelpie", json!({ "rulings": { "decide": "issue" } }));
let err = resolve(
"web.dogs.jobs",
"api.dogs.jobs",
source.clone(),
target.clone(),
&[],
false,
)
.expect_err("no table for `jobs`");
assert_eq!(
err,
CopyRefusal::MissingSource(
"no `web.dogs.jobs`: that sheep has no table for the dog `jobs`".to_string()
)
);
let err = resolve(
"web.dogs.kelpie.reporting",
"api.dogs.kelpie.reporting",
source.clone(),
target.clone(),
&[],
false,
)
.expect_err("no key `reporting`");
assert_eq!(
err,
CopyRefusal::MissingSource("no `reporting` under `web.dogs.kelpie`".to_string())
);
let err = resolve(
"nope.dogs.kelpie",
"api.dogs.kelpie",
source,
target,
&[],
false,
)
.expect_err("no sheep `nope`");
assert_eq!(
err,
CopyRefusal::UnknownSheep(
"no sheep named `nope`; `shep flock` lists the flock".to_string()
)
);
}
#[test]
fn a_sheep_name_with_a_dot_resolves() {
let source = config_with_dog("kelpie", json!({ "rulings": { "decide": "panel" } }));
let target = config_with_dog("kelpie", json!({ "rulings": { "decide": "issue" } }));
let plan = resolve(
"we.b.dogs.kelpie.rulings",
"api.dogs.kelpie.rulings",
source,
target,
&[],
false,
)
.expect("`we.b` is a sheep name, dots and all");
assert_eq!(plan.changes.len(), 1);
assert_eq!(plan.changes[0].key, "decide");
}
#[test]
fn the_same_sheep_on_both_sides_is_refused() {
let err = same_pair(&address("web.dogs.kelpie"), &address("web.dogs.kelpie"))
.expect_err("one sheep is not a copy");
assert_eq!(
err,
CopyRefusal::Usage(
"`web` is on both sides: a copy needs two different sheep".to_string()
)
);
}
#[test]
fn except_on_a_field_is_refused() {
let err = resolve(
"web.restart_delay",
"api.restart_delay",
json!({ "restart_delay": "3s" }),
json!({ "restart_delay": "1s" }),
&["git.checkout".to_string()],
false,
)
.expect_err("a field has no keys to leave out");
assert!(err.message().contains("one field"), "{err}");
}
#[test]
fn a_flock_dog_name_does_not_hide_a_key_under_a_dog_it_prefixes() {
let source = config_with_dog("foo", json!({ "bar": { "enabled": true } }));
let target = config_with_dog("foo", json!({ "keep": 1 }));
let plan = resolve(
"web.dogs.foo.bar",
"api.dogs.foo.bar",
source,
target,
&[],
false,
)
.expect("the table reading wins over the flock's dog name");
assert_eq!(plan.changes.len(), 1);
assert_eq!(
plan.changes[0].key, "enabled",
"a sub-table copy names its keys under the path it copied"
);
let table = written_table(&plan);
assert_eq!(table["bar"], json!({ "enabled": true }));
assert_eq!(
table["keep"],
json!(1),
"the target's other key under `foo` survives"
);
}
}