use std::path::PathBuf;
use serde::Deserialize;
use serde_json::Value;
use shep_client::shep_core::config::AppConfig;
use crate::build::{BuildSpec, contained_artifacts};
use crate::daemon::Daemon;
use crate::error::Error;
use crate::hook::{HookSpec, Hooks};
use crate::shared::printable;
const TABLE: &str = "deploy";
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub command: Option<String>,
#[serde(deserialize_with = "contained_artifacts")]
pub artifacts: Vec<PathBuf>,
pub reload: Vec<String>,
pub release: HookSpec,
pub after: HookSpec,
}
impl Settings {
pub(crate) fn build_spec(&self) -> BuildSpec {
BuildSpec {
command: self.command.clone(),
artifacts: self.artifacts.clone(),
..BuildSpec::default()
}
}
pub(crate) fn hooks(&self) -> Hooks {
Hooks {
release: self.release.clone(),
after: self.after.clone(),
}
}
}
pub(crate) async fn read<D: Daemon>(daemon: &D, sheep: &str) -> Result<Settings, Error> {
from_app(sheep, &daemon.sheep_config(sheep).await?)
}
pub(crate) fn from_app(sheep: &str, app: &AppConfig) -> Result<Settings, Error> {
let Some(table) = app.dogs.get(TABLE) else {
return Ok(Settings::default());
};
serde_json::from_value(Value::Object(table.as_map().clone())).map_err(|source| {
Error::Config(format!(
"{sheep}: `[app.dogs.deploy]` does not match the settings schema: {}",
printable(without_value(&source.to_string()))
))
})
}
fn without_value(message: &str) -> String {
for kind in ["invalid type", "invalid value"] {
if let Some(rest) = message.strip_prefix(kind)
&& rest.starts_with(": ")
&& let Some(at) = rest.rfind(", expected ")
{
return format!("{kind}{}", &rest[at..]);
}
}
message.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures;
#[test]
fn a_table_parses_into_settings() {
let app = fixtures::app_with_settings(
"web",
"command = 'make build'\nartifacts = ['target/release/koji']\n\
reload = ['cmd/voting']\n\
[release]\ncommand = 'migrate'\nenv = { PASSWORD = 'hunter2' }\n\
[after]\ncommand = 'purge-cache'\n",
);
let settings = from_app("web", &app).expect("parses");
assert_eq!(settings.command.as_deref(), Some("make build"));
assert_eq!(settings.artifacts, [PathBuf::from("target/release/koji")]);
assert_eq!(settings.reload, ["cmd/voting"]);
assert_eq!(settings.release.command.as_deref(), Some("migrate"));
assert_eq!(
settings.release.env.get("PASSWORD").map(String::as_str),
Some("hunter2")
);
assert_eq!(settings.after.command.as_deref(), Some("purge-cache"));
}
#[test]
fn no_table_and_an_empty_table_are_both_the_default() {
let bare = fixtures::app_named("web");
assert_eq!(from_app("web", &bare).expect("parses"), Settings::default());
let empty = fixtures::app_with_settings("web", "");
assert_eq!(
from_app("web", &empty).expect("parses"),
Settings::default()
);
}
#[test]
fn an_unknown_key_is_refused_naming_the_key_and_the_sheep() {
let app = fixtures::app_with_settings("web", "commnd = 'make'\n");
let err = from_app("web", &app).expect_err("refused");
assert!(matches!(err, Error::Config(_)), "{err}");
let shown = err.to_string();
assert!(shown.contains("commnd"), "{shown}");
assert!(shown.contains("web"), "{shown}");
}
#[test]
fn an_unknown_hook_key_is_refused() {
let app = fixtures::app_with_settings("web", "[after]\ncommnd = 'true'\n");
let err = from_app("web", &app).expect_err("refused");
assert!(err.to_string().contains("commnd"), "{err}");
}
#[test]
fn an_artifact_outside_the_release_is_refused() {
let app = fixtures::app_with_settings("web", "artifacts = ['../../etc/passwd']\n");
let err = from_app("web", &app).expect_err("refused");
assert!(err.to_string().contains(".."), "{err}");
}
#[test]
fn a_wrong_typed_value_is_not_echoed() {
for table in [
"[release]\nenv = { PASSWORD = 1234567 }\n",
"[release]\nenv = 'hunter2'\n",
"release = 'hunter2'\n",
"reload = 'hunter2'\n",
] {
let app = fixtures::app_with_settings("web", table);
let shown = from_app("web", &app).expect_err("refused").to_string();
assert!(!shown.contains("hunter2"), "{shown}");
assert!(!shown.contains("1234567"), "{shown}");
assert!(shown.contains("web"), "{shown}");
}
}
#[test]
fn debug_redacts_hook_env_values() {
let settings = Settings {
command: Some("make".to_owned()),
release: HookSpec {
command: Some("migrate".to_owned()),
env: [("PASSWORD".to_owned(), "hunter2".to_owned())].into(),
},
..Settings::default()
};
assert_eq!(
format!("{settings:?}"),
"Settings { command: Some(\"make\"), artifacts: [], reload: [], \
release: HookSpec { command: Some(\"migrate\"), env: <1 vars> }, \
after: HookSpec { command: None, env: <0 vars> } }"
);
}
#[test]
fn a_parser_message_never_carries_a_control_character() {
let app = fixtures::app_with_settings("web", "\"a\\u001bb\" = 1\n");
let shown = from_app("web", &app).expect_err("refused").to_string();
assert!(!shown.chars().any(char::is_control), "{shown:?}");
assert!(shown.contains("a?b"), "{shown}");
}
}