use std::time::Duration;
use serde::Deserialize;
use shep_client::shep_core::values::UpDuration;
use crate::daemon::{Daemon, adopted_name};
use crate::error::Error;
const DEFAULT_INTERVAL: UpDuration = UpDuration::from_millis(30_000);
const DEFAULT_RETENTION: usize = 5;
const DEFAULT_GIT_TIMEOUT: UpDuration = UpDuration::from_millis(300_000);
const DEFAULT_BUILD_TIMEOUT: UpDuration = UpDuration::from_millis(3_600_000);
const MINIMUM_RETENTION: usize = 2;
const MINIMUM_DURATION: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DogConfig {
pub interval: Duration,
pub retention: usize,
pub git_timeout: Duration,
pub build_timeout: Duration,
pub passthrough: Vec<String>,
}
#[shep_client::dogs::dog_config]
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
#[schemars(rename = "deploy")]
#[schemars(
description = "How the deploy dog polls, how long it lets git and a build run, and how many \
releases it keeps. Every target this dog manages shares these; what to deploy \
and where lives in each target's own deploy.toml."
)]
pub struct Section {
interval: UpDuration,
#[schemars(range(min = 2))]
retention: usize,
git_timeout: UpDuration,
build_timeout: UpDuration,
passthrough: Vec<String>,
}
impl Default for Section {
fn default() -> Self {
Self {
interval: DEFAULT_INTERVAL,
retention: DEFAULT_RETENTION,
git_timeout: DEFAULT_GIT_TIMEOUT,
build_timeout: DEFAULT_BUILD_TIMEOUT,
passthrough: Vec::new(),
}
}
}
impl DogConfig {
pub fn parse(toml: &str) -> Result<Self, Error> {
let raw: Section = toml::from_str(toml)
.map_err(|source| Error::Config(format!("[dog.<name>]: {source}")))?;
if raw.retention < MINIMUM_RETENTION {
return Err(Error::Config(format!(
"retention = {} keeps too few releases to roll back: the release a failed \
deploy returns to is the second newest, so anything below {MINIMUM_RETENTION} \
prunes the only thing there is to roll back to",
raw.retention
)));
}
let interval = at_least_a_second(
"interval",
raw.interval,
"would fetch continuously rather than on a schedule, which reads as a hung dog and \
hammers the remote it is watching",
)?;
let git_timeout = at_least_a_second(
"git_timeout",
raw.git_timeout,
"would abandon every fetch the moment it started; the point of the bound is to \
turn a hung remote into an ordinary per-target failure, not to disable git",
)?;
let build_timeout = at_least_a_second(
"build_timeout",
raw.build_timeout,
"would abandon every build the moment it started; the point of the bound is to \
turn a build that never finishes into an ordinary per-target failure, not to \
disable building",
)?;
let mut seen = std::collections::BTreeSet::new();
for name in &raw.passthrough {
let why = if name.is_empty() {
Some("is empty")
} else if name.contains(['=', '\0']) {
Some("is not an environment variable name: one cannot contain `=` or NUL")
} else if !seen.insert(name.as_str()) {
Some("is listed twice")
} else {
None
};
if let Some(why) = why {
return Err(Error::Config(format!(
"passthrough entry {name:?} {why}; each entry names one variable to copy \
from the dog's environment into a build"
)));
}
}
Ok(Self {
interval,
retention: raw.retention,
git_timeout,
build_timeout,
passthrough: raw.passthrough,
})
}
}
fn at_least_a_second(key: &str, value: UpDuration, harm: &str) -> Result<Duration, Error> {
let duration = value.as_duration();
if duration < MINIMUM_DURATION {
return Err(Error::Config(format!(
"{key} = {}ms is under a second, which {harm}. A bare number is read as \
milliseconds: write `{key} = \"30s\"` for thirty seconds",
duration.as_millis()
)));
}
Ok(duration)
}
pub async fn read<D: Daemon>(daemon: &D) -> Result<DogConfig, Error> {
match adopted_name(daemon).await {
Some(name) => section_of(daemon, &name).await,
None => DogConfig::parse(""),
}
}
async fn section_of<D: Daemon>(daemon: &D, name: &str) -> Result<DogConfig, Error> {
let section = daemon.dog_config(name).await?;
DogConfig::parse(§ion)
}
#[derive(Debug)]
pub struct Reader {
name: Option<String>,
current: DogConfig,
}
impl Reader {
pub async fn open<D: Daemon>(daemon: &D) -> Result<Self, Error> {
let name = adopted_name(daemon).await;
let current = match &name {
Some(name) => section_of(daemon, name).await?,
None => DogConfig::parse("")?,
};
Ok(Self { name, current })
}
pub const fn current(&self) -> &DogConfig {
&self.current
}
pub async fn refresh<D: Daemon>(&mut self, daemon: &D) -> Option<Error> {
let name = self.name.clone()?;
match section_of(daemon, &name).await {
Ok(fresh) => {
self.current = fresh;
None
}
Err(err) => Some(err),
}
}
#[cfg(test)]
pub const fn primed(name: Option<String>, current: DogConfig) -> Self {
Self { name, current }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_empty_section_is_the_documented_defaults() {
let config = DogConfig::parse("").expect("an empty section parses");
assert_eq!(config.interval, Duration::from_secs(30));
assert_eq!(config.retention, 5);
}
#[test]
fn both_keys_are_read() {
let config = DogConfig::parse("interval = \"5m\"\nretention = 12").expect("parses");
assert_eq!(config.interval, Duration::from_secs(300));
assert_eq!(config.retention, 12);
}
#[test]
fn a_retention_below_two_is_refused_by_name() {
for count in ["0", "1"] {
let err = DogConfig::parse(&format!("retention = {count}")).expect_err("refuses");
let shown = err.to_string();
assert!(shown.contains("retention"), "{shown}");
assert!(shown.contains("roll back"), "{shown}");
}
}
#[test]
fn a_zero_interval_is_refused() {
let err = DogConfig::parse("interval = \"0s\"").expect_err("refuses");
assert!(err.to_string().contains("interval"), "{err}");
}
#[test]
fn a_duration_under_a_second_is_refused_and_the_unit_is_named() {
for key in ["interval", "git_timeout", "build_timeout"] {
let err = DogConfig::parse(&format!("{key} = \"30\"")).expect_err(key);
let shown = err.to_string();
assert!(shown.contains(key), "{shown}");
assert!(
shown.contains("30ms"),
"must say what it was read as: {shown}"
);
assert!(
shown.contains("\"30s\""),
"must show the spelling meant: {shown}"
);
}
DogConfig::parse("interval = \"1s\"\ngit_timeout = \"1s\"\nbuild_timeout = \"1s\"")
.expect("one second is allowed");
}
#[test]
fn the_timeouts_and_passthrough_are_read() {
let config = DogConfig::parse(
"git_timeout = \"2m\"\nbuild_timeout = \"3h\"\npassthrough = [\"CARGO_HOME\", \"NPM_TOKEN\"]",
)
.expect("parses");
assert_eq!(config.git_timeout, Duration::from_secs(120));
assert_eq!(config.build_timeout, Duration::from_secs(3 * 3600));
assert_eq!(config.passthrough, vec!["CARGO_HOME", "NPM_TOKEN"]);
}
#[test]
fn a_passthrough_entry_that_is_not_a_variable_name_is_refused() {
for entry in ["\"\"", "\"A=B\"", "\"X\", \"X\""] {
let err = DogConfig::parse(&format!("passthrough = [{entry}]")).expect_err(entry);
let shown = err.to_string();
assert!(shown.contains("passthrough"), "{shown}");
}
}
#[test]
fn an_unknown_key_is_refused_and_named() {
let err = DogConfig::parse("retenton = 2").expect_err("refuses");
assert!(err.to_string().contains("retenton"), "{err}");
}
#[test]
fn a_value_of_the_wrong_type_names_the_key() {
let err = DogConfig::parse("retention = \"five\"").expect_err("refuses");
assert!(err.to_string().contains("retention"), "{err}");
}
#[test]
fn every_property_the_schema_publishes_is_a_key_the_parser_takes() {
let schema = shep_client::dogs::config_schema::<Section>();
let schema = schema.as_value();
let properties = schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("a derived struct schema has properties");
assert_eq!(
properties.len(),
5,
"a property was added or removed without this test being read"
);
for name in properties.keys() {
if let Err(err) = DogConfig::parse(&format!("{name} = []")) {
assert!(
!err.to_string().contains("unknown field"),
"the schema publishes `{name}`, which the parser does not know: {err}"
);
}
}
}
#[test]
fn the_schemas_retention_floor_is_the_one_the_parser_enforces() {
let schema = shep_client::dogs::config_schema::<Section>();
let minimum = schema
.as_value()
.pointer("/properties/retention/minimum")
.and_then(serde_json::Value::as_u64)
.expect("retention states a minimum");
assert_eq!(minimum, MINIMUM_RETENTION as u64);
DogConfig::parse(&format!("retention = {minimum}")).expect("the floor itself is accepted");
DogConfig::parse(&format!("retention = {}", minimum - 1)).expect_err("below it is not");
}
const ADOPTED: &str = "deploy";
fn reading() -> Reader {
Reader::primed(
Some(ADOPTED.to_owned()),
DogConfig::parse("").expect("the defaults"),
)
}
#[tokio::test]
async fn a_refresh_that_parses_replaces_the_whole_section() {
let daemon = crate::fixtures::Sections::of("interval = \"5m\"\nretention = 9", &[]);
let mut reader = reading();
assert!(reader.refresh(&daemon).await.is_none(), "it parsed");
assert_eq!(reader.current().interval, Duration::from_secs(300));
assert_eq!(reader.current().retention, 9);
}
#[tokio::test]
async fn a_refresh_that_fails_keeps_the_last_section_that_parsed() {
let daemon = crate::fixtures::Sections::of("retention = 9", &["retention = 1"]);
let mut reader = reading();
assert!(reader.refresh(&daemon).await.is_none(), "the first parsed");
assert_eq!(reader.current().retention, 9);
let complaint = reader
.refresh(&daemon)
.await
.expect("the second is refused");
assert!(
complaint.to_string().contains("keeps too few releases"),
"{complaint}"
);
assert_eq!(reader.current().retention, 9, "still the one that parsed");
}
#[test]
fn debug_shows_the_name_and_the_parsed_values_only() {
let reader = Reader::primed(
Some(ADOPTED.to_owned()),
DogConfig::parse("retention = 9").expect("a section"),
);
let shown = format!("{reader:?}");
assert!(shown.contains("name: Some(\"deploy\")"), "{shown}");
assert!(shown.contains("retention: 9"), "{shown}");
assert!(!shown.contains("Section"), "no raw section text: {shown}");
}
#[tokio::test]
async fn an_unnamed_dog_asks_for_no_section_at_all() {
let daemon = crate::fixtures::Sections::of("retention = 9", &[]);
let mut reader = Reader::primed(None, crate::fixtures::dog_config());
assert!(reader.refresh(&daemon).await.is_none());
assert_eq!(daemon.asked(), 0, "nothing was asked for");
assert_eq!(reader.current(), &crate::fixtures::dog_config());
}
}