pub(crate) mod parse;
pub(crate) mod plan;
use std::collections::BTreeMap;
use shep_client::Client;
use shep_core::paths::ShepPaths;
use shep_core::protocol::{EnvValue, Request, Response};
use shep_core::secrets;
use plan::{Class, ImportPlan, Planned};
use crate::cli::ImportEnvArgs;
use crate::commands::secret::{daemon_config, exit_code_for};
use crate::exit::ExitCode;
use crate::output::{ImportEnvRow, ImportEnvRows, Streams, emit, write_outcome};
const SECRET_STORE: &str = "secret";
const ENV_STORE: &str = "env";
const ENV_HOLDER: &str = "the sheep's env, from its Flockfile or an earlier override";
const SECRET_HOLDER: &str = "the secret store";
pub async fn import_env(
client: &Client,
streams: &mut Streams<'_>,
paths: &ShepPaths,
args: &ImportEnvArgs,
) -> ExitCode {
let text = match std::fs::read_to_string(&args.file) {
Ok(text) => text,
Err(err) => {
let message = format!("could not read {}: {err}", args.file.display());
return streams.fail(ExitCode::Usage, &message);
}
};
let entries = match parse::parse(&text) {
Ok(entries) => entries,
Err(err) => return streams.fail(ExitCode::InvalidConfig, &err.to_string()),
};
let plan = match plan::build(entries, &args.only, &args.secret) {
Ok(plan) => plan,
Err(err) => return streams.fail(ExitCode::Usage, &err.to_string()),
};
let environment = match resolve_environment(client, streams, paths, args).await {
Ok(environment) => environment,
Err(code) => return code,
};
let wire = wire_entries(&plan);
let env_collisions = match probe_env(client, streams, args, &wire).await {
Ok(collisions) => collisions,
Err(code) => return code,
};
let secret_collisions = match probe_secrets(streams, paths, &plan, &environment) {
Ok(collisions) => collisions,
Err(code) => return code,
};
if !args.force {
let refused = report_collisions(streams, &env_collisions, &secret_collisions);
if refused != ExitCode::Success {
return refused;
}
}
if !plan.unnamed.is_empty() {
let message = format!(
"stored in the clear because no --secret named them: {}",
plan.unnamed.join(", ")
);
streams.aside("secretish", &message);
}
if args.dry_run {
return emit_rows(streams, &plan, &environment);
}
let mut written = 0_usize;
for planned in &plan.entries {
if planned.class == Class::Secret {
if let Err(err) =
secrets::set(&paths.secrets, &planned.key, &environment, &planned.value)
{
let message = format!("{err}{}", already_written(written));
return streams.fail(exit_code_for(&err), &message);
}
written += 1;
}
}
let request = Request::SetSheepEnvBatch {
name: args.app.clone(),
entries: wire,
force: args.force,
dry_run: false,
};
match client.request(request).await {
Ok(Response::SheepEnvBatch { collisions, .. })
if is_late_refusal(args.force, &collisions) =>
{
return report_late_collisions(streams, &collisions, written);
}
Ok(Response::SheepEnvBatch { .. }) => {}
Ok(_) => {
let message = format!("{UNDERSTOOD_NOTHING}{}", already_written(written));
return streams.fail(ExitCode::Internal, &message);
}
Err(err) => {
let message = format!("{err}{}", already_written(written));
return streams.fail(ExitCode::from(&err), &message);
}
}
let message = format!(
"{}'s env is parked for its next spawn; `shep reload {}` promotes it",
args.app, args.app
);
streams.aside("parked", &message);
emit_rows(streams, &plan, &environment)
}
const UNDERSTOOD_NOTHING: &str =
"the daemon answered with a response this client does not understand";
async fn resolve_environment(
client: &Client,
streams: &mut Streams<'_>,
paths: &ShepPaths,
args: &ImportEnvArgs,
) -> Result<String, ExitCode> {
if let Some(environment) = &args.env {
return Ok(environment.clone());
}
let request = Request::SheepConfig {
name: args.app.clone(),
};
match client.request(request).await {
Ok(Response::SheepConfig(view)) => Ok(view
.config
.environment
.clone()
.unwrap_or_else(|| daemon_config(paths).daemon.environment)),
Ok(_) => Err(streams.fail(ExitCode::Internal, UNDERSTOOD_NOTHING)),
Err(err) => Err(streams.fail(ExitCode::from(&err), &err.to_string())),
}
}
fn wire_entries(plan: &ImportPlan) -> BTreeMap<String, EnvValue> {
plan.entries
.iter()
.map(|planned| {
let value = match planned.class {
Class::Secret => format!("{{{{secret:{}}}}}", planned.key),
Class::Plain => escape_template(&planned.value),
};
(planned.key.clone(), EnvValue::from(value))
})
.collect()
}
fn escape_template(value: &str) -> String {
value.replace("{{", "{{{{").replace("}}", "}}}}")
}
async fn probe_env(
client: &Client,
streams: &mut Streams<'_>,
args: &ImportEnvArgs,
entries: &BTreeMap<String, EnvValue>,
) -> Result<Vec<String>, ExitCode> {
let request = Request::SetSheepEnvBatch {
name: args.app.clone(),
entries: entries.clone(),
force: args.force,
dry_run: true,
};
match client.request(request).await {
Ok(Response::SheepEnvBatch { collisions, .. }) => Ok(collisions),
Ok(_) => Err(streams.fail(ExitCode::Internal, UNDERSTOOD_NOTHING)),
Err(err) => Err(streams.fail(ExitCode::from(&err), &err.to_string())),
}
}
fn probe_secrets(
streams: &mut Streams<'_>,
paths: &ShepPaths,
plan: &ImportPlan,
environment: &str,
) -> Result<Vec<String>, ExitCode> {
let mut collisions = Vec::new();
for planned in plan.entries.iter().filter(|e| e.class == Class::Secret) {
match secrets::get(&paths.secrets, &planned.key, environment) {
Ok(Some(existing)) if existing != planned.value => collisions.push(planned.key.clone()),
Ok(_) => {}
Err(err) => return Err(streams.fail(exit_code_for(&err), &err.to_string())),
}
}
Ok(collisions)
}
fn report_collisions(
streams: &mut Streams<'_>,
env_collisions: &[String],
secret_collisions: &[String],
) -> ExitCode {
if env_collisions.is_empty() && secret_collisions.is_empty() {
return ExitCode::Success;
}
name_collisions(streams, env_collisions, secret_collisions);
streams.fail(
ExitCode::Usage,
"nothing was imported; pass --force to overwrite the keys named above",
)
}
fn name_collisions(
streams: &mut Streams<'_>,
env_collisions: &[String],
secret_collisions: &[String],
) {
for (keys, holder) in [
(env_collisions, ENV_HOLDER),
(secret_collisions, SECRET_HOLDER),
] {
for key in keys {
let message = format!("`{key}` already holds a different value in {holder}");
streams.aside("collision", &message);
}
}
}
fn report_late_collisions(
streams: &mut Streams<'_>,
collisions: &[String],
written: usize,
) -> ExitCode {
name_collisions(streams, collisions, &[]);
let message = format!(
"the sheep's env changed since this run's dry run, so nothing was written to it{}. \
Re-run the import, with --force to overwrite the keys named above",
already_written(written)
);
streams.fail(ExitCode::Usage, &message)
}
fn is_late_refusal(force: bool, collisions: &[String]) -> bool {
!force && !collisions.is_empty()
}
fn already_written(written: usize) -> String {
if written == 0 {
return String::new();
}
let (keys, was_were, sit_sat) = if written == 1 {
("key", "was", "sits")
} else {
("keys", "were", "sit")
};
format!(
"; {written} secret {keys} {was_were} already written to the secret store and {sit_sat} \
unreferenced until the import is re-run. A re-run is clean, since an identical \
value is not a collision"
)
}
fn emit_rows(streams: &mut Streams<'_>, plan: &ImportPlan, environment: &str) -> ExitCode {
let rows = ImportEnvRows(
plan.entries
.iter()
.map(|p| row_for(p, environment))
.collect(),
);
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"import",
rows,
streams.style,
))
}
fn row_for(planned: &Planned, environment: &str) -> ImportEnvRow {
let (store, slot) = match planned.class {
Class::Secret => (SECRET_STORE, environment),
Class::Plain => (ENV_STORE, "-"),
};
ImportEnvRow {
key: planned.key.clone(),
store: store.to_string(),
slot: slot.to_string(),
bytes: planned.value.len(),
}
}
#[cfg(test)]
mod tests {
use shep_core::config::template::render_positional;
use super::*;
const SAMPLE: &str = "PORT=8080\nDB_PASSWORD=hunter2\n";
fn sample_plan() -> ImportPlan {
let entries = parse::parse(SAMPLE).expect("this fixture parses");
plan::build(entries, &[], &["DB_PASSWORD".to_string()]).expect("this fixture plans")
}
#[test]
fn a_secret_reaches_the_sheep_as_a_reference_and_a_plain_key_as_itself() {
let wire = wire_entries(&sample_plan());
assert_eq!(wire["PORT"].as_str(), "8080");
assert_eq!(wire["DB_PASSWORD"].as_str(), "{{secret:DB_PASSWORD}}");
}
#[test]
fn a_plain_value_escapes_its_braces_and_a_secret_reference_stays_live() {
const BRACES: &str = "MOTD=hello {{world}}\nGREETING={{name}}-prod\nJSON={\"a\":{\"b\":1}}\nDB_PASSWORD=hunter2\n";
let entries = parse::parse(BRACES).expect("this fixture parses");
let plan =
plan::build(entries, &[], &["DB_PASSWORD".to_string()]).expect("this fixture plans");
let wire = wire_entries(&plan);
assert_eq!(wire["MOTD"].as_str(), "hello {{{{world}}}}");
assert_eq!(wire["GREETING"].as_str(), "{{{{name}}}}-prod");
assert_eq!(wire["DB_PASSWORD"].as_str(), "{{secret:DB_PASSWORD}}");
for planned in plan.entries.iter().filter(|p| p.class == Class::Plain) {
assert_eq!(
render_positional(wire[&planned.key].as_str(), "echoer", 0),
planned.value,
"{} was rewritten on its way to the child",
planned.key
);
}
}
#[test]
fn a_row_carries_a_length_and_never_a_value() {
let plan = sample_plan();
let rows: Vec<ImportEnvRow> = plan
.entries
.iter()
.map(|planned| row_for(planned, "production"))
.collect();
assert_eq!(rows[0].store, "env");
assert_eq!(rows[0].slot, "-");
assert_eq!(rows[1].store, "secret");
assert_eq!(rows[1].slot, "production");
assert_eq!(rows[1].bytes, "hunter2".len());
assert!(!format!("{rows:?}").contains("hunter2"), "{rows:?}");
}
#[test]
fn the_already_written_note_counts_keys_and_says_a_re_run_is_clean() {
assert!(already_written(1).starts_with("; 1 secret key was already written"));
assert!(already_written(4).starts_with("; 4 secret keys were already written"));
assert!(already_written(1).contains("A re-run is clean"));
}
#[test]
fn the_already_written_note_is_empty_when_nothing_was_written() {
assert_eq!(already_written(0), "");
}
#[test]
fn only_an_unforced_answer_naming_collisions_is_a_late_refusal() {
let none: &[String] = &[];
let some = &["PORT".to_string()];
assert!(is_late_refusal(false, some));
assert!(!is_late_refusal(true, some));
assert!(!is_late_refusal(false, none));
assert!(!is_late_refusal(true, none));
}
}