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>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema, shep_client::dogs::DogConfig)]
#[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> {
let Some(name) = adopted_name(daemon).await else {
return DogConfig::parse("");
};
let section = daemon.dog_config(&name).await?;
DogConfig::parse(§ion)
}
#[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 the_section_renders_a_schema_with_every_mark_landing() {
shep_client::dogs::config_schema::<Section>()
.expect("every `#[shep(secret)]` field names a property of this type");
}
#[test]
fn every_property_the_schema_publishes_is_a_key_the_parser_takes() {
let schema = shep_client::dogs::config_schema::<Section>().expect("renders");
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>().expect("renders");
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");
}
}