use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Deserializer, Serialize};
use std::str::FromStr;
use std::{collections::HashMap, fs::File, path::PathBuf, time::Duration};
use strum::{Display, EnumString};
#[derive(Debug, Deserialize)]
pub struct Config {
pub services: HashMap<String, ServiceDetails>,
}
impl Config {
pub fn new(config_path: PathBuf) -> Result<Self> {
let file = File::open(config_path)?;
let config: Self = serde_yaml::from_reader(file).context("Failed to parse config file")?;
for (name, service) in &config.services {
service
.validate()
.with_context(|| format!("Invalid configuration for service '{name}'"))?;
}
Ok(config)
}
#[must_use]
pub fn get_service(&self, service_name: &str) -> Option<&ServiceDetails> {
self.services.get(service_name)
}
}
#[derive(Default, Debug, Clone, Copy, EnumString, Display, Serialize, PartialEq, Eq)]
#[strum(serialize_all = "UPPERCASE")] pub enum HttpMethod {
Connect,
Delete,
#[default]
Get,
Head,
Options,
Patch,
Post,
Put,
Trace,
}
impl<'de> Deserialize<'de> for HttpMethod {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let method = String::deserialize(deserializer)?;
Self::from_str(&method.to_uppercase()).map_err(serde::de::Error::custom)
}
}
const fn default_http_method() -> HttpMethod {
HttpMethod::Get
}
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case", untagged)]
pub enum BodyType {
Json(serde_json::Value), Form(HashMap<String, String>), Text(String), }
impl<'de> Deserialize<'de> for BodyType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
if let Some(json_value) = value.get("json") {
return Ok(Self::Json(json_value.clone()));
}
if let Some(form) = value.get("form") {
let form_map = serde_json::from_value::<HashMap<String, String>>(form.clone())
.map_err(serde::de::Error::custom)?;
return Ok(Self::Form(form_map));
}
if let Some(text) = value.as_str() {
return Ok(Self::Text(text.to_string()));
}
serde_json::from_value(value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServiceDetails {
#[serde(deserialize_with = "parse_duration")]
pub every: Duration,
pub expect: Expect,
pub follow_redirects: Option<bool>,
pub headers: Option<HashMap<String, String>>,
#[serde(rename = "max_bytes", default = "default_max_bytes")]
pub max_bytes: Option<usize>,
pub test: Option<String>,
#[serde(deserialize_with = "parse_duration", default = "default_timeout")]
pub timeout: Duration,
pub url: Option<String>,
#[serde(default = "default_http_method")]
pub method: HttpMethod,
#[serde(default)]
pub body: Option<BodyType>,
}
impl ServiceDetails {
pub fn validate(&self) -> Result<()> {
match (&self.url, &self.test) {
(Some(_), Some(_)) => Err(anyhow!("Service cannot have both 'url' and 'test'.")),
(None, None) => Err(anyhow!("Service must have either 'url' or 'test'.")),
(None, Some(_)) if self.expect.status.is_none() => Err(anyhow!(
"Command checks using 'test' must configure 'expect.status'."
)),
_ => self.expect.validate(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct Expect {
pub status: Option<u16>, pub header: Option<HashMap<String, String>>,
pub body: Option<String>,
#[serde(rename = "body_not")]
pub body_not: Option<String>,
pub json: Option<serde_json::Value>,
#[serde(rename = "if_not")]
pub if_not: Option<Action>,
}
impl Expect {
#[must_use]
pub fn status_matches(&self, actual_status: u16) -> bool {
self.status.is_none_or(|status| status == actual_status)
}
#[must_use]
pub fn expected_status_i32(&self) -> Option<i32> {
self.status.map(i32::from)
}
pub fn validate(&self) -> Result<()> {
if self.body.is_some() && self.json.is_some() {
return Err(anyhow!(
"Expect cannot have both 'body' and 'json' configured."
));
}
if self.status.is_none()
&& self.body.is_none()
&& self.body_not.is_none()
&& self.json.is_none()
{
return Err(anyhow!(
"Expect must configure at least one of 'status', 'body', 'body_not', or 'json'."
));
}
Ok(())
}
}
#[derive(Default, Debug, Deserialize, Clone)]
pub struct Action {
pub cmd: Option<String>,
pub http: Option<String>,
pub stop: Option<usize>,
pub threshold: Option<usize>,
}
const fn default_timeout() -> Duration {
Duration::from_secs(5)
}
#[allow(clippy::unnecessary_wraps)]
const fn default_max_bytes() -> Option<usize> {
Some(524_288)
}
fn parse_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse_duration_str(&s).map_err(serde::de::Error::custom)
}
fn parse_duration_str(input: &str) -> Result<Duration> {
let (value, unit) = input.split_at(input.len() - 1);
let value: u64 = value
.parse()
.map_err(|_| anyhow!("Invalid number in duration: {input}"))?;
match unit {
"s" => Ok(Duration::from_secs(value)),
"m" => Ok(Duration::from_secs(value * 60)),
"h" => Ok(Duration::from_secs(value * 60 * 60)),
"d" => Ok(Duration::from_secs(value * 60 * 60 * 24)),
_ => Err(anyhow!("Invalid duration unit: {unit}")),
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
fn create_config(yaml: &str) -> tempfile::NamedTempFile {
let mut tmp_file = tempfile::NamedTempFile::new().expect("Failed to create temp file");
tmp_file
.write_all(yaml.as_bytes())
.expect("Failed to write to temp file");
tmp_file.flush().expect("Failed to flush temp file");
tmp_file
}
#[test]
fn test_parse_duration() {
assert_eq!(
parse_duration_str("5s").expect("Failed to parse duration"),
Duration::from_secs(5)
);
assert_eq!(
parse_duration_str("3m").expect("Failed to parse duration"),
Duration::from_mins(3)
);
assert_eq!(
parse_duration_str("1h").expect("Failed to parse duration"),
Duration::from_hours(1)
);
assert_eq!(
parse_duration_str("2d").expect("Failed to parse duration"),
Duration::from_hours(48)
);
}
#[test]
fn test_config() {
let yaml = r"
---
services:
test:
url: https://epazote.io
every: 30s
headers:
X-Custom-Header: TestValue
expect:
status: 200
";
let tmp_file = create_config(yaml);
let config_file = tmp_file.path().to_path_buf();
let config = Config::new(config_file).expect("Failed to load config");
assert_eq!(config.services.len(), 1);
assert_eq!(
config.services.get("test").expect("Service not found").url,
Some("https://epazote.io".to_string())
);
assert_eq!(
config
.services
.get("test")
.expect("Service not found")
.every,
Duration::from_secs(30)
);
assert_eq!(
config
.services
.get("test")
.expect("Service not found")
.headers
.as_ref()
.expect("Headers not found")
.get("X-Custom-Header")
.expect("Header not found"),
"TestValue"
);
assert_eq!(
config
.services
.get("test")
.expect("Service not found")
.expect
.status,
Some(200)
);
assert_eq!(
config
.services
.get("test")
.expect("Service not found")
.method,
HttpMethod::Get
);
assert_eq!(
config
.services
.get("test")
.expect("Service not found")
.follow_redirects,
None
);
assert_eq!(
config
.services
.get("test")
.expect("Service not found")
.max_bytes,
Some(524_288) );
}
#[test]
fn test_bad_config_url_and_test() {
let yaml = r#"
---
services:
test:
url: https://epazote.io
every: 30s
headers:
X-Custom-Header: TestValue
expect:
status: 200
test: "echo test"
"#;
let tmp_file = create_config(yaml);
let config_file = tmp_file.path().to_path_buf();
let config = Config::new(config_file);
assert!(config.is_err());
}
#[test]
fn test_bad_config_missing_url_and_test() {
let yaml = r"
---
services:
test:
every: 30s
headers:
X-Custom-Header: TestValue
expect:
status: 200
";
let tmp_file = create_config(yaml);
let config_file = tmp_file.path().to_path_buf();
let config = Config::new(config_file);
assert!(config.is_err());
}
#[test]
fn test_all_http_methods_case_insensitive() {
let methods = [
"GET", "get", "Get", "POST", "post", "Post", "PUT", "put", "Put", "DELETE", "delete",
"Delete", "PATCH", "patch", "Patch", "HEAD", "head", "Head", "OPTIONS", "options",
"Options", "CONNECT", "connect", "Connect", "TRACE", "trace", "Trace",
];
for method in methods {
let yaml = format!(
r"
---
services:
test:
url: https://epazote.io
every: 30s
method: {method}
expect:
status: 200
"
);
let tmp_file = create_config(&yaml);
let config_file = tmp_file.path().to_path_buf();
let config = Config::new(config_file).expect("Failed to load config");
assert_eq!(
config
.services
.get("test")
.expect("Service not found")
.method
.to_string(),
method.to_uppercase(),
"Failed for method: {method}"
);
}
}
#[test]
fn test_body_type_json() {
let yaml = r"
---
services:
test:
url: https://epazote.io
method: POST
body:
json:
key: value
oi: hola
every: 30s
expect:
status: 200
";
let expected_json = json!({
"key": "value",
"oi": "hola"
});
let tmp_file = create_config(yaml);
let config_file = tmp_file.path().to_path_buf();
let config = Config::new(config_file).expect("Failed to load config");
let service = config.services.get("test").expect("Service not found");
let body = service.body.as_ref().expect("Body not found");
assert_eq!(body, &BodyType::Json(expected_json));
}
#[test]
fn test_expect_json() {
let yaml = r"
---
services:
test:
url: https://epazote.io
every: 30s
expect:
status: 200
json:
status: success
data:
activeTargets:
- health: up
";
let tmp_file = create_config(yaml);
let config_file = tmp_file.path().to_path_buf();
let config = Config::new(config_file).expect("Failed to load config");
let expected_json = json!({
"status": "success",
"data": {
"activeTargets": [
{ "health": "up" }
]
}
});
let service = config.services.get("test").expect("Service not found");
let body = service
.expect
.json
.as_ref()
.expect("JSON expectation not found");
assert_eq!(body, &expected_json);
}
#[test]
fn test_expect_body_not() {
let yaml = r"
---
services:
test:
url: https://epazote.io
every: 30s
expect:
body_not: Failure
";
let tmp_file = create_config(yaml);
let config_file = tmp_file.path().to_path_buf();
let config = Config::new(config_file).expect("Failed to load config");
let service = config.services.get("test").expect("Service not found");
assert_eq!(service.expect.body_not.as_deref(), Some("Failure"));
assert_eq!(service.expect.status, None);
}
#[test]
fn test_command_expect_requires_status() {
let yaml = r"
---
services:
test:
test: pgrep -x nginx
every: 30s
expect:
body_not: Failure
";
let tmp_file = create_config(yaml);
let config_file = tmp_file.path().to_path_buf();
let config = Config::new(config_file);
assert!(config.is_err());
}
#[test]
fn test_expect_body_and_json_are_mutually_exclusive() {
let yaml = r"
---
services:
test:
url: https://epazote.io
every: 30s
expect:
status: 200
body: success
json:
status: success
";
let tmp_file = create_config(yaml);
let config_file = tmp_file.path().to_path_buf();
let config = Config::new(config_file);
assert!(config.is_err());
}
#[test]
fn test_expect_if_not_threshold_and_stop() {
let yaml = r"
---
services:
test:
url: https://epazote.io
every: 30s
expect:
status: 200
json:
status: success
if_not:
threshold: 3
stop: 2
cmd: systemctl restart test
";
let tmp_file = create_config(yaml);
let config_file = tmp_file.path().to_path_buf();
let config = Config::new(config_file).expect("Failed to load config");
let service = config.services.get("test").expect("Service not found");
let if_not = service.expect.if_not.as_ref().expect("if_not not found");
assert_eq!(if_not.threshold, Some(3));
assert_eq!(if_not.stop, Some(2));
assert_eq!(if_not.cmd.as_deref(), Some("systemctl restart test"));
}
}