use std::borrow::Cow;
use std::fmt;
use std::num::NonZeroU16;
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::de::{self, MapAccess, SeqAccess, Visitor};
use serde::{Deserialize, Deserializer};
use super::names::{InputName, PortName};
use crate::Error;
use crate::model::CommandLine;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(from = "CommandInput")]
pub struct Command {
pub run: CommandLine,
pub cwd: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct CommandFull {
run: CommandLine,
#[serde(default)]
cwd: Option<String>,
}
#[derive(JsonSchema)]
#[serde(untagged)]
enum CommandInput {
Shell(String),
Exec(Vec<String>),
Full(CommandFull),
}
impl<'de> Deserialize<'de> for CommandInput {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<CommandInput, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = CommandInput;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a text, a list of arguments, or `{ run, cwd }`")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<CommandInput, E> {
Ok(CommandInput::Shell(v.to_owned()))
}
fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<CommandInput, A::Error> {
Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq)).map(CommandInput::Exec)
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<CommandInput, A::Error> {
CommandFull::deserialize(de::value::MapAccessDeserializer::new(map)).map(CommandInput::Full)
}
}
d.deserialize_any(V)
}
}
impl From<CommandInput> for Command {
fn from(written: CommandInput) -> Command {
match written {
CommandInput::Shell(text) => Command {
run: CommandLine::Shell(text),
cwd: None,
},
CommandInput::Exec(args) => Command {
run: CommandLine::Exec(args),
cwd: None,
},
CommandInput::Full(c) => Command { run: c.run, cwd: c.cwd },
}
}
}
impl JsonSchema for Command {
fn schema_name() -> Cow<'static, str> {
"Command".into()
}
fn json_schema(g: &mut SchemaGenerator) -> Schema {
CommandInput::json_schema(g)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum PortRef {
Number(NonZeroU16),
Name(PortName),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HealthPage {
pub port: PortRef,
#[serde(default = "root")]
pub path: String,
}
fn root() -> String {
"/".into()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HealthProbe {
Test(CommandLine),
Http(HealthPage),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(try_from = "HealthcheckInput")]
pub struct Healthcheck {
pub probe: HealthProbe,
pub interval: Option<String>,
pub timeout: Option<String>,
pub retries: Option<u32>,
pub start_period: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct HealthcheckInput {
#[serde(default)]
test: Option<CommandLine>,
#[serde(default)]
http: Option<HealthPage>,
#[serde(default)]
interval: Option<String>,
#[serde(default)]
timeout: Option<String>,
#[serde(default)]
retries: Option<u32>,
#[serde(default)]
start_period: Option<String>,
}
impl TryFrom<HealthcheckInput> for Healthcheck {
type Error = Error;
fn try_from(h: HealthcheckInput) -> Result<Healthcheck, Error> {
let probe = match (h.test, h.http) {
(Some(test), None) => HealthProbe::Test(test),
(None, Some(page)) => HealthProbe::Http(page),
(Some(_), Some(_)) => return Err(Error::TestAndHttp),
(None, None) => return Err(Error::NoHealthProbe),
};
Ok(Healthcheck {
probe,
interval: h.interval,
timeout: h.timeout,
retries: h.retries,
start_period: h.start_period,
})
}
}
impl JsonSchema for Healthcheck {
fn schema_name() -> Cow<'static, str> {
"Healthcheck".into()
}
fn json_schema(g: &mut SchemaGenerator) -> Schema {
HealthcheckInput::json_schema(g)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(try_from = "EnvInput")]
pub enum EnvValue {
Text(String),
Input(InputName),
Secret(String),
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct EnvReference {
#[serde(default)]
input: Option<InputName>,
#[serde(default)]
secret: Option<String>,
}
#[derive(JsonSchema)]
#[serde(untagged)]
enum EnvInput {
Text(String),
Number(f64),
Bool(bool),
Reference(EnvReference),
}
impl<'de> Deserialize<'de> for EnvInput {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<EnvInput, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = EnvInput;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a text, a number, a boolean, or `{ input: NAME }`")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<EnvInput, E> {
Ok(EnvInput::Text(v.to_owned()))
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<EnvInput, E> {
Ok(EnvInput::Bool(v))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<EnvInput, E> {
Ok(EnvInput::Text(v.to_string()))
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<EnvInput, E> {
Ok(EnvInput::Text(v.to_string()))
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<EnvInput, E> {
Ok(EnvInput::Number(v))
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<EnvInput, A::Error> {
EnvReference::deserialize(de::value::MapAccessDeserializer::new(map)).map(EnvInput::Reference)
}
}
d.deserialize_any(V)
}
}
impl TryFrom<EnvInput> for EnvValue {
type Error = Error;
fn try_from(written: EnvInput) -> Result<EnvValue, Error> {
match written {
EnvInput::Text(text) => Ok(EnvValue::Text(text)),
EnvInput::Number(n) => Ok(EnvValue::Text(n.to_string())),
EnvInput::Bool(b) => Ok(EnvValue::Text(b.to_string())),
EnvInput::Reference(EnvReference { input: Some(i), secret: None }) => Ok(EnvValue::Input(i)),
EnvInput::Reference(EnvReference { input: None, secret: Some(s) }) => Ok(EnvValue::Secret(s)),
EnvInput::Reference(_) => Err(Error::EnvReference),
}
}
}
impl JsonSchema for EnvValue {
fn schema_name() -> Cow<'static, str> {
"EnvValue".into()
}
fn json_schema(g: &mut SchemaGenerator) -> Schema {
EnvInput::json_schema(g)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_command_or_an_environment_value_of_the_wrong_shape_says_what_is_expected() {
let command = serde_yaml_ng::from_str::<Command>("5").unwrap_err().to_string();
assert!(command.contains("a text, a list of arguments, or `{ run, cwd }`"), "{command}");
let value = serde_yaml_ng::from_str::<EnvValue>("[1]").unwrap_err().to_string();
assert!(value.contains("a text, a number, a boolean, or `{ input: NAME }`"), "{value}");
let both = serde_yaml_ng::from_str::<EnvValue>("{ input: A, secret: b }").unwrap_err().to_string();
assert!(both.contains("write one of `{ input: NAME }`"), "{both}");
assert_eq!(serde_yaml_ng::from_str::<EnvValue>("1.5").ok(), Some(EnvValue::Text("1.5".into())));
assert_eq!(serde_yaml_ng::from_str::<EnvValue>("-3").ok(), Some(EnvValue::Text("-3".into())));
}
#[test]
fn a_command_is_a_text_a_list_or_with_its_directory() {
let text: Command = serde_yaml_ng::from_str("python3 app.py").unwrap();
assert_eq!(text.run, CommandLine::Shell("python3 app.py".into()));
let list: Command = serde_yaml_ng::from_str("[nginx, -g, daemon off;]").unwrap();
assert!(matches!(list.run, CommandLine::Exec(ref a) if a.len() == 3));
let full: Command = serde_yaml_ng::from_str("{ run: [python3, app.py], cwd: /srv }").unwrap();
assert_eq!(full.cwd.as_deref(), Some("/srv"));
}
#[test]
fn a_healthcheck_names_its_port() {
let by_name: Healthcheck = serde_yaml_ng::from_str("{ http: { port: site, path: /health } }").unwrap();
assert!(matches!(by_name.probe, HealthProbe::Http(HealthPage { port: PortRef::Name(ref n), .. }) if n.as_str() == "site"));
let by_number: Healthcheck = serde_yaml_ng::from_str("{ http: { port: 8000 } }").unwrap();
assert!(matches!(by_number.probe, HealthProbe::Http(HealthPage { port: PortRef::Number(_), .. })));
}
#[test]
fn a_healthcheck_tries_one_thing() {
let test: Healthcheck = serde_yaml_ng::from_str("{ test: [pg_isready], interval: 2s }").unwrap();
assert_eq!(
(test.probe, test.interval.as_deref()),
(HealthProbe::Test(CommandLine::Exec(vec!["pg_isready".into()])), Some("2s"))
);
let both = serde_yaml_ng::from_str::<Healthcheck>("{ test: x, http: { port: 80 } }")
.unwrap_err()
.to_string();
assert!(both.contains("`test` and `http` are both given"), "{both}");
let neither = serde_yaml_ng::from_str::<Healthcheck>("{ interval: 5s }").unwrap_err().to_string();
assert!(neither.contains("a healthcheck says what to try"), "{neither}");
}
#[test]
fn environment_values_are_scalars_or_inputs() {
let v: indexmap::IndexMap<String, EnvValue> = serde_yaml_ng::from_str("{ A: x, B: 1024, C: true, D: { input: API_URL }, E: { secret: db } }").unwrap();
let texts: Vec<_> = v.values().collect();
assert_eq!(texts[1], &EnvValue::Text("1024".into()));
assert!(matches!(texts[3], EnvValue::Input(_)) && matches!(texts[4], EnvValue::Secret(_)));
assert!(serde_yaml_ng::from_str::<EnvValue>("{ file: x }").is_err());
}
}