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
181impl HttpsRequirement {
182 pub fn needs_https(&self, auth_requested: bool, url: Option<&str>) -> bool {
190 matches!(self, HttpsRequirement::Always)
191 || (matches!(self, HttpsRequirement::Auth) && auth_requested)
192 || url.is_some_and(|u| u.starts_with("https://"))
193 }
194}
195
196#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
198#[serde(rename_all = "lowercase")]
199pub enum PortProtocol {
200 #[default]
201 Tcp,
202 Udp,
203}
204
205impl std::fmt::Display for PortProtocol {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 match self {
208 PortProtocol::Tcp => write!(f, "tcp"),
209 PortProtocol::Udp => write!(f, "udp"),
210 }
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct PortDef {
216 pub name: String,
217 pub container_port: u16,
218 #[serde(default)]
221 pub host_port: Option<u16>,
222 #[serde(default)]
223 pub protocol: PortProtocol,
224 #[serde(default)]
233 pub tailscale_https: Option<u16>,
234}
235
236#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
245#[serde(rename_all = "lowercase")]
246pub enum EnvKind {
247 #[default]
249 Default,
250 Prompted,
252 Required,
255}
256
257#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
259#[serde(rename_all = "snake_case")]
260pub enum EnvFormat {
261 #[default]
263 String,
264 Hex,
266 Base64,
271 Base64Url,
275 Uuid,
277 JwtHs256,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct EnvVar {
283 pub name: String,
284 pub value: String,
285 #[serde(default)]
286 pub kind: EnvKind,
287 #[serde(default)]
289 pub prompt: Option<String>,
290 #[serde(default)]
292 pub format: EnvFormat,
293 #[serde(default)]
296 pub length: Option<u32>,
297 #[serde(default)]
300 pub jwt_claims: Option<std::collections::BTreeMap<std::string::String, serde_json::Value>>,
301 #[serde(default)]
303 pub jwt_signing_key: Option<std::string::String>,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct EnvGroup {
315 pub name: String,
318 pub prompt: String,
320 #[serde(default)]
321 pub env: Vec<EnvVar>,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct ServiceRequirement {
330 pub service: String,
331}
332
333#[derive(Debug, Clone, Default, Serialize, Deserialize)]
334pub struct Mappings {
335 #[serde(default)]
336 pub smtp: BTreeMap<String, String>,
337 #[serde(default)]
338 pub auth: BTreeMap<String, String>,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(rename_all = "kebab-case")]
344pub enum AuthKind {
345 Oidc,
347}
348
349impl std::fmt::Display for AuthKind {
350 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351 match self {
352 AuthKind::Oidc => write!(f, "oidc"),
353 }
354 }
355}
356
357#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
359#[serde(rename_all = "snake_case")]
360pub enum TokenAuthMethod {
361 #[default]
362 ClientSecretPost,
363 ClientSecretBasic,
364 None,
367}
368
369impl TokenAuthMethod {
370 pub fn as_str(&self) -> &'static str {
371 match self {
372 TokenAuthMethod::ClientSecretPost => "client_secret_post",
373 TokenAuthMethod::ClientSecretBasic => "client_secret_basic",
374 TokenAuthMethod::None => "none",
375 }
376 }
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct IntegrationFlags {
381 #[serde(default)]
383 pub auth: Vec<AuthKind>,
384 #[serde(default)]
386 pub token_auth_method: TokenAuthMethod,
387 #[serde(default)]
390 pub oidc_callbacks: Vec<String>,
391 #[serde(default = "default_true")]
392 pub smtp: bool,
393 #[serde(default)]
404 pub backup: bool,
405}
406
407impl Default for IntegrationFlags {
408 fn default() -> Self {
409 Self {
410 auth: vec![],
411 token_auth_method: TokenAuthMethod::default(),
412 oidc_callbacks: vec![],
413 smtp: true,
414 backup: false,
415 }
416 }
417}
418
419fn default_true() -> bool {
420 true
421}
422
423#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
444pub struct BackupConfig {
445 #[serde(default)]
449 pub paths: Vec<String>,
450 #[serde(default)]
453 pub exclude: Vec<String>,
454 #[serde(default)]
457 pub pre_backup: Option<String>,
458 #[serde(default)]
461 pub post_backup: Option<String>,
462 #[serde(default)]
465 pub pre_restore: Option<String>,
466 #[serde(default)]
469 pub post_restore: Option<String>,
470}
471
472impl ServiceDef {
477 pub fn check_architecture(&self) -> Option<String> {
480 if self.service.architecture.is_empty() {
481 return None;
482 }
483 let current = current_architecture();
484 if self.service.architecture.contains(¤t) {
485 None
486 } else {
487 let supported: Vec<_> = self
488 .service
489 .architecture
490 .iter()
491 .map(|a| a.to_string())
492 .collect();
493 Some(format!(
494 "{} only supports {} — this system is {current}",
495 self.service.name,
496 supported.join(", "),
497 ))
498 }
499 }
500
501 pub fn required_env_vars(&self) -> Vec<&str> {
503 self.env
504 .iter()
505 .filter(|e| e.kind == EnvKind::Required)
506 .map(|e| e.name.as_str())
507 .collect()
508 }
509
510 pub fn validate(&self) -> Result<(), String> {
514 let name = &self.service.name;
515 let mut errors: Vec<String> = Vec::new();
516
517 let mut seen_ports = std::collections::HashSet::new();
520 let mut seen_ts_https = std::collections::HashSet::new();
521 for p in &self.ports {
522 if !seen_ports.insert(&p.name) {
523 errors.push(format!("duplicate port name '{}'", p.name));
524 }
525 if p.container_port == 0 {
528 errors.push(format!(
529 "port '{}' has container_port = 0 — fill in the port your service listens on",
530 p.name
531 ));
532 }
533 if let Some(https) = p.tailscale_https
536 && !seen_ts_https.insert(https)
537 {
538 errors.push(format!(
539 "two ports map to the same tailscale_https port {https}"
540 ));
541 }
542 }
543 let ts_ports: Vec<&PortDef> = self
546 .ports
547 .iter()
548 .filter(|p| p.tailscale_https.is_some())
549 .collect();
550 if !ts_ports.is_empty()
551 && ts_ports
552 .iter()
553 .filter(|p| p.tailscale_https == Some(443))
554 .count()
555 != 1
556 {
557 errors.push(
558 "services exposing ports over Tailscale must mark exactly one port \
559 tailscale_https = 443 (the web root)"
560 .to_string(),
561 );
562 }
563
564 let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
568 for e in &self.env {
569 if !seen_envs.insert(&e.name) {
570 errors.push(format!("duplicate env var name '{}'", e.name));
571 }
572 }
573 for g in &self.env_groups {
574 for e in &g.env {
575 if !seen_envs.insert(&e.name) {
576 errors.push(format!(
577 "env var '{}' in group '{}' collides with another env var",
578 e.name, g.name
579 ));
580 }
581 }
582 }
583
584 for e in &self.env {
587 check_env_var(e, None, &mut errors);
588 }
589
590 let mut seen_groups = std::collections::HashSet::new();
593 for g in &self.env_groups {
594 if !seen_groups.insert(&g.name) {
595 errors.push(format!("duplicate env_group name '{}'", g.name));
596 }
597 if g.name.is_empty() {
598 errors.push("env_group has empty name".to_string());
599 } else if !g
600 .name
601 .chars()
602 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
603 {
604 errors.push(format!(
605 "env_group '{}' must be lowercase snake_case ([a-z0-9_])",
606 g.name
607 ));
608 }
609 if g.prompt.is_empty() {
610 errors.push(format!("env_group '{}' has empty prompt", g.name));
611 }
612 if g.env.is_empty() {
613 errors.push(format!("env_group '{}' has no env vars", g.name));
614 }
615 for e in &g.env {
616 check_env_var(e, Some(&g.name), &mut errors);
617 }
618 }
619
620 if let Some(ref req) = self.requirements
623 && let Some(rec) = req.ram.recommended
624 && rec < req.ram.min
625 {
626 errors.push(format!(
627 "recommended RAM ({rec}MB) is less than minimum ({}MB)",
628 req.ram.min
629 ));
630 }
631
632 if let Some(ref backup) = self.backup
640 && !self.integrations.backup
641 {
642 errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
643 let _ = backup;
646 }
647 if let Some(ref backup) = self.backup {
648 for (label, hook) in [
649 ("pre_backup", &backup.pre_backup),
650 ("post_backup", &backup.post_backup),
651 ("pre_restore", &backup.pre_restore),
652 ("post_restore", &backup.post_restore),
653 ] {
654 if let Some(script) = hook
655 && (script.is_empty() || script.contains('/') || script.contains(".."))
656 {
657 errors.push(format!(
658 "backup hook '{label}' must be a bare filename under configs/scripts/ \
659 (got {script:?})"
660 ));
661 }
662 }
663 for p in &backup.paths {
664 if p.is_empty() || p.starts_with('/') || p.contains("..") {
665 errors.push(format!(
666 "backup path {p:?} must be a relative path within the service home"
667 ));
668 }
669 }
670 }
671
672 match self.service.runtime {
677 Runtime::Native => match &self.service.run {
678 None => errors.push(
679 "runtime = \"native\" requires a `run` command under [service]".to_string(),
680 ),
681 Some(run) if run.trim().is_empty() => {
682 errors.push("[service].run must not be empty".to_string())
683 }
684 Some(_) => {}
685 },
686 Runtime::Podman => {
687 if self.service.run.is_some() || self.service.build.is_some() {
688 errors.push(
689 "`run` / `build` are only valid for runtime = \"native\" services"
690 .to_string(),
691 );
692 }
693 }
694 }
695
696 if errors.is_empty() {
697 Ok(())
698 } else {
699 Err(format!("{name}: {}", errors.join("; ")))
700 }
701 }
702}
703
704fn check_env_var(e: &EnvVar, group: Option<&str>, errors: &mut Vec<String>) {
709 let where_ = match group {
710 Some(g) => format!(" in group '{g}'"),
711 None => String::new(),
712 };
713 if e.name.is_empty() {
714 errors.push(format!("env var has empty name{where_}"));
715 } else if !e
716 .name
717 .chars()
718 .next()
719 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
720 {
721 errors.push(format!(
722 "env var '{}'{where_} must start with a letter or _",
723 e.name
724 ));
725 } else if !e
726 .name
727 .chars()
728 .all(|c| c.is_ascii_alphanumeric() || c == '_')
729 {
730 errors.push(format!(
731 "env var '{}'{where_} contains invalid characters — must match [A-Za-z0-9_]",
732 e.name
733 ));
734 }
735 if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
736 errors.push(format!(
737 "env var '{}'{where_} is kind=required but has a secret template default — use kind=prompted or kind=default",
738 e.name
739 ));
740 }
741}
742
743pub fn current_architecture() -> Arch {
745 match std::env::consts::ARCH {
746 "x86_64" => Arch::Amd64,
747 "aarch64" => Arch::Arm64,
748 _ => Arch::Amd64,
751 }
752}
753
754#[cfg(test)]
755mod backup_tests {
756 use super::*;
757
758 fn parse(toml_src: &str) -> ServiceDef {
759 toml::from_str(toml_src).expect("parse")
760 }
761
762 #[test]
763 fn tailscale_https_requires_exactly_one_root() {
764 let svc = parse(
766 r#"
767[service]
768name = "x"
769description = "x"
770
771[[ports]]
772name = "http"
773container_port = 8080
774tailscale_https = 8080
775
776[[ports]]
777name = "photos"
778container_port = 3000
779tailscale_https = 3000
780"#,
781 );
782 let err = svc.validate().expect_err("must reject");
783 assert!(err.contains("tailscale_https = 443"), "got: {err}");
784 }
785
786 #[test]
787 fn tailscale_https_duplicate_port_rejected() {
788 let svc = parse(
789 r#"
790[service]
791name = "x"
792description = "x"
793
794[[ports]]
795name = "a"
796container_port = 1
797tailscale_https = 443
798
799[[ports]]
800name = "b"
801container_port = 2
802tailscale_https = 443
803"#,
804 );
805 let err = svc.validate().expect_err("must reject");
806 assert!(err.contains("same tailscale_https"), "got: {err}");
807 }
808
809 #[test]
810 fn tailscale_https_one_root_plus_api_validates() {
811 let svc = parse(
812 r#"
813[service]
814name = "x"
815description = "x"
816
817[[ports]]
818name = "http"
819container_port = 8080
820tailscale_https = 8080
821
822[[ports]]
823name = "photos"
824container_port = 3000
825tailscale_https = 443
826"#,
827 );
828 svc.validate()
829 .expect("one 443 root + one api port is valid");
830 }
831
832 #[test]
833 fn backup_defaults_to_false_when_omitted() {
834 let svc = parse(
835 r#"
836[service]
837name = "x"
838description = "x"
839"#,
840 );
841 assert!(!svc.integrations.backup);
842 assert!(svc.backup.is_none());
843 svc.validate().expect("default is valid");
844 }
845
846 #[test]
847 fn backup_section_alone_is_rejected_without_integration_flag() {
848 let svc = parse(
849 r#"
850[service]
851name = "x"
852description = "x"
853
854[backup]
855"#,
856 );
857 let err = svc.validate().expect_err("must reject");
858 assert!(
859 err.contains("backup = true"),
860 "error mentions the required flag: {err}"
861 );
862 }
863
864 #[test]
865 fn backup_supported_without_hooks_validates() {
866 let svc = parse(
867 r#"
868[service]
869name = "x"
870description = "x"
871
872[integrations]
873backup = true
874"#,
875 );
876 assert!(svc.integrations.backup);
877 assert!(svc.backup.is_none());
878 svc.validate().expect("ok without [backup] table");
879 }
880
881 #[test]
882 fn backup_with_full_hooks_validates() {
883 let svc = parse(
884 r#"
885[service]
886name = "x"
887description = "x"
888
889[integrations]
890backup = true
891
892[backup]
893paths = [".backup/db.sql.gz", "data"]
894exclude = ["data/cache"]
895pre_backup = "backup-pre.sh"
896post_backup = "backup-post.sh"
897pre_restore = "restore-pre.sh"
898post_restore = "restore-post.sh"
899"#,
900 );
901 svc.validate().expect("ok");
902 let backup = svc.backup.as_ref().expect("section present");
903 assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
904 assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
905 }
906
907 #[test]
908 fn backup_hook_with_slash_is_rejected() {
909 let svc = parse(
910 r#"
911[service]
912name = "x"
913description = "x"
914
915[integrations]
916backup = true
917
918[backup]
919pre_backup = "subdir/script.sh"
920"#,
921 );
922 let err = svc.validate().expect_err("must reject");
923 assert!(err.contains("pre_backup"), "{err}");
924 }
925
926 #[test]
927 fn backup_hook_with_dotdot_is_rejected() {
928 let svc = parse(
929 r#"
930[service]
931name = "x"
932description = "x"
933
934[integrations]
935backup = true
936
937[backup]
938post_backup = "../escape.sh"
939"#,
940 );
941 let err = svc.validate().expect_err("must reject");
942 assert!(err.contains("post_backup"), "{err}");
943 }
944
945 #[test]
946 fn backup_absolute_path_is_rejected() {
947 let svc = parse(
948 r#"
949[service]
950name = "x"
951description = "x"
952
953[integrations]
954backup = true
955
956[backup]
957paths = ["/etc/passwd"]
958"#,
959 );
960 let err = svc.validate().expect_err("must reject");
961 assert!(err.contains("/etc/passwd"), "{err}");
962 }
963
964 #[test]
965 fn backup_path_with_dotdot_is_rejected() {
966 let svc = parse(
967 r#"
968[service]
969name = "x"
970description = "x"
971
972[integrations]
973backup = true
974
975[backup]
976paths = ["../../somewhere"]
977"#,
978 );
979 let err = svc.validate().expect_err("must reject");
980 assert!(err.contains("somewhere"), "{err}");
981 }
982}
983
984#[cfg(test)]
985mod https_requirement_tests {
986 use super::*;
987
988 #[test]
989 fn never_service_stays_http() {
990 assert!(!HttpsRequirement::Never.needs_https(false, None));
991 assert!(!HttpsRequirement::Never.needs_https(true, None));
996 assert!(!HttpsRequirement::Never.needs_https(true, Some("http://foo.example.com")));
998 }
999
1000 #[test]
1001 fn always_service_always_promotes() {
1002 assert!(HttpsRequirement::Always.needs_https(false, None));
1003 assert!(HttpsRequirement::Always.needs_https(false, Some("http://foo.example.com")));
1004 }
1005
1006 #[test]
1007 fn auth_service_promotes_only_with_auth() {
1008 assert!(HttpsRequirement::Auth.needs_https(true, None));
1012 assert!(!HttpsRequirement::Auth.needs_https(false, None));
1014 }
1015
1016 #[test]
1017 fn explicit_https_url_promotes() {
1018 assert!(HttpsRequirement::Never.needs_https(false, Some("https://foo.example.com")));
1019 }
1020}