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 #[serde(default)]
95 pub runtime: Runtime,
96 #[serde(default)]
102 pub run: Option<String>,
103 #[serde(default)]
107 pub build: Option<String>,
108}
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
112#[serde(rename_all = "lowercase")]
113pub enum ServiceKind {
114 #[default]
115 Application,
116 Infrastructure,
117}
118
119#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "lowercase")]
122pub enum Runtime {
123 #[default]
126 Podman,
127 Native,
131}
132
133impl Runtime {
134 pub fn is_podman(&self) -> bool {
138 matches!(self, Runtime::Podman)
139 }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "lowercase")]
145pub enum Arch {
146 Amd64,
147 Arm64,
148}
149
150impl std::fmt::Display for Arch {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 match self {
153 Arch::Amd64 => write!(f, "amd64"),
154 Arch::Arm64 => write!(f, "arm64"),
155 }
156 }
157}
158
159#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
173#[serde(rename_all = "lowercase")]
174pub enum HttpsRequirement {
175 #[default]
176 Never,
177 Auth,
178 Always,
179}
180
181#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
183#[serde(rename_all = "lowercase")]
184pub enum PortProtocol {
185 #[default]
186 Tcp,
187 Udp,
188}
189
190impl std::fmt::Display for PortProtocol {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 match self {
193 PortProtocol::Tcp => write!(f, "tcp"),
194 PortProtocol::Udp => write!(f, "udp"),
195 }
196 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct PortDef {
201 pub name: String,
202 pub container_port: u16,
203 #[serde(default)]
206 pub host_port: Option<u16>,
207 #[serde(default)]
208 pub protocol: PortProtocol,
209 #[serde(default)]
218 pub tailscale_https: Option<u16>,
219}
220
221#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
230#[serde(rename_all = "lowercase")]
231pub enum EnvKind {
232 #[default]
234 Default,
235 Prompted,
237 Required,
240}
241
242#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
244#[serde(rename_all = "snake_case")]
245pub enum EnvFormat {
246 #[default]
248 String,
249 Hex,
251 Base64,
256 Base64Url,
260 Uuid,
262 JwtHs256,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct EnvVar {
268 pub name: String,
269 pub value: String,
270 #[serde(default)]
271 pub kind: EnvKind,
272 #[serde(default)]
274 pub prompt: Option<String>,
275 #[serde(default)]
277 pub format: EnvFormat,
278 #[serde(default)]
281 pub length: Option<u32>,
282 #[serde(default)]
285 pub jwt_claims: Option<std::collections::BTreeMap<std::string::String, serde_json::Value>>,
286 #[serde(default)]
288 pub jwt_signing_key: Option<std::string::String>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct EnvGroup {
300 pub name: String,
303 pub prompt: String,
305 #[serde(default)]
306 pub env: Vec<EnvVar>,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct ServiceRequirement {
315 pub service: String,
316}
317
318#[derive(Debug, Clone, Default, Serialize, Deserialize)]
319pub struct Mappings {
320 #[serde(default)]
321 pub smtp: BTreeMap<String, String>,
322 #[serde(default)]
323 pub auth: BTreeMap<String, String>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(rename_all = "kebab-case")]
329pub enum AuthKind {
330 Oidc,
332}
333
334impl std::fmt::Display for AuthKind {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 match self {
337 AuthKind::Oidc => write!(f, "oidc"),
338 }
339 }
340}
341
342#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
344#[serde(rename_all = "snake_case")]
345pub enum TokenAuthMethod {
346 #[default]
347 ClientSecretPost,
348 ClientSecretBasic,
349 None,
352}
353
354impl TokenAuthMethod {
355 pub fn as_str(&self) -> &'static str {
356 match self {
357 TokenAuthMethod::ClientSecretPost => "client_secret_post",
358 TokenAuthMethod::ClientSecretBasic => "client_secret_basic",
359 TokenAuthMethod::None => "none",
360 }
361 }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct IntegrationFlags {
366 #[serde(default)]
368 pub auth: Vec<AuthKind>,
369 #[serde(default)]
371 pub token_auth_method: TokenAuthMethod,
372 #[serde(default)]
375 pub oidc_callbacks: Vec<String>,
376 #[serde(default = "default_true")]
377 pub smtp: bool,
378 #[serde(default)]
389 pub backup: bool,
390}
391
392impl Default for IntegrationFlags {
393 fn default() -> Self {
394 Self {
395 auth: vec![],
396 token_auth_method: TokenAuthMethod::default(),
397 oidc_callbacks: vec![],
398 smtp: true,
399 backup: false,
400 }
401 }
402}
403
404fn default_true() -> bool {
405 true
406}
407
408#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
429pub struct BackupConfig {
430 #[serde(default)]
434 pub paths: Vec<String>,
435 #[serde(default)]
438 pub exclude: Vec<String>,
439 #[serde(default)]
442 pub pre_backup: Option<String>,
443 #[serde(default)]
446 pub post_backup: Option<String>,
447 #[serde(default)]
450 pub pre_restore: Option<String>,
451 #[serde(default)]
454 pub post_restore: Option<String>,
455}
456
457impl ServiceDef {
462 pub fn check_architecture(&self) -> Option<String> {
465 if self.service.architecture.is_empty() {
466 return None;
467 }
468 let current = current_architecture();
469 if self.service.architecture.contains(¤t) {
470 None
471 } else {
472 let supported: Vec<_> = self
473 .service
474 .architecture
475 .iter()
476 .map(|a| a.to_string())
477 .collect();
478 Some(format!(
479 "{} only supports {} — this system is {current}",
480 self.service.name,
481 supported.join(", "),
482 ))
483 }
484 }
485
486 pub fn required_env_vars(&self) -> Vec<&str> {
488 self.env
489 .iter()
490 .filter(|e| e.kind == EnvKind::Required)
491 .map(|e| e.name.as_str())
492 .collect()
493 }
494
495 pub fn validate(&self) -> Result<(), String> {
499 let name = &self.service.name;
500 let mut errors: Vec<String> = Vec::new();
501
502 let mut seen_ports = std::collections::HashSet::new();
505 let mut seen_ts_https = std::collections::HashSet::new();
506 for p in &self.ports {
507 if !seen_ports.insert(&p.name) {
508 errors.push(format!("duplicate port name '{}'", p.name));
509 }
510 if p.container_port == 0 {
513 errors.push(format!(
514 "port '{}' has container_port = 0 — fill in the port your service listens on",
515 p.name
516 ));
517 }
518 if let Some(https) = p.tailscale_https
521 && !seen_ts_https.insert(https)
522 {
523 errors.push(format!(
524 "two ports map to the same tailscale_https port {https}"
525 ));
526 }
527 }
528 let ts_ports: Vec<&PortDef> = self
531 .ports
532 .iter()
533 .filter(|p| p.tailscale_https.is_some())
534 .collect();
535 if !ts_ports.is_empty()
536 && ts_ports
537 .iter()
538 .filter(|p| p.tailscale_https == Some(443))
539 .count()
540 != 1
541 {
542 errors.push(
543 "services exposing ports over Tailscale must mark exactly one port \
544 tailscale_https = 443 (the web root)"
545 .to_string(),
546 );
547 }
548
549 let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
553 for e in &self.env {
554 if !seen_envs.insert(&e.name) {
555 errors.push(format!("duplicate env var name '{}'", e.name));
556 }
557 }
558 for g in &self.env_groups {
559 for e in &g.env {
560 if !seen_envs.insert(&e.name) {
561 errors.push(format!(
562 "env var '{}' in group '{}' collides with another env var",
563 e.name, g.name
564 ));
565 }
566 }
567 }
568
569 for e in &self.env {
572 check_env_var(e, None, &mut errors);
573 }
574
575 let mut seen_groups = std::collections::HashSet::new();
578 for g in &self.env_groups {
579 if !seen_groups.insert(&g.name) {
580 errors.push(format!("duplicate env_group name '{}'", g.name));
581 }
582 if g.name.is_empty() {
583 errors.push("env_group has empty name".to_string());
584 } else if !g
585 .name
586 .chars()
587 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
588 {
589 errors.push(format!(
590 "env_group '{}' must be lowercase snake_case ([a-z0-9_])",
591 g.name
592 ));
593 }
594 if g.prompt.is_empty() {
595 errors.push(format!("env_group '{}' has empty prompt", g.name));
596 }
597 if g.env.is_empty() {
598 errors.push(format!("env_group '{}' has no env vars", g.name));
599 }
600 for e in &g.env {
601 check_env_var(e, Some(&g.name), &mut errors);
602 }
603 }
604
605 if let Some(ref req) = self.requirements
608 && let Some(rec) = req.ram.recommended
609 && rec < req.ram.min
610 {
611 errors.push(format!(
612 "recommended RAM ({rec}MB) is less than minimum ({}MB)",
613 req.ram.min
614 ));
615 }
616
617 if let Some(ref backup) = self.backup
625 && !self.integrations.backup
626 {
627 errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
628 let _ = backup;
631 }
632 if let Some(ref backup) = self.backup {
633 for (label, hook) in [
634 ("pre_backup", &backup.pre_backup),
635 ("post_backup", &backup.post_backup),
636 ("pre_restore", &backup.pre_restore),
637 ("post_restore", &backup.post_restore),
638 ] {
639 if let Some(script) = hook
640 && (script.is_empty() || script.contains('/') || script.contains(".."))
641 {
642 errors.push(format!(
643 "backup hook '{label}' must be a bare filename under configs/scripts/ \
644 (got {script:?})"
645 ));
646 }
647 }
648 for p in &backup.paths {
649 if p.is_empty() || p.starts_with('/') || p.contains("..") {
650 errors.push(format!(
651 "backup path {p:?} must be a relative path within the service home"
652 ));
653 }
654 }
655 }
656
657 match self.service.runtime {
662 Runtime::Native => match &self.service.run {
663 None => errors.push(
664 "runtime = \"native\" requires a `run` command under [service]".to_string(),
665 ),
666 Some(run) if run.trim().is_empty() => {
667 errors.push("[service].run must not be empty".to_string())
668 }
669 Some(_) => {}
670 },
671 Runtime::Podman => {
672 if self.service.run.is_some() || self.service.build.is_some() {
673 errors.push(
674 "`run` / `build` are only valid for runtime = \"native\" services"
675 .to_string(),
676 );
677 }
678 }
679 }
680
681 if errors.is_empty() {
682 Ok(())
683 } else {
684 Err(format!("{name}: {}", errors.join("; ")))
685 }
686 }
687}
688
689fn check_env_var(e: &EnvVar, group: Option<&str>, errors: &mut Vec<String>) {
694 let where_ = match group {
695 Some(g) => format!(" in group '{g}'"),
696 None => String::new(),
697 };
698 if e.name.is_empty() {
699 errors.push(format!("env var has empty name{where_}"));
700 } else if !e
701 .name
702 .chars()
703 .next()
704 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
705 {
706 errors.push(format!(
707 "env var '{}'{where_} must start with a letter or _",
708 e.name
709 ));
710 } else if !e
711 .name
712 .chars()
713 .all(|c| c.is_ascii_alphanumeric() || c == '_')
714 {
715 errors.push(format!(
716 "env var '{}'{where_} contains invalid characters — must match [A-Za-z0-9_]",
717 e.name
718 ));
719 }
720 if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
721 errors.push(format!(
722 "env var '{}'{where_} is kind=required but has a secret template default — use kind=prompted or kind=default",
723 e.name
724 ));
725 }
726}
727
728pub fn current_architecture() -> Arch {
730 match std::env::consts::ARCH {
731 "x86_64" => Arch::Amd64,
732 "aarch64" => Arch::Arm64,
733 _ => Arch::Amd64,
736 }
737}
738
739#[cfg(test)]
740mod backup_tests {
741 use super::*;
742
743 fn parse(toml_src: &str) -> ServiceDef {
744 toml::from_str(toml_src).expect("parse")
745 }
746
747 #[test]
748 fn tailscale_https_requires_exactly_one_root() {
749 let svc = parse(
751 r#"
752[service]
753name = "x"
754description = "x"
755
756[[ports]]
757name = "http"
758container_port = 8080
759tailscale_https = 8080
760
761[[ports]]
762name = "photos"
763container_port = 3000
764tailscale_https = 3000
765"#,
766 );
767 let err = svc.validate().expect_err("must reject");
768 assert!(err.contains("tailscale_https = 443"), "got: {err}");
769 }
770
771 #[test]
772 fn tailscale_https_duplicate_port_rejected() {
773 let svc = parse(
774 r#"
775[service]
776name = "x"
777description = "x"
778
779[[ports]]
780name = "a"
781container_port = 1
782tailscale_https = 443
783
784[[ports]]
785name = "b"
786container_port = 2
787tailscale_https = 443
788"#,
789 );
790 let err = svc.validate().expect_err("must reject");
791 assert!(err.contains("same tailscale_https"), "got: {err}");
792 }
793
794 #[test]
795 fn tailscale_https_one_root_plus_api_validates() {
796 let svc = parse(
797 r#"
798[service]
799name = "x"
800description = "x"
801
802[[ports]]
803name = "http"
804container_port = 8080
805tailscale_https = 8080
806
807[[ports]]
808name = "photos"
809container_port = 3000
810tailscale_https = 443
811"#,
812 );
813 svc.validate()
814 .expect("one 443 root + one api port is valid");
815 }
816
817 #[test]
818 fn backup_defaults_to_false_when_omitted() {
819 let svc = parse(
820 r#"
821[service]
822name = "x"
823description = "x"
824"#,
825 );
826 assert!(!svc.integrations.backup);
827 assert!(svc.backup.is_none());
828 svc.validate().expect("default is valid");
829 }
830
831 #[test]
832 fn backup_section_alone_is_rejected_without_integration_flag() {
833 let svc = parse(
834 r#"
835[service]
836name = "x"
837description = "x"
838
839[backup]
840"#,
841 );
842 let err = svc.validate().expect_err("must reject");
843 assert!(
844 err.contains("backup = true"),
845 "error mentions the required flag: {err}"
846 );
847 }
848
849 #[test]
850 fn backup_supported_without_hooks_validates() {
851 let svc = parse(
852 r#"
853[service]
854name = "x"
855description = "x"
856
857[integrations]
858backup = true
859"#,
860 );
861 assert!(svc.integrations.backup);
862 assert!(svc.backup.is_none());
863 svc.validate().expect("ok without [backup] table");
864 }
865
866 #[test]
867 fn backup_with_full_hooks_validates() {
868 let svc = parse(
869 r#"
870[service]
871name = "x"
872description = "x"
873
874[integrations]
875backup = true
876
877[backup]
878paths = [".backup/db.sql.gz", "data"]
879exclude = ["data/cache"]
880pre_backup = "backup-pre.sh"
881post_backup = "backup-post.sh"
882pre_restore = "restore-pre.sh"
883post_restore = "restore-post.sh"
884"#,
885 );
886 svc.validate().expect("ok");
887 let backup = svc.backup.as_ref().expect("section present");
888 assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
889 assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
890 }
891
892 #[test]
893 fn backup_hook_with_slash_is_rejected() {
894 let svc = parse(
895 r#"
896[service]
897name = "x"
898description = "x"
899
900[integrations]
901backup = true
902
903[backup]
904pre_backup = "subdir/script.sh"
905"#,
906 );
907 let err = svc.validate().expect_err("must reject");
908 assert!(err.contains("pre_backup"), "{err}");
909 }
910
911 #[test]
912 fn backup_hook_with_dotdot_is_rejected() {
913 let svc = parse(
914 r#"
915[service]
916name = "x"
917description = "x"
918
919[integrations]
920backup = true
921
922[backup]
923post_backup = "../escape.sh"
924"#,
925 );
926 let err = svc.validate().expect_err("must reject");
927 assert!(err.contains("post_backup"), "{err}");
928 }
929
930 #[test]
931 fn backup_absolute_path_is_rejected() {
932 let svc = parse(
933 r#"
934[service]
935name = "x"
936description = "x"
937
938[integrations]
939backup = true
940
941[backup]
942paths = ["/etc/passwd"]
943"#,
944 );
945 let err = svc.validate().expect_err("must reject");
946 assert!(err.contains("/etc/passwd"), "{err}");
947 }
948
949 #[test]
950 fn backup_path_with_dotdot_is_rejected() {
951 let svc = parse(
952 r#"
953[service]
954name = "x"
955description = "x"
956
957[integrations]
958backup = true
959
960[backup]
961paths = ["../../somewhere"]
962"#,
963 );
964 let err = svc.validate().expect_err("must reject");
965 assert!(err.contains("somewhere"), "{err}");
966 }
967}