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, Serialize, Deserialize, PartialEq, Validate)]
789#[serde(deny_unknown_fields)]
790pub struct ServiceSpec {
791 #[serde(default = "default_resource_type")]
793 pub rtype: ResourceType,
794
795 #[serde(default, skip_serializing_if = "Option::is_none")]
802 #[validate(custom(function = "crate::spec::validate::validate_schedule_wrapper"))]
803 pub schedule: Option<String>,
804
805 #[validate(nested)]
807 pub image: ImageSpec,
808
809 #[serde(default)]
811 #[validate(nested)]
812 pub resources: ResourcesSpec,
813
814 #[serde(default)]
821 pub env: HashMap<String, String>,
822
823 #[serde(default)]
825 pub command: CommandSpec,
826
827 #[serde(default)]
829 pub network: ServiceNetworkSpec,
830
831 #[serde(default)]
833 #[validate(nested)]
834 pub endpoints: Vec<EndpointSpec>,
835
836 #[serde(default)]
838 #[validate(custom(function = "crate::spec::validate::validate_scale_spec"))]
839 pub scale: ScaleSpec,
840
841 #[serde(default)]
843 pub depends: Vec<DependsSpec>,
844
845 #[serde(default = "default_health")]
847 pub health: HealthSpec,
848
849 #[serde(default)]
851 pub init: InitSpec,
852
853 #[serde(default)]
855 pub errors: ErrorsSpec,
856
857 #[serde(default)]
859 pub devices: Vec<DeviceSpec>,
860
861 #[serde(default, skip_serializing_if = "Vec::is_empty")]
863 pub storage: Vec<StorageSpec>,
864
865 #[serde(default, skip_serializing_if = "Vec::is_empty")]
870 pub port_mappings: Vec<PortMapping>,
871
872 #[serde(default)]
874 pub capabilities: Vec<String>,
875
876 #[serde(default)]
878 pub privileged: bool,
879
880 #[serde(default)]
882 pub node_mode: NodeMode,
883
884 #[serde(default, skip_serializing_if = "Option::is_none")]
886 pub node_selector: Option<NodeSelector>,
887
888 #[serde(default, skip_serializing_if = "Option::is_none")]
892 pub platform: Option<TargetPlatform>,
893
894 #[serde(default)]
896 pub service_type: ServiceType,
897
898 #[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
901 pub wasm: Option<WasmConfig>,
902
903 #[serde(default, skip_serializing_if = "Option::is_none")]
905 pub logs: Option<LogsConfig>,
906
907 #[serde(skip)]
912 pub host_network: bool,
913
914 #[serde(default, skip_serializing_if = "Option::is_none")]
920 pub hostname: Option<String>,
921
922 #[serde(default, skip_serializing_if = "Vec::is_empty")]
928 pub dns: Vec<String>,
929
930 #[serde(default, skip_serializing_if = "Vec::is_empty")]
938 pub extra_hosts: Vec<String>,
939
940 #[serde(default, skip_serializing_if = "Option::is_none")]
948 pub restart_policy: Option<ContainerRestartPolicy>,
949}
950
951#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
953#[serde(deny_unknown_fields)]
954pub struct CommandSpec {
955 #[serde(default, skip_serializing_if = "Option::is_none")]
957 pub entrypoint: Option<Vec<String>>,
958
959 #[serde(default, skip_serializing_if = "Option::is_none")]
961 pub args: Option<Vec<String>>,
962
963 #[serde(default, skip_serializing_if = "Option::is_none")]
965 pub workdir: Option<String>,
966}
967
968fn default_resource_type() -> ResourceType {
969 ResourceType::Service
970}
971
972fn default_health() -> HealthSpec {
973 HealthSpec {
974 start_grace: Some(std::time::Duration::from_secs(5)),
975 interval: None,
976 timeout: None,
977 retries: 3,
978 check: HealthCheck::Tcp { port: 0 },
979 }
980}
981
982#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
984#[serde(rename_all = "lowercase")]
985pub enum ResourceType {
986 Service,
988 Job,
990 Cron,
992}
993
994#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
996#[serde(deny_unknown_fields)]
997pub struct ImageSpec {
998 #[serde(with = "crate::image_ref_serde")]
1000 pub name: crate::ImageReference,
1001
1002 #[serde(default = "default_pull_policy")]
1004 pub pull_policy: PullPolicy,
1005}
1006
1007fn default_pull_policy() -> PullPolicy {
1008 PullPolicy::IfNotPresent
1009}
1010
1011#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1013#[serde(rename_all = "snake_case")]
1014pub enum PullPolicy {
1015 Always,
1017 Newer,
1019 IfNotPresent,
1021 Never,
1023}
1024
1025#[must_use]
1034pub fn effective_pull_policy(image: &crate::ImageReference, spec_policy: PullPolicy) -> PullPolicy {
1035 match spec_policy {
1036 PullPolicy::Always | PullPolicy::Never | PullPolicy::Newer => spec_policy,
1037 PullPolicy::IfNotPresent => {
1038 if image_is_latest_or_untagged(image) {
1040 PullPolicy::Newer
1041 } else {
1042 PullPolicy::IfNotPresent
1043 }
1044 }
1045 }
1046}
1047
1048fn image_is_latest_or_untagged(image: &crate::ImageReference) -> bool {
1049 match image.tag() {
1053 None => true,
1054 Some(tag) => tag == "latest",
1055 }
1056}
1057
1058#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1060#[serde(deny_unknown_fields)]
1061pub struct DeviceSpec {
1062 #[validate(length(min = 1, message = "device path cannot be empty"))]
1064 pub path: String,
1065
1066 #[serde(default = "default_true")]
1068 pub read: bool,
1069
1070 #[serde(default = "default_true")]
1072 pub write: bool,
1073
1074 #[serde(default)]
1076 pub mknod: bool,
1077}
1078
1079fn default_true() -> bool {
1080 true
1081}
1082
1083#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1085#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
1086pub enum StorageSpec {
1087 Bind {
1089 source: String,
1090 target: String,
1091 #[serde(default)]
1092 readonly: bool,
1093 },
1094 Named {
1096 name: String,
1097 target: String,
1098 #[serde(default)]
1099 readonly: bool,
1100 #[serde(default)]
1102 tier: StorageTier,
1103 #[serde(default, skip_serializing_if = "Option::is_none")]
1105 size: Option<String>,
1106 },
1107 Anonymous {
1109 target: String,
1110 #[serde(default)]
1112 tier: StorageTier,
1113 },
1114 Tmpfs {
1116 target: String,
1117 #[serde(default)]
1118 size: Option<String>,
1119 #[serde(default)]
1120 mode: Option<u32>,
1121 },
1122 S3 {
1124 bucket: String,
1125 #[serde(default)]
1126 prefix: Option<String>,
1127 target: String,
1128 #[serde(default)]
1129 readonly: bool,
1130 #[serde(default)]
1131 endpoint: Option<String>,
1132 #[serde(default)]
1133 credentials: Option<String>,
1134 },
1135}
1136
1137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
1139#[serde(deny_unknown_fields)]
1140pub struct ResourcesSpec {
1141 #[serde(default)]
1143 #[validate(custom(function = "crate::spec::validate::validate_cpu_option_wrapper"))]
1144 pub cpu: Option<f64>,
1145
1146 #[serde(default)]
1148 #[validate(custom(function = "crate::spec::validate::validate_memory_option_wrapper"))]
1149 pub memory: Option<String>,
1150
1151 #[serde(default, skip_serializing_if = "Option::is_none")]
1153 pub gpu: Option<GpuSpec>,
1154}
1155
1156#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1158#[serde(rename_all = "kebab-case")]
1159pub enum SchedulingPolicy {
1160 #[default]
1162 BestEffort,
1163 Gang,
1165 Spread,
1167}
1168
1169#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1171#[serde(rename_all = "kebab-case")]
1172pub enum GpuSharingMode {
1173 #[default]
1175 Exclusive,
1176 Mps,
1179 TimeSlice,
1182}
1183
1184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1191#[serde(deny_unknown_fields)]
1192pub struct DistributedConfig {
1193 #[serde(default = "default_dist_backend")]
1195 pub backend: String,
1196 #[serde(default = "default_dist_port")]
1198 pub master_port: u16,
1199}
1200
1201fn default_dist_backend() -> String {
1202 "nccl".to_string()
1203}
1204
1205fn default_dist_port() -> u16 {
1206 29500
1207}
1208
1209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1228#[serde(deny_unknown_fields)]
1229pub struct GpuSpec {
1230 #[serde(default = "default_gpu_count")]
1232 pub count: u32,
1233 #[serde(default = "default_gpu_vendor")]
1235 pub vendor: String,
1236 #[serde(default, skip_serializing_if = "Option::is_none")]
1238 pub mode: Option<String>,
1239 #[serde(default, skip_serializing_if = "Option::is_none")]
1242 pub model: Option<String>,
1243 #[serde(default, skip_serializing_if = "Option::is_none")]
1248 pub scheduling: Option<SchedulingPolicy>,
1249 #[serde(default, skip_serializing_if = "Option::is_none")]
1252 pub distributed: Option<DistributedConfig>,
1253 #[serde(default, skip_serializing_if = "Option::is_none")]
1255 pub sharing: Option<GpuSharingMode>,
1256}
1257
1258fn default_gpu_count() -> u32 {
1259 1
1260}
1261
1262fn default_gpu_vendor() -> String {
1263 "nvidia".to_string()
1264}
1265
1266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1268#[serde(deny_unknown_fields)]
1269#[derive(Default)]
1270pub struct ServiceNetworkSpec {
1271 #[serde(default)]
1273 pub overlays: OverlayConfig,
1274
1275 #[serde(default)]
1277 pub join: JoinPolicy,
1278}
1279
1280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1282#[serde(deny_unknown_fields)]
1283pub struct OverlayConfig {
1284 #[serde(default)]
1286 pub service: OverlaySettings,
1287
1288 #[serde(default)]
1290 pub global: OverlaySettings,
1291}
1292
1293impl Default for OverlayConfig {
1294 fn default() -> Self {
1295 Self {
1296 service: OverlaySettings {
1297 enabled: true,
1298 encrypted: true,
1299 isolated: true,
1300 },
1301 global: OverlaySettings {
1302 enabled: true,
1303 encrypted: true,
1304 isolated: false,
1305 },
1306 }
1307 }
1308}
1309
1310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1312#[serde(deny_unknown_fields)]
1313pub struct OverlaySettings {
1314 #[serde(default = "default_enabled")]
1316 pub enabled: bool,
1317
1318 #[serde(default = "default_encrypted")]
1320 pub encrypted: bool,
1321
1322 #[serde(default)]
1324 pub isolated: bool,
1325}
1326
1327fn default_enabled() -> bool {
1328 true
1329}
1330
1331fn default_encrypted() -> bool {
1332 true
1333}
1334
1335#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1337#[serde(deny_unknown_fields)]
1338pub struct JoinPolicy {
1339 #[serde(default = "default_join_mode")]
1341 pub mode: JoinMode,
1342
1343 #[serde(default = "default_join_scope")]
1345 pub scope: JoinScope,
1346}
1347
1348impl Default for JoinPolicy {
1349 fn default() -> Self {
1350 Self {
1351 mode: default_join_mode(),
1352 scope: default_join_scope(),
1353 }
1354 }
1355}
1356
1357fn default_join_mode() -> JoinMode {
1358 JoinMode::Token
1359}
1360
1361fn default_join_scope() -> JoinScope {
1362 JoinScope::Service
1363}
1364
1365#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1367#[serde(rename_all = "snake_case")]
1368pub enum JoinMode {
1369 Open,
1371 Token,
1373 Closed,
1375}
1376
1377#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1379#[serde(rename_all = "snake_case")]
1380pub enum JoinScope {
1381 Service,
1383 Global,
1385}
1386
1387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1389#[serde(deny_unknown_fields)]
1390pub struct EndpointSpec {
1391 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
1393 pub name: String,
1394
1395 pub protocol: Protocol,
1397
1398 #[validate(custom(function = "crate::spec::validate::validate_port_wrapper"))]
1400 pub port: u16,
1401
1402 #[serde(default, skip_serializing_if = "Option::is_none")]
1405 pub target_port: Option<u16>,
1406
1407 pub path: Option<String>,
1409
1410 #[serde(default, skip_serializing_if = "Option::is_none")]
1413 pub host: Option<String>,
1414
1415 #[serde(default = "default_expose")]
1417 pub expose: ExposeType,
1418
1419 #[serde(default, skip_serializing_if = "Option::is_none")]
1422 pub stream: Option<StreamEndpointConfig>,
1423
1424 #[serde(default, skip_serializing_if = "Option::is_none")]
1426 pub tunnel: Option<EndpointTunnelConfig>,
1427}
1428
1429impl EndpointSpec {
1430 #[must_use]
1433 pub fn target_port(&self) -> u16 {
1434 self.target_port.unwrap_or(self.port)
1435 }
1436}
1437
1438#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1440#[serde(deny_unknown_fields)]
1441pub struct EndpointTunnelConfig {
1442 #[serde(default)]
1444 pub enabled: bool,
1445
1446 #[serde(default, skip_serializing_if = "Option::is_none")]
1448 pub from: Option<String>,
1449
1450 #[serde(default, skip_serializing_if = "Option::is_none")]
1452 pub to: Option<String>,
1453
1454 #[serde(default)]
1456 pub remote_port: u16,
1457
1458 #[serde(default, skip_serializing_if = "Option::is_none")]
1460 pub expose: Option<ExposeType>,
1461
1462 #[serde(default, skip_serializing_if = "Option::is_none")]
1464 pub access: Option<TunnelAccessConfig>,
1465}
1466
1467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1469#[serde(deny_unknown_fields)]
1470pub struct TunnelAccessConfig {
1471 #[serde(default)]
1473 pub enabled: bool,
1474
1475 #[serde(default, skip_serializing_if = "Option::is_none")]
1477 pub max_ttl: Option<String>,
1478
1479 #[serde(default)]
1481 pub audit: bool,
1482}
1483
1484fn default_expose() -> ExposeType {
1485 ExposeType::Internal
1486}
1487
1488#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1490#[serde(rename_all = "lowercase")]
1491pub enum Protocol {
1492 Http,
1493 Https,
1494 Tcp,
1495 Udp,
1496 Websocket,
1497}
1498
1499#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1501#[serde(rename_all = "lowercase")]
1502pub enum ExposeType {
1503 Public,
1504 #[default]
1505 Internal,
1506}
1507
1508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1510#[serde(deny_unknown_fields)]
1511pub struct StreamEndpointConfig {
1512 #[serde(default)]
1514 pub tls: bool,
1515
1516 #[serde(default)]
1518 pub proxy_protocol: bool,
1519
1520 #[serde(default, skip_serializing_if = "Option::is_none")]
1523 pub session_timeout: Option<String>,
1524
1525 #[serde(default, skip_serializing_if = "Option::is_none")]
1527 pub health_check: Option<StreamHealthCheck>,
1528}
1529
1530#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1532#[serde(tag = "type", rename_all = "snake_case")]
1533pub enum StreamHealthCheck {
1534 TcpConnect,
1536 UdpProbe {
1538 request: String,
1540 #[serde(default, skip_serializing_if = "Option::is_none")]
1542 expect: Option<String>,
1543 },
1544}
1545
1546#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1548#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
1549pub enum ScaleSpec {
1550 #[serde(rename = "adaptive")]
1552 Adaptive {
1553 min: u32,
1555
1556 max: u32,
1558
1559 #[serde(default, with = "duration::option")]
1561 cooldown: Option<std::time::Duration>,
1562
1563 #[serde(default)]
1565 targets: ScaleTargets,
1566 },
1567
1568 #[serde(rename = "fixed")]
1570 Fixed { replicas: u32 },
1571
1572 #[serde(rename = "manual")]
1574 Manual,
1575}
1576
1577impl Default for ScaleSpec {
1578 fn default() -> Self {
1579 Self::Adaptive {
1580 min: 1,
1581 max: 10,
1582 cooldown: Some(std::time::Duration::from_secs(30)),
1583 targets: ScaleTargets::default(),
1584 }
1585 }
1586}
1587
1588#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1590#[serde(deny_unknown_fields)]
1591#[derive(Default)]
1592pub struct ScaleTargets {
1593 #[serde(default)]
1595 pub cpu: Option<u8>,
1596
1597 #[serde(default)]
1599 pub memory: Option<u8>,
1600
1601 #[serde(default)]
1603 pub rps: Option<u32>,
1604}
1605
1606#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1608#[serde(deny_unknown_fields)]
1609pub struct DependsSpec {
1610 pub service: String,
1612
1613 #[serde(default = "default_condition")]
1615 pub condition: DependencyCondition,
1616
1617 #[serde(default = "default_timeout", with = "duration::option")]
1619 pub timeout: Option<std::time::Duration>,
1620
1621 #[serde(default = "default_on_timeout")]
1623 pub on_timeout: TimeoutAction,
1624}
1625
1626fn default_condition() -> DependencyCondition {
1627 DependencyCondition::Healthy
1628}
1629
1630#[allow(clippy::unnecessary_wraps)]
1631fn default_timeout() -> Option<std::time::Duration> {
1632 Some(std::time::Duration::from_secs(300))
1633}
1634
1635fn default_on_timeout() -> TimeoutAction {
1636 TimeoutAction::Fail
1637}
1638
1639#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1641#[serde(rename_all = "lowercase")]
1642pub enum DependencyCondition {
1643 Started,
1645 Healthy,
1647 Ready,
1649}
1650
1651#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1653#[serde(rename_all = "lowercase")]
1654pub enum TimeoutAction {
1655 Fail,
1656 Warn,
1657 Continue,
1658}
1659
1660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1662#[serde(deny_unknown_fields)]
1663pub struct HealthSpec {
1664 #[serde(default, with = "duration::option")]
1666 pub start_grace: Option<std::time::Duration>,
1667
1668 #[serde(default, with = "duration::option")]
1670 pub interval: Option<std::time::Duration>,
1671
1672 #[serde(default, with = "duration::option")]
1674 pub timeout: Option<std::time::Duration>,
1675
1676 #[serde(default = "default_retries")]
1678 pub retries: u32,
1679
1680 pub check: HealthCheck,
1682}
1683
1684fn default_retries() -> u32 {
1685 3
1686}
1687
1688#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1690#[serde(tag = "type", rename_all = "lowercase")]
1691pub enum HealthCheck {
1692 Tcp {
1694 port: u16,
1696 },
1697
1698 Http {
1700 url: String,
1702 #[serde(default = "default_expect_status")]
1704 expect_status: u16,
1705 },
1706
1707 Command {
1709 command: String,
1711 },
1712}
1713
1714fn default_expect_status() -> u16 {
1715 200
1716}
1717
1718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1720#[serde(deny_unknown_fields)]
1721#[derive(Default)]
1722pub struct InitSpec {
1723 #[serde(default)]
1725 pub steps: Vec<InitStep>,
1726}
1727
1728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1730#[serde(deny_unknown_fields)]
1731pub struct InitStep {
1732 pub id: String,
1734
1735 pub uses: String,
1737
1738 #[serde(default)]
1740 pub with: InitParams,
1741
1742 #[serde(default)]
1744 pub retry: Option<u32>,
1745
1746 #[serde(default, with = "duration::option")]
1748 pub timeout: Option<std::time::Duration>,
1749
1750 #[serde(default = "default_on_failure")]
1752 pub on_failure: FailureAction,
1753}
1754
1755fn default_on_failure() -> FailureAction {
1756 FailureAction::Fail
1757}
1758
1759pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
1761
1762#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1764#[serde(rename_all = "lowercase")]
1765pub enum FailureAction {
1766 Fail,
1767 Warn,
1768 Continue,
1769}
1770
1771#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1773#[serde(deny_unknown_fields)]
1774#[derive(Default)]
1775pub struct ErrorsSpec {
1776 #[serde(default)]
1778 pub on_init_failure: InitFailurePolicy,
1779
1780 #[serde(default)]
1782 pub on_panic: PanicPolicy,
1783}
1784
1785#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1787#[serde(deny_unknown_fields)]
1788pub struct InitFailurePolicy {
1789 #[serde(default = "default_init_action")]
1790 pub action: InitFailureAction,
1791}
1792
1793impl Default for InitFailurePolicy {
1794 fn default() -> Self {
1795 Self {
1796 action: default_init_action(),
1797 }
1798 }
1799}
1800
1801fn default_init_action() -> InitFailureAction {
1802 InitFailureAction::Fail
1803}
1804
1805#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1807#[serde(rename_all = "lowercase")]
1808pub enum InitFailureAction {
1809 Fail,
1810 Restart,
1811 Backoff,
1812}
1813
1814#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1816#[serde(deny_unknown_fields)]
1817pub struct PanicPolicy {
1818 #[serde(default = "default_panic_action")]
1819 pub action: PanicAction,
1820}
1821
1822impl Default for PanicPolicy {
1823 fn default() -> Self {
1824 Self {
1825 action: default_panic_action(),
1826 }
1827 }
1828}
1829
1830fn default_panic_action() -> PanicAction {
1831 PanicAction::Restart
1832}
1833
1834#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1836#[serde(rename_all = "lowercase")]
1837pub enum PanicAction {
1838 Restart,
1839 Shutdown,
1840 Isolate,
1841}
1842
1843#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1850pub struct NetworkPolicySpec {
1851 pub name: String,
1853
1854 #[serde(default, skip_serializing_if = "Option::is_none")]
1856 pub description: Option<String>,
1857
1858 #[serde(default)]
1860 pub cidrs: Vec<String>,
1861
1862 #[serde(default)]
1864 pub members: Vec<NetworkMember>,
1865
1866 #[serde(default)]
1868 pub access_rules: Vec<AccessRule>,
1869}
1870
1871#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1873pub struct NetworkMember {
1874 pub name: String,
1876 #[serde(default)]
1878 pub kind: MemberKind,
1879}
1880
1881#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1883#[serde(rename_all = "lowercase")]
1884pub enum MemberKind {
1885 #[default]
1887 User,
1888 Group,
1890 Node,
1892 Cidr,
1894}
1895
1896#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1898pub struct AccessRule {
1899 #[serde(default = "wildcard")]
1901 pub service: String,
1902
1903 #[serde(default = "wildcard")]
1905 pub deployment: String,
1906
1907 #[serde(default, skip_serializing_if = "Option::is_none")]
1909 pub ports: Option<Vec<u16>>,
1910
1911 #[serde(default)]
1913 pub action: AccessAction,
1914}
1915
1916fn wildcard() -> String {
1917 "*".to_string()
1918}
1919
1920#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1922#[serde(rename_all = "lowercase")]
1923pub enum AccessAction {
1924 #[default]
1926 Allow,
1927 Deny,
1929}
1930
1931#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1943pub struct BridgeNetwork {
1944 pub id: String,
1946
1947 pub name: String,
1949
1950 #[serde(default)]
1952 pub driver: BridgeNetworkDriver,
1953
1954 #[serde(default, skip_serializing_if = "Option::is_none")]
1956 pub subnet: Option<String>,
1957
1958 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1960 pub labels: HashMap<String, String>,
1961
1962 #[serde(default)]
1965 pub internal: bool,
1966
1967 #[schema(value_type = String, format = "date-time")]
1969 pub created_at: chrono::DateTime<chrono::Utc>,
1970}
1971
1972#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
1974#[serde(rename_all = "lowercase")]
1975pub enum BridgeNetworkDriver {
1976 #[default]
1978 Bridge,
1979 Overlay,
1981}
1982
1983#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1985pub struct BridgeNetworkAttachment {
1986 pub container_id: String,
1988
1989 #[serde(default, skip_serializing_if = "Option::is_none")]
1991 pub container_name: Option<String>,
1992
1993 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1995 pub aliases: Vec<String>,
1996
1997 #[serde(default, skip_serializing_if = "Option::is_none")]
1999 pub ipv4: Option<String>,
2000}
2001
2002#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2023pub struct RegistryAuth {
2024 pub username: String,
2027 pub password: String,
2030 #[serde(default = "default_registry_auth_type")]
2032 pub auth_type: RegistryAuthType,
2033}
2034
2035#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2037#[serde(rename_all = "snake_case")]
2038pub enum RegistryAuthType {
2039 #[default]
2041 Basic,
2042 Token,
2045}
2046
2047#[must_use]
2050pub fn default_registry_auth_type() -> RegistryAuthType {
2051 RegistryAuthType::Basic
2052}
2053
2054#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2069#[serde(rename_all = "snake_case", deny_unknown_fields)]
2070pub struct ContainerRestartPolicy {
2071 pub kind: ContainerRestartKind,
2073
2074 #[serde(default, skip_serializing_if = "Option::is_none")]
2077 pub max_attempts: Option<u32>,
2078
2079 #[serde(default, skip_serializing_if = "Option::is_none")]
2084 pub delay: Option<String>,
2085}
2086
2087#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2089#[serde(rename_all = "snake_case")]
2090pub enum ContainerRestartKind {
2091 No,
2093 Always,
2095 UnlessStopped,
2098 OnFailure,
2101}
2102
2103#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2109#[serde(rename_all = "snake_case")]
2110pub enum PortProtocol {
2111 Tcp,
2113 Udp,
2115}
2116
2117impl Default for PortProtocol {
2118 fn default() -> Self {
2119 default_port_protocol()
2120 }
2121}
2122
2123impl PortProtocol {
2124 #[must_use]
2127 pub fn as_str(&self) -> &'static str {
2128 match self {
2129 PortProtocol::Tcp => "tcp",
2130 PortProtocol::Udp => "udp",
2131 }
2132 }
2133}
2134
2135fn default_port_protocol() -> PortProtocol {
2136 PortProtocol::Tcp
2137}
2138
2139fn default_host_ip() -> String {
2140 "0.0.0.0".to_string()
2141}
2142
2143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2149#[serde(rename_all = "snake_case")]
2150pub struct PortMapping {
2151 #[serde(default, skip_serializing_if = "Option::is_none")]
2153 pub host_port: Option<u16>,
2154 pub container_port: u16,
2156 #[serde(default = "default_port_protocol")]
2158 pub protocol: PortProtocol,
2159 #[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
2161 pub host_ip: String,
2162}
2163
2164#[cfg(test)]
2165mod tests {
2166 use super::*;
2167
2168 #[test]
2169 fn port_mapping_defaults_via_serde() {
2170 let json = r#"{"container_port": 8080}"#;
2173 let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
2174 assert_eq!(m.container_port, 8080);
2175 assert_eq!(m.host_port, None);
2176 assert_eq!(m.protocol, PortProtocol::Tcp);
2177 assert_eq!(m.host_ip, "0.0.0.0");
2178 }
2179
2180 #[test]
2181 fn port_mapping_skips_none_host_port_and_empty_host_ip() {
2182 let m = PortMapping {
2183 host_port: None,
2184 container_port: 443,
2185 protocol: PortProtocol::Tcp,
2186 host_ip: String::new(),
2187 };
2188 let s = serde_json::to_string(&m).expect("serialize");
2189 assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
2191 assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
2192 assert!(s.contains("\"container_port\":443"));
2193 assert!(s.contains("\"protocol\":\"tcp\""));
2194 }
2195
2196 #[test]
2197 fn test_parse_simple_spec() {
2198 let yaml = r"
2199version: v1
2200deployment: test
2201services:
2202 hello:
2203 rtype: service
2204 image:
2205 name: hello-world:latest
2206 endpoints:
2207 - name: http
2208 protocol: http
2209 port: 8080
2210 expose: public
2211";
2212
2213 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2214 assert_eq!(spec.version, "v1");
2215 assert_eq!(spec.deployment, "test");
2216 assert!(spec.services.contains_key("hello"));
2217 }
2218
2219 #[test]
2220 fn test_parse_duration() {
2221 let yaml = r"
2222version: v1
2223deployment: test
2224services:
2225 test:
2226 rtype: service
2227 image:
2228 name: test:latest
2229 health:
2230 timeout: 30s
2231 interval: 1m
2232 start_grace: 5s
2233 check:
2234 type: tcp
2235 port: 8080
2236";
2237
2238 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2239 let health = &spec.services["test"].health;
2240 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
2241 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
2242 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
2243 match &health.check {
2244 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
2245 _ => panic!("Expected TCP health check"),
2246 }
2247 }
2248
2249 #[test]
2250 fn test_parse_adaptive_scale() {
2251 let yaml = r"
2252version: v1
2253deployment: test
2254services:
2255 test:
2256 rtype: service
2257 image:
2258 name: test:latest
2259 scale:
2260 mode: adaptive
2261 min: 2
2262 max: 10
2263 cooldown: 15s
2264 targets:
2265 cpu: 70
2266 rps: 800
2267";
2268
2269 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2270 let scale = &spec.services["test"].scale;
2271 match scale {
2272 ScaleSpec::Adaptive {
2273 min,
2274 max,
2275 cooldown,
2276 targets,
2277 } => {
2278 assert_eq!(*min, 2);
2279 assert_eq!(*max, 10);
2280 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
2281 assert_eq!(targets.cpu, Some(70));
2282 assert_eq!(targets.rps, Some(800));
2283 }
2284 _ => panic!("Expected Adaptive scale mode"),
2285 }
2286 }
2287
2288 #[test]
2289 fn test_node_mode_default() {
2290 let yaml = r"
2291version: v1
2292deployment: test
2293services:
2294 hello:
2295 rtype: service
2296 image:
2297 name: hello-world:latest
2298";
2299
2300 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2301 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
2302 assert!(spec.services["hello"].node_selector.is_none());
2303 }
2304
2305 #[test]
2306 fn test_node_mode_dedicated() {
2307 let yaml = r"
2308version: v1
2309deployment: test
2310services:
2311 api:
2312 rtype: service
2313 image:
2314 name: api:latest
2315 node_mode: dedicated
2316";
2317
2318 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2319 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2320 }
2321
2322 #[test]
2323 fn test_node_mode_exclusive() {
2324 let yaml = r"
2325version: v1
2326deployment: test
2327services:
2328 database:
2329 rtype: service
2330 image:
2331 name: postgres:15
2332 node_mode: exclusive
2333";
2334
2335 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2336 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2337 }
2338
2339 #[test]
2340 fn test_node_selector_with_labels() {
2341 let yaml = r#"
2342version: v1
2343deployment: test
2344services:
2345 ml-worker:
2346 rtype: service
2347 image:
2348 name: ml-worker:latest
2349 node_mode: dedicated
2350 node_selector:
2351 labels:
2352 gpu: "true"
2353 zone: us-east
2354 prefer_labels:
2355 storage: ssd
2356"#;
2357
2358 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2359 let service = &spec.services["ml-worker"];
2360 assert_eq!(service.node_mode, NodeMode::Dedicated);
2361
2362 let selector = service.node_selector.as_ref().unwrap();
2363 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
2364 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
2365 assert_eq!(
2366 selector.prefer_labels.get("storage"),
2367 Some(&"ssd".to_string())
2368 );
2369 }
2370
2371 #[test]
2372 fn test_node_mode_serialization_roundtrip() {
2373 use serde_json;
2374
2375 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
2377 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
2378
2379 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
2380 let json = serde_json::to_string(mode).unwrap();
2381 assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
2382
2383 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
2384 assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
2385 }
2386 }
2387
2388 #[test]
2389 fn test_node_selector_empty() {
2390 let yaml = r"
2391version: v1
2392deployment: test
2393services:
2394 api:
2395 rtype: service
2396 image:
2397 name: api:latest
2398 node_selector:
2399 labels: {}
2400";
2401
2402 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2403 let selector = spec.services["api"].node_selector.as_ref().unwrap();
2404 assert!(selector.labels.is_empty());
2405 assert!(selector.prefer_labels.is_empty());
2406 }
2407
2408 #[test]
2409 fn test_mixed_node_modes_in_deployment() {
2410 let yaml = r"
2411version: v1
2412deployment: test
2413services:
2414 redis:
2415 rtype: service
2416 image:
2417 name: redis:alpine
2418 # Default shared mode
2419 api:
2420 rtype: service
2421 image:
2422 name: api:latest
2423 node_mode: dedicated
2424 database:
2425 rtype: service
2426 image:
2427 name: postgres:15
2428 node_mode: exclusive
2429 node_selector:
2430 labels:
2431 storage: ssd
2432";
2433
2434 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2435 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
2436 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2437 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2438
2439 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
2440 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
2441 }
2442
2443 #[test]
2444 fn test_storage_bind_mount() {
2445 let yaml = r"
2446version: v1
2447deployment: test
2448services:
2449 app:
2450 image:
2451 name: app:latest
2452 storage:
2453 - type: bind
2454 source: /host/data
2455 target: /app/data
2456 readonly: true
2457";
2458 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2459 let storage = &spec.services["app"].storage;
2460 assert_eq!(storage.len(), 1);
2461 match &storage[0] {
2462 StorageSpec::Bind {
2463 source,
2464 target,
2465 readonly,
2466 } => {
2467 assert_eq!(source, "/host/data");
2468 assert_eq!(target, "/app/data");
2469 assert!(*readonly);
2470 }
2471 _ => panic!("Expected Bind storage"),
2472 }
2473 }
2474
2475 #[test]
2476 fn test_storage_named_with_tier() {
2477 let yaml = r"
2478version: v1
2479deployment: test
2480services:
2481 app:
2482 image:
2483 name: app:latest
2484 storage:
2485 - type: named
2486 name: my-data
2487 target: /app/data
2488 tier: cached
2489";
2490 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2491 let storage = &spec.services["app"].storage;
2492 match &storage[0] {
2493 StorageSpec::Named {
2494 name, target, tier, ..
2495 } => {
2496 assert_eq!(name, "my-data");
2497 assert_eq!(target, "/app/data");
2498 assert_eq!(*tier, StorageTier::Cached);
2499 }
2500 _ => panic!("Expected Named storage"),
2501 }
2502 }
2503
2504 #[test]
2505 fn test_storage_anonymous() {
2506 let yaml = r"
2507version: v1
2508deployment: test
2509services:
2510 app:
2511 image:
2512 name: app:latest
2513 storage:
2514 - type: anonymous
2515 target: /app/cache
2516";
2517 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2518 let storage = &spec.services["app"].storage;
2519 match &storage[0] {
2520 StorageSpec::Anonymous { target, tier } => {
2521 assert_eq!(target, "/app/cache");
2522 assert_eq!(*tier, StorageTier::Local); }
2524 _ => panic!("Expected Anonymous storage"),
2525 }
2526 }
2527
2528 #[test]
2529 fn test_storage_tmpfs() {
2530 let yaml = r"
2531version: v1
2532deployment: test
2533services:
2534 app:
2535 image:
2536 name: app:latest
2537 storage:
2538 - type: tmpfs
2539 target: /app/tmp
2540 size: 256Mi
2541 mode: 1777
2542";
2543 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2544 let storage = &spec.services["app"].storage;
2545 match &storage[0] {
2546 StorageSpec::Tmpfs { target, size, mode } => {
2547 assert_eq!(target, "/app/tmp");
2548 assert_eq!(size.as_deref(), Some("256Mi"));
2549 assert_eq!(*mode, Some(1777));
2550 }
2551 _ => panic!("Expected Tmpfs storage"),
2552 }
2553 }
2554
2555 #[test]
2556 fn test_storage_s3() {
2557 let yaml = r"
2558version: v1
2559deployment: test
2560services:
2561 app:
2562 image:
2563 name: app:latest
2564 storage:
2565 - type: s3
2566 bucket: my-bucket
2567 prefix: models/
2568 target: /app/models
2569 readonly: true
2570 endpoint: https://s3.us-west-2.amazonaws.com
2571 credentials: aws-creds
2572";
2573 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2574 let storage = &spec.services["app"].storage;
2575 match &storage[0] {
2576 StorageSpec::S3 {
2577 bucket,
2578 prefix,
2579 target,
2580 readonly,
2581 endpoint,
2582 credentials,
2583 } => {
2584 assert_eq!(bucket, "my-bucket");
2585 assert_eq!(prefix.as_deref(), Some("models/"));
2586 assert_eq!(target, "/app/models");
2587 assert!(*readonly);
2588 assert_eq!(
2589 endpoint.as_deref(),
2590 Some("https://s3.us-west-2.amazonaws.com")
2591 );
2592 assert_eq!(credentials.as_deref(), Some("aws-creds"));
2593 }
2594 _ => panic!("Expected S3 storage"),
2595 }
2596 }
2597
2598 #[test]
2599 fn test_storage_multiple_types() {
2600 let yaml = r"
2601version: v1
2602deployment: test
2603services:
2604 app:
2605 image:
2606 name: app:latest
2607 storage:
2608 - type: bind
2609 source: /etc/config
2610 target: /app/config
2611 readonly: true
2612 - type: named
2613 name: app-data
2614 target: /app/data
2615 - type: tmpfs
2616 target: /app/tmp
2617";
2618 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2619 let storage = &spec.services["app"].storage;
2620 assert_eq!(storage.len(), 3);
2621 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
2622 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
2623 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
2624 }
2625
2626 #[test]
2627 fn test_storage_tier_default() {
2628 let yaml = r"
2629version: v1
2630deployment: test
2631services:
2632 app:
2633 image:
2634 name: app:latest
2635 storage:
2636 - type: named
2637 name: data
2638 target: /data
2639";
2640 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2641 match &spec.services["app"].storage[0] {
2642 StorageSpec::Named { tier, .. } => {
2643 assert_eq!(*tier, StorageTier::Local); }
2645 _ => panic!("Expected Named storage"),
2646 }
2647 }
2648
2649 #[test]
2654 fn test_endpoint_tunnel_config_basic() {
2655 let yaml = r"
2656version: v1
2657deployment: test
2658services:
2659 api:
2660 image:
2661 name: api:latest
2662 endpoints:
2663 - name: http
2664 protocol: http
2665 port: 8080
2666 tunnel:
2667 enabled: true
2668 remote_port: 8080
2669";
2670 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2671 let endpoint = &spec.services["api"].endpoints[0];
2672 let tunnel = endpoint.tunnel.as_ref().unwrap();
2673 assert!(tunnel.enabled);
2674 assert_eq!(tunnel.remote_port, 8080);
2675 assert!(tunnel.from.is_none());
2676 assert!(tunnel.to.is_none());
2677 }
2678
2679 #[test]
2680 fn test_endpoint_tunnel_config_full() {
2681 let yaml = r"
2682version: v1
2683deployment: test
2684services:
2685 api:
2686 image:
2687 name: api:latest
2688 endpoints:
2689 - name: http
2690 protocol: http
2691 port: 8080
2692 tunnel:
2693 enabled: true
2694 from: node-1
2695 to: ingress-node
2696 remote_port: 9000
2697 expose: public
2698 access:
2699 enabled: true
2700 max_ttl: 4h
2701 audit: true
2702";
2703 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2704 let endpoint = &spec.services["api"].endpoints[0];
2705 let tunnel = endpoint.tunnel.as_ref().unwrap();
2706 assert!(tunnel.enabled);
2707 assert_eq!(tunnel.from, Some("node-1".to_string()));
2708 assert_eq!(tunnel.to, Some("ingress-node".to_string()));
2709 assert_eq!(tunnel.remote_port, 9000);
2710 assert_eq!(tunnel.expose, Some(ExposeType::Public));
2711
2712 let access = tunnel.access.as_ref().unwrap();
2713 assert!(access.enabled);
2714 assert_eq!(access.max_ttl, Some("4h".to_string()));
2715 assert!(access.audit);
2716 }
2717
2718 #[test]
2719 fn test_top_level_tunnel_definition() {
2720 let yaml = r"
2721version: v1
2722deployment: test
2723services: {}
2724tunnels:
2725 db-tunnel:
2726 from: app-node
2727 to: db-node
2728 local_port: 5432
2729 remote_port: 5432
2730 protocol: tcp
2731 expose: internal
2732";
2733 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2734 let tunnel = spec.tunnels.get("db-tunnel").unwrap();
2735 assert_eq!(tunnel.from, "app-node");
2736 assert_eq!(tunnel.to, "db-node");
2737 assert_eq!(tunnel.local_port, 5432);
2738 assert_eq!(tunnel.remote_port, 5432);
2739 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
2740 assert_eq!(tunnel.expose, ExposeType::Internal);
2741 }
2742
2743 #[test]
2744 fn test_top_level_tunnel_defaults() {
2745 let yaml = r"
2746version: v1
2747deployment: test
2748services: {}
2749tunnels:
2750 simple-tunnel:
2751 from: node-a
2752 to: node-b
2753 local_port: 3000
2754 remote_port: 3000
2755";
2756 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2757 let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
2758 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
2761
2762 #[test]
2763 fn test_tunnel_protocol_udp() {
2764 let yaml = r"
2765version: v1
2766deployment: test
2767services: {}
2768tunnels:
2769 udp-tunnel:
2770 from: node-a
2771 to: node-b
2772 local_port: 5353
2773 remote_port: 5353
2774 protocol: udp
2775";
2776 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2777 let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
2778 assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
2779 }
2780
2781 #[test]
2782 fn test_endpoint_without_tunnel() {
2783 let yaml = r"
2784version: v1
2785deployment: test
2786services:
2787 api:
2788 image:
2789 name: api:latest
2790 endpoints:
2791 - name: http
2792 protocol: http
2793 port: 8080
2794";
2795 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2796 let endpoint = &spec.services["api"].endpoints[0];
2797 assert!(endpoint.tunnel.is_none());
2798 }
2799
2800 #[test]
2801 fn test_deployment_without_tunnels() {
2802 let yaml = r"
2803version: v1
2804deployment: test
2805services:
2806 api:
2807 image:
2808 name: api:latest
2809";
2810 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2811 assert!(spec.tunnels.is_empty());
2812 }
2813
2814 #[test]
2819 fn test_spec_without_api_block_uses_defaults() {
2820 let yaml = r"
2821version: v1
2822deployment: test
2823services:
2824 hello:
2825 image:
2826 name: hello-world:latest
2827";
2828 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2829 assert!(spec.api.enabled);
2830 assert_eq!(spec.api.bind, "0.0.0.0:3669");
2831 assert!(spec.api.jwt_secret.is_none());
2832 assert!(spec.api.swagger);
2833 }
2834
2835 #[test]
2836 fn test_spec_with_explicit_api_block() {
2837 let yaml = r#"
2838version: v1
2839deployment: test
2840services:
2841 hello:
2842 image:
2843 name: hello-world:latest
2844api:
2845 enabled: false
2846 bind: "127.0.0.1:9090"
2847 jwt_secret: "my-secret"
2848 swagger: false
2849"#;
2850 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2851 assert!(!spec.api.enabled);
2852 assert_eq!(spec.api.bind, "127.0.0.1:9090");
2853 assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
2854 assert!(!spec.api.swagger);
2855 }
2856
2857 #[test]
2858 fn test_spec_with_partial_api_block() {
2859 let yaml = r#"
2860version: v1
2861deployment: test
2862services:
2863 hello:
2864 image:
2865 name: hello-world:latest
2866api:
2867 bind: "0.0.0.0:3000"
2868"#;
2869 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2870 assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
2872 assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
2875
2876 #[test]
2881 fn test_network_policy_spec_roundtrip() {
2882 let spec = NetworkPolicySpec {
2883 name: "corp-vpn".to_string(),
2884 description: Some("Corporate VPN network".to_string()),
2885 cidrs: vec!["10.200.0.0/16".to_string()],
2886 members: vec![
2887 NetworkMember {
2888 name: "alice".to_string(),
2889 kind: MemberKind::User,
2890 },
2891 NetworkMember {
2892 name: "ops-team".to_string(),
2893 kind: MemberKind::Group,
2894 },
2895 NetworkMember {
2896 name: "node-01".to_string(),
2897 kind: MemberKind::Node,
2898 },
2899 ],
2900 access_rules: vec![
2901 AccessRule {
2902 service: "api-gateway".to_string(),
2903 deployment: "*".to_string(),
2904 ports: Some(vec![443, 8080]),
2905 action: AccessAction::Allow,
2906 },
2907 AccessRule {
2908 service: "*".to_string(),
2909 deployment: "staging".to_string(),
2910 ports: None,
2911 action: AccessAction::Deny,
2912 },
2913 ],
2914 };
2915
2916 let yaml = serde_yaml::to_string(&spec).unwrap();
2917 let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
2918 assert_eq!(spec, deserialized);
2919 }
2920
2921 #[test]
2922 fn test_network_policy_spec_defaults() {
2923 let yaml = r"
2924name: minimal
2925";
2926 let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
2927 assert_eq!(spec.name, "minimal");
2928 assert!(spec.description.is_none());
2929 assert!(spec.cidrs.is_empty());
2930 assert!(spec.members.is_empty());
2931 assert!(spec.access_rules.is_empty());
2932 }
2933
2934 #[test]
2935 fn test_access_rule_defaults() {
2936 let yaml = "{}";
2937 let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
2938 assert_eq!(rule.service, "*");
2939 assert_eq!(rule.deployment, "*");
2940 assert!(rule.ports.is_none());
2941 assert_eq!(rule.action, AccessAction::Allow);
2942 }
2943
2944 #[test]
2945 fn test_member_kind_defaults_to_user() {
2946 let yaml = r"
2947name: bob
2948";
2949 let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
2950 assert_eq!(member.name, "bob");
2951 assert_eq!(member.kind, MemberKind::User);
2952 }
2953
2954 #[test]
2955 fn test_member_kind_variants() {
2956 for (input, expected) in [
2957 ("user", MemberKind::User),
2958 ("group", MemberKind::Group),
2959 ("node", MemberKind::Node),
2960 ("cidr", MemberKind::Cidr),
2961 ] {
2962 let yaml = format!("name: test\nkind: {input}");
2963 let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
2964 assert_eq!(member.kind, expected);
2965 }
2966 }
2967
2968 #[test]
2969 fn test_access_action_variants() {
2970 #[derive(Debug, Deserialize)]
2972 struct Wrapper {
2973 action: AccessAction,
2974 }
2975
2976 let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
2977 let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
2978
2979 assert_eq!(allow.action, AccessAction::Allow);
2980 assert_eq!(deny.action, AccessAction::Deny);
2981 }
2982
2983 #[test]
2984 fn test_network_policy_spec_default_impl() {
2985 let spec = NetworkPolicySpec::default();
2986 assert_eq!(spec.name, "");
2987 assert!(spec.description.is_none());
2988 assert!(spec.cidrs.is_empty());
2989 assert!(spec.members.is_empty());
2990 assert!(spec.access_rules.is_empty());
2991 }
2992
2993 #[test]
2994 fn container_restart_policy_serde_roundtrip_all_kinds() {
2995 let cases = [
3000 (
3001 ContainerRestartPolicy {
3002 kind: ContainerRestartKind::No,
3003 max_attempts: None,
3004 delay: None,
3005 },
3006 r#"{"kind":"no"}"#,
3007 ),
3008 (
3009 ContainerRestartPolicy {
3010 kind: ContainerRestartKind::Always,
3011 max_attempts: None,
3012 delay: Some("500ms".to_string()),
3013 },
3014 r#"{"kind":"always","delay":"500ms"}"#,
3015 ),
3016 (
3017 ContainerRestartPolicy {
3018 kind: ContainerRestartKind::UnlessStopped,
3019 max_attempts: None,
3020 delay: None,
3021 },
3022 r#"{"kind":"unless_stopped"}"#,
3023 ),
3024 (
3025 ContainerRestartPolicy {
3026 kind: ContainerRestartKind::OnFailure,
3027 max_attempts: Some(5),
3028 delay: None,
3029 },
3030 r#"{"kind":"on_failure","max_attempts":5}"#,
3031 ),
3032 ];
3033
3034 for (value, expected_json) in &cases {
3035 let serialized = serde_json::to_string(value).expect("serialize");
3036 assert_eq!(&serialized, expected_json, "serialize mismatch");
3037 let round: ContainerRestartPolicy =
3038 serde_json::from_str(&serialized).expect("deserialize");
3039 assert_eq!(&round, value, "roundtrip mismatch");
3040 }
3041 }
3042
3043 #[test]
3046 fn registry_auth_type_serializes_snake_case() {
3047 assert_eq!(
3048 serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
3049 "\"basic\""
3050 );
3051 assert_eq!(
3052 serde_json::to_string(&RegistryAuthType::Token).unwrap(),
3053 "\"token\""
3054 );
3055 }
3056
3057 #[test]
3058 fn registry_auth_default_auth_type_is_basic() {
3059 let json = r#"{"username":"u","password":"p"}"#;
3061 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3062 assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
3063 assert_eq!(parsed.username, "u");
3064 assert_eq!(parsed.password, "p");
3065 }
3066
3067 #[test]
3068 fn registry_auth_serde_roundtrip_both_variants() {
3069 for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
3070 let cred = RegistryAuth {
3071 username: "ci-bot".to_string(),
3072 password: "s3cret".to_string(),
3073 auth_type: variant,
3074 };
3075 let serialized = serde_json::to_string(&cred).expect("serialize");
3076 let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
3077 assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
3078 }
3079 }
3080
3081 #[test]
3082 fn registry_auth_explicit_token_type_parses() {
3083 let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
3084 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3085 assert_eq!(parsed.auth_type, RegistryAuthType::Token);
3086 }
3087
3088 #[test]
3089 fn target_platform_as_oci_str() {
3090 assert_eq!(
3091 TargetPlatform::new(OsKind::Linux, ArchKind::Amd64).as_oci_str(),
3092 "linux/amd64"
3093 );
3094 assert_eq!(
3095 TargetPlatform::new(OsKind::Windows, ArchKind::Arm64).as_oci_str(),
3096 "windows/arm64"
3097 );
3098 assert_eq!(
3099 TargetPlatform::new(OsKind::Macos, ArchKind::Arm64).as_oci_str(),
3100 "darwin/arm64"
3101 );
3102 }
3103
3104 #[test]
3105 fn os_kind_from_rust_consts() {
3106 assert_eq!(OsKind::from_rust_os("linux"), Some(OsKind::Linux));
3107 assert_eq!(OsKind::from_rust_os("windows"), Some(OsKind::Windows));
3108 assert_eq!(OsKind::from_rust_os("macos"), Some(OsKind::Macos));
3109 assert_eq!(OsKind::from_rust_os("freebsd"), None);
3110 }
3111
3112 #[test]
3113 fn arch_kind_from_rust_consts() {
3114 assert_eq!(ArchKind::from_rust_arch("x86_64"), Some(ArchKind::Amd64));
3115 assert_eq!(ArchKind::from_rust_arch("aarch64"), Some(ArchKind::Arm64));
3116 assert_eq!(ArchKind::from_rust_arch("riscv64"), None);
3117 }
3118
3119 #[test]
3120 fn service_spec_platform_yaml_round_trip_none() {
3121 let yaml = r"
3124version: v1
3125deployment: test
3126services:
3127 app:
3128 rtype: service
3129 image:
3130 name: nginx:latest
3131";
3132 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3133 assert!(spec.services["app"].platform.is_none());
3134 }
3135
3136 #[test]
3137 fn service_spec_platform_yaml_round_trip_some() {
3138 let yaml = r"
3139version: v1
3140deployment: test
3141services:
3142 app:
3143 rtype: service
3144 image:
3145 name: nginx:latest
3146 platform:
3147 os: windows
3148 arch: amd64
3149";
3150 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3151 assert_eq!(
3152 spec.services["app"].platform,
3153 Some(TargetPlatform::new(OsKind::Windows, ArchKind::Amd64))
3154 );
3155 }
3156
3157 #[test]
3158 fn service_spec_platform_serializes_omitted_when_none() {
3159 let yaml = r"
3162version: v1
3163deployment: test
3164services:
3165 app:
3166 rtype: service
3167 image:
3168 name: nginx:latest
3169";
3170 let mut spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3171 let service = spec.services.get_mut("app").expect("service present");
3172 service.platform = None;
3173 let rendered = serde_yaml::to_string(service).expect("render");
3174 assert!(
3175 !rendered.contains("platform"),
3176 "platform must be omitted when None: {rendered}"
3177 );
3178 }
3179
3180 #[test]
3181 fn target_platform_os_version_builder() {
3182 let p =
3183 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3184 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3185 assert_eq!(p.os, OsKind::Windows);
3186 assert_eq!(p.arch, ArchKind::Amd64);
3187 }
3188
3189 #[test]
3190 fn target_platform_os_version_yaml_roundtrip() {
3191 let yaml = "os: windows\narch: amd64\nosVersion: 10.0.26100.1\n";
3192 let p: TargetPlatform = serde_yaml::from_str(yaml).expect("yaml parse");
3193 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3194 assert_eq!(p.os, OsKind::Windows);
3195 assert_eq!(p.arch, ArchKind::Amd64);
3196 }
3197
3198 #[test]
3199 fn target_platform_os_version_yaml_omits_when_none() {
3200 let p = TargetPlatform::new(OsKind::Linux, ArchKind::Amd64);
3201 let rendered = serde_yaml::to_string(&p).expect("render");
3202 assert!(
3203 !rendered.contains("osVersion"),
3204 "osVersion must be omitted when None: {rendered}"
3205 );
3206 }
3207
3208 #[test]
3209 fn target_platform_as_detailed_str_includes_version() {
3210 let without = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).as_detailed_str();
3211 assert_eq!(without, "windows/amd64");
3212
3213 let with = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64)
3214 .with_os_version("10.0.26100.1")
3215 .as_detailed_str();
3216 assert_eq!(with, "windows/amd64 (os.version=10.0.26100.1)");
3217 }
3218
3219 #[test]
3220 fn target_platform_display_ignores_version() {
3221 let p =
3223 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3224 assert_eq!(format!("{p}"), "windows/amd64");
3225 }
3226}