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)]
648pub struct BackupConfig {
649 #[serde(default)]
653 pub paths: Vec<String>,
654 #[serde(default)]
657 pub exclude: Vec<String>,
658 #[serde(default)]
664 pub online: bool,
665 #[serde(default)]
669 pub pre_backup: Option<String>,
670 #[serde(default)]
674 pub post_backup: Option<String>,
675 #[serde(default)]
679 pub pre_restore: Option<String>,
680 #[serde(default)]
685 pub post_restore: Option<String>,
686}
687
688impl ServiceDef {
693 pub fn check_architecture(&self) -> Option<String> {
696 if self.service.architecture.is_empty() {
697 return None;
698 }
699 let current = current_architecture();
700 if self.service.architecture.contains(¤t) {
701 None
702 } else {
703 let supported: Vec<_> = self
704 .service
705 .architecture
706 .iter()
707 .map(|a| a.to_string())
708 .collect();
709 Some(format!(
710 "{} only supports {} — this system is {current}",
711 self.service.name,
712 supported.join(", "),
713 ))
714 }
715 }
716
717 pub fn required_env_vars(&self) -> Vec<&str> {
719 self.env
720 .iter()
721 .filter(|e| e.kind == EnvKind::Required)
722 .map(|e| e.name.as_str())
723 .collect()
724 }
725
726 pub fn validate(&self) -> Result<(), String> {
730 let name = &self.service.name;
731 let mut errors: Vec<String> = Vec::new();
732
733 let mut seen_ports = std::collections::HashSet::new();
736 let mut seen_ts_https = std::collections::HashSet::new();
737 for p in &self.ports {
738 if !seen_ports.insert(&p.name) {
739 errors.push(format!("duplicate port name '{}'", p.name));
740 }
741 if p.container_port == 0 {
744 errors.push(format!(
745 "port '{}' has container_port = 0 — fill in the port your service listens on",
746 p.name
747 ));
748 }
749 if let Some(https) = p.tailscale_https
752 && !seen_ts_https.insert(https)
753 {
754 errors.push(format!(
755 "two ports map to the same tailscale_https port {https}"
756 ));
757 }
758 }
759 let ts_ports: Vec<&PortDef> = self
762 .ports
763 .iter()
764 .filter(|p| p.tailscale_https.is_some())
765 .collect();
766 if !ts_ports.is_empty()
767 && ts_ports
768 .iter()
769 .filter(|p| p.tailscale_https == Some(443))
770 .count()
771 != 1
772 {
773 errors.push(
774 "services exposing ports over Tailscale must mark exactly one port \
775 tailscale_https = 443 (the web root)"
776 .to_string(),
777 );
778 }
779
780 if let Some(metrics) = &self.metrics
783 && !self.ports.iter().any(|p| p.name == metrics.port)
784 {
785 errors.push(format!(
786 "[metrics] references port '{}' but no [[ports]] entry has that name",
787 metrics.port
788 ));
789 }
790
791 let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
795 for e in &self.env {
796 if !seen_envs.insert(&e.name) {
797 errors.push(format!("duplicate env var name '{}'", e.name));
798 }
799 }
800 for g in &self.env_groups {
801 for e in &g.env {
802 if !seen_envs.insert(&e.name) {
803 errors.push(format!(
804 "env var '{}' in group '{}' collides with another env var",
805 e.name, g.name
806 ));
807 }
808 }
809 }
810 for c in &self.choices {
819 let mut choice_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
820 for o in &c.options {
821 let mut option_envs: std::collections::HashSet<&str> =
822 std::collections::HashSet::new();
823 for e in &o.env {
824 if !option_envs.insert(e.name.as_str()) {
825 errors.push(format!(
826 "env var '{}' is declared twice in choice '{}' option '{}'",
827 e.name, c.name, o.name
828 ));
829 }
830 if seen_envs.contains(e.name.as_str()) {
831 errors.push(format!(
832 "env var '{}' in choice '{}' option '{}' collides with another env var",
833 e.name, c.name, o.name
834 ));
835 }
836 choice_envs.insert(e.name.as_str());
837 }
838 }
839 seen_envs.extend(choice_envs);
840 }
841
842 for e in &self.env {
845 check_env_var(e, EnvLoc::TopLevel, &mut errors);
846 }
847
848 let mut seen_groups = std::collections::HashSet::new();
851 for g in &self.env_groups {
852 if !seen_groups.insert(&g.name) {
853 errors.push(format!("duplicate env_group name '{}'", g.name));
854 }
855 if g.name.is_empty() {
856 errors.push("env_group has empty name".to_string());
857 } else if !g
858 .name
859 .chars()
860 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
861 {
862 errors.push(format!(
863 "env_group '{}' must be lowercase snake_case ([a-z0-9_])",
864 g.name
865 ));
866 }
867 if g.prompt.is_empty() {
868 errors.push(format!("env_group '{}' has empty prompt", g.name));
869 }
870 if g.env.is_empty() {
871 errors.push(format!("env_group '{}' has no env vars", g.name));
872 }
873 for e in &g.env {
874 check_env_var(e, EnvLoc::Group(&g.name), &mut errors);
875 }
876 }
877
878 let mut seen_choices: std::collections::HashSet<&str> = std::collections::HashSet::new();
885 for c in &self.choices {
886 if !seen_choices.insert(c.name.as_str()) {
887 errors.push(format!("duplicate choice name '{}'", c.name));
888 }
889 if self.env_groups.iter().any(|g| g.name == c.name) {
890 errors.push(format!(
891 "choice '{}' shares a name with an env_group; names must be distinct",
892 c.name
893 ));
894 }
895 if c.name.is_empty() {
896 errors.push("choice has empty name".to_string());
897 } else if !c
898 .name
899 .chars()
900 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
901 {
902 errors.push(format!(
903 "choice '{}' must be lowercase snake_case ([a-z0-9_])",
904 c.name
905 ));
906 }
907 if c.prompt.is_empty() {
908 errors.push(format!("choice '{}' has empty prompt", c.name));
909 }
910 if c.options.len() < 2 {
912 errors.push(format!(
913 "choice '{}' has {} option(s); a choice needs at least two",
914 c.name,
915 c.options.len()
916 ));
917 }
918 let mut seen_options: std::collections::HashSet<&str> =
919 std::collections::HashSet::new();
920 for o in &c.options {
921 if !seen_options.insert(o.name.as_str()) {
922 errors.push(format!(
923 "duplicate option '{}' in choice '{}'",
924 o.name, c.name
925 ));
926 }
927 if o.name.is_empty() {
928 errors.push(format!("choice '{}' has an option with empty name", c.name));
929 } else if !o
930 .name
931 .chars()
932 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
933 {
934 errors.push(format!(
935 "option '{}' in choice '{}' must be lowercase snake_case ([a-z0-9_])",
936 o.name, c.name
937 ));
938 }
939 for e in &o.env {
940 check_env_var(
941 e,
942 EnvLoc::ChoiceOption {
943 choice: &c.name,
944 option: &o.name,
945 },
946 &mut errors,
947 );
948 }
949 }
950 if !c.options.iter().any(|o| o.name == c.default) {
953 errors.push(format!(
954 "choice '{}' default '{}' names no option (have: {})",
955 c.name,
956 c.default,
957 c.options
958 .iter()
959 .map(|o| o.name.as_str())
960 .collect::<Vec<_>>()
961 .join(", ")
962 ));
963 }
964 }
965
966 if let Some(ref req) = self.requirements
969 && let Some(rec) = req.ram.recommended
970 && rec < req.ram.min
971 {
972 errors.push(format!(
973 "recommended RAM ({rec}MB) is less than minimum ({}MB)",
974 req.ram.min
975 ));
976 }
977
978 if let Some(ref backup) = self.backup
986 && !self.integrations.backup
987 {
988 errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
989 let _ = backup;
992 }
993 if let Some(ref backup) = self.backup {
994 for (label, hook) in [
995 ("pre_backup", &backup.pre_backup),
996 ("post_backup", &backup.post_backup),
997 ("pre_restore", &backup.pre_restore),
998 ("post_restore", &backup.post_restore),
999 ] {
1000 if let Some(script) = hook
1001 && (script.is_empty() || script.contains('/') || script.contains(".."))
1002 {
1003 errors.push(format!(
1004 "backup hook '{label}' must be a bare filename under configs/scripts/ \
1005 (got {script:?})"
1006 ));
1007 }
1008 }
1009 for p in &backup.paths {
1010 if p.is_empty() || p.starts_with('/') || p.contains("..") {
1011 errors.push(format!(
1012 "backup path {p:?} must be a relative path within the service home"
1013 ));
1014 }
1015 }
1016 }
1017
1018 match self.service.runtime {
1023 Runtime::Native => match &self.service.run {
1024 None => errors.push(
1025 "runtime = \"native\" requires a `run` command under [service]".to_string(),
1026 ),
1027 Some(run) if run.trim().is_empty() => {
1028 errors.push("[service].run must not be empty".to_string())
1029 }
1030 Some(_) => {}
1031 },
1032 Runtime::Podman => {
1033 if self.service.run.is_some() || self.service.build.is_some() {
1034 errors.push(
1035 "`run` / `build` are only valid for runtime = \"native\" services"
1036 .to_string(),
1037 );
1038 }
1039 }
1040 }
1041
1042 if self.service.deploy == DeployStrategy::BlueGreen {
1050 if self.ports.is_empty() {
1051 errors.push(
1052 "deploy = \"blue-green\" requires at least one [[ports]] entry to route"
1053 .to_string(),
1054 );
1055 }
1056 match self.service.health_check.as_deref() {
1057 None => errors.push(
1058 "deploy = \"blue-green\" requires a `health_check` path under [service]"
1059 .to_string(),
1060 ),
1061 Some(p) if !p.starts_with('/') => errors.push(format!(
1062 "`health_check` must be an absolute path starting with '/', got {p:?}"
1063 )),
1064 Some(_) => {}
1065 }
1066 if self.service.health_timeout == Some(0) {
1067 errors.push(
1068 "`health_timeout` must be greater than 0 seconds (omit it for the default)"
1069 .to_string(),
1070 );
1071 }
1072 }
1073
1074 if errors.is_empty() {
1075 Ok(())
1076 } else {
1077 Err(format!("{name}: {}", errors.join("; ")))
1078 }
1079 }
1080}
1081
1082enum EnvLoc<'a> {
1086 TopLevel,
1087 Group(&'a str),
1088 ChoiceOption { choice: &'a str, option: &'a str },
1089}
1090
1091impl std::fmt::Display for EnvLoc<'_> {
1092 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1093 match self {
1094 EnvLoc::TopLevel => Ok(()),
1095 EnvLoc::Group(g) => write!(f, " in group '{g}'"),
1096 EnvLoc::ChoiceOption { choice, option } => {
1097 write!(f, " in choice '{choice}' option '{option}'")
1098 }
1099 }
1100 }
1101}
1102
1103fn check_env_var(e: &EnvVar, loc: EnvLoc, errors: &mut Vec<String>) {
1107 if e.name.is_empty() {
1108 errors.push(format!("env var has empty name{loc}"));
1109 } else if !e
1110 .name
1111 .chars()
1112 .next()
1113 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1114 {
1115 errors.push(format!(
1116 "env var '{}'{loc} must start with a letter or _",
1117 e.name
1118 ));
1119 } else if !e
1120 .name
1121 .chars()
1122 .all(|c| c.is_ascii_alphanumeric() || c == '_')
1123 {
1124 errors.push(format!(
1125 "env var '{}'{loc} contains invalid characters (must match [A-Za-z0-9_])",
1126 e.name
1127 ));
1128 }
1129 if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
1130 errors.push(format!(
1131 "env var '{}'{loc} is kind=required but has a secret template default; use kind=prompted or kind=default",
1132 e.name
1133 ));
1134 }
1135}
1136
1137pub fn current_architecture() -> Arch {
1139 match std::env::consts::ARCH {
1140 "x86_64" => Arch::Amd64,
1141 "aarch64" => Arch::Arm64,
1142 _ => Arch::Amd64,
1145 }
1146}
1147
1148#[cfg(test)]
1149mod backup_tests {
1150 use super::*;
1151
1152 fn parse(toml_src: &str) -> ServiceDef {
1153 toml::from_str(toml_src).expect("parse")
1154 }
1155
1156 #[test]
1157 fn blue_green_requires_health_check() {
1158 let svc = parse(
1159 r#"
1160[service]
1161name = "x"
1162description = "x"
1163deploy = "blue-green"
1164
1165[[ports]]
1166name = "http"
1167container_port = 8080
1168"#,
1169 );
1170 let err = svc.validate().expect_err("must reject");
1171 assert!(err.contains("health_check"), "got: {err}");
1172 }
1173
1174 #[test]
1175 fn blue_green_health_check_must_be_absolute_path() {
1176 let svc = parse(
1177 r#"
1178[service]
1179name = "x"
1180description = "x"
1181deploy = "blue-green"
1182health_check = "healthz"
1183
1184[[ports]]
1185name = "http"
1186container_port = 8080
1187"#,
1188 );
1189 let err = svc.validate().expect_err("must reject");
1190 assert!(err.contains("absolute path"), "got: {err}");
1191 }
1192
1193 #[test]
1194 fn blue_green_requires_a_port() {
1195 let svc = parse(
1196 r#"
1197[service]
1198name = "x"
1199description = "x"
1200deploy = "blue-green"
1201health_check = "/healthz"
1202"#,
1203 );
1204 let err = svc.validate().expect_err("must reject");
1205 assert!(err.contains("[[ports]]"), "got: {err}");
1206 }
1207
1208 #[test]
1209 fn blue_green_with_port_and_health_check_validates() {
1210 let svc = parse(
1211 r#"
1212[service]
1213name = "x"
1214description = "x"
1215deploy = "blue-green"
1216health_check = "/healthz"
1217
1218[[ports]]
1219name = "http"
1220container_port = 8080
1221"#,
1222 );
1223 assert!(svc.validate().is_ok());
1224 assert_eq!(svc.service.deploy, DeployStrategy::BlueGreen);
1225 }
1226
1227 #[test]
1228 fn health_timeout_defaults_to_120_and_honors_override() {
1229 let default = parse(
1230 r#"
1231[service]
1232name = "x"
1233description = "x"
1234deploy = "blue-green"
1235health_check = "/healthz"
1236
1237[[ports]]
1238name = "http"
1239container_port = 8080
1240"#,
1241 );
1242 assert_eq!(default.service.health_timeout, None);
1243 assert_eq!(default.service.health_timeout_secs(), 120);
1244
1245 let custom = parse(
1246 r#"
1247[service]
1248name = "x"
1249description = "x"
1250deploy = "blue-green"
1251health_check = "/healthz"
1252health_timeout = 300
1253
1254[[ports]]
1255name = "http"
1256container_port = 8080
1257"#,
1258 );
1259 assert_eq!(custom.service.health_timeout_secs(), 300);
1260 assert!(custom.validate().is_ok());
1261 }
1262
1263 #[test]
1264 fn health_timeout_zero_is_rejected() {
1265 let svc = parse(
1266 r#"
1267[service]
1268name = "x"
1269description = "x"
1270deploy = "blue-green"
1271health_check = "/healthz"
1272health_timeout = 0
1273
1274[[ports]]
1275name = "http"
1276container_port = 8080
1277"#,
1278 );
1279 let err = svc.validate().expect_err("must reject");
1280 assert!(err.contains("health_timeout"), "got: {err}");
1281 }
1282
1283 #[test]
1284 fn deploy_defaults_to_restart_and_is_omitted_when_serialized() {
1285 let svc = parse(
1288 r#"
1289[service]
1290name = "x"
1291description = "x"
1292
1293[[ports]]
1294name = "http"
1295container_port = 8080
1296"#,
1297 );
1298 assert_eq!(svc.service.deploy, DeployStrategy::Restart);
1299 let text = toml::to_string(&svc.service).expect("serialize ServiceMeta");
1300 assert!(!text.contains("deploy"), "got: {text}");
1301 }
1302
1303 #[test]
1304 fn tailscale_https_requires_exactly_one_root() {
1305 let svc = parse(
1307 r#"
1308[service]
1309name = "x"
1310description = "x"
1311
1312[[ports]]
1313name = "http"
1314container_port = 8080
1315tailscale_https = 8080
1316
1317[[ports]]
1318name = "photos"
1319container_port = 3000
1320tailscale_https = 3000
1321"#,
1322 );
1323 let err = svc.validate().expect_err("must reject");
1324 assert!(err.contains("tailscale_https = 443"), "got: {err}");
1325 }
1326
1327 #[test]
1328 fn tailscale_https_duplicate_port_rejected() {
1329 let svc = parse(
1330 r#"
1331[service]
1332name = "x"
1333description = "x"
1334
1335[[ports]]
1336name = "a"
1337container_port = 1
1338tailscale_https = 443
1339
1340[[ports]]
1341name = "b"
1342container_port = 2
1343tailscale_https = 443
1344"#,
1345 );
1346 let err = svc.validate().expect_err("must reject");
1347 assert!(err.contains("same tailscale_https"), "got: {err}");
1348 }
1349
1350 #[test]
1351 fn tailscale_https_one_root_plus_api_validates() {
1352 let svc = parse(
1353 r#"
1354[service]
1355name = "x"
1356description = "x"
1357
1358[[ports]]
1359name = "http"
1360container_port = 8080
1361tailscale_https = 8080
1362
1363[[ports]]
1364name = "photos"
1365container_port = 3000
1366tailscale_https = 443
1367"#,
1368 );
1369 svc.validate()
1370 .expect("one 443 root + one api port is valid");
1371 }
1372
1373 #[test]
1374 fn backup_defaults_to_false_when_omitted() {
1375 let svc = parse(
1376 r#"
1377[service]
1378name = "x"
1379description = "x"
1380"#,
1381 );
1382 assert!(!svc.integrations.backup);
1383 assert!(svc.backup.is_none());
1384 svc.validate().expect("default is valid");
1385 }
1386
1387 #[test]
1388 fn backup_section_alone_is_rejected_without_integration_flag() {
1389 let svc = parse(
1390 r#"
1391[service]
1392name = "x"
1393description = "x"
1394
1395[backup]
1396"#,
1397 );
1398 let err = svc.validate().expect_err("must reject");
1399 assert!(
1400 err.contains("backup = true"),
1401 "error mentions the required flag: {err}"
1402 );
1403 }
1404
1405 #[test]
1406 fn backup_supported_without_hooks_validates() {
1407 let svc = parse(
1408 r#"
1409[service]
1410name = "x"
1411description = "x"
1412
1413[integrations]
1414backup = true
1415"#,
1416 );
1417 assert!(svc.integrations.backup);
1418 assert!(svc.backup.is_none());
1419 svc.validate().expect("ok without [backup] table");
1420 }
1421
1422 #[test]
1423 fn backup_with_full_hooks_validates() {
1424 let svc = parse(
1425 r#"
1426[service]
1427name = "x"
1428description = "x"
1429
1430[integrations]
1431backup = true
1432
1433[backup]
1434paths = [".backup/db.sql.gz", "data"]
1435exclude = ["data/cache"]
1436pre_backup = "backup-pre.sh"
1437post_backup = "backup-post.sh"
1438pre_restore = "restore-pre.sh"
1439post_restore = "restore-post.sh"
1440"#,
1441 );
1442 svc.validate().expect("ok");
1443 let backup = svc.backup.as_ref().expect("section present");
1444 assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
1445 assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
1446 }
1447
1448 #[test]
1449 fn backup_hook_with_slash_is_rejected() {
1450 let svc = parse(
1451 r#"
1452[service]
1453name = "x"
1454description = "x"
1455
1456[integrations]
1457backup = true
1458
1459[backup]
1460pre_backup = "subdir/script.sh"
1461"#,
1462 );
1463 let err = svc.validate().expect_err("must reject");
1464 assert!(err.contains("pre_backup"), "{err}");
1465 }
1466
1467 #[test]
1468 fn backup_hook_with_dotdot_is_rejected() {
1469 let svc = parse(
1470 r#"
1471[service]
1472name = "x"
1473description = "x"
1474
1475[integrations]
1476backup = true
1477
1478[backup]
1479post_backup = "../escape.sh"
1480"#,
1481 );
1482 let err = svc.validate().expect_err("must reject");
1483 assert!(err.contains("post_backup"), "{err}");
1484 }
1485
1486 #[test]
1487 fn backup_absolute_path_is_rejected() {
1488 let svc = parse(
1489 r#"
1490[service]
1491name = "x"
1492description = "x"
1493
1494[integrations]
1495backup = true
1496
1497[backup]
1498paths = ["/etc/passwd"]
1499"#,
1500 );
1501 let err = svc.validate().expect_err("must reject");
1502 assert!(err.contains("/etc/passwd"), "{err}");
1503 }
1504
1505 #[test]
1506 fn backup_path_with_dotdot_is_rejected() {
1507 let svc = parse(
1508 r#"
1509[service]
1510name = "x"
1511description = "x"
1512
1513[integrations]
1514backup = true
1515
1516[backup]
1517paths = ["../../somewhere"]
1518"#,
1519 );
1520 let err = svc.validate().expect_err("must reject");
1521 assert!(err.contains("somewhere"), "{err}");
1522 }
1523}
1524
1525#[cfg(test)]
1526mod https_requirement_tests {
1527 use super::*;
1528
1529 fn parse(toml_src: &str) -> ServiceDef {
1530 toml::from_str(toml_src).expect("parse")
1531 }
1532
1533 #[test]
1538 fn all_registry_services_parse_and_validate() {
1539 let registry = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../registry");
1540 if !registry.is_dir() {
1541 eprintln!("registry dir not found ({}); skipping", registry.display());
1542 return;
1543 }
1544 let mut failures = Vec::new();
1545 let entries = std::fs::read_dir(®istry).expect("read registry dir");
1546 for entry in entries {
1547 let entry = entry.expect("dir entry");
1548 let svc_toml = entry.path().join("service.toml");
1549 if !svc_toml.is_file() {
1550 continue;
1551 }
1552 let name = entry.file_name().to_string_lossy().into_owned();
1553 let text = std::fs::read_to_string(&svc_toml).expect("read service.toml");
1554 match toml::from_str::<ServiceDef>(&text) {
1555 Ok(def) => {
1556 if let Err(e) = def.validate() {
1557 failures.push(format!("{name}: validate: {e}"));
1558 }
1559 }
1560 Err(e) => failures.push(format!("{name}: parse: {e}")),
1561 }
1562 }
1563 assert!(
1564 failures.is_empty(),
1565 "registry service.toml failures:\n {}",
1566 failures.join("\n ")
1567 );
1568 }
1569
1570 #[test]
1571 fn never_service_stays_http() {
1572 assert!(!HttpsRequirement::Never.needs_https(false, None));
1573 assert!(!HttpsRequirement::Never.needs_https(true, None));
1578 assert!(!HttpsRequirement::Never.needs_https(true, Some("http://foo.example.com")));
1580 }
1581
1582 #[test]
1583 fn always_service_always_promotes() {
1584 assert!(HttpsRequirement::Always.needs_https(false, None));
1585 assert!(HttpsRequirement::Always.needs_https(false, Some("http://foo.example.com")));
1586 }
1587
1588 #[test]
1589 fn auth_service_promotes_only_with_auth() {
1590 assert!(HttpsRequirement::Auth.needs_https(true, None));
1594 assert!(!HttpsRequirement::Auth.needs_https(false, None));
1596 }
1597
1598 #[test]
1599 fn explicit_https_url_promotes() {
1600 assert!(HttpsRequirement::Never.needs_https(false, Some("https://foo.example.com")));
1601 }
1602
1603 const BILLING_CHOICE: &str = r#"
1606[service]
1607name = "x"
1608description = "x"
1609
1610[[choice]]
1611name = "billing"
1612prompt = "Billing mode"
1613default = "mock"
1614
1615[[choice.option]]
1616name = "live"
1617label = "Stripe"
1618[[choice.option.env]]
1619name = "BILLING_MODE"
1620value = "live"
1621[[choice.option.env]]
1622name = "STRIPE_SECRET_KEY"
1623value = ""
1624kind = "required"
1625
1626[[choice.option]]
1627name = "mock"
1628[[choice.option.env]]
1629name = "BILLING_MODE"
1630value = "mock"
1631"#;
1632
1633 #[test]
1634 fn valid_choice_validates() {
1635 parse(BILLING_CHOICE)
1636 .validate()
1637 .expect("a well-formed choice is valid");
1638 }
1639
1640 #[test]
1641 fn choice_option_carries_quadlets() {
1642 let def = parse(
1643 r#"
1644[service]
1645name = "x"
1646description = "x"
1647
1648[[choice]]
1649name = "database"
1650prompt = "Database"
1651default = "internal"
1652
1653[[choice.option]]
1654name = "internal"
1655quadlets = ["x-postgres.container"]
1656[[choice.option.env]]
1657name = "DATABASE_URL"
1658value = "postgres://ryra@postgres/x"
1659
1660[[choice.option]]
1661name = "external"
1662[[choice.option.env]]
1663name = "DATABASE_URL"
1664value = ""
1665kind = "required"
1666"#,
1667 );
1668 def.validate().expect("valid");
1669 let internal = &def.choices[0].options[0];
1670 assert_eq!(internal.quadlets, vec!["x-postgres.container".to_string()]);
1671 assert!(def.choices[0].options[1].quadlets.is_empty());
1672 }
1673
1674 #[test]
1675 fn sibling_options_may_reuse_an_env_name() {
1676 let def = parse(BILLING_CHOICE);
1679 let billing = &def.choices[0];
1680 assert!(
1681 billing
1682 .options
1683 .iter()
1684 .all(|o| o.env.iter().any(|e| e.name == "BILLING_MODE"))
1685 );
1686 def.validate().expect("sibling reuse is allowed");
1687 }
1688
1689 #[test]
1690 fn choice_needs_at_least_two_options() {
1691 let svc = parse(
1692 r#"
1693[service]
1694name = "x"
1695description = "x"
1696
1697[[choice]]
1698name = "billing"
1699prompt = "p"
1700default = "only"
1701
1702[[choice.option]]
1703name = "only"
1704"#,
1705 );
1706 let err = svc.validate().expect_err("one option is not a choice");
1707 assert!(err.contains("at least two"), "got: {err}");
1708 }
1709
1710 #[test]
1711 fn choice_default_must_name_an_option() {
1712 let svc = parse(
1713 r#"
1714[service]
1715name = "x"
1716description = "x"
1717
1718[[choice]]
1719name = "billing"
1720prompt = "p"
1721default = "nope"
1722
1723[[choice.option]]
1724name = "live"
1725[[choice.option]]
1726name = "mock"
1727"#,
1728 );
1729 let err = svc.validate().expect_err("bad default rejected");
1730 assert!(err.contains("names no option"), "got: {err}");
1731 }
1732
1733 #[test]
1734 fn duplicate_option_name_rejected() {
1735 let svc = parse(
1736 r#"
1737[service]
1738name = "x"
1739description = "x"
1740
1741[[choice]]
1742name = "billing"
1743prompt = "p"
1744default = "live"
1745
1746[[choice.option]]
1747name = "live"
1748[[choice.option]]
1749name = "live"
1750"#,
1751 );
1752 let err = svc.validate().expect_err("dup option rejected");
1753 assert!(err.contains("duplicate option"), "got: {err}");
1754 }
1755
1756 #[test]
1757 fn two_choices_sharing_an_env_name_collide() {
1758 let svc = parse(
1761 r#"
1762[service]
1763name = "x"
1764description = "x"
1765
1766[[choice]]
1767name = "a"
1768prompt = "p"
1769default = "one"
1770[[choice.option]]
1771name = "one"
1772[[choice.option.env]]
1773name = "SHARED"
1774value = "1"
1775[[choice.option]]
1776name = "two"
1777
1778[[choice]]
1779name = "b"
1780prompt = "p"
1781default = "one"
1782[[choice.option]]
1783name = "one"
1784[[choice.option.env]]
1785name = "SHARED"
1786value = "2"
1787[[choice.option]]
1788name = "two"
1789"#,
1790 );
1791 let err = svc.validate().expect_err("cross-choice collision rejected");
1792 assert!(err.contains("collides"), "got: {err}");
1793 }
1794
1795 #[test]
1796 fn choice_name_colliding_with_group_rejected() {
1797 let svc = parse(
1798 r#"
1799[service]
1800name = "x"
1801description = "x"
1802
1803[[env_group]]
1804name = "billing"
1805prompt = "p"
1806[[env_group.env]]
1807name = "FOO"
1808value = "1"
1809
1810[[choice]]
1811name = "billing"
1812prompt = "p"
1813default = "live"
1814[[choice.option]]
1815name = "live"
1816[[choice.option]]
1817name = "mock"
1818"#,
1819 );
1820 let err = svc.validate().expect_err("name clash rejected");
1821 assert!(err.contains("shares a name"), "got: {err}");
1822 }
1823}