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(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134#[serde(deny_unknown_fields)]
135#[allow(clippy::struct_excessive_bools)]
136pub struct WasmCapabilities {
137 #[serde(default = "default_true")]
139 pub config: bool,
140 #[serde(default = "default_true")]
142 pub keyvalue: bool,
143 #[serde(default = "default_true")]
145 pub logging: bool,
146 #[serde(default)]
148 pub secrets: bool,
149 #[serde(default = "default_true")]
151 pub metrics: bool,
152 #[serde(default)]
154 pub http_client: bool,
155 #[serde(default)]
157 pub cli: bool,
158 #[serde(default)]
160 pub filesystem: bool,
161 #[serde(default)]
163 pub sockets: bool,
164}
165
166impl Default for WasmCapabilities {
167 fn default() -> Self {
168 Self {
169 config: true,
170 keyvalue: true,
171 logging: true,
172 secrets: false,
173 metrics: true,
174 http_client: false,
175 cli: false,
176 filesystem: false,
177 sockets: false,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184#[serde(deny_unknown_fields)]
185pub struct WasmPreopen {
186 pub source: String,
188 pub target: String,
190 #[serde(default)]
192 pub readonly: bool,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200#[serde(deny_unknown_fields)]
201#[allow(clippy::struct_excessive_bools)]
202pub struct WasmConfig {
203 #[serde(default = "default_min_instances")]
206 pub min_instances: u32,
207 #[serde(default = "default_max_instances")]
209 pub max_instances: u32,
210 #[serde(default = "default_idle_timeout", with = "duration::required")]
212 pub idle_timeout: std::time::Duration,
213 #[serde(default = "default_request_timeout", with = "duration::required")]
215 pub request_timeout: std::time::Duration,
216
217 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub max_memory: Option<String>,
221 #[serde(default)]
223 pub max_fuel: u64,
224 #[serde(
226 default,
227 skip_serializing_if = "Option::is_none",
228 with = "duration::option"
229 )]
230 pub epoch_interval: Option<std::time::Duration>,
231
232 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub capabilities: Option<WasmCapabilities>,
236
237 #[serde(default = "default_true")]
240 pub allow_http_outgoing: bool,
241 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub allowed_hosts: Vec<String>,
244 #[serde(default)]
246 pub allow_tcp: bool,
247 #[serde(default)]
249 pub allow_udp: bool,
250
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub preopens: Vec<WasmPreopen>,
255 #[serde(default = "default_true")]
257 pub kv_enabled: bool,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub kv_namespace: Option<String>,
261 #[serde(default = "default_kv_max_value_size")]
263 pub kv_max_value_size: u64,
264
265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
268 pub secrets: Vec<String>,
269
270 #[serde(default = "default_true")]
273 pub precompile: bool,
274}
275
276fn default_kv_max_value_size() -> u64 {
277 1_048_576 }
279
280impl Default for WasmConfig {
281 fn default() -> Self {
282 Self {
283 min_instances: default_min_instances(),
284 max_instances: default_max_instances(),
285 idle_timeout: default_idle_timeout(),
286 request_timeout: default_request_timeout(),
287 max_memory: None,
288 max_fuel: 0,
289 epoch_interval: None,
290 capabilities: None,
291 allow_http_outgoing: true,
292 allowed_hosts: Vec::new(),
293 allow_tcp: false,
294 allow_udp: false,
295 preopens: Vec::new(),
296 kv_enabled: true,
297 kv_namespace: None,
298 kv_max_value_size: default_kv_max_value_size(),
299 secrets: Vec::new(),
300 precompile: true,
301 }
302 }
303}
304
305#[deprecated(note = "Use WasmConfig instead")]
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
308#[serde(deny_unknown_fields)]
309pub struct WasmHttpConfig {
310 #[serde(default = "default_min_instances")]
312 pub min_instances: u32,
313 #[serde(default = "default_max_instances")]
315 pub max_instances: u32,
316 #[serde(default = "default_idle_timeout", with = "duration::required")]
318 pub idle_timeout: std::time::Duration,
319 #[serde(default = "default_request_timeout", with = "duration::required")]
321 pub request_timeout: std::time::Duration,
322}
323
324fn default_min_instances() -> u32 {
325 0
326}
327
328fn default_max_instances() -> u32 {
329 10
330}
331
332fn default_idle_timeout() -> std::time::Duration {
333 std::time::Duration::from_secs(300)
334}
335
336fn default_request_timeout() -> std::time::Duration {
337 std::time::Duration::from_secs(30)
338}
339
340#[allow(deprecated)]
341impl Default for WasmHttpConfig {
342 fn default() -> Self {
343 Self {
344 min_instances: default_min_instances(),
345 max_instances: default_max_instances(),
346 idle_timeout: default_idle_timeout(),
347 request_timeout: default_request_timeout(),
348 }
349 }
350}
351
352#[allow(deprecated)]
353impl From<WasmHttpConfig> for WasmConfig {
354 fn from(old: WasmHttpConfig) -> Self {
355 Self {
356 min_instances: old.min_instances,
357 max_instances: old.max_instances,
358 idle_timeout: old.idle_timeout,
359 request_timeout: old.request_timeout,
360 ..Default::default()
361 }
362 }
363}
364
365impl ServiceType {
366 #[must_use]
368 pub fn is_wasm(&self) -> bool {
369 matches!(
370 self,
371 ServiceType::WasmHttp
372 | ServiceType::WasmPlugin
373 | ServiceType::WasmTransformer
374 | ServiceType::WasmAuthenticator
375 | ServiceType::WasmRateLimiter
376 | ServiceType::WasmMiddleware
377 | ServiceType::WasmRouter
378 )
379 }
380
381 #[must_use]
384 pub fn default_wasm_capabilities(&self) -> Option<WasmCapabilities> {
385 match self {
386 ServiceType::WasmHttp | ServiceType::WasmRouter => Some(WasmCapabilities {
387 config: true,
388 keyvalue: true,
389 logging: true,
390 secrets: false,
391 metrics: false,
392 http_client: true,
393 cli: false,
394 filesystem: false,
395 sockets: false,
396 }),
397 ServiceType::WasmPlugin => Some(WasmCapabilities {
398 config: true,
399 keyvalue: true,
400 logging: true,
401 secrets: true,
402 metrics: true,
403 http_client: true,
404 cli: true,
405 filesystem: true,
406 sockets: false,
407 }),
408 ServiceType::WasmTransformer => Some(WasmCapabilities {
409 config: false,
410 keyvalue: false,
411 logging: true,
412 secrets: false,
413 metrics: false,
414 http_client: false,
415 cli: true,
416 filesystem: false,
417 sockets: false,
418 }),
419 ServiceType::WasmAuthenticator => Some(WasmCapabilities {
420 config: true,
421 keyvalue: false,
422 logging: true,
423 secrets: true,
424 metrics: false,
425 http_client: true,
426 cli: false,
427 filesystem: false,
428 sockets: false,
429 }),
430 ServiceType::WasmRateLimiter => Some(WasmCapabilities {
431 config: true,
432 keyvalue: true,
433 logging: true,
434 secrets: false,
435 metrics: true,
436 http_client: false,
437 cli: true,
438 filesystem: false,
439 sockets: false,
440 }),
441 ServiceType::WasmMiddleware => Some(WasmCapabilities {
442 config: true,
443 keyvalue: false,
444 logging: true,
445 secrets: false,
446 metrics: false,
447 http_client: true,
448 cli: false,
449 filesystem: false,
450 sockets: false,
451 }),
452 _ => None,
453 }
454 }
455}
456
457fn default_api_bind() -> String {
458 "0.0.0.0:3669".to_string()
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
463pub struct ApiSpec {
464 #[serde(default = "default_true")]
466 pub enabled: bool,
467 #[serde(default = "default_api_bind")]
469 pub bind: String,
470 #[serde(default)]
472 pub jwt_secret: Option<String>,
473 #[serde(default = "default_true")]
475 pub swagger: bool,
476}
477
478impl Default for ApiSpec {
479 fn default() -> Self {
480 Self {
481 enabled: true,
482 bind: default_api_bind(),
483 jwt_secret: None,
484 swagger: true,
485 }
486 }
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
491#[serde(deny_unknown_fields)]
492pub struct DeploymentSpec {
493 #[validate(custom(function = "crate::validate::validate_version_wrapper"))]
495 pub version: String,
496
497 #[validate(custom(function = "crate::validate::validate_deployment_name_wrapper"))]
499 pub deployment: String,
500
501 #[serde(default)]
503 #[validate(nested)]
504 pub services: HashMap<String, ServiceSpec>,
505
506 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
513 #[validate(nested)]
514 pub externals: HashMap<String, ExternalSpec>,
515
516 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
518 pub tunnels: HashMap<String, TunnelDefinition>,
519
520 #[serde(default)]
522 pub api: ApiSpec,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
531#[serde(deny_unknown_fields)]
532pub struct ExternalSpec {
533 #[validate(length(min = 1, message = "at least one backend address is required"))]
538 pub backends: Vec<String>,
539
540 #[serde(default)]
544 #[validate(nested)]
545 pub endpoints: Vec<EndpointSpec>,
546
547 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub health: Option<HealthSpec>,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
557#[serde(deny_unknown_fields)]
558pub struct TunnelDefinition {
559 pub from: String,
561
562 pub to: String,
564
565 pub local_port: u16,
567
568 pub remote_port: u16,
570
571 #[serde(default)]
573 pub protocol: TunnelProtocol,
574
575 #[serde(default)]
577 pub expose: ExposeType,
578}
579
580#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
582#[serde(rename_all = "lowercase")]
583pub enum TunnelProtocol {
584 #[default]
585 Tcp,
586 Udp,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
591pub struct LogsConfig {
592 #[serde(default = "default_logs_destination")]
594 pub destination: String,
595
596 #[serde(default = "default_logs_max_size")]
598 pub max_size_bytes: u64,
599
600 #[serde(default = "default_logs_retention")]
602 pub retention_secs: u64,
603}
604
605fn default_logs_destination() -> String {
606 "disk".to_string()
607}
608
609fn default_logs_max_size() -> u64 {
610 100 * 1024 * 1024 }
612
613fn default_logs_retention() -> u64 {
614 7 * 24 * 60 * 60 }
616
617impl Default for LogsConfig {
618 fn default() -> Self {
619 Self {
620 destination: default_logs_destination(),
621 max_size_bytes: default_logs_max_size(),
622 retention_secs: default_logs_retention(),
623 }
624 }
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
629#[serde(deny_unknown_fields)]
630pub struct ServiceSpec {
631 #[serde(default = "default_resource_type")]
633 pub rtype: ResourceType,
634
635 #[serde(default, skip_serializing_if = "Option::is_none")]
642 #[validate(custom(function = "crate::validate::validate_schedule_wrapper"))]
643 pub schedule: Option<String>,
644
645 #[validate(nested)]
647 pub image: ImageSpec,
648
649 #[serde(default)]
651 #[validate(nested)]
652 pub resources: ResourcesSpec,
653
654 #[serde(default)]
661 pub env: HashMap<String, String>,
662
663 #[serde(default)]
665 pub command: CommandSpec,
666
667 #[serde(default)]
669 pub network: ServiceNetworkSpec,
670
671 #[serde(default)]
673 #[validate(nested)]
674 pub endpoints: Vec<EndpointSpec>,
675
676 #[serde(default)]
678 #[validate(custom(function = "crate::validate::validate_scale_spec"))]
679 pub scale: ScaleSpec,
680
681 #[serde(default)]
683 pub depends: Vec<DependsSpec>,
684
685 #[serde(default = "default_health")]
687 pub health: HealthSpec,
688
689 #[serde(default)]
691 pub init: InitSpec,
692
693 #[serde(default)]
695 pub errors: ErrorsSpec,
696
697 #[serde(default)]
699 pub devices: Vec<DeviceSpec>,
700
701 #[serde(default, skip_serializing_if = "Vec::is_empty")]
703 pub storage: Vec<StorageSpec>,
704
705 #[serde(default)]
707 pub capabilities: Vec<String>,
708
709 #[serde(default)]
711 pub privileged: bool,
712
713 #[serde(default)]
715 pub node_mode: NodeMode,
716
717 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub node_selector: Option<NodeSelector>,
720
721 #[serde(default)]
723 pub service_type: ServiceType,
724
725 #[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
728 pub wasm: Option<WasmConfig>,
729
730 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub logs: Option<LogsConfig>,
733
734 #[serde(skip)]
739 pub host_network: bool,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
744#[serde(deny_unknown_fields)]
745pub struct CommandSpec {
746 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub entrypoint: Option<Vec<String>>,
749
750 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub args: Option<Vec<String>>,
753
754 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub workdir: Option<String>,
757}
758
759fn default_resource_type() -> ResourceType {
760 ResourceType::Service
761}
762
763fn default_health() -> HealthSpec {
764 HealthSpec {
765 start_grace: Some(std::time::Duration::from_secs(5)),
766 interval: None,
767 timeout: None,
768 retries: 3,
769 check: HealthCheck::Tcp { port: 0 },
770 }
771}
772
773#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
775#[serde(rename_all = "lowercase")]
776pub enum ResourceType {
777 Service,
779 Job,
781 Cron,
783}
784
785#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
787#[serde(deny_unknown_fields)]
788pub struct ImageSpec {
789 #[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
791 pub name: String,
792
793 #[serde(default = "default_pull_policy")]
795 pub pull_policy: PullPolicy,
796}
797
798fn default_pull_policy() -> PullPolicy {
799 PullPolicy::IfNotPresent
800}
801
802#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
804#[serde(rename_all = "snake_case")]
805pub enum PullPolicy {
806 Always,
808 IfNotPresent,
810 Never,
812}
813
814#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
816#[serde(deny_unknown_fields)]
817pub struct DeviceSpec {
818 #[validate(length(min = 1, message = "device path cannot be empty"))]
820 pub path: String,
821
822 #[serde(default = "default_true")]
824 pub read: bool,
825
826 #[serde(default = "default_true")]
828 pub write: bool,
829
830 #[serde(default)]
832 pub mknod: bool,
833}
834
835fn default_true() -> bool {
836 true
837}
838
839#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
841#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
842pub enum StorageSpec {
843 Bind {
845 source: String,
846 target: String,
847 #[serde(default)]
848 readonly: bool,
849 },
850 Named {
852 name: String,
853 target: String,
854 #[serde(default)]
855 readonly: bool,
856 #[serde(default)]
858 tier: StorageTier,
859 #[serde(default, skip_serializing_if = "Option::is_none")]
861 size: Option<String>,
862 },
863 Anonymous {
865 target: String,
866 #[serde(default)]
868 tier: StorageTier,
869 },
870 Tmpfs {
872 target: String,
873 #[serde(default)]
874 size: Option<String>,
875 #[serde(default)]
876 mode: Option<u32>,
877 },
878 S3 {
880 bucket: String,
881 #[serde(default)]
882 prefix: Option<String>,
883 target: String,
884 #[serde(default)]
885 readonly: bool,
886 #[serde(default)]
887 endpoint: Option<String>,
888 #[serde(default)]
889 credentials: Option<String>,
890 },
891}
892
893#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
895#[serde(deny_unknown_fields)]
896pub struct ResourcesSpec {
897 #[serde(default)]
899 #[validate(custom(function = "crate::validate::validate_cpu_option_wrapper"))]
900 pub cpu: Option<f64>,
901
902 #[serde(default)]
904 #[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
905 pub memory: Option<String>,
906
907 #[serde(default, skip_serializing_if = "Option::is_none")]
909 pub gpu: Option<GpuSpec>,
910}
911
912#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
914#[serde(rename_all = "kebab-case")]
915pub enum SchedulingPolicy {
916 #[default]
918 BestEffort,
919 Gang,
921 Spread,
923}
924
925#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
927#[serde(rename_all = "kebab-case")]
928pub enum GpuSharingMode {
929 #[default]
931 Exclusive,
932 Mps,
935 TimeSlice,
938}
939
940#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
947#[serde(deny_unknown_fields)]
948pub struct DistributedConfig {
949 #[serde(default = "default_dist_backend")]
951 pub backend: String,
952 #[serde(default = "default_dist_port")]
954 pub master_port: u16,
955}
956
957fn default_dist_backend() -> String {
958 "nccl".to_string()
959}
960
961fn default_dist_port() -> u16 {
962 29500
963}
964
965#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
984#[serde(deny_unknown_fields)]
985pub struct GpuSpec {
986 #[serde(default = "default_gpu_count")]
988 pub count: u32,
989 #[serde(default = "default_gpu_vendor")]
991 pub vendor: String,
992 #[serde(default, skip_serializing_if = "Option::is_none")]
994 pub mode: Option<String>,
995 #[serde(default, skip_serializing_if = "Option::is_none")]
998 pub model: Option<String>,
999 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub scheduling: Option<SchedulingPolicy>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub distributed: Option<DistributedConfig>,
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1011 pub sharing: Option<GpuSharingMode>,
1012}
1013
1014fn default_gpu_count() -> u32 {
1015 1
1016}
1017
1018fn default_gpu_vendor() -> String {
1019 "nvidia".to_string()
1020}
1021
1022#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1024#[serde(deny_unknown_fields)]
1025#[derive(Default)]
1026pub struct ServiceNetworkSpec {
1027 #[serde(default)]
1029 pub overlays: OverlayConfig,
1030
1031 #[serde(default)]
1033 pub join: JoinPolicy,
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1038#[serde(deny_unknown_fields)]
1039pub struct OverlayConfig {
1040 #[serde(default)]
1042 pub service: OverlaySettings,
1043
1044 #[serde(default)]
1046 pub global: OverlaySettings,
1047}
1048
1049impl Default for OverlayConfig {
1050 fn default() -> Self {
1051 Self {
1052 service: OverlaySettings {
1053 enabled: true,
1054 encrypted: true,
1055 isolated: true,
1056 },
1057 global: OverlaySettings {
1058 enabled: true,
1059 encrypted: true,
1060 isolated: false,
1061 },
1062 }
1063 }
1064}
1065
1066#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1068#[serde(deny_unknown_fields)]
1069pub struct OverlaySettings {
1070 #[serde(default = "default_enabled")]
1072 pub enabled: bool,
1073
1074 #[serde(default = "default_encrypted")]
1076 pub encrypted: bool,
1077
1078 #[serde(default)]
1080 pub isolated: bool,
1081}
1082
1083fn default_enabled() -> bool {
1084 true
1085}
1086
1087fn default_encrypted() -> bool {
1088 true
1089}
1090
1091#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1093#[serde(deny_unknown_fields)]
1094pub struct JoinPolicy {
1095 #[serde(default = "default_join_mode")]
1097 pub mode: JoinMode,
1098
1099 #[serde(default = "default_join_scope")]
1101 pub scope: JoinScope,
1102}
1103
1104impl Default for JoinPolicy {
1105 fn default() -> Self {
1106 Self {
1107 mode: default_join_mode(),
1108 scope: default_join_scope(),
1109 }
1110 }
1111}
1112
1113fn default_join_mode() -> JoinMode {
1114 JoinMode::Token
1115}
1116
1117fn default_join_scope() -> JoinScope {
1118 JoinScope::Service
1119}
1120
1121#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1123#[serde(rename_all = "snake_case")]
1124pub enum JoinMode {
1125 Open,
1127 Token,
1129 Closed,
1131}
1132
1133#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1135#[serde(rename_all = "snake_case")]
1136pub enum JoinScope {
1137 Service,
1139 Global,
1141}
1142
1143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1145#[serde(deny_unknown_fields)]
1146pub struct EndpointSpec {
1147 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
1149 pub name: String,
1150
1151 pub protocol: Protocol,
1153
1154 #[validate(custom(function = "crate::validate::validate_port_wrapper"))]
1156 pub port: u16,
1157
1158 #[serde(default, skip_serializing_if = "Option::is_none")]
1161 pub target_port: Option<u16>,
1162
1163 pub path: Option<String>,
1165
1166 #[serde(default, skip_serializing_if = "Option::is_none")]
1169 pub host: Option<String>,
1170
1171 #[serde(default = "default_expose")]
1173 pub expose: ExposeType,
1174
1175 #[serde(default, skip_serializing_if = "Option::is_none")]
1178 pub stream: Option<StreamEndpointConfig>,
1179
1180 #[serde(default, skip_serializing_if = "Option::is_none")]
1182 pub tunnel: Option<EndpointTunnelConfig>,
1183}
1184
1185impl EndpointSpec {
1186 #[must_use]
1189 pub fn target_port(&self) -> u16 {
1190 self.target_port.unwrap_or(self.port)
1191 }
1192}
1193
1194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1196#[serde(deny_unknown_fields)]
1197pub struct EndpointTunnelConfig {
1198 #[serde(default)]
1200 pub enabled: bool,
1201
1202 #[serde(default, skip_serializing_if = "Option::is_none")]
1204 pub from: Option<String>,
1205
1206 #[serde(default, skip_serializing_if = "Option::is_none")]
1208 pub to: Option<String>,
1209
1210 #[serde(default)]
1212 pub remote_port: u16,
1213
1214 #[serde(default, skip_serializing_if = "Option::is_none")]
1216 pub expose: Option<ExposeType>,
1217
1218 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub access: Option<TunnelAccessConfig>,
1221}
1222
1223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1225#[serde(deny_unknown_fields)]
1226pub struct TunnelAccessConfig {
1227 #[serde(default)]
1229 pub enabled: bool,
1230
1231 #[serde(default, skip_serializing_if = "Option::is_none")]
1233 pub max_ttl: Option<String>,
1234
1235 #[serde(default)]
1237 pub audit: bool,
1238}
1239
1240fn default_expose() -> ExposeType {
1241 ExposeType::Internal
1242}
1243
1244#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1246#[serde(rename_all = "lowercase")]
1247pub enum Protocol {
1248 Http,
1249 Https,
1250 Tcp,
1251 Udp,
1252 Websocket,
1253}
1254
1255#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1257#[serde(rename_all = "lowercase")]
1258pub enum ExposeType {
1259 Public,
1260 #[default]
1261 Internal,
1262}
1263
1264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1266#[serde(deny_unknown_fields)]
1267pub struct StreamEndpointConfig {
1268 #[serde(default)]
1270 pub tls: bool,
1271
1272 #[serde(default)]
1274 pub proxy_protocol: bool,
1275
1276 #[serde(default, skip_serializing_if = "Option::is_none")]
1279 pub session_timeout: Option<String>,
1280
1281 #[serde(default, skip_serializing_if = "Option::is_none")]
1283 pub health_check: Option<StreamHealthCheck>,
1284}
1285
1286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1288#[serde(tag = "type", rename_all = "snake_case")]
1289pub enum StreamHealthCheck {
1290 TcpConnect,
1292 UdpProbe {
1294 request: String,
1296 #[serde(default, skip_serializing_if = "Option::is_none")]
1298 expect: Option<String>,
1299 },
1300}
1301
1302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1304#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
1305pub enum ScaleSpec {
1306 #[serde(rename = "adaptive")]
1308 Adaptive {
1309 min: u32,
1311
1312 max: u32,
1314
1315 #[serde(default, with = "duration::option")]
1317 cooldown: Option<std::time::Duration>,
1318
1319 #[serde(default)]
1321 targets: ScaleTargets,
1322 },
1323
1324 #[serde(rename = "fixed")]
1326 Fixed { replicas: u32 },
1327
1328 #[serde(rename = "manual")]
1330 Manual,
1331}
1332
1333impl Default for ScaleSpec {
1334 fn default() -> Self {
1335 Self::Adaptive {
1336 min: 1,
1337 max: 10,
1338 cooldown: Some(std::time::Duration::from_secs(30)),
1339 targets: ScaleTargets::default(),
1340 }
1341 }
1342}
1343
1344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1346#[serde(deny_unknown_fields)]
1347#[derive(Default)]
1348pub struct ScaleTargets {
1349 #[serde(default)]
1351 pub cpu: Option<u8>,
1352
1353 #[serde(default)]
1355 pub memory: Option<u8>,
1356
1357 #[serde(default)]
1359 pub rps: Option<u32>,
1360}
1361
1362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1364#[serde(deny_unknown_fields)]
1365pub struct DependsSpec {
1366 pub service: String,
1368
1369 #[serde(default = "default_condition")]
1371 pub condition: DependencyCondition,
1372
1373 #[serde(default = "default_timeout", with = "duration::option")]
1375 pub timeout: Option<std::time::Duration>,
1376
1377 #[serde(default = "default_on_timeout")]
1379 pub on_timeout: TimeoutAction,
1380}
1381
1382fn default_condition() -> DependencyCondition {
1383 DependencyCondition::Healthy
1384}
1385
1386#[allow(clippy::unnecessary_wraps)]
1387fn default_timeout() -> Option<std::time::Duration> {
1388 Some(std::time::Duration::from_secs(300))
1389}
1390
1391fn default_on_timeout() -> TimeoutAction {
1392 TimeoutAction::Fail
1393}
1394
1395#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1397#[serde(rename_all = "lowercase")]
1398pub enum DependencyCondition {
1399 Started,
1401 Healthy,
1403 Ready,
1405}
1406
1407#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1409#[serde(rename_all = "lowercase")]
1410pub enum TimeoutAction {
1411 Fail,
1412 Warn,
1413 Continue,
1414}
1415
1416#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1418#[serde(deny_unknown_fields)]
1419pub struct HealthSpec {
1420 #[serde(default, with = "duration::option")]
1422 pub start_grace: Option<std::time::Duration>,
1423
1424 #[serde(default, with = "duration::option")]
1426 pub interval: Option<std::time::Duration>,
1427
1428 #[serde(default, with = "duration::option")]
1430 pub timeout: Option<std::time::Duration>,
1431
1432 #[serde(default = "default_retries")]
1434 pub retries: u32,
1435
1436 pub check: HealthCheck,
1438}
1439
1440fn default_retries() -> u32 {
1441 3
1442}
1443
1444#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1446#[serde(tag = "type", rename_all = "lowercase")]
1447pub enum HealthCheck {
1448 Tcp {
1450 port: u16,
1452 },
1453
1454 Http {
1456 url: String,
1458 #[serde(default = "default_expect_status")]
1460 expect_status: u16,
1461 },
1462
1463 Command {
1465 command: String,
1467 },
1468}
1469
1470fn default_expect_status() -> u16 {
1471 200
1472}
1473
1474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1476#[serde(deny_unknown_fields)]
1477#[derive(Default)]
1478pub struct InitSpec {
1479 #[serde(default)]
1481 pub steps: Vec<InitStep>,
1482}
1483
1484#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1486#[serde(deny_unknown_fields)]
1487pub struct InitStep {
1488 pub id: String,
1490
1491 pub uses: String,
1493
1494 #[serde(default)]
1496 pub with: InitParams,
1497
1498 #[serde(default)]
1500 pub retry: Option<u32>,
1501
1502 #[serde(default, with = "duration::option")]
1504 pub timeout: Option<std::time::Duration>,
1505
1506 #[serde(default = "default_on_failure")]
1508 pub on_failure: FailureAction,
1509}
1510
1511fn default_on_failure() -> FailureAction {
1512 FailureAction::Fail
1513}
1514
1515pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
1517
1518#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1520#[serde(rename_all = "lowercase")]
1521pub enum FailureAction {
1522 Fail,
1523 Warn,
1524 Continue,
1525}
1526
1527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1529#[serde(deny_unknown_fields)]
1530#[derive(Default)]
1531pub struct ErrorsSpec {
1532 #[serde(default)]
1534 pub on_init_failure: InitFailurePolicy,
1535
1536 #[serde(default)]
1538 pub on_panic: PanicPolicy,
1539}
1540
1541#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1543#[serde(deny_unknown_fields)]
1544pub struct InitFailurePolicy {
1545 #[serde(default = "default_init_action")]
1546 pub action: InitFailureAction,
1547}
1548
1549impl Default for InitFailurePolicy {
1550 fn default() -> Self {
1551 Self {
1552 action: default_init_action(),
1553 }
1554 }
1555}
1556
1557fn default_init_action() -> InitFailureAction {
1558 InitFailureAction::Fail
1559}
1560
1561#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1563#[serde(rename_all = "lowercase")]
1564pub enum InitFailureAction {
1565 Fail,
1566 Restart,
1567 Backoff,
1568}
1569
1570#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1572#[serde(deny_unknown_fields)]
1573pub struct PanicPolicy {
1574 #[serde(default = "default_panic_action")]
1575 pub action: PanicAction,
1576}
1577
1578impl Default for PanicPolicy {
1579 fn default() -> Self {
1580 Self {
1581 action: default_panic_action(),
1582 }
1583 }
1584}
1585
1586fn default_panic_action() -> PanicAction {
1587 PanicAction::Restart
1588}
1589
1590#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1592#[serde(rename_all = "lowercase")]
1593pub enum PanicAction {
1594 Restart,
1595 Shutdown,
1596 Isolate,
1597}
1598
1599#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1606pub struct NetworkPolicySpec {
1607 pub name: String,
1609
1610 #[serde(default, skip_serializing_if = "Option::is_none")]
1612 pub description: Option<String>,
1613
1614 #[serde(default)]
1616 pub cidrs: Vec<String>,
1617
1618 #[serde(default)]
1620 pub members: Vec<NetworkMember>,
1621
1622 #[serde(default)]
1624 pub access_rules: Vec<AccessRule>,
1625}
1626
1627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1629pub struct NetworkMember {
1630 pub name: String,
1632 #[serde(default)]
1634 pub kind: MemberKind,
1635}
1636
1637#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1639#[serde(rename_all = "lowercase")]
1640pub enum MemberKind {
1641 #[default]
1643 User,
1644 Group,
1646 Node,
1648 Cidr,
1650}
1651
1652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1654pub struct AccessRule {
1655 #[serde(default = "wildcard")]
1657 pub service: String,
1658
1659 #[serde(default = "wildcard")]
1661 pub deployment: String,
1662
1663 #[serde(default, skip_serializing_if = "Option::is_none")]
1665 pub ports: Option<Vec<u16>>,
1666
1667 #[serde(default)]
1669 pub action: AccessAction,
1670}
1671
1672fn wildcard() -> String {
1673 "*".to_string()
1674}
1675
1676#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1678#[serde(rename_all = "lowercase")]
1679pub enum AccessAction {
1680 #[default]
1682 Allow,
1683 Deny,
1685}
1686
1687#[cfg(test)]
1688mod tests {
1689 use super::*;
1690
1691 #[test]
1692 fn test_parse_simple_spec() {
1693 let yaml = r"
1694version: v1
1695deployment: test
1696services:
1697 hello:
1698 rtype: service
1699 image:
1700 name: hello-world:latest
1701 endpoints:
1702 - name: http
1703 protocol: http
1704 port: 8080
1705 expose: public
1706";
1707
1708 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1709 assert_eq!(spec.version, "v1");
1710 assert_eq!(spec.deployment, "test");
1711 assert!(spec.services.contains_key("hello"));
1712 }
1713
1714 #[test]
1715 fn test_parse_duration() {
1716 let yaml = r"
1717version: v1
1718deployment: test
1719services:
1720 test:
1721 rtype: service
1722 image:
1723 name: test:latest
1724 health:
1725 timeout: 30s
1726 interval: 1m
1727 start_grace: 5s
1728 check:
1729 type: tcp
1730 port: 8080
1731";
1732
1733 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1734 let health = &spec.services["test"].health;
1735 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
1736 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
1737 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
1738 match &health.check {
1739 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
1740 _ => panic!("Expected TCP health check"),
1741 }
1742 }
1743
1744 #[test]
1745 fn test_parse_adaptive_scale() {
1746 let yaml = r"
1747version: v1
1748deployment: test
1749services:
1750 test:
1751 rtype: service
1752 image:
1753 name: test:latest
1754 scale:
1755 mode: adaptive
1756 min: 2
1757 max: 10
1758 cooldown: 15s
1759 targets:
1760 cpu: 70
1761 rps: 800
1762";
1763
1764 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1765 let scale = &spec.services["test"].scale;
1766 match scale {
1767 ScaleSpec::Adaptive {
1768 min,
1769 max,
1770 cooldown,
1771 targets,
1772 } => {
1773 assert_eq!(*min, 2);
1774 assert_eq!(*max, 10);
1775 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
1776 assert_eq!(targets.cpu, Some(70));
1777 assert_eq!(targets.rps, Some(800));
1778 }
1779 _ => panic!("Expected Adaptive scale mode"),
1780 }
1781 }
1782
1783 #[test]
1784 fn test_node_mode_default() {
1785 let yaml = r"
1786version: v1
1787deployment: test
1788services:
1789 hello:
1790 rtype: service
1791 image:
1792 name: hello-world:latest
1793";
1794
1795 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1796 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
1797 assert!(spec.services["hello"].node_selector.is_none());
1798 }
1799
1800 #[test]
1801 fn test_node_mode_dedicated() {
1802 let yaml = r"
1803version: v1
1804deployment: test
1805services:
1806 api:
1807 rtype: service
1808 image:
1809 name: api:latest
1810 node_mode: dedicated
1811";
1812
1813 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1814 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1815 }
1816
1817 #[test]
1818 fn test_node_mode_exclusive() {
1819 let yaml = r"
1820version: v1
1821deployment: test
1822services:
1823 database:
1824 rtype: service
1825 image:
1826 name: postgres:15
1827 node_mode: exclusive
1828";
1829
1830 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1831 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1832 }
1833
1834 #[test]
1835 fn test_node_selector_with_labels() {
1836 let yaml = r#"
1837version: v1
1838deployment: test
1839services:
1840 ml-worker:
1841 rtype: service
1842 image:
1843 name: ml-worker:latest
1844 node_mode: dedicated
1845 node_selector:
1846 labels:
1847 gpu: "true"
1848 zone: us-east
1849 prefer_labels:
1850 storage: ssd
1851"#;
1852
1853 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1854 let service = &spec.services["ml-worker"];
1855 assert_eq!(service.node_mode, NodeMode::Dedicated);
1856
1857 let selector = service.node_selector.as_ref().unwrap();
1858 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
1859 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
1860 assert_eq!(
1861 selector.prefer_labels.get("storage"),
1862 Some(&"ssd".to_string())
1863 );
1864 }
1865
1866 #[test]
1867 fn test_node_mode_serialization_roundtrip() {
1868 use serde_json;
1869
1870 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
1872 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
1873
1874 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
1875 let json = serde_json::to_string(mode).unwrap();
1876 assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
1877
1878 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
1879 assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
1880 }
1881 }
1882
1883 #[test]
1884 fn test_node_selector_empty() {
1885 let yaml = r"
1886version: v1
1887deployment: test
1888services:
1889 api:
1890 rtype: service
1891 image:
1892 name: api:latest
1893 node_selector:
1894 labels: {}
1895";
1896
1897 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1898 let selector = spec.services["api"].node_selector.as_ref().unwrap();
1899 assert!(selector.labels.is_empty());
1900 assert!(selector.prefer_labels.is_empty());
1901 }
1902
1903 #[test]
1904 fn test_mixed_node_modes_in_deployment() {
1905 let yaml = r"
1906version: v1
1907deployment: test
1908services:
1909 redis:
1910 rtype: service
1911 image:
1912 name: redis:alpine
1913 # Default shared mode
1914 api:
1915 rtype: service
1916 image:
1917 name: api:latest
1918 node_mode: dedicated
1919 database:
1920 rtype: service
1921 image:
1922 name: postgres:15
1923 node_mode: exclusive
1924 node_selector:
1925 labels:
1926 storage: ssd
1927";
1928
1929 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1930 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
1931 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1932 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1933
1934 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
1935 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
1936 }
1937
1938 #[test]
1939 fn test_storage_bind_mount() {
1940 let yaml = r"
1941version: v1
1942deployment: test
1943services:
1944 app:
1945 image:
1946 name: app:latest
1947 storage:
1948 - type: bind
1949 source: /host/data
1950 target: /app/data
1951 readonly: true
1952";
1953 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1954 let storage = &spec.services["app"].storage;
1955 assert_eq!(storage.len(), 1);
1956 match &storage[0] {
1957 StorageSpec::Bind {
1958 source,
1959 target,
1960 readonly,
1961 } => {
1962 assert_eq!(source, "/host/data");
1963 assert_eq!(target, "/app/data");
1964 assert!(*readonly);
1965 }
1966 _ => panic!("Expected Bind storage"),
1967 }
1968 }
1969
1970 #[test]
1971 fn test_storage_named_with_tier() {
1972 let yaml = r"
1973version: v1
1974deployment: test
1975services:
1976 app:
1977 image:
1978 name: app:latest
1979 storage:
1980 - type: named
1981 name: my-data
1982 target: /app/data
1983 tier: cached
1984";
1985 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1986 let storage = &spec.services["app"].storage;
1987 match &storage[0] {
1988 StorageSpec::Named {
1989 name, target, tier, ..
1990 } => {
1991 assert_eq!(name, "my-data");
1992 assert_eq!(target, "/app/data");
1993 assert_eq!(*tier, StorageTier::Cached);
1994 }
1995 _ => panic!("Expected Named storage"),
1996 }
1997 }
1998
1999 #[test]
2000 fn test_storage_anonymous() {
2001 let yaml = r"
2002version: v1
2003deployment: test
2004services:
2005 app:
2006 image:
2007 name: app:latest
2008 storage:
2009 - type: anonymous
2010 target: /app/cache
2011";
2012 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2013 let storage = &spec.services["app"].storage;
2014 match &storage[0] {
2015 StorageSpec::Anonymous { target, tier } => {
2016 assert_eq!(target, "/app/cache");
2017 assert_eq!(*tier, StorageTier::Local); }
2019 _ => panic!("Expected Anonymous storage"),
2020 }
2021 }
2022
2023 #[test]
2024 fn test_storage_tmpfs() {
2025 let yaml = r"
2026version: v1
2027deployment: test
2028services:
2029 app:
2030 image:
2031 name: app:latest
2032 storage:
2033 - type: tmpfs
2034 target: /app/tmp
2035 size: 256Mi
2036 mode: 1777
2037";
2038 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2039 let storage = &spec.services["app"].storage;
2040 match &storage[0] {
2041 StorageSpec::Tmpfs { target, size, mode } => {
2042 assert_eq!(target, "/app/tmp");
2043 assert_eq!(size.as_deref(), Some("256Mi"));
2044 assert_eq!(*mode, Some(1777));
2045 }
2046 _ => panic!("Expected Tmpfs storage"),
2047 }
2048 }
2049
2050 #[test]
2051 fn test_storage_s3() {
2052 let yaml = r"
2053version: v1
2054deployment: test
2055services:
2056 app:
2057 image:
2058 name: app:latest
2059 storage:
2060 - type: s3
2061 bucket: my-bucket
2062 prefix: models/
2063 target: /app/models
2064 readonly: true
2065 endpoint: https://s3.us-west-2.amazonaws.com
2066 credentials: aws-creds
2067";
2068 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2069 let storage = &spec.services["app"].storage;
2070 match &storage[0] {
2071 StorageSpec::S3 {
2072 bucket,
2073 prefix,
2074 target,
2075 readonly,
2076 endpoint,
2077 credentials,
2078 } => {
2079 assert_eq!(bucket, "my-bucket");
2080 assert_eq!(prefix.as_deref(), Some("models/"));
2081 assert_eq!(target, "/app/models");
2082 assert!(*readonly);
2083 assert_eq!(
2084 endpoint.as_deref(),
2085 Some("https://s3.us-west-2.amazonaws.com")
2086 );
2087 assert_eq!(credentials.as_deref(), Some("aws-creds"));
2088 }
2089 _ => panic!("Expected S3 storage"),
2090 }
2091 }
2092
2093 #[test]
2094 fn test_storage_multiple_types() {
2095 let yaml = r"
2096version: v1
2097deployment: test
2098services:
2099 app:
2100 image:
2101 name: app:latest
2102 storage:
2103 - type: bind
2104 source: /etc/config
2105 target: /app/config
2106 readonly: true
2107 - type: named
2108 name: app-data
2109 target: /app/data
2110 - type: tmpfs
2111 target: /app/tmp
2112";
2113 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2114 let storage = &spec.services["app"].storage;
2115 assert_eq!(storage.len(), 3);
2116 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
2117 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
2118 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
2119 }
2120
2121 #[test]
2122 fn test_storage_tier_default() {
2123 let yaml = r"
2124version: v1
2125deployment: test
2126services:
2127 app:
2128 image:
2129 name: app:latest
2130 storage:
2131 - type: named
2132 name: data
2133 target: /data
2134";
2135 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2136 match &spec.services["app"].storage[0] {
2137 StorageSpec::Named { tier, .. } => {
2138 assert_eq!(*tier, StorageTier::Local); }
2140 _ => panic!("Expected Named storage"),
2141 }
2142 }
2143
2144 #[test]
2149 fn test_endpoint_tunnel_config_basic() {
2150 let yaml = r"
2151version: v1
2152deployment: test
2153services:
2154 api:
2155 image:
2156 name: api:latest
2157 endpoints:
2158 - name: http
2159 protocol: http
2160 port: 8080
2161 tunnel:
2162 enabled: true
2163 remote_port: 8080
2164";
2165 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2166 let endpoint = &spec.services["api"].endpoints[0];
2167 let tunnel = endpoint.tunnel.as_ref().unwrap();
2168 assert!(tunnel.enabled);
2169 assert_eq!(tunnel.remote_port, 8080);
2170 assert!(tunnel.from.is_none());
2171 assert!(tunnel.to.is_none());
2172 }
2173
2174 #[test]
2175 fn test_endpoint_tunnel_config_full() {
2176 let yaml = r"
2177version: v1
2178deployment: test
2179services:
2180 api:
2181 image:
2182 name: api:latest
2183 endpoints:
2184 - name: http
2185 protocol: http
2186 port: 8080
2187 tunnel:
2188 enabled: true
2189 from: node-1
2190 to: ingress-node
2191 remote_port: 9000
2192 expose: public
2193 access:
2194 enabled: true
2195 max_ttl: 4h
2196 audit: true
2197";
2198 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2199 let endpoint = &spec.services["api"].endpoints[0];
2200 let tunnel = endpoint.tunnel.as_ref().unwrap();
2201 assert!(tunnel.enabled);
2202 assert_eq!(tunnel.from, Some("node-1".to_string()));
2203 assert_eq!(tunnel.to, Some("ingress-node".to_string()));
2204 assert_eq!(tunnel.remote_port, 9000);
2205 assert_eq!(tunnel.expose, Some(ExposeType::Public));
2206
2207 let access = tunnel.access.as_ref().unwrap();
2208 assert!(access.enabled);
2209 assert_eq!(access.max_ttl, Some("4h".to_string()));
2210 assert!(access.audit);
2211 }
2212
2213 #[test]
2214 fn test_top_level_tunnel_definition() {
2215 let yaml = r"
2216version: v1
2217deployment: test
2218services: {}
2219tunnels:
2220 db-tunnel:
2221 from: app-node
2222 to: db-node
2223 local_port: 5432
2224 remote_port: 5432
2225 protocol: tcp
2226 expose: internal
2227";
2228 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2229 let tunnel = spec.tunnels.get("db-tunnel").unwrap();
2230 assert_eq!(tunnel.from, "app-node");
2231 assert_eq!(tunnel.to, "db-node");
2232 assert_eq!(tunnel.local_port, 5432);
2233 assert_eq!(tunnel.remote_port, 5432);
2234 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
2235 assert_eq!(tunnel.expose, ExposeType::Internal);
2236 }
2237
2238 #[test]
2239 fn test_top_level_tunnel_defaults() {
2240 let yaml = r"
2241version: v1
2242deployment: test
2243services: {}
2244tunnels:
2245 simple-tunnel:
2246 from: node-a
2247 to: node-b
2248 local_port: 3000
2249 remote_port: 3000
2250";
2251 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2252 let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
2253 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
2256
2257 #[test]
2258 fn test_tunnel_protocol_udp() {
2259 let yaml = r"
2260version: v1
2261deployment: test
2262services: {}
2263tunnels:
2264 udp-tunnel:
2265 from: node-a
2266 to: node-b
2267 local_port: 5353
2268 remote_port: 5353
2269 protocol: udp
2270";
2271 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2272 let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
2273 assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
2274 }
2275
2276 #[test]
2277 fn test_endpoint_without_tunnel() {
2278 let yaml = r"
2279version: v1
2280deployment: test
2281services:
2282 api:
2283 image:
2284 name: api:latest
2285 endpoints:
2286 - name: http
2287 protocol: http
2288 port: 8080
2289";
2290 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2291 let endpoint = &spec.services["api"].endpoints[0];
2292 assert!(endpoint.tunnel.is_none());
2293 }
2294
2295 #[test]
2296 fn test_deployment_without_tunnels() {
2297 let yaml = r"
2298version: v1
2299deployment: test
2300services:
2301 api:
2302 image:
2303 name: api:latest
2304";
2305 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2306 assert!(spec.tunnels.is_empty());
2307 }
2308
2309 #[test]
2314 fn test_spec_without_api_block_uses_defaults() {
2315 let yaml = r"
2316version: v1
2317deployment: test
2318services:
2319 hello:
2320 image:
2321 name: hello-world:latest
2322";
2323 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2324 assert!(spec.api.enabled);
2325 assert_eq!(spec.api.bind, "0.0.0.0:3669");
2326 assert!(spec.api.jwt_secret.is_none());
2327 assert!(spec.api.swagger);
2328 }
2329
2330 #[test]
2331 fn test_spec_with_explicit_api_block() {
2332 let yaml = r#"
2333version: v1
2334deployment: test
2335services:
2336 hello:
2337 image:
2338 name: hello-world:latest
2339api:
2340 enabled: false
2341 bind: "127.0.0.1:9090"
2342 jwt_secret: "my-secret"
2343 swagger: false
2344"#;
2345 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2346 assert!(!spec.api.enabled);
2347 assert_eq!(spec.api.bind, "127.0.0.1:9090");
2348 assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
2349 assert!(!spec.api.swagger);
2350 }
2351
2352 #[test]
2353 fn test_spec_with_partial_api_block() {
2354 let yaml = r#"
2355version: v1
2356deployment: test
2357services:
2358 hello:
2359 image:
2360 name: hello-world:latest
2361api:
2362 bind: "0.0.0.0:3000"
2363"#;
2364 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2365 assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
2367 assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
2370
2371 #[test]
2376 fn test_network_policy_spec_roundtrip() {
2377 let spec = NetworkPolicySpec {
2378 name: "corp-vpn".to_string(),
2379 description: Some("Corporate VPN network".to_string()),
2380 cidrs: vec!["10.200.0.0/16".to_string()],
2381 members: vec![
2382 NetworkMember {
2383 name: "alice".to_string(),
2384 kind: MemberKind::User,
2385 },
2386 NetworkMember {
2387 name: "ops-team".to_string(),
2388 kind: MemberKind::Group,
2389 },
2390 NetworkMember {
2391 name: "node-01".to_string(),
2392 kind: MemberKind::Node,
2393 },
2394 ],
2395 access_rules: vec![
2396 AccessRule {
2397 service: "api-gateway".to_string(),
2398 deployment: "*".to_string(),
2399 ports: Some(vec![443, 8080]),
2400 action: AccessAction::Allow,
2401 },
2402 AccessRule {
2403 service: "*".to_string(),
2404 deployment: "staging".to_string(),
2405 ports: None,
2406 action: AccessAction::Deny,
2407 },
2408 ],
2409 };
2410
2411 let yaml = serde_yaml::to_string(&spec).unwrap();
2412 let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
2413 assert_eq!(spec, deserialized);
2414 }
2415
2416 #[test]
2417 fn test_network_policy_spec_defaults() {
2418 let yaml = r"
2419name: minimal
2420";
2421 let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
2422 assert_eq!(spec.name, "minimal");
2423 assert!(spec.description.is_none());
2424 assert!(spec.cidrs.is_empty());
2425 assert!(spec.members.is_empty());
2426 assert!(spec.access_rules.is_empty());
2427 }
2428
2429 #[test]
2430 fn test_access_rule_defaults() {
2431 let yaml = "{}";
2432 let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
2433 assert_eq!(rule.service, "*");
2434 assert_eq!(rule.deployment, "*");
2435 assert!(rule.ports.is_none());
2436 assert_eq!(rule.action, AccessAction::Allow);
2437 }
2438
2439 #[test]
2440 fn test_member_kind_defaults_to_user() {
2441 let yaml = r"
2442name: bob
2443";
2444 let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
2445 assert_eq!(member.name, "bob");
2446 assert_eq!(member.kind, MemberKind::User);
2447 }
2448
2449 #[test]
2450 fn test_member_kind_variants() {
2451 for (input, expected) in [
2452 ("user", MemberKind::User),
2453 ("group", MemberKind::Group),
2454 ("node", MemberKind::Node),
2455 ("cidr", MemberKind::Cidr),
2456 ] {
2457 let yaml = format!("name: test\nkind: {input}");
2458 let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
2459 assert_eq!(member.kind, expected);
2460 }
2461 }
2462
2463 #[test]
2464 fn test_access_action_variants() {
2465 #[derive(Debug, Deserialize)]
2467 struct Wrapper {
2468 action: AccessAction,
2469 }
2470
2471 let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
2472 let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
2473
2474 assert_eq!(allow.action, AccessAction::Allow);
2475 assert_eq!(deny.action, AccessAction::Deny);
2476 }
2477
2478 #[test]
2479 fn test_network_policy_spec_default_impl() {
2480 let spec = NetworkPolicySpec::default();
2481 assert_eq!(spec.name, "");
2482 assert!(spec.description.is_none());
2483 assert!(spec.cidrs.is_empty());
2484 assert!(spec.members.is_empty());
2485 assert!(spec.access_rules.is_empty());
2486 }
2487}