use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::capability::Capability;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDef {
pub service: ServiceMeta,
#[serde(default)]
pub requirements: Option<Requirements>,
#[serde(default)]
pub ports: Vec<PortDef>,
#[serde(default)]
pub env: Vec<EnvVar>,
#[serde(default, rename = "env_group")]
pub env_groups: Vec<EnvGroup>,
#[serde(default)]
pub requires: Vec<ServiceRequirement>,
#[serde(default)]
pub mappings: Mappings,
#[serde(default)]
pub integrations: IntegrationFlags,
#[serde(default)]
pub capabilities: Capabilities,
#[serde(default)]
pub backup: Option<BackupConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Capabilities {
#[serde(default)]
pub provides: Vec<Capability>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Requirements {
pub ram: RamRequirement,
#[serde(default)]
pub disk: Option<DiskRequirement>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RamRequirement {
pub min: u64,
#[serde(default)]
pub recommended: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiskRequirement {
pub min: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceMeta {
pub name: String,
pub description: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub kind: ServiceKind,
#[serde(default)]
pub architecture: Vec<Arch>,
#[serde(default)]
pub https: HttpsRequirement,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ServiceKind {
#[default]
Application,
Infrastructure,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Arch {
Amd64,
Arm64,
}
impl std::fmt::Display for Arch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Arch::Amd64 => write!(f, "amd64"),
Arch::Arm64 => write!(f, "arm64"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum HttpsRequirement {
#[default]
Never,
Auth,
Always,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PortProtocol {
#[default]
Tcp,
Udp,
}
impl std::fmt::Display for PortProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PortProtocol::Tcp => write!(f, "tcp"),
PortProtocol::Udp => write!(f, "udp"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortDef {
pub name: String,
pub container_port: u16,
#[serde(default)]
pub host_port: Option<u16>,
#[serde(default)]
pub protocol: PortProtocol,
#[serde(default)]
pub tailscale_https: Option<u16>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum EnvKind {
#[default]
Default,
Prompted,
Required,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EnvFormat {
#[default]
String,
Hex,
Base64,
Base64Url,
Uuid,
JwtHs256,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvVar {
pub name: String,
pub value: String,
#[serde(default)]
pub kind: EnvKind,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub format: EnvFormat,
#[serde(default)]
pub length: Option<u32>,
#[serde(default)]
pub jwt_claims: Option<std::collections::BTreeMap<std::string::String, serde_json::Value>>,
#[serde(default)]
pub jwt_signing_key: Option<std::string::String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvGroup {
pub name: String,
pub prompt: String,
#[serde(default)]
pub env: Vec<EnvVar>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceRequirement {
pub service: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Mappings {
#[serde(default)]
pub smtp: BTreeMap<String, String>,
#[serde(default)]
pub auth: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuthKind {
Oidc,
}
impl std::fmt::Display for AuthKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthKind::Oidc => write!(f, "oidc"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TokenAuthMethod {
#[default]
ClientSecretPost,
ClientSecretBasic,
None,
}
impl TokenAuthMethod {
pub fn as_str(&self) -> &'static str {
match self {
TokenAuthMethod::ClientSecretPost => "client_secret_post",
TokenAuthMethod::ClientSecretBasic => "client_secret_basic",
TokenAuthMethod::None => "none",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrationFlags {
#[serde(default)]
pub auth: Vec<AuthKind>,
#[serde(default)]
pub token_auth_method: TokenAuthMethod,
#[serde(default)]
pub oidc_callbacks: Vec<String>,
#[serde(default = "default_true")]
pub smtp: bool,
#[serde(default)]
pub backup: bool,
}
impl Default for IntegrationFlags {
fn default() -> Self {
Self {
auth: vec![],
token_auth_method: TokenAuthMethod::default(),
oidc_callbacks: vec![],
smtp: true,
backup: false,
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackupConfig {
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub pre_backup: Option<String>,
#[serde(default)]
pub post_backup: Option<String>,
#[serde(default)]
pub pre_restore: Option<String>,
#[serde(default)]
pub post_restore: Option<String>,
}
impl ServiceDef {
pub fn check_architecture(&self) -> Option<String> {
if self.service.architecture.is_empty() {
return None;
}
let current = current_architecture();
if self.service.architecture.contains(¤t) {
None
} else {
let supported: Vec<_> = self
.service
.architecture
.iter()
.map(|a| a.to_string())
.collect();
Some(format!(
"{} only supports {} — this system is {current}",
self.service.name,
supported.join(", "),
))
}
}
pub fn required_env_vars(&self) -> Vec<&str> {
self.env
.iter()
.filter(|e| e.kind == EnvKind::Required)
.map(|e| e.name.as_str())
.collect()
}
pub fn validate(&self) -> Result<(), String> {
let name = &self.service.name;
let mut errors: Vec<String> = Vec::new();
let mut seen_ports = std::collections::HashSet::new();
let mut seen_ts_https = std::collections::HashSet::new();
for p in &self.ports {
if !seen_ports.insert(&p.name) {
errors.push(format!("duplicate port name '{}'", p.name));
}
if let Some(https) = p.tailscale_https
&& !seen_ts_https.insert(https)
{
errors.push(format!(
"two ports map to the same tailscale_https port {https}"
));
}
}
let ts_ports: Vec<&PortDef> = self
.ports
.iter()
.filter(|p| p.tailscale_https.is_some())
.collect();
if !ts_ports.is_empty()
&& ts_ports
.iter()
.filter(|p| p.tailscale_https == Some(443))
.count()
!= 1
{
errors.push(
"services exposing ports over Tailscale must mark exactly one port \
tailscale_https = 443 (the web root)"
.to_string(),
);
}
let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
for e in &self.env {
if !seen_envs.insert(&e.name) {
errors.push(format!("duplicate env var name '{}'", e.name));
}
}
for g in &self.env_groups {
for e in &g.env {
if !seen_envs.insert(&e.name) {
errors.push(format!(
"env var '{}' in group '{}' collides with another env var",
e.name, g.name
));
}
}
}
for e in &self.env {
check_env_var(e, None, &mut errors);
}
let mut seen_groups = std::collections::HashSet::new();
for g in &self.env_groups {
if !seen_groups.insert(&g.name) {
errors.push(format!("duplicate env_group name '{}'", g.name));
}
if g.name.is_empty() {
errors.push("env_group has empty name".to_string());
} else if !g
.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
errors.push(format!(
"env_group '{}' must be lowercase snake_case ([a-z0-9_])",
g.name
));
}
if g.prompt.is_empty() {
errors.push(format!("env_group '{}' has empty prompt", g.name));
}
if g.env.is_empty() {
errors.push(format!("env_group '{}' has no env vars", g.name));
}
for e in &g.env {
check_env_var(e, Some(&g.name), &mut errors);
}
}
if let Some(ref req) = self.requirements
&& let Some(rec) = req.ram.recommended
&& rec < req.ram.min
{
errors.push(format!(
"recommended RAM ({rec}MB) is less than minimum ({}MB)",
req.ram.min
));
}
if let Some(ref backup) = self.backup
&& !self.integrations.backup
{
errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
let _ = backup;
}
if let Some(ref backup) = self.backup {
for (label, hook) in [
("pre_backup", &backup.pre_backup),
("post_backup", &backup.post_backup),
("pre_restore", &backup.pre_restore),
("post_restore", &backup.post_restore),
] {
if let Some(script) = hook
&& (script.is_empty() || script.contains('/') || script.contains(".."))
{
errors.push(format!(
"backup hook '{label}' must be a bare filename under configs/scripts/ \
(got {script:?})"
));
}
}
for p in &backup.paths {
if p.is_empty() || p.starts_with('/') || p.contains("..") {
errors.push(format!(
"backup path {p:?} must be a relative path within the service home"
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(format!("{name}: {}", errors.join("; ")))
}
}
}
fn check_env_var(e: &EnvVar, group: Option<&str>, errors: &mut Vec<String>) {
let where_ = match group {
Some(g) => format!(" in group '{g}'"),
None => String::new(),
};
if e.name.is_empty() {
errors.push(format!("env var has empty name{where_}"));
} else if !e
.name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
{
errors.push(format!(
"env var '{}'{where_} must start with a letter or _",
e.name
));
} else if !e
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
errors.push(format!(
"env var '{}'{where_} contains invalid characters — must match [A-Za-z0-9_]",
e.name
));
}
if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
errors.push(format!(
"env var '{}'{where_} is kind=required but has a secret template default — use kind=prompted or kind=default",
e.name
));
}
}
pub fn current_architecture() -> Arch {
match std::env::consts::ARCH {
"x86_64" => Arch::Amd64,
"aarch64" => Arch::Arm64,
_ => Arch::Amd64,
}
}
#[cfg(test)]
mod backup_tests {
use super::*;
fn parse(toml_src: &str) -> ServiceDef {
toml::from_str(toml_src).expect("parse")
}
#[test]
fn tailscale_https_requires_exactly_one_root() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
[[ports]]
name = "http"
container_port = 8080
tailscale_https = 8080
[[ports]]
name = "photos"
container_port = 3000
tailscale_https = 3000
"#,
);
let err = svc.validate().expect_err("must reject");
assert!(err.contains("tailscale_https = 443"), "got: {err}");
}
#[test]
fn tailscale_https_duplicate_port_rejected() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
[[ports]]
name = "a"
container_port = 1
tailscale_https = 443
[[ports]]
name = "b"
container_port = 2
tailscale_https = 443
"#,
);
let err = svc.validate().expect_err("must reject");
assert!(err.contains("same tailscale_https"), "got: {err}");
}
#[test]
fn tailscale_https_one_root_plus_api_validates() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
[[ports]]
name = "http"
container_port = 8080
tailscale_https = 8080
[[ports]]
name = "photos"
container_port = 3000
tailscale_https = 443
"#,
);
svc.validate()
.expect("one 443 root + one api port is valid");
}
#[test]
fn backup_defaults_to_false_when_omitted() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
"#,
);
assert!(!svc.integrations.backup);
assert!(svc.backup.is_none());
svc.validate().expect("default is valid");
}
#[test]
fn backup_section_alone_is_rejected_without_integration_flag() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
[backup]
"#,
);
let err = svc.validate().expect_err("must reject");
assert!(
err.contains("backup = true"),
"error mentions the required flag: {err}"
);
}
#[test]
fn backup_supported_without_hooks_validates() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
[integrations]
backup = true
"#,
);
assert!(svc.integrations.backup);
assert!(svc.backup.is_none());
svc.validate().expect("ok without [backup] table");
}
#[test]
fn backup_with_full_hooks_validates() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
[integrations]
backup = true
[backup]
paths = [".backup/db.sql.gz", "data"]
exclude = ["data/cache"]
pre_backup = "backup-pre.sh"
post_backup = "backup-post.sh"
pre_restore = "restore-pre.sh"
post_restore = "restore-post.sh"
"#,
);
svc.validate().expect("ok");
let backup = svc.backup.as_ref().expect("section present");
assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
}
#[test]
fn backup_hook_with_slash_is_rejected() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
[integrations]
backup = true
[backup]
pre_backup = "subdir/script.sh"
"#,
);
let err = svc.validate().expect_err("must reject");
assert!(err.contains("pre_backup"), "{err}");
}
#[test]
fn backup_hook_with_dotdot_is_rejected() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
[integrations]
backup = true
[backup]
post_backup = "../escape.sh"
"#,
);
let err = svc.validate().expect_err("must reject");
assert!(err.contains("post_backup"), "{err}");
}
#[test]
fn backup_absolute_path_is_rejected() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
[integrations]
backup = true
[backup]
paths = ["/etc/passwd"]
"#,
);
let err = svc.validate().expect_err("must reject");
assert!(err.contains("/etc/passwd"), "{err}");
}
#[test]
fn backup_path_with_dotdot_is_rejected() {
let svc = parse(
r#"
[service]
name = "x"
description = "x"
[integrations]
backup = true
[backup]
paths = ["../../somewhere"]
"#,
);
let err = svc.validate().expect_err("must reject");
assert!(err.contains("somewhere"), "{err}");
}
}