1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::capability::Capability;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ServiceDef {
10 pub service: ServiceMeta,
11 #[serde(default)]
12 pub requirements: Option<Requirements>,
13 #[serde(default)]
14 pub ports: Vec<PortDef>,
15 #[serde(default)]
16 pub env: Vec<EnvVar>,
17 #[serde(default, rename = "env_group")]
21 pub env_groups: Vec<EnvGroup>,
22 #[serde(default)]
23 pub requires: Vec<ServiceRequirement>,
24 #[serde(default)]
25 pub mappings: Mappings,
26 #[serde(default)]
27 pub integrations: IntegrationFlags,
28 #[serde(default)]
32 pub capabilities: Capabilities,
33 #[serde(default)]
38 pub backup: Option<BackupConfig>,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct Capabilities {
44 #[serde(default)]
46 pub provides: Vec<Capability>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Requirements {
52 pub ram: RamRequirement,
54 #[serde(default)]
56 pub disk: Option<DiskRequirement>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct RamRequirement {
62 pub min: u64,
64 #[serde(default)]
66 pub recommended: Option<u64>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct DiskRequirement {
72 pub min: u32,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ServiceMeta {
78 pub name: String,
79 pub description: String,
80 #[serde(default)]
82 pub url: Option<String>,
83 #[serde(default)]
84 pub kind: ServiceKind,
85 #[serde(default)]
88 pub architecture: Vec<Arch>,
89 #[serde(default)]
91 pub https: HttpsRequirement,
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "lowercase")]
97pub enum ServiceKind {
98 #[default]
99 Application,
100 Infrastructure,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "lowercase")]
106pub enum Arch {
107 Amd64,
108 Arm64,
109}
110
111impl std::fmt::Display for Arch {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 Arch::Amd64 => write!(f, "amd64"),
115 Arch::Arm64 => write!(f, "arm64"),
116 }
117 }
118}
119
120#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
134#[serde(rename_all = "lowercase")]
135pub enum HttpsRequirement {
136 #[default]
137 Never,
138 Auth,
139 Always,
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "lowercase")]
145pub enum PortProtocol {
146 #[default]
147 Tcp,
148 Udp,
149}
150
151impl std::fmt::Display for PortProtocol {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 match self {
154 PortProtocol::Tcp => write!(f, "tcp"),
155 PortProtocol::Udp => write!(f, "udp"),
156 }
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct PortDef {
162 pub name: String,
163 pub container_port: u16,
164 #[serde(default)]
167 pub host_port: Option<u16>,
168 #[serde(default)]
169 pub protocol: PortProtocol,
170}
171
172#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
181#[serde(rename_all = "lowercase")]
182pub enum EnvKind {
183 #[default]
185 Default,
186 Prompted,
188 Required,
191}
192
193#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
195#[serde(rename_all = "snake_case")]
196pub enum EnvFormat {
197 #[default]
199 String,
200 Hex,
202 Uuid,
204 JwtHs256,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct EnvVar {
210 pub name: String,
211 pub value: String,
212 #[serde(default)]
213 pub kind: EnvKind,
214 #[serde(default)]
216 pub prompt: Option<String>,
217 #[serde(default)]
219 pub format: EnvFormat,
220 #[serde(default)]
223 pub length: Option<u32>,
224 #[serde(default)]
227 pub jwt_claims: Option<std::collections::BTreeMap<std::string::String, serde_json::Value>>,
228 #[serde(default)]
230 pub jwt_signing_key: Option<std::string::String>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct EnvGroup {
242 pub name: String,
245 pub prompt: String,
247 #[serde(default)]
248 pub env: Vec<EnvVar>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct ServiceRequirement {
257 pub service: String,
258}
259
260#[derive(Debug, Clone, Default, Serialize, Deserialize)]
261pub struct Mappings {
262 #[serde(default)]
263 pub smtp: BTreeMap<String, String>,
264 #[serde(default)]
265 pub auth: BTreeMap<String, String>,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(rename_all = "kebab-case")]
271pub enum AuthKind {
272 Oidc,
274}
275
276impl std::fmt::Display for AuthKind {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 match self {
279 AuthKind::Oidc => write!(f, "oidc"),
280 }
281 }
282}
283
284#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
286#[serde(rename_all = "snake_case")]
287pub enum TokenAuthMethod {
288 #[default]
289 ClientSecretPost,
290 ClientSecretBasic,
291 None,
294}
295
296impl TokenAuthMethod {
297 pub fn as_str(&self) -> &'static str {
298 match self {
299 TokenAuthMethod::ClientSecretPost => "client_secret_post",
300 TokenAuthMethod::ClientSecretBasic => "client_secret_basic",
301 TokenAuthMethod::None => "none",
302 }
303 }
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct IntegrationFlags {
308 #[serde(default)]
310 pub auth: Vec<AuthKind>,
311 #[serde(default)]
313 pub token_auth_method: TokenAuthMethod,
314 #[serde(default)]
317 pub oidc_callbacks: Vec<String>,
318 #[serde(default = "default_true")]
319 pub smtp: bool,
320 #[serde(default)]
331 pub backup: bool,
332}
333
334impl Default for IntegrationFlags {
335 fn default() -> Self {
336 Self {
337 auth: vec![],
338 token_auth_method: TokenAuthMethod::default(),
339 oidc_callbacks: vec![],
340 smtp: true,
341 backup: false,
342 }
343 }
344}
345
346fn default_true() -> bool {
347 true
348}
349
350#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
371pub struct BackupConfig {
372 #[serde(default)]
376 pub paths: Vec<String>,
377 #[serde(default)]
380 pub exclude: Vec<String>,
381 #[serde(default)]
384 pub pre_backup: Option<String>,
385 #[serde(default)]
388 pub post_backup: Option<String>,
389 #[serde(default)]
392 pub pre_restore: Option<String>,
393 #[serde(default)]
396 pub post_restore: Option<String>,
397}
398
399impl ServiceDef {
404 pub fn check_architecture(&self) -> Option<String> {
407 if self.service.architecture.is_empty() {
408 return None;
409 }
410 let current = current_architecture();
411 if self.service.architecture.contains(¤t) {
412 None
413 } else {
414 let supported: Vec<_> = self
415 .service
416 .architecture
417 .iter()
418 .map(|a| a.to_string())
419 .collect();
420 Some(format!(
421 "{} only supports {} — this system is {current}",
422 self.service.name,
423 supported.join(", "),
424 ))
425 }
426 }
427
428 pub fn required_env_vars(&self) -> Vec<&str> {
430 self.env
431 .iter()
432 .filter(|e| e.kind == EnvKind::Required)
433 .map(|e| e.name.as_str())
434 .collect()
435 }
436
437 pub fn validate(&self) -> Result<(), String> {
441 let name = &self.service.name;
442 let mut errors: Vec<String> = Vec::new();
443
444 let mut seen_ports = std::collections::HashSet::new();
447 for p in &self.ports {
448 if !seen_ports.insert(&p.name) {
449 errors.push(format!("duplicate port name '{}'", p.name));
450 }
451 }
452
453 let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
457 for e in &self.env {
458 if !seen_envs.insert(&e.name) {
459 errors.push(format!("duplicate env var name '{}'", e.name));
460 }
461 }
462 for g in &self.env_groups {
463 for e in &g.env {
464 if !seen_envs.insert(&e.name) {
465 errors.push(format!(
466 "env var '{}' in group '{}' collides with another env var",
467 e.name, g.name
468 ));
469 }
470 }
471 }
472
473 for e in &self.env {
476 check_env_var(e, None, &mut errors);
477 }
478
479 let mut seen_groups = std::collections::HashSet::new();
482 for g in &self.env_groups {
483 if !seen_groups.insert(&g.name) {
484 errors.push(format!("duplicate env_group name '{}'", g.name));
485 }
486 if g.name.is_empty() {
487 errors.push("env_group has empty name".to_string());
488 } else if !g
489 .name
490 .chars()
491 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
492 {
493 errors.push(format!(
494 "env_group '{}' must be lowercase snake_case ([a-z0-9_])",
495 g.name
496 ));
497 }
498 if g.prompt.is_empty() {
499 errors.push(format!("env_group '{}' has empty prompt", g.name));
500 }
501 if g.env.is_empty() {
502 errors.push(format!("env_group '{}' has no env vars", g.name));
503 }
504 for e in &g.env {
505 check_env_var(e, Some(&g.name), &mut errors);
506 }
507 }
508
509 if let Some(ref req) = self.requirements
512 && let Some(rec) = req.ram.recommended
513 && rec < req.ram.min
514 {
515 errors.push(format!(
516 "recommended RAM ({rec}MB) is less than minimum ({}MB)",
517 req.ram.min
518 ));
519 }
520
521 if let Some(ref backup) = self.backup
529 && !self.integrations.backup
530 {
531 errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
532 let _ = backup;
535 }
536 if let Some(ref backup) = self.backup {
537 for (label, hook) in [
538 ("pre_backup", &backup.pre_backup),
539 ("post_backup", &backup.post_backup),
540 ("pre_restore", &backup.pre_restore),
541 ("post_restore", &backup.post_restore),
542 ] {
543 if let Some(script) = hook
544 && (script.is_empty() || script.contains('/') || script.contains(".."))
545 {
546 errors.push(format!(
547 "backup hook '{label}' must be a bare filename under configs/scripts/ \
548 (got {script:?})"
549 ));
550 }
551 }
552 for p in &backup.paths {
553 if p.is_empty() || p.starts_with('/') || p.contains("..") {
554 errors.push(format!(
555 "backup path {p:?} must be a relative path within the service home"
556 ));
557 }
558 }
559 }
560
561 if errors.is_empty() {
562 Ok(())
563 } else {
564 Err(format!("{name}: {}", errors.join("; ")))
565 }
566 }
567}
568
569fn check_env_var(e: &EnvVar, group: Option<&str>, errors: &mut Vec<String>) {
574 let where_ = match group {
575 Some(g) => format!(" in group '{g}'"),
576 None => String::new(),
577 };
578 if e.name.is_empty() {
579 errors.push(format!("env var has empty name{where_}"));
580 } else if !e
581 .name
582 .chars()
583 .next()
584 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
585 {
586 errors.push(format!(
587 "env var '{}'{where_} must start with a letter or _",
588 e.name
589 ));
590 } else if !e
591 .name
592 .chars()
593 .all(|c| c.is_ascii_alphanumeric() || c == '_')
594 {
595 errors.push(format!(
596 "env var '{}'{where_} contains invalid characters — must match [A-Za-z0-9_]",
597 e.name
598 ));
599 }
600 if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
601 errors.push(format!(
602 "env var '{}'{where_} is kind=required but has a secret template default — use kind=prompted or kind=default",
603 e.name
604 ));
605 }
606}
607
608pub fn current_architecture() -> Arch {
610 match std::env::consts::ARCH {
611 "x86_64" => Arch::Amd64,
612 "aarch64" => Arch::Arm64,
613 _ => Arch::Amd64,
616 }
617}
618
619#[cfg(test)]
620mod backup_tests {
621 use super::*;
622
623 fn parse(toml_src: &str) -> ServiceDef {
624 toml::from_str(toml_src).expect("parse")
625 }
626
627 #[test]
628 fn backup_defaults_to_false_when_omitted() {
629 let svc = parse(
630 r#"
631[service]
632name = "x"
633description = "x"
634"#,
635 );
636 assert!(!svc.integrations.backup);
637 assert!(svc.backup.is_none());
638 svc.validate().expect("default is valid");
639 }
640
641 #[test]
642 fn backup_section_alone_is_rejected_without_integration_flag() {
643 let svc = parse(
644 r#"
645[service]
646name = "x"
647description = "x"
648
649[backup]
650"#,
651 );
652 let err = svc.validate().expect_err("must reject");
653 assert!(
654 err.contains("backup = true"),
655 "error mentions the required flag: {err}"
656 );
657 }
658
659 #[test]
660 fn backup_supported_without_hooks_validates() {
661 let svc = parse(
662 r#"
663[service]
664name = "x"
665description = "x"
666
667[integrations]
668backup = true
669"#,
670 );
671 assert!(svc.integrations.backup);
672 assert!(svc.backup.is_none());
673 svc.validate().expect("ok without [backup] table");
674 }
675
676 #[test]
677 fn backup_with_full_hooks_validates() {
678 let svc = parse(
679 r#"
680[service]
681name = "x"
682description = "x"
683
684[integrations]
685backup = true
686
687[backup]
688paths = [".backup/db.sql.gz", "data"]
689exclude = ["data/cache"]
690pre_backup = "backup-pre.sh"
691post_backup = "backup-post.sh"
692pre_restore = "restore-pre.sh"
693post_restore = "restore-post.sh"
694"#,
695 );
696 svc.validate().expect("ok");
697 let backup = svc.backup.as_ref().expect("section present");
698 assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
699 assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
700 }
701
702 #[test]
703 fn backup_hook_with_slash_is_rejected() {
704 let svc = parse(
705 r#"
706[service]
707name = "x"
708description = "x"
709
710[integrations]
711backup = true
712
713[backup]
714pre_backup = "subdir/script.sh"
715"#,
716 );
717 let err = svc.validate().expect_err("must reject");
718 assert!(err.contains("pre_backup"), "{err}");
719 }
720
721 #[test]
722 fn backup_hook_with_dotdot_is_rejected() {
723 let svc = parse(
724 r#"
725[service]
726name = "x"
727description = "x"
728
729[integrations]
730backup = true
731
732[backup]
733post_backup = "../escape.sh"
734"#,
735 );
736 let err = svc.validate().expect_err("must reject");
737 assert!(err.contains("post_backup"), "{err}");
738 }
739
740 #[test]
741 fn backup_absolute_path_is_rejected() {
742 let svc = parse(
743 r#"
744[service]
745name = "x"
746description = "x"
747
748[integrations]
749backup = true
750
751[backup]
752paths = ["/etc/passwd"]
753"#,
754 );
755 let err = svc.validate().expect_err("must reject");
756 assert!(err.contains("/etc/passwd"), "{err}");
757 }
758
759 #[test]
760 fn backup_path_with_dotdot_is_rejected() {
761 let svc = parse(
762 r#"
763[service]
764name = "x"
765description = "x"
766
767[integrations]
768backup = true
769
770[backup]
771paths = ["../../somewhere"]
772"#,
773 );
774 let err = svc.validate().expect_err("must reject");
775 assert!(err.contains("somewhere"), "{err}");
776 }
777}