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)]
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(
136 Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
137)]
138#[serde(rename_all = "lowercase")]
139pub enum OsKind {
140 Linux,
141 Windows,
142 Macos,
143}
144
145impl OsKind {
146 #[must_use]
149 pub const fn as_oci_str(self) -> &'static str {
150 match self {
151 OsKind::Linux => "linux",
152 OsKind::Windows => "windows",
153 OsKind::Macos => "darwin",
154 }
155 }
156
157 #[must_use]
159 pub fn from_rust_os(s: &str) -> Option<Self> {
160 match s {
161 "linux" => Some(Self::Linux),
162 "windows" => Some(Self::Windows),
163 "macos" => Some(Self::Macos),
164 _ => None,
165 }
166 }
167
168 #[must_use]
175 pub fn from_oci_str(s: &str) -> Option<Self> {
176 match s {
177 "linux" => Some(Self::Linux),
178 "windows" => Some(Self::Windows),
179 "darwin" => Some(Self::Macos),
180 _ => None,
181 }
182 }
183}
184
185#[derive(
187 Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
188)]
189#[serde(rename_all = "lowercase")]
190pub enum ArchKind {
191 Amd64,
192 Arm64,
193}
194
195impl ArchKind {
196 #[must_use]
198 pub const fn as_oci_str(self) -> &'static str {
199 match self {
200 ArchKind::Amd64 => "amd64",
201 ArchKind::Arm64 => "arm64",
202 }
203 }
204
205 #[must_use]
207 pub fn from_rust_arch(s: &str) -> Option<Self> {
208 match s {
209 "x86_64" => Some(Self::Amd64),
210 "aarch64" => Some(Self::Arm64),
211 _ => None,
212 }
213 }
214}
215
216#[derive(
222 Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
223)]
224pub struct TargetPlatform {
225 pub os: OsKind,
226 pub arch: ArchKind,
227 #[serde(default, rename = "osVersion", skip_serializing_if = "Option::is_none")]
235 pub os_version: Option<String>,
236}
237
238impl TargetPlatform {
239 #[must_use]
240 pub const fn new(os: OsKind, arch: ArchKind) -> Self {
241 Self {
242 os,
243 arch,
244 os_version: None,
245 }
246 }
247
248 #[must_use]
254 pub fn with_os_version(mut self, v: impl Into<String>) -> Self {
255 self.os_version = Some(v.into());
256 self
257 }
258
259 #[must_use]
265 pub fn as_oci_str(self) -> String {
266 format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
267 }
268
269 #[must_use]
273 pub fn as_detailed_str(&self) -> String {
274 match &self.os_version {
275 Some(v) => format!(
276 "{}/{} (os.version={v})",
277 self.os.as_oci_str(),
278 self.arch.as_oci_str()
279 ),
280 None => format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str()),
281 }
282 }
283}
284
285impl std::fmt::Display for TargetPlatform {
286 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 write!(f, "{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
288 }
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294#[serde(deny_unknown_fields)]
295#[allow(clippy::struct_excessive_bools)]
296pub struct WasmCapabilities {
297 #[serde(default = "default_true")]
299 pub config: bool,
300 #[serde(default = "default_true")]
302 pub keyvalue: bool,
303 #[serde(default = "default_true")]
305 pub logging: bool,
306 #[serde(default)]
308 pub secrets: bool,
309 #[serde(default = "default_true")]
311 pub metrics: bool,
312 #[serde(default)]
314 pub http_client: bool,
315 #[serde(default)]
317 pub cli: bool,
318 #[serde(default)]
320 pub filesystem: bool,
321 #[serde(default)]
323 pub sockets: bool,
324}
325
326impl Default for WasmCapabilities {
327 fn default() -> Self {
328 Self {
329 config: true,
330 keyvalue: true,
331 logging: true,
332 secrets: false,
333 metrics: true,
334 http_client: false,
335 cli: false,
336 filesystem: false,
337 sockets: false,
338 }
339 }
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
344#[serde(deny_unknown_fields)]
345pub struct WasmPreopen {
346 pub source: String,
348 pub target: String,
350 #[serde(default)]
352 pub readonly: bool,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
360#[serde(deny_unknown_fields)]
361#[allow(clippy::struct_excessive_bools)]
362pub struct WasmConfig {
363 #[serde(default = "default_min_instances")]
366 pub min_instances: u32,
367 #[serde(default = "default_max_instances")]
369 pub max_instances: u32,
370 #[serde(default = "default_idle_timeout", with = "duration::required")]
372 pub idle_timeout: std::time::Duration,
373 #[serde(default = "default_request_timeout", with = "duration::required")]
375 pub request_timeout: std::time::Duration,
376
377 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub max_memory: Option<String>,
381 #[serde(default)]
383 pub max_fuel: u64,
384 #[serde(
386 default,
387 skip_serializing_if = "Option::is_none",
388 with = "duration::option"
389 )]
390 pub epoch_interval: Option<std::time::Duration>,
391
392 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub capabilities: Option<WasmCapabilities>,
396
397 #[serde(default = "default_true")]
400 pub allow_http_outgoing: bool,
401 #[serde(default, skip_serializing_if = "Vec::is_empty")]
403 pub allowed_hosts: Vec<String>,
404 #[serde(default)]
406 pub allow_tcp: bool,
407 #[serde(default)]
409 pub allow_udp: bool,
410
411 #[serde(default, skip_serializing_if = "Vec::is_empty")]
414 pub preopens: Vec<WasmPreopen>,
415 #[serde(default = "default_true")]
417 pub kv_enabled: bool,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub kv_namespace: Option<String>,
421 #[serde(default = "default_kv_max_value_size")]
423 pub kv_max_value_size: u64,
424
425 #[serde(default, skip_serializing_if = "Vec::is_empty")]
428 pub secrets: Vec<String>,
429
430 #[serde(default = "default_true")]
433 pub precompile: bool,
434}
435
436fn default_kv_max_value_size() -> u64 {
437 1_048_576 }
439
440impl Default for WasmConfig {
441 fn default() -> Self {
442 Self {
443 min_instances: default_min_instances(),
444 max_instances: default_max_instances(),
445 idle_timeout: default_idle_timeout(),
446 request_timeout: default_request_timeout(),
447 max_memory: None,
448 max_fuel: 0,
449 epoch_interval: None,
450 capabilities: None,
451 allow_http_outgoing: true,
452 allowed_hosts: Vec::new(),
453 allow_tcp: false,
454 allow_udp: false,
455 preopens: Vec::new(),
456 kv_enabled: true,
457 kv_namespace: None,
458 kv_max_value_size: default_kv_max_value_size(),
459 secrets: Vec::new(),
460 precompile: true,
461 }
462 }
463}
464
465#[deprecated(note = "Use WasmConfig instead")]
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
468#[serde(deny_unknown_fields)]
469pub struct WasmHttpConfig {
470 #[serde(default = "default_min_instances")]
472 pub min_instances: u32,
473 #[serde(default = "default_max_instances")]
475 pub max_instances: u32,
476 #[serde(default = "default_idle_timeout", with = "duration::required")]
478 pub idle_timeout: std::time::Duration,
479 #[serde(default = "default_request_timeout", with = "duration::required")]
481 pub request_timeout: std::time::Duration,
482}
483
484fn default_min_instances() -> u32 {
485 0
486}
487
488fn default_max_instances() -> u32 {
489 10
490}
491
492fn default_idle_timeout() -> std::time::Duration {
493 std::time::Duration::from_secs(300)
494}
495
496fn default_request_timeout() -> std::time::Duration {
497 std::time::Duration::from_secs(30)
498}
499
500#[allow(deprecated)]
501impl Default for WasmHttpConfig {
502 fn default() -> Self {
503 Self {
504 min_instances: default_min_instances(),
505 max_instances: default_max_instances(),
506 idle_timeout: default_idle_timeout(),
507 request_timeout: default_request_timeout(),
508 }
509 }
510}
511
512#[allow(deprecated)]
513impl From<WasmHttpConfig> for WasmConfig {
514 fn from(old: WasmHttpConfig) -> Self {
515 Self {
516 min_instances: old.min_instances,
517 max_instances: old.max_instances,
518 idle_timeout: old.idle_timeout,
519 request_timeout: old.request_timeout,
520 ..Default::default()
521 }
522 }
523}
524
525impl ServiceType {
526 #[must_use]
528 pub fn is_wasm(&self) -> bool {
529 matches!(
530 self,
531 ServiceType::WasmHttp
532 | ServiceType::WasmPlugin
533 | ServiceType::WasmTransformer
534 | ServiceType::WasmAuthenticator
535 | ServiceType::WasmRateLimiter
536 | ServiceType::WasmMiddleware
537 | ServiceType::WasmRouter
538 )
539 }
540
541 #[must_use]
544 pub fn default_wasm_capabilities(&self) -> Option<WasmCapabilities> {
545 match self {
546 ServiceType::WasmHttp | ServiceType::WasmRouter => Some(WasmCapabilities {
547 config: true,
548 keyvalue: true,
549 logging: true,
550 secrets: false,
551 metrics: false,
552 http_client: true,
553 cli: false,
554 filesystem: false,
555 sockets: false,
556 }),
557 ServiceType::WasmPlugin => Some(WasmCapabilities {
558 config: true,
559 keyvalue: true,
560 logging: true,
561 secrets: true,
562 metrics: true,
563 http_client: true,
564 cli: true,
565 filesystem: true,
566 sockets: false,
567 }),
568 ServiceType::WasmTransformer => Some(WasmCapabilities {
569 config: false,
570 keyvalue: false,
571 logging: true,
572 secrets: false,
573 metrics: false,
574 http_client: false,
575 cli: true,
576 filesystem: false,
577 sockets: false,
578 }),
579 ServiceType::WasmAuthenticator => Some(WasmCapabilities {
580 config: true,
581 keyvalue: false,
582 logging: true,
583 secrets: true,
584 metrics: false,
585 http_client: true,
586 cli: false,
587 filesystem: false,
588 sockets: false,
589 }),
590 ServiceType::WasmRateLimiter => Some(WasmCapabilities {
591 config: true,
592 keyvalue: true,
593 logging: true,
594 secrets: false,
595 metrics: true,
596 http_client: false,
597 cli: true,
598 filesystem: false,
599 sockets: false,
600 }),
601 ServiceType::WasmMiddleware => Some(WasmCapabilities {
602 config: true,
603 keyvalue: false,
604 logging: true,
605 secrets: false,
606 metrics: false,
607 http_client: true,
608 cli: false,
609 filesystem: false,
610 sockets: false,
611 }),
612 _ => None,
613 }
614 }
615}
616
617fn default_api_bind() -> String {
618 "0.0.0.0:3669".to_string()
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
623pub struct ApiSpec {
624 #[serde(default = "default_true")]
626 pub enabled: bool,
627 #[serde(default = "default_api_bind")]
629 pub bind: String,
630 #[serde(default)]
632 pub jwt_secret: Option<String>,
633 #[serde(default = "default_true")]
635 pub swagger: bool,
636}
637
638impl Default for ApiSpec {
639 fn default() -> Self {
640 Self {
641 enabled: true,
642 bind: default_api_bind(),
643 jwt_secret: None,
644 swagger: true,
645 }
646 }
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
651#[serde(deny_unknown_fields)]
652pub struct DeploymentSpec {
653 #[validate(custom(function = "crate::spec::validate::validate_version_wrapper"))]
655 pub version: String,
656
657 #[validate(custom(function = "crate::spec::validate::validate_deployment_name_wrapper"))]
659 pub deployment: String,
660
661 #[serde(default)]
663 #[validate(nested)]
664 pub services: HashMap<String, ServiceSpec>,
665
666 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
673 #[validate(nested)]
674 pub externals: HashMap<String, ExternalSpec>,
675
676 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
678 pub tunnels: HashMap<String, TunnelDefinition>,
679
680 #[serde(default)]
682 pub api: ApiSpec,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
691#[serde(deny_unknown_fields)]
692pub struct ExternalSpec {
693 #[validate(length(min = 1, message = "at least one backend address is required"))]
698 pub backends: Vec<String>,
699
700 #[serde(default)]
704 #[validate(nested)]
705 pub endpoints: Vec<EndpointSpec>,
706
707 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub health: Option<HealthSpec>,
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
717#[serde(deny_unknown_fields)]
718pub struct TunnelDefinition {
719 pub from: String,
721
722 pub to: String,
724
725 pub local_port: u16,
727
728 pub remote_port: u16,
730
731 #[serde(default)]
733 pub protocol: TunnelProtocol,
734
735 #[serde(default)]
737 pub expose: ExposeType,
738}
739
740#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
742#[serde(rename_all = "lowercase")]
743pub enum TunnelProtocol {
744 #[default]
745 Tcp,
746 Udp,
747}
748
749#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
751pub struct LogsConfig {
752 #[serde(default = "default_logs_destination")]
754 pub destination: String,
755
756 #[serde(default = "default_logs_max_size")]
758 pub max_size_bytes: u64,
759
760 #[serde(default = "default_logs_retention")]
762 pub retention_secs: u64,
763}
764
765fn default_logs_destination() -> String {
766 "disk".to_string()
767}
768
769fn default_logs_max_size() -> u64 {
770 100 * 1024 * 1024 }
772
773fn default_logs_retention() -> u64 {
774 7 * 24 * 60 * 60 }
776
777impl Default for LogsConfig {
778 fn default() -> Self {
779 Self {
780 destination: default_logs_destination(),
781 max_size_bytes: default_logs_max_size(),
782 retention_secs: default_logs_retention(),
783 }
784 }
785}
786
787#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
793#[serde(rename_all = "lowercase")]
794pub enum NetworkMode {
795 #[default]
797 Default,
798 Host,
800 None,
802 Bridge {
805 #[serde(default)]
806 name: Option<String>,
807 },
808 Container { id: String },
811}
812
813fn deserialize_network_mode<'de, D>(deserializer: D) -> Result<NetworkMode, D::Error>
820where
821 D: serde::Deserializer<'de>,
822{
823 use serde::de::Error;
824
825 #[derive(Deserialize)]
830 #[serde(rename_all = "lowercase")]
831 enum Inner {
832 Default,
833 Host,
834 None,
835 Bridge {
836 #[serde(default)]
837 name: Option<String>,
838 },
839 Container {
840 id: String,
841 },
842 }
843
844 impl From<Inner> for NetworkMode {
845 fn from(i: Inner) -> Self {
846 match i {
847 Inner::Default => Self::Default,
848 Inner::Host => Self::Host,
849 Inner::None => Self::None,
850 Inner::Bridge { name } => Self::Bridge { name },
851 Inner::Container { id } => Self::Container { id },
852 }
853 }
854 }
855
856 let value = serde_yaml::Value::deserialize(deserializer)?;
860
861 if let Some(s) = value.as_str() {
862 return match s {
863 "default" => Ok(NetworkMode::Default),
864 "host" => Ok(NetworkMode::Host),
865 "none" => Ok(NetworkMode::None),
866 "bridge" => Ok(NetworkMode::Bridge { name: None }),
867 _ => {
868 if let Some(rest) = s.strip_prefix("bridge:") {
869 if rest.is_empty() {
870 Ok(NetworkMode::Bridge { name: None })
871 } else {
872 Ok(NetworkMode::Bridge {
873 name: Some(rest.to_string()),
874 })
875 }
876 } else if let Some(rest) = s.strip_prefix("container:") {
877 if rest.is_empty() {
878 Err(D::Error::custom(
879 "network mode \"container:<id>\" requires a non-empty id",
880 ))
881 } else {
882 Ok(NetworkMode::Container {
883 id: rest.to_string(),
884 })
885 }
886 } else {
887 Err(D::Error::custom(format!("unknown network mode: {s}")))
888 }
889 }
890 };
891 }
892
893 let inner: Inner = serde_yaml::from_value(value).map_err(D::Error::custom)?;
894 Ok(NetworkMode::from(inner))
895}
896
897#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
899#[serde(deny_unknown_fields)]
900pub struct UlimitSpec {
901 #[serde(default)]
903 pub soft: i64,
904 #[serde(default)]
906 pub hard: i64,
907}
908
909#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
911#[serde(from = "ServiceSpecCompat")]
912#[allow(clippy::struct_excessive_bools)]
913pub struct ServiceSpec {
914 #[serde(default = "default_resource_type")]
916 pub rtype: ResourceType,
917
918 #[serde(default, skip_serializing_if = "Option::is_none")]
925 #[validate(custom(function = "crate::spec::validate::validate_schedule_wrapper"))]
926 pub schedule: Option<String>,
927
928 #[validate(nested)]
930 pub image: ImageSpec,
931
932 #[serde(default)]
934 #[validate(nested)]
935 pub resources: ResourcesSpec,
936
937 #[serde(default)]
944 pub env: HashMap<String, String>,
945
946 #[serde(default)]
948 pub command: CommandSpec,
949
950 #[serde(default)]
952 pub network: ServiceNetworkSpec,
953
954 #[serde(default)]
956 #[validate(nested)]
957 pub endpoints: Vec<EndpointSpec>,
958
959 #[serde(default)]
961 #[validate(custom(function = "crate::spec::validate::validate_scale_spec"))]
962 pub scale: ScaleSpec,
963
964 #[serde(default)]
966 pub depends: Vec<DependsSpec>,
967
968 #[serde(default = "default_health")]
970 pub health: HealthSpec,
971
972 #[serde(default)]
974 pub init: InitSpec,
975
976 #[serde(default)]
978 pub errors: ErrorsSpec,
979
980 #[serde(default)]
986 pub lifecycle: LifecycleSpec,
987
988 #[serde(default)]
990 pub devices: Vec<DeviceSpec>,
991
992 #[serde(default, skip_serializing_if = "Vec::is_empty")]
994 pub storage: Vec<StorageSpec>,
995
996 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1001 pub port_mappings: Vec<PortMapping>,
1002
1003 #[serde(default, alias = "cap_add", skip_serializing_if = "Vec::is_empty")]
1007 pub capabilities: Vec<String>,
1008
1009 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1011 pub cap_drop: Vec<String>,
1012
1013 #[serde(default)]
1015 pub privileged: bool,
1016
1017 #[serde(default)]
1019 pub node_mode: NodeMode,
1020
1021 #[serde(default, skip_serializing_if = "Option::is_none")]
1023 pub node_selector: Option<NodeSelector>,
1024
1025 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub platform: Option<TargetPlatform>,
1030
1031 #[serde(default)]
1033 pub service_type: ServiceType,
1034
1035 #[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
1038 pub wasm: Option<WasmConfig>,
1039
1040 #[serde(default, skip_serializing_if = "Option::is_none")]
1042 pub logs: Option<LogsConfig>,
1043
1044 #[serde(skip)]
1049 pub host_network: bool,
1050
1051 #[serde(default, skip_serializing_if = "Option::is_none")]
1057 pub hostname: Option<String>,
1058
1059 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1065 pub dns: Vec<String>,
1066
1067 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1075 pub extra_hosts: Vec<String>,
1076
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub restart_policy: Option<ContainerRestartPolicy>,
1086
1087 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1090 pub labels: HashMap<String, String>,
1091
1092 #[serde(default, skip_serializing_if = "Option::is_none")]
1095 pub user: Option<String>,
1096
1097 #[serde(default, skip_serializing_if = "Option::is_none")]
1100 pub stop_signal: Option<String>,
1101
1102 #[serde(
1105 default,
1106 with = "duration::option",
1107 skip_serializing_if = "Option::is_none"
1108 )]
1109 pub stop_grace_period: Option<std::time::Duration>,
1110
1111 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1113 pub sysctls: HashMap<String, String>,
1114
1115 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1117 pub ulimits: HashMap<String, UlimitSpec>,
1118
1119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1122 pub security_opt: Vec<String>,
1123
1124 #[serde(default, skip_serializing_if = "Option::is_none")]
1127 pub pid_mode: Option<String>,
1128
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub ipc_mode: Option<String>,
1133
1134 #[serde(default, deserialize_with = "deserialize_network_mode")]
1138 pub network_mode: NetworkMode,
1139
1140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1143 pub extra_groups: Vec<String>,
1144
1145 #[serde(default)]
1147 pub read_only_root_fs: bool,
1148
1149 #[serde(default, skip_serializing_if = "Option::is_none")]
1153 pub init_container: Option<bool>,
1154
1155 #[serde(default)]
1158 pub tty: bool,
1159
1160 #[serde(default)]
1163 pub stdin_open: bool,
1164
1165 #[serde(default, skip_serializing_if = "Option::is_none")]
1168 pub userns_mode: Option<String>,
1169
1170 #[serde(default, skip_serializing_if = "Option::is_none")]
1173 pub cgroup_parent: Option<String>,
1174
1175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1181 pub expose: Vec<String>,
1182}
1183
1184#[derive(Deserialize)]
1191#[serde(deny_unknown_fields)]
1192#[allow(clippy::struct_excessive_bools)]
1193struct ServiceSpecCompat {
1194 #[serde(default = "default_resource_type")]
1195 rtype: ResourceType,
1196 #[serde(default)]
1197 schedule: Option<String>,
1198 image: ImageSpec,
1199 #[serde(default)]
1200 resources: ResourcesSpec,
1201 #[serde(default)]
1202 env: HashMap<String, String>,
1203 #[serde(default)]
1204 command: CommandSpec,
1205 #[serde(default)]
1206 network: ServiceNetworkSpec,
1207 #[serde(default)]
1208 endpoints: Vec<EndpointSpec>,
1209 #[serde(default)]
1210 scale: ScaleSpec,
1211 #[serde(default)]
1212 depends: Vec<DependsSpec>,
1213 #[serde(default = "default_health")]
1214 health: HealthSpec,
1215 #[serde(default)]
1216 init: InitSpec,
1217 #[serde(default)]
1218 errors: ErrorsSpec,
1219 #[serde(default)]
1220 lifecycle: LifecycleSpec,
1221 #[serde(default)]
1222 devices: Vec<DeviceSpec>,
1223 #[serde(default)]
1224 storage: Vec<StorageSpec>,
1225 #[serde(default)]
1226 port_mappings: Vec<PortMapping>,
1227 #[serde(default, alias = "cap_add")]
1228 capabilities: Vec<String>,
1229 #[serde(default)]
1230 cap_drop: Vec<String>,
1231 #[serde(default)]
1232 privileged: bool,
1233 #[serde(default)]
1234 node_mode: NodeMode,
1235 #[serde(default)]
1236 node_selector: Option<NodeSelector>,
1237 #[serde(default)]
1238 platform: Option<TargetPlatform>,
1239 #[serde(default)]
1240 service_type: ServiceType,
1241 #[serde(default, alias = "wasm_http")]
1242 wasm: Option<WasmConfig>,
1243 #[serde(default)]
1244 logs: Option<LogsConfig>,
1245 #[serde(default)]
1248 host_network: Option<bool>,
1249 #[serde(default)]
1250 hostname: Option<String>,
1251 #[serde(default)]
1252 dns: Vec<String>,
1253 #[serde(default)]
1254 extra_hosts: Vec<String>,
1255 #[serde(default)]
1256 restart_policy: Option<ContainerRestartPolicy>,
1257 #[serde(default)]
1258 labels: HashMap<String, String>,
1259 #[serde(default)]
1260 user: Option<String>,
1261 #[serde(default)]
1262 stop_signal: Option<String>,
1263 #[serde(default, with = "duration::option")]
1264 stop_grace_period: Option<std::time::Duration>,
1265 #[serde(default)]
1266 sysctls: HashMap<String, String>,
1267 #[serde(default)]
1268 ulimits: HashMap<String, UlimitSpec>,
1269 #[serde(default)]
1270 security_opt: Vec<String>,
1271 #[serde(default)]
1272 pid_mode: Option<String>,
1273 #[serde(default)]
1274 ipc_mode: Option<String>,
1275 #[serde(default, deserialize_with = "deserialize_network_mode")]
1276 network_mode: NetworkMode,
1277 #[serde(default)]
1278 extra_groups: Vec<String>,
1279 #[serde(default)]
1280 read_only_root_fs: bool,
1281 #[serde(default)]
1282 init_container: Option<bool>,
1283 #[serde(default)]
1284 tty: bool,
1285 #[serde(default)]
1286 stdin_open: bool,
1287 #[serde(default)]
1288 userns_mode: Option<String>,
1289 #[serde(default)]
1290 cgroup_parent: Option<String>,
1291 #[serde(default)]
1292 expose: Vec<String>,
1293}
1294
1295impl From<ServiceSpecCompat> for ServiceSpec {
1296 fn from(c: ServiceSpecCompat) -> Self {
1297 let network_mode = match (c.host_network, &c.network_mode) {
1302 (Some(true), NetworkMode::Default) => NetworkMode::Host,
1303 _ => c.network_mode,
1304 };
1305 let host_network = c.host_network.unwrap_or(false) || network_mode == NetworkMode::Host;
1306
1307 Self {
1308 rtype: c.rtype,
1309 schedule: c.schedule,
1310 image: c.image,
1311 resources: c.resources,
1312 env: c.env,
1313 command: c.command,
1314 network: c.network,
1315 endpoints: c.endpoints,
1316 scale: c.scale,
1317 depends: c.depends,
1318 health: c.health,
1319 init: c.init,
1320 errors: c.errors,
1321 lifecycle: c.lifecycle,
1322 devices: c.devices,
1323 storage: c.storage,
1324 port_mappings: c.port_mappings,
1325 capabilities: c.capabilities,
1326 cap_drop: c.cap_drop,
1327 privileged: c.privileged,
1328 node_mode: c.node_mode,
1329 node_selector: c.node_selector,
1330 platform: c.platform,
1331 service_type: c.service_type,
1332 wasm: c.wasm,
1333 logs: c.logs,
1334 host_network,
1335 hostname: c.hostname,
1336 dns: c.dns,
1337 extra_hosts: c.extra_hosts,
1338 restart_policy: c.restart_policy,
1339 labels: c.labels,
1340 user: c.user,
1341 stop_signal: c.stop_signal,
1342 stop_grace_period: c.stop_grace_period,
1343 sysctls: c.sysctls,
1344 ulimits: c.ulimits,
1345 security_opt: c.security_opt,
1346 pid_mode: c.pid_mode,
1347 ipc_mode: c.ipc_mode,
1348 network_mode,
1349 extra_groups: c.extra_groups,
1350 read_only_root_fs: c.read_only_root_fs,
1351 init_container: c.init_container,
1352 tty: c.tty,
1353 stdin_open: c.stdin_open,
1354 userns_mode: c.userns_mode,
1355 cgroup_parent: c.cgroup_parent,
1356 expose: c.expose,
1357 }
1358 }
1359}
1360
1361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1363#[serde(deny_unknown_fields)]
1364pub struct CommandSpec {
1365 #[serde(default, skip_serializing_if = "Option::is_none")]
1367 pub entrypoint: Option<Vec<String>>,
1368
1369 #[serde(default, skip_serializing_if = "Option::is_none")]
1371 pub args: Option<Vec<String>>,
1372
1373 #[serde(default, skip_serializing_if = "Option::is_none")]
1375 pub workdir: Option<String>,
1376}
1377
1378fn default_resource_type() -> ResourceType {
1379 ResourceType::Service
1380}
1381
1382fn default_health() -> HealthSpec {
1383 HealthSpec {
1384 start_grace: Some(std::time::Duration::from_secs(5)),
1385 interval: None,
1386 timeout: None,
1387 retries: 3,
1388 check: HealthCheck::Tcp { port: 0 },
1389 }
1390}
1391
1392#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1394#[serde(rename_all = "lowercase")]
1395pub enum ResourceType {
1396 Service,
1398 Job,
1400 Cron,
1402}
1403
1404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1406#[serde(deny_unknown_fields)]
1407pub struct ImageSpec {
1408 #[serde(with = "crate::image_ref_serde")]
1410 pub name: crate::ImageReference,
1411
1412 #[serde(default = "default_pull_policy")]
1414 pub pull_policy: PullPolicy,
1415}
1416
1417fn default_pull_policy() -> PullPolicy {
1418 PullPolicy::IfNotPresent
1419}
1420
1421#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1423#[serde(rename_all = "snake_case")]
1424pub enum PullPolicy {
1425 Always,
1427 Newer,
1429 IfNotPresent,
1431 Never,
1433}
1434
1435#[must_use]
1444pub fn effective_pull_policy(image: &crate::ImageReference, spec_policy: PullPolicy) -> PullPolicy {
1445 match spec_policy {
1446 PullPolicy::Always | PullPolicy::Never | PullPolicy::Newer => spec_policy,
1447 PullPolicy::IfNotPresent => {
1448 if image_is_latest_or_untagged(image) {
1450 PullPolicy::Newer
1451 } else {
1452 PullPolicy::IfNotPresent
1453 }
1454 }
1455 }
1456}
1457
1458fn image_is_latest_or_untagged(image: &crate::ImageReference) -> bool {
1459 match image.tag() {
1463 None => true,
1464 Some(tag) => tag == "latest",
1465 }
1466}
1467
1468#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate, utoipa::ToSchema)]
1470#[serde(deny_unknown_fields)]
1471pub struct DeviceSpec {
1472 #[validate(length(min = 1, message = "device path cannot be empty"))]
1474 pub path: String,
1475
1476 #[serde(default = "default_true")]
1478 pub read: bool,
1479
1480 #[serde(default = "default_true")]
1482 pub write: bool,
1483
1484 #[serde(default)]
1486 pub mknod: bool,
1487}
1488
1489fn default_true() -> bool {
1490 true
1491}
1492
1493#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1495#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
1496pub enum StorageSpec {
1497 Bind {
1499 source: String,
1500 target: String,
1501 #[serde(default)]
1502 readonly: bool,
1503 },
1504 Named {
1506 name: String,
1507 target: String,
1508 #[serde(default)]
1509 readonly: bool,
1510 #[serde(default)]
1512 tier: StorageTier,
1513 #[serde(default, skip_serializing_if = "Option::is_none")]
1515 size: Option<String>,
1516 },
1517 Anonymous {
1519 target: String,
1520 #[serde(default)]
1522 tier: StorageTier,
1523 },
1524 Tmpfs {
1526 target: String,
1527 #[serde(default)]
1528 size: Option<String>,
1529 #[serde(default)]
1530 mode: Option<u32>,
1531 },
1532 S3 {
1534 bucket: String,
1535 #[serde(default)]
1536 prefix: Option<String>,
1537 target: String,
1538 #[serde(default)]
1539 readonly: bool,
1540 #[serde(default)]
1541 endpoint: Option<String>,
1542 #[serde(default)]
1543 credentials: Option<String>,
1544 },
1545}
1546
1547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
1549#[serde(deny_unknown_fields)]
1550pub struct ResourcesSpec {
1551 #[serde(default)]
1553 #[validate(custom(function = "crate::spec::validate::validate_cpu_option_wrapper"))]
1554 pub cpu: Option<f64>,
1555
1556 #[serde(default)]
1558 #[validate(custom(function = "crate::spec::validate::validate_memory_option_wrapper"))]
1559 pub memory: Option<String>,
1560
1561 #[serde(default, skip_serializing_if = "Option::is_none")]
1563 pub gpu: Option<GpuSpec>,
1564
1565 #[serde(default, skip_serializing_if = "Option::is_none")]
1568 pub pids_limit: Option<i64>,
1569
1570 #[serde(default, skip_serializing_if = "Option::is_none")]
1572 pub cpuset: Option<String>,
1573
1574 #[serde(default, skip_serializing_if = "Option::is_none")]
1576 pub cpu_shares: Option<u32>,
1577
1578 #[serde(default, skip_serializing_if = "Option::is_none")]
1580 pub memory_swap: Option<String>,
1581
1582 #[serde(default, skip_serializing_if = "Option::is_none")]
1584 pub memory_reservation: Option<String>,
1585
1586 #[serde(default, skip_serializing_if = "Option::is_none")]
1588 pub memory_swappiness: Option<u8>,
1589
1590 #[serde(default, skip_serializing_if = "Option::is_none")]
1592 pub oom_score_adj: Option<i32>,
1593
1594 #[serde(default, skip_serializing_if = "Option::is_none")]
1596 pub oom_kill_disable: Option<bool>,
1597
1598 #[serde(default, skip_serializing_if = "Option::is_none")]
1600 pub blkio_weight: Option<u16>,
1601}
1602
1603#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1605#[serde(rename_all = "kebab-case")]
1606pub enum SchedulingPolicy {
1607 #[default]
1609 BestEffort,
1610 Gang,
1612 Spread,
1614}
1615
1616#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1618#[serde(rename_all = "kebab-case")]
1619pub enum GpuSharingMode {
1620 #[default]
1622 Exclusive,
1623 Mps,
1626 TimeSlice,
1629}
1630
1631#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1638#[serde(deny_unknown_fields)]
1639pub struct DistributedConfig {
1640 #[serde(default = "default_dist_backend")]
1642 pub backend: String,
1643 #[serde(default = "default_dist_port")]
1645 pub master_port: u16,
1646}
1647
1648fn default_dist_backend() -> String {
1649 "nccl".to_string()
1650}
1651
1652fn default_dist_port() -> u16 {
1653 29500
1654}
1655
1656#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1675#[serde(deny_unknown_fields)]
1676pub struct GpuSpec {
1677 #[serde(default = "default_gpu_count")]
1679 pub count: u32,
1680 #[serde(default = "default_gpu_vendor")]
1682 pub vendor: String,
1683 #[serde(default, skip_serializing_if = "Option::is_none")]
1685 pub mode: Option<String>,
1686 #[serde(default, skip_serializing_if = "Option::is_none")]
1689 pub model: Option<String>,
1690 #[serde(default, skip_serializing_if = "Option::is_none")]
1695 pub scheduling: Option<SchedulingPolicy>,
1696 #[serde(default, skip_serializing_if = "Option::is_none")]
1699 pub distributed: Option<DistributedConfig>,
1700 #[serde(default, skip_serializing_if = "Option::is_none")]
1702 pub sharing: Option<GpuSharingMode>,
1703}
1704
1705fn default_gpu_count() -> u32 {
1706 1
1707}
1708
1709fn default_gpu_vendor() -> String {
1710 "nvidia".to_string()
1711}
1712
1713#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1715#[serde(deny_unknown_fields)]
1716#[derive(Default)]
1717pub struct ServiceNetworkSpec {
1718 #[serde(default)]
1720 pub overlays: OverlayConfig,
1721
1722 #[serde(default)]
1724 pub join: JoinPolicy,
1725}
1726
1727#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1729#[serde(deny_unknown_fields)]
1730pub struct OverlayConfig {
1731 #[serde(default)]
1733 pub service: OverlaySettings,
1734
1735 #[serde(default)]
1737 pub global: OverlaySettings,
1738}
1739
1740impl Default for OverlayConfig {
1741 fn default() -> Self {
1742 Self {
1743 service: OverlaySettings {
1744 enabled: true,
1745 encrypted: true,
1746 isolated: true,
1747 },
1748 global: OverlaySettings {
1749 enabled: true,
1750 encrypted: true,
1751 isolated: false,
1752 },
1753 }
1754 }
1755}
1756
1757#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1759#[serde(deny_unknown_fields)]
1760pub struct OverlaySettings {
1761 #[serde(default = "default_enabled")]
1763 pub enabled: bool,
1764
1765 #[serde(default = "default_encrypted")]
1767 pub encrypted: bool,
1768
1769 #[serde(default)]
1771 pub isolated: bool,
1772}
1773
1774fn default_enabled() -> bool {
1775 true
1776}
1777
1778fn default_encrypted() -> bool {
1779 true
1780}
1781
1782#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1784#[serde(deny_unknown_fields)]
1785pub struct JoinPolicy {
1786 #[serde(default = "default_join_mode")]
1788 pub mode: JoinMode,
1789
1790 #[serde(default = "default_join_scope")]
1792 pub scope: JoinScope,
1793}
1794
1795impl Default for JoinPolicy {
1796 fn default() -> Self {
1797 Self {
1798 mode: default_join_mode(),
1799 scope: default_join_scope(),
1800 }
1801 }
1802}
1803
1804fn default_join_mode() -> JoinMode {
1805 JoinMode::Token
1806}
1807
1808fn default_join_scope() -> JoinScope {
1809 JoinScope::Service
1810}
1811
1812#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1814#[serde(rename_all = "snake_case")]
1815pub enum JoinMode {
1816 Open,
1818 Token,
1820 Closed,
1822}
1823
1824#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1826#[serde(rename_all = "snake_case")]
1827pub enum JoinScope {
1828 Service,
1830 Global,
1832}
1833
1834#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1836#[serde(deny_unknown_fields)]
1837pub struct EndpointSpec {
1838 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
1840 pub name: String,
1841
1842 pub protocol: Protocol,
1844
1845 #[validate(custom(function = "crate::spec::validate::validate_port_wrapper"))]
1847 pub port: u16,
1848
1849 #[serde(default, skip_serializing_if = "Option::is_none")]
1852 pub target_port: Option<u16>,
1853
1854 pub path: Option<String>,
1856
1857 #[serde(default, skip_serializing_if = "Option::is_none")]
1860 pub host: Option<String>,
1861
1862 #[serde(default = "default_expose")]
1864 pub expose: ExposeType,
1865
1866 #[serde(default, skip_serializing_if = "Option::is_none")]
1869 pub stream: Option<StreamEndpointConfig>,
1870
1871 #[serde(default, skip_serializing_if = "Option::is_none")]
1873 pub tunnel: Option<EndpointTunnelConfig>,
1874}
1875
1876impl EndpointSpec {
1877 #[must_use]
1880 pub fn target_port(&self) -> u16 {
1881 self.target_port.unwrap_or(self.port)
1882 }
1883}
1884
1885#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1887#[serde(deny_unknown_fields)]
1888pub struct EndpointTunnelConfig {
1889 #[serde(default)]
1891 pub enabled: bool,
1892
1893 #[serde(default, skip_serializing_if = "Option::is_none")]
1895 pub from: Option<String>,
1896
1897 #[serde(default, skip_serializing_if = "Option::is_none")]
1899 pub to: Option<String>,
1900
1901 #[serde(default)]
1903 pub remote_port: u16,
1904
1905 #[serde(default, skip_serializing_if = "Option::is_none")]
1907 pub expose: Option<ExposeType>,
1908
1909 #[serde(default, skip_serializing_if = "Option::is_none")]
1911 pub access: Option<TunnelAccessConfig>,
1912}
1913
1914#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1916#[serde(deny_unknown_fields)]
1917pub struct TunnelAccessConfig {
1918 #[serde(default)]
1920 pub enabled: bool,
1921
1922 #[serde(default, skip_serializing_if = "Option::is_none")]
1924 pub max_ttl: Option<String>,
1925
1926 #[serde(default)]
1928 pub audit: bool,
1929}
1930
1931fn default_expose() -> ExposeType {
1932 ExposeType::Internal
1933}
1934
1935#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1937#[serde(rename_all = "lowercase")]
1938pub enum Protocol {
1939 Http,
1940 Https,
1941 Tcp,
1942 Udp,
1943 Websocket,
1944}
1945
1946#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1948#[serde(rename_all = "lowercase")]
1949pub enum ExposeType {
1950 Public,
1951 #[default]
1952 Internal,
1953}
1954
1955#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1957#[serde(deny_unknown_fields)]
1958pub struct StreamEndpointConfig {
1959 #[serde(default)]
1961 pub tls: bool,
1962
1963 #[serde(default)]
1965 pub proxy_protocol: bool,
1966
1967 #[serde(default, skip_serializing_if = "Option::is_none")]
1970 pub session_timeout: Option<String>,
1971
1972 #[serde(default, skip_serializing_if = "Option::is_none")]
1974 pub health_check: Option<StreamHealthCheck>,
1975}
1976
1977#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1979#[serde(tag = "type", rename_all = "snake_case")]
1980pub enum StreamHealthCheck {
1981 TcpConnect,
1983 UdpProbe {
1985 request: String,
1987 #[serde(default, skip_serializing_if = "Option::is_none")]
1989 expect: Option<String>,
1990 },
1991}
1992
1993#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1995#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
1996pub enum ScaleSpec {
1997 #[serde(rename = "adaptive")]
1999 Adaptive {
2000 min: u32,
2002
2003 max: u32,
2005
2006 #[serde(default, with = "duration::option")]
2008 cooldown: Option<std::time::Duration>,
2009
2010 #[serde(default)]
2012 targets: ScaleTargets,
2013 },
2014
2015 #[serde(rename = "fixed")]
2017 Fixed { replicas: u32 },
2018
2019 #[serde(rename = "manual")]
2021 Manual,
2022}
2023
2024impl Default for ScaleSpec {
2025 fn default() -> Self {
2026 Self::Adaptive {
2027 min: 1,
2028 max: 10,
2029 cooldown: Some(std::time::Duration::from_secs(30)),
2030 targets: ScaleTargets::default(),
2031 }
2032 }
2033}
2034
2035#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2037#[serde(deny_unknown_fields)]
2038#[derive(Default)]
2039pub struct ScaleTargets {
2040 #[serde(default)]
2042 pub cpu: Option<u8>,
2043
2044 #[serde(default)]
2046 pub memory: Option<u8>,
2047
2048 #[serde(default)]
2050 pub rps: Option<u32>,
2051}
2052
2053#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2055#[serde(deny_unknown_fields)]
2056pub struct DependsSpec {
2057 pub service: String,
2059
2060 #[serde(default = "default_condition")]
2062 pub condition: DependencyCondition,
2063
2064 #[serde(default = "default_timeout", with = "duration::option")]
2066 pub timeout: Option<std::time::Duration>,
2067
2068 #[serde(default = "default_on_timeout")]
2070 pub on_timeout: TimeoutAction,
2071}
2072
2073fn default_condition() -> DependencyCondition {
2074 DependencyCondition::Healthy
2075}
2076
2077#[allow(clippy::unnecessary_wraps)]
2078fn default_timeout() -> Option<std::time::Duration> {
2079 Some(std::time::Duration::from_secs(300))
2080}
2081
2082fn default_on_timeout() -> TimeoutAction {
2083 TimeoutAction::Fail
2084}
2085
2086#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2088#[serde(rename_all = "lowercase")]
2089pub enum DependencyCondition {
2090 Started,
2092 Healthy,
2094 Ready,
2096}
2097
2098#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2100#[serde(rename_all = "lowercase")]
2101pub enum TimeoutAction {
2102 Fail,
2103 Warn,
2104 Continue,
2105}
2106
2107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2109#[serde(deny_unknown_fields)]
2110pub struct HealthSpec {
2111 #[serde(default, with = "duration::option")]
2113 pub start_grace: Option<std::time::Duration>,
2114
2115 #[serde(default, with = "duration::option")]
2117 pub interval: Option<std::time::Duration>,
2118
2119 #[serde(default, with = "duration::option")]
2121 pub timeout: Option<std::time::Duration>,
2122
2123 #[serde(default = "default_retries")]
2125 pub retries: u32,
2126
2127 pub check: HealthCheck,
2129}
2130
2131fn default_retries() -> u32 {
2132 3
2133}
2134
2135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2137#[serde(tag = "type", rename_all = "lowercase")]
2138pub enum HealthCheck {
2139 Tcp {
2141 port: u16,
2143 },
2144
2145 Http {
2147 url: String,
2149 #[serde(default = "default_expect_status")]
2151 expect_status: u16,
2152 },
2153
2154 Command {
2156 command: String,
2158 },
2159}
2160
2161fn default_expect_status() -> u16 {
2162 200
2163}
2164
2165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2167#[serde(deny_unknown_fields)]
2168#[derive(Default)]
2169pub struct InitSpec {
2170 #[serde(default)]
2172 pub steps: Vec<InitStep>,
2173}
2174
2175#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
2182#[serde(deny_unknown_fields)]
2183pub struct LifecycleSpec {
2184 #[serde(default)]
2188 pub delete_on_exit: bool,
2189}
2190
2191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2193#[serde(deny_unknown_fields)]
2194pub struct InitStep {
2195 pub id: String,
2197
2198 pub uses: String,
2200
2201 #[serde(default)]
2203 pub with: InitParams,
2204
2205 #[serde(default)]
2207 pub retry: Option<u32>,
2208
2209 #[serde(default, with = "duration::option")]
2211 pub timeout: Option<std::time::Duration>,
2212
2213 #[serde(default = "default_on_failure")]
2215 pub on_failure: FailureAction,
2216}
2217
2218fn default_on_failure() -> FailureAction {
2219 FailureAction::Fail
2220}
2221
2222pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
2224
2225#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2227#[serde(rename_all = "lowercase")]
2228pub enum FailureAction {
2229 Fail,
2230 Warn,
2231 Continue,
2232}
2233
2234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2236#[serde(deny_unknown_fields)]
2237#[derive(Default)]
2238pub struct ErrorsSpec {
2239 #[serde(default)]
2241 pub on_init_failure: InitFailurePolicy,
2242
2243 #[serde(default)]
2245 pub on_panic: PanicPolicy,
2246}
2247
2248#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2250#[serde(deny_unknown_fields)]
2251pub struct InitFailurePolicy {
2252 #[serde(default = "default_init_action")]
2253 pub action: InitFailureAction,
2254}
2255
2256impl Default for InitFailurePolicy {
2257 fn default() -> Self {
2258 Self {
2259 action: default_init_action(),
2260 }
2261 }
2262}
2263
2264fn default_init_action() -> InitFailureAction {
2265 InitFailureAction::Fail
2266}
2267
2268#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2270#[serde(rename_all = "lowercase")]
2271pub enum InitFailureAction {
2272 Fail,
2273 Restart,
2274 Backoff,
2275}
2276
2277#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2279#[serde(deny_unknown_fields)]
2280pub struct PanicPolicy {
2281 #[serde(default = "default_panic_action")]
2282 pub action: PanicAction,
2283}
2284
2285impl Default for PanicPolicy {
2286 fn default() -> Self {
2287 Self {
2288 action: default_panic_action(),
2289 }
2290 }
2291}
2292
2293fn default_panic_action() -> PanicAction {
2294 PanicAction::Restart
2295}
2296
2297#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2299#[serde(rename_all = "lowercase")]
2300pub enum PanicAction {
2301 Restart,
2302 Shutdown,
2303 Isolate,
2304}
2305
2306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
2313pub struct NetworkPolicySpec {
2314 pub name: String,
2316
2317 #[serde(default, skip_serializing_if = "Option::is_none")]
2319 pub description: Option<String>,
2320
2321 #[serde(default)]
2323 pub cidrs: Vec<String>,
2324
2325 #[serde(default)]
2327 pub members: Vec<NetworkMember>,
2328
2329 #[serde(default)]
2331 pub access_rules: Vec<AccessRule>,
2332}
2333
2334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2336pub struct NetworkMember {
2337 pub name: String,
2339 #[serde(default)]
2341 pub kind: MemberKind,
2342}
2343
2344#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2346#[serde(rename_all = "lowercase")]
2347pub enum MemberKind {
2348 #[default]
2350 User,
2351 Group,
2353 Node,
2355 Cidr,
2357}
2358
2359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2361pub struct AccessRule {
2362 #[serde(default = "wildcard")]
2364 pub service: String,
2365
2366 #[serde(default = "wildcard")]
2368 pub deployment: String,
2369
2370 #[serde(default, skip_serializing_if = "Option::is_none")]
2372 pub ports: Option<Vec<u16>>,
2373
2374 #[serde(default)]
2376 pub action: AccessAction,
2377}
2378
2379fn wildcard() -> String {
2380 "*".to_string()
2381}
2382
2383#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2385#[serde(rename_all = "lowercase")]
2386pub enum AccessAction {
2387 #[default]
2389 Allow,
2390 Deny,
2392}
2393
2394#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2406pub struct BridgeNetwork {
2407 pub id: String,
2409
2410 pub name: String,
2412
2413 #[serde(default)]
2415 pub driver: BridgeNetworkDriver,
2416
2417 #[serde(default, skip_serializing_if = "Option::is_none")]
2419 pub subnet: Option<String>,
2420
2421 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2423 pub labels: HashMap<String, String>,
2424
2425 #[serde(default)]
2428 pub internal: bool,
2429
2430 #[schema(value_type = String, format = "date-time")]
2432 pub created_at: chrono::DateTime<chrono::Utc>,
2433}
2434
2435#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2437#[serde(rename_all = "lowercase")]
2438pub enum BridgeNetworkDriver {
2439 #[default]
2441 Bridge,
2442 Overlay,
2444}
2445
2446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2448pub struct BridgeNetworkAttachment {
2449 pub container_id: String,
2451
2452 #[serde(default, skip_serializing_if = "Option::is_none")]
2454 pub container_name: Option<String>,
2455
2456 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2458 pub aliases: Vec<String>,
2459
2460 #[serde(default, skip_serializing_if = "Option::is_none")]
2462 pub ipv4: Option<String>,
2463}
2464
2465#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2486pub struct RegistryAuth {
2487 pub username: String,
2490 pub password: String,
2493 #[serde(default = "default_registry_auth_type")]
2495 pub auth_type: RegistryAuthType,
2496}
2497
2498#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2500#[serde(rename_all = "snake_case")]
2501pub enum RegistryAuthType {
2502 #[default]
2504 Basic,
2505 Token,
2508}
2509
2510#[must_use]
2513pub fn default_registry_auth_type() -> RegistryAuthType {
2514 RegistryAuthType::Basic
2515}
2516
2517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2532#[serde(rename_all = "snake_case", deny_unknown_fields)]
2533pub struct ContainerRestartPolicy {
2534 pub kind: ContainerRestartKind,
2536
2537 #[serde(default, skip_serializing_if = "Option::is_none")]
2540 pub max_attempts: Option<u32>,
2541
2542 #[serde(default, skip_serializing_if = "Option::is_none")]
2547 pub delay: Option<String>,
2548}
2549
2550#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2552#[serde(rename_all = "snake_case")]
2553pub enum ContainerRestartKind {
2554 No,
2556 Always,
2558 UnlessStopped,
2561 OnFailure,
2564}
2565
2566#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2572#[serde(rename_all = "snake_case")]
2573pub enum PortProtocol {
2574 Tcp,
2576 Udp,
2578}
2579
2580impl Default for PortProtocol {
2581 fn default() -> Self {
2582 default_port_protocol()
2583 }
2584}
2585
2586impl PortProtocol {
2587 #[must_use]
2590 pub fn as_str(&self) -> &'static str {
2591 match self {
2592 PortProtocol::Tcp => "tcp",
2593 PortProtocol::Udp => "udp",
2594 }
2595 }
2596}
2597
2598fn default_port_protocol() -> PortProtocol {
2599 PortProtocol::Tcp
2600}
2601
2602fn default_host_ip() -> String {
2603 "0.0.0.0".to_string()
2604}
2605
2606#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2612#[serde(rename_all = "snake_case")]
2613pub struct PortMapping {
2614 #[serde(default, skip_serializing_if = "Option::is_none")]
2616 pub host_port: Option<u16>,
2617 pub container_port: u16,
2619 #[serde(default = "default_port_protocol")]
2621 pub protocol: PortProtocol,
2622 #[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
2624 pub host_ip: String,
2625}
2626
2627#[cfg(test)]
2628mod tests {
2629 use super::*;
2630
2631 #[test]
2632 fn port_mapping_defaults_via_serde() {
2633 let json = r#"{"container_port": 8080}"#;
2636 let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
2637 assert_eq!(m.container_port, 8080);
2638 assert_eq!(m.host_port, None);
2639 assert_eq!(m.protocol, PortProtocol::Tcp);
2640 assert_eq!(m.host_ip, "0.0.0.0");
2641 }
2642
2643 #[test]
2644 fn port_mapping_skips_none_host_port_and_empty_host_ip() {
2645 let m = PortMapping {
2646 host_port: None,
2647 container_port: 443,
2648 protocol: PortProtocol::Tcp,
2649 host_ip: String::new(),
2650 };
2651 let s = serde_json::to_string(&m).expect("serialize");
2652 assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
2654 assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
2655 assert!(s.contains("\"container_port\":443"));
2656 assert!(s.contains("\"protocol\":\"tcp\""));
2657 }
2658
2659 #[test]
2660 fn test_parse_simple_spec() {
2661 let yaml = r"
2662version: v1
2663deployment: test
2664services:
2665 hello:
2666 rtype: service
2667 image:
2668 name: hello-world:latest
2669 endpoints:
2670 - name: http
2671 protocol: http
2672 port: 8080
2673 expose: public
2674";
2675
2676 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2677 assert_eq!(spec.version, "v1");
2678 assert_eq!(spec.deployment, "test");
2679 assert!(spec.services.contains_key("hello"));
2680 }
2681
2682 #[test]
2683 fn test_parse_duration() {
2684 let yaml = r"
2685version: v1
2686deployment: test
2687services:
2688 test:
2689 rtype: service
2690 image:
2691 name: test:latest
2692 health:
2693 timeout: 30s
2694 interval: 1m
2695 start_grace: 5s
2696 check:
2697 type: tcp
2698 port: 8080
2699";
2700
2701 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2702 let health = &spec.services["test"].health;
2703 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
2704 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
2705 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
2706 match &health.check {
2707 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
2708 _ => panic!("Expected TCP health check"),
2709 }
2710 }
2711
2712 #[test]
2713 fn test_parse_adaptive_scale() {
2714 let yaml = r"
2715version: v1
2716deployment: test
2717services:
2718 test:
2719 rtype: service
2720 image:
2721 name: test:latest
2722 scale:
2723 mode: adaptive
2724 min: 2
2725 max: 10
2726 cooldown: 15s
2727 targets:
2728 cpu: 70
2729 rps: 800
2730";
2731
2732 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2733 let scale = &spec.services["test"].scale;
2734 match scale {
2735 ScaleSpec::Adaptive {
2736 min,
2737 max,
2738 cooldown,
2739 targets,
2740 } => {
2741 assert_eq!(*min, 2);
2742 assert_eq!(*max, 10);
2743 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
2744 assert_eq!(targets.cpu, Some(70));
2745 assert_eq!(targets.rps, Some(800));
2746 }
2747 _ => panic!("Expected Adaptive scale mode"),
2748 }
2749 }
2750
2751 #[test]
2752 fn test_node_mode_default() {
2753 let yaml = r"
2754version: v1
2755deployment: test
2756services:
2757 hello:
2758 rtype: service
2759 image:
2760 name: hello-world:latest
2761";
2762
2763 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2764 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
2765 assert!(spec.services["hello"].node_selector.is_none());
2766 }
2767
2768 #[test]
2769 fn test_node_mode_dedicated() {
2770 let yaml = r"
2771version: v1
2772deployment: test
2773services:
2774 api:
2775 rtype: service
2776 image:
2777 name: api:latest
2778 node_mode: dedicated
2779";
2780
2781 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2782 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2783 }
2784
2785 #[test]
2786 fn test_node_mode_exclusive() {
2787 let yaml = r"
2788version: v1
2789deployment: test
2790services:
2791 database:
2792 rtype: service
2793 image:
2794 name: postgres:15
2795 node_mode: exclusive
2796";
2797
2798 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2799 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2800 }
2801
2802 #[test]
2803 fn test_node_selector_with_labels() {
2804 let yaml = r#"
2805version: v1
2806deployment: test
2807services:
2808 ml-worker:
2809 rtype: service
2810 image:
2811 name: ml-worker:latest
2812 node_mode: dedicated
2813 node_selector:
2814 labels:
2815 gpu: "true"
2816 zone: us-east
2817 prefer_labels:
2818 storage: ssd
2819"#;
2820
2821 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2822 let service = &spec.services["ml-worker"];
2823 assert_eq!(service.node_mode, NodeMode::Dedicated);
2824
2825 let selector = service.node_selector.as_ref().unwrap();
2826 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
2827 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
2828 assert_eq!(
2829 selector.prefer_labels.get("storage"),
2830 Some(&"ssd".to_string())
2831 );
2832 }
2833
2834 #[test]
2835 fn test_node_mode_serialization_roundtrip() {
2836 use serde_json;
2837
2838 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
2840 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
2841
2842 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
2843 let json = serde_json::to_string(mode).unwrap();
2844 assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
2845
2846 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
2847 assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
2848 }
2849 }
2850
2851 #[test]
2852 fn test_node_selector_empty() {
2853 let yaml = r"
2854version: v1
2855deployment: test
2856services:
2857 api:
2858 rtype: service
2859 image:
2860 name: api:latest
2861 node_selector:
2862 labels: {}
2863";
2864
2865 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2866 let selector = spec.services["api"].node_selector.as_ref().unwrap();
2867 assert!(selector.labels.is_empty());
2868 assert!(selector.prefer_labels.is_empty());
2869 }
2870
2871 #[test]
2872 fn test_mixed_node_modes_in_deployment() {
2873 let yaml = r"
2874version: v1
2875deployment: test
2876services:
2877 redis:
2878 rtype: service
2879 image:
2880 name: redis:alpine
2881 # Default shared mode
2882 api:
2883 rtype: service
2884 image:
2885 name: api:latest
2886 node_mode: dedicated
2887 database:
2888 rtype: service
2889 image:
2890 name: postgres:15
2891 node_mode: exclusive
2892 node_selector:
2893 labels:
2894 storage: ssd
2895";
2896
2897 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2898 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
2899 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2900 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2901
2902 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
2903 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
2904 }
2905
2906 #[test]
2907 fn test_storage_bind_mount() {
2908 let yaml = r"
2909version: v1
2910deployment: test
2911services:
2912 app:
2913 image:
2914 name: app:latest
2915 storage:
2916 - type: bind
2917 source: /host/data
2918 target: /app/data
2919 readonly: true
2920";
2921 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2922 let storage = &spec.services["app"].storage;
2923 assert_eq!(storage.len(), 1);
2924 match &storage[0] {
2925 StorageSpec::Bind {
2926 source,
2927 target,
2928 readonly,
2929 } => {
2930 assert_eq!(source, "/host/data");
2931 assert_eq!(target, "/app/data");
2932 assert!(*readonly);
2933 }
2934 _ => panic!("Expected Bind storage"),
2935 }
2936 }
2937
2938 #[test]
2939 fn test_storage_named_with_tier() {
2940 let yaml = r"
2941version: v1
2942deployment: test
2943services:
2944 app:
2945 image:
2946 name: app:latest
2947 storage:
2948 - type: named
2949 name: my-data
2950 target: /app/data
2951 tier: cached
2952";
2953 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2954 let storage = &spec.services["app"].storage;
2955 match &storage[0] {
2956 StorageSpec::Named {
2957 name, target, tier, ..
2958 } => {
2959 assert_eq!(name, "my-data");
2960 assert_eq!(target, "/app/data");
2961 assert_eq!(*tier, StorageTier::Cached);
2962 }
2963 _ => panic!("Expected Named storage"),
2964 }
2965 }
2966
2967 #[test]
2968 fn test_storage_anonymous() {
2969 let yaml = r"
2970version: v1
2971deployment: test
2972services:
2973 app:
2974 image:
2975 name: app:latest
2976 storage:
2977 - type: anonymous
2978 target: /app/cache
2979";
2980 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2981 let storage = &spec.services["app"].storage;
2982 match &storage[0] {
2983 StorageSpec::Anonymous { target, tier } => {
2984 assert_eq!(target, "/app/cache");
2985 assert_eq!(*tier, StorageTier::Local); }
2987 _ => panic!("Expected Anonymous storage"),
2988 }
2989 }
2990
2991 #[test]
2992 fn test_storage_tmpfs() {
2993 let yaml = r"
2994version: v1
2995deployment: test
2996services:
2997 app:
2998 image:
2999 name: app:latest
3000 storage:
3001 - type: tmpfs
3002 target: /app/tmp
3003 size: 256Mi
3004 mode: 1777
3005";
3006 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3007 let storage = &spec.services["app"].storage;
3008 match &storage[0] {
3009 StorageSpec::Tmpfs { target, size, mode } => {
3010 assert_eq!(target, "/app/tmp");
3011 assert_eq!(size.as_deref(), Some("256Mi"));
3012 assert_eq!(*mode, Some(1777));
3013 }
3014 _ => panic!("Expected Tmpfs storage"),
3015 }
3016 }
3017
3018 #[test]
3019 fn test_storage_s3() {
3020 let yaml = r"
3021version: v1
3022deployment: test
3023services:
3024 app:
3025 image:
3026 name: app:latest
3027 storage:
3028 - type: s3
3029 bucket: my-bucket
3030 prefix: models/
3031 target: /app/models
3032 readonly: true
3033 endpoint: https://s3.us-west-2.amazonaws.com
3034 credentials: aws-creds
3035";
3036 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3037 let storage = &spec.services["app"].storage;
3038 match &storage[0] {
3039 StorageSpec::S3 {
3040 bucket,
3041 prefix,
3042 target,
3043 readonly,
3044 endpoint,
3045 credentials,
3046 } => {
3047 assert_eq!(bucket, "my-bucket");
3048 assert_eq!(prefix.as_deref(), Some("models/"));
3049 assert_eq!(target, "/app/models");
3050 assert!(*readonly);
3051 assert_eq!(
3052 endpoint.as_deref(),
3053 Some("https://s3.us-west-2.amazonaws.com")
3054 );
3055 assert_eq!(credentials.as_deref(), Some("aws-creds"));
3056 }
3057 _ => panic!("Expected S3 storage"),
3058 }
3059 }
3060
3061 #[test]
3062 fn test_storage_multiple_types() {
3063 let yaml = r"
3064version: v1
3065deployment: test
3066services:
3067 app:
3068 image:
3069 name: app:latest
3070 storage:
3071 - type: bind
3072 source: /etc/config
3073 target: /app/config
3074 readonly: true
3075 - type: named
3076 name: app-data
3077 target: /app/data
3078 - type: tmpfs
3079 target: /app/tmp
3080";
3081 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3082 let storage = &spec.services["app"].storage;
3083 assert_eq!(storage.len(), 3);
3084 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
3085 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
3086 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
3087 }
3088
3089 #[test]
3090 fn test_storage_tier_default() {
3091 let yaml = r"
3092version: v1
3093deployment: test
3094services:
3095 app:
3096 image:
3097 name: app:latest
3098 storage:
3099 - type: named
3100 name: data
3101 target: /data
3102";
3103 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3104 match &spec.services["app"].storage[0] {
3105 StorageSpec::Named { tier, .. } => {
3106 assert_eq!(*tier, StorageTier::Local); }
3108 _ => panic!("Expected Named storage"),
3109 }
3110 }
3111
3112 #[test]
3117 fn test_endpoint_tunnel_config_basic() {
3118 let yaml = r"
3119version: v1
3120deployment: test
3121services:
3122 api:
3123 image:
3124 name: api:latest
3125 endpoints:
3126 - name: http
3127 protocol: http
3128 port: 8080
3129 tunnel:
3130 enabled: true
3131 remote_port: 8080
3132";
3133 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3134 let endpoint = &spec.services["api"].endpoints[0];
3135 let tunnel = endpoint.tunnel.as_ref().unwrap();
3136 assert!(tunnel.enabled);
3137 assert_eq!(tunnel.remote_port, 8080);
3138 assert!(tunnel.from.is_none());
3139 assert!(tunnel.to.is_none());
3140 }
3141
3142 #[test]
3143 fn test_endpoint_tunnel_config_full() {
3144 let yaml = r"
3145version: v1
3146deployment: test
3147services:
3148 api:
3149 image:
3150 name: api:latest
3151 endpoints:
3152 - name: http
3153 protocol: http
3154 port: 8080
3155 tunnel:
3156 enabled: true
3157 from: node-1
3158 to: ingress-node
3159 remote_port: 9000
3160 expose: public
3161 access:
3162 enabled: true
3163 max_ttl: 4h
3164 audit: true
3165";
3166 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3167 let endpoint = &spec.services["api"].endpoints[0];
3168 let tunnel = endpoint.tunnel.as_ref().unwrap();
3169 assert!(tunnel.enabled);
3170 assert_eq!(tunnel.from, Some("node-1".to_string()));
3171 assert_eq!(tunnel.to, Some("ingress-node".to_string()));
3172 assert_eq!(tunnel.remote_port, 9000);
3173 assert_eq!(tunnel.expose, Some(ExposeType::Public));
3174
3175 let access = tunnel.access.as_ref().unwrap();
3176 assert!(access.enabled);
3177 assert_eq!(access.max_ttl, Some("4h".to_string()));
3178 assert!(access.audit);
3179 }
3180
3181 #[test]
3182 fn test_top_level_tunnel_definition() {
3183 let yaml = r"
3184version: v1
3185deployment: test
3186services: {}
3187tunnels:
3188 db-tunnel:
3189 from: app-node
3190 to: db-node
3191 local_port: 5432
3192 remote_port: 5432
3193 protocol: tcp
3194 expose: internal
3195";
3196 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3197 let tunnel = spec.tunnels.get("db-tunnel").unwrap();
3198 assert_eq!(tunnel.from, "app-node");
3199 assert_eq!(tunnel.to, "db-node");
3200 assert_eq!(tunnel.local_port, 5432);
3201 assert_eq!(tunnel.remote_port, 5432);
3202 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
3203 assert_eq!(tunnel.expose, ExposeType::Internal);
3204 }
3205
3206 #[test]
3207 fn test_top_level_tunnel_defaults() {
3208 let yaml = r"
3209version: v1
3210deployment: test
3211services: {}
3212tunnels:
3213 simple-tunnel:
3214 from: node-a
3215 to: node-b
3216 local_port: 3000
3217 remote_port: 3000
3218";
3219 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3220 let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
3221 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
3224
3225 #[test]
3226 fn test_tunnel_protocol_udp() {
3227 let yaml = r"
3228version: v1
3229deployment: test
3230services: {}
3231tunnels:
3232 udp-tunnel:
3233 from: node-a
3234 to: node-b
3235 local_port: 5353
3236 remote_port: 5353
3237 protocol: udp
3238";
3239 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3240 let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
3241 assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
3242 }
3243
3244 #[test]
3245 fn test_endpoint_without_tunnel() {
3246 let yaml = r"
3247version: v1
3248deployment: test
3249services:
3250 api:
3251 image:
3252 name: api:latest
3253 endpoints:
3254 - name: http
3255 protocol: http
3256 port: 8080
3257";
3258 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3259 let endpoint = &spec.services["api"].endpoints[0];
3260 assert!(endpoint.tunnel.is_none());
3261 }
3262
3263 #[test]
3264 fn test_deployment_without_tunnels() {
3265 let yaml = r"
3266version: v1
3267deployment: test
3268services:
3269 api:
3270 image:
3271 name: api:latest
3272";
3273 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3274 assert!(spec.tunnels.is_empty());
3275 }
3276
3277 #[test]
3282 fn test_spec_without_api_block_uses_defaults() {
3283 let yaml = r"
3284version: v1
3285deployment: test
3286services:
3287 hello:
3288 image:
3289 name: hello-world:latest
3290";
3291 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3292 assert!(spec.api.enabled);
3293 assert_eq!(spec.api.bind, "0.0.0.0:3669");
3294 assert!(spec.api.jwt_secret.is_none());
3295 assert!(spec.api.swagger);
3296 }
3297
3298 #[test]
3299 fn test_spec_with_explicit_api_block() {
3300 let yaml = r#"
3301version: v1
3302deployment: test
3303services:
3304 hello:
3305 image:
3306 name: hello-world:latest
3307api:
3308 enabled: false
3309 bind: "127.0.0.1:9090"
3310 jwt_secret: "my-secret"
3311 swagger: false
3312"#;
3313 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3314 assert!(!spec.api.enabled);
3315 assert_eq!(spec.api.bind, "127.0.0.1:9090");
3316 assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
3317 assert!(!spec.api.swagger);
3318 }
3319
3320 #[test]
3321 fn test_spec_with_partial_api_block() {
3322 let yaml = r#"
3323version: v1
3324deployment: test
3325services:
3326 hello:
3327 image:
3328 name: hello-world:latest
3329api:
3330 bind: "0.0.0.0:3000"
3331"#;
3332 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3333 assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
3335 assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
3338
3339 #[test]
3344 fn test_network_policy_spec_roundtrip() {
3345 let spec = NetworkPolicySpec {
3346 name: "corp-vpn".to_string(),
3347 description: Some("Corporate VPN network".to_string()),
3348 cidrs: vec!["10.200.0.0/16".to_string()],
3349 members: vec![
3350 NetworkMember {
3351 name: "alice".to_string(),
3352 kind: MemberKind::User,
3353 },
3354 NetworkMember {
3355 name: "ops-team".to_string(),
3356 kind: MemberKind::Group,
3357 },
3358 NetworkMember {
3359 name: "node-01".to_string(),
3360 kind: MemberKind::Node,
3361 },
3362 ],
3363 access_rules: vec![
3364 AccessRule {
3365 service: "api-gateway".to_string(),
3366 deployment: "*".to_string(),
3367 ports: Some(vec![443, 8080]),
3368 action: AccessAction::Allow,
3369 },
3370 AccessRule {
3371 service: "*".to_string(),
3372 deployment: "staging".to_string(),
3373 ports: None,
3374 action: AccessAction::Deny,
3375 },
3376 ],
3377 };
3378
3379 let yaml = serde_yaml::to_string(&spec).unwrap();
3380 let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
3381 assert_eq!(spec, deserialized);
3382 }
3383
3384 #[test]
3385 fn test_network_policy_spec_defaults() {
3386 let yaml = r"
3387name: minimal
3388";
3389 let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
3390 assert_eq!(spec.name, "minimal");
3391 assert!(spec.description.is_none());
3392 assert!(spec.cidrs.is_empty());
3393 assert!(spec.members.is_empty());
3394 assert!(spec.access_rules.is_empty());
3395 }
3396
3397 #[test]
3398 fn test_access_rule_defaults() {
3399 let yaml = "{}";
3400 let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
3401 assert_eq!(rule.service, "*");
3402 assert_eq!(rule.deployment, "*");
3403 assert!(rule.ports.is_none());
3404 assert_eq!(rule.action, AccessAction::Allow);
3405 }
3406
3407 #[test]
3408 fn test_member_kind_defaults_to_user() {
3409 let yaml = r"
3410name: bob
3411";
3412 let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
3413 assert_eq!(member.name, "bob");
3414 assert_eq!(member.kind, MemberKind::User);
3415 }
3416
3417 #[test]
3418 fn test_member_kind_variants() {
3419 for (input, expected) in [
3420 ("user", MemberKind::User),
3421 ("group", MemberKind::Group),
3422 ("node", MemberKind::Node),
3423 ("cidr", MemberKind::Cidr),
3424 ] {
3425 let yaml = format!("name: test\nkind: {input}");
3426 let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
3427 assert_eq!(member.kind, expected);
3428 }
3429 }
3430
3431 #[test]
3432 fn test_access_action_variants() {
3433 #[derive(Debug, Deserialize)]
3435 struct Wrapper {
3436 action: AccessAction,
3437 }
3438
3439 let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
3440 let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
3441
3442 assert_eq!(allow.action, AccessAction::Allow);
3443 assert_eq!(deny.action, AccessAction::Deny);
3444 }
3445
3446 #[test]
3447 fn test_network_policy_spec_default_impl() {
3448 let spec = NetworkPolicySpec::default();
3449 assert_eq!(spec.name, "");
3450 assert!(spec.description.is_none());
3451 assert!(spec.cidrs.is_empty());
3452 assert!(spec.members.is_empty());
3453 assert!(spec.access_rules.is_empty());
3454 }
3455
3456 #[test]
3457 fn container_restart_policy_serde_roundtrip_all_kinds() {
3458 let cases = [
3463 (
3464 ContainerRestartPolicy {
3465 kind: ContainerRestartKind::No,
3466 max_attempts: None,
3467 delay: None,
3468 },
3469 r#"{"kind":"no"}"#,
3470 ),
3471 (
3472 ContainerRestartPolicy {
3473 kind: ContainerRestartKind::Always,
3474 max_attempts: None,
3475 delay: Some("500ms".to_string()),
3476 },
3477 r#"{"kind":"always","delay":"500ms"}"#,
3478 ),
3479 (
3480 ContainerRestartPolicy {
3481 kind: ContainerRestartKind::UnlessStopped,
3482 max_attempts: None,
3483 delay: None,
3484 },
3485 r#"{"kind":"unless_stopped"}"#,
3486 ),
3487 (
3488 ContainerRestartPolicy {
3489 kind: ContainerRestartKind::OnFailure,
3490 max_attempts: Some(5),
3491 delay: None,
3492 },
3493 r#"{"kind":"on_failure","max_attempts":5}"#,
3494 ),
3495 ];
3496
3497 for (value, expected_json) in &cases {
3498 let serialized = serde_json::to_string(value).expect("serialize");
3499 assert_eq!(&serialized, expected_json, "serialize mismatch");
3500 let round: ContainerRestartPolicy =
3501 serde_json::from_str(&serialized).expect("deserialize");
3502 assert_eq!(&round, value, "roundtrip mismatch");
3503 }
3504 }
3505
3506 #[test]
3509 fn registry_auth_type_serializes_snake_case() {
3510 assert_eq!(
3511 serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
3512 "\"basic\""
3513 );
3514 assert_eq!(
3515 serde_json::to_string(&RegistryAuthType::Token).unwrap(),
3516 "\"token\""
3517 );
3518 }
3519
3520 #[test]
3521 fn registry_auth_default_auth_type_is_basic() {
3522 let json = r#"{"username":"u","password":"p"}"#;
3524 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3525 assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
3526 assert_eq!(parsed.username, "u");
3527 assert_eq!(parsed.password, "p");
3528 }
3529
3530 #[test]
3531 fn registry_auth_serde_roundtrip_both_variants() {
3532 for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
3533 let cred = RegistryAuth {
3534 username: "ci-bot".to_string(),
3535 password: "s3cret".to_string(),
3536 auth_type: variant,
3537 };
3538 let serialized = serde_json::to_string(&cred).expect("serialize");
3539 let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
3540 assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
3541 }
3542 }
3543
3544 #[test]
3545 fn registry_auth_explicit_token_type_parses() {
3546 let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
3547 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3548 assert_eq!(parsed.auth_type, RegistryAuthType::Token);
3549 }
3550
3551 #[test]
3552 fn target_platform_as_oci_str() {
3553 assert_eq!(
3554 TargetPlatform::new(OsKind::Linux, ArchKind::Amd64).as_oci_str(),
3555 "linux/amd64"
3556 );
3557 assert_eq!(
3558 TargetPlatform::new(OsKind::Windows, ArchKind::Arm64).as_oci_str(),
3559 "windows/arm64"
3560 );
3561 assert_eq!(
3562 TargetPlatform::new(OsKind::Macos, ArchKind::Arm64).as_oci_str(),
3563 "darwin/arm64"
3564 );
3565 }
3566
3567 #[test]
3568 fn os_kind_from_rust_consts() {
3569 assert_eq!(OsKind::from_rust_os("linux"), Some(OsKind::Linux));
3570 assert_eq!(OsKind::from_rust_os("windows"), Some(OsKind::Windows));
3571 assert_eq!(OsKind::from_rust_os("macos"), Some(OsKind::Macos));
3572 assert_eq!(OsKind::from_rust_os("freebsd"), None);
3573 }
3574
3575 #[test]
3576 fn arch_kind_from_rust_consts() {
3577 assert_eq!(ArchKind::from_rust_arch("x86_64"), Some(ArchKind::Amd64));
3578 assert_eq!(ArchKind::from_rust_arch("aarch64"), Some(ArchKind::Arm64));
3579 assert_eq!(ArchKind::from_rust_arch("riscv64"), None);
3580 }
3581
3582 #[test]
3583 fn service_spec_platform_yaml_round_trip_none() {
3584 let yaml = r"
3587version: v1
3588deployment: test
3589services:
3590 app:
3591 rtype: service
3592 image:
3593 name: nginx:latest
3594";
3595 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3596 assert!(spec.services["app"].platform.is_none());
3597 }
3598
3599 #[test]
3600 fn service_spec_platform_yaml_round_trip_some() {
3601 let yaml = r"
3602version: v1
3603deployment: test
3604services:
3605 app:
3606 rtype: service
3607 image:
3608 name: nginx:latest
3609 platform:
3610 os: windows
3611 arch: amd64
3612";
3613 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3614 assert_eq!(
3615 spec.services["app"].platform,
3616 Some(TargetPlatform::new(OsKind::Windows, ArchKind::Amd64))
3617 );
3618 }
3619
3620 #[test]
3621 fn service_spec_platform_serializes_omitted_when_none() {
3622 let yaml = r"
3625version: v1
3626deployment: test
3627services:
3628 app:
3629 rtype: service
3630 image:
3631 name: nginx:latest
3632";
3633 let mut spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3634 let service = spec.services.get_mut("app").expect("service present");
3635 service.platform = None;
3636 let rendered = serde_yaml::to_string(service).expect("render");
3637 assert!(
3638 !rendered.contains("platform"),
3639 "platform must be omitted when None: {rendered}"
3640 );
3641 }
3642
3643 #[test]
3644 fn target_platform_os_version_builder() {
3645 let p =
3646 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3647 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3648 assert_eq!(p.os, OsKind::Windows);
3649 assert_eq!(p.arch, ArchKind::Amd64);
3650 }
3651
3652 #[test]
3653 fn target_platform_os_version_yaml_roundtrip() {
3654 let yaml = "os: windows\narch: amd64\nosVersion: 10.0.26100.1\n";
3655 let p: TargetPlatform = serde_yaml::from_str(yaml).expect("yaml parse");
3656 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3657 assert_eq!(p.os, OsKind::Windows);
3658 assert_eq!(p.arch, ArchKind::Amd64);
3659 }
3660
3661 #[test]
3662 fn target_platform_os_version_yaml_omits_when_none() {
3663 let p = TargetPlatform::new(OsKind::Linux, ArchKind::Amd64);
3664 let rendered = serde_yaml::to_string(&p).expect("render");
3665 assert!(
3666 !rendered.contains("osVersion"),
3667 "osVersion must be omitted when None: {rendered}"
3668 );
3669 }
3670
3671 #[test]
3672 fn target_platform_as_detailed_str_includes_version() {
3673 let without = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).as_detailed_str();
3674 assert_eq!(without, "windows/amd64");
3675
3676 let with = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64)
3677 .with_os_version("10.0.26100.1")
3678 .as_detailed_str();
3679 assert_eq!(with, "windows/amd64 (os.version=10.0.26100.1)");
3680 }
3681
3682 #[test]
3683 fn target_platform_display_ignores_version() {
3684 let p =
3686 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3687 assert_eq!(format!("{p}"), "windows/amd64");
3688 }
3689
3690 fn fixture_service_spec_full() -> ServiceSpec {
3696 let yaml = r"
3697version: v1
3698deployment: phase1-task1
3699services:
3700 hello:
3701 rtype: service
3702 image:
3703 name: hello-world:latest
3704";
3705 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse fixture");
3706 spec.services.get("hello").expect("hello service").clone()
3707 }
3708
3709 #[test]
3710 fn service_spec_round_trip_with_all_new_fields() {
3711 let mut spec = fixture_service_spec_full();
3712 spec.labels
3713 .insert("zlayer.team".to_string(), "platform".to_string());
3714 spec.user = Some("1000:1000".to_string());
3715 spec.stop_signal = Some("SIGTERM".to_string());
3716 spec.stop_grace_period = Some(std::time::Duration::from_secs(30));
3717 spec.sysctls
3718 .insert("net.core.somaxconn".to_string(), "1024".to_string());
3719 spec.ulimits.insert(
3720 "nofile".to_string(),
3721 UlimitSpec {
3722 soft: 65_536,
3723 hard: 65_536,
3724 },
3725 );
3726 spec.security_opt.push("no-new-privileges:true".to_string());
3727 spec.pid_mode = Some("host".to_string());
3728 spec.ipc_mode = Some("private".to_string());
3729 spec.network_mode = NetworkMode::Bridge {
3730 name: Some("custom-net".to_string()),
3731 };
3732 spec.cap_drop.push("NET_RAW".to_string());
3733 spec.extra_groups.push("docker".to_string());
3734 spec.read_only_root_fs = true;
3735 spec.init_container = Some(true);
3736 spec.resources.pids_limit = Some(2048);
3737 spec.resources.cpuset = Some("0-3".to_string());
3738 spec.resources.cpu_shares = Some(1024);
3739 spec.resources.memory_swap = Some("2Gi".to_string());
3740 spec.resources.memory_reservation = Some("256Mi".to_string());
3741 spec.resources.memory_swappiness = Some(10);
3742 spec.resources.oom_score_adj = Some(-500);
3743 spec.resources.oom_kill_disable = Some(false);
3744 spec.resources.blkio_weight = Some(500);
3745
3746 let yaml = serde_yaml::to_string(&spec).expect("serialize");
3747 let round: ServiceSpec = serde_yaml::from_str(&yaml).expect("deserialize");
3748 assert_eq!(spec, round, "round-trip mismatch:\n{yaml}");
3749 }
3750
3751 #[test]
3752 fn network_mode_string_form_round_trip() {
3753 let cases: &[(&str, NetworkMode)] = &[
3754 ("default", NetworkMode::Default),
3755 ("host", NetworkMode::Host),
3756 ("none", NetworkMode::None),
3757 ("bridge", NetworkMode::Bridge { name: None }),
3758 (
3759 "bridge:custom",
3760 NetworkMode::Bridge {
3761 name: Some("custom".to_string()),
3762 },
3763 ),
3764 (
3765 "container:abc123",
3766 NetworkMode::Container {
3767 id: "abc123".to_string(),
3768 },
3769 ),
3770 ];
3771
3772 for (input, expected) in cases {
3773 #[derive(Deserialize)]
3774 struct Wrap {
3775 #[serde(deserialize_with = "deserialize_network_mode")]
3776 m: NetworkMode,
3777 }
3778 let yaml = format!("m: \"{input}\"\n");
3779 let parsed: Wrap = serde_yaml::from_str(&yaml).expect("parse network mode");
3780 assert_eq!(&parsed.m, expected, "mismatch for {input}");
3781 }
3782 }
3783
3784 #[test]
3785 fn ulimit_spec_round_trip() {
3786 let u = UlimitSpec {
3787 soft: 1024,
3788 hard: 65_536,
3789 };
3790 let yaml = serde_yaml::to_string(&u).expect("serialize");
3791 let parsed: UlimitSpec = serde_yaml::from_str(&yaml).expect("parse");
3792 assert_eq!(u, parsed);
3793 }
3794
3795 #[test]
3796 fn host_network_true_yaml_promotes_to_network_mode_host() {
3797 let yaml = r"
3798version: v1
3799deployment: bc-test
3800services:
3801 hello:
3802 rtype: service
3803 image:
3804 name: hello-world:latest
3805 host_network: true
3806";
3807 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse");
3808 let svc = dep.services.get("hello").expect("hello service");
3809 assert_eq!(svc.network_mode, NetworkMode::Host);
3810 assert!(svc.host_network);
3813 }
3814
3815 #[test]
3816 fn capabilities_yaml_alias_cap_add_round_trip() {
3817 let yaml = r"
3820version: v1
3821deployment: cap-test
3822services:
3823 hello:
3824 rtype: service
3825 image:
3826 name: hello-world:latest
3827 cap_add:
3828 - NET_ADMIN
3829 - SYS_PTRACE
3830";
3831 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse cap_add alias");
3832 let svc = dep.services.get("hello").expect("hello service");
3833 assert_eq!(
3834 svc.capabilities,
3835 vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()]
3836 );
3837 }
3838
3839 #[test]
3840 fn lifecycle_omitted_defaults_to_false() {
3841 let yaml = r"
3847version: v1
3848deployment: lifecycle-default-test
3849services:
3850 app:
3851 rtype: service
3852 image:
3853 name: hello-world:latest
3854";
3855 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec without lifecycle");
3856 let svc = dep.services.get("app").expect("app service");
3857 assert_eq!(svc.lifecycle, LifecycleSpec::default());
3858 assert!(!svc.lifecycle.delete_on_exit);
3859 }
3860
3861 #[test]
3862 fn lifecycle_delete_on_exit_round_trips() {
3863 let yaml = r"
3867version: v1
3868deployment: lifecycle-delete-test
3869services:
3870 app:
3871 rtype: service
3872 image:
3873 name: hello-world:latest
3874 lifecycle:
3875 delete_on_exit: true
3876";
3877 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec with lifecycle");
3878 let svc = dep.services.get("app").expect("app service");
3879 assert!(svc.lifecycle.delete_on_exit);
3880
3881 let dumped = serde_yaml::to_string(&dep).expect("serialize spec with lifecycle");
3884 let reparsed: DeploymentSpec =
3885 serde_yaml::from_str(&dumped).expect("reparse round-tripped spec");
3886 let reparsed_svc = reparsed.services.get("app").expect("app service after rt");
3887 assert!(reparsed_svc.lifecycle.delete_on_exit);
3888 assert_eq!(svc.lifecycle, reparsed_svc.lifecycle);
3889 }
3890}