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, skip_serializing_if = "Option::is_none")]
1082 pub deployment: Option<String>,
1083
1084 #[serde(default = "default_resource_type")]
1086 pub rtype: ResourceType,
1087
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1095 #[validate(custom(function = "crate::spec::validate::validate_schedule_wrapper"))]
1096 pub schedule: Option<String>,
1097
1098 #[validate(nested)]
1100 pub image: ImageSpec,
1101
1102 #[serde(default)]
1104 #[validate(nested)]
1105 pub resources: ResourcesSpec,
1106
1107 #[serde(default)]
1114 pub env: HashMap<String, String>,
1115
1116 #[serde(default)]
1118 pub command: CommandSpec,
1119
1120 #[serde(default)]
1122 pub network: ServiceNetworkSpec,
1123
1124 #[serde(default)]
1126 #[validate(nested)]
1127 pub endpoints: Vec<EndpointSpec>,
1128
1129 #[serde(default)]
1131 #[validate(custom(function = "crate::spec::validate::validate_scale_spec"))]
1132 pub scale: ScaleSpec,
1133
1134 #[serde(default, skip_serializing_if = "Option::is_none")]
1149 #[validate(nested)]
1150 pub replica_groups: Option<Vec<ReplicaGroup>>,
1151
1152 #[serde(default)]
1154 pub depends: Vec<DependsSpec>,
1155
1156 #[serde(default = "default_health")]
1158 pub health: HealthSpec,
1159
1160 #[serde(default)]
1162 pub init: InitSpec,
1163
1164 #[serde(default)]
1166 pub errors: ErrorsSpec,
1167
1168 #[serde(default)]
1174 pub lifecycle: LifecycleSpec,
1175
1176 #[serde(default, skip_serializing_if = "Option::is_none")]
1178 pub isolation: Option<IsolationMode>,
1179
1180 #[serde(default)]
1182 pub devices: Vec<DeviceSpec>,
1183
1184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1186 pub storage: Vec<StorageSpec>,
1187
1188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1193 pub port_mappings: Vec<PortMapping>,
1194
1195 #[serde(default, alias = "cap_add", skip_serializing_if = "Vec::is_empty")]
1199 pub capabilities: Vec<String>,
1200
1201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1203 pub cap_drop: Vec<String>,
1204
1205 #[serde(default)]
1207 pub privileged: bool,
1208
1209 #[serde(default)]
1211 pub node_mode: NodeMode,
1212
1213 #[serde(default, skip_serializing_if = "Option::is_none")]
1215 pub node_selector: Option<NodeSelector>,
1216
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1229 pub affinity: Option<GroupAffinity>,
1230
1231 #[serde(default, skip_serializing_if = "Option::is_none")]
1235 pub platform: Option<TargetPlatform>,
1236
1237 #[serde(default)]
1239 pub service_type: ServiceType,
1240
1241 #[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
1244 pub wasm: Option<WasmConfig>,
1245
1246 #[serde(default, skip_serializing_if = "Option::is_none")]
1248 pub logs: Option<LogsConfig>,
1249
1250 #[serde(skip)]
1255 pub host_network: bool,
1256
1257 #[serde(default, skip_serializing_if = "Option::is_none")]
1263 pub hostname: Option<String>,
1264
1265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1271 pub dns: Vec<String>,
1272
1273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1281 pub extra_hosts: Vec<String>,
1282
1283 #[serde(default, skip_serializing_if = "Option::is_none")]
1291 pub restart_policy: Option<ContainerRestartPolicy>,
1292
1293 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1296 pub labels: HashMap<String, String>,
1297
1298 #[serde(default, skip_serializing_if = "Option::is_none")]
1301 pub user: Option<String>,
1302
1303 #[serde(default, skip_serializing_if = "Option::is_none")]
1306 pub stop_signal: Option<String>,
1307
1308 #[serde(
1311 default,
1312 with = "duration::option",
1313 skip_serializing_if = "Option::is_none"
1314 )]
1315 pub stop_grace_period: Option<std::time::Duration>,
1316
1317 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1319 pub sysctls: HashMap<String, String>,
1320
1321 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1323 pub ulimits: HashMap<String, UlimitSpec>,
1324
1325 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1328 pub security_opt: Vec<String>,
1329
1330 #[serde(default, skip_serializing_if = "Option::is_none")]
1333 pub pid_mode: Option<String>,
1334
1335 #[serde(default, skip_serializing_if = "Option::is_none")]
1338 pub ipc_mode: Option<String>,
1339
1340 #[serde(default, deserialize_with = "deserialize_network_mode")]
1344 pub network_mode: NetworkMode,
1345
1346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1349 pub extra_groups: Vec<String>,
1350
1351 #[serde(default)]
1353 pub read_only_root_fs: bool,
1354
1355 #[serde(default, skip_serializing_if = "Option::is_none")]
1359 pub init_container: Option<bool>,
1360
1361 #[serde(default)]
1364 pub tty: bool,
1365
1366 #[serde(default)]
1369 pub stdin_open: bool,
1370
1371 #[serde(default, skip_serializing_if = "Option::is_none")]
1374 pub userns_mode: Option<String>,
1375
1376 #[serde(default, skip_serializing_if = "Option::is_none")]
1379 pub cgroup_parent: Option<String>,
1380
1381 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1387 pub expose: Vec<String>,
1388
1389 #[serde(default, skip_serializing_if = "Option::is_none")]
1396 pub overlay: Option<crate::overlay::OverlayConfig>,
1397
1398 #[serde(default, skip_serializing_if = "LocalhostReachability::is_default")]
1403 pub localhost_reachability: LocalhostReachability,
1404}
1405
1406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1417#[serde(rename_all = "snake_case")]
1418pub enum LocalhostReachability {
1419 #[default]
1425 Auto,
1426 Always,
1428 Never,
1430}
1431
1432impl LocalhostReachability {
1433 #[must_use]
1436 pub fn is_default(&self) -> bool {
1437 matches!(self, Self::Auto)
1438 }
1439}
1440
1441#[derive(Deserialize)]
1448#[serde(deny_unknown_fields)]
1449#[allow(clippy::struct_excessive_bools)]
1450struct ServiceSpecCompat {
1451 #[serde(default)]
1452 deployment: Option<String>,
1453 #[serde(default = "default_resource_type")]
1454 rtype: ResourceType,
1455 #[serde(default)]
1456 schedule: Option<String>,
1457 image: ImageSpec,
1458 #[serde(default)]
1459 resources: ResourcesSpec,
1460 #[serde(default)]
1461 env: HashMap<String, String>,
1462 #[serde(default)]
1463 command: CommandSpec,
1464 #[serde(default)]
1465 network: ServiceNetworkSpec,
1466 #[serde(default)]
1467 endpoints: Vec<EndpointSpec>,
1468 #[serde(default)]
1469 scale: ScaleSpec,
1470 #[serde(default)]
1471 replica_groups: Option<Vec<ReplicaGroup>>,
1472 #[serde(default)]
1473 depends: Vec<DependsSpec>,
1474 #[serde(default = "default_health")]
1475 health: HealthSpec,
1476 #[serde(default)]
1477 init: InitSpec,
1478 #[serde(default)]
1479 errors: ErrorsSpec,
1480 #[serde(default)]
1481 lifecycle: LifecycleSpec,
1482 #[serde(default)]
1483 isolation: Option<IsolationMode>,
1484 #[serde(default)]
1485 devices: Vec<DeviceSpec>,
1486 #[serde(default)]
1487 storage: Vec<StorageSpec>,
1488 #[serde(default)]
1489 port_mappings: Vec<PortMapping>,
1490 #[serde(default, alias = "cap_add")]
1491 capabilities: Vec<String>,
1492 #[serde(default)]
1493 cap_drop: Vec<String>,
1494 #[serde(default)]
1495 privileged: bool,
1496 #[serde(default)]
1497 node_mode: NodeMode,
1498 #[serde(default)]
1499 node_selector: Option<NodeSelector>,
1500 #[serde(default)]
1501 affinity: Option<GroupAffinity>,
1502 #[serde(default)]
1503 platform: Option<TargetPlatform>,
1504 #[serde(default)]
1505 service_type: ServiceType,
1506 #[serde(default, alias = "wasm_http")]
1507 wasm: Option<WasmConfig>,
1508 #[serde(default)]
1509 logs: Option<LogsConfig>,
1510 #[serde(default)]
1513 host_network: Option<bool>,
1514 #[serde(default)]
1515 hostname: Option<String>,
1516 #[serde(default)]
1517 dns: Vec<String>,
1518 #[serde(default)]
1519 extra_hosts: Vec<String>,
1520 #[serde(default)]
1521 restart_policy: Option<ContainerRestartPolicy>,
1522 #[serde(default)]
1523 labels: HashMap<String, String>,
1524 #[serde(default)]
1525 user: Option<String>,
1526 #[serde(default)]
1527 stop_signal: Option<String>,
1528 #[serde(default, with = "duration::option")]
1529 stop_grace_period: Option<std::time::Duration>,
1530 #[serde(default)]
1531 sysctls: HashMap<String, String>,
1532 #[serde(default)]
1533 ulimits: HashMap<String, UlimitSpec>,
1534 #[serde(default)]
1535 security_opt: Vec<String>,
1536 #[serde(default)]
1537 pid_mode: Option<String>,
1538 #[serde(default)]
1539 ipc_mode: Option<String>,
1540 #[serde(default, deserialize_with = "deserialize_network_mode")]
1541 network_mode: NetworkMode,
1542 #[serde(default)]
1543 extra_groups: Vec<String>,
1544 #[serde(default)]
1545 read_only_root_fs: bool,
1546 #[serde(default)]
1547 init_container: Option<bool>,
1548 #[serde(default)]
1549 tty: bool,
1550 #[serde(default)]
1551 stdin_open: bool,
1552 #[serde(default)]
1553 userns_mode: Option<String>,
1554 #[serde(default)]
1555 cgroup_parent: Option<String>,
1556 #[serde(default)]
1557 expose: Vec<String>,
1558 #[serde(default)]
1559 overlay: Option<crate::overlay::OverlayConfig>,
1560 #[serde(default)]
1561 localhost_reachability: LocalhostReachability,
1562}
1563
1564impl From<ServiceSpecCompat> for ServiceSpec {
1565 fn from(c: ServiceSpecCompat) -> Self {
1566 let network_mode = match (c.host_network, &c.network_mode) {
1571 (Some(true), NetworkMode::Default) => NetworkMode::Host,
1572 _ => c.network_mode,
1573 };
1574 let host_network = c.host_network.unwrap_or(false) || network_mode == NetworkMode::Host;
1575
1576 Self {
1577 deployment: c.deployment,
1578 rtype: c.rtype,
1579 schedule: c.schedule,
1580 image: c.image,
1581 resources: c.resources,
1582 env: c.env,
1583 command: c.command,
1584 network: c.network,
1585 endpoints: c.endpoints,
1586 scale: c.scale,
1587 replica_groups: c.replica_groups,
1588 depends: c.depends,
1589 health: c.health,
1590 init: c.init,
1591 errors: c.errors,
1592 lifecycle: c.lifecycle,
1593 isolation: c.isolation,
1594 devices: c.devices,
1595 storage: c.storage,
1596 port_mappings: c.port_mappings,
1597 capabilities: c.capabilities,
1598 cap_drop: c.cap_drop,
1599 privileged: c.privileged,
1600 node_mode: c.node_mode,
1601 node_selector: c.node_selector,
1602 affinity: c.affinity,
1603 platform: c.platform,
1604 service_type: c.service_type,
1605 wasm: c.wasm,
1606 logs: c.logs,
1607 host_network,
1608 hostname: c.hostname,
1609 dns: c.dns,
1610 extra_hosts: c.extra_hosts,
1611 restart_policy: c.restart_policy,
1612 labels: c.labels,
1613 user: c.user,
1614 stop_signal: c.stop_signal,
1615 stop_grace_period: c.stop_grace_period,
1616 sysctls: c.sysctls,
1617 ulimits: c.ulimits,
1618 security_opt: c.security_opt,
1619 pid_mode: c.pid_mode,
1620 ipc_mode: c.ipc_mode,
1621 network_mode,
1622 extra_groups: c.extra_groups,
1623 read_only_root_fs: c.read_only_root_fs,
1624 init_container: c.init_container,
1625 tty: c.tty,
1626 stdin_open: c.stdin_open,
1627 userns_mode: c.userns_mode,
1628 cgroup_parent: c.cgroup_parent,
1629 expose: c.expose,
1630 overlay: c.overlay,
1631 localhost_reachability: c.localhost_reachability,
1632 }
1633 }
1634}
1635
1636impl ServiceSpec {
1637 #[must_use]
1646 pub fn is_single_member(&self) -> bool {
1647 if let Some(groups) = &self.replica_groups {
1648 let total: u32 = groups.iter().map(|g| g.count).sum();
1649 return groups.len() <= 1 && total <= 1;
1650 }
1651 match &self.scale {
1652 ScaleSpec::Fixed { replicas } => *replicas <= 1,
1653 ScaleSpec::Adaptive { max, .. } => *max <= 1,
1654 ScaleSpec::Manual => true,
1655 }
1656 }
1657
1658 #[must_use]
1663 pub fn publish_to_node_loopback(&self) -> bool {
1664 match self.localhost_reachability {
1665 LocalhostReachability::Always => true,
1666 LocalhostReachability::Never => false,
1667 LocalhostReachability::Auto => self.is_single_member(),
1668 }
1669 }
1670
1671 #[must_use]
1693 pub fn minimal(_name: impl Into<String>, image: impl Into<String>) -> Self {
1694 use std::str::FromStr;
1695 let image_str = image.into();
1696 let image_ref = crate::ImageRef::from_str(&image_str).unwrap_or_else(|_| {
1697 crate::ImageRef::from_str("scratch:latest")
1698 .expect("'scratch:latest' is a valid image reference")
1699 });
1700 Self {
1701 image: ImageSpec {
1702 name: image_ref,
1703 pull_policy: default_pull_policy(),
1704 source_policy: None,
1705 },
1706 ..Self::default()
1707 }
1708 }
1709}
1710
1711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1713#[serde(deny_unknown_fields)]
1714pub struct CommandSpec {
1715 #[serde(default, skip_serializing_if = "Option::is_none")]
1717 pub entrypoint: Option<Vec<String>>,
1718
1719 #[serde(default, skip_serializing_if = "Option::is_none")]
1721 pub args: Option<Vec<String>>,
1722
1723 #[serde(default, skip_serializing_if = "Option::is_none")]
1725 pub workdir: Option<String>,
1726}
1727
1728fn default_resource_type() -> ResourceType {
1729 ResourceType::Service
1730}
1731
1732fn default_health() -> HealthSpec {
1733 HealthSpec {
1734 start_grace: Some(std::time::Duration::from_secs(5)),
1735 interval: None,
1736 timeout: None,
1737 retries: 3,
1738 check: HealthCheck::Tcp { port: 0 },
1739 }
1740}
1741
1742#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1744#[serde(rename_all = "lowercase")]
1745pub enum ResourceType {
1746 #[default]
1748 Service,
1749 Job,
1751 Cron,
1753}
1754
1755#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1761#[serde(rename_all = "snake_case")]
1762pub enum SourcePolicy {
1763 #[default]
1765 LocalFirst,
1766 S3First,
1769 RemoteOnly,
1772 LocalOnly,
1775}
1776
1777#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1779#[serde(deny_unknown_fields)]
1780pub struct ImageSpec {
1781 pub name: crate::ImageRef,
1783
1784 #[serde(default = "default_pull_policy")]
1786 pub pull_policy: PullPolicy,
1787
1788 #[serde(default, skip_serializing_if = "Option::is_none")]
1791 pub source_policy: Option<SourcePolicy>,
1792}
1793
1794fn default_pull_policy() -> PullPolicy {
1795 PullPolicy::IfNotPresent
1796}
1797
1798impl Default for ImageSpec {
1799 fn default() -> Self {
1807 use std::str::FromStr;
1808 Self {
1809 name: crate::ImageRef::from_str("scratch:latest")
1810 .expect("'scratch:latest' is a valid image reference"),
1811 pull_policy: default_pull_policy(),
1812 source_policy: None,
1813 }
1814 }
1815}
1816
1817#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1819#[serde(rename_all = "snake_case")]
1820pub enum PullPolicy {
1821 Always,
1823 Newer,
1825 IfNotPresent,
1831 Never,
1833}
1834
1835#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate, utoipa::ToSchema)]
1837#[serde(deny_unknown_fields)]
1838pub struct DeviceSpec {
1839 #[validate(length(min = 1, message = "device path cannot be empty"))]
1841 pub path: String,
1842
1843 #[serde(default = "default_true")]
1845 pub read: bool,
1846
1847 #[serde(default = "default_true")]
1849 pub write: bool,
1850
1851 #[serde(default)]
1853 pub mknod: bool,
1854}
1855
1856fn default_true() -> bool {
1857 true
1858}
1859
1860#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1862#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
1863pub enum StorageSpec {
1864 Bind {
1866 source: String,
1867 target: String,
1868 #[serde(default)]
1869 readonly: bool,
1870 },
1871 Named {
1873 name: String,
1874 target: String,
1875 #[serde(default)]
1876 readonly: bool,
1877 #[serde(default)]
1879 tier: StorageTier,
1880 #[serde(default, skip_serializing_if = "Option::is_none")]
1882 size: Option<String>,
1883 },
1884 Anonymous {
1886 target: String,
1887 #[serde(default)]
1889 tier: StorageTier,
1890 },
1891 Tmpfs {
1893 target: String,
1894 #[serde(default)]
1895 size: Option<String>,
1896 #[serde(default)]
1897 mode: Option<u32>,
1898 },
1899 S3 {
1901 bucket: String,
1902 #[serde(default)]
1903 prefix: Option<String>,
1904 target: String,
1905 #[serde(default)]
1906 readonly: bool,
1907 #[serde(default)]
1908 endpoint: Option<String>,
1909 #[serde(default)]
1910 credentials: Option<String>,
1911 },
1912}
1913
1914#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
1916#[serde(deny_unknown_fields)]
1917pub struct ResourcesSpec {
1918 #[serde(default)]
1920 #[validate(custom(function = "crate::spec::validate::validate_cpu_option_wrapper"))]
1921 pub cpu: Option<f64>,
1922
1923 #[serde(default)]
1925 #[validate(custom(function = "crate::spec::validate::validate_memory_option_wrapper"))]
1926 pub memory: Option<String>,
1927
1928 #[serde(default, skip_serializing_if = "Option::is_none")]
1930 pub gpu: Option<GpuSpec>,
1931
1932 #[serde(default, skip_serializing_if = "Option::is_none")]
1935 pub pids_limit: Option<i64>,
1936
1937 #[serde(default, skip_serializing_if = "Option::is_none")]
1939 pub cpuset: Option<String>,
1940
1941 #[serde(default, skip_serializing_if = "Option::is_none")]
1943 pub cpu_shares: Option<u32>,
1944
1945 #[serde(default, skip_serializing_if = "Option::is_none")]
1947 pub memory_swap: Option<String>,
1948
1949 #[serde(default, skip_serializing_if = "Option::is_none")]
1951 pub memory_reservation: Option<String>,
1952
1953 #[serde(default, skip_serializing_if = "Option::is_none")]
1955 pub memory_swappiness: Option<u8>,
1956
1957 #[serde(default, skip_serializing_if = "Option::is_none")]
1959 pub oom_score_adj: Option<i32>,
1960
1961 #[serde(default, skip_serializing_if = "Option::is_none")]
1963 pub oom_kill_disable: Option<bool>,
1964
1965 #[serde(default, skip_serializing_if = "Option::is_none")]
1967 pub blkio_weight: Option<u16>,
1968}
1969
1970#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1972#[serde(rename_all = "kebab-case")]
1973pub enum SchedulingPolicy {
1974 #[default]
1976 BestEffort,
1977 Gang,
1979 Spread,
1981}
1982
1983#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1985#[serde(rename_all = "kebab-case")]
1986pub enum GpuSharingMode {
1987 #[default]
1989 Exclusive,
1990 Mps,
1993 TimeSlice,
1996}
1997
1998#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
2005#[serde(deny_unknown_fields)]
2006pub struct DistributedConfig {
2007 #[serde(default = "default_dist_backend")]
2009 pub backend: String,
2010 #[serde(default = "default_dist_port")]
2012 pub master_port: u16,
2013}
2014
2015fn default_dist_backend() -> String {
2016 "nccl".to_string()
2017}
2018
2019fn default_dist_port() -> u16 {
2020 29500
2021}
2022
2023#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
2042#[serde(deny_unknown_fields)]
2043pub struct GpuSpec {
2044 #[serde(default = "default_gpu_count")]
2046 pub count: u32,
2047 #[serde(default = "default_gpu_vendor")]
2049 pub vendor: String,
2050 #[serde(default, skip_serializing_if = "Option::is_none")]
2052 pub mode: Option<String>,
2053 #[serde(default, skip_serializing_if = "Option::is_none")]
2056 pub model: Option<String>,
2057 #[serde(default, skip_serializing_if = "Option::is_none")]
2062 pub scheduling: Option<SchedulingPolicy>,
2063 #[serde(default, skip_serializing_if = "Option::is_none")]
2066 pub distributed: Option<DistributedConfig>,
2067 #[serde(default, skip_serializing_if = "Option::is_none")]
2069 pub sharing: Option<GpuSharingMode>,
2070 #[serde(default, skip_serializing_if = "Option::is_none")]
2077 pub mps_pipe_dir: Option<String>,
2078 #[serde(default, skip_serializing_if = "Option::is_none")]
2084 pub mps_log_dir: Option<String>,
2085 #[serde(default, skip_serializing_if = "Option::is_none")]
2092 pub time_slice_index: Option<u32>,
2093 #[serde(default, skip_serializing_if = "Option::is_none")]
2101 pub time_slicing_config_path: Option<String>,
2102}
2103
2104fn default_gpu_count() -> u32 {
2105 1
2106}
2107
2108fn default_gpu_vendor() -> String {
2109 "nvidia".to_string()
2110}
2111
2112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2114#[serde(deny_unknown_fields)]
2115#[derive(Default)]
2116pub struct ServiceNetworkSpec {
2117 #[serde(default)]
2119 pub overlays: OverlayConfig,
2120
2121 #[serde(default)]
2123 pub join: JoinPolicy,
2124}
2125
2126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2128#[serde(deny_unknown_fields)]
2129pub struct OverlayConfig {
2130 #[serde(default)]
2132 pub service: OverlaySettings,
2133
2134 #[serde(default)]
2136 pub global: OverlaySettings,
2137}
2138
2139impl Default for OverlayConfig {
2140 fn default() -> Self {
2141 Self {
2142 service: OverlaySettings {
2143 enabled: true,
2144 encrypted: true,
2145 isolated: true,
2146 },
2147 global: OverlaySettings {
2148 enabled: true,
2149 encrypted: true,
2150 isolated: false,
2151 },
2152 }
2153 }
2154}
2155
2156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2158#[serde(deny_unknown_fields)]
2159pub struct OverlaySettings {
2160 #[serde(default = "default_enabled")]
2162 pub enabled: bool,
2163
2164 #[serde(default = "default_encrypted")]
2166 pub encrypted: bool,
2167
2168 #[serde(default)]
2170 pub isolated: bool,
2171}
2172
2173fn default_enabled() -> bool {
2174 true
2175}
2176
2177fn default_encrypted() -> bool {
2178 true
2179}
2180
2181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2183#[serde(deny_unknown_fields)]
2184pub struct JoinPolicy {
2185 #[serde(default = "default_join_mode")]
2187 pub mode: JoinMode,
2188
2189 #[serde(default = "default_join_scope")]
2191 pub scope: JoinScope,
2192}
2193
2194impl Default for JoinPolicy {
2195 fn default() -> Self {
2196 Self {
2197 mode: default_join_mode(),
2198 scope: default_join_scope(),
2199 }
2200 }
2201}
2202
2203fn default_join_mode() -> JoinMode {
2204 JoinMode::Token
2205}
2206
2207fn default_join_scope() -> JoinScope {
2208 JoinScope::Service
2209}
2210
2211#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2213#[serde(rename_all = "snake_case")]
2214pub enum JoinMode {
2215 Open,
2217 Token,
2219 Closed,
2221}
2222
2223#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2225#[serde(rename_all = "snake_case")]
2226pub enum JoinScope {
2227 Service,
2229 Global,
2231}
2232
2233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
2235#[serde(deny_unknown_fields)]
2236pub struct EndpointSpec {
2237 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
2239 pub name: String,
2240
2241 pub protocol: Protocol,
2243
2244 #[validate(custom(function = "crate::spec::validate::validate_port_wrapper"))]
2246 pub port: u16,
2247
2248 #[serde(default, skip_serializing_if = "Option::is_none")]
2251 pub target_port: Option<u16>,
2252
2253 pub path: Option<String>,
2255
2256 #[serde(default, skip_serializing_if = "Option::is_none")]
2259 pub host: Option<String>,
2260
2261 #[serde(default = "default_expose")]
2263 pub expose: ExposeType,
2264
2265 #[serde(default, skip_serializing_if = "Option::is_none")]
2268 pub stream: Option<StreamEndpointConfig>,
2269
2270 #[serde(default, skip_serializing_if = "Option::is_none")]
2294 pub target_role: Option<String>,
2295
2296 #[serde(default, skip_serializing_if = "Option::is_none")]
2298 pub tunnel: Option<EndpointTunnelConfig>,
2299}
2300
2301impl EndpointSpec {
2302 #[must_use]
2305 pub fn target_port(&self) -> u16 {
2306 self.target_port.unwrap_or(self.port)
2307 }
2308}
2309
2310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2312#[serde(deny_unknown_fields)]
2313pub struct EndpointTunnelConfig {
2314 #[serde(default)]
2316 pub enabled: bool,
2317
2318 #[serde(default, skip_serializing_if = "Option::is_none")]
2320 pub from: Option<String>,
2321
2322 #[serde(default, skip_serializing_if = "Option::is_none")]
2324 pub to: Option<String>,
2325
2326 #[serde(default)]
2328 pub remote_port: u16,
2329
2330 #[serde(default, skip_serializing_if = "Option::is_none")]
2332 pub expose: Option<ExposeType>,
2333
2334 #[serde(default, skip_serializing_if = "Option::is_none")]
2336 pub access: Option<TunnelAccessConfig>,
2337}
2338
2339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2341#[serde(deny_unknown_fields)]
2342pub struct TunnelAccessConfig {
2343 #[serde(default)]
2345 pub enabled: bool,
2346
2347 #[serde(default, skip_serializing_if = "Option::is_none")]
2349 pub max_ttl: Option<String>,
2350
2351 #[serde(default)]
2353 pub audit: bool,
2354}
2355
2356fn default_expose() -> ExposeType {
2357 ExposeType::Internal
2358}
2359
2360#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2362#[serde(rename_all = "lowercase")]
2363pub enum Protocol {
2364 Http,
2365 Https,
2366 Tcp,
2367 Udp,
2368 Websocket,
2369}
2370
2371#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2373#[serde(rename_all = "lowercase")]
2374pub enum ExposeType {
2375 Public,
2376 #[default]
2377 Internal,
2378}
2379
2380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2382#[serde(deny_unknown_fields)]
2383pub struct StreamEndpointConfig {
2384 #[serde(default)]
2386 pub tls: bool,
2387
2388 #[serde(default)]
2390 pub proxy_protocol: bool,
2391
2392 #[serde(default, skip_serializing_if = "Option::is_none")]
2395 pub session_timeout: Option<String>,
2396
2397 #[serde(default, skip_serializing_if = "Option::is_none")]
2399 pub health_check: Option<StreamHealthCheck>,
2400}
2401
2402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2404#[serde(tag = "type", rename_all = "snake_case")]
2405pub enum StreamHealthCheck {
2406 TcpConnect,
2408 UdpProbe {
2410 request: String,
2412 #[serde(default, skip_serializing_if = "Option::is_none")]
2414 expect: Option<String>,
2415 },
2416}
2417
2418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2420#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
2421pub enum ScaleSpec {
2422 #[serde(rename = "adaptive")]
2424 Adaptive {
2425 min: u32,
2427
2428 max: u32,
2430
2431 #[serde(default, with = "duration::option")]
2433 cooldown: Option<std::time::Duration>,
2434
2435 #[serde(default)]
2437 targets: ScaleTargets,
2438 },
2439
2440 #[serde(rename = "fixed")]
2442 Fixed { replicas: u32 },
2443
2444 #[serde(rename = "manual")]
2446 Manual,
2447}
2448
2449impl Default for ScaleSpec {
2450 fn default() -> Self {
2451 Self::Adaptive {
2452 min: 1,
2453 max: 10,
2454 cooldown: Some(std::time::Duration::from_secs(30)),
2455 targets: ScaleTargets::default(),
2456 }
2457 }
2458}
2459
2460#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2462#[serde(deny_unknown_fields)]
2463#[derive(Default)]
2464pub struct ScaleTargets {
2465 #[serde(default)]
2467 pub cpu: Option<u8>,
2468
2469 #[serde(default)]
2471 pub memory: Option<u8>,
2472
2473 #[serde(default)]
2475 pub rps: Option<u32>,
2476}
2477
2478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2480#[serde(deny_unknown_fields)]
2481pub struct DependsSpec {
2482 pub service: String,
2484
2485 #[serde(default = "default_condition")]
2487 pub condition: DependencyCondition,
2488
2489 #[serde(default = "default_timeout", with = "duration::option")]
2491 pub timeout: Option<std::time::Duration>,
2492
2493 #[serde(default = "default_on_timeout")]
2495 pub on_timeout: TimeoutAction,
2496}
2497
2498fn default_condition() -> DependencyCondition {
2499 DependencyCondition::Healthy
2500}
2501
2502#[allow(clippy::unnecessary_wraps)]
2503fn default_timeout() -> Option<std::time::Duration> {
2504 Some(std::time::Duration::from_secs(300))
2505}
2506
2507fn default_on_timeout() -> TimeoutAction {
2508 TimeoutAction::Fail
2509}
2510
2511#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2513#[serde(rename_all = "lowercase")]
2514pub enum DependencyCondition {
2515 Started,
2517 Healthy,
2519 Ready,
2521}
2522
2523#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2525#[serde(rename_all = "lowercase")]
2526pub enum TimeoutAction {
2527 Fail,
2528 Warn,
2529 Continue,
2530}
2531
2532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2534#[serde(deny_unknown_fields)]
2535pub struct HealthSpec {
2536 #[serde(default, with = "duration::option")]
2538 pub start_grace: Option<std::time::Duration>,
2539
2540 #[serde(default, with = "duration::option")]
2542 pub interval: Option<std::time::Duration>,
2543
2544 #[serde(default, with = "duration::option")]
2546 pub timeout: Option<std::time::Duration>,
2547
2548 #[serde(default = "default_retries")]
2550 pub retries: u32,
2551
2552 pub check: HealthCheck,
2554}
2555
2556fn default_retries() -> u32 {
2557 3
2558}
2559
2560impl Default for HealthSpec {
2561 fn default() -> Self {
2566 default_health()
2567 }
2568}
2569
2570#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2572#[serde(tag = "type", rename_all = "lowercase")]
2573pub enum HealthCheck {
2574 Tcp {
2576 port: u16,
2578 },
2579
2580 Http {
2582 url: String,
2584 #[serde(default = "default_expect_status")]
2586 expect_status: u16,
2587 },
2588
2589 Command {
2591 command: String,
2593 },
2594}
2595
2596fn default_expect_status() -> u16 {
2597 200
2598}
2599
2600#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2602#[serde(deny_unknown_fields)]
2603#[derive(Default)]
2604pub struct InitSpec {
2605 #[serde(default)]
2607 pub steps: Vec<InitStep>,
2608}
2609
2610#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
2617#[serde(deny_unknown_fields)]
2618pub struct LifecycleSpec {
2619 #[serde(default)]
2623 pub delete_on_exit: bool,
2624}
2625
2626#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2628#[serde(deny_unknown_fields)]
2629pub struct InitStep {
2630 pub id: String,
2632
2633 pub uses: String,
2635
2636 #[serde(default)]
2638 pub with: InitParams,
2639
2640 #[serde(default)]
2642 pub retry: Option<u32>,
2643
2644 #[serde(default, with = "duration::option")]
2646 pub timeout: Option<std::time::Duration>,
2647
2648 #[serde(default = "default_on_failure")]
2650 pub on_failure: FailureAction,
2651}
2652
2653fn default_on_failure() -> FailureAction {
2654 FailureAction::Fail
2655}
2656
2657pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
2659
2660#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2662#[serde(rename_all = "lowercase")]
2663pub enum FailureAction {
2664 Fail,
2665 Warn,
2666 Continue,
2667}
2668
2669#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2671#[serde(deny_unknown_fields)]
2672#[derive(Default)]
2673pub struct ErrorsSpec {
2674 #[serde(default)]
2676 pub on_init_failure: InitFailurePolicy,
2677
2678 #[serde(default)]
2680 pub on_panic: PanicPolicy,
2681}
2682
2683#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2685#[serde(deny_unknown_fields)]
2686pub struct InitFailurePolicy {
2687 #[serde(default = "default_init_action")]
2688 pub action: InitFailureAction,
2689}
2690
2691impl Default for InitFailurePolicy {
2692 fn default() -> Self {
2693 Self {
2694 action: default_init_action(),
2695 }
2696 }
2697}
2698
2699fn default_init_action() -> InitFailureAction {
2700 InitFailureAction::Fail
2701}
2702
2703#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2705#[serde(rename_all = "lowercase")]
2706pub enum InitFailureAction {
2707 Fail,
2708 Restart,
2709 Backoff,
2710}
2711
2712#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2714#[serde(deny_unknown_fields)]
2715pub struct PanicPolicy {
2716 #[serde(default = "default_panic_action")]
2717 pub action: PanicAction,
2718}
2719
2720impl Default for PanicPolicy {
2721 fn default() -> Self {
2722 Self {
2723 action: default_panic_action(),
2724 }
2725 }
2726}
2727
2728fn default_panic_action() -> PanicAction {
2729 PanicAction::Restart
2730}
2731
2732#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2734#[serde(rename_all = "lowercase")]
2735pub enum PanicAction {
2736 Restart,
2737 Shutdown,
2738 Isolate,
2739}
2740
2741#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
2748pub struct NetworkPolicySpec {
2749 pub name: String,
2751
2752 #[serde(default, skip_serializing_if = "Option::is_none")]
2754 pub description: Option<String>,
2755
2756 #[serde(default)]
2758 pub cidrs: Vec<String>,
2759
2760 #[serde(default)]
2762 pub members: Vec<NetworkMember>,
2763
2764 #[serde(default)]
2766 pub access_rules: Vec<AccessRule>,
2767}
2768
2769#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2771pub struct NetworkMember {
2772 pub name: String,
2774 #[serde(default)]
2776 pub kind: MemberKind,
2777}
2778
2779#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2781#[serde(rename_all = "lowercase")]
2782pub enum MemberKind {
2783 #[default]
2785 User,
2786 Group,
2788 Node,
2790 Cidr,
2792}
2793
2794#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2796pub struct AccessRule {
2797 #[serde(default = "wildcard")]
2799 pub service: String,
2800
2801 #[serde(default = "wildcard")]
2803 pub deployment: String,
2804
2805 #[serde(default, skip_serializing_if = "Option::is_none")]
2807 pub ports: Option<Vec<u16>>,
2808
2809 #[serde(default)]
2811 pub action: AccessAction,
2812}
2813
2814fn wildcard() -> String {
2815 "*".to_string()
2816}
2817
2818#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2820#[serde(rename_all = "lowercase")]
2821pub enum AccessAction {
2822 #[default]
2824 Allow,
2825 Deny,
2827}
2828
2829#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2841pub struct BridgeNetwork {
2842 pub id: String,
2844
2845 pub name: String,
2847
2848 #[serde(default)]
2850 pub driver: BridgeNetworkDriver,
2851
2852 #[serde(default, skip_serializing_if = "Option::is_none")]
2854 pub subnet: Option<String>,
2855
2856 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2858 pub labels: HashMap<String, String>,
2859
2860 #[serde(default)]
2863 pub internal: bool,
2864
2865 #[schema(value_type = String, format = "date-time")]
2867 pub created_at: chrono::DateTime<chrono::Utc>,
2868}
2869
2870#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2872#[serde(rename_all = "lowercase")]
2873pub enum BridgeNetworkDriver {
2874 #[default]
2876 Bridge,
2877 Overlay,
2879}
2880
2881#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2883pub struct BridgeNetworkAttachment {
2884 pub container_id: String,
2886
2887 #[serde(default, skip_serializing_if = "Option::is_none")]
2889 pub container_name: Option<String>,
2890
2891 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2893 pub aliases: Vec<String>,
2894
2895 #[serde(default, skip_serializing_if = "Option::is_none")]
2897 pub ipv4: Option<String>,
2898}
2899
2900#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2921pub struct RegistryAuth {
2922 pub username: String,
2925 pub password: String,
2928 #[serde(default = "default_registry_auth_type")]
2930 pub auth_type: RegistryAuthType,
2931}
2932
2933#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2935#[serde(rename_all = "snake_case")]
2936pub enum RegistryAuthType {
2937 #[default]
2939 Basic,
2940 Token,
2943}
2944
2945#[must_use]
2948pub fn default_registry_auth_type() -> RegistryAuthType {
2949 RegistryAuthType::Basic
2950}
2951
2952#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2967#[serde(rename_all = "snake_case", deny_unknown_fields)]
2968pub struct ContainerRestartPolicy {
2969 pub kind: ContainerRestartKind,
2971
2972 #[serde(default, skip_serializing_if = "Option::is_none")]
2975 pub max_attempts: Option<u32>,
2976
2977 #[serde(default, skip_serializing_if = "Option::is_none")]
2982 pub delay: Option<String>,
2983}
2984
2985#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2987#[serde(rename_all = "snake_case")]
2988pub enum ContainerRestartKind {
2989 No,
2991 Always,
2993 UnlessStopped,
2996 OnFailure,
2999}
3000
3001#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
3007#[serde(rename_all = "snake_case")]
3008pub enum PortProtocol {
3009 Tcp,
3011 Udp,
3013}
3014
3015impl Default for PortProtocol {
3016 fn default() -> Self {
3017 default_port_protocol()
3018 }
3019}
3020
3021impl PortProtocol {
3022 #[must_use]
3025 pub fn as_str(&self) -> &'static str {
3026 match self {
3027 PortProtocol::Tcp => "tcp",
3028 PortProtocol::Udp => "udp",
3029 }
3030 }
3031}
3032
3033fn default_port_protocol() -> PortProtocol {
3034 PortProtocol::Tcp
3035}
3036
3037fn default_host_ip() -> String {
3038 "0.0.0.0".to_string()
3039}
3040
3041#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
3047#[serde(rename_all = "snake_case")]
3048pub struct PortMapping {
3049 #[serde(default, skip_serializing_if = "Option::is_none")]
3051 pub host_port: Option<u16>,
3052 pub container_port: u16,
3054 #[serde(default = "default_port_protocol")]
3056 pub protocol: PortProtocol,
3057 #[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
3059 pub host_ip: String,
3060}
3061
3062#[cfg(test)]
3063mod tests {
3064 use super::*;
3065
3066 #[test]
3067 fn service_spec_default_round_trips_through_json() {
3068 let spec = ServiceSpec::default();
3073
3074 assert_eq!(spec.rtype, ResourceType::Service);
3076 assert_eq!(spec.image.pull_policy, PullPolicy::IfNotPresent);
3077 assert_eq!(spec.health.retries, 3);
3078 assert_eq!(spec.network_mode, NetworkMode::Default);
3079 assert!(spec.env.is_empty());
3080 assert!(spec.endpoints.is_empty());
3081 assert!(spec.overlay.is_none());
3082
3083 let json = serde_json::to_string(&spec).expect("serialize default ServiceSpec");
3084 let parsed: ServiceSpec =
3085 serde_json::from_str(&json).expect("re-parse default ServiceSpec");
3086 assert_eq!(spec, parsed);
3087 }
3088
3089 #[test]
3090 fn service_spec_deployment_field_serde_round_trips() {
3091 let yaml_without = "image:\n name: nginx:latest\n";
3094 let parsed: ServiceSpec =
3095 serde_yaml::from_str(yaml_without).expect("parse spec without deployment");
3096 assert_eq!(parsed.deployment, None);
3097 let reser = serde_json::to_string(&parsed).expect("serialize");
3098 assert!(
3099 !reser.contains("\"deployment\""),
3100 "absent deployment must not be serialized: {reser}"
3101 );
3102
3103 let yaml_with = "deployment: my-app\nimage:\n name: nginx:latest\n";
3105 let parsed_with: ServiceSpec =
3106 serde_yaml::from_str(yaml_with).expect("parse spec with deployment");
3107 assert_eq!(parsed_with.deployment.as_deref(), Some("my-app"));
3108 let json = serde_json::to_string(&parsed_with).expect("serialize with deployment");
3109 let reparsed: ServiceSpec = serde_json::from_str(&json).expect("re-parse");
3110 assert_eq!(reparsed.deployment.as_deref(), Some("my-app"));
3111 assert_eq!(parsed_with, reparsed);
3112 }
3113
3114 #[test]
3115 fn service_spec_minimal_sets_name_and_image() {
3116 let spec = ServiceSpec::minimal("api", "ghcr.io/acme/api:1.2");
3117 assert_eq!(spec.image.name.repository(), "acme/api");
3118 assert_eq!(spec.image.name.tag(), Some("1.2"));
3119 let baseline = ServiceSpec::default();
3121 assert_eq!(spec.rtype, baseline.rtype);
3122 assert_eq!(spec.scale, baseline.scale);
3123 assert_eq!(spec.network_mode, baseline.network_mode);
3124 }
3125
3126 #[test]
3127 fn port_mapping_defaults_via_serde() {
3128 let json = r#"{"container_port": 8080}"#;
3131 let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
3132 assert_eq!(m.container_port, 8080);
3133 assert_eq!(m.host_port, None);
3134 assert_eq!(m.protocol, PortProtocol::Tcp);
3135 assert_eq!(m.host_ip, "0.0.0.0");
3136 }
3137
3138 #[test]
3139 fn port_mapping_skips_none_host_port_and_empty_host_ip() {
3140 let m = PortMapping {
3141 host_port: None,
3142 container_port: 443,
3143 protocol: PortProtocol::Tcp,
3144 host_ip: String::new(),
3145 };
3146 let s = serde_json::to_string(&m).expect("serialize");
3147 assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
3149 assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
3150 assert!(s.contains("\"container_port\":443"));
3151 assert!(s.contains("\"protocol\":\"tcp\""));
3152 }
3153
3154 #[test]
3155 fn test_parse_simple_spec() {
3156 let yaml = r"
3157version: v1
3158deployment: test
3159services:
3160 hello:
3161 rtype: service
3162 image:
3163 name: hello-world:latest
3164 endpoints:
3165 - name: http
3166 protocol: http
3167 port: 8080
3168 expose: public
3169";
3170
3171 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3172 assert_eq!(spec.version, "v1");
3173 assert_eq!(spec.deployment, "test");
3174 assert!(spec.services.contains_key("hello"));
3175 }
3176
3177 #[test]
3178 fn test_parse_duration() {
3179 let yaml = r"
3180version: v1
3181deployment: test
3182services:
3183 test:
3184 rtype: service
3185 image:
3186 name: test:latest
3187 health:
3188 timeout: 30s
3189 interval: 1m
3190 start_grace: 5s
3191 check:
3192 type: tcp
3193 port: 8080
3194";
3195
3196 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3197 let health = &spec.services["test"].health;
3198 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
3199 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
3200 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
3201 match &health.check {
3202 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
3203 _ => panic!("Expected TCP health check"),
3204 }
3205 }
3206
3207 #[test]
3208 fn test_parse_adaptive_scale() {
3209 let yaml = r"
3210version: v1
3211deployment: test
3212services:
3213 test:
3214 rtype: service
3215 image:
3216 name: test:latest
3217 scale:
3218 mode: adaptive
3219 min: 2
3220 max: 10
3221 cooldown: 15s
3222 targets:
3223 cpu: 70
3224 rps: 800
3225";
3226
3227 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3228 let scale = &spec.services["test"].scale;
3229 match scale {
3230 ScaleSpec::Adaptive {
3231 min,
3232 max,
3233 cooldown,
3234 targets,
3235 } => {
3236 assert_eq!(*min, 2);
3237 assert_eq!(*max, 10);
3238 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
3239 assert_eq!(targets.cpu, Some(70));
3240 assert_eq!(targets.rps, Some(800));
3241 }
3242 _ => panic!("Expected Adaptive scale mode"),
3243 }
3244 }
3245
3246 #[test]
3247 fn test_node_mode_default() {
3248 let yaml = r"
3249version: v1
3250deployment: test
3251services:
3252 hello:
3253 rtype: service
3254 image:
3255 name: hello-world:latest
3256";
3257
3258 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3259 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
3260 assert!(spec.services["hello"].node_selector.is_none());
3261 }
3262
3263 #[test]
3264 fn test_node_mode_dedicated() {
3265 let yaml = r"
3266version: v1
3267deployment: test
3268services:
3269 api:
3270 rtype: service
3271 image:
3272 name: api:latest
3273 node_mode: dedicated
3274";
3275
3276 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3277 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
3278 }
3279
3280 #[test]
3281 fn test_node_mode_exclusive() {
3282 let yaml = r"
3283version: v1
3284deployment: test
3285services:
3286 database:
3287 rtype: service
3288 image:
3289 name: postgres:15
3290 node_mode: exclusive
3291";
3292
3293 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3294 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
3295 }
3296
3297 #[test]
3298 fn test_node_selector_with_labels() {
3299 let yaml = r#"
3300version: v1
3301deployment: test
3302services:
3303 ml-worker:
3304 rtype: service
3305 image:
3306 name: ml-worker:latest
3307 node_mode: dedicated
3308 node_selector:
3309 labels:
3310 gpu: "true"
3311 zone: us-east
3312 prefer_labels:
3313 storage: ssd
3314"#;
3315
3316 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3317 let service = &spec.services["ml-worker"];
3318 assert_eq!(service.node_mode, NodeMode::Dedicated);
3319
3320 let selector = service.node_selector.as_ref().unwrap();
3321 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
3322 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
3323 assert_eq!(
3324 selector.prefer_labels.get("storage"),
3325 Some(&"ssd".to_string())
3326 );
3327 }
3328
3329 #[test]
3330 fn test_node_mode_serialization_roundtrip() {
3331 use serde_json;
3332
3333 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
3335 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
3336
3337 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
3338 let json = serde_json::to_string(mode).unwrap();
3339 assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
3340
3341 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
3342 assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
3343 }
3344 }
3345
3346 #[test]
3347 fn test_node_selector_empty() {
3348 let yaml = r"
3349version: v1
3350deployment: test
3351services:
3352 api:
3353 rtype: service
3354 image:
3355 name: api:latest
3356 node_selector:
3357 labels: {}
3358";
3359
3360 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3361 let selector = spec.services["api"].node_selector.as_ref().unwrap();
3362 assert!(selector.labels.is_empty());
3363 assert!(selector.prefer_labels.is_empty());
3364 }
3365
3366 #[test]
3367 fn test_mixed_node_modes_in_deployment() {
3368 let yaml = r"
3369version: v1
3370deployment: test
3371services:
3372 redis:
3373 rtype: service
3374 image:
3375 name: redis:alpine
3376 # Default shared mode
3377 api:
3378 rtype: service
3379 image:
3380 name: api:latest
3381 node_mode: dedicated
3382 database:
3383 rtype: service
3384 image:
3385 name: postgres:15
3386 node_mode: exclusive
3387 node_selector:
3388 labels:
3389 storage: ssd
3390";
3391
3392 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3393 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
3394 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
3395 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
3396
3397 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
3398 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
3399 }
3400
3401 #[test]
3402 fn test_storage_bind_mount() {
3403 let yaml = r"
3404version: v1
3405deployment: test
3406services:
3407 app:
3408 image:
3409 name: app:latest
3410 storage:
3411 - type: bind
3412 source: /host/data
3413 target: /app/data
3414 readonly: true
3415";
3416 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3417 let storage = &spec.services["app"].storage;
3418 assert_eq!(storage.len(), 1);
3419 match &storage[0] {
3420 StorageSpec::Bind {
3421 source,
3422 target,
3423 readonly,
3424 } => {
3425 assert_eq!(source, "/host/data");
3426 assert_eq!(target, "/app/data");
3427 assert!(*readonly);
3428 }
3429 _ => panic!("Expected Bind storage"),
3430 }
3431 }
3432
3433 #[test]
3434 fn test_storage_named_with_tier() {
3435 let yaml = r"
3436version: v1
3437deployment: test
3438services:
3439 app:
3440 image:
3441 name: app:latest
3442 storage:
3443 - type: named
3444 name: my-data
3445 target: /app/data
3446 tier: cached
3447";
3448 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3449 let storage = &spec.services["app"].storage;
3450 match &storage[0] {
3451 StorageSpec::Named {
3452 name, target, tier, ..
3453 } => {
3454 assert_eq!(name, "my-data");
3455 assert_eq!(target, "/app/data");
3456 assert_eq!(*tier, StorageTier::Cached);
3457 }
3458 _ => panic!("Expected Named storage"),
3459 }
3460 }
3461
3462 #[test]
3463 fn test_storage_anonymous() {
3464 let yaml = r"
3465version: v1
3466deployment: test
3467services:
3468 app:
3469 image:
3470 name: app:latest
3471 storage:
3472 - type: anonymous
3473 target: /app/cache
3474";
3475 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3476 let storage = &spec.services["app"].storage;
3477 match &storage[0] {
3478 StorageSpec::Anonymous { target, tier } => {
3479 assert_eq!(target, "/app/cache");
3480 assert_eq!(*tier, StorageTier::Local); }
3482 _ => panic!("Expected Anonymous storage"),
3483 }
3484 }
3485
3486 #[test]
3487 fn test_storage_tmpfs() {
3488 let yaml = r"
3489version: v1
3490deployment: test
3491services:
3492 app:
3493 image:
3494 name: app:latest
3495 storage:
3496 - type: tmpfs
3497 target: /app/tmp
3498 size: 256Mi
3499 mode: 1777
3500";
3501 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3502 let storage = &spec.services["app"].storage;
3503 match &storage[0] {
3504 StorageSpec::Tmpfs { target, size, mode } => {
3505 assert_eq!(target, "/app/tmp");
3506 assert_eq!(size.as_deref(), Some("256Mi"));
3507 assert_eq!(*mode, Some(1777));
3508 }
3509 _ => panic!("Expected Tmpfs storage"),
3510 }
3511 }
3512
3513 #[test]
3514 fn test_storage_s3() {
3515 let yaml = r"
3516version: v1
3517deployment: test
3518services:
3519 app:
3520 image:
3521 name: app:latest
3522 storage:
3523 - type: s3
3524 bucket: my-bucket
3525 prefix: models/
3526 target: /app/models
3527 readonly: true
3528 endpoint: https://s3.us-west-2.amazonaws.com
3529 credentials: aws-creds
3530";
3531 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3532 let storage = &spec.services["app"].storage;
3533 match &storage[0] {
3534 StorageSpec::S3 {
3535 bucket,
3536 prefix,
3537 target,
3538 readonly,
3539 endpoint,
3540 credentials,
3541 } => {
3542 assert_eq!(bucket, "my-bucket");
3543 assert_eq!(prefix.as_deref(), Some("models/"));
3544 assert_eq!(target, "/app/models");
3545 assert!(*readonly);
3546 assert_eq!(
3547 endpoint.as_deref(),
3548 Some("https://s3.us-west-2.amazonaws.com")
3549 );
3550 assert_eq!(credentials.as_deref(), Some("aws-creds"));
3551 }
3552 _ => panic!("Expected S3 storage"),
3553 }
3554 }
3555
3556 #[test]
3557 fn test_storage_multiple_types() {
3558 let yaml = r"
3559version: v1
3560deployment: test
3561services:
3562 app:
3563 image:
3564 name: app:latest
3565 storage:
3566 - type: bind
3567 source: /etc/config
3568 target: /app/config
3569 readonly: true
3570 - type: named
3571 name: app-data
3572 target: /app/data
3573 - type: tmpfs
3574 target: /app/tmp
3575";
3576 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3577 let storage = &spec.services["app"].storage;
3578 assert_eq!(storage.len(), 3);
3579 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
3580 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
3581 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
3582 }
3583
3584 #[test]
3585 fn test_storage_tier_default() {
3586 let yaml = r"
3587version: v1
3588deployment: test
3589services:
3590 app:
3591 image:
3592 name: app:latest
3593 storage:
3594 - type: named
3595 name: data
3596 target: /data
3597";
3598 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3599 match &spec.services["app"].storage[0] {
3600 StorageSpec::Named { tier, .. } => {
3601 assert_eq!(*tier, StorageTier::Local); }
3603 _ => panic!("Expected Named storage"),
3604 }
3605 }
3606
3607 #[test]
3612 fn test_endpoint_tunnel_config_basic() {
3613 let yaml = r"
3614version: v1
3615deployment: test
3616services:
3617 api:
3618 image:
3619 name: api:latest
3620 endpoints:
3621 - name: http
3622 protocol: http
3623 port: 8080
3624 tunnel:
3625 enabled: true
3626 remote_port: 8080
3627";
3628 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3629 let endpoint = &spec.services["api"].endpoints[0];
3630 let tunnel = endpoint.tunnel.as_ref().unwrap();
3631 assert!(tunnel.enabled);
3632 assert_eq!(tunnel.remote_port, 8080);
3633 assert!(tunnel.from.is_none());
3634 assert!(tunnel.to.is_none());
3635 }
3636
3637 #[test]
3638 fn test_endpoint_tunnel_config_full() {
3639 let yaml = r"
3640version: v1
3641deployment: test
3642services:
3643 api:
3644 image:
3645 name: api:latest
3646 endpoints:
3647 - name: http
3648 protocol: http
3649 port: 8080
3650 tunnel:
3651 enabled: true
3652 from: node-1
3653 to: ingress-node
3654 remote_port: 9000
3655 expose: public
3656 access:
3657 enabled: true
3658 max_ttl: 4h
3659 audit: true
3660";
3661 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3662 let endpoint = &spec.services["api"].endpoints[0];
3663 let tunnel = endpoint.tunnel.as_ref().unwrap();
3664 assert!(tunnel.enabled);
3665 assert_eq!(tunnel.from, Some("node-1".to_string()));
3666 assert_eq!(tunnel.to, Some("ingress-node".to_string()));
3667 assert_eq!(tunnel.remote_port, 9000);
3668 assert_eq!(tunnel.expose, Some(ExposeType::Public));
3669
3670 let access = tunnel.access.as_ref().unwrap();
3671 assert!(access.enabled);
3672 assert_eq!(access.max_ttl, Some("4h".to_string()));
3673 assert!(access.audit);
3674 }
3675
3676 #[test]
3677 fn test_top_level_tunnel_definition() {
3678 let yaml = r"
3679version: v1
3680deployment: test
3681services: {}
3682tunnels:
3683 db-tunnel:
3684 from: app-node
3685 to: db-node
3686 local_port: 5432
3687 remote_port: 5432
3688 protocol: tcp
3689 expose: internal
3690";
3691 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3692 let tunnel = spec.tunnels.get("db-tunnel").unwrap();
3693 assert_eq!(tunnel.from, "app-node");
3694 assert_eq!(tunnel.to, "db-node");
3695 assert_eq!(tunnel.local_port, 5432);
3696 assert_eq!(tunnel.remote_port, 5432);
3697 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
3698 assert_eq!(tunnel.expose, ExposeType::Internal);
3699 }
3700
3701 #[test]
3702 fn test_top_level_tunnel_defaults() {
3703 let yaml = r"
3704version: v1
3705deployment: test
3706services: {}
3707tunnels:
3708 simple-tunnel:
3709 from: node-a
3710 to: node-b
3711 local_port: 3000
3712 remote_port: 3000
3713";
3714 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3715 let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
3716 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
3719
3720 #[test]
3721 fn test_tunnel_protocol_udp() {
3722 let yaml = r"
3723version: v1
3724deployment: test
3725services: {}
3726tunnels:
3727 udp-tunnel:
3728 from: node-a
3729 to: node-b
3730 local_port: 5353
3731 remote_port: 5353
3732 protocol: udp
3733";
3734 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3735 let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
3736 assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
3737 }
3738
3739 #[test]
3740 fn test_endpoint_without_tunnel() {
3741 let yaml = r"
3742version: v1
3743deployment: test
3744services:
3745 api:
3746 image:
3747 name: api:latest
3748 endpoints:
3749 - name: http
3750 protocol: http
3751 port: 8080
3752";
3753 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3754 let endpoint = &spec.services["api"].endpoints[0];
3755 assert!(endpoint.tunnel.is_none());
3756 }
3757
3758 #[test]
3759 fn test_deployment_without_tunnels() {
3760 let yaml = r"
3761version: v1
3762deployment: test
3763services:
3764 api:
3765 image:
3766 name: api:latest
3767";
3768 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3769 assert!(spec.tunnels.is_empty());
3770 }
3771
3772 #[test]
3777 fn test_spec_without_api_block_uses_defaults() {
3778 let yaml = r"
3779version: v1
3780deployment: test
3781services:
3782 hello:
3783 image:
3784 name: hello-world:latest
3785";
3786 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3787 assert!(spec.api.enabled);
3788 assert_eq!(spec.api.bind, "0.0.0.0:3669");
3789 assert!(spec.api.jwt_secret.is_none());
3790 assert!(spec.api.swagger);
3791 }
3792
3793 #[test]
3794 fn test_spec_with_explicit_api_block() {
3795 let yaml = r#"
3796version: v1
3797deployment: test
3798services:
3799 hello:
3800 image:
3801 name: hello-world:latest
3802api:
3803 enabled: false
3804 bind: "127.0.0.1:9090"
3805 jwt_secret: "my-secret"
3806 swagger: false
3807"#;
3808 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3809 assert!(!spec.api.enabled);
3810 assert_eq!(spec.api.bind, "127.0.0.1:9090");
3811 assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
3812 assert!(!spec.api.swagger);
3813 }
3814
3815 #[test]
3816 fn test_spec_with_partial_api_block() {
3817 let yaml = r#"
3818version: v1
3819deployment: test
3820services:
3821 hello:
3822 image:
3823 name: hello-world:latest
3824api:
3825 bind: "0.0.0.0:3000"
3826"#;
3827 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3828 assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
3830 assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
3833
3834 #[test]
3839 fn test_network_policy_spec_roundtrip() {
3840 let spec = NetworkPolicySpec {
3841 name: "corp-vpn".to_string(),
3842 description: Some("Corporate VPN network".to_string()),
3843 cidrs: vec!["10.200.0.0/16".to_string()],
3844 members: vec![
3845 NetworkMember {
3846 name: "alice".to_string(),
3847 kind: MemberKind::User,
3848 },
3849 NetworkMember {
3850 name: "ops-team".to_string(),
3851 kind: MemberKind::Group,
3852 },
3853 NetworkMember {
3854 name: "node-01".to_string(),
3855 kind: MemberKind::Node,
3856 },
3857 ],
3858 access_rules: vec![
3859 AccessRule {
3860 service: "api-gateway".to_string(),
3861 deployment: "*".to_string(),
3862 ports: Some(vec![443, 8080]),
3863 action: AccessAction::Allow,
3864 },
3865 AccessRule {
3866 service: "*".to_string(),
3867 deployment: "staging".to_string(),
3868 ports: None,
3869 action: AccessAction::Deny,
3870 },
3871 ],
3872 };
3873
3874 let yaml = serde_yaml::to_string(&spec).unwrap();
3875 let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
3876 assert_eq!(spec, deserialized);
3877 }
3878
3879 #[test]
3880 fn test_network_policy_spec_defaults() {
3881 let yaml = r"
3882name: minimal
3883";
3884 let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
3885 assert_eq!(spec.name, "minimal");
3886 assert!(spec.description.is_none());
3887 assert!(spec.cidrs.is_empty());
3888 assert!(spec.members.is_empty());
3889 assert!(spec.access_rules.is_empty());
3890 }
3891
3892 #[test]
3893 fn test_access_rule_defaults() {
3894 let yaml = "{}";
3895 let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
3896 assert_eq!(rule.service, "*");
3897 assert_eq!(rule.deployment, "*");
3898 assert!(rule.ports.is_none());
3899 assert_eq!(rule.action, AccessAction::Allow);
3900 }
3901
3902 #[test]
3903 fn test_member_kind_defaults_to_user() {
3904 let yaml = r"
3905name: bob
3906";
3907 let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
3908 assert_eq!(member.name, "bob");
3909 assert_eq!(member.kind, MemberKind::User);
3910 }
3911
3912 #[test]
3913 fn test_member_kind_variants() {
3914 for (input, expected) in [
3915 ("user", MemberKind::User),
3916 ("group", MemberKind::Group),
3917 ("node", MemberKind::Node),
3918 ("cidr", MemberKind::Cidr),
3919 ] {
3920 let yaml = format!("name: test\nkind: {input}");
3921 let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
3922 assert_eq!(member.kind, expected);
3923 }
3924 }
3925
3926 #[test]
3927 fn test_access_action_variants() {
3928 #[derive(Debug, Deserialize)]
3930 struct Wrapper {
3931 action: AccessAction,
3932 }
3933
3934 let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
3935 let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
3936
3937 assert_eq!(allow.action, AccessAction::Allow);
3938 assert_eq!(deny.action, AccessAction::Deny);
3939 }
3940
3941 #[test]
3942 fn test_network_policy_spec_default_impl() {
3943 let spec = NetworkPolicySpec::default();
3944 assert_eq!(spec.name, "");
3945 assert!(spec.description.is_none());
3946 assert!(spec.cidrs.is_empty());
3947 assert!(spec.members.is_empty());
3948 assert!(spec.access_rules.is_empty());
3949 }
3950
3951 #[test]
3952 fn container_restart_policy_serde_roundtrip_all_kinds() {
3953 let cases = [
3958 (
3959 ContainerRestartPolicy {
3960 kind: ContainerRestartKind::No,
3961 max_attempts: None,
3962 delay: None,
3963 },
3964 r#"{"kind":"no"}"#,
3965 ),
3966 (
3967 ContainerRestartPolicy {
3968 kind: ContainerRestartKind::Always,
3969 max_attempts: None,
3970 delay: Some("500ms".to_string()),
3971 },
3972 r#"{"kind":"always","delay":"500ms"}"#,
3973 ),
3974 (
3975 ContainerRestartPolicy {
3976 kind: ContainerRestartKind::UnlessStopped,
3977 max_attempts: None,
3978 delay: None,
3979 },
3980 r#"{"kind":"unless_stopped"}"#,
3981 ),
3982 (
3983 ContainerRestartPolicy {
3984 kind: ContainerRestartKind::OnFailure,
3985 max_attempts: Some(5),
3986 delay: None,
3987 },
3988 r#"{"kind":"on_failure","max_attempts":5}"#,
3989 ),
3990 ];
3991
3992 for (value, expected_json) in &cases {
3993 let serialized = serde_json::to_string(value).expect("serialize");
3994 assert_eq!(&serialized, expected_json, "serialize mismatch");
3995 let round: ContainerRestartPolicy =
3996 serde_json::from_str(&serialized).expect("deserialize");
3997 assert_eq!(&round, value, "roundtrip mismatch");
3998 }
3999 }
4000
4001 #[test]
4004 fn registry_auth_type_serializes_snake_case() {
4005 assert_eq!(
4006 serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
4007 "\"basic\""
4008 );
4009 assert_eq!(
4010 serde_json::to_string(&RegistryAuthType::Token).unwrap(),
4011 "\"token\""
4012 );
4013 }
4014
4015 #[test]
4016 fn registry_auth_default_auth_type_is_basic() {
4017 let json = r#"{"username":"u","password":"p"}"#;
4019 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
4020 assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
4021 assert_eq!(parsed.username, "u");
4022 assert_eq!(parsed.password, "p");
4023 }
4024
4025 #[test]
4026 fn registry_auth_serde_roundtrip_both_variants() {
4027 for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
4028 let cred = RegistryAuth {
4029 username: "ci-bot".to_string(),
4030 password: "s3cret".to_string(),
4031 auth_type: variant,
4032 };
4033 let serialized = serde_json::to_string(&cred).expect("serialize");
4034 let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
4035 assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
4036 }
4037 }
4038
4039 #[test]
4040 fn registry_auth_explicit_token_type_parses() {
4041 let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
4042 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
4043 assert_eq!(parsed.auth_type, RegistryAuthType::Token);
4044 }
4045
4046 #[test]
4047 fn target_platform_as_oci_str() {
4048 assert_eq!(
4049 TargetPlatform::new(OsKind::Linux, ArchKind::Amd64).as_oci_str(),
4050 "linux/amd64"
4051 );
4052 assert_eq!(
4053 TargetPlatform::new(OsKind::Windows, ArchKind::Arm64).as_oci_str(),
4054 "windows/arm64"
4055 );
4056 assert_eq!(
4057 TargetPlatform::new(OsKind::Macos, ArchKind::Arm64).as_oci_str(),
4058 "darwin/arm64"
4059 );
4060 }
4061
4062 #[test]
4063 fn os_kind_from_rust_consts() {
4064 assert_eq!(OsKind::from_rust_os("linux"), Some(OsKind::Linux));
4065 assert_eq!(OsKind::from_rust_os("windows"), Some(OsKind::Windows));
4066 assert_eq!(OsKind::from_rust_os("macos"), Some(OsKind::Macos));
4067 assert_eq!(OsKind::from_rust_os("freebsd"), None);
4068 }
4069
4070 #[test]
4071 fn arch_kind_from_rust_consts() {
4072 assert_eq!(ArchKind::from_rust_arch("x86_64"), Some(ArchKind::Amd64));
4073 assert_eq!(ArchKind::from_rust_arch("aarch64"), Some(ArchKind::Arm64));
4074 assert_eq!(ArchKind::from_rust_arch("riscv64"), None);
4075 }
4076
4077 #[test]
4078 fn service_spec_platform_yaml_round_trip_none() {
4079 let yaml = r"
4082version: v1
4083deployment: test
4084services:
4085 app:
4086 rtype: service
4087 image:
4088 name: nginx:latest
4089";
4090 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
4091 assert!(spec.services["app"].platform.is_none());
4092 }
4093
4094 #[test]
4095 fn service_spec_platform_yaml_round_trip_some() {
4096 let yaml = r"
4097version: v1
4098deployment: test
4099services:
4100 app:
4101 rtype: service
4102 image:
4103 name: nginx:latest
4104 platform:
4105 os: windows
4106 arch: amd64
4107";
4108 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
4109 assert_eq!(
4110 spec.services["app"].platform,
4111 Some(TargetPlatform::new(OsKind::Windows, ArchKind::Amd64))
4112 );
4113 }
4114
4115 #[test]
4116 fn service_spec_platform_serializes_omitted_when_none() {
4117 let yaml = r"
4120version: v1
4121deployment: test
4122services:
4123 app:
4124 rtype: service
4125 image:
4126 name: nginx:latest
4127";
4128 let mut spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
4129 let service = spec.services.get_mut("app").expect("service present");
4130 service.platform = None;
4131 let rendered = serde_yaml::to_string(service).expect("render");
4132 assert!(
4133 !rendered.contains("platform"),
4134 "platform must be omitted when None: {rendered}"
4135 );
4136 }
4137
4138 #[test]
4139 fn target_platform_os_version_builder() {
4140 let p =
4141 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
4142 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
4143 assert_eq!(p.os, OsKind::Windows);
4144 assert_eq!(p.arch, ArchKind::Amd64);
4145 }
4146
4147 #[test]
4148 fn target_platform_os_version_yaml_roundtrip() {
4149 let yaml = "os: windows\narch: amd64\nosVersion: 10.0.26100.1\n";
4150 let p: TargetPlatform = serde_yaml::from_str(yaml).expect("yaml parse");
4151 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
4152 assert_eq!(p.os, OsKind::Windows);
4153 assert_eq!(p.arch, ArchKind::Amd64);
4154 }
4155
4156 #[test]
4157 fn target_platform_os_version_yaml_omits_when_none() {
4158 let p = TargetPlatform::new(OsKind::Linux, ArchKind::Amd64);
4159 let rendered = serde_yaml::to_string(&p).expect("render");
4160 assert!(
4161 !rendered.contains("osVersion"),
4162 "osVersion must be omitted when None: {rendered}"
4163 );
4164 }
4165
4166 #[test]
4167 fn target_platform_as_detailed_str_includes_version() {
4168 let without = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).as_detailed_str();
4169 assert_eq!(without, "windows/amd64");
4170
4171 let with = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64)
4172 .with_os_version("10.0.26100.1")
4173 .as_detailed_str();
4174 assert_eq!(with, "windows/amd64 (os.version=10.0.26100.1)");
4175 }
4176
4177 #[test]
4178 fn target_platform_display_ignores_version() {
4179 let p =
4181 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
4182 assert_eq!(format!("{p}"), "windows/amd64");
4183 }
4184
4185 fn fixture_service_spec_full() -> ServiceSpec {
4191 let yaml = r"
4192version: v1
4193deployment: phase1-task1
4194services:
4195 hello:
4196 rtype: service
4197 image:
4198 name: hello-world:latest
4199";
4200 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse fixture");
4201 spec.services.get("hello").expect("hello service").clone()
4202 }
4203
4204 #[test]
4205 fn service_spec_round_trip_with_all_new_fields() {
4206 let mut spec = fixture_service_spec_full();
4207 spec.labels
4208 .insert("zlayer.team".to_string(), "platform".to_string());
4209 spec.user = Some("1000:1000".to_string());
4210 spec.stop_signal = Some("SIGTERM".to_string());
4211 spec.stop_grace_period = Some(std::time::Duration::from_secs(30));
4212 spec.sysctls
4213 .insert("net.core.somaxconn".to_string(), "1024".to_string());
4214 spec.ulimits.insert(
4215 "nofile".to_string(),
4216 UlimitSpec {
4217 soft: 65_536,
4218 hard: 65_536,
4219 },
4220 );
4221 spec.security_opt.push("no-new-privileges:true".to_string());
4222 spec.pid_mode = Some("host".to_string());
4223 spec.ipc_mode = Some("private".to_string());
4224 spec.network_mode = NetworkMode::Bridge {
4225 name: Some("custom-net".to_string()),
4226 };
4227 spec.cap_drop.push("NET_RAW".to_string());
4228 spec.extra_groups.push("docker".to_string());
4229 spec.read_only_root_fs = true;
4230 spec.init_container = Some(true);
4231 spec.resources.pids_limit = Some(2048);
4232 spec.resources.cpuset = Some("0-3".to_string());
4233 spec.resources.cpu_shares = Some(1024);
4234 spec.resources.memory_swap = Some("2Gi".to_string());
4235 spec.resources.memory_reservation = Some("256Mi".to_string());
4236 spec.resources.memory_swappiness = Some(10);
4237 spec.resources.oom_score_adj = Some(-500);
4238 spec.resources.oom_kill_disable = Some(false);
4239 spec.resources.blkio_weight = Some(500);
4240
4241 let yaml = serde_yaml::to_string(&spec).expect("serialize");
4242 let round: ServiceSpec = serde_yaml::from_str(&yaml).expect("deserialize");
4243 assert_eq!(spec, round, "round-trip mismatch:\n{yaml}");
4244 }
4245
4246 #[test]
4247 fn network_mode_string_form_round_trip() {
4248 let cases: &[(&str, NetworkMode)] = &[
4249 ("default", NetworkMode::Default),
4250 ("host", NetworkMode::Host),
4251 ("none", NetworkMode::None),
4252 ("bridge", NetworkMode::Bridge { name: None }),
4253 (
4254 "bridge:custom",
4255 NetworkMode::Bridge {
4256 name: Some("custom".to_string()),
4257 },
4258 ),
4259 (
4260 "container:abc123",
4261 NetworkMode::Container {
4262 id: "abc123".to_string(),
4263 },
4264 ),
4265 ];
4266
4267 for (input, expected) in cases {
4268 #[derive(Deserialize)]
4269 struct Wrap {
4270 #[serde(deserialize_with = "deserialize_network_mode")]
4271 m: NetworkMode,
4272 }
4273 let yaml = format!("m: \"{input}\"\n");
4274 let parsed: Wrap = serde_yaml::from_str(&yaml).expect("parse network mode");
4275 assert_eq!(&parsed.m, expected, "mismatch for {input}");
4276 }
4277 }
4278
4279 #[test]
4280 fn ulimit_spec_round_trip() {
4281 let u = UlimitSpec {
4282 soft: 1024,
4283 hard: 65_536,
4284 };
4285 let yaml = serde_yaml::to_string(&u).expect("serialize");
4286 let parsed: UlimitSpec = serde_yaml::from_str(&yaml).expect("parse");
4287 assert_eq!(u, parsed);
4288 }
4289
4290 #[test]
4291 fn ulimit_spec_full_form() {
4292 let parsed: UlimitSpec =
4293 serde_yaml::from_str("soft: 100000\nhard: 200000\n").expect("parse");
4294 assert_eq!(
4295 parsed,
4296 UlimitSpec {
4297 soft: 100_000,
4298 hard: 200_000,
4299 }
4300 );
4301 }
4302
4303 #[test]
4304 fn ulimit_spec_soft_only_defaults_hard_to_soft() {
4305 let parsed: UlimitSpec = serde_yaml::from_str("soft: 100000\n").expect("parse");
4309 assert_eq!(
4310 parsed,
4311 UlimitSpec {
4312 soft: 100_000,
4313 hard: 100_000,
4314 }
4315 );
4316 }
4317
4318 #[test]
4319 fn ulimit_spec_hard_only_defaults_soft_to_hard() {
4320 let parsed: UlimitSpec = serde_yaml::from_str("hard: 100000\n").expect("parse");
4322 assert_eq!(
4323 parsed,
4324 UlimitSpec {
4325 soft: 100_000,
4326 hard: 100_000,
4327 }
4328 );
4329 }
4330
4331 #[test]
4332 fn ulimit_spec_both_absent_is_zero() {
4333 let parsed: UlimitSpec = serde_yaml::from_str("{}\n").expect("parse");
4334 assert_eq!(parsed, UlimitSpec { soft: 0, hard: 0 });
4335 }
4336
4337 #[test]
4338 fn ulimit_spec_explicit_zero_hard_is_preserved() {
4339 let parsed: UlimitSpec = serde_yaml::from_str("soft: 100000\nhard: 0\n").expect("parse");
4342 assert_eq!(
4343 parsed,
4344 UlimitSpec {
4345 soft: 100_000,
4346 hard: 0,
4347 }
4348 );
4349 }
4350
4351 #[test]
4352 fn ulimit_spec_in_service_map_soft_only() {
4353 #[derive(Deserialize)]
4356 struct Wrap {
4357 ulimits: std::collections::HashMap<String, UlimitSpec>,
4358 }
4359 let yaml = r"
4360ulimits:
4361 nofile:
4362 soft: 100000
4363";
4364 let parsed: Wrap = serde_yaml::from_str(yaml).expect("parse");
4365 assert_eq!(
4366 parsed.ulimits.get("nofile"),
4367 Some(&UlimitSpec {
4368 soft: 100_000,
4369 hard: 100_000,
4370 })
4371 );
4372 }
4373
4374 #[test]
4375 fn host_network_true_yaml_promotes_to_network_mode_host() {
4376 let yaml = r"
4377version: v1
4378deployment: bc-test
4379services:
4380 hello:
4381 rtype: service
4382 image:
4383 name: hello-world:latest
4384 host_network: true
4385";
4386 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse");
4387 let svc = dep.services.get("hello").expect("hello service");
4388 assert_eq!(svc.network_mode, NetworkMode::Host);
4389 assert!(svc.host_network);
4392 }
4393
4394 #[test]
4395 fn capabilities_yaml_alias_cap_add_round_trip() {
4396 let yaml = r"
4399version: v1
4400deployment: cap-test
4401services:
4402 hello:
4403 rtype: service
4404 image:
4405 name: hello-world:latest
4406 cap_add:
4407 - NET_ADMIN
4408 - SYS_PTRACE
4409";
4410 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse cap_add alias");
4411 let svc = dep.services.get("hello").expect("hello service");
4412 assert_eq!(
4413 svc.capabilities,
4414 vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()]
4415 );
4416 }
4417
4418 #[test]
4419 fn lifecycle_omitted_defaults_to_false() {
4420 let yaml = r"
4426version: v1
4427deployment: lifecycle-default-test
4428services:
4429 app:
4430 rtype: service
4431 image:
4432 name: hello-world:latest
4433";
4434 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec without lifecycle");
4435 let svc = dep.services.get("app").expect("app service");
4436 assert_eq!(svc.lifecycle, LifecycleSpec::default());
4437 assert!(!svc.lifecycle.delete_on_exit);
4438 }
4439
4440 #[test]
4441 fn lifecycle_delete_on_exit_round_trips() {
4442 let yaml = r"
4446version: v1
4447deployment: lifecycle-delete-test
4448services:
4449 app:
4450 rtype: service
4451 image:
4452 name: hello-world:latest
4453 lifecycle:
4454 delete_on_exit: true
4455";
4456 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec with lifecycle");
4457 let svc = dep.services.get("app").expect("app service");
4458 assert!(svc.lifecycle.delete_on_exit);
4459
4460 let dumped = serde_yaml::to_string(&dep).expect("serialize spec with lifecycle");
4463 let reparsed: DeploymentSpec =
4464 serde_yaml::from_str(&dumped).expect("reparse round-tripped spec");
4465 let reparsed_svc = reparsed.services.get("app").expect("app service after rt");
4466 assert!(reparsed_svc.lifecycle.delete_on_exit);
4467 assert_eq!(svc.lifecycle, reparsed_svc.lifecycle);
4468 }
4469}
4470
4471#[cfg(test)]
4472mod replica_group_tests {
4473 use super::{
4474 validate_unique_replica_group_roles, EndpointSpec, GroupAffinity, LocalhostReachability,
4475 ReplicaGroup, ScaleSpec, ScaleTargets, ServiceSpec, REPLICA_GROUP_ROLE_RE,
4476 };
4477
4478 #[test]
4479 fn yaml_roundtrip_basic_group() {
4480 let yaml = r"
4481role: primary
4482count: 1
4483env:
4484 POSTGRES_REPLICATION_MODE: primary
4485affinity: spread
4486";
4487 let group: ReplicaGroup = serde_yaml::from_str(yaml).expect("parse basic group");
4488 assert_eq!(group.role, "primary");
4489 assert_eq!(group.count, 1);
4490 assert_eq!(group.affinity, GroupAffinity::Spread);
4491 assert_eq!(
4492 group.env.get("POSTGRES_REPLICATION_MODE"),
4493 Some(&"primary".to_string())
4494 );
4495 }
4496
4497 #[test]
4498 fn yaml_default_affinity_is_spread() {
4499 let yaml = "role: x\ncount: 2\n";
4500 let group: ReplicaGroup = serde_yaml::from_str(yaml).expect("parse minimal group");
4501 assert_eq!(group.affinity, GroupAffinity::Spread);
4502 }
4503
4504 #[test]
4505 fn role_regex_accepts_valid_labels() {
4506 for ok in ["a", "primary", "read-only", "x1", "ab-cd-ef"] {
4507 assert!(
4508 REPLICA_GROUP_ROLE_RE.is_match(ok),
4509 "regex should accept: {ok}"
4510 );
4511 }
4512 }
4513
4514 #[test]
4515 fn role_regex_rejects_invalid_labels() {
4516 for bad in [
4517 "",
4518 "-primary",
4519 "primary-",
4520 "Primary",
4521 "0primary",
4522 "primary_role",
4523 "this-is-way-too-long-of-a-role-name-here",
4524 ] {
4525 assert!(
4526 !REPLICA_GROUP_ROLE_RE.is_match(bad),
4527 "regex should reject: {bad}"
4528 );
4529 }
4530 }
4531
4532 #[test]
4533 fn group_affinity_pin_roundtrips_via_serde_yaml() {
4534 let pinned = GroupAffinity::Pin("id=2".to_string());
4537 let dumped = serde_yaml::to_string(&pinned).expect("serialize pin");
4538 let reparsed: GroupAffinity = serde_yaml::from_str(&dumped).expect("reparse pin");
4539 match reparsed {
4540 GroupAffinity::Pin(s) => assert_eq!(s, "id=2"),
4541 other => panic!("expected Pin, got {other:?}"),
4542 }
4543 }
4544
4545 #[test]
4546 fn unique_role_validator_rejects_duplicates() {
4547 let mk = |role: &str| ReplicaGroup {
4548 role: role.to_string(),
4549 count: 1,
4550 image: None,
4551 env: std::collections::HashMap::new(),
4552 command: None,
4553 resources: None,
4554 affinity: GroupAffinity::Spread,
4555 };
4556 assert!(validate_unique_replica_group_roles(&[mk("a"), mk("b")]).is_ok());
4557 let err = validate_unique_replica_group_roles(&[mk("a"), mk("a")])
4558 .expect_err("duplicate should fail");
4559 assert_eq!(err, "a");
4560 }
4561
4562 #[test]
4563 fn endpoint_target_role_yaml_roundtrip() {
4564 let yaml = "name: read\nprotocol: tcp\nport: 5433\ntarget_role: read\n";
4565 let ep: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
4566 assert_eq!(ep.target_role, Some("read".to_string()));
4567 }
4568
4569 #[test]
4570 fn endpoint_without_target_role_is_none() {
4571 let yaml = "name: any\nprotocol: tcp\nport: 5432\n";
4572 let ep: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
4573 assert_eq!(ep.target_role, None);
4574 }
4575
4576 fn spec_with_scale(scale: ScaleSpec) -> ServiceSpec {
4581 let mut s = ServiceSpec::minimal("svc", "scratch:latest");
4582 s.scale = scale;
4583 s
4584 }
4585
4586 fn replica_group(role: &str, count: u32) -> ReplicaGroup {
4587 ReplicaGroup {
4588 role: role.to_string(),
4589 count,
4590 image: None,
4591 env: std::collections::HashMap::new(),
4592 command: None,
4593 resources: None,
4594 affinity: GroupAffinity::Spread,
4595 }
4596 }
4597
4598 #[test]
4599 fn is_single_member_across_scale_modes() {
4600 assert!(spec_with_scale(ScaleSpec::Fixed { replicas: 1 }).is_single_member());
4601 assert!(spec_with_scale(ScaleSpec::Fixed { replicas: 0 }).is_single_member());
4602 assert!(!spec_with_scale(ScaleSpec::Fixed { replicas: 3 }).is_single_member());
4603
4604 let adaptive = |min, max| ScaleSpec::Adaptive {
4605 min,
4606 max,
4607 cooldown: None,
4608 targets: ScaleTargets::default(),
4609 };
4610 assert!(spec_with_scale(adaptive(1, 1)).is_single_member());
4611 assert!(!spec_with_scale(adaptive(1, 5)).is_single_member());
4612
4613 assert!(spec_with_scale(ScaleSpec::Manual).is_single_member());
4614 }
4615
4616 #[test]
4617 fn is_single_member_with_replica_groups() {
4618 let mut s = ServiceSpec::minimal("svc", "scratch:latest");
4620 s.replica_groups = Some(vec![replica_group("only", 1)]);
4621 assert!(s.is_single_member());
4622
4623 s.replica_groups = Some(vec![replica_group("only", 2)]);
4625 assert!(!s.is_single_member());
4626
4627 s.replica_groups = Some(vec![replica_group("a", 1), replica_group("b", 1)]);
4629 assert!(!s.is_single_member());
4630
4631 s.scale = ScaleSpec::Fixed { replicas: 1 };
4633 s.replica_groups = Some(vec![replica_group("a", 1), replica_group("b", 1)]);
4634 assert!(!s.is_single_member());
4635 }
4636
4637 #[test]
4638 fn publish_to_node_loopback_override_matrix() {
4639 let single = spec_with_scale(ScaleSpec::Fixed { replicas: 1 });
4641 let multi = spec_with_scale(ScaleSpec::Fixed { replicas: 3 });
4643
4644 let mut s = single.clone();
4646 s.localhost_reachability = LocalhostReachability::Auto;
4647 assert!(s.publish_to_node_loopback());
4648 let mut m = multi.clone();
4649 m.localhost_reachability = LocalhostReachability::Auto;
4650 assert!(!m.publish_to_node_loopback());
4651
4652 let mut s = single.clone();
4654 s.localhost_reachability = LocalhostReachability::Always;
4655 assert!(s.publish_to_node_loopback());
4656 let mut m = multi.clone();
4657 m.localhost_reachability = LocalhostReachability::Always;
4658 assert!(m.publish_to_node_loopback());
4659
4660 let mut s = single;
4662 s.localhost_reachability = LocalhostReachability::Never;
4663 assert!(!s.publish_to_node_loopback());
4664 let mut m = multi;
4665 m.localhost_reachability = LocalhostReachability::Never;
4666 assert!(!m.publish_to_node_loopback());
4667 }
4668
4669 #[test]
4670 fn localhost_reachability_default_is_auto() {
4671 assert_eq!(
4672 LocalhostReachability::default(),
4673 LocalhostReachability::Auto
4674 );
4675 assert!(LocalhostReachability::Auto.is_default());
4676 assert!(!LocalhostReachability::Always.is_default());
4677 assert!(!LocalhostReachability::Never.is_default());
4678 let minimal = ServiceSpec::minimal("svc", "scratch:latest");
4681 assert_eq!(minimal.localhost_reachability, LocalhostReachability::Auto);
4682 assert!(!minimal.is_single_member());
4683 assert!(!minimal.publish_to_node_loopback());
4684 }
4685}