feat(protocol): KDL parser for ServerConfig
Adds kdl_parse module with parse_server_config() that deserialises a KDL document into ServerConfig, with full validation of name, types, durations, and restart/stop blocks. Also derives Default on RestartPolicy to satisfy clippy.
This commit is contained in:
@@ -1,20 +1,15 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum RestartPolicy {
|
||||
Always,
|
||||
#[default]
|
||||
OnFailure,
|
||||
Never,
|
||||
}
|
||||
|
||||
impl Default for RestartPolicy {
|
||||
fn default() -> Self {
|
||||
RestartPolicy::OnFailure
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RestartConfig {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -4,7 +4,11 @@ use thiserror::Error;
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("failed to read {path}: {source}")]
|
||||
Io { path: PathBuf, #[source] source: std::io::Error },
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("failed to parse KDL in {path}: {message}")]
|
||||
Parse { path: PathBuf, message: String },
|
||||
@@ -13,10 +17,18 @@ pub enum ConfigError {
|
||||
MissingField { path: PathBuf, field: &'static str },
|
||||
|
||||
#[error("invalid value for `{field}` in {path}: {message}")]
|
||||
InvalidValue { path: PathBuf, field: &'static str, message: String },
|
||||
InvalidValue {
|
||||
path: PathBuf,
|
||||
field: &'static str,
|
||||
message: String,
|
||||
},
|
||||
|
||||
#[error("duplicate port {port} declared by both `{name_a}` and `{name_b}`")]
|
||||
DuplicatePort { name_a: String, name_b: String, port: u16 },
|
||||
DuplicatePort {
|
||||
name_a: String,
|
||||
name_b: String,
|
||||
port: u16,
|
||||
},
|
||||
|
||||
#[error("server name `{name}` contains invalid characters (allowed: a-z, 0-9, '-', '_')")]
|
||||
InvalidName { name: String },
|
||||
@@ -33,5 +45,7 @@ pub enum RpcErrorCode {
|
||||
}
|
||||
|
||||
impl RpcErrorCode {
|
||||
pub fn as_i32(self) -> i32 { self as i32 }
|
||||
pub fn as_i32(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
use crate::{ConfigError, RestartConfig, RestartPolicy, ServerConfig, StopConfig};
|
||||
use kdl::{KdlDocument, KdlNode};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn parse_server_config(
|
||||
name: &str,
|
||||
text: &str,
|
||||
source_path: &Path,
|
||||
) -> Result<ServerConfig, ConfigError> {
|
||||
validate_name(name).map_err(|_| ConfigError::InvalidName {
|
||||
name: name.to_string(),
|
||||
})?;
|
||||
|
||||
let doc: KdlDocument = text
|
||||
.parse()
|
||||
.map_err(|err: kdl::KdlError| ConfigError::Parse {
|
||||
path: source_path.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
|
||||
let command = require_string_arg(&doc, "command", source_path)?;
|
||||
let args = optional_string_args(&doc, "args");
|
||||
let port = require_u16_arg(&doc, "port", source_path)?;
|
||||
let env = optional_string_map(&doc, "env");
|
||||
let working_dir = optional_string_arg(&doc, "working-dir").map(PathBuf::from);
|
||||
let restart = parse_restart(&doc, source_path)?;
|
||||
let stop = parse_stop(&doc, source_path)?;
|
||||
|
||||
Ok(ServerConfig {
|
||||
name: name.to_string(),
|
||||
command: PathBuf::from(command),
|
||||
args,
|
||||
port,
|
||||
env,
|
||||
working_dir,
|
||||
restart,
|
||||
stop,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_name(name: &str) -> Result<(), ()> {
|
||||
if name.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
if name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
fn require_string_arg(
|
||||
doc: &KdlDocument,
|
||||
name: &'static str,
|
||||
path: &Path,
|
||||
) -> Result<String, ConfigError> {
|
||||
let node = find_node(doc, name).ok_or(ConfigError::MissingField {
|
||||
path: path.to_path_buf(),
|
||||
field: name,
|
||||
})?;
|
||||
|
||||
node.entries()
|
||||
.first()
|
||||
.and_then(|e| e.value().as_string().map(str::to_string))
|
||||
.ok_or(ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field: name,
|
||||
message: "expected string argument".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn require_u16_arg(doc: &KdlDocument, name: &'static str, path: &Path) -> Result<u16, ConfigError> {
|
||||
let node = find_node(doc, name).ok_or(ConfigError::MissingField {
|
||||
path: path.to_path_buf(),
|
||||
field: name,
|
||||
})?;
|
||||
|
||||
let v = node
|
||||
.entries()
|
||||
.first()
|
||||
.and_then(|e| e.value().as_integer())
|
||||
.ok_or(ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field: name,
|
||||
message: "expected integer".into(),
|
||||
})?;
|
||||
|
||||
u16::try_from(v).map_err(|_| ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field: name,
|
||||
message: format!("port {v} out of u16 range"),
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_string_arg(doc: &KdlDocument, name: &str) -> Option<String> {
|
||||
find_node(doc, name)
|
||||
.and_then(|n| n.entries().first())
|
||||
.and_then(|e| e.value().as_string().map(str::to_string))
|
||||
}
|
||||
|
||||
fn optional_string_args(doc: &KdlDocument, name: &str) -> Vec<String> {
|
||||
find_node(doc, name)
|
||||
.map(|n| {
|
||||
n.entries()
|
||||
.iter()
|
||||
.filter_map(|e| e.value().as_string().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn optional_string_map(doc: &KdlDocument, name: &str) -> BTreeMap<String, String> {
|
||||
let Some(node) = find_node(doc, name) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
|
||||
let mut out = BTreeMap::new();
|
||||
|
||||
if let Some(children) = node.children() {
|
||||
for child in children.nodes() {
|
||||
let key = child.name().value().to_string();
|
||||
|
||||
if let Some(val) = child.entries().first().and_then(|e| e.value().as_string()) {
|
||||
out.insert(key, val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_restart(doc: &KdlDocument, path: &Path) -> Result<RestartConfig, ConfigError> {
|
||||
let Some(node) = find_node(doc, "restart") else {
|
||||
return Ok(RestartConfig::default());
|
||||
};
|
||||
|
||||
let Some(children) = node.children() else {
|
||||
return Ok(RestartConfig::default());
|
||||
};
|
||||
|
||||
let mut out = RestartConfig::default();
|
||||
|
||||
for child in children.nodes() {
|
||||
match child.name().value() {
|
||||
"policy" => {
|
||||
let s = string_arg(child, "policy", path)?;
|
||||
|
||||
out.policy = match s.as_str() {
|
||||
"always" => RestartPolicy::Always,
|
||||
"on-failure" => RestartPolicy::OnFailure,
|
||||
"never" => RestartPolicy::Never,
|
||||
other => {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field: "restart.policy",
|
||||
message: format!("unknown policy `{other}`"),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
"backoff-initial" => {
|
||||
out.backoff_initial = parse_duration_arg(child, "restart.backoff-initial", path)?;
|
||||
}
|
||||
"backoff-max" => {
|
||||
out.backoff_max = parse_duration_arg(child, "restart.backoff-max", path)?;
|
||||
}
|
||||
"max-retries-per-minute" => {
|
||||
let v = child
|
||||
.entries()
|
||||
.first()
|
||||
.and_then(|e| e.value().as_integer())
|
||||
.ok_or(ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field: "restart.max-retries-per-minute",
|
||||
message: "expected integer".into(),
|
||||
})?;
|
||||
|
||||
out.max_retries_per_minute =
|
||||
u32::try_from(v).map_err(|_| ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field: "restart.max-retries-per-minute",
|
||||
message: format!("out of u32 range: {v}"),
|
||||
})?;
|
||||
}
|
||||
other => {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field: "restart",
|
||||
message: format!("unknown key `{other}`"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError> {
|
||||
let Some(node) = find_node(doc, "stop") else {
|
||||
return Ok(StopConfig::default());
|
||||
};
|
||||
|
||||
let Some(children) = node.children() else {
|
||||
return Ok(StopConfig::default());
|
||||
};
|
||||
|
||||
let mut out = StopConfig::default();
|
||||
|
||||
for child in children.nodes() {
|
||||
match child.name().value() {
|
||||
"grace" => {
|
||||
out.grace = parse_duration_arg(child, "stop.grace", path)?;
|
||||
}
|
||||
other => {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field: "stop",
|
||||
message: format!("unknown key `{other}`"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn string_arg(node: &KdlNode, field: &'static str, path: &Path) -> Result<String, ConfigError> {
|
||||
node.entries()
|
||||
.first()
|
||||
.and_then(|e| e.value().as_string().map(str::to_string))
|
||||
.ok_or(ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field,
|
||||
message: "expected string".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_duration_arg(
|
||||
node: &KdlNode,
|
||||
field: &'static str,
|
||||
path: &Path,
|
||||
) -> Result<Duration, ConfigError> {
|
||||
let s = string_arg(node, field, path)?;
|
||||
|
||||
humantime::parse_duration(&s).map_err(|err| ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field,
|
||||
message: format!("invalid duration `{s}`: {err}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn find_node<'a>(doc: &'a KdlDocument, name: &str) -> Option<&'a KdlNode> {
|
||||
doc.nodes().iter().find(|n| n.name().value() == name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
fn p() -> &'static Path {
|
||||
Path::new("/tmp/test.kdl")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_minimal_config() {
|
||||
let text = "command \"/usr/local/bin/foo\"\nport 8421\n";
|
||||
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||
|
||||
assert_eq!(cfg.name, "foo");
|
||||
assert_eq!(cfg.command, PathBuf::from("/usr/local/bin/foo"));
|
||||
assert_eq!(cfg.port, 8421);
|
||||
assert!(cfg.args.is_empty());
|
||||
assert!(cfg.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_full_config() {
|
||||
let text = r#"
|
||||
command "/usr/local/bin/foo"
|
||||
args "--http" "--port" "8421"
|
||||
port 8421
|
||||
env {
|
||||
RUST_LOG "info"
|
||||
FOO_BAR "baz"
|
||||
}
|
||||
working-dir "/tmp/work"
|
||||
restart {
|
||||
policy "always"
|
||||
backoff-initial "2s"
|
||||
backoff-max "1m"
|
||||
max-retries-per-minute 10
|
||||
}
|
||||
stop {
|
||||
grace "30s"
|
||||
}
|
||||
"#;
|
||||
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||
|
||||
assert_eq!(cfg.args, vec!["--http", "--port", "8421"]);
|
||||
assert_eq!(cfg.env.get("RUST_LOG").map(String::as_str), Some("info"));
|
||||
assert_eq!(cfg.working_dir, Some(PathBuf::from("/tmp/work")));
|
||||
assert_eq!(cfg.restart.policy, RestartPolicy::Always);
|
||||
assert_eq!(cfg.restart.backoff_initial, Duration::from_secs(2));
|
||||
assert_eq!(cfg.restart.backoff_max, Duration::from_secs(60));
|
||||
assert_eq!(cfg.restart.max_retries_per_minute, 10);
|
||||
assert_eq!(cfg.stop.grace, Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_command_fails() {
|
||||
let err = parse_server_config("foo", "port 8421", p()).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ConfigError::MissingField {
|
||||
field: "command",
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_port_fails() {
|
||||
let err = parse_server_config("foo", "command \"/bin/x\"", p()).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ConfigError::MissingField { field: "port", .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_restart_policy_fails() {
|
||||
let text = "command \"/bin/x\"\nport 1\nrestart { policy \"maybe\" }";
|
||||
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ConfigError::InvalidValue {
|
||||
field: "restart.policy",
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_name_rejected() {
|
||||
let text = "command \"/bin/x\"\nport 1";
|
||||
let err = parse_server_config("Foo Bar", text, p()).unwrap_err();
|
||||
|
||||
assert!(matches!(err, ConfigError::InvalidName { .. }));
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod kdl_parse;
|
||||
pub mod state;
|
||||
|
||||
pub use config::{RestartConfig, RestartPolicy, ServerConfig, StopConfig};
|
||||
pub use error::{ConfigError, RpcErrorCode};
|
||||
pub use kdl_parse::parse_server_config;
|
||||
pub use state::ServerState;
|
||||
|
||||
Reference in New Issue
Block a user