use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
const DEFAULT_MANIFEST_YAML: &str = include_str!("../../assets/default-services.yaml");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServicesManifest {
pub version: u32,
pub services: BTreeMap<String, ServiceDecl>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PortDiscovery {
#[default]
Static,
File,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDecl {
pub description: String,
#[serde(default)]
pub default_port: Option<u16>,
#[serde(default)]
pub port_discovery: PortDiscovery,
#[serde(default)]
pub port_file: Option<String>,
#[serde(default)]
pub health_url: Option<String>,
#[serde(default)]
pub log_path: Option<String>,
#[serde(default)]
pub version_cmd: Option<String>,
#[serde(default)]
pub process_match: Option<String>,
#[serde(default)]
pub start_cmd: Option<String>,
#[serde(default)]
pub stop_cmd: Option<String>,
#[serde(default)]
pub restart_cmd: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestValidationError {
#[error("manifest version {0} is unsupported (max supported: 1)")]
UnsupportedVersion(u32),
#[error("service '{0}': default_port {1} is not in the valid range 1-65535")]
InvalidPort(String, u32),
#[error("service '{0}': port_discovery is 'file' but port_file is not set")]
MissingPortFile(String),
#[error("service '{0}': process_match '{1}' contains shell metacharacters")]
UnsafeProcessMatch(String, String),
#[error("service '{0}': health_url '{1}' is not a valid URL template")]
InvalidHealthUrl(String, String),
}
impl ServicesManifest {
pub fn default_manifest() -> Self {
let m: ServicesManifest = serde_yaml::from_str(DEFAULT_MANIFEST_YAML)
.expect("embedded default-services.yaml is valid YAML");
m.validate()
.expect("embedded default-services.yaml passes validation");
m
}
pub fn validate(&self) -> Result<(), ManifestValidationError> {
if self.version > 1 {
return Err(ManifestValidationError::UnsupportedVersion(self.version));
}
for (name, decl) in &self.services {
if let Some(0) = decl.default_port {
return Err(ManifestValidationError::InvalidPort(name.clone(), 0));
}
if decl.port_discovery == PortDiscovery::File && decl.port_file.is_none() {
return Err(ManifestValidationError::MissingPortFile(name.clone()));
}
if let Some(pm) = &decl.process_match {
const METACHARACTERS: &[char] = &[
'|', ';', '&', '$', '`', '(', ')', '{', '}', '<', '>', '!', '*', '?', '[', ']',
'\\', '"', '\'',
];
if pm.chars().any(|c| METACHARACTERS.contains(&c)) {
return Err(ManifestValidationError::UnsafeProcessMatch(
name.clone(),
pm.clone(),
));
}
}
if let Some(url) = &decl.health_url
&& !url.contains("{port}")
{
return Err(ManifestValidationError::InvalidHealthUrl(
name.clone(),
url.clone(),
));
}
}
Ok(())
}
pub fn expand_paths(&mut self) -> anyhow::Result<()> {
let home = dirs::home_dir().ok_or_else(|| {
anyhow::anyhow!("could not resolve home directory for tilde expansion")
})?;
for decl in self.services.values_mut() {
if let Some(p) = &decl.log_path {
decl.log_path = Some(expand_tilde(p, &home));
}
if let Some(p) = &decl.port_file {
decl.port_file = Some(expand_tilde(p, &home));
}
}
Ok(())
}
}
pub fn expand_tilde(path: &str, home: &std::path::Path) -> String {
if let Some(rest) = path.strip_prefix("~/") {
home.join(rest).to_string_lossy().into_owned()
} else if path == "~" {
home.to_string_lossy().into_owned()
} else {
path.to_string()
}
}
pub fn expand_tilde_owned(path: &str) -> PathBuf {
if let Some(home) = dirs::home_dir() {
PathBuf::from(expand_tilde(path, &home))
} else {
PathBuf::from(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_parse_happy_path() {
let m: ServicesManifest = serde_yaml::from_str(DEFAULT_MANIFEST_YAML).expect("parse");
assert_eq!(m.version, 1);
assert_eq!(m.services.len(), 6);
assert!(m.services.contains_key("trusty-search"));
assert!(m.services.contains_key("trusty-analyze"));
assert!(m.services.contains_key("trusty-mpm-daemon"));
assert!(m.services.contains_key("trusty-memory"));
assert!(m.services.contains_key("trusty-embedderd"));
assert!(m.services.contains_key("trusty-bm25-daemon"));
let ts = &m.services["trusty-search"];
assert_eq!(ts.default_port, Some(7878));
assert!(ts.health_url.as_deref().unwrap().contains("{port}"));
let mem = &m.services["trusty-memory"];
assert_eq!(mem.port_discovery, PortDiscovery::File);
assert!(mem.port_file.is_some());
let emb = &m.services["trusty-embedderd"];
assert!(emb.default_port.is_none());
assert!(emb.health_url.is_none());
}
#[test]
fn manifest_parse_minimal_service() {
let yaml = r#"
version: 1
services:
my-svc:
description: "Minimal service"
"#;
let m: ServicesManifest = serde_yaml::from_str(yaml).expect("parse");
let svc = &m.services["my-svc"];
assert_eq!(svc.description, "Minimal service");
assert!(svc.default_port.is_none());
assert!(svc.health_url.is_none());
assert!(svc.process_match.is_none());
m.validate().expect("minimal service is valid");
}
#[test]
fn manifest_rejects_future_version() {
let yaml = "version: 2\nservices: {}";
let m: ServicesManifest = serde_yaml::from_str(yaml).expect("parse");
let err = m.validate().unwrap_err();
assert!(
matches!(err, ManifestValidationError::UnsupportedVersion(2)),
"expected UnsupportedVersion(2), got {err}"
);
}
#[test]
fn manifest_rejects_invalid_port() {
let yaml = r#"
version: 1
services:
bad-svc:
description: "Bad port"
default_port: 0
"#;
let m: ServicesManifest = serde_yaml::from_str(yaml).expect("parse");
let err = m.validate().unwrap_err();
assert!(
matches!(err, ManifestValidationError::InvalidPort(ref n, 0) if n == "bad-svc"),
"expected InvalidPort, got {err}"
);
}
#[test]
fn manifest_rejects_file_discovery_without_port_file() {
let yaml = r#"
version: 1
services:
dyn-svc:
description: "Dynamic port service"
default_port: 8080
port_discovery: file
"#;
let m: ServicesManifest = serde_yaml::from_str(yaml).expect("parse");
let err = m.validate().unwrap_err();
assert!(
matches!(err, ManifestValidationError::MissingPortFile(ref n) if n == "dyn-svc"),
"expected MissingPortFile, got {err}"
);
}
#[test]
fn manifest_rejects_metacharacters_in_process_match() {
let yaml = r#"
version: 1
services:
unsafe-svc:
description: "Has metachar"
process_match: "foo|bar"
"#;
let m: ServicesManifest = serde_yaml::from_str(yaml).expect("parse");
let err = m.validate().unwrap_err();
assert!(
matches!(
err,
ManifestValidationError::UnsafeProcessMatch(ref n, ref pm)
if n == "unsafe-svc" && pm.contains('|')
),
"expected UnsafeProcessMatch, got {err}"
);
}
#[test]
fn manifest_rejects_bad_yaml() {
let yaml = "version: [not a number]";
let result: Result<ServicesManifest, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "expected parse error");
}
#[test]
fn manifest_expands_tilde() {
let home = dirs::home_dir().expect("home dir available in test");
let yaml = r#"
version: 1
services:
my-svc:
description: "Tilde test"
log_path: "~/some/log/path"
port_file: "~/port-file"
"#;
let mut m: ServicesManifest = serde_yaml::from_str(yaml).expect("parse");
m.expand_paths().expect("expand");
let svc = &m.services["my-svc"];
let expected_log = home.join("some/log/path").to_string_lossy().into_owned();
let expected_port = home.join("port-file").to_string_lossy().into_owned();
assert_eq!(svc.log_path.as_deref(), Some(expected_log.as_str()));
assert_eq!(svc.port_file.as_deref(), Some(expected_port.as_str()));
}
#[test]
fn embedded_default_manifest_is_valid() {
let m = ServicesManifest::default_manifest();
m.validate().expect("embedded default manifest is valid");
}
}