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::validate::validate_version_wrapper"))]
655 pub version: String,
656
657 #[validate(custom(function = "crate::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::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::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 #[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
1000 pub name: String,
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 IfNotPresent,
1019 Never,
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1025#[serde(deny_unknown_fields)]
1026pub struct DeviceSpec {
1027 #[validate(length(min = 1, message = "device path cannot be empty"))]
1029 pub path: String,
1030
1031 #[serde(default = "default_true")]
1033 pub read: bool,
1034
1035 #[serde(default = "default_true")]
1037 pub write: bool,
1038
1039 #[serde(default)]
1041 pub mknod: bool,
1042}
1043
1044fn default_true() -> bool {
1045 true
1046}
1047
1048#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1050#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
1051pub enum StorageSpec {
1052 Bind {
1054 source: String,
1055 target: String,
1056 #[serde(default)]
1057 readonly: bool,
1058 },
1059 Named {
1061 name: String,
1062 target: String,
1063 #[serde(default)]
1064 readonly: bool,
1065 #[serde(default)]
1067 tier: StorageTier,
1068 #[serde(default, skip_serializing_if = "Option::is_none")]
1070 size: Option<String>,
1071 },
1072 Anonymous {
1074 target: String,
1075 #[serde(default)]
1077 tier: StorageTier,
1078 },
1079 Tmpfs {
1081 target: String,
1082 #[serde(default)]
1083 size: Option<String>,
1084 #[serde(default)]
1085 mode: Option<u32>,
1086 },
1087 S3 {
1089 bucket: String,
1090 #[serde(default)]
1091 prefix: Option<String>,
1092 target: String,
1093 #[serde(default)]
1094 readonly: bool,
1095 #[serde(default)]
1096 endpoint: Option<String>,
1097 #[serde(default)]
1098 credentials: Option<String>,
1099 },
1100}
1101
1102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
1104#[serde(deny_unknown_fields)]
1105pub struct ResourcesSpec {
1106 #[serde(default)]
1108 #[validate(custom(function = "crate::validate::validate_cpu_option_wrapper"))]
1109 pub cpu: Option<f64>,
1110
1111 #[serde(default)]
1113 #[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
1114 pub memory: Option<String>,
1115
1116 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub gpu: Option<GpuSpec>,
1119}
1120
1121#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1123#[serde(rename_all = "kebab-case")]
1124pub enum SchedulingPolicy {
1125 #[default]
1127 BestEffort,
1128 Gang,
1130 Spread,
1132}
1133
1134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1136#[serde(rename_all = "kebab-case")]
1137pub enum GpuSharingMode {
1138 #[default]
1140 Exclusive,
1141 Mps,
1144 TimeSlice,
1147}
1148
1149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1156#[serde(deny_unknown_fields)]
1157pub struct DistributedConfig {
1158 #[serde(default = "default_dist_backend")]
1160 pub backend: String,
1161 #[serde(default = "default_dist_port")]
1163 pub master_port: u16,
1164}
1165
1166fn default_dist_backend() -> String {
1167 "nccl".to_string()
1168}
1169
1170fn default_dist_port() -> u16 {
1171 29500
1172}
1173
1174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1193#[serde(deny_unknown_fields)]
1194pub struct GpuSpec {
1195 #[serde(default = "default_gpu_count")]
1197 pub count: u32,
1198 #[serde(default = "default_gpu_vendor")]
1200 pub vendor: String,
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1203 pub mode: Option<String>,
1204 #[serde(default, skip_serializing_if = "Option::is_none")]
1207 pub model: Option<String>,
1208 #[serde(default, skip_serializing_if = "Option::is_none")]
1213 pub scheduling: Option<SchedulingPolicy>,
1214 #[serde(default, skip_serializing_if = "Option::is_none")]
1217 pub distributed: Option<DistributedConfig>,
1218 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub sharing: Option<GpuSharingMode>,
1221}
1222
1223fn default_gpu_count() -> u32 {
1224 1
1225}
1226
1227fn default_gpu_vendor() -> String {
1228 "nvidia".to_string()
1229}
1230
1231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1233#[serde(deny_unknown_fields)]
1234#[derive(Default)]
1235pub struct ServiceNetworkSpec {
1236 #[serde(default)]
1238 pub overlays: OverlayConfig,
1239
1240 #[serde(default)]
1242 pub join: JoinPolicy,
1243}
1244
1245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1247#[serde(deny_unknown_fields)]
1248pub struct OverlayConfig {
1249 #[serde(default)]
1251 pub service: OverlaySettings,
1252
1253 #[serde(default)]
1255 pub global: OverlaySettings,
1256}
1257
1258impl Default for OverlayConfig {
1259 fn default() -> Self {
1260 Self {
1261 service: OverlaySettings {
1262 enabled: true,
1263 encrypted: true,
1264 isolated: true,
1265 },
1266 global: OverlaySettings {
1267 enabled: true,
1268 encrypted: true,
1269 isolated: false,
1270 },
1271 }
1272 }
1273}
1274
1275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1277#[serde(deny_unknown_fields)]
1278pub struct OverlaySettings {
1279 #[serde(default = "default_enabled")]
1281 pub enabled: bool,
1282
1283 #[serde(default = "default_encrypted")]
1285 pub encrypted: bool,
1286
1287 #[serde(default)]
1289 pub isolated: bool,
1290}
1291
1292fn default_enabled() -> bool {
1293 true
1294}
1295
1296fn default_encrypted() -> bool {
1297 true
1298}
1299
1300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1302#[serde(deny_unknown_fields)]
1303pub struct JoinPolicy {
1304 #[serde(default = "default_join_mode")]
1306 pub mode: JoinMode,
1307
1308 #[serde(default = "default_join_scope")]
1310 pub scope: JoinScope,
1311}
1312
1313impl Default for JoinPolicy {
1314 fn default() -> Self {
1315 Self {
1316 mode: default_join_mode(),
1317 scope: default_join_scope(),
1318 }
1319 }
1320}
1321
1322fn default_join_mode() -> JoinMode {
1323 JoinMode::Token
1324}
1325
1326fn default_join_scope() -> JoinScope {
1327 JoinScope::Service
1328}
1329
1330#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1332#[serde(rename_all = "snake_case")]
1333pub enum JoinMode {
1334 Open,
1336 Token,
1338 Closed,
1340}
1341
1342#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1344#[serde(rename_all = "snake_case")]
1345pub enum JoinScope {
1346 Service,
1348 Global,
1350}
1351
1352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1354#[serde(deny_unknown_fields)]
1355pub struct EndpointSpec {
1356 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
1358 pub name: String,
1359
1360 pub protocol: Protocol,
1362
1363 #[validate(custom(function = "crate::validate::validate_port_wrapper"))]
1365 pub port: u16,
1366
1367 #[serde(default, skip_serializing_if = "Option::is_none")]
1370 pub target_port: Option<u16>,
1371
1372 pub path: Option<String>,
1374
1375 #[serde(default, skip_serializing_if = "Option::is_none")]
1378 pub host: Option<String>,
1379
1380 #[serde(default = "default_expose")]
1382 pub expose: ExposeType,
1383
1384 #[serde(default, skip_serializing_if = "Option::is_none")]
1387 pub stream: Option<StreamEndpointConfig>,
1388
1389 #[serde(default, skip_serializing_if = "Option::is_none")]
1391 pub tunnel: Option<EndpointTunnelConfig>,
1392}
1393
1394impl EndpointSpec {
1395 #[must_use]
1398 pub fn target_port(&self) -> u16 {
1399 self.target_port.unwrap_or(self.port)
1400 }
1401}
1402
1403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1405#[serde(deny_unknown_fields)]
1406pub struct EndpointTunnelConfig {
1407 #[serde(default)]
1409 pub enabled: bool,
1410
1411 #[serde(default, skip_serializing_if = "Option::is_none")]
1413 pub from: Option<String>,
1414
1415 #[serde(default, skip_serializing_if = "Option::is_none")]
1417 pub to: Option<String>,
1418
1419 #[serde(default)]
1421 pub remote_port: u16,
1422
1423 #[serde(default, skip_serializing_if = "Option::is_none")]
1425 pub expose: Option<ExposeType>,
1426
1427 #[serde(default, skip_serializing_if = "Option::is_none")]
1429 pub access: Option<TunnelAccessConfig>,
1430}
1431
1432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1434#[serde(deny_unknown_fields)]
1435pub struct TunnelAccessConfig {
1436 #[serde(default)]
1438 pub enabled: bool,
1439
1440 #[serde(default, skip_serializing_if = "Option::is_none")]
1442 pub max_ttl: Option<String>,
1443
1444 #[serde(default)]
1446 pub audit: bool,
1447}
1448
1449fn default_expose() -> ExposeType {
1450 ExposeType::Internal
1451}
1452
1453#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1455#[serde(rename_all = "lowercase")]
1456pub enum Protocol {
1457 Http,
1458 Https,
1459 Tcp,
1460 Udp,
1461 Websocket,
1462}
1463
1464#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1466#[serde(rename_all = "lowercase")]
1467pub enum ExposeType {
1468 Public,
1469 #[default]
1470 Internal,
1471}
1472
1473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1475#[serde(deny_unknown_fields)]
1476pub struct StreamEndpointConfig {
1477 #[serde(default)]
1479 pub tls: bool,
1480
1481 #[serde(default)]
1483 pub proxy_protocol: bool,
1484
1485 #[serde(default, skip_serializing_if = "Option::is_none")]
1488 pub session_timeout: Option<String>,
1489
1490 #[serde(default, skip_serializing_if = "Option::is_none")]
1492 pub health_check: Option<StreamHealthCheck>,
1493}
1494
1495#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1497#[serde(tag = "type", rename_all = "snake_case")]
1498pub enum StreamHealthCheck {
1499 TcpConnect,
1501 UdpProbe {
1503 request: String,
1505 #[serde(default, skip_serializing_if = "Option::is_none")]
1507 expect: Option<String>,
1508 },
1509}
1510
1511#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1513#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
1514pub enum ScaleSpec {
1515 #[serde(rename = "adaptive")]
1517 Adaptive {
1518 min: u32,
1520
1521 max: u32,
1523
1524 #[serde(default, with = "duration::option")]
1526 cooldown: Option<std::time::Duration>,
1527
1528 #[serde(default)]
1530 targets: ScaleTargets,
1531 },
1532
1533 #[serde(rename = "fixed")]
1535 Fixed { replicas: u32 },
1536
1537 #[serde(rename = "manual")]
1539 Manual,
1540}
1541
1542impl Default for ScaleSpec {
1543 fn default() -> Self {
1544 Self::Adaptive {
1545 min: 1,
1546 max: 10,
1547 cooldown: Some(std::time::Duration::from_secs(30)),
1548 targets: ScaleTargets::default(),
1549 }
1550 }
1551}
1552
1553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1555#[serde(deny_unknown_fields)]
1556#[derive(Default)]
1557pub struct ScaleTargets {
1558 #[serde(default)]
1560 pub cpu: Option<u8>,
1561
1562 #[serde(default)]
1564 pub memory: Option<u8>,
1565
1566 #[serde(default)]
1568 pub rps: Option<u32>,
1569}
1570
1571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1573#[serde(deny_unknown_fields)]
1574pub struct DependsSpec {
1575 pub service: String,
1577
1578 #[serde(default = "default_condition")]
1580 pub condition: DependencyCondition,
1581
1582 #[serde(default = "default_timeout", with = "duration::option")]
1584 pub timeout: Option<std::time::Duration>,
1585
1586 #[serde(default = "default_on_timeout")]
1588 pub on_timeout: TimeoutAction,
1589}
1590
1591fn default_condition() -> DependencyCondition {
1592 DependencyCondition::Healthy
1593}
1594
1595#[allow(clippy::unnecessary_wraps)]
1596fn default_timeout() -> Option<std::time::Duration> {
1597 Some(std::time::Duration::from_secs(300))
1598}
1599
1600fn default_on_timeout() -> TimeoutAction {
1601 TimeoutAction::Fail
1602}
1603
1604#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1606#[serde(rename_all = "lowercase")]
1607pub enum DependencyCondition {
1608 Started,
1610 Healthy,
1612 Ready,
1614}
1615
1616#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1618#[serde(rename_all = "lowercase")]
1619pub enum TimeoutAction {
1620 Fail,
1621 Warn,
1622 Continue,
1623}
1624
1625#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1627#[serde(deny_unknown_fields)]
1628pub struct HealthSpec {
1629 #[serde(default, with = "duration::option")]
1631 pub start_grace: Option<std::time::Duration>,
1632
1633 #[serde(default, with = "duration::option")]
1635 pub interval: Option<std::time::Duration>,
1636
1637 #[serde(default, with = "duration::option")]
1639 pub timeout: Option<std::time::Duration>,
1640
1641 #[serde(default = "default_retries")]
1643 pub retries: u32,
1644
1645 pub check: HealthCheck,
1647}
1648
1649fn default_retries() -> u32 {
1650 3
1651}
1652
1653#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1655#[serde(tag = "type", rename_all = "lowercase")]
1656pub enum HealthCheck {
1657 Tcp {
1659 port: u16,
1661 },
1662
1663 Http {
1665 url: String,
1667 #[serde(default = "default_expect_status")]
1669 expect_status: u16,
1670 },
1671
1672 Command {
1674 command: String,
1676 },
1677}
1678
1679fn default_expect_status() -> u16 {
1680 200
1681}
1682
1683#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1685#[serde(deny_unknown_fields)]
1686#[derive(Default)]
1687pub struct InitSpec {
1688 #[serde(default)]
1690 pub steps: Vec<InitStep>,
1691}
1692
1693#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1695#[serde(deny_unknown_fields)]
1696pub struct InitStep {
1697 pub id: String,
1699
1700 pub uses: String,
1702
1703 #[serde(default)]
1705 pub with: InitParams,
1706
1707 #[serde(default)]
1709 pub retry: Option<u32>,
1710
1711 #[serde(default, with = "duration::option")]
1713 pub timeout: Option<std::time::Duration>,
1714
1715 #[serde(default = "default_on_failure")]
1717 pub on_failure: FailureAction,
1718}
1719
1720fn default_on_failure() -> FailureAction {
1721 FailureAction::Fail
1722}
1723
1724pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
1726
1727#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1729#[serde(rename_all = "lowercase")]
1730pub enum FailureAction {
1731 Fail,
1732 Warn,
1733 Continue,
1734}
1735
1736#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1738#[serde(deny_unknown_fields)]
1739#[derive(Default)]
1740pub struct ErrorsSpec {
1741 #[serde(default)]
1743 pub on_init_failure: InitFailurePolicy,
1744
1745 #[serde(default)]
1747 pub on_panic: PanicPolicy,
1748}
1749
1750#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1752#[serde(deny_unknown_fields)]
1753pub struct InitFailurePolicy {
1754 #[serde(default = "default_init_action")]
1755 pub action: InitFailureAction,
1756}
1757
1758impl Default for InitFailurePolicy {
1759 fn default() -> Self {
1760 Self {
1761 action: default_init_action(),
1762 }
1763 }
1764}
1765
1766fn default_init_action() -> InitFailureAction {
1767 InitFailureAction::Fail
1768}
1769
1770#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1772#[serde(rename_all = "lowercase")]
1773pub enum InitFailureAction {
1774 Fail,
1775 Restart,
1776 Backoff,
1777}
1778
1779#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1781#[serde(deny_unknown_fields)]
1782pub struct PanicPolicy {
1783 #[serde(default = "default_panic_action")]
1784 pub action: PanicAction,
1785}
1786
1787impl Default for PanicPolicy {
1788 fn default() -> Self {
1789 Self {
1790 action: default_panic_action(),
1791 }
1792 }
1793}
1794
1795fn default_panic_action() -> PanicAction {
1796 PanicAction::Restart
1797}
1798
1799#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1801#[serde(rename_all = "lowercase")]
1802pub enum PanicAction {
1803 Restart,
1804 Shutdown,
1805 Isolate,
1806}
1807
1808#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1815pub struct NetworkPolicySpec {
1816 pub name: String,
1818
1819 #[serde(default, skip_serializing_if = "Option::is_none")]
1821 pub description: Option<String>,
1822
1823 #[serde(default)]
1825 pub cidrs: Vec<String>,
1826
1827 #[serde(default)]
1829 pub members: Vec<NetworkMember>,
1830
1831 #[serde(default)]
1833 pub access_rules: Vec<AccessRule>,
1834}
1835
1836#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1838pub struct NetworkMember {
1839 pub name: String,
1841 #[serde(default)]
1843 pub kind: MemberKind,
1844}
1845
1846#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1848#[serde(rename_all = "lowercase")]
1849pub enum MemberKind {
1850 #[default]
1852 User,
1853 Group,
1855 Node,
1857 Cidr,
1859}
1860
1861#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1863pub struct AccessRule {
1864 #[serde(default = "wildcard")]
1866 pub service: String,
1867
1868 #[serde(default = "wildcard")]
1870 pub deployment: String,
1871
1872 #[serde(default, skip_serializing_if = "Option::is_none")]
1874 pub ports: Option<Vec<u16>>,
1875
1876 #[serde(default)]
1878 pub action: AccessAction,
1879}
1880
1881fn wildcard() -> String {
1882 "*".to_string()
1883}
1884
1885#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1887#[serde(rename_all = "lowercase")]
1888pub enum AccessAction {
1889 #[default]
1891 Allow,
1892 Deny,
1894}
1895
1896#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1908pub struct BridgeNetwork {
1909 pub id: String,
1911
1912 pub name: String,
1914
1915 #[serde(default)]
1917 pub driver: BridgeNetworkDriver,
1918
1919 #[serde(default, skip_serializing_if = "Option::is_none")]
1921 pub subnet: Option<String>,
1922
1923 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1925 pub labels: HashMap<String, String>,
1926
1927 #[serde(default)]
1930 pub internal: bool,
1931
1932 #[schema(value_type = String, format = "date-time")]
1934 pub created_at: chrono::DateTime<chrono::Utc>,
1935}
1936
1937#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
1939#[serde(rename_all = "lowercase")]
1940pub enum BridgeNetworkDriver {
1941 #[default]
1943 Bridge,
1944 Overlay,
1946}
1947
1948#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1950pub struct BridgeNetworkAttachment {
1951 pub container_id: String,
1953
1954 #[serde(default, skip_serializing_if = "Option::is_none")]
1956 pub container_name: Option<String>,
1957
1958 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1960 pub aliases: Vec<String>,
1961
1962 #[serde(default, skip_serializing_if = "Option::is_none")]
1964 pub ipv4: Option<String>,
1965}
1966
1967#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1988pub struct RegistryAuth {
1989 pub username: String,
1992 pub password: String,
1995 #[serde(default = "default_registry_auth_type")]
1997 pub auth_type: RegistryAuthType,
1998}
1999
2000#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2002#[serde(rename_all = "snake_case")]
2003pub enum RegistryAuthType {
2004 #[default]
2006 Basic,
2007 Token,
2010}
2011
2012#[must_use]
2015pub fn default_registry_auth_type() -> RegistryAuthType {
2016 RegistryAuthType::Basic
2017}
2018
2019#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2034#[serde(rename_all = "snake_case", deny_unknown_fields)]
2035pub struct ContainerRestartPolicy {
2036 pub kind: ContainerRestartKind,
2038
2039 #[serde(default, skip_serializing_if = "Option::is_none")]
2042 pub max_attempts: Option<u32>,
2043
2044 #[serde(default, skip_serializing_if = "Option::is_none")]
2049 pub delay: Option<String>,
2050}
2051
2052#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2054#[serde(rename_all = "snake_case")]
2055pub enum ContainerRestartKind {
2056 No,
2058 Always,
2060 UnlessStopped,
2063 OnFailure,
2066}
2067
2068#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2074#[serde(rename_all = "snake_case")]
2075pub enum PortProtocol {
2076 Tcp,
2078 Udp,
2080}
2081
2082impl Default for PortProtocol {
2083 fn default() -> Self {
2084 default_port_protocol()
2085 }
2086}
2087
2088impl PortProtocol {
2089 #[must_use]
2092 pub fn as_str(&self) -> &'static str {
2093 match self {
2094 PortProtocol::Tcp => "tcp",
2095 PortProtocol::Udp => "udp",
2096 }
2097 }
2098}
2099
2100fn default_port_protocol() -> PortProtocol {
2101 PortProtocol::Tcp
2102}
2103
2104fn default_host_ip() -> String {
2105 "0.0.0.0".to_string()
2106}
2107
2108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2114#[serde(rename_all = "snake_case")]
2115pub struct PortMapping {
2116 #[serde(default, skip_serializing_if = "Option::is_none")]
2118 pub host_port: Option<u16>,
2119 pub container_port: u16,
2121 #[serde(default = "default_port_protocol")]
2123 pub protocol: PortProtocol,
2124 #[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
2126 pub host_ip: String,
2127}
2128
2129#[cfg(test)]
2130mod tests {
2131 use super::*;
2132
2133 #[test]
2134 fn port_mapping_defaults_via_serde() {
2135 let json = r#"{"container_port": 8080}"#;
2138 let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
2139 assert_eq!(m.container_port, 8080);
2140 assert_eq!(m.host_port, None);
2141 assert_eq!(m.protocol, PortProtocol::Tcp);
2142 assert_eq!(m.host_ip, "0.0.0.0");
2143 }
2144
2145 #[test]
2146 fn port_mapping_skips_none_host_port_and_empty_host_ip() {
2147 let m = PortMapping {
2148 host_port: None,
2149 container_port: 443,
2150 protocol: PortProtocol::Tcp,
2151 host_ip: String::new(),
2152 };
2153 let s = serde_json::to_string(&m).expect("serialize");
2154 assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
2156 assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
2157 assert!(s.contains("\"container_port\":443"));
2158 assert!(s.contains("\"protocol\":\"tcp\""));
2159 }
2160
2161 #[test]
2162 fn test_parse_simple_spec() {
2163 let yaml = r"
2164version: v1
2165deployment: test
2166services:
2167 hello:
2168 rtype: service
2169 image:
2170 name: hello-world:latest
2171 endpoints:
2172 - name: http
2173 protocol: http
2174 port: 8080
2175 expose: public
2176";
2177
2178 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2179 assert_eq!(spec.version, "v1");
2180 assert_eq!(spec.deployment, "test");
2181 assert!(spec.services.contains_key("hello"));
2182 }
2183
2184 #[test]
2185 fn test_parse_duration() {
2186 let yaml = r"
2187version: v1
2188deployment: test
2189services:
2190 test:
2191 rtype: service
2192 image:
2193 name: test:latest
2194 health:
2195 timeout: 30s
2196 interval: 1m
2197 start_grace: 5s
2198 check:
2199 type: tcp
2200 port: 8080
2201";
2202
2203 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2204 let health = &spec.services["test"].health;
2205 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
2206 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
2207 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
2208 match &health.check {
2209 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
2210 _ => panic!("Expected TCP health check"),
2211 }
2212 }
2213
2214 #[test]
2215 fn test_parse_adaptive_scale() {
2216 let yaml = r"
2217version: v1
2218deployment: test
2219services:
2220 test:
2221 rtype: service
2222 image:
2223 name: test:latest
2224 scale:
2225 mode: adaptive
2226 min: 2
2227 max: 10
2228 cooldown: 15s
2229 targets:
2230 cpu: 70
2231 rps: 800
2232";
2233
2234 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2235 let scale = &spec.services["test"].scale;
2236 match scale {
2237 ScaleSpec::Adaptive {
2238 min,
2239 max,
2240 cooldown,
2241 targets,
2242 } => {
2243 assert_eq!(*min, 2);
2244 assert_eq!(*max, 10);
2245 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
2246 assert_eq!(targets.cpu, Some(70));
2247 assert_eq!(targets.rps, Some(800));
2248 }
2249 _ => panic!("Expected Adaptive scale mode"),
2250 }
2251 }
2252
2253 #[test]
2254 fn test_node_mode_default() {
2255 let yaml = r"
2256version: v1
2257deployment: test
2258services:
2259 hello:
2260 rtype: service
2261 image:
2262 name: hello-world:latest
2263";
2264
2265 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2266 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
2267 assert!(spec.services["hello"].node_selector.is_none());
2268 }
2269
2270 #[test]
2271 fn test_node_mode_dedicated() {
2272 let yaml = r"
2273version: v1
2274deployment: test
2275services:
2276 api:
2277 rtype: service
2278 image:
2279 name: api:latest
2280 node_mode: dedicated
2281";
2282
2283 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2284 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2285 }
2286
2287 #[test]
2288 fn test_node_mode_exclusive() {
2289 let yaml = r"
2290version: v1
2291deployment: test
2292services:
2293 database:
2294 rtype: service
2295 image:
2296 name: postgres:15
2297 node_mode: exclusive
2298";
2299
2300 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2301 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2302 }
2303
2304 #[test]
2305 fn test_node_selector_with_labels() {
2306 let yaml = r#"
2307version: v1
2308deployment: test
2309services:
2310 ml-worker:
2311 rtype: service
2312 image:
2313 name: ml-worker:latest
2314 node_mode: dedicated
2315 node_selector:
2316 labels:
2317 gpu: "true"
2318 zone: us-east
2319 prefer_labels:
2320 storage: ssd
2321"#;
2322
2323 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2324 let service = &spec.services["ml-worker"];
2325 assert_eq!(service.node_mode, NodeMode::Dedicated);
2326
2327 let selector = service.node_selector.as_ref().unwrap();
2328 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
2329 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
2330 assert_eq!(
2331 selector.prefer_labels.get("storage"),
2332 Some(&"ssd".to_string())
2333 );
2334 }
2335
2336 #[test]
2337 fn test_node_mode_serialization_roundtrip() {
2338 use serde_json;
2339
2340 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
2342 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
2343
2344 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
2345 let json = serde_json::to_string(mode).unwrap();
2346 assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
2347
2348 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
2349 assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
2350 }
2351 }
2352
2353 #[test]
2354 fn test_node_selector_empty() {
2355 let yaml = r"
2356version: v1
2357deployment: test
2358services:
2359 api:
2360 rtype: service
2361 image:
2362 name: api:latest
2363 node_selector:
2364 labels: {}
2365";
2366
2367 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2368 let selector = spec.services["api"].node_selector.as_ref().unwrap();
2369 assert!(selector.labels.is_empty());
2370 assert!(selector.prefer_labels.is_empty());
2371 }
2372
2373 #[test]
2374 fn test_mixed_node_modes_in_deployment() {
2375 let yaml = r"
2376version: v1
2377deployment: test
2378services:
2379 redis:
2380 rtype: service
2381 image:
2382 name: redis:alpine
2383 # Default shared mode
2384 api:
2385 rtype: service
2386 image:
2387 name: api:latest
2388 node_mode: dedicated
2389 database:
2390 rtype: service
2391 image:
2392 name: postgres:15
2393 node_mode: exclusive
2394 node_selector:
2395 labels:
2396 storage: ssd
2397";
2398
2399 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2400 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
2401 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2402 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2403
2404 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
2405 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
2406 }
2407
2408 #[test]
2409 fn test_storage_bind_mount() {
2410 let yaml = r"
2411version: v1
2412deployment: test
2413services:
2414 app:
2415 image:
2416 name: app:latest
2417 storage:
2418 - type: bind
2419 source: /host/data
2420 target: /app/data
2421 readonly: true
2422";
2423 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2424 let storage = &spec.services["app"].storage;
2425 assert_eq!(storage.len(), 1);
2426 match &storage[0] {
2427 StorageSpec::Bind {
2428 source,
2429 target,
2430 readonly,
2431 } => {
2432 assert_eq!(source, "/host/data");
2433 assert_eq!(target, "/app/data");
2434 assert!(*readonly);
2435 }
2436 _ => panic!("Expected Bind storage"),
2437 }
2438 }
2439
2440 #[test]
2441 fn test_storage_named_with_tier() {
2442 let yaml = r"
2443version: v1
2444deployment: test
2445services:
2446 app:
2447 image:
2448 name: app:latest
2449 storage:
2450 - type: named
2451 name: my-data
2452 target: /app/data
2453 tier: cached
2454";
2455 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2456 let storage = &spec.services["app"].storage;
2457 match &storage[0] {
2458 StorageSpec::Named {
2459 name, target, tier, ..
2460 } => {
2461 assert_eq!(name, "my-data");
2462 assert_eq!(target, "/app/data");
2463 assert_eq!(*tier, StorageTier::Cached);
2464 }
2465 _ => panic!("Expected Named storage"),
2466 }
2467 }
2468
2469 #[test]
2470 fn test_storage_anonymous() {
2471 let yaml = r"
2472version: v1
2473deployment: test
2474services:
2475 app:
2476 image:
2477 name: app:latest
2478 storage:
2479 - type: anonymous
2480 target: /app/cache
2481";
2482 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2483 let storage = &spec.services["app"].storage;
2484 match &storage[0] {
2485 StorageSpec::Anonymous { target, tier } => {
2486 assert_eq!(target, "/app/cache");
2487 assert_eq!(*tier, StorageTier::Local); }
2489 _ => panic!("Expected Anonymous storage"),
2490 }
2491 }
2492
2493 #[test]
2494 fn test_storage_tmpfs() {
2495 let yaml = r"
2496version: v1
2497deployment: test
2498services:
2499 app:
2500 image:
2501 name: app:latest
2502 storage:
2503 - type: tmpfs
2504 target: /app/tmp
2505 size: 256Mi
2506 mode: 1777
2507";
2508 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2509 let storage = &spec.services["app"].storage;
2510 match &storage[0] {
2511 StorageSpec::Tmpfs { target, size, mode } => {
2512 assert_eq!(target, "/app/tmp");
2513 assert_eq!(size.as_deref(), Some("256Mi"));
2514 assert_eq!(*mode, Some(1777));
2515 }
2516 _ => panic!("Expected Tmpfs storage"),
2517 }
2518 }
2519
2520 #[test]
2521 fn test_storage_s3() {
2522 let yaml = r"
2523version: v1
2524deployment: test
2525services:
2526 app:
2527 image:
2528 name: app:latest
2529 storage:
2530 - type: s3
2531 bucket: my-bucket
2532 prefix: models/
2533 target: /app/models
2534 readonly: true
2535 endpoint: https://s3.us-west-2.amazonaws.com
2536 credentials: aws-creds
2537";
2538 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2539 let storage = &spec.services["app"].storage;
2540 match &storage[0] {
2541 StorageSpec::S3 {
2542 bucket,
2543 prefix,
2544 target,
2545 readonly,
2546 endpoint,
2547 credentials,
2548 } => {
2549 assert_eq!(bucket, "my-bucket");
2550 assert_eq!(prefix.as_deref(), Some("models/"));
2551 assert_eq!(target, "/app/models");
2552 assert!(*readonly);
2553 assert_eq!(
2554 endpoint.as_deref(),
2555 Some("https://s3.us-west-2.amazonaws.com")
2556 );
2557 assert_eq!(credentials.as_deref(), Some("aws-creds"));
2558 }
2559 _ => panic!("Expected S3 storage"),
2560 }
2561 }
2562
2563 #[test]
2564 fn test_storage_multiple_types() {
2565 let yaml = r"
2566version: v1
2567deployment: test
2568services:
2569 app:
2570 image:
2571 name: app:latest
2572 storage:
2573 - type: bind
2574 source: /etc/config
2575 target: /app/config
2576 readonly: true
2577 - type: named
2578 name: app-data
2579 target: /app/data
2580 - type: tmpfs
2581 target: /app/tmp
2582";
2583 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2584 let storage = &spec.services["app"].storage;
2585 assert_eq!(storage.len(), 3);
2586 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
2587 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
2588 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
2589 }
2590
2591 #[test]
2592 fn test_storage_tier_default() {
2593 let yaml = r"
2594version: v1
2595deployment: test
2596services:
2597 app:
2598 image:
2599 name: app:latest
2600 storage:
2601 - type: named
2602 name: data
2603 target: /data
2604";
2605 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2606 match &spec.services["app"].storage[0] {
2607 StorageSpec::Named { tier, .. } => {
2608 assert_eq!(*tier, StorageTier::Local); }
2610 _ => panic!("Expected Named storage"),
2611 }
2612 }
2613
2614 #[test]
2619 fn test_endpoint_tunnel_config_basic() {
2620 let yaml = r"
2621version: v1
2622deployment: test
2623services:
2624 api:
2625 image:
2626 name: api:latest
2627 endpoints:
2628 - name: http
2629 protocol: http
2630 port: 8080
2631 tunnel:
2632 enabled: true
2633 remote_port: 8080
2634";
2635 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2636 let endpoint = &spec.services["api"].endpoints[0];
2637 let tunnel = endpoint.tunnel.as_ref().unwrap();
2638 assert!(tunnel.enabled);
2639 assert_eq!(tunnel.remote_port, 8080);
2640 assert!(tunnel.from.is_none());
2641 assert!(tunnel.to.is_none());
2642 }
2643
2644 #[test]
2645 fn test_endpoint_tunnel_config_full() {
2646 let yaml = r"
2647version: v1
2648deployment: test
2649services:
2650 api:
2651 image:
2652 name: api:latest
2653 endpoints:
2654 - name: http
2655 protocol: http
2656 port: 8080
2657 tunnel:
2658 enabled: true
2659 from: node-1
2660 to: ingress-node
2661 remote_port: 9000
2662 expose: public
2663 access:
2664 enabled: true
2665 max_ttl: 4h
2666 audit: true
2667";
2668 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2669 let endpoint = &spec.services["api"].endpoints[0];
2670 let tunnel = endpoint.tunnel.as_ref().unwrap();
2671 assert!(tunnel.enabled);
2672 assert_eq!(tunnel.from, Some("node-1".to_string()));
2673 assert_eq!(tunnel.to, Some("ingress-node".to_string()));
2674 assert_eq!(tunnel.remote_port, 9000);
2675 assert_eq!(tunnel.expose, Some(ExposeType::Public));
2676
2677 let access = tunnel.access.as_ref().unwrap();
2678 assert!(access.enabled);
2679 assert_eq!(access.max_ttl, Some("4h".to_string()));
2680 assert!(access.audit);
2681 }
2682
2683 #[test]
2684 fn test_top_level_tunnel_definition() {
2685 let yaml = r"
2686version: v1
2687deployment: test
2688services: {}
2689tunnels:
2690 db-tunnel:
2691 from: app-node
2692 to: db-node
2693 local_port: 5432
2694 remote_port: 5432
2695 protocol: tcp
2696 expose: internal
2697";
2698 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2699 let tunnel = spec.tunnels.get("db-tunnel").unwrap();
2700 assert_eq!(tunnel.from, "app-node");
2701 assert_eq!(tunnel.to, "db-node");
2702 assert_eq!(tunnel.local_port, 5432);
2703 assert_eq!(tunnel.remote_port, 5432);
2704 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
2705 assert_eq!(tunnel.expose, ExposeType::Internal);
2706 }
2707
2708 #[test]
2709 fn test_top_level_tunnel_defaults() {
2710 let yaml = r"
2711version: v1
2712deployment: test
2713services: {}
2714tunnels:
2715 simple-tunnel:
2716 from: node-a
2717 to: node-b
2718 local_port: 3000
2719 remote_port: 3000
2720";
2721 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2722 let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
2723 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
2726
2727 #[test]
2728 fn test_tunnel_protocol_udp() {
2729 let yaml = r"
2730version: v1
2731deployment: test
2732services: {}
2733tunnels:
2734 udp-tunnel:
2735 from: node-a
2736 to: node-b
2737 local_port: 5353
2738 remote_port: 5353
2739 protocol: udp
2740";
2741 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2742 let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
2743 assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
2744 }
2745
2746 #[test]
2747 fn test_endpoint_without_tunnel() {
2748 let yaml = r"
2749version: v1
2750deployment: test
2751services:
2752 api:
2753 image:
2754 name: api:latest
2755 endpoints:
2756 - name: http
2757 protocol: http
2758 port: 8080
2759";
2760 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2761 let endpoint = &spec.services["api"].endpoints[0];
2762 assert!(endpoint.tunnel.is_none());
2763 }
2764
2765 #[test]
2766 fn test_deployment_without_tunnels() {
2767 let yaml = r"
2768version: v1
2769deployment: test
2770services:
2771 api:
2772 image:
2773 name: api:latest
2774";
2775 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2776 assert!(spec.tunnels.is_empty());
2777 }
2778
2779 #[test]
2784 fn test_spec_without_api_block_uses_defaults() {
2785 let yaml = r"
2786version: v1
2787deployment: test
2788services:
2789 hello:
2790 image:
2791 name: hello-world:latest
2792";
2793 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2794 assert!(spec.api.enabled);
2795 assert_eq!(spec.api.bind, "0.0.0.0:3669");
2796 assert!(spec.api.jwt_secret.is_none());
2797 assert!(spec.api.swagger);
2798 }
2799
2800 #[test]
2801 fn test_spec_with_explicit_api_block() {
2802 let yaml = r#"
2803version: v1
2804deployment: test
2805services:
2806 hello:
2807 image:
2808 name: hello-world:latest
2809api:
2810 enabled: false
2811 bind: "127.0.0.1:9090"
2812 jwt_secret: "my-secret"
2813 swagger: false
2814"#;
2815 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2816 assert!(!spec.api.enabled);
2817 assert_eq!(spec.api.bind, "127.0.0.1:9090");
2818 assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
2819 assert!(!spec.api.swagger);
2820 }
2821
2822 #[test]
2823 fn test_spec_with_partial_api_block() {
2824 let yaml = r#"
2825version: v1
2826deployment: test
2827services:
2828 hello:
2829 image:
2830 name: hello-world:latest
2831api:
2832 bind: "0.0.0.0:3000"
2833"#;
2834 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2835 assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
2837 assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
2840
2841 #[test]
2846 fn test_network_policy_spec_roundtrip() {
2847 let spec = NetworkPolicySpec {
2848 name: "corp-vpn".to_string(),
2849 description: Some("Corporate VPN network".to_string()),
2850 cidrs: vec!["10.200.0.0/16".to_string()],
2851 members: vec![
2852 NetworkMember {
2853 name: "alice".to_string(),
2854 kind: MemberKind::User,
2855 },
2856 NetworkMember {
2857 name: "ops-team".to_string(),
2858 kind: MemberKind::Group,
2859 },
2860 NetworkMember {
2861 name: "node-01".to_string(),
2862 kind: MemberKind::Node,
2863 },
2864 ],
2865 access_rules: vec![
2866 AccessRule {
2867 service: "api-gateway".to_string(),
2868 deployment: "*".to_string(),
2869 ports: Some(vec![443, 8080]),
2870 action: AccessAction::Allow,
2871 },
2872 AccessRule {
2873 service: "*".to_string(),
2874 deployment: "staging".to_string(),
2875 ports: None,
2876 action: AccessAction::Deny,
2877 },
2878 ],
2879 };
2880
2881 let yaml = serde_yaml::to_string(&spec).unwrap();
2882 let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
2883 assert_eq!(spec, deserialized);
2884 }
2885
2886 #[test]
2887 fn test_network_policy_spec_defaults() {
2888 let yaml = r"
2889name: minimal
2890";
2891 let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
2892 assert_eq!(spec.name, "minimal");
2893 assert!(spec.description.is_none());
2894 assert!(spec.cidrs.is_empty());
2895 assert!(spec.members.is_empty());
2896 assert!(spec.access_rules.is_empty());
2897 }
2898
2899 #[test]
2900 fn test_access_rule_defaults() {
2901 let yaml = "{}";
2902 let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
2903 assert_eq!(rule.service, "*");
2904 assert_eq!(rule.deployment, "*");
2905 assert!(rule.ports.is_none());
2906 assert_eq!(rule.action, AccessAction::Allow);
2907 }
2908
2909 #[test]
2910 fn test_member_kind_defaults_to_user() {
2911 let yaml = r"
2912name: bob
2913";
2914 let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
2915 assert_eq!(member.name, "bob");
2916 assert_eq!(member.kind, MemberKind::User);
2917 }
2918
2919 #[test]
2920 fn test_member_kind_variants() {
2921 for (input, expected) in [
2922 ("user", MemberKind::User),
2923 ("group", MemberKind::Group),
2924 ("node", MemberKind::Node),
2925 ("cidr", MemberKind::Cidr),
2926 ] {
2927 let yaml = format!("name: test\nkind: {input}");
2928 let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
2929 assert_eq!(member.kind, expected);
2930 }
2931 }
2932
2933 #[test]
2934 fn test_access_action_variants() {
2935 #[derive(Debug, Deserialize)]
2937 struct Wrapper {
2938 action: AccessAction,
2939 }
2940
2941 let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
2942 let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
2943
2944 assert_eq!(allow.action, AccessAction::Allow);
2945 assert_eq!(deny.action, AccessAction::Deny);
2946 }
2947
2948 #[test]
2949 fn test_network_policy_spec_default_impl() {
2950 let spec = NetworkPolicySpec::default();
2951 assert_eq!(spec.name, "");
2952 assert!(spec.description.is_none());
2953 assert!(spec.cidrs.is_empty());
2954 assert!(spec.members.is_empty());
2955 assert!(spec.access_rules.is_empty());
2956 }
2957
2958 #[test]
2959 fn container_restart_policy_serde_roundtrip_all_kinds() {
2960 let cases = [
2965 (
2966 ContainerRestartPolicy {
2967 kind: ContainerRestartKind::No,
2968 max_attempts: None,
2969 delay: None,
2970 },
2971 r#"{"kind":"no"}"#,
2972 ),
2973 (
2974 ContainerRestartPolicy {
2975 kind: ContainerRestartKind::Always,
2976 max_attempts: None,
2977 delay: Some("500ms".to_string()),
2978 },
2979 r#"{"kind":"always","delay":"500ms"}"#,
2980 ),
2981 (
2982 ContainerRestartPolicy {
2983 kind: ContainerRestartKind::UnlessStopped,
2984 max_attempts: None,
2985 delay: None,
2986 },
2987 r#"{"kind":"unless_stopped"}"#,
2988 ),
2989 (
2990 ContainerRestartPolicy {
2991 kind: ContainerRestartKind::OnFailure,
2992 max_attempts: Some(5),
2993 delay: None,
2994 },
2995 r#"{"kind":"on_failure","max_attempts":5}"#,
2996 ),
2997 ];
2998
2999 for (value, expected_json) in &cases {
3000 let serialized = serde_json::to_string(value).expect("serialize");
3001 assert_eq!(&serialized, expected_json, "serialize mismatch");
3002 let round: ContainerRestartPolicy =
3003 serde_json::from_str(&serialized).expect("deserialize");
3004 assert_eq!(&round, value, "roundtrip mismatch");
3005 }
3006 }
3007
3008 #[test]
3011 fn registry_auth_type_serializes_snake_case() {
3012 assert_eq!(
3013 serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
3014 "\"basic\""
3015 );
3016 assert_eq!(
3017 serde_json::to_string(&RegistryAuthType::Token).unwrap(),
3018 "\"token\""
3019 );
3020 }
3021
3022 #[test]
3023 fn registry_auth_default_auth_type_is_basic() {
3024 let json = r#"{"username":"u","password":"p"}"#;
3026 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3027 assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
3028 assert_eq!(parsed.username, "u");
3029 assert_eq!(parsed.password, "p");
3030 }
3031
3032 #[test]
3033 fn registry_auth_serde_roundtrip_both_variants() {
3034 for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
3035 let cred = RegistryAuth {
3036 username: "ci-bot".to_string(),
3037 password: "s3cret".to_string(),
3038 auth_type: variant,
3039 };
3040 let serialized = serde_json::to_string(&cred).expect("serialize");
3041 let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
3042 assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
3043 }
3044 }
3045
3046 #[test]
3047 fn registry_auth_explicit_token_type_parses() {
3048 let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
3049 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3050 assert_eq!(parsed.auth_type, RegistryAuthType::Token);
3051 }
3052
3053 #[test]
3054 fn target_platform_as_oci_str() {
3055 assert_eq!(
3056 TargetPlatform::new(OsKind::Linux, ArchKind::Amd64).as_oci_str(),
3057 "linux/amd64"
3058 );
3059 assert_eq!(
3060 TargetPlatform::new(OsKind::Windows, ArchKind::Arm64).as_oci_str(),
3061 "windows/arm64"
3062 );
3063 assert_eq!(
3064 TargetPlatform::new(OsKind::Macos, ArchKind::Arm64).as_oci_str(),
3065 "darwin/arm64"
3066 );
3067 }
3068
3069 #[test]
3070 fn os_kind_from_rust_consts() {
3071 assert_eq!(OsKind::from_rust_os("linux"), Some(OsKind::Linux));
3072 assert_eq!(OsKind::from_rust_os("windows"), Some(OsKind::Windows));
3073 assert_eq!(OsKind::from_rust_os("macos"), Some(OsKind::Macos));
3074 assert_eq!(OsKind::from_rust_os("freebsd"), None);
3075 }
3076
3077 #[test]
3078 fn arch_kind_from_rust_consts() {
3079 assert_eq!(ArchKind::from_rust_arch("x86_64"), Some(ArchKind::Amd64));
3080 assert_eq!(ArchKind::from_rust_arch("aarch64"), Some(ArchKind::Arm64));
3081 assert_eq!(ArchKind::from_rust_arch("riscv64"), None);
3082 }
3083
3084 #[test]
3085 fn service_spec_platform_yaml_round_trip_none() {
3086 let yaml = r"
3089version: v1
3090deployment: test
3091services:
3092 app:
3093 rtype: service
3094 image:
3095 name: nginx:latest
3096";
3097 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3098 assert!(spec.services["app"].platform.is_none());
3099 }
3100
3101 #[test]
3102 fn service_spec_platform_yaml_round_trip_some() {
3103 let yaml = r"
3104version: v1
3105deployment: test
3106services:
3107 app:
3108 rtype: service
3109 image:
3110 name: nginx:latest
3111 platform:
3112 os: windows
3113 arch: amd64
3114";
3115 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3116 assert_eq!(
3117 spec.services["app"].platform,
3118 Some(TargetPlatform::new(OsKind::Windows, ArchKind::Amd64))
3119 );
3120 }
3121
3122 #[test]
3123 fn service_spec_platform_serializes_omitted_when_none() {
3124 let yaml = r"
3127version: v1
3128deployment: test
3129services:
3130 app:
3131 rtype: service
3132 image:
3133 name: nginx:latest
3134";
3135 let mut spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3136 let service = spec.services.get_mut("app").expect("service present");
3137 service.platform = None;
3138 let rendered = serde_yaml::to_string(service).expect("render");
3139 assert!(
3140 !rendered.contains("platform"),
3141 "platform must be omitted when None: {rendered}"
3142 );
3143 }
3144
3145 #[test]
3146 fn target_platform_os_version_builder() {
3147 let p =
3148 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3149 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3150 assert_eq!(p.os, OsKind::Windows);
3151 assert_eq!(p.arch, ArchKind::Amd64);
3152 }
3153
3154 #[test]
3155 fn target_platform_os_version_yaml_roundtrip() {
3156 let yaml = "os: windows\narch: amd64\nosVersion: 10.0.26100.1\n";
3157 let p: TargetPlatform = serde_yaml::from_str(yaml).expect("yaml parse");
3158 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3159 assert_eq!(p.os, OsKind::Windows);
3160 assert_eq!(p.arch, ArchKind::Amd64);
3161 }
3162
3163 #[test]
3164 fn target_platform_os_version_yaml_omits_when_none() {
3165 let p = TargetPlatform::new(OsKind::Linux, ArchKind::Amd64);
3166 let rendered = serde_yaml::to_string(&p).expect("render");
3167 assert!(
3168 !rendered.contains("osVersion"),
3169 "osVersion must be omitted when None: {rendered}"
3170 );
3171 }
3172
3173 #[test]
3174 fn target_platform_as_detailed_str_includes_version() {
3175 let without = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).as_detailed_str();
3176 assert_eq!(without, "windows/amd64");
3177
3178 let with = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64)
3179 .with_os_version("10.0.26100.1")
3180 .as_detailed_str();
3181 assert_eq!(with, "windows/amd64 (os.version=10.0.26100.1)");
3182 }
3183
3184 #[test]
3185 fn target_platform_display_ignores_version() {
3186 let p =
3188 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3189 assert_eq!(format!("{p}"), "windows/amd64");
3190 }
3191}