1mod duration {
6 use humantime::format_duration;
7 use serde::{Deserialize, Deserializer, Serializer};
8 use std::time::Duration;
9
10 #[allow(clippy::ref_option)]
11 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
12 where
13 S: Serializer,
14 {
15 match duration {
16 Some(d) => serializer.serialize_str(&format_duration(*d).to_string()),
17 None => serializer.serialize_none(),
18 }
19 }
20
21 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
22 where
23 D: Deserializer<'de>,
24 {
25 use serde::de::Error;
26 let s: Option<String> = Option::deserialize(deserializer)?;
27 match s {
28 Some(s) => humantime::parse_duration(&s)
29 .map(Some)
30 .map_err(|e| D::Error::custom(format!("invalid duration: {e}"))),
31 None => Ok(None),
32 }
33 }
34
35 pub mod option {
36 pub use super::*;
37 }
38
39 pub mod required {
41 use humantime::format_duration;
42 use serde::{Deserialize, Deserializer, Serializer};
43 use std::time::Duration;
44
45 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
46 where
47 S: Serializer,
48 {
49 serializer.serialize_str(&format_duration(*duration).to_string())
50 }
51
52 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
53 where
54 D: Deserializer<'de>,
55 {
56 use serde::de::Error;
57 let s: String = String::deserialize(deserializer)?;
58 humantime::parse_duration(&s)
59 .map_err(|e| D::Error::custom(format!("invalid duration: {e}")))
60 }
61 }
62}
63
64use serde::{Deserialize, Serialize};
65use std::collections::HashMap;
66use validator::Validate;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum NodeMode {
72 #[default]
74 Shared,
75 Dedicated,
77 Exclusive,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum ServiceType {
85 #[default]
87 Standard,
88 WasmHttp,
90 WasmPlugin,
92 WasmTransformer,
94 WasmAuthenticator,
96 WasmRateLimiter,
98 WasmMiddleware,
100 WasmRouter,
102 Job,
104}
105
106#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
108#[serde(rename_all = "snake_case")]
109pub enum StorageTier {
110 #[default]
112 Local,
113 Cached,
115 Network,
117}
118
119#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
121#[serde(deny_unknown_fields)]
122pub struct NodeSelector {
123 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
125 pub labels: HashMap<String, String>,
126 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
128 pub prefer_labels: HashMap<String, String>,
129}
130
131#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "snake_case", deny_unknown_fields)]
140pub enum GroupAffinity {
141 #[default]
143 Spread,
144 Pack,
146 Pin(String),
152}
153
154static REPLICA_GROUP_ROLE_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
158 regex::Regex::new(r"^[a-z]([a-z0-9-]{0,28}[a-z0-9])?$").expect("valid regex literal")
159});
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
172#[serde(deny_unknown_fields)]
173pub struct ReplicaGroup {
174 #[validate(length(min = 1, max = 30))]
178 #[validate(regex(path = *REPLICA_GROUP_ROLE_RE))]
179 pub role: String,
180
181 #[validate(range(min = 1))]
183 pub count: u32,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub image: Option<ImageSpec>,
188
189 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
192 pub env: HashMap<String, String>,
193
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub command: Option<CommandSpec>,
197
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub resources: Option<ResourcesSpec>,
201
202 #[serde(default)]
204 pub affinity: GroupAffinity,
205}
206
207pub fn validate_unique_replica_group_roles(groups: &[ReplicaGroup]) -> Result<(), String> {
218 let mut seen = std::collections::HashSet::new();
219 for g in groups {
220 if !seen.insert(g.role.as_str()) {
221 return Err(g.role.clone());
222 }
223 }
224 Ok(())
225}
226
227#[derive(
232 Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
233)]
234#[serde(rename_all = "lowercase")]
235pub enum OsKind {
236 Linux,
237 Windows,
238 Macos,
239}
240
241impl OsKind {
242 #[must_use]
245 pub const fn as_oci_str(self) -> &'static str {
246 match self {
247 OsKind::Linux => "linux",
248 OsKind::Windows => "windows",
249 OsKind::Macos => "darwin",
250 }
251 }
252
253 #[must_use]
255 pub fn from_rust_os(s: &str) -> Option<Self> {
256 match s {
257 "linux" => Some(Self::Linux),
258 "windows" => Some(Self::Windows),
259 "macos" => Some(Self::Macos),
260 _ => None,
261 }
262 }
263
264 #[must_use]
271 pub fn from_oci_str(s: &str) -> Option<Self> {
272 match s {
273 "linux" => Some(Self::Linux),
274 "windows" => Some(Self::Windows),
275 "darwin" => Some(Self::Macos),
276 _ => None,
277 }
278 }
279}
280
281#[derive(
283 Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
284)]
285#[serde(rename_all = "lowercase")]
286pub enum ArchKind {
287 Amd64,
288 Arm64,
289}
290
291impl ArchKind {
292 #[must_use]
294 pub const fn as_oci_str(self) -> &'static str {
295 match self {
296 ArchKind::Amd64 => "amd64",
297 ArchKind::Arm64 => "arm64",
298 }
299 }
300
301 #[must_use]
303 pub fn from_rust_arch(s: &str) -> Option<Self> {
304 match s {
305 "x86_64" => Some(Self::Amd64),
306 "aarch64" => Some(Self::Arm64),
307 _ => None,
308 }
309 }
310}
311
312#[derive(
318 Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
319)]
320pub struct TargetPlatform {
321 pub os: OsKind,
322 pub arch: ArchKind,
323 #[serde(default, rename = "osVersion", skip_serializing_if = "Option::is_none")]
331 pub os_version: Option<String>,
332}
333
334impl TargetPlatform {
335 #[must_use]
336 pub const fn new(os: OsKind, arch: ArchKind) -> Self {
337 Self {
338 os,
339 arch,
340 os_version: None,
341 }
342 }
343
344 #[must_use]
350 pub fn with_os_version(mut self, v: impl Into<String>) -> Self {
351 self.os_version = Some(v.into());
352 self
353 }
354
355 #[must_use]
361 pub fn as_oci_str(self) -> String {
362 format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
363 }
364
365 #[must_use]
369 pub fn as_detailed_str(&self) -> String {
370 match &self.os_version {
371 Some(v) => format!(
372 "{}/{} (os.version={v})",
373 self.os.as_oci_str(),
374 self.arch.as_oci_str()
375 ),
376 None => format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str()),
377 }
378 }
379}
380
381impl std::fmt::Display for TargetPlatform {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 write!(f, "{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
384 }
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390#[serde(deny_unknown_fields)]
391#[allow(clippy::struct_excessive_bools)]
392pub struct WasmCapabilities {
393 #[serde(default = "default_true")]
395 pub config: bool,
396 #[serde(default = "default_true")]
398 pub keyvalue: bool,
399 #[serde(default = "default_true")]
401 pub logging: bool,
402 #[serde(default)]
404 pub secrets: bool,
405 #[serde(default = "default_true")]
407 pub metrics: bool,
408 #[serde(default)]
410 pub http_client: bool,
411 #[serde(default)]
413 pub cli: bool,
414 #[serde(default)]
416 pub filesystem: bool,
417 #[serde(default)]
419 pub sockets: bool,
420}
421
422impl Default for WasmCapabilities {
423 fn default() -> Self {
424 Self {
425 config: true,
426 keyvalue: true,
427 logging: true,
428 secrets: false,
429 metrics: true,
430 http_client: false,
431 cli: false,
432 filesystem: false,
433 sockets: false,
434 }
435 }
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
440#[serde(deny_unknown_fields)]
441pub struct WasmPreopen {
442 pub source: String,
444 pub target: String,
446 #[serde(default)]
448 pub readonly: bool,
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
456#[serde(deny_unknown_fields)]
457#[allow(clippy::struct_excessive_bools)]
458pub struct WasmConfig {
459 #[serde(default = "default_min_instances")]
462 pub min_instances: u32,
463 #[serde(default = "default_max_instances")]
465 pub max_instances: u32,
466 #[serde(default = "default_idle_timeout", with = "duration::required")]
468 pub idle_timeout: std::time::Duration,
469 #[serde(default = "default_request_timeout", with = "duration::required")]
471 pub request_timeout: std::time::Duration,
472
473 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub max_memory: Option<String>,
477 #[serde(default)]
479 pub max_fuel: u64,
480 #[serde(
482 default,
483 skip_serializing_if = "Option::is_none",
484 with = "duration::option"
485 )]
486 pub epoch_interval: Option<std::time::Duration>,
487
488 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub capabilities: Option<WasmCapabilities>,
492
493 #[serde(default = "default_true")]
496 pub allow_http_outgoing: bool,
497 #[serde(default, skip_serializing_if = "Vec::is_empty")]
499 pub allowed_hosts: Vec<String>,
500 #[serde(default)]
502 pub allow_tcp: bool,
503 #[serde(default)]
505 pub allow_udp: bool,
506
507 #[serde(default, skip_serializing_if = "Vec::is_empty")]
510 pub preopens: Vec<WasmPreopen>,
511 #[serde(default = "default_true")]
513 pub kv_enabled: bool,
514 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub kv_namespace: Option<String>,
517 #[serde(default = "default_kv_max_value_size")]
519 pub kv_max_value_size: u64,
520
521 #[serde(default, skip_serializing_if = "Vec::is_empty")]
524 pub secrets: Vec<String>,
525
526 #[serde(default = "default_true")]
529 pub precompile: bool,
530}
531
532fn default_kv_max_value_size() -> u64 {
533 1_048_576 }
535
536impl Default for WasmConfig {
537 fn default() -> Self {
538 Self {
539 min_instances: default_min_instances(),
540 max_instances: default_max_instances(),
541 idle_timeout: default_idle_timeout(),
542 request_timeout: default_request_timeout(),
543 max_memory: None,
544 max_fuel: 0,
545 epoch_interval: None,
546 capabilities: None,
547 allow_http_outgoing: true,
548 allowed_hosts: Vec::new(),
549 allow_tcp: false,
550 allow_udp: false,
551 preopens: Vec::new(),
552 kv_enabled: true,
553 kv_namespace: None,
554 kv_max_value_size: default_kv_max_value_size(),
555 secrets: Vec::new(),
556 precompile: true,
557 }
558 }
559}
560
561#[deprecated(note = "Use WasmConfig instead")]
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
564#[serde(deny_unknown_fields)]
565pub struct WasmHttpConfig {
566 #[serde(default = "default_min_instances")]
568 pub min_instances: u32,
569 #[serde(default = "default_max_instances")]
571 pub max_instances: u32,
572 #[serde(default = "default_idle_timeout", with = "duration::required")]
574 pub idle_timeout: std::time::Duration,
575 #[serde(default = "default_request_timeout", with = "duration::required")]
577 pub request_timeout: std::time::Duration,
578}
579
580fn default_min_instances() -> u32 {
581 0
582}
583
584fn default_max_instances() -> u32 {
585 10
586}
587
588fn default_idle_timeout() -> std::time::Duration {
589 std::time::Duration::from_secs(300)
590}
591
592fn default_request_timeout() -> std::time::Duration {
593 std::time::Duration::from_secs(30)
594}
595
596#[allow(deprecated)]
597impl Default for WasmHttpConfig {
598 fn default() -> Self {
599 Self {
600 min_instances: default_min_instances(),
601 max_instances: default_max_instances(),
602 idle_timeout: default_idle_timeout(),
603 request_timeout: default_request_timeout(),
604 }
605 }
606}
607
608#[allow(deprecated)]
609impl From<WasmHttpConfig> for WasmConfig {
610 fn from(old: WasmHttpConfig) -> Self {
611 Self {
612 min_instances: old.min_instances,
613 max_instances: old.max_instances,
614 idle_timeout: old.idle_timeout,
615 request_timeout: old.request_timeout,
616 ..Default::default()
617 }
618 }
619}
620
621impl ServiceType {
622 #[must_use]
624 pub fn is_wasm(&self) -> bool {
625 matches!(
626 self,
627 ServiceType::WasmHttp
628 | ServiceType::WasmPlugin
629 | ServiceType::WasmTransformer
630 | ServiceType::WasmAuthenticator
631 | ServiceType::WasmRateLimiter
632 | ServiceType::WasmMiddleware
633 | ServiceType::WasmRouter
634 )
635 }
636
637 #[must_use]
640 pub fn default_wasm_capabilities(&self) -> Option<WasmCapabilities> {
641 match self {
642 ServiceType::WasmHttp | ServiceType::WasmRouter => Some(WasmCapabilities {
643 config: true,
644 keyvalue: true,
645 logging: true,
646 secrets: false,
647 metrics: false,
648 http_client: true,
649 cli: false,
650 filesystem: false,
651 sockets: false,
652 }),
653 ServiceType::WasmPlugin => Some(WasmCapabilities {
654 config: true,
655 keyvalue: true,
656 logging: true,
657 secrets: true,
658 metrics: true,
659 http_client: true,
660 cli: true,
661 filesystem: true,
662 sockets: false,
663 }),
664 ServiceType::WasmTransformer => Some(WasmCapabilities {
665 config: false,
666 keyvalue: false,
667 logging: true,
668 secrets: false,
669 metrics: false,
670 http_client: false,
671 cli: true,
672 filesystem: false,
673 sockets: false,
674 }),
675 ServiceType::WasmAuthenticator => Some(WasmCapabilities {
676 config: true,
677 keyvalue: false,
678 logging: true,
679 secrets: true,
680 metrics: false,
681 http_client: true,
682 cli: false,
683 filesystem: false,
684 sockets: false,
685 }),
686 ServiceType::WasmRateLimiter => Some(WasmCapabilities {
687 config: true,
688 keyvalue: true,
689 logging: true,
690 secrets: false,
691 metrics: true,
692 http_client: false,
693 cli: true,
694 filesystem: false,
695 sockets: false,
696 }),
697 ServiceType::WasmMiddleware => Some(WasmCapabilities {
698 config: true,
699 keyvalue: false,
700 logging: true,
701 secrets: false,
702 metrics: false,
703 http_client: true,
704 cli: false,
705 filesystem: false,
706 sockets: false,
707 }),
708 _ => None,
709 }
710 }
711}
712
713fn default_api_bind() -> String {
714 "0.0.0.0:3669".to_string()
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
719pub struct ApiSpec {
720 #[serde(default = "default_true")]
722 pub enabled: bool,
723 #[serde(default = "default_api_bind")]
725 pub bind: String,
726 #[serde(default)]
728 pub jwt_secret: Option<String>,
729 #[serde(default = "default_true")]
731 pub swagger: bool,
732}
733
734impl Default for ApiSpec {
735 fn default() -> Self {
736 Self {
737 enabled: true,
738 bind: default_api_bind(),
739 jwt_secret: None,
740 swagger: true,
741 }
742 }
743}
744
745#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
747#[serde(deny_unknown_fields)]
748pub struct DeploymentSpec {
749 #[validate(custom(function = "crate::spec::validate::validate_version_wrapper"))]
751 pub version: String,
752
753 #[validate(custom(function = "crate::spec::validate::validate_deployment_name_wrapper"))]
755 pub deployment: String,
756
757 #[serde(default)]
759 #[validate(nested)]
760 pub services: HashMap<String, ServiceSpec>,
761
762 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
769 #[validate(nested)]
770 pub externals: HashMap<String, ExternalSpec>,
771
772 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
774 pub tunnels: HashMap<String, TunnelDefinition>,
775
776 #[serde(default)]
778 pub api: ApiSpec,
779}
780
781#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
787#[serde(deny_unknown_fields)]
788pub struct ExternalSpec {
789 #[validate(length(min = 1, message = "at least one backend address is required"))]
794 pub backends: Vec<String>,
795
796 #[serde(default)]
800 #[validate(nested)]
801 pub endpoints: Vec<EndpointSpec>,
802
803 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub health: Option<HealthSpec>,
809}
810
811#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
813#[serde(deny_unknown_fields)]
814pub struct TunnelDefinition {
815 pub from: String,
817
818 pub to: String,
820
821 pub local_port: u16,
823
824 pub remote_port: u16,
826
827 #[serde(default)]
829 pub protocol: TunnelProtocol,
830
831 #[serde(default)]
833 pub expose: ExposeType,
834}
835
836#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
838#[serde(rename_all = "lowercase")]
839pub enum TunnelProtocol {
840 #[default]
841 Tcp,
842 Udp,
843}
844
845#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
847pub struct LogsConfig {
848 #[serde(default = "default_logs_destination")]
850 pub destination: String,
851
852 #[serde(default = "default_logs_max_size")]
854 pub max_size_bytes: u64,
855
856 #[serde(default = "default_logs_retention")]
858 pub retention_secs: u64,
859}
860
861fn default_logs_destination() -> String {
862 "disk".to_string()
863}
864
865fn default_logs_max_size() -> u64 {
866 100 * 1024 * 1024 }
868
869fn default_logs_retention() -> u64 {
870 7 * 24 * 60 * 60 }
872
873impl Default for LogsConfig {
874 fn default() -> Self {
875 Self {
876 destination: default_logs_destination(),
877 max_size_bytes: default_logs_max_size(),
878 retention_secs: default_logs_retention(),
879 }
880 }
881}
882
883#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
889#[serde(rename_all = "lowercase")]
890pub enum NetworkMode {
891 #[default]
893 Default,
894 Host,
896 None,
898 Bridge {
901 #[serde(default)]
902 name: Option<String>,
903 },
904 Container { id: String },
907}
908
909fn deserialize_network_mode<'de, D>(deserializer: D) -> Result<NetworkMode, D::Error>
916where
917 D: serde::Deserializer<'de>,
918{
919 use serde::de::Error;
920
921 #[derive(Deserialize)]
926 #[serde(rename_all = "lowercase")]
927 enum Inner {
928 Default,
929 Host,
930 None,
931 Bridge {
932 #[serde(default)]
933 name: Option<String>,
934 },
935 Container {
936 id: String,
937 },
938 }
939
940 impl From<Inner> for NetworkMode {
941 fn from(i: Inner) -> Self {
942 match i {
943 Inner::Default => Self::Default,
944 Inner::Host => Self::Host,
945 Inner::None => Self::None,
946 Inner::Bridge { name } => Self::Bridge { name },
947 Inner::Container { id } => Self::Container { id },
948 }
949 }
950 }
951
952 let value = serde_yaml::Value::deserialize(deserializer)?;
956
957 if let Some(s) = value.as_str() {
958 return match s {
959 "default" => Ok(NetworkMode::Default),
960 "host" => Ok(NetworkMode::Host),
961 "none" => Ok(NetworkMode::None),
962 "bridge" => Ok(NetworkMode::Bridge { name: None }),
963 _ => {
964 if let Some(rest) = s.strip_prefix("bridge:") {
965 if rest.is_empty() {
966 Ok(NetworkMode::Bridge { name: None })
967 } else {
968 Ok(NetworkMode::Bridge {
969 name: Some(rest.to_string()),
970 })
971 }
972 } else if let Some(rest) = s.strip_prefix("container:") {
973 if rest.is_empty() {
974 Err(D::Error::custom(
975 "network mode \"container:<id>\" requires a non-empty id",
976 ))
977 } else {
978 Ok(NetworkMode::Container {
979 id: rest.to_string(),
980 })
981 }
982 } else {
983 Err(D::Error::custom(format!("unknown network mode: {s}")))
984 }
985 }
986 };
987 }
988
989 let inner: Inner = serde_yaml::from_value(value).map_err(D::Error::custom)?;
990 Ok(NetworkMode::from(inner))
991}
992
993#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
999#[serde(rename_all = "kebab-case")]
1000pub enum IsolationMode {
1001 #[default]
1002 Auto,
1003 Process,
1004 Hyperv,
1005}
1006
1007#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq, utoipa::ToSchema)]
1025#[serde(deny_unknown_fields)]
1026pub struct UlimitSpec {
1027 #[serde(default)]
1030 pub soft: i64,
1031 #[serde(default)]
1035 pub hard: i64,
1036}
1037
1038impl<'de> Deserialize<'de> for UlimitSpec {
1039 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1040 where
1041 D: serde::Deserializer<'de>,
1042 {
1043 #[derive(Deserialize)]
1047 #[serde(deny_unknown_fields)]
1048 struct Shadow {
1049 #[serde(default)]
1050 soft: Option<i64>,
1051 #[serde(default)]
1052 hard: Option<i64>,
1053 }
1054
1055 let Shadow { soft, hard } = Shadow::deserialize(deserializer)?;
1056 Ok(match (soft, hard) {
1057 (Some(soft), Some(hard)) => Self { soft, hard },
1058 (Some(soft), None) => Self { soft, hard: soft },
1060 (None, Some(hard)) => Self { soft: hard, hard },
1062 (None, None) => Self::default(),
1063 })
1064 }
1065}
1066
1067#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Validate)]
1069#[serde(from = "ServiceSpecCompat")]
1070#[allow(clippy::struct_excessive_bools)]
1071pub struct ServiceSpec {
1072 #[serde(default = "default_resource_type")]
1074 pub rtype: ResourceType,
1075
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 #[validate(custom(function = "crate::spec::validate::validate_schedule_wrapper"))]
1084 pub schedule: Option<String>,
1085
1086 #[validate(nested)]
1088 pub image: ImageSpec,
1089
1090 #[serde(default)]
1092 #[validate(nested)]
1093 pub resources: ResourcesSpec,
1094
1095 #[serde(default)]
1102 pub env: HashMap<String, String>,
1103
1104 #[serde(default)]
1106 pub command: CommandSpec,
1107
1108 #[serde(default)]
1110 pub network: ServiceNetworkSpec,
1111
1112 #[serde(default)]
1114 #[validate(nested)]
1115 pub endpoints: Vec<EndpointSpec>,
1116
1117 #[serde(default)]
1119 #[validate(custom(function = "crate::spec::validate::validate_scale_spec"))]
1120 pub scale: ScaleSpec,
1121
1122 #[serde(default, skip_serializing_if = "Option::is_none")]
1137 #[validate(nested)]
1138 pub replica_groups: Option<Vec<ReplicaGroup>>,
1139
1140 #[serde(default)]
1142 pub depends: Vec<DependsSpec>,
1143
1144 #[serde(default = "default_health")]
1146 pub health: HealthSpec,
1147
1148 #[serde(default)]
1150 pub init: InitSpec,
1151
1152 #[serde(default)]
1154 pub errors: ErrorsSpec,
1155
1156 #[serde(default)]
1162 pub lifecycle: LifecycleSpec,
1163
1164 #[serde(default, skip_serializing_if = "Option::is_none")]
1166 pub isolation: Option<IsolationMode>,
1167
1168 #[serde(default)]
1170 pub devices: Vec<DeviceSpec>,
1171
1172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1174 pub storage: Vec<StorageSpec>,
1175
1176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1181 pub port_mappings: Vec<PortMapping>,
1182
1183 #[serde(default, alias = "cap_add", skip_serializing_if = "Vec::is_empty")]
1187 pub capabilities: Vec<String>,
1188
1189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1191 pub cap_drop: Vec<String>,
1192
1193 #[serde(default)]
1195 pub privileged: bool,
1196
1197 #[serde(default)]
1199 pub node_mode: NodeMode,
1200
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1203 pub node_selector: Option<NodeSelector>,
1204
1205 #[serde(default, skip_serializing_if = "Option::is_none")]
1217 pub affinity: Option<GroupAffinity>,
1218
1219 #[serde(default, skip_serializing_if = "Option::is_none")]
1223 pub platform: Option<TargetPlatform>,
1224
1225 #[serde(default)]
1227 pub service_type: ServiceType,
1228
1229 #[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
1232 pub wasm: Option<WasmConfig>,
1233
1234 #[serde(default, skip_serializing_if = "Option::is_none")]
1236 pub logs: Option<LogsConfig>,
1237
1238 #[serde(skip)]
1243 pub host_network: bool,
1244
1245 #[serde(default, skip_serializing_if = "Option::is_none")]
1251 pub hostname: Option<String>,
1252
1253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1259 pub dns: Vec<String>,
1260
1261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1269 pub extra_hosts: Vec<String>,
1270
1271 #[serde(default, skip_serializing_if = "Option::is_none")]
1279 pub restart_policy: Option<ContainerRestartPolicy>,
1280
1281 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1284 pub labels: HashMap<String, String>,
1285
1286 #[serde(default, skip_serializing_if = "Option::is_none")]
1289 pub user: Option<String>,
1290
1291 #[serde(default, skip_serializing_if = "Option::is_none")]
1294 pub stop_signal: Option<String>,
1295
1296 #[serde(
1299 default,
1300 with = "duration::option",
1301 skip_serializing_if = "Option::is_none"
1302 )]
1303 pub stop_grace_period: Option<std::time::Duration>,
1304
1305 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1307 pub sysctls: HashMap<String, String>,
1308
1309 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1311 pub ulimits: HashMap<String, UlimitSpec>,
1312
1313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1316 pub security_opt: Vec<String>,
1317
1318 #[serde(default, skip_serializing_if = "Option::is_none")]
1321 pub pid_mode: Option<String>,
1322
1323 #[serde(default, skip_serializing_if = "Option::is_none")]
1326 pub ipc_mode: Option<String>,
1327
1328 #[serde(default, deserialize_with = "deserialize_network_mode")]
1332 pub network_mode: NetworkMode,
1333
1334 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1337 pub extra_groups: Vec<String>,
1338
1339 #[serde(default)]
1341 pub read_only_root_fs: bool,
1342
1343 #[serde(default, skip_serializing_if = "Option::is_none")]
1347 pub init_container: Option<bool>,
1348
1349 #[serde(default)]
1352 pub tty: bool,
1353
1354 #[serde(default)]
1357 pub stdin_open: bool,
1358
1359 #[serde(default, skip_serializing_if = "Option::is_none")]
1362 pub userns_mode: Option<String>,
1363
1364 #[serde(default, skip_serializing_if = "Option::is_none")]
1367 pub cgroup_parent: Option<String>,
1368
1369 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1375 pub expose: Vec<String>,
1376
1377 #[serde(default, skip_serializing_if = "Option::is_none")]
1384 pub overlay: Option<crate::overlay::OverlayConfig>,
1385
1386 #[serde(default, skip_serializing_if = "LocalhostReachability::is_default")]
1391 pub localhost_reachability: LocalhostReachability,
1392}
1393
1394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1405#[serde(rename_all = "snake_case")]
1406pub enum LocalhostReachability {
1407 #[default]
1413 Auto,
1414 Always,
1416 Never,
1418}
1419
1420impl LocalhostReachability {
1421 #[must_use]
1424 pub fn is_default(&self) -> bool {
1425 matches!(self, Self::Auto)
1426 }
1427}
1428
1429#[derive(Deserialize)]
1436#[serde(deny_unknown_fields)]
1437#[allow(clippy::struct_excessive_bools)]
1438struct ServiceSpecCompat {
1439 #[serde(default = "default_resource_type")]
1440 rtype: ResourceType,
1441 #[serde(default)]
1442 schedule: Option<String>,
1443 image: ImageSpec,
1444 #[serde(default)]
1445 resources: ResourcesSpec,
1446 #[serde(default)]
1447 env: HashMap<String, String>,
1448 #[serde(default)]
1449 command: CommandSpec,
1450 #[serde(default)]
1451 network: ServiceNetworkSpec,
1452 #[serde(default)]
1453 endpoints: Vec<EndpointSpec>,
1454 #[serde(default)]
1455 scale: ScaleSpec,
1456 #[serde(default)]
1457 replica_groups: Option<Vec<ReplicaGroup>>,
1458 #[serde(default)]
1459 depends: Vec<DependsSpec>,
1460 #[serde(default = "default_health")]
1461 health: HealthSpec,
1462 #[serde(default)]
1463 init: InitSpec,
1464 #[serde(default)]
1465 errors: ErrorsSpec,
1466 #[serde(default)]
1467 lifecycle: LifecycleSpec,
1468 #[serde(default)]
1469 isolation: Option<IsolationMode>,
1470 #[serde(default)]
1471 devices: Vec<DeviceSpec>,
1472 #[serde(default)]
1473 storage: Vec<StorageSpec>,
1474 #[serde(default)]
1475 port_mappings: Vec<PortMapping>,
1476 #[serde(default, alias = "cap_add")]
1477 capabilities: Vec<String>,
1478 #[serde(default)]
1479 cap_drop: Vec<String>,
1480 #[serde(default)]
1481 privileged: bool,
1482 #[serde(default)]
1483 node_mode: NodeMode,
1484 #[serde(default)]
1485 node_selector: Option<NodeSelector>,
1486 #[serde(default)]
1487 affinity: Option<GroupAffinity>,
1488 #[serde(default)]
1489 platform: Option<TargetPlatform>,
1490 #[serde(default)]
1491 service_type: ServiceType,
1492 #[serde(default, alias = "wasm_http")]
1493 wasm: Option<WasmConfig>,
1494 #[serde(default)]
1495 logs: Option<LogsConfig>,
1496 #[serde(default)]
1499 host_network: Option<bool>,
1500 #[serde(default)]
1501 hostname: Option<String>,
1502 #[serde(default)]
1503 dns: Vec<String>,
1504 #[serde(default)]
1505 extra_hosts: Vec<String>,
1506 #[serde(default)]
1507 restart_policy: Option<ContainerRestartPolicy>,
1508 #[serde(default)]
1509 labels: HashMap<String, String>,
1510 #[serde(default)]
1511 user: Option<String>,
1512 #[serde(default)]
1513 stop_signal: Option<String>,
1514 #[serde(default, with = "duration::option")]
1515 stop_grace_period: Option<std::time::Duration>,
1516 #[serde(default)]
1517 sysctls: HashMap<String, String>,
1518 #[serde(default)]
1519 ulimits: HashMap<String, UlimitSpec>,
1520 #[serde(default)]
1521 security_opt: Vec<String>,
1522 #[serde(default)]
1523 pid_mode: Option<String>,
1524 #[serde(default)]
1525 ipc_mode: Option<String>,
1526 #[serde(default, deserialize_with = "deserialize_network_mode")]
1527 network_mode: NetworkMode,
1528 #[serde(default)]
1529 extra_groups: Vec<String>,
1530 #[serde(default)]
1531 read_only_root_fs: bool,
1532 #[serde(default)]
1533 init_container: Option<bool>,
1534 #[serde(default)]
1535 tty: bool,
1536 #[serde(default)]
1537 stdin_open: bool,
1538 #[serde(default)]
1539 userns_mode: Option<String>,
1540 #[serde(default)]
1541 cgroup_parent: Option<String>,
1542 #[serde(default)]
1543 expose: Vec<String>,
1544 #[serde(default)]
1545 overlay: Option<crate::overlay::OverlayConfig>,
1546 #[serde(default)]
1547 localhost_reachability: LocalhostReachability,
1548}
1549
1550impl From<ServiceSpecCompat> for ServiceSpec {
1551 fn from(c: ServiceSpecCompat) -> Self {
1552 let network_mode = match (c.host_network, &c.network_mode) {
1557 (Some(true), NetworkMode::Default) => NetworkMode::Host,
1558 _ => c.network_mode,
1559 };
1560 let host_network = c.host_network.unwrap_or(false) || network_mode == NetworkMode::Host;
1561
1562 Self {
1563 rtype: c.rtype,
1564 schedule: c.schedule,
1565 image: c.image,
1566 resources: c.resources,
1567 env: c.env,
1568 command: c.command,
1569 network: c.network,
1570 endpoints: c.endpoints,
1571 scale: c.scale,
1572 replica_groups: c.replica_groups,
1573 depends: c.depends,
1574 health: c.health,
1575 init: c.init,
1576 errors: c.errors,
1577 lifecycle: c.lifecycle,
1578 isolation: c.isolation,
1579 devices: c.devices,
1580 storage: c.storage,
1581 port_mappings: c.port_mappings,
1582 capabilities: c.capabilities,
1583 cap_drop: c.cap_drop,
1584 privileged: c.privileged,
1585 node_mode: c.node_mode,
1586 node_selector: c.node_selector,
1587 affinity: c.affinity,
1588 platform: c.platform,
1589 service_type: c.service_type,
1590 wasm: c.wasm,
1591 logs: c.logs,
1592 host_network,
1593 hostname: c.hostname,
1594 dns: c.dns,
1595 extra_hosts: c.extra_hosts,
1596 restart_policy: c.restart_policy,
1597 labels: c.labels,
1598 user: c.user,
1599 stop_signal: c.stop_signal,
1600 stop_grace_period: c.stop_grace_period,
1601 sysctls: c.sysctls,
1602 ulimits: c.ulimits,
1603 security_opt: c.security_opt,
1604 pid_mode: c.pid_mode,
1605 ipc_mode: c.ipc_mode,
1606 network_mode,
1607 extra_groups: c.extra_groups,
1608 read_only_root_fs: c.read_only_root_fs,
1609 init_container: c.init_container,
1610 tty: c.tty,
1611 stdin_open: c.stdin_open,
1612 userns_mode: c.userns_mode,
1613 cgroup_parent: c.cgroup_parent,
1614 expose: c.expose,
1615 overlay: c.overlay,
1616 localhost_reachability: c.localhost_reachability,
1617 }
1618 }
1619}
1620
1621impl ServiceSpec {
1622 #[must_use]
1631 pub fn is_single_member(&self) -> bool {
1632 if let Some(groups) = &self.replica_groups {
1633 let total: u32 = groups.iter().map(|g| g.count).sum();
1634 return groups.len() <= 1 && total <= 1;
1635 }
1636 match &self.scale {
1637 ScaleSpec::Fixed { replicas } => *replicas <= 1,
1638 ScaleSpec::Adaptive { max, .. } => *max <= 1,
1639 ScaleSpec::Manual => true,
1640 }
1641 }
1642
1643 #[must_use]
1648 pub fn publish_to_node_loopback(&self) -> bool {
1649 match self.localhost_reachability {
1650 LocalhostReachability::Always => true,
1651 LocalhostReachability::Never => false,
1652 LocalhostReachability::Auto => self.is_single_member(),
1653 }
1654 }
1655
1656 #[must_use]
1678 pub fn minimal(_name: impl Into<String>, image: impl Into<String>) -> Self {
1679 use std::str::FromStr;
1680 let image_str = image.into();
1681 let image_ref = crate::ImageRef::from_str(&image_str).unwrap_or_else(|_| {
1682 crate::ImageRef::from_str("scratch:latest")
1683 .expect("'scratch:latest' is a valid image reference")
1684 });
1685 Self {
1686 image: ImageSpec {
1687 name: image_ref,
1688 pull_policy: default_pull_policy(),
1689 source_policy: None,
1690 },
1691 ..Self::default()
1692 }
1693 }
1694}
1695
1696#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1698#[serde(deny_unknown_fields)]
1699pub struct CommandSpec {
1700 #[serde(default, skip_serializing_if = "Option::is_none")]
1702 pub entrypoint: Option<Vec<String>>,
1703
1704 #[serde(default, skip_serializing_if = "Option::is_none")]
1706 pub args: Option<Vec<String>>,
1707
1708 #[serde(default, skip_serializing_if = "Option::is_none")]
1710 pub workdir: Option<String>,
1711}
1712
1713fn default_resource_type() -> ResourceType {
1714 ResourceType::Service
1715}
1716
1717fn default_health() -> HealthSpec {
1718 HealthSpec {
1719 start_grace: Some(std::time::Duration::from_secs(5)),
1720 interval: None,
1721 timeout: None,
1722 retries: 3,
1723 check: HealthCheck::Tcp { port: 0 },
1724 }
1725}
1726
1727#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1729#[serde(rename_all = "lowercase")]
1730pub enum ResourceType {
1731 #[default]
1733 Service,
1734 Job,
1736 Cron,
1738}
1739
1740#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1746#[serde(rename_all = "snake_case")]
1747pub enum SourcePolicy {
1748 #[default]
1750 LocalFirst,
1751 S3First,
1754 RemoteOnly,
1757 LocalOnly,
1760}
1761
1762#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1764#[serde(deny_unknown_fields)]
1765pub struct ImageSpec {
1766 pub name: crate::ImageRef,
1768
1769 #[serde(default = "default_pull_policy")]
1771 pub pull_policy: PullPolicy,
1772
1773 #[serde(default, skip_serializing_if = "Option::is_none")]
1776 pub source_policy: Option<SourcePolicy>,
1777}
1778
1779fn default_pull_policy() -> PullPolicy {
1780 PullPolicy::IfNotPresent
1781}
1782
1783impl Default for ImageSpec {
1784 fn default() -> Self {
1792 use std::str::FromStr;
1793 Self {
1794 name: crate::ImageRef::from_str("scratch:latest")
1795 .expect("'scratch:latest' is a valid image reference"),
1796 pull_policy: default_pull_policy(),
1797 source_policy: None,
1798 }
1799 }
1800}
1801
1802#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1804#[serde(rename_all = "snake_case")]
1805pub enum PullPolicy {
1806 Always,
1808 Newer,
1810 IfNotPresent,
1816 Never,
1818}
1819
1820#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate, utoipa::ToSchema)]
1822#[serde(deny_unknown_fields)]
1823pub struct DeviceSpec {
1824 #[validate(length(min = 1, message = "device path cannot be empty"))]
1826 pub path: String,
1827
1828 #[serde(default = "default_true")]
1830 pub read: bool,
1831
1832 #[serde(default = "default_true")]
1834 pub write: bool,
1835
1836 #[serde(default)]
1838 pub mknod: bool,
1839}
1840
1841fn default_true() -> bool {
1842 true
1843}
1844
1845#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1847#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
1848pub enum StorageSpec {
1849 Bind {
1851 source: String,
1852 target: String,
1853 #[serde(default)]
1854 readonly: bool,
1855 },
1856 Named {
1858 name: String,
1859 target: String,
1860 #[serde(default)]
1861 readonly: bool,
1862 #[serde(default)]
1864 tier: StorageTier,
1865 #[serde(default, skip_serializing_if = "Option::is_none")]
1867 size: Option<String>,
1868 },
1869 Anonymous {
1871 target: String,
1872 #[serde(default)]
1874 tier: StorageTier,
1875 },
1876 Tmpfs {
1878 target: String,
1879 #[serde(default)]
1880 size: Option<String>,
1881 #[serde(default)]
1882 mode: Option<u32>,
1883 },
1884 S3 {
1886 bucket: String,
1887 #[serde(default)]
1888 prefix: Option<String>,
1889 target: String,
1890 #[serde(default)]
1891 readonly: bool,
1892 #[serde(default)]
1893 endpoint: Option<String>,
1894 #[serde(default)]
1895 credentials: Option<String>,
1896 },
1897}
1898
1899#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
1901#[serde(deny_unknown_fields)]
1902pub struct ResourcesSpec {
1903 #[serde(default)]
1905 #[validate(custom(function = "crate::spec::validate::validate_cpu_option_wrapper"))]
1906 pub cpu: Option<f64>,
1907
1908 #[serde(default)]
1910 #[validate(custom(function = "crate::spec::validate::validate_memory_option_wrapper"))]
1911 pub memory: Option<String>,
1912
1913 #[serde(default, skip_serializing_if = "Option::is_none")]
1915 pub gpu: Option<GpuSpec>,
1916
1917 #[serde(default, skip_serializing_if = "Option::is_none")]
1920 pub pids_limit: Option<i64>,
1921
1922 #[serde(default, skip_serializing_if = "Option::is_none")]
1924 pub cpuset: Option<String>,
1925
1926 #[serde(default, skip_serializing_if = "Option::is_none")]
1928 pub cpu_shares: Option<u32>,
1929
1930 #[serde(default, skip_serializing_if = "Option::is_none")]
1932 pub memory_swap: Option<String>,
1933
1934 #[serde(default, skip_serializing_if = "Option::is_none")]
1936 pub memory_reservation: Option<String>,
1937
1938 #[serde(default, skip_serializing_if = "Option::is_none")]
1940 pub memory_swappiness: Option<u8>,
1941
1942 #[serde(default, skip_serializing_if = "Option::is_none")]
1944 pub oom_score_adj: Option<i32>,
1945
1946 #[serde(default, skip_serializing_if = "Option::is_none")]
1948 pub oom_kill_disable: Option<bool>,
1949
1950 #[serde(default, skip_serializing_if = "Option::is_none")]
1952 pub blkio_weight: Option<u16>,
1953}
1954
1955#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1957#[serde(rename_all = "kebab-case")]
1958pub enum SchedulingPolicy {
1959 #[default]
1961 BestEffort,
1962 Gang,
1964 Spread,
1966}
1967
1968#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1970#[serde(rename_all = "kebab-case")]
1971pub enum GpuSharingMode {
1972 #[default]
1974 Exclusive,
1975 Mps,
1978 TimeSlice,
1981}
1982
1983#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1990#[serde(deny_unknown_fields)]
1991pub struct DistributedConfig {
1992 #[serde(default = "default_dist_backend")]
1994 pub backend: String,
1995 #[serde(default = "default_dist_port")]
1997 pub master_port: u16,
1998}
1999
2000fn default_dist_backend() -> String {
2001 "nccl".to_string()
2002}
2003
2004fn default_dist_port() -> u16 {
2005 29500
2006}
2007
2008#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
2027#[serde(deny_unknown_fields)]
2028pub struct GpuSpec {
2029 #[serde(default = "default_gpu_count")]
2031 pub count: u32,
2032 #[serde(default = "default_gpu_vendor")]
2034 pub vendor: String,
2035 #[serde(default, skip_serializing_if = "Option::is_none")]
2037 pub mode: Option<String>,
2038 #[serde(default, skip_serializing_if = "Option::is_none")]
2041 pub model: Option<String>,
2042 #[serde(default, skip_serializing_if = "Option::is_none")]
2047 pub scheduling: Option<SchedulingPolicy>,
2048 #[serde(default, skip_serializing_if = "Option::is_none")]
2051 pub distributed: Option<DistributedConfig>,
2052 #[serde(default, skip_serializing_if = "Option::is_none")]
2054 pub sharing: Option<GpuSharingMode>,
2055 #[serde(default, skip_serializing_if = "Option::is_none")]
2062 pub mps_pipe_dir: Option<String>,
2063 #[serde(default, skip_serializing_if = "Option::is_none")]
2069 pub mps_log_dir: Option<String>,
2070 #[serde(default, skip_serializing_if = "Option::is_none")]
2077 pub time_slice_index: Option<u32>,
2078 #[serde(default, skip_serializing_if = "Option::is_none")]
2086 pub time_slicing_config_path: Option<String>,
2087}
2088
2089fn default_gpu_count() -> u32 {
2090 1
2091}
2092
2093fn default_gpu_vendor() -> String {
2094 "nvidia".to_string()
2095}
2096
2097#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2099#[serde(deny_unknown_fields)]
2100#[derive(Default)]
2101pub struct ServiceNetworkSpec {
2102 #[serde(default)]
2104 pub overlays: OverlayConfig,
2105
2106 #[serde(default)]
2108 pub join: JoinPolicy,
2109}
2110
2111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2113#[serde(deny_unknown_fields)]
2114pub struct OverlayConfig {
2115 #[serde(default)]
2117 pub service: OverlaySettings,
2118
2119 #[serde(default)]
2121 pub global: OverlaySettings,
2122}
2123
2124impl Default for OverlayConfig {
2125 fn default() -> Self {
2126 Self {
2127 service: OverlaySettings {
2128 enabled: true,
2129 encrypted: true,
2130 isolated: true,
2131 },
2132 global: OverlaySettings {
2133 enabled: true,
2134 encrypted: true,
2135 isolated: false,
2136 },
2137 }
2138 }
2139}
2140
2141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2143#[serde(deny_unknown_fields)]
2144pub struct OverlaySettings {
2145 #[serde(default = "default_enabled")]
2147 pub enabled: bool,
2148
2149 #[serde(default = "default_encrypted")]
2151 pub encrypted: bool,
2152
2153 #[serde(default)]
2155 pub isolated: bool,
2156}
2157
2158fn default_enabled() -> bool {
2159 true
2160}
2161
2162fn default_encrypted() -> bool {
2163 true
2164}
2165
2166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2168#[serde(deny_unknown_fields)]
2169pub struct JoinPolicy {
2170 #[serde(default = "default_join_mode")]
2172 pub mode: JoinMode,
2173
2174 #[serde(default = "default_join_scope")]
2176 pub scope: JoinScope,
2177}
2178
2179impl Default for JoinPolicy {
2180 fn default() -> Self {
2181 Self {
2182 mode: default_join_mode(),
2183 scope: default_join_scope(),
2184 }
2185 }
2186}
2187
2188fn default_join_mode() -> JoinMode {
2189 JoinMode::Token
2190}
2191
2192fn default_join_scope() -> JoinScope {
2193 JoinScope::Service
2194}
2195
2196#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2198#[serde(rename_all = "snake_case")]
2199pub enum JoinMode {
2200 Open,
2202 Token,
2204 Closed,
2206}
2207
2208#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2210#[serde(rename_all = "snake_case")]
2211pub enum JoinScope {
2212 Service,
2214 Global,
2216}
2217
2218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
2220#[serde(deny_unknown_fields)]
2221pub struct EndpointSpec {
2222 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
2224 pub name: String,
2225
2226 pub protocol: Protocol,
2228
2229 #[validate(custom(function = "crate::spec::validate::validate_port_wrapper"))]
2231 pub port: u16,
2232
2233 #[serde(default, skip_serializing_if = "Option::is_none")]
2236 pub target_port: Option<u16>,
2237
2238 pub path: Option<String>,
2240
2241 #[serde(default, skip_serializing_if = "Option::is_none")]
2244 pub host: Option<String>,
2245
2246 #[serde(default = "default_expose")]
2248 pub expose: ExposeType,
2249
2250 #[serde(default, skip_serializing_if = "Option::is_none")]
2253 pub stream: Option<StreamEndpointConfig>,
2254
2255 #[serde(default, skip_serializing_if = "Option::is_none")]
2279 pub target_role: Option<String>,
2280
2281 #[serde(default, skip_serializing_if = "Option::is_none")]
2283 pub tunnel: Option<EndpointTunnelConfig>,
2284}
2285
2286impl EndpointSpec {
2287 #[must_use]
2290 pub fn target_port(&self) -> u16 {
2291 self.target_port.unwrap_or(self.port)
2292 }
2293}
2294
2295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2297#[serde(deny_unknown_fields)]
2298pub struct EndpointTunnelConfig {
2299 #[serde(default)]
2301 pub enabled: bool,
2302
2303 #[serde(default, skip_serializing_if = "Option::is_none")]
2305 pub from: Option<String>,
2306
2307 #[serde(default, skip_serializing_if = "Option::is_none")]
2309 pub to: Option<String>,
2310
2311 #[serde(default)]
2313 pub remote_port: u16,
2314
2315 #[serde(default, skip_serializing_if = "Option::is_none")]
2317 pub expose: Option<ExposeType>,
2318
2319 #[serde(default, skip_serializing_if = "Option::is_none")]
2321 pub access: Option<TunnelAccessConfig>,
2322}
2323
2324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2326#[serde(deny_unknown_fields)]
2327pub struct TunnelAccessConfig {
2328 #[serde(default)]
2330 pub enabled: bool,
2331
2332 #[serde(default, skip_serializing_if = "Option::is_none")]
2334 pub max_ttl: Option<String>,
2335
2336 #[serde(default)]
2338 pub audit: bool,
2339}
2340
2341fn default_expose() -> ExposeType {
2342 ExposeType::Internal
2343}
2344
2345#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2347#[serde(rename_all = "lowercase")]
2348pub enum Protocol {
2349 Http,
2350 Https,
2351 Tcp,
2352 Udp,
2353 Websocket,
2354}
2355
2356#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2358#[serde(rename_all = "lowercase")]
2359pub enum ExposeType {
2360 Public,
2361 #[default]
2362 Internal,
2363}
2364
2365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2367#[serde(deny_unknown_fields)]
2368pub struct StreamEndpointConfig {
2369 #[serde(default)]
2371 pub tls: bool,
2372
2373 #[serde(default)]
2375 pub proxy_protocol: bool,
2376
2377 #[serde(default, skip_serializing_if = "Option::is_none")]
2380 pub session_timeout: Option<String>,
2381
2382 #[serde(default, skip_serializing_if = "Option::is_none")]
2384 pub health_check: Option<StreamHealthCheck>,
2385}
2386
2387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2389#[serde(tag = "type", rename_all = "snake_case")]
2390pub enum StreamHealthCheck {
2391 TcpConnect,
2393 UdpProbe {
2395 request: String,
2397 #[serde(default, skip_serializing_if = "Option::is_none")]
2399 expect: Option<String>,
2400 },
2401}
2402
2403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2405#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
2406pub enum ScaleSpec {
2407 #[serde(rename = "adaptive")]
2409 Adaptive {
2410 min: u32,
2412
2413 max: u32,
2415
2416 #[serde(default, with = "duration::option")]
2418 cooldown: Option<std::time::Duration>,
2419
2420 #[serde(default)]
2422 targets: ScaleTargets,
2423 },
2424
2425 #[serde(rename = "fixed")]
2427 Fixed { replicas: u32 },
2428
2429 #[serde(rename = "manual")]
2431 Manual,
2432}
2433
2434impl Default for ScaleSpec {
2435 fn default() -> Self {
2436 Self::Adaptive {
2437 min: 1,
2438 max: 10,
2439 cooldown: Some(std::time::Duration::from_secs(30)),
2440 targets: ScaleTargets::default(),
2441 }
2442 }
2443}
2444
2445#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2447#[serde(deny_unknown_fields)]
2448#[derive(Default)]
2449pub struct ScaleTargets {
2450 #[serde(default)]
2452 pub cpu: Option<u8>,
2453
2454 #[serde(default)]
2456 pub memory: Option<u8>,
2457
2458 #[serde(default)]
2460 pub rps: Option<u32>,
2461}
2462
2463#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2465#[serde(deny_unknown_fields)]
2466pub struct DependsSpec {
2467 pub service: String,
2469
2470 #[serde(default = "default_condition")]
2472 pub condition: DependencyCondition,
2473
2474 #[serde(default = "default_timeout", with = "duration::option")]
2476 pub timeout: Option<std::time::Duration>,
2477
2478 #[serde(default = "default_on_timeout")]
2480 pub on_timeout: TimeoutAction,
2481}
2482
2483fn default_condition() -> DependencyCondition {
2484 DependencyCondition::Healthy
2485}
2486
2487#[allow(clippy::unnecessary_wraps)]
2488fn default_timeout() -> Option<std::time::Duration> {
2489 Some(std::time::Duration::from_secs(300))
2490}
2491
2492fn default_on_timeout() -> TimeoutAction {
2493 TimeoutAction::Fail
2494}
2495
2496#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2498#[serde(rename_all = "lowercase")]
2499pub enum DependencyCondition {
2500 Started,
2502 Healthy,
2504 Ready,
2506}
2507
2508#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2510#[serde(rename_all = "lowercase")]
2511pub enum TimeoutAction {
2512 Fail,
2513 Warn,
2514 Continue,
2515}
2516
2517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2519#[serde(deny_unknown_fields)]
2520pub struct HealthSpec {
2521 #[serde(default, with = "duration::option")]
2523 pub start_grace: Option<std::time::Duration>,
2524
2525 #[serde(default, with = "duration::option")]
2527 pub interval: Option<std::time::Duration>,
2528
2529 #[serde(default, with = "duration::option")]
2531 pub timeout: Option<std::time::Duration>,
2532
2533 #[serde(default = "default_retries")]
2535 pub retries: u32,
2536
2537 pub check: HealthCheck,
2539}
2540
2541fn default_retries() -> u32 {
2542 3
2543}
2544
2545impl Default for HealthSpec {
2546 fn default() -> Self {
2551 default_health()
2552 }
2553}
2554
2555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2557#[serde(tag = "type", rename_all = "lowercase")]
2558pub enum HealthCheck {
2559 Tcp {
2561 port: u16,
2563 },
2564
2565 Http {
2567 url: String,
2569 #[serde(default = "default_expect_status")]
2571 expect_status: u16,
2572 },
2573
2574 Command {
2576 command: String,
2578 },
2579}
2580
2581fn default_expect_status() -> u16 {
2582 200
2583}
2584
2585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2587#[serde(deny_unknown_fields)]
2588#[derive(Default)]
2589pub struct InitSpec {
2590 #[serde(default)]
2592 pub steps: Vec<InitStep>,
2593}
2594
2595#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
2602#[serde(deny_unknown_fields)]
2603pub struct LifecycleSpec {
2604 #[serde(default)]
2608 pub delete_on_exit: bool,
2609}
2610
2611#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2613#[serde(deny_unknown_fields)]
2614pub struct InitStep {
2615 pub id: String,
2617
2618 pub uses: String,
2620
2621 #[serde(default)]
2623 pub with: InitParams,
2624
2625 #[serde(default)]
2627 pub retry: Option<u32>,
2628
2629 #[serde(default, with = "duration::option")]
2631 pub timeout: Option<std::time::Duration>,
2632
2633 #[serde(default = "default_on_failure")]
2635 pub on_failure: FailureAction,
2636}
2637
2638fn default_on_failure() -> FailureAction {
2639 FailureAction::Fail
2640}
2641
2642pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
2644
2645#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2647#[serde(rename_all = "lowercase")]
2648pub enum FailureAction {
2649 Fail,
2650 Warn,
2651 Continue,
2652}
2653
2654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2656#[serde(deny_unknown_fields)]
2657#[derive(Default)]
2658pub struct ErrorsSpec {
2659 #[serde(default)]
2661 pub on_init_failure: InitFailurePolicy,
2662
2663 #[serde(default)]
2665 pub on_panic: PanicPolicy,
2666}
2667
2668#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2670#[serde(deny_unknown_fields)]
2671pub struct InitFailurePolicy {
2672 #[serde(default = "default_init_action")]
2673 pub action: InitFailureAction,
2674}
2675
2676impl Default for InitFailurePolicy {
2677 fn default() -> Self {
2678 Self {
2679 action: default_init_action(),
2680 }
2681 }
2682}
2683
2684fn default_init_action() -> InitFailureAction {
2685 InitFailureAction::Fail
2686}
2687
2688#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2690#[serde(rename_all = "lowercase")]
2691pub enum InitFailureAction {
2692 Fail,
2693 Restart,
2694 Backoff,
2695}
2696
2697#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2699#[serde(deny_unknown_fields)]
2700pub struct PanicPolicy {
2701 #[serde(default = "default_panic_action")]
2702 pub action: PanicAction,
2703}
2704
2705impl Default for PanicPolicy {
2706 fn default() -> Self {
2707 Self {
2708 action: default_panic_action(),
2709 }
2710 }
2711}
2712
2713fn default_panic_action() -> PanicAction {
2714 PanicAction::Restart
2715}
2716
2717#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2719#[serde(rename_all = "lowercase")]
2720pub enum PanicAction {
2721 Restart,
2722 Shutdown,
2723 Isolate,
2724}
2725
2726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
2733pub struct NetworkPolicySpec {
2734 pub name: String,
2736
2737 #[serde(default, skip_serializing_if = "Option::is_none")]
2739 pub description: Option<String>,
2740
2741 #[serde(default)]
2743 pub cidrs: Vec<String>,
2744
2745 #[serde(default)]
2747 pub members: Vec<NetworkMember>,
2748
2749 #[serde(default)]
2751 pub access_rules: Vec<AccessRule>,
2752}
2753
2754#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2756pub struct NetworkMember {
2757 pub name: String,
2759 #[serde(default)]
2761 pub kind: MemberKind,
2762}
2763
2764#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2766#[serde(rename_all = "lowercase")]
2767pub enum MemberKind {
2768 #[default]
2770 User,
2771 Group,
2773 Node,
2775 Cidr,
2777}
2778
2779#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2781pub struct AccessRule {
2782 #[serde(default = "wildcard")]
2784 pub service: String,
2785
2786 #[serde(default = "wildcard")]
2788 pub deployment: String,
2789
2790 #[serde(default, skip_serializing_if = "Option::is_none")]
2792 pub ports: Option<Vec<u16>>,
2793
2794 #[serde(default)]
2796 pub action: AccessAction,
2797}
2798
2799fn wildcard() -> String {
2800 "*".to_string()
2801}
2802
2803#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2805#[serde(rename_all = "lowercase")]
2806pub enum AccessAction {
2807 #[default]
2809 Allow,
2810 Deny,
2812}
2813
2814#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2826pub struct BridgeNetwork {
2827 pub id: String,
2829
2830 pub name: String,
2832
2833 #[serde(default)]
2835 pub driver: BridgeNetworkDriver,
2836
2837 #[serde(default, skip_serializing_if = "Option::is_none")]
2839 pub subnet: Option<String>,
2840
2841 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2843 pub labels: HashMap<String, String>,
2844
2845 #[serde(default)]
2848 pub internal: bool,
2849
2850 #[schema(value_type = String, format = "date-time")]
2852 pub created_at: chrono::DateTime<chrono::Utc>,
2853}
2854
2855#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2857#[serde(rename_all = "lowercase")]
2858pub enum BridgeNetworkDriver {
2859 #[default]
2861 Bridge,
2862 Overlay,
2864}
2865
2866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2868pub struct BridgeNetworkAttachment {
2869 pub container_id: String,
2871
2872 #[serde(default, skip_serializing_if = "Option::is_none")]
2874 pub container_name: Option<String>,
2875
2876 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2878 pub aliases: Vec<String>,
2879
2880 #[serde(default, skip_serializing_if = "Option::is_none")]
2882 pub ipv4: Option<String>,
2883}
2884
2885#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2906pub struct RegistryAuth {
2907 pub username: String,
2910 pub password: String,
2913 #[serde(default = "default_registry_auth_type")]
2915 pub auth_type: RegistryAuthType,
2916}
2917
2918#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2920#[serde(rename_all = "snake_case")]
2921pub enum RegistryAuthType {
2922 #[default]
2924 Basic,
2925 Token,
2928}
2929
2930#[must_use]
2933pub fn default_registry_auth_type() -> RegistryAuthType {
2934 RegistryAuthType::Basic
2935}
2936
2937#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2952#[serde(rename_all = "snake_case", deny_unknown_fields)]
2953pub struct ContainerRestartPolicy {
2954 pub kind: ContainerRestartKind,
2956
2957 #[serde(default, skip_serializing_if = "Option::is_none")]
2960 pub max_attempts: Option<u32>,
2961
2962 #[serde(default, skip_serializing_if = "Option::is_none")]
2967 pub delay: Option<String>,
2968}
2969
2970#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2972#[serde(rename_all = "snake_case")]
2973pub enum ContainerRestartKind {
2974 No,
2976 Always,
2978 UnlessStopped,
2981 OnFailure,
2984}
2985
2986#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2992#[serde(rename_all = "snake_case")]
2993pub enum PortProtocol {
2994 Tcp,
2996 Udp,
2998}
2999
3000impl Default for PortProtocol {
3001 fn default() -> Self {
3002 default_port_protocol()
3003 }
3004}
3005
3006impl PortProtocol {
3007 #[must_use]
3010 pub fn as_str(&self) -> &'static str {
3011 match self {
3012 PortProtocol::Tcp => "tcp",
3013 PortProtocol::Udp => "udp",
3014 }
3015 }
3016}
3017
3018fn default_port_protocol() -> PortProtocol {
3019 PortProtocol::Tcp
3020}
3021
3022fn default_host_ip() -> String {
3023 "0.0.0.0".to_string()
3024}
3025
3026#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
3032#[serde(rename_all = "snake_case")]
3033pub struct PortMapping {
3034 #[serde(default, skip_serializing_if = "Option::is_none")]
3036 pub host_port: Option<u16>,
3037 pub container_port: u16,
3039 #[serde(default = "default_port_protocol")]
3041 pub protocol: PortProtocol,
3042 #[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
3044 pub host_ip: String,
3045}
3046
3047#[cfg(test)]
3048mod tests {
3049 use super::*;
3050
3051 #[test]
3052 fn service_spec_default_round_trips_through_json() {
3053 let spec = ServiceSpec::default();
3058
3059 assert_eq!(spec.rtype, ResourceType::Service);
3061 assert_eq!(spec.image.pull_policy, PullPolicy::IfNotPresent);
3062 assert_eq!(spec.health.retries, 3);
3063 assert_eq!(spec.network_mode, NetworkMode::Default);
3064 assert!(spec.env.is_empty());
3065 assert!(spec.endpoints.is_empty());
3066 assert!(spec.overlay.is_none());
3067
3068 let json = serde_json::to_string(&spec).expect("serialize default ServiceSpec");
3069 let parsed: ServiceSpec =
3070 serde_json::from_str(&json).expect("re-parse default ServiceSpec");
3071 assert_eq!(spec, parsed);
3072 }
3073
3074 #[test]
3075 fn service_spec_minimal_sets_name_and_image() {
3076 let spec = ServiceSpec::minimal("api", "ghcr.io/acme/api:1.2");
3077 assert_eq!(spec.image.name.repository(), "acme/api");
3078 assert_eq!(spec.image.name.tag(), Some("1.2"));
3079 let baseline = ServiceSpec::default();
3081 assert_eq!(spec.rtype, baseline.rtype);
3082 assert_eq!(spec.scale, baseline.scale);
3083 assert_eq!(spec.network_mode, baseline.network_mode);
3084 }
3085
3086 #[test]
3087 fn port_mapping_defaults_via_serde() {
3088 let json = r#"{"container_port": 8080}"#;
3091 let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
3092 assert_eq!(m.container_port, 8080);
3093 assert_eq!(m.host_port, None);
3094 assert_eq!(m.protocol, PortProtocol::Tcp);
3095 assert_eq!(m.host_ip, "0.0.0.0");
3096 }
3097
3098 #[test]
3099 fn port_mapping_skips_none_host_port_and_empty_host_ip() {
3100 let m = PortMapping {
3101 host_port: None,
3102 container_port: 443,
3103 protocol: PortProtocol::Tcp,
3104 host_ip: String::new(),
3105 };
3106 let s = serde_json::to_string(&m).expect("serialize");
3107 assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
3109 assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
3110 assert!(s.contains("\"container_port\":443"));
3111 assert!(s.contains("\"protocol\":\"tcp\""));
3112 }
3113
3114 #[test]
3115 fn test_parse_simple_spec() {
3116 let yaml = r"
3117version: v1
3118deployment: test
3119services:
3120 hello:
3121 rtype: service
3122 image:
3123 name: hello-world:latest
3124 endpoints:
3125 - name: http
3126 protocol: http
3127 port: 8080
3128 expose: public
3129";
3130
3131 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3132 assert_eq!(spec.version, "v1");
3133 assert_eq!(spec.deployment, "test");
3134 assert!(spec.services.contains_key("hello"));
3135 }
3136
3137 #[test]
3138 fn test_parse_duration() {
3139 let yaml = r"
3140version: v1
3141deployment: test
3142services:
3143 test:
3144 rtype: service
3145 image:
3146 name: test:latest
3147 health:
3148 timeout: 30s
3149 interval: 1m
3150 start_grace: 5s
3151 check:
3152 type: tcp
3153 port: 8080
3154";
3155
3156 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3157 let health = &spec.services["test"].health;
3158 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
3159 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
3160 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
3161 match &health.check {
3162 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
3163 _ => panic!("Expected TCP health check"),
3164 }
3165 }
3166
3167 #[test]
3168 fn test_parse_adaptive_scale() {
3169 let yaml = r"
3170version: v1
3171deployment: test
3172services:
3173 test:
3174 rtype: service
3175 image:
3176 name: test:latest
3177 scale:
3178 mode: adaptive
3179 min: 2
3180 max: 10
3181 cooldown: 15s
3182 targets:
3183 cpu: 70
3184 rps: 800
3185";
3186
3187 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3188 let scale = &spec.services["test"].scale;
3189 match scale {
3190 ScaleSpec::Adaptive {
3191 min,
3192 max,
3193 cooldown,
3194 targets,
3195 } => {
3196 assert_eq!(*min, 2);
3197 assert_eq!(*max, 10);
3198 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
3199 assert_eq!(targets.cpu, Some(70));
3200 assert_eq!(targets.rps, Some(800));
3201 }
3202 _ => panic!("Expected Adaptive scale mode"),
3203 }
3204 }
3205
3206 #[test]
3207 fn test_node_mode_default() {
3208 let yaml = r"
3209version: v1
3210deployment: test
3211services:
3212 hello:
3213 rtype: service
3214 image:
3215 name: hello-world:latest
3216";
3217
3218 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3219 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
3220 assert!(spec.services["hello"].node_selector.is_none());
3221 }
3222
3223 #[test]
3224 fn test_node_mode_dedicated() {
3225 let yaml = r"
3226version: v1
3227deployment: test
3228services:
3229 api:
3230 rtype: service
3231 image:
3232 name: api:latest
3233 node_mode: dedicated
3234";
3235
3236 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3237 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
3238 }
3239
3240 #[test]
3241 fn test_node_mode_exclusive() {
3242 let yaml = r"
3243version: v1
3244deployment: test
3245services:
3246 database:
3247 rtype: service
3248 image:
3249 name: postgres:15
3250 node_mode: exclusive
3251";
3252
3253 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3254 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
3255 }
3256
3257 #[test]
3258 fn test_node_selector_with_labels() {
3259 let yaml = r#"
3260version: v1
3261deployment: test
3262services:
3263 ml-worker:
3264 rtype: service
3265 image:
3266 name: ml-worker:latest
3267 node_mode: dedicated
3268 node_selector:
3269 labels:
3270 gpu: "true"
3271 zone: us-east
3272 prefer_labels:
3273 storage: ssd
3274"#;
3275
3276 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3277 let service = &spec.services["ml-worker"];
3278 assert_eq!(service.node_mode, NodeMode::Dedicated);
3279
3280 let selector = service.node_selector.as_ref().unwrap();
3281 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
3282 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
3283 assert_eq!(
3284 selector.prefer_labels.get("storage"),
3285 Some(&"ssd".to_string())
3286 );
3287 }
3288
3289 #[test]
3290 fn test_node_mode_serialization_roundtrip() {
3291 use serde_json;
3292
3293 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
3295 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
3296
3297 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
3298 let json = serde_json::to_string(mode).unwrap();
3299 assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
3300
3301 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
3302 assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
3303 }
3304 }
3305
3306 #[test]
3307 fn test_node_selector_empty() {
3308 let yaml = r"
3309version: v1
3310deployment: test
3311services:
3312 api:
3313 rtype: service
3314 image:
3315 name: api:latest
3316 node_selector:
3317 labels: {}
3318";
3319
3320 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3321 let selector = spec.services["api"].node_selector.as_ref().unwrap();
3322 assert!(selector.labels.is_empty());
3323 assert!(selector.prefer_labels.is_empty());
3324 }
3325
3326 #[test]
3327 fn test_mixed_node_modes_in_deployment() {
3328 let yaml = r"
3329version: v1
3330deployment: test
3331services:
3332 redis:
3333 rtype: service
3334 image:
3335 name: redis:alpine
3336 # Default shared mode
3337 api:
3338 rtype: service
3339 image:
3340 name: api:latest
3341 node_mode: dedicated
3342 database:
3343 rtype: service
3344 image:
3345 name: postgres:15
3346 node_mode: exclusive
3347 node_selector:
3348 labels:
3349 storage: ssd
3350";
3351
3352 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3353 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
3354 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
3355 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
3356
3357 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
3358 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
3359 }
3360
3361 #[test]
3362 fn test_storage_bind_mount() {
3363 let yaml = r"
3364version: v1
3365deployment: test
3366services:
3367 app:
3368 image:
3369 name: app:latest
3370 storage:
3371 - type: bind
3372 source: /host/data
3373 target: /app/data
3374 readonly: true
3375";
3376 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3377 let storage = &spec.services["app"].storage;
3378 assert_eq!(storage.len(), 1);
3379 match &storage[0] {
3380 StorageSpec::Bind {
3381 source,
3382 target,
3383 readonly,
3384 } => {
3385 assert_eq!(source, "/host/data");
3386 assert_eq!(target, "/app/data");
3387 assert!(*readonly);
3388 }
3389 _ => panic!("Expected Bind storage"),
3390 }
3391 }
3392
3393 #[test]
3394 fn test_storage_named_with_tier() {
3395 let yaml = r"
3396version: v1
3397deployment: test
3398services:
3399 app:
3400 image:
3401 name: app:latest
3402 storage:
3403 - type: named
3404 name: my-data
3405 target: /app/data
3406 tier: cached
3407";
3408 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3409 let storage = &spec.services["app"].storage;
3410 match &storage[0] {
3411 StorageSpec::Named {
3412 name, target, tier, ..
3413 } => {
3414 assert_eq!(name, "my-data");
3415 assert_eq!(target, "/app/data");
3416 assert_eq!(*tier, StorageTier::Cached);
3417 }
3418 _ => panic!("Expected Named storage"),
3419 }
3420 }
3421
3422 #[test]
3423 fn test_storage_anonymous() {
3424 let yaml = r"
3425version: v1
3426deployment: test
3427services:
3428 app:
3429 image:
3430 name: app:latest
3431 storage:
3432 - type: anonymous
3433 target: /app/cache
3434";
3435 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3436 let storage = &spec.services["app"].storage;
3437 match &storage[0] {
3438 StorageSpec::Anonymous { target, tier } => {
3439 assert_eq!(target, "/app/cache");
3440 assert_eq!(*tier, StorageTier::Local); }
3442 _ => panic!("Expected Anonymous storage"),
3443 }
3444 }
3445
3446 #[test]
3447 fn test_storage_tmpfs() {
3448 let yaml = r"
3449version: v1
3450deployment: test
3451services:
3452 app:
3453 image:
3454 name: app:latest
3455 storage:
3456 - type: tmpfs
3457 target: /app/tmp
3458 size: 256Mi
3459 mode: 1777
3460";
3461 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3462 let storage = &spec.services["app"].storage;
3463 match &storage[0] {
3464 StorageSpec::Tmpfs { target, size, mode } => {
3465 assert_eq!(target, "/app/tmp");
3466 assert_eq!(size.as_deref(), Some("256Mi"));
3467 assert_eq!(*mode, Some(1777));
3468 }
3469 _ => panic!("Expected Tmpfs storage"),
3470 }
3471 }
3472
3473 #[test]
3474 fn test_storage_s3() {
3475 let yaml = r"
3476version: v1
3477deployment: test
3478services:
3479 app:
3480 image:
3481 name: app:latest
3482 storage:
3483 - type: s3
3484 bucket: my-bucket
3485 prefix: models/
3486 target: /app/models
3487 readonly: true
3488 endpoint: https://s3.us-west-2.amazonaws.com
3489 credentials: aws-creds
3490";
3491 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3492 let storage = &spec.services["app"].storage;
3493 match &storage[0] {
3494 StorageSpec::S3 {
3495 bucket,
3496 prefix,
3497 target,
3498 readonly,
3499 endpoint,
3500 credentials,
3501 } => {
3502 assert_eq!(bucket, "my-bucket");
3503 assert_eq!(prefix.as_deref(), Some("models/"));
3504 assert_eq!(target, "/app/models");
3505 assert!(*readonly);
3506 assert_eq!(
3507 endpoint.as_deref(),
3508 Some("https://s3.us-west-2.amazonaws.com")
3509 );
3510 assert_eq!(credentials.as_deref(), Some("aws-creds"));
3511 }
3512 _ => panic!("Expected S3 storage"),
3513 }
3514 }
3515
3516 #[test]
3517 fn test_storage_multiple_types() {
3518 let yaml = r"
3519version: v1
3520deployment: test
3521services:
3522 app:
3523 image:
3524 name: app:latest
3525 storage:
3526 - type: bind
3527 source: /etc/config
3528 target: /app/config
3529 readonly: true
3530 - type: named
3531 name: app-data
3532 target: /app/data
3533 - type: tmpfs
3534 target: /app/tmp
3535";
3536 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3537 let storage = &spec.services["app"].storage;
3538 assert_eq!(storage.len(), 3);
3539 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
3540 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
3541 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
3542 }
3543
3544 #[test]
3545 fn test_storage_tier_default() {
3546 let yaml = r"
3547version: v1
3548deployment: test
3549services:
3550 app:
3551 image:
3552 name: app:latest
3553 storage:
3554 - type: named
3555 name: data
3556 target: /data
3557";
3558 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3559 match &spec.services["app"].storage[0] {
3560 StorageSpec::Named { tier, .. } => {
3561 assert_eq!(*tier, StorageTier::Local); }
3563 _ => panic!("Expected Named storage"),
3564 }
3565 }
3566
3567 #[test]
3572 fn test_endpoint_tunnel_config_basic() {
3573 let yaml = r"
3574version: v1
3575deployment: test
3576services:
3577 api:
3578 image:
3579 name: api:latest
3580 endpoints:
3581 - name: http
3582 protocol: http
3583 port: 8080
3584 tunnel:
3585 enabled: true
3586 remote_port: 8080
3587";
3588 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3589 let endpoint = &spec.services["api"].endpoints[0];
3590 let tunnel = endpoint.tunnel.as_ref().unwrap();
3591 assert!(tunnel.enabled);
3592 assert_eq!(tunnel.remote_port, 8080);
3593 assert!(tunnel.from.is_none());
3594 assert!(tunnel.to.is_none());
3595 }
3596
3597 #[test]
3598 fn test_endpoint_tunnel_config_full() {
3599 let yaml = r"
3600version: v1
3601deployment: test
3602services:
3603 api:
3604 image:
3605 name: api:latest
3606 endpoints:
3607 - name: http
3608 protocol: http
3609 port: 8080
3610 tunnel:
3611 enabled: true
3612 from: node-1
3613 to: ingress-node
3614 remote_port: 9000
3615 expose: public
3616 access:
3617 enabled: true
3618 max_ttl: 4h
3619 audit: true
3620";
3621 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3622 let endpoint = &spec.services["api"].endpoints[0];
3623 let tunnel = endpoint.tunnel.as_ref().unwrap();
3624 assert!(tunnel.enabled);
3625 assert_eq!(tunnel.from, Some("node-1".to_string()));
3626 assert_eq!(tunnel.to, Some("ingress-node".to_string()));
3627 assert_eq!(tunnel.remote_port, 9000);
3628 assert_eq!(tunnel.expose, Some(ExposeType::Public));
3629
3630 let access = tunnel.access.as_ref().unwrap();
3631 assert!(access.enabled);
3632 assert_eq!(access.max_ttl, Some("4h".to_string()));
3633 assert!(access.audit);
3634 }
3635
3636 #[test]
3637 fn test_top_level_tunnel_definition() {
3638 let yaml = r"
3639version: v1
3640deployment: test
3641services: {}
3642tunnels:
3643 db-tunnel:
3644 from: app-node
3645 to: db-node
3646 local_port: 5432
3647 remote_port: 5432
3648 protocol: tcp
3649 expose: internal
3650";
3651 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3652 let tunnel = spec.tunnels.get("db-tunnel").unwrap();
3653 assert_eq!(tunnel.from, "app-node");
3654 assert_eq!(tunnel.to, "db-node");
3655 assert_eq!(tunnel.local_port, 5432);
3656 assert_eq!(tunnel.remote_port, 5432);
3657 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
3658 assert_eq!(tunnel.expose, ExposeType::Internal);
3659 }
3660
3661 #[test]
3662 fn test_top_level_tunnel_defaults() {
3663 let yaml = r"
3664version: v1
3665deployment: test
3666services: {}
3667tunnels:
3668 simple-tunnel:
3669 from: node-a
3670 to: node-b
3671 local_port: 3000
3672 remote_port: 3000
3673";
3674 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3675 let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
3676 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
3679
3680 #[test]
3681 fn test_tunnel_protocol_udp() {
3682 let yaml = r"
3683version: v1
3684deployment: test
3685services: {}
3686tunnels:
3687 udp-tunnel:
3688 from: node-a
3689 to: node-b
3690 local_port: 5353
3691 remote_port: 5353
3692 protocol: udp
3693";
3694 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3695 let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
3696 assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
3697 }
3698
3699 #[test]
3700 fn test_endpoint_without_tunnel() {
3701 let yaml = r"
3702version: v1
3703deployment: test
3704services:
3705 api:
3706 image:
3707 name: api:latest
3708 endpoints:
3709 - name: http
3710 protocol: http
3711 port: 8080
3712";
3713 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3714 let endpoint = &spec.services["api"].endpoints[0];
3715 assert!(endpoint.tunnel.is_none());
3716 }
3717
3718 #[test]
3719 fn test_deployment_without_tunnels() {
3720 let yaml = r"
3721version: v1
3722deployment: test
3723services:
3724 api:
3725 image:
3726 name: api:latest
3727";
3728 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3729 assert!(spec.tunnels.is_empty());
3730 }
3731
3732 #[test]
3737 fn test_spec_without_api_block_uses_defaults() {
3738 let yaml = r"
3739version: v1
3740deployment: test
3741services:
3742 hello:
3743 image:
3744 name: hello-world:latest
3745";
3746 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3747 assert!(spec.api.enabled);
3748 assert_eq!(spec.api.bind, "0.0.0.0:3669");
3749 assert!(spec.api.jwt_secret.is_none());
3750 assert!(spec.api.swagger);
3751 }
3752
3753 #[test]
3754 fn test_spec_with_explicit_api_block() {
3755 let yaml = r#"
3756version: v1
3757deployment: test
3758services:
3759 hello:
3760 image:
3761 name: hello-world:latest
3762api:
3763 enabled: false
3764 bind: "127.0.0.1:9090"
3765 jwt_secret: "my-secret"
3766 swagger: false
3767"#;
3768 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3769 assert!(!spec.api.enabled);
3770 assert_eq!(spec.api.bind, "127.0.0.1:9090");
3771 assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
3772 assert!(!spec.api.swagger);
3773 }
3774
3775 #[test]
3776 fn test_spec_with_partial_api_block() {
3777 let yaml = r#"
3778version: v1
3779deployment: test
3780services:
3781 hello:
3782 image:
3783 name: hello-world:latest
3784api:
3785 bind: "0.0.0.0:3000"
3786"#;
3787 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3788 assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
3790 assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
3793
3794 #[test]
3799 fn test_network_policy_spec_roundtrip() {
3800 let spec = NetworkPolicySpec {
3801 name: "corp-vpn".to_string(),
3802 description: Some("Corporate VPN network".to_string()),
3803 cidrs: vec!["10.200.0.0/16".to_string()],
3804 members: vec![
3805 NetworkMember {
3806 name: "alice".to_string(),
3807 kind: MemberKind::User,
3808 },
3809 NetworkMember {
3810 name: "ops-team".to_string(),
3811 kind: MemberKind::Group,
3812 },
3813 NetworkMember {
3814 name: "node-01".to_string(),
3815 kind: MemberKind::Node,
3816 },
3817 ],
3818 access_rules: vec![
3819 AccessRule {
3820 service: "api-gateway".to_string(),
3821 deployment: "*".to_string(),
3822 ports: Some(vec![443, 8080]),
3823 action: AccessAction::Allow,
3824 },
3825 AccessRule {
3826 service: "*".to_string(),
3827 deployment: "staging".to_string(),
3828 ports: None,
3829 action: AccessAction::Deny,
3830 },
3831 ],
3832 };
3833
3834 let yaml = serde_yaml::to_string(&spec).unwrap();
3835 let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
3836 assert_eq!(spec, deserialized);
3837 }
3838
3839 #[test]
3840 fn test_network_policy_spec_defaults() {
3841 let yaml = r"
3842name: minimal
3843";
3844 let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
3845 assert_eq!(spec.name, "minimal");
3846 assert!(spec.description.is_none());
3847 assert!(spec.cidrs.is_empty());
3848 assert!(spec.members.is_empty());
3849 assert!(spec.access_rules.is_empty());
3850 }
3851
3852 #[test]
3853 fn test_access_rule_defaults() {
3854 let yaml = "{}";
3855 let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
3856 assert_eq!(rule.service, "*");
3857 assert_eq!(rule.deployment, "*");
3858 assert!(rule.ports.is_none());
3859 assert_eq!(rule.action, AccessAction::Allow);
3860 }
3861
3862 #[test]
3863 fn test_member_kind_defaults_to_user() {
3864 let yaml = r"
3865name: bob
3866";
3867 let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
3868 assert_eq!(member.name, "bob");
3869 assert_eq!(member.kind, MemberKind::User);
3870 }
3871
3872 #[test]
3873 fn test_member_kind_variants() {
3874 for (input, expected) in [
3875 ("user", MemberKind::User),
3876 ("group", MemberKind::Group),
3877 ("node", MemberKind::Node),
3878 ("cidr", MemberKind::Cidr),
3879 ] {
3880 let yaml = format!("name: test\nkind: {input}");
3881 let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
3882 assert_eq!(member.kind, expected);
3883 }
3884 }
3885
3886 #[test]
3887 fn test_access_action_variants() {
3888 #[derive(Debug, Deserialize)]
3890 struct Wrapper {
3891 action: AccessAction,
3892 }
3893
3894 let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
3895 let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
3896
3897 assert_eq!(allow.action, AccessAction::Allow);
3898 assert_eq!(deny.action, AccessAction::Deny);
3899 }
3900
3901 #[test]
3902 fn test_network_policy_spec_default_impl() {
3903 let spec = NetworkPolicySpec::default();
3904 assert_eq!(spec.name, "");
3905 assert!(spec.description.is_none());
3906 assert!(spec.cidrs.is_empty());
3907 assert!(spec.members.is_empty());
3908 assert!(spec.access_rules.is_empty());
3909 }
3910
3911 #[test]
3912 fn container_restart_policy_serde_roundtrip_all_kinds() {
3913 let cases = [
3918 (
3919 ContainerRestartPolicy {
3920 kind: ContainerRestartKind::No,
3921 max_attempts: None,
3922 delay: None,
3923 },
3924 r#"{"kind":"no"}"#,
3925 ),
3926 (
3927 ContainerRestartPolicy {
3928 kind: ContainerRestartKind::Always,
3929 max_attempts: None,
3930 delay: Some("500ms".to_string()),
3931 },
3932 r#"{"kind":"always","delay":"500ms"}"#,
3933 ),
3934 (
3935 ContainerRestartPolicy {
3936 kind: ContainerRestartKind::UnlessStopped,
3937 max_attempts: None,
3938 delay: None,
3939 },
3940 r#"{"kind":"unless_stopped"}"#,
3941 ),
3942 (
3943 ContainerRestartPolicy {
3944 kind: ContainerRestartKind::OnFailure,
3945 max_attempts: Some(5),
3946 delay: None,
3947 },
3948 r#"{"kind":"on_failure","max_attempts":5}"#,
3949 ),
3950 ];
3951
3952 for (value, expected_json) in &cases {
3953 let serialized = serde_json::to_string(value).expect("serialize");
3954 assert_eq!(&serialized, expected_json, "serialize mismatch");
3955 let round: ContainerRestartPolicy =
3956 serde_json::from_str(&serialized).expect("deserialize");
3957 assert_eq!(&round, value, "roundtrip mismatch");
3958 }
3959 }
3960
3961 #[test]
3964 fn registry_auth_type_serializes_snake_case() {
3965 assert_eq!(
3966 serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
3967 "\"basic\""
3968 );
3969 assert_eq!(
3970 serde_json::to_string(&RegistryAuthType::Token).unwrap(),
3971 "\"token\""
3972 );
3973 }
3974
3975 #[test]
3976 fn registry_auth_default_auth_type_is_basic() {
3977 let json = r#"{"username":"u","password":"p"}"#;
3979 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3980 assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
3981 assert_eq!(parsed.username, "u");
3982 assert_eq!(parsed.password, "p");
3983 }
3984
3985 #[test]
3986 fn registry_auth_serde_roundtrip_both_variants() {
3987 for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
3988 let cred = RegistryAuth {
3989 username: "ci-bot".to_string(),
3990 password: "s3cret".to_string(),
3991 auth_type: variant,
3992 };
3993 let serialized = serde_json::to_string(&cred).expect("serialize");
3994 let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
3995 assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
3996 }
3997 }
3998
3999 #[test]
4000 fn registry_auth_explicit_token_type_parses() {
4001 let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
4002 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
4003 assert_eq!(parsed.auth_type, RegistryAuthType::Token);
4004 }
4005
4006 #[test]
4007 fn target_platform_as_oci_str() {
4008 assert_eq!(
4009 TargetPlatform::new(OsKind::Linux, ArchKind::Amd64).as_oci_str(),
4010 "linux/amd64"
4011 );
4012 assert_eq!(
4013 TargetPlatform::new(OsKind::Windows, ArchKind::Arm64).as_oci_str(),
4014 "windows/arm64"
4015 );
4016 assert_eq!(
4017 TargetPlatform::new(OsKind::Macos, ArchKind::Arm64).as_oci_str(),
4018 "darwin/arm64"
4019 );
4020 }
4021
4022 #[test]
4023 fn os_kind_from_rust_consts() {
4024 assert_eq!(OsKind::from_rust_os("linux"), Some(OsKind::Linux));
4025 assert_eq!(OsKind::from_rust_os("windows"), Some(OsKind::Windows));
4026 assert_eq!(OsKind::from_rust_os("macos"), Some(OsKind::Macos));
4027 assert_eq!(OsKind::from_rust_os("freebsd"), None);
4028 }
4029
4030 #[test]
4031 fn arch_kind_from_rust_consts() {
4032 assert_eq!(ArchKind::from_rust_arch("x86_64"), Some(ArchKind::Amd64));
4033 assert_eq!(ArchKind::from_rust_arch("aarch64"), Some(ArchKind::Arm64));
4034 assert_eq!(ArchKind::from_rust_arch("riscv64"), None);
4035 }
4036
4037 #[test]
4038 fn service_spec_platform_yaml_round_trip_none() {
4039 let yaml = r"
4042version: v1
4043deployment: test
4044services:
4045 app:
4046 rtype: service
4047 image:
4048 name: nginx:latest
4049";
4050 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
4051 assert!(spec.services["app"].platform.is_none());
4052 }
4053
4054 #[test]
4055 fn service_spec_platform_yaml_round_trip_some() {
4056 let yaml = r"
4057version: v1
4058deployment: test
4059services:
4060 app:
4061 rtype: service
4062 image:
4063 name: nginx:latest
4064 platform:
4065 os: windows
4066 arch: amd64
4067";
4068 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
4069 assert_eq!(
4070 spec.services["app"].platform,
4071 Some(TargetPlatform::new(OsKind::Windows, ArchKind::Amd64))
4072 );
4073 }
4074
4075 #[test]
4076 fn service_spec_platform_serializes_omitted_when_none() {
4077 let yaml = r"
4080version: v1
4081deployment: test
4082services:
4083 app:
4084 rtype: service
4085 image:
4086 name: nginx:latest
4087";
4088 let mut spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
4089 let service = spec.services.get_mut("app").expect("service present");
4090 service.platform = None;
4091 let rendered = serde_yaml::to_string(service).expect("render");
4092 assert!(
4093 !rendered.contains("platform"),
4094 "platform must be omitted when None: {rendered}"
4095 );
4096 }
4097
4098 #[test]
4099 fn target_platform_os_version_builder() {
4100 let p =
4101 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
4102 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
4103 assert_eq!(p.os, OsKind::Windows);
4104 assert_eq!(p.arch, ArchKind::Amd64);
4105 }
4106
4107 #[test]
4108 fn target_platform_os_version_yaml_roundtrip() {
4109 let yaml = "os: windows\narch: amd64\nosVersion: 10.0.26100.1\n";
4110 let p: TargetPlatform = serde_yaml::from_str(yaml).expect("yaml parse");
4111 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
4112 assert_eq!(p.os, OsKind::Windows);
4113 assert_eq!(p.arch, ArchKind::Amd64);
4114 }
4115
4116 #[test]
4117 fn target_platform_os_version_yaml_omits_when_none() {
4118 let p = TargetPlatform::new(OsKind::Linux, ArchKind::Amd64);
4119 let rendered = serde_yaml::to_string(&p).expect("render");
4120 assert!(
4121 !rendered.contains("osVersion"),
4122 "osVersion must be omitted when None: {rendered}"
4123 );
4124 }
4125
4126 #[test]
4127 fn target_platform_as_detailed_str_includes_version() {
4128 let without = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).as_detailed_str();
4129 assert_eq!(without, "windows/amd64");
4130
4131 let with = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64)
4132 .with_os_version("10.0.26100.1")
4133 .as_detailed_str();
4134 assert_eq!(with, "windows/amd64 (os.version=10.0.26100.1)");
4135 }
4136
4137 #[test]
4138 fn target_platform_display_ignores_version() {
4139 let p =
4141 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
4142 assert_eq!(format!("{p}"), "windows/amd64");
4143 }
4144
4145 fn fixture_service_spec_full() -> ServiceSpec {
4151 let yaml = r"
4152version: v1
4153deployment: phase1-task1
4154services:
4155 hello:
4156 rtype: service
4157 image:
4158 name: hello-world:latest
4159";
4160 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse fixture");
4161 spec.services.get("hello").expect("hello service").clone()
4162 }
4163
4164 #[test]
4165 fn service_spec_round_trip_with_all_new_fields() {
4166 let mut spec = fixture_service_spec_full();
4167 spec.labels
4168 .insert("zlayer.team".to_string(), "platform".to_string());
4169 spec.user = Some("1000:1000".to_string());
4170 spec.stop_signal = Some("SIGTERM".to_string());
4171 spec.stop_grace_period = Some(std::time::Duration::from_secs(30));
4172 spec.sysctls
4173 .insert("net.core.somaxconn".to_string(), "1024".to_string());
4174 spec.ulimits.insert(
4175 "nofile".to_string(),
4176 UlimitSpec {
4177 soft: 65_536,
4178 hard: 65_536,
4179 },
4180 );
4181 spec.security_opt.push("no-new-privileges:true".to_string());
4182 spec.pid_mode = Some("host".to_string());
4183 spec.ipc_mode = Some("private".to_string());
4184 spec.network_mode = NetworkMode::Bridge {
4185 name: Some("custom-net".to_string()),
4186 };
4187 spec.cap_drop.push("NET_RAW".to_string());
4188 spec.extra_groups.push("docker".to_string());
4189 spec.read_only_root_fs = true;
4190 spec.init_container = Some(true);
4191 spec.resources.pids_limit = Some(2048);
4192 spec.resources.cpuset = Some("0-3".to_string());
4193 spec.resources.cpu_shares = Some(1024);
4194 spec.resources.memory_swap = Some("2Gi".to_string());
4195 spec.resources.memory_reservation = Some("256Mi".to_string());
4196 spec.resources.memory_swappiness = Some(10);
4197 spec.resources.oom_score_adj = Some(-500);
4198 spec.resources.oom_kill_disable = Some(false);
4199 spec.resources.blkio_weight = Some(500);
4200
4201 let yaml = serde_yaml::to_string(&spec).expect("serialize");
4202 let round: ServiceSpec = serde_yaml::from_str(&yaml).expect("deserialize");
4203 assert_eq!(spec, round, "round-trip mismatch:\n{yaml}");
4204 }
4205
4206 #[test]
4207 fn network_mode_string_form_round_trip() {
4208 let cases: &[(&str, NetworkMode)] = &[
4209 ("default", NetworkMode::Default),
4210 ("host", NetworkMode::Host),
4211 ("none", NetworkMode::None),
4212 ("bridge", NetworkMode::Bridge { name: None }),
4213 (
4214 "bridge:custom",
4215 NetworkMode::Bridge {
4216 name: Some("custom".to_string()),
4217 },
4218 ),
4219 (
4220 "container:abc123",
4221 NetworkMode::Container {
4222 id: "abc123".to_string(),
4223 },
4224 ),
4225 ];
4226
4227 for (input, expected) in cases {
4228 #[derive(Deserialize)]
4229 struct Wrap {
4230 #[serde(deserialize_with = "deserialize_network_mode")]
4231 m: NetworkMode,
4232 }
4233 let yaml = format!("m: \"{input}\"\n");
4234 let parsed: Wrap = serde_yaml::from_str(&yaml).expect("parse network mode");
4235 assert_eq!(&parsed.m, expected, "mismatch for {input}");
4236 }
4237 }
4238
4239 #[test]
4240 fn ulimit_spec_round_trip() {
4241 let u = UlimitSpec {
4242 soft: 1024,
4243 hard: 65_536,
4244 };
4245 let yaml = serde_yaml::to_string(&u).expect("serialize");
4246 let parsed: UlimitSpec = serde_yaml::from_str(&yaml).expect("parse");
4247 assert_eq!(u, parsed);
4248 }
4249
4250 #[test]
4251 fn ulimit_spec_full_form() {
4252 let parsed: UlimitSpec =
4253 serde_yaml::from_str("soft: 100000\nhard: 200000\n").expect("parse");
4254 assert_eq!(
4255 parsed,
4256 UlimitSpec {
4257 soft: 100_000,
4258 hard: 200_000,
4259 }
4260 );
4261 }
4262
4263 #[test]
4264 fn ulimit_spec_soft_only_defaults_hard_to_soft() {
4265 let parsed: UlimitSpec = serde_yaml::from_str("soft: 100000\n").expect("parse");
4269 assert_eq!(
4270 parsed,
4271 UlimitSpec {
4272 soft: 100_000,
4273 hard: 100_000,
4274 }
4275 );
4276 }
4277
4278 #[test]
4279 fn ulimit_spec_hard_only_defaults_soft_to_hard() {
4280 let parsed: UlimitSpec = serde_yaml::from_str("hard: 100000\n").expect("parse");
4282 assert_eq!(
4283 parsed,
4284 UlimitSpec {
4285 soft: 100_000,
4286 hard: 100_000,
4287 }
4288 );
4289 }
4290
4291 #[test]
4292 fn ulimit_spec_both_absent_is_zero() {
4293 let parsed: UlimitSpec = serde_yaml::from_str("{}\n").expect("parse");
4294 assert_eq!(parsed, UlimitSpec { soft: 0, hard: 0 });
4295 }
4296
4297 #[test]
4298 fn ulimit_spec_explicit_zero_hard_is_preserved() {
4299 let parsed: UlimitSpec = serde_yaml::from_str("soft: 100000\nhard: 0\n").expect("parse");
4302 assert_eq!(
4303 parsed,
4304 UlimitSpec {
4305 soft: 100_000,
4306 hard: 0,
4307 }
4308 );
4309 }
4310
4311 #[test]
4312 fn ulimit_spec_in_service_map_soft_only() {
4313 #[derive(Deserialize)]
4316 struct Wrap {
4317 ulimits: std::collections::HashMap<String, UlimitSpec>,
4318 }
4319 let yaml = r"
4320ulimits:
4321 nofile:
4322 soft: 100000
4323";
4324 let parsed: Wrap = serde_yaml::from_str(yaml).expect("parse");
4325 assert_eq!(
4326 parsed.ulimits.get("nofile"),
4327 Some(&UlimitSpec {
4328 soft: 100_000,
4329 hard: 100_000,
4330 })
4331 );
4332 }
4333
4334 #[test]
4335 fn host_network_true_yaml_promotes_to_network_mode_host() {
4336 let yaml = r"
4337version: v1
4338deployment: bc-test
4339services:
4340 hello:
4341 rtype: service
4342 image:
4343 name: hello-world:latest
4344 host_network: true
4345";
4346 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse");
4347 let svc = dep.services.get("hello").expect("hello service");
4348 assert_eq!(svc.network_mode, NetworkMode::Host);
4349 assert!(svc.host_network);
4352 }
4353
4354 #[test]
4355 fn capabilities_yaml_alias_cap_add_round_trip() {
4356 let yaml = r"
4359version: v1
4360deployment: cap-test
4361services:
4362 hello:
4363 rtype: service
4364 image:
4365 name: hello-world:latest
4366 cap_add:
4367 - NET_ADMIN
4368 - SYS_PTRACE
4369";
4370 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse cap_add alias");
4371 let svc = dep.services.get("hello").expect("hello service");
4372 assert_eq!(
4373 svc.capabilities,
4374 vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()]
4375 );
4376 }
4377
4378 #[test]
4379 fn lifecycle_omitted_defaults_to_false() {
4380 let yaml = r"
4386version: v1
4387deployment: lifecycle-default-test
4388services:
4389 app:
4390 rtype: service
4391 image:
4392 name: hello-world:latest
4393";
4394 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec without lifecycle");
4395 let svc = dep.services.get("app").expect("app service");
4396 assert_eq!(svc.lifecycle, LifecycleSpec::default());
4397 assert!(!svc.lifecycle.delete_on_exit);
4398 }
4399
4400 #[test]
4401 fn lifecycle_delete_on_exit_round_trips() {
4402 let yaml = r"
4406version: v1
4407deployment: lifecycle-delete-test
4408services:
4409 app:
4410 rtype: service
4411 image:
4412 name: hello-world:latest
4413 lifecycle:
4414 delete_on_exit: true
4415";
4416 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec with lifecycle");
4417 let svc = dep.services.get("app").expect("app service");
4418 assert!(svc.lifecycle.delete_on_exit);
4419
4420 let dumped = serde_yaml::to_string(&dep).expect("serialize spec with lifecycle");
4423 let reparsed: DeploymentSpec =
4424 serde_yaml::from_str(&dumped).expect("reparse round-tripped spec");
4425 let reparsed_svc = reparsed.services.get("app").expect("app service after rt");
4426 assert!(reparsed_svc.lifecycle.delete_on_exit);
4427 assert_eq!(svc.lifecycle, reparsed_svc.lifecycle);
4428 }
4429}
4430
4431#[cfg(test)]
4432mod replica_group_tests {
4433 use super::{
4434 validate_unique_replica_group_roles, EndpointSpec, GroupAffinity, LocalhostReachability,
4435 ReplicaGroup, ScaleSpec, ScaleTargets, ServiceSpec, REPLICA_GROUP_ROLE_RE,
4436 };
4437
4438 #[test]
4439 fn yaml_roundtrip_basic_group() {
4440 let yaml = r"
4441role: primary
4442count: 1
4443env:
4444 POSTGRES_REPLICATION_MODE: primary
4445affinity: spread
4446";
4447 let group: ReplicaGroup = serde_yaml::from_str(yaml).expect("parse basic group");
4448 assert_eq!(group.role, "primary");
4449 assert_eq!(group.count, 1);
4450 assert_eq!(group.affinity, GroupAffinity::Spread);
4451 assert_eq!(
4452 group.env.get("POSTGRES_REPLICATION_MODE"),
4453 Some(&"primary".to_string())
4454 );
4455 }
4456
4457 #[test]
4458 fn yaml_default_affinity_is_spread() {
4459 let yaml = "role: x\ncount: 2\n";
4460 let group: ReplicaGroup = serde_yaml::from_str(yaml).expect("parse minimal group");
4461 assert_eq!(group.affinity, GroupAffinity::Spread);
4462 }
4463
4464 #[test]
4465 fn role_regex_accepts_valid_labels() {
4466 for ok in ["a", "primary", "read-only", "x1", "ab-cd-ef"] {
4467 assert!(
4468 REPLICA_GROUP_ROLE_RE.is_match(ok),
4469 "regex should accept: {ok}"
4470 );
4471 }
4472 }
4473
4474 #[test]
4475 fn role_regex_rejects_invalid_labels() {
4476 for bad in [
4477 "",
4478 "-primary",
4479 "primary-",
4480 "Primary",
4481 "0primary",
4482 "primary_role",
4483 "this-is-way-too-long-of-a-role-name-here",
4484 ] {
4485 assert!(
4486 !REPLICA_GROUP_ROLE_RE.is_match(bad),
4487 "regex should reject: {bad}"
4488 );
4489 }
4490 }
4491
4492 #[test]
4493 fn group_affinity_pin_roundtrips_via_serde_yaml() {
4494 let pinned = GroupAffinity::Pin("id=2".to_string());
4497 let dumped = serde_yaml::to_string(&pinned).expect("serialize pin");
4498 let reparsed: GroupAffinity = serde_yaml::from_str(&dumped).expect("reparse pin");
4499 match reparsed {
4500 GroupAffinity::Pin(s) => assert_eq!(s, "id=2"),
4501 other => panic!("expected Pin, got {other:?}"),
4502 }
4503 }
4504
4505 #[test]
4506 fn unique_role_validator_rejects_duplicates() {
4507 let mk = |role: &str| ReplicaGroup {
4508 role: role.to_string(),
4509 count: 1,
4510 image: None,
4511 env: std::collections::HashMap::new(),
4512 command: None,
4513 resources: None,
4514 affinity: GroupAffinity::Spread,
4515 };
4516 assert!(validate_unique_replica_group_roles(&[mk("a"), mk("b")]).is_ok());
4517 let err = validate_unique_replica_group_roles(&[mk("a"), mk("a")])
4518 .expect_err("duplicate should fail");
4519 assert_eq!(err, "a");
4520 }
4521
4522 #[test]
4523 fn endpoint_target_role_yaml_roundtrip() {
4524 let yaml = "name: read\nprotocol: tcp\nport: 5433\ntarget_role: read\n";
4525 let ep: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
4526 assert_eq!(ep.target_role, Some("read".to_string()));
4527 }
4528
4529 #[test]
4530 fn endpoint_without_target_role_is_none() {
4531 let yaml = "name: any\nprotocol: tcp\nport: 5432\n";
4532 let ep: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
4533 assert_eq!(ep.target_role, None);
4534 }
4535
4536 fn spec_with_scale(scale: ScaleSpec) -> ServiceSpec {
4541 let mut s = ServiceSpec::minimal("svc", "scratch:latest");
4542 s.scale = scale;
4543 s
4544 }
4545
4546 fn replica_group(role: &str, count: u32) -> ReplicaGroup {
4547 ReplicaGroup {
4548 role: role.to_string(),
4549 count,
4550 image: None,
4551 env: std::collections::HashMap::new(),
4552 command: None,
4553 resources: None,
4554 affinity: GroupAffinity::Spread,
4555 }
4556 }
4557
4558 #[test]
4559 fn is_single_member_across_scale_modes() {
4560 assert!(spec_with_scale(ScaleSpec::Fixed { replicas: 1 }).is_single_member());
4561 assert!(spec_with_scale(ScaleSpec::Fixed { replicas: 0 }).is_single_member());
4562 assert!(!spec_with_scale(ScaleSpec::Fixed { replicas: 3 }).is_single_member());
4563
4564 let adaptive = |min, max| ScaleSpec::Adaptive {
4565 min,
4566 max,
4567 cooldown: None,
4568 targets: ScaleTargets::default(),
4569 };
4570 assert!(spec_with_scale(adaptive(1, 1)).is_single_member());
4571 assert!(!spec_with_scale(adaptive(1, 5)).is_single_member());
4572
4573 assert!(spec_with_scale(ScaleSpec::Manual).is_single_member());
4574 }
4575
4576 #[test]
4577 fn is_single_member_with_replica_groups() {
4578 let mut s = ServiceSpec::minimal("svc", "scratch:latest");
4580 s.replica_groups = Some(vec![replica_group("only", 1)]);
4581 assert!(s.is_single_member());
4582
4583 s.replica_groups = Some(vec![replica_group("only", 2)]);
4585 assert!(!s.is_single_member());
4586
4587 s.replica_groups = Some(vec![replica_group("a", 1), replica_group("b", 1)]);
4589 assert!(!s.is_single_member());
4590
4591 s.scale = ScaleSpec::Fixed { replicas: 1 };
4593 s.replica_groups = Some(vec![replica_group("a", 1), replica_group("b", 1)]);
4594 assert!(!s.is_single_member());
4595 }
4596
4597 #[test]
4598 fn publish_to_node_loopback_override_matrix() {
4599 let single = spec_with_scale(ScaleSpec::Fixed { replicas: 1 });
4601 let multi = spec_with_scale(ScaleSpec::Fixed { replicas: 3 });
4603
4604 let mut s = single.clone();
4606 s.localhost_reachability = LocalhostReachability::Auto;
4607 assert!(s.publish_to_node_loopback());
4608 let mut m = multi.clone();
4609 m.localhost_reachability = LocalhostReachability::Auto;
4610 assert!(!m.publish_to_node_loopback());
4611
4612 let mut s = single.clone();
4614 s.localhost_reachability = LocalhostReachability::Always;
4615 assert!(s.publish_to_node_loopback());
4616 let mut m = multi.clone();
4617 m.localhost_reachability = LocalhostReachability::Always;
4618 assert!(m.publish_to_node_loopback());
4619
4620 let mut s = single;
4622 s.localhost_reachability = LocalhostReachability::Never;
4623 assert!(!s.publish_to_node_loopback());
4624 let mut m = multi;
4625 m.localhost_reachability = LocalhostReachability::Never;
4626 assert!(!m.publish_to_node_loopback());
4627 }
4628
4629 #[test]
4630 fn localhost_reachability_default_is_auto() {
4631 assert_eq!(
4632 LocalhostReachability::default(),
4633 LocalhostReachability::Auto
4634 );
4635 assert!(LocalhostReachability::Auto.is_default());
4636 assert!(!LocalhostReachability::Always.is_default());
4637 assert!(!LocalhostReachability::Never.is_default());
4638 let minimal = ServiceSpec::minimal("svc", "scratch:latest");
4641 assert_eq!(minimal.localhost_reachability, LocalhostReachability::Auto);
4642 assert!(!minimal.is_single_member());
4643 assert!(!minimal.publish_to_node_loopback());
4644 }
4645}