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, rename = "choice")]
27 pub choices: Vec<Choice>,
28 #[serde(default)]
29 pub requires: Vec<ServiceRequirement>,
30 #[serde(default)]
31 pub mappings: Mappings,
32 #[serde(default)]
33 pub integrations: IntegrationFlags,
34 #[serde(default)]
38 pub capabilities: Capabilities,
39 #[serde(default)]
44 pub backup: Option<BackupConfig>,
45 #[serde(default)]
49 pub metrics: Option<MetricsDef>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct MetricsDef {
55 pub port: String,
59 #[serde(default = "default_metrics_path")]
61 pub path: String,
62 #[serde(default)]
68 pub host_network: bool,
69}
70
71fn default_metrics_path() -> String {
72 "/metrics".to_string()
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct Capabilities {
78 #[serde(default)]
80 pub provides: Vec<Capability>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Requirements {
86 pub ram: RamRequirement,
88 #[serde(default)]
90 pub disk: Option<DiskRequirement>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct RamRequirement {
96 pub min: u64,
98 #[serde(default)]
100 pub recommended: Option<u64>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct DiskRequirement {
106 pub min: u32,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ServiceMeta {
112 pub name: String,
113 pub description: String,
114 #[serde(default)]
116 pub url: Option<String>,
117 #[serde(default)]
118 pub kind: ServiceKind,
119 #[serde(default)]
122 pub architecture: Vec<Arch>,
123 #[serde(default)]
125 pub https: HttpsRequirement,
126 #[serde(default)]
129 pub runtime: Runtime,
130 #[serde(default)]
136 pub run: Option<String>,
137 #[serde(default)]
141 pub build: Option<String>,
142 #[serde(default)]
147 pub post_install: Option<String>,
148 #[serde(default, skip_serializing_if = "DeployStrategy::is_restart")]
152 pub deploy: DeployStrategy,
153 #[serde(default)]
159 pub health_check: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub health_timeout: Option<u32>,
167}
168
169pub const DEFAULT_HEALTH_TIMEOUT_SECS: u32 = 120;
173
174impl ServiceMeta {
175 pub fn health_timeout_secs(&self) -> u32 {
179 self.health_timeout.unwrap_or(DEFAULT_HEALTH_TIMEOUT_SECS)
180 }
181}
182
183#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
185#[serde(rename_all = "lowercase")]
186pub enum ServiceKind {
187 #[default]
188 Application,
189 Infrastructure,
190}
191
192#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
194#[serde(rename_all = "lowercase")]
195pub enum Runtime {
196 #[default]
199 Podman,
200 Native,
204}
205
206impl Runtime {
207 pub fn is_podman(&self) -> bool {
211 matches!(self, Runtime::Podman)
212 }
213}
214
215#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
221#[serde(rename_all = "kebab-case")]
222pub enum DeployStrategy {
223 #[default]
227 Restart,
228 BlueGreen,
235}
236
237impl DeployStrategy {
238 pub fn is_restart(&self) -> bool {
242 matches!(self, DeployStrategy::Restart)
243 }
244}
245
246#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
251#[serde(rename_all = "lowercase")]
252pub enum Color {
253 Blue,
254 Green,
255}
256
257impl Color {
258 pub fn other(self) -> Color {
260 match self {
261 Color::Blue => Color::Green,
262 Color::Green => Color::Blue,
263 }
264 }
265
266 pub fn as_str(self) -> &'static str {
269 match self {
270 Color::Blue => "blue",
271 Color::Green => "green",
272 }
273 }
274}
275
276impl std::fmt::Display for Color {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 f.write_str(self.as_str())
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284#[serde(rename_all = "lowercase")]
285pub enum Arch {
286 Amd64,
287 Arm64,
288}
289
290impl std::fmt::Display for Arch {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 match self {
293 Arch::Amd64 => write!(f, "amd64"),
294 Arch::Arm64 => write!(f, "arm64"),
295 }
296 }
297}
298
299#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(rename_all = "lowercase")]
314pub enum HttpsRequirement {
315 #[default]
316 Never,
317 Auth,
318 Always,
319}
320
321impl HttpsRequirement {
322 pub fn needs_https(&self, auth_requested: bool, url: Option<&str>) -> bool {
330 matches!(self, HttpsRequirement::Always)
331 || (matches!(self, HttpsRequirement::Auth) && auth_requested)
332 || url.is_some_and(|u| u.starts_with("https://"))
333 }
334}
335
336#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
338#[serde(rename_all = "lowercase")]
339pub enum PortProtocol {
340 #[default]
341 Tcp,
342 Udp,
343}
344
345impl std::fmt::Display for PortProtocol {
346 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347 match self {
348 PortProtocol::Tcp => write!(f, "tcp"),
349 PortProtocol::Udp => write!(f, "udp"),
350 }
351 }
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct PortDef {
356 pub name: String,
357 pub container_port: u16,
358 #[serde(default)]
361 pub host_port: Option<u16>,
362 #[serde(default)]
363 pub protocol: PortProtocol,
364 #[serde(default)]
373 pub tailscale_https: Option<u16>,
374}
375
376#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
385#[serde(rename_all = "lowercase")]
386pub enum EnvKind {
387 #[default]
389 Default,
390 Prompted,
392 Required,
395}
396
397#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
399#[serde(rename_all = "snake_case")]
400pub enum EnvFormat {
401 #[default]
403 String,
404 Hex,
406 Base64,
411 Base64Url,
415 Uuid,
417 JwtHs256,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct EnvVar {
423 pub name: String,
424 pub value: String,
425 #[serde(default)]
426 pub kind: EnvKind,
427 #[serde(default)]
429 pub prompt: Option<String>,
430 #[serde(default)]
432 pub format: EnvFormat,
433 #[serde(default)]
436 pub length: Option<u32>,
437 #[serde(default)]
440 pub jwt_claims: Option<std::collections::BTreeMap<std::string::String, serde_json::Value>>,
441 #[serde(default)]
443 pub jwt_signing_key: Option<std::string::String>,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct EnvGroup {
455 pub name: String,
458 pub prompt: String,
460 #[serde(default)]
461 pub env: Vec<EnvVar>,
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct Choice {
472 pub name: String,
475 pub prompt: String,
477 pub default: String,
480 #[serde(default, rename = "option")]
481 pub options: Vec<ChoiceOption>,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct ChoiceOption {
489 pub name: String,
491 #[serde(default)]
493 pub label: Option<String>,
494 #[serde(default)]
495 pub env: Vec<EnvVar>,
496 #[serde(default)]
503 pub quadlets: Vec<String>,
504 #[serde(default, rename = "ports")]
510 pub ports: Vec<PortDef>,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct ServiceRequirement {
519 pub service: String,
520}
521
522#[derive(Debug, Clone, Default, Serialize, Deserialize)]
523pub struct Mappings {
524 #[serde(default)]
525 pub smtp: BTreeMap<String, String>,
526 #[serde(default)]
527 pub auth: BTreeMap<String, String>,
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
532#[serde(rename_all = "kebab-case")]
533pub enum AuthKind {
534 Oidc,
536}
537
538impl std::fmt::Display for AuthKind {
539 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540 match self {
541 AuthKind::Oidc => write!(f, "oidc"),
542 }
543 }
544}
545
546#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
548#[serde(rename_all = "snake_case")]
549pub enum TokenAuthMethod {
550 #[default]
551 ClientSecretPost,
552 ClientSecretBasic,
553 None,
556}
557
558impl TokenAuthMethod {
559 pub fn as_str(&self) -> &'static str {
560 match self {
561 TokenAuthMethod::ClientSecretPost => "client_secret_post",
562 TokenAuthMethod::ClientSecretBasic => "client_secret_basic",
563 TokenAuthMethod::None => "none",
564 }
565 }
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
569pub struct IntegrationFlags {
570 #[serde(default)]
572 pub auth: Vec<AuthKind>,
573 #[serde(default)]
575 pub token_auth_method: TokenAuthMethod,
576 #[serde(default)]
579 pub oidc_callbacks: Vec<String>,
580 #[serde(default = "default_true")]
581 pub smtp: bool,
582 #[serde(default)]
593 pub backup: bool,
594}
595
596impl Default for IntegrationFlags {
597 fn default() -> Self {
598 Self {
599 auth: vec![],
600 token_auth_method: TokenAuthMethod::default(),
601 oidc_callbacks: vec![],
602 smtp: true,
603 backup: false,
604 }
605 }
606}
607
608fn default_true() -> bool {
609 true
610}
611
612#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
633pub struct BackupConfig {
634 #[serde(default)]
638 pub paths: Vec<String>,
639 #[serde(default)]
642 pub exclude: Vec<String>,
643 #[serde(default)]
646 pub pre_backup: Option<String>,
647 #[serde(default)]
650 pub post_backup: Option<String>,
651 #[serde(default)]
654 pub pre_restore: Option<String>,
655 #[serde(default)]
658 pub post_restore: Option<String>,
659}
660
661impl ServiceDef {
666 pub fn check_architecture(&self) -> Option<String> {
669 if self.service.architecture.is_empty() {
670 return None;
671 }
672 let current = current_architecture();
673 if self.service.architecture.contains(¤t) {
674 None
675 } else {
676 let supported: Vec<_> = self
677 .service
678 .architecture
679 .iter()
680 .map(|a| a.to_string())
681 .collect();
682 Some(format!(
683 "{} only supports {} — this system is {current}",
684 self.service.name,
685 supported.join(", "),
686 ))
687 }
688 }
689
690 pub fn required_env_vars(&self) -> Vec<&str> {
692 self.env
693 .iter()
694 .filter(|e| e.kind == EnvKind::Required)
695 .map(|e| e.name.as_str())
696 .collect()
697 }
698
699 pub fn validate(&self) -> Result<(), String> {
703 let name = &self.service.name;
704 let mut errors: Vec<String> = Vec::new();
705
706 let mut seen_ports = std::collections::HashSet::new();
709 let mut seen_ts_https = std::collections::HashSet::new();
710 for p in &self.ports {
711 if !seen_ports.insert(&p.name) {
712 errors.push(format!("duplicate port name '{}'", p.name));
713 }
714 if p.container_port == 0 {
717 errors.push(format!(
718 "port '{}' has container_port = 0 — fill in the port your service listens on",
719 p.name
720 ));
721 }
722 if let Some(https) = p.tailscale_https
725 && !seen_ts_https.insert(https)
726 {
727 errors.push(format!(
728 "two ports map to the same tailscale_https port {https}"
729 ));
730 }
731 }
732 let ts_ports: Vec<&PortDef> = self
735 .ports
736 .iter()
737 .filter(|p| p.tailscale_https.is_some())
738 .collect();
739 if !ts_ports.is_empty()
740 && ts_ports
741 .iter()
742 .filter(|p| p.tailscale_https == Some(443))
743 .count()
744 != 1
745 {
746 errors.push(
747 "services exposing ports over Tailscale must mark exactly one port \
748 tailscale_https = 443 (the web root)"
749 .to_string(),
750 );
751 }
752
753 if let Some(metrics) = &self.metrics
756 && !self.ports.iter().any(|p| p.name == metrics.port)
757 {
758 errors.push(format!(
759 "[metrics] references port '{}' but no [[ports]] entry has that name",
760 metrics.port
761 ));
762 }
763
764 let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
768 for e in &self.env {
769 if !seen_envs.insert(&e.name) {
770 errors.push(format!("duplicate env var name '{}'", e.name));
771 }
772 }
773 for g in &self.env_groups {
774 for e in &g.env {
775 if !seen_envs.insert(&e.name) {
776 errors.push(format!(
777 "env var '{}' in group '{}' collides with another env var",
778 e.name, g.name
779 ));
780 }
781 }
782 }
783 for c in &self.choices {
792 let mut choice_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
793 for o in &c.options {
794 let mut option_envs: std::collections::HashSet<&str> =
795 std::collections::HashSet::new();
796 for e in &o.env {
797 if !option_envs.insert(e.name.as_str()) {
798 errors.push(format!(
799 "env var '{}' is declared twice in choice '{}' option '{}'",
800 e.name, c.name, o.name
801 ));
802 }
803 if seen_envs.contains(e.name.as_str()) {
804 errors.push(format!(
805 "env var '{}' in choice '{}' option '{}' collides with another env var",
806 e.name, c.name, o.name
807 ));
808 }
809 choice_envs.insert(e.name.as_str());
810 }
811 }
812 seen_envs.extend(choice_envs);
813 }
814
815 for e in &self.env {
818 check_env_var(e, EnvLoc::TopLevel, &mut errors);
819 }
820
821 let mut seen_groups = std::collections::HashSet::new();
824 for g in &self.env_groups {
825 if !seen_groups.insert(&g.name) {
826 errors.push(format!("duplicate env_group name '{}'", g.name));
827 }
828 if g.name.is_empty() {
829 errors.push("env_group has empty name".to_string());
830 } else if !g
831 .name
832 .chars()
833 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
834 {
835 errors.push(format!(
836 "env_group '{}' must be lowercase snake_case ([a-z0-9_])",
837 g.name
838 ));
839 }
840 if g.prompt.is_empty() {
841 errors.push(format!("env_group '{}' has empty prompt", g.name));
842 }
843 if g.env.is_empty() {
844 errors.push(format!("env_group '{}' has no env vars", g.name));
845 }
846 for e in &g.env {
847 check_env_var(e, EnvLoc::Group(&g.name), &mut errors);
848 }
849 }
850
851 let mut seen_choices: std::collections::HashSet<&str> = std::collections::HashSet::new();
858 for c in &self.choices {
859 if !seen_choices.insert(c.name.as_str()) {
860 errors.push(format!("duplicate choice name '{}'", c.name));
861 }
862 if self.env_groups.iter().any(|g| g.name == c.name) {
863 errors.push(format!(
864 "choice '{}' shares a name with an env_group; names must be distinct",
865 c.name
866 ));
867 }
868 if c.name.is_empty() {
869 errors.push("choice has empty name".to_string());
870 } else if !c
871 .name
872 .chars()
873 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
874 {
875 errors.push(format!(
876 "choice '{}' must be lowercase snake_case ([a-z0-9_])",
877 c.name
878 ));
879 }
880 if c.prompt.is_empty() {
881 errors.push(format!("choice '{}' has empty prompt", c.name));
882 }
883 if c.options.len() < 2 {
885 errors.push(format!(
886 "choice '{}' has {} option(s); a choice needs at least two",
887 c.name,
888 c.options.len()
889 ));
890 }
891 let mut seen_options: std::collections::HashSet<&str> =
892 std::collections::HashSet::new();
893 for o in &c.options {
894 if !seen_options.insert(o.name.as_str()) {
895 errors.push(format!(
896 "duplicate option '{}' in choice '{}'",
897 o.name, c.name
898 ));
899 }
900 if o.name.is_empty() {
901 errors.push(format!("choice '{}' has an option with empty name", c.name));
902 } else if !o
903 .name
904 .chars()
905 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
906 {
907 errors.push(format!(
908 "option '{}' in choice '{}' must be lowercase snake_case ([a-z0-9_])",
909 o.name, c.name
910 ));
911 }
912 for e in &o.env {
913 check_env_var(
914 e,
915 EnvLoc::ChoiceOption {
916 choice: &c.name,
917 option: &o.name,
918 },
919 &mut errors,
920 );
921 }
922 }
923 if !c.options.iter().any(|o| o.name == c.default) {
926 errors.push(format!(
927 "choice '{}' default '{}' names no option (have: {})",
928 c.name,
929 c.default,
930 c.options
931 .iter()
932 .map(|o| o.name.as_str())
933 .collect::<Vec<_>>()
934 .join(", ")
935 ));
936 }
937 }
938
939 if let Some(ref req) = self.requirements
942 && let Some(rec) = req.ram.recommended
943 && rec < req.ram.min
944 {
945 errors.push(format!(
946 "recommended RAM ({rec}MB) is less than minimum ({}MB)",
947 req.ram.min
948 ));
949 }
950
951 if let Some(ref backup) = self.backup
959 && !self.integrations.backup
960 {
961 errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
962 let _ = backup;
965 }
966 if let Some(ref backup) = self.backup {
967 for (label, hook) in [
968 ("pre_backup", &backup.pre_backup),
969 ("post_backup", &backup.post_backup),
970 ("pre_restore", &backup.pre_restore),
971 ("post_restore", &backup.post_restore),
972 ] {
973 if let Some(script) = hook
974 && (script.is_empty() || script.contains('/') || script.contains(".."))
975 {
976 errors.push(format!(
977 "backup hook '{label}' must be a bare filename under configs/scripts/ \
978 (got {script:?})"
979 ));
980 }
981 }
982 for p in &backup.paths {
983 if p.is_empty() || p.starts_with('/') || p.contains("..") {
984 errors.push(format!(
985 "backup path {p:?} must be a relative path within the service home"
986 ));
987 }
988 }
989 }
990
991 match self.service.runtime {
996 Runtime::Native => match &self.service.run {
997 None => errors.push(
998 "runtime = \"native\" requires a `run` command under [service]".to_string(),
999 ),
1000 Some(run) if run.trim().is_empty() => {
1001 errors.push("[service].run must not be empty".to_string())
1002 }
1003 Some(_) => {}
1004 },
1005 Runtime::Podman => {
1006 if self.service.run.is_some() || self.service.build.is_some() {
1007 errors.push(
1008 "`run` / `build` are only valid for runtime = \"native\" services"
1009 .to_string(),
1010 );
1011 }
1012 }
1013 }
1014
1015 if self.service.deploy == DeployStrategy::BlueGreen {
1023 if self.ports.is_empty() {
1024 errors.push(
1025 "deploy = \"blue-green\" requires at least one [[ports]] entry to route"
1026 .to_string(),
1027 );
1028 }
1029 match self.service.health_check.as_deref() {
1030 None => errors.push(
1031 "deploy = \"blue-green\" requires a `health_check` path under [service]"
1032 .to_string(),
1033 ),
1034 Some(p) if !p.starts_with('/') => errors.push(format!(
1035 "`health_check` must be an absolute path starting with '/', got {p:?}"
1036 )),
1037 Some(_) => {}
1038 }
1039 if self.service.health_timeout == Some(0) {
1040 errors.push(
1041 "`health_timeout` must be greater than 0 seconds (omit it for the default)"
1042 .to_string(),
1043 );
1044 }
1045 }
1046
1047 if errors.is_empty() {
1048 Ok(())
1049 } else {
1050 Err(format!("{name}: {}", errors.join("; ")))
1051 }
1052 }
1053}
1054
1055enum EnvLoc<'a> {
1059 TopLevel,
1060 Group(&'a str),
1061 ChoiceOption { choice: &'a str, option: &'a str },
1062}
1063
1064impl std::fmt::Display for EnvLoc<'_> {
1065 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1066 match self {
1067 EnvLoc::TopLevel => Ok(()),
1068 EnvLoc::Group(g) => write!(f, " in group '{g}'"),
1069 EnvLoc::ChoiceOption { choice, option } => {
1070 write!(f, " in choice '{choice}' option '{option}'")
1071 }
1072 }
1073 }
1074}
1075
1076fn check_env_var(e: &EnvVar, loc: EnvLoc, errors: &mut Vec<String>) {
1080 if e.name.is_empty() {
1081 errors.push(format!("env var has empty name{loc}"));
1082 } else if !e
1083 .name
1084 .chars()
1085 .next()
1086 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1087 {
1088 errors.push(format!(
1089 "env var '{}'{loc} must start with a letter or _",
1090 e.name
1091 ));
1092 } else if !e
1093 .name
1094 .chars()
1095 .all(|c| c.is_ascii_alphanumeric() || c == '_')
1096 {
1097 errors.push(format!(
1098 "env var '{}'{loc} contains invalid characters (must match [A-Za-z0-9_])",
1099 e.name
1100 ));
1101 }
1102 if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
1103 errors.push(format!(
1104 "env var '{}'{loc} is kind=required but has a secret template default; use kind=prompted or kind=default",
1105 e.name
1106 ));
1107 }
1108}
1109
1110pub fn current_architecture() -> Arch {
1112 match std::env::consts::ARCH {
1113 "x86_64" => Arch::Amd64,
1114 "aarch64" => Arch::Arm64,
1115 _ => Arch::Amd64,
1118 }
1119}
1120
1121#[cfg(test)]
1122mod backup_tests {
1123 use super::*;
1124
1125 fn parse(toml_src: &str) -> ServiceDef {
1126 toml::from_str(toml_src).expect("parse")
1127 }
1128
1129 #[test]
1130 fn blue_green_requires_health_check() {
1131 let svc = parse(
1132 r#"
1133[service]
1134name = "x"
1135description = "x"
1136deploy = "blue-green"
1137
1138[[ports]]
1139name = "http"
1140container_port = 8080
1141"#,
1142 );
1143 let err = svc.validate().expect_err("must reject");
1144 assert!(err.contains("health_check"), "got: {err}");
1145 }
1146
1147 #[test]
1148 fn blue_green_health_check_must_be_absolute_path() {
1149 let svc = parse(
1150 r#"
1151[service]
1152name = "x"
1153description = "x"
1154deploy = "blue-green"
1155health_check = "healthz"
1156
1157[[ports]]
1158name = "http"
1159container_port = 8080
1160"#,
1161 );
1162 let err = svc.validate().expect_err("must reject");
1163 assert!(err.contains("absolute path"), "got: {err}");
1164 }
1165
1166 #[test]
1167 fn blue_green_requires_a_port() {
1168 let svc = parse(
1169 r#"
1170[service]
1171name = "x"
1172description = "x"
1173deploy = "blue-green"
1174health_check = "/healthz"
1175"#,
1176 );
1177 let err = svc.validate().expect_err("must reject");
1178 assert!(err.contains("[[ports]]"), "got: {err}");
1179 }
1180
1181 #[test]
1182 fn blue_green_with_port_and_health_check_validates() {
1183 let svc = parse(
1184 r#"
1185[service]
1186name = "x"
1187description = "x"
1188deploy = "blue-green"
1189health_check = "/healthz"
1190
1191[[ports]]
1192name = "http"
1193container_port = 8080
1194"#,
1195 );
1196 assert!(svc.validate().is_ok());
1197 assert_eq!(svc.service.deploy, DeployStrategy::BlueGreen);
1198 }
1199
1200 #[test]
1201 fn health_timeout_defaults_to_120_and_honors_override() {
1202 let default = parse(
1203 r#"
1204[service]
1205name = "x"
1206description = "x"
1207deploy = "blue-green"
1208health_check = "/healthz"
1209
1210[[ports]]
1211name = "http"
1212container_port = 8080
1213"#,
1214 );
1215 assert_eq!(default.service.health_timeout, None);
1216 assert_eq!(default.service.health_timeout_secs(), 120);
1217
1218 let custom = parse(
1219 r#"
1220[service]
1221name = "x"
1222description = "x"
1223deploy = "blue-green"
1224health_check = "/healthz"
1225health_timeout = 300
1226
1227[[ports]]
1228name = "http"
1229container_port = 8080
1230"#,
1231 );
1232 assert_eq!(custom.service.health_timeout_secs(), 300);
1233 assert!(custom.validate().is_ok());
1234 }
1235
1236 #[test]
1237 fn health_timeout_zero_is_rejected() {
1238 let svc = parse(
1239 r#"
1240[service]
1241name = "x"
1242description = "x"
1243deploy = "blue-green"
1244health_check = "/healthz"
1245health_timeout = 0
1246
1247[[ports]]
1248name = "http"
1249container_port = 8080
1250"#,
1251 );
1252 let err = svc.validate().expect_err("must reject");
1253 assert!(err.contains("health_timeout"), "got: {err}");
1254 }
1255
1256 #[test]
1257 fn deploy_defaults_to_restart_and_is_omitted_when_serialized() {
1258 let svc = parse(
1261 r#"
1262[service]
1263name = "x"
1264description = "x"
1265
1266[[ports]]
1267name = "http"
1268container_port = 8080
1269"#,
1270 );
1271 assert_eq!(svc.service.deploy, DeployStrategy::Restart);
1272 let text = toml::to_string(&svc.service).expect("serialize ServiceMeta");
1273 assert!(!text.contains("deploy"), "got: {text}");
1274 }
1275
1276 #[test]
1277 fn tailscale_https_requires_exactly_one_root() {
1278 let svc = parse(
1280 r#"
1281[service]
1282name = "x"
1283description = "x"
1284
1285[[ports]]
1286name = "http"
1287container_port = 8080
1288tailscale_https = 8080
1289
1290[[ports]]
1291name = "photos"
1292container_port = 3000
1293tailscale_https = 3000
1294"#,
1295 );
1296 let err = svc.validate().expect_err("must reject");
1297 assert!(err.contains("tailscale_https = 443"), "got: {err}");
1298 }
1299
1300 #[test]
1301 fn tailscale_https_duplicate_port_rejected() {
1302 let svc = parse(
1303 r#"
1304[service]
1305name = "x"
1306description = "x"
1307
1308[[ports]]
1309name = "a"
1310container_port = 1
1311tailscale_https = 443
1312
1313[[ports]]
1314name = "b"
1315container_port = 2
1316tailscale_https = 443
1317"#,
1318 );
1319 let err = svc.validate().expect_err("must reject");
1320 assert!(err.contains("same tailscale_https"), "got: {err}");
1321 }
1322
1323 #[test]
1324 fn tailscale_https_one_root_plus_api_validates() {
1325 let svc = parse(
1326 r#"
1327[service]
1328name = "x"
1329description = "x"
1330
1331[[ports]]
1332name = "http"
1333container_port = 8080
1334tailscale_https = 8080
1335
1336[[ports]]
1337name = "photos"
1338container_port = 3000
1339tailscale_https = 443
1340"#,
1341 );
1342 svc.validate()
1343 .expect("one 443 root + one api port is valid");
1344 }
1345
1346 #[test]
1347 fn backup_defaults_to_false_when_omitted() {
1348 let svc = parse(
1349 r#"
1350[service]
1351name = "x"
1352description = "x"
1353"#,
1354 );
1355 assert!(!svc.integrations.backup);
1356 assert!(svc.backup.is_none());
1357 svc.validate().expect("default is valid");
1358 }
1359
1360 #[test]
1361 fn backup_section_alone_is_rejected_without_integration_flag() {
1362 let svc = parse(
1363 r#"
1364[service]
1365name = "x"
1366description = "x"
1367
1368[backup]
1369"#,
1370 );
1371 let err = svc.validate().expect_err("must reject");
1372 assert!(
1373 err.contains("backup = true"),
1374 "error mentions the required flag: {err}"
1375 );
1376 }
1377
1378 #[test]
1379 fn backup_supported_without_hooks_validates() {
1380 let svc = parse(
1381 r#"
1382[service]
1383name = "x"
1384description = "x"
1385
1386[integrations]
1387backup = true
1388"#,
1389 );
1390 assert!(svc.integrations.backup);
1391 assert!(svc.backup.is_none());
1392 svc.validate().expect("ok without [backup] table");
1393 }
1394
1395 #[test]
1396 fn backup_with_full_hooks_validates() {
1397 let svc = parse(
1398 r#"
1399[service]
1400name = "x"
1401description = "x"
1402
1403[integrations]
1404backup = true
1405
1406[backup]
1407paths = [".backup/db.sql.gz", "data"]
1408exclude = ["data/cache"]
1409pre_backup = "backup-pre.sh"
1410post_backup = "backup-post.sh"
1411pre_restore = "restore-pre.sh"
1412post_restore = "restore-post.sh"
1413"#,
1414 );
1415 svc.validate().expect("ok");
1416 let backup = svc.backup.as_ref().expect("section present");
1417 assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
1418 assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
1419 }
1420
1421 #[test]
1422 fn backup_hook_with_slash_is_rejected() {
1423 let svc = parse(
1424 r#"
1425[service]
1426name = "x"
1427description = "x"
1428
1429[integrations]
1430backup = true
1431
1432[backup]
1433pre_backup = "subdir/script.sh"
1434"#,
1435 );
1436 let err = svc.validate().expect_err("must reject");
1437 assert!(err.contains("pre_backup"), "{err}");
1438 }
1439
1440 #[test]
1441 fn backup_hook_with_dotdot_is_rejected() {
1442 let svc = parse(
1443 r#"
1444[service]
1445name = "x"
1446description = "x"
1447
1448[integrations]
1449backup = true
1450
1451[backup]
1452post_backup = "../escape.sh"
1453"#,
1454 );
1455 let err = svc.validate().expect_err("must reject");
1456 assert!(err.contains("post_backup"), "{err}");
1457 }
1458
1459 #[test]
1460 fn backup_absolute_path_is_rejected() {
1461 let svc = parse(
1462 r#"
1463[service]
1464name = "x"
1465description = "x"
1466
1467[integrations]
1468backup = true
1469
1470[backup]
1471paths = ["/etc/passwd"]
1472"#,
1473 );
1474 let err = svc.validate().expect_err("must reject");
1475 assert!(err.contains("/etc/passwd"), "{err}");
1476 }
1477
1478 #[test]
1479 fn backup_path_with_dotdot_is_rejected() {
1480 let svc = parse(
1481 r#"
1482[service]
1483name = "x"
1484description = "x"
1485
1486[integrations]
1487backup = true
1488
1489[backup]
1490paths = ["../../somewhere"]
1491"#,
1492 );
1493 let err = svc.validate().expect_err("must reject");
1494 assert!(err.contains("somewhere"), "{err}");
1495 }
1496}
1497
1498#[cfg(test)]
1499mod https_requirement_tests {
1500 use super::*;
1501
1502 fn parse(toml_src: &str) -> ServiceDef {
1503 toml::from_str(toml_src).expect("parse")
1504 }
1505
1506 #[test]
1511 fn all_registry_services_parse_and_validate() {
1512 let registry = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../registry");
1513 if !registry.is_dir() {
1514 eprintln!("registry dir not found ({}); skipping", registry.display());
1515 return;
1516 }
1517 let mut failures = Vec::new();
1518 let entries = std::fs::read_dir(®istry).expect("read registry dir");
1519 for entry in entries {
1520 let entry = entry.expect("dir entry");
1521 let svc_toml = entry.path().join("service.toml");
1522 if !svc_toml.is_file() {
1523 continue;
1524 }
1525 let name = entry.file_name().to_string_lossy().into_owned();
1526 let text = std::fs::read_to_string(&svc_toml).expect("read service.toml");
1527 match toml::from_str::<ServiceDef>(&text) {
1528 Ok(def) => {
1529 if let Err(e) = def.validate() {
1530 failures.push(format!("{name}: validate: {e}"));
1531 }
1532 }
1533 Err(e) => failures.push(format!("{name}: parse: {e}")),
1534 }
1535 }
1536 assert!(
1537 failures.is_empty(),
1538 "registry service.toml failures:\n {}",
1539 failures.join("\n ")
1540 );
1541 }
1542
1543 #[test]
1544 fn never_service_stays_http() {
1545 assert!(!HttpsRequirement::Never.needs_https(false, None));
1546 assert!(!HttpsRequirement::Never.needs_https(true, None));
1551 assert!(!HttpsRequirement::Never.needs_https(true, Some("http://foo.example.com")));
1553 }
1554
1555 #[test]
1556 fn always_service_always_promotes() {
1557 assert!(HttpsRequirement::Always.needs_https(false, None));
1558 assert!(HttpsRequirement::Always.needs_https(false, Some("http://foo.example.com")));
1559 }
1560
1561 #[test]
1562 fn auth_service_promotes_only_with_auth() {
1563 assert!(HttpsRequirement::Auth.needs_https(true, None));
1567 assert!(!HttpsRequirement::Auth.needs_https(false, None));
1569 }
1570
1571 #[test]
1572 fn explicit_https_url_promotes() {
1573 assert!(HttpsRequirement::Never.needs_https(false, Some("https://foo.example.com")));
1574 }
1575
1576 const BILLING_CHOICE: &str = r#"
1579[service]
1580name = "x"
1581description = "x"
1582
1583[[choice]]
1584name = "billing"
1585prompt = "Billing mode"
1586default = "mock"
1587
1588[[choice.option]]
1589name = "live"
1590label = "Stripe"
1591[[choice.option.env]]
1592name = "BILLING_MODE"
1593value = "live"
1594[[choice.option.env]]
1595name = "STRIPE_SECRET_KEY"
1596value = ""
1597kind = "required"
1598
1599[[choice.option]]
1600name = "mock"
1601[[choice.option.env]]
1602name = "BILLING_MODE"
1603value = "mock"
1604"#;
1605
1606 #[test]
1607 fn valid_choice_validates() {
1608 parse(BILLING_CHOICE)
1609 .validate()
1610 .expect("a well-formed choice is valid");
1611 }
1612
1613 #[test]
1614 fn choice_option_carries_quadlets() {
1615 let def = parse(
1616 r#"
1617[service]
1618name = "x"
1619description = "x"
1620
1621[[choice]]
1622name = "database"
1623prompt = "Database"
1624default = "internal"
1625
1626[[choice.option]]
1627name = "internal"
1628quadlets = ["x-postgres.container"]
1629[[choice.option.env]]
1630name = "DATABASE_URL"
1631value = "postgres://ryra@postgres/x"
1632
1633[[choice.option]]
1634name = "external"
1635[[choice.option.env]]
1636name = "DATABASE_URL"
1637value = ""
1638kind = "required"
1639"#,
1640 );
1641 def.validate().expect("valid");
1642 let internal = &def.choices[0].options[0];
1643 assert_eq!(internal.quadlets, vec!["x-postgres.container".to_string()]);
1644 assert!(def.choices[0].options[1].quadlets.is_empty());
1645 }
1646
1647 #[test]
1648 fn sibling_options_may_reuse_an_env_name() {
1649 let def = parse(BILLING_CHOICE);
1652 let billing = &def.choices[0];
1653 assert!(
1654 billing
1655 .options
1656 .iter()
1657 .all(|o| o.env.iter().any(|e| e.name == "BILLING_MODE"))
1658 );
1659 def.validate().expect("sibling reuse is allowed");
1660 }
1661
1662 #[test]
1663 fn choice_needs_at_least_two_options() {
1664 let svc = parse(
1665 r#"
1666[service]
1667name = "x"
1668description = "x"
1669
1670[[choice]]
1671name = "billing"
1672prompt = "p"
1673default = "only"
1674
1675[[choice.option]]
1676name = "only"
1677"#,
1678 );
1679 let err = svc.validate().expect_err("one option is not a choice");
1680 assert!(err.contains("at least two"), "got: {err}");
1681 }
1682
1683 #[test]
1684 fn choice_default_must_name_an_option() {
1685 let svc = parse(
1686 r#"
1687[service]
1688name = "x"
1689description = "x"
1690
1691[[choice]]
1692name = "billing"
1693prompt = "p"
1694default = "nope"
1695
1696[[choice.option]]
1697name = "live"
1698[[choice.option]]
1699name = "mock"
1700"#,
1701 );
1702 let err = svc.validate().expect_err("bad default rejected");
1703 assert!(err.contains("names no option"), "got: {err}");
1704 }
1705
1706 #[test]
1707 fn duplicate_option_name_rejected() {
1708 let svc = parse(
1709 r#"
1710[service]
1711name = "x"
1712description = "x"
1713
1714[[choice]]
1715name = "billing"
1716prompt = "p"
1717default = "live"
1718
1719[[choice.option]]
1720name = "live"
1721[[choice.option]]
1722name = "live"
1723"#,
1724 );
1725 let err = svc.validate().expect_err("dup option rejected");
1726 assert!(err.contains("duplicate option"), "got: {err}");
1727 }
1728
1729 #[test]
1730 fn two_choices_sharing_an_env_name_collide() {
1731 let svc = parse(
1734 r#"
1735[service]
1736name = "x"
1737description = "x"
1738
1739[[choice]]
1740name = "a"
1741prompt = "p"
1742default = "one"
1743[[choice.option]]
1744name = "one"
1745[[choice.option.env]]
1746name = "SHARED"
1747value = "1"
1748[[choice.option]]
1749name = "two"
1750
1751[[choice]]
1752name = "b"
1753prompt = "p"
1754default = "one"
1755[[choice.option]]
1756name = "one"
1757[[choice.option.env]]
1758name = "SHARED"
1759value = "2"
1760[[choice.option]]
1761name = "two"
1762"#,
1763 );
1764 let err = svc.validate().expect_err("cross-choice collision rejected");
1765 assert!(err.contains("collides"), "got: {err}");
1766 }
1767
1768 #[test]
1769 fn choice_name_colliding_with_group_rejected() {
1770 let svc = parse(
1771 r#"
1772[service]
1773name = "x"
1774description = "x"
1775
1776[[env_group]]
1777name = "billing"
1778prompt = "p"
1779[[env_group.env]]
1780name = "FOO"
1781value = "1"
1782
1783[[choice]]
1784name = "billing"
1785prompt = "p"
1786default = "live"
1787[[choice.option]]
1788name = "live"
1789[[choice.option]]
1790name = "mock"
1791"#,
1792 );
1793 let err = svc.validate().expect_err("name clash rejected");
1794 assert!(err.contains("shares a name"), "got: {err}");
1795 }
1796}