1mod duration {
6 use humantime::format_duration;
7 use serde::{Deserialize, Deserializer, Serializer};
8 use std::time::Duration;
9
10 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
11 where
12 S: Serializer,
13 {
14 match duration {
15 Some(d) => serializer.serialize_str(&format_duration(*d).to_string()),
16 None => serializer.serialize_none(),
17 }
18 }
19
20 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
21 where
22 D: Deserializer<'de>,
23 {
24 use serde::de::Error;
25 let s: Option<String> = Option::deserialize(deserializer)?;
26 match s {
27 Some(s) => humantime::parse_duration(&s)
28 .map(Some)
29 .map_err(|e| D::Error::custom(format!("invalid duration: {}", e))),
30 None => Ok(None),
31 }
32 }
33
34 pub mod option {
35 pub use super::*;
36 }
37
38 pub mod required {
40 use humantime::format_duration;
41 use serde::{Deserialize, Deserializer, Serializer};
42 use std::time::Duration;
43
44 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
45 where
46 S: Serializer,
47 {
48 serializer.serialize_str(&format_duration(*duration).to_string())
49 }
50
51 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
52 where
53 D: Deserializer<'de>,
54 {
55 use serde::de::Error;
56 let s: String = String::deserialize(deserializer)?;
57 humantime::parse_duration(&s)
58 .map_err(|e| D::Error::custom(format!("invalid duration: {}", e)))
59 }
60 }
61}
62
63use serde::{Deserialize, Serialize};
64use std::collections::HashMap;
65use validator::Validate;
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum NodeMode {
71 #[default]
73 Shared,
74 Dedicated,
76 Exclusive,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum ServiceType {
84 #[default]
86 Standard,
87 WasmHttp,
89 Job,
91}
92
93#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
95#[serde(rename_all = "snake_case")]
96pub enum StorageTier {
97 #[default]
99 Local,
100 Cached,
102 Network,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct NodeSelector {
110 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
112 pub labels: HashMap<String, String>,
113 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
115 pub prefer_labels: HashMap<String, String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(deny_unknown_fields)]
121pub struct WasmHttpConfig {
122 #[serde(default = "default_min_instances")]
124 pub min_instances: u32,
125 #[serde(default = "default_max_instances")]
127 pub max_instances: u32,
128 #[serde(default = "default_idle_timeout", with = "duration::required")]
130 pub idle_timeout: std::time::Duration,
131 #[serde(default = "default_request_timeout", with = "duration::required")]
133 pub request_timeout: std::time::Duration,
134}
135
136fn default_min_instances() -> u32 {
137 0
138}
139
140fn default_max_instances() -> u32 {
141 10
142}
143
144fn default_idle_timeout() -> std::time::Duration {
145 std::time::Duration::from_secs(300)
146}
147
148fn default_request_timeout() -> std::time::Duration {
149 std::time::Duration::from_secs(30)
150}
151
152impl Default for WasmHttpConfig {
153 fn default() -> Self {
154 Self {
155 min_instances: default_min_instances(),
156 max_instances: default_max_instances(),
157 idle_timeout: default_idle_timeout(),
158 request_timeout: default_request_timeout(),
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
165#[serde(deny_unknown_fields)]
166pub struct DeploymentSpec {
167 #[validate(custom(function = "crate::validate::validate_version_wrapper"))]
169 pub version: String,
170
171 #[validate(custom(function = "crate::validate::validate_deployment_name_wrapper"))]
173 pub deployment: String,
174
175 #[serde(default)]
177 #[validate(nested)]
178 pub services: HashMap<String, ServiceSpec>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
183#[serde(deny_unknown_fields)]
184pub struct ServiceSpec {
185 #[serde(default = "default_resource_type")]
187 pub rtype: ResourceType,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
196 #[validate(custom(function = "crate::validate::validate_schedule_wrapper"))]
197 pub schedule: Option<String>,
198
199 #[validate(nested)]
201 pub image: ImageSpec,
202
203 #[serde(default)]
205 #[validate(nested)]
206 pub resources: ResourcesSpec,
207
208 #[serde(default)]
215 pub env: HashMap<String, String>,
216
217 #[serde(default)]
219 pub command: CommandSpec,
220
221 #[serde(default)]
223 pub network: NetworkSpec,
224
225 #[serde(default)]
227 #[validate(nested)]
228 pub endpoints: Vec<EndpointSpec>,
229
230 #[serde(default)]
232 #[validate(custom(function = "crate::validate::validate_scale_spec"))]
233 pub scale: ScaleSpec,
234
235 #[serde(default)]
237 pub depends: Vec<DependsSpec>,
238
239 #[serde(default = "default_health")]
241 pub health: HealthSpec,
242
243 #[serde(default)]
245 pub init: InitSpec,
246
247 #[serde(default)]
249 pub errors: ErrorsSpec,
250
251 #[serde(default)]
253 pub devices: Vec<DeviceSpec>,
254
255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub storage: Vec<StorageSpec>,
258
259 #[serde(default)]
261 pub capabilities: Vec<String>,
262
263 #[serde(default)]
265 pub privileged: bool,
266
267 #[serde(default)]
269 pub node_mode: NodeMode,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub node_selector: Option<NodeSelector>,
274
275 #[serde(default)]
277 pub service_type: ServiceType,
278
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub wasm_http: Option<WasmHttpConfig>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
286#[serde(deny_unknown_fields)]
287pub struct CommandSpec {
288 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub entrypoint: Option<Vec<String>>,
291
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub args: Option<Vec<String>>,
295
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub workdir: Option<String>,
299}
300
301fn default_resource_type() -> ResourceType {
302 ResourceType::Service
303}
304
305fn default_health() -> HealthSpec {
306 HealthSpec {
307 start_grace: None,
308 interval: None,
309 timeout: None,
310 retries: 3,
311 check: HealthCheck::Tcp { port: 0 },
312 }
313}
314
315#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
317#[serde(rename_all = "lowercase")]
318pub enum ResourceType {
319 Service,
321 Job,
323 Cron,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
329#[serde(deny_unknown_fields)]
330pub struct ImageSpec {
331 #[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
333 pub name: String,
334
335 #[serde(default = "default_pull_policy")]
337 pub pull_policy: PullPolicy,
338}
339
340fn default_pull_policy() -> PullPolicy {
341 PullPolicy::IfNotPresent
342}
343
344#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
346#[serde(rename_all = "snake_case")]
347pub enum PullPolicy {
348 Always,
350 IfNotPresent,
352 Never,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
358#[serde(deny_unknown_fields)]
359pub struct DeviceSpec {
360 #[validate(length(min = 1, message = "device path cannot be empty"))]
362 pub path: String,
363
364 #[serde(default = "default_true")]
366 pub read: bool,
367
368 #[serde(default = "default_true")]
370 pub write: bool,
371
372 #[serde(default)]
374 pub mknod: bool,
375}
376
377fn default_true() -> bool {
378 true
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
383#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
384pub enum StorageSpec {
385 Bind {
387 source: String,
388 target: String,
389 #[serde(default)]
390 readonly: bool,
391 },
392 Named {
394 name: String,
395 target: String,
396 #[serde(default)]
397 readonly: bool,
398 #[serde(default)]
400 tier: StorageTier,
401 },
402 Anonymous {
404 target: String,
405 #[serde(default)]
407 tier: StorageTier,
408 },
409 Tmpfs {
411 target: String,
412 #[serde(default)]
413 size: Option<String>,
414 #[serde(default)]
415 mode: Option<u32>,
416 },
417 S3 {
419 bucket: String,
420 #[serde(default)]
421 prefix: Option<String>,
422 target: String,
423 #[serde(default)]
424 readonly: bool,
425 #[serde(default)]
426 endpoint: Option<String>,
427 #[serde(default)]
428 credentials: Option<String>,
429 },
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
434#[serde(deny_unknown_fields)]
435pub struct ResourcesSpec {
436 #[serde(default)]
438 #[validate(custom(function = "crate::validate::validate_cpu_option_wrapper"))]
439 pub cpu: Option<f64>,
440
441 #[serde(default)]
443 #[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
444 pub memory: Option<String>,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
449#[serde(deny_unknown_fields)]
450#[derive(Default)]
451pub struct NetworkSpec {
452 #[serde(default)]
454 pub overlays: OverlayConfig,
455
456 #[serde(default)]
458 pub join: JoinPolicy,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463#[serde(deny_unknown_fields)]
464pub struct OverlayConfig {
465 #[serde(default)]
467 pub service: OverlaySettings,
468
469 #[serde(default)]
471 pub global: OverlaySettings,
472}
473
474impl Default for OverlayConfig {
475 fn default() -> Self {
476 Self {
477 service: OverlaySettings {
478 enabled: true,
479 encrypted: true,
480 isolated: true,
481 },
482 global: OverlaySettings {
483 enabled: true,
484 encrypted: true,
485 isolated: false,
486 },
487 }
488 }
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
493#[serde(deny_unknown_fields)]
494pub struct OverlaySettings {
495 #[serde(default = "default_enabled")]
497 pub enabled: bool,
498
499 #[serde(default = "default_encrypted")]
501 pub encrypted: bool,
502
503 #[serde(default)]
505 pub isolated: bool,
506}
507
508fn default_enabled() -> bool {
509 true
510}
511
512fn default_encrypted() -> bool {
513 true
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
518#[serde(deny_unknown_fields)]
519pub struct JoinPolicy {
520 #[serde(default = "default_join_mode")]
522 pub mode: JoinMode,
523
524 #[serde(default = "default_join_scope")]
526 pub scope: JoinScope,
527}
528
529impl Default for JoinPolicy {
530 fn default() -> Self {
531 Self {
532 mode: default_join_mode(),
533 scope: default_join_scope(),
534 }
535 }
536}
537
538fn default_join_mode() -> JoinMode {
539 JoinMode::Token
540}
541
542fn default_join_scope() -> JoinScope {
543 JoinScope::Service
544}
545
546#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
548#[serde(rename_all = "snake_case")]
549pub enum JoinMode {
550 Open,
552 Token,
554 Closed,
556}
557
558#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
560#[serde(rename_all = "snake_case")]
561pub enum JoinScope {
562 Service,
564 Global,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
570#[serde(deny_unknown_fields)]
571pub struct EndpointSpec {
572 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
574 pub name: String,
575
576 pub protocol: Protocol,
578
579 #[validate(custom(function = "crate::validate::validate_port_wrapper"))]
581 pub port: u16,
582
583 pub path: Option<String>,
585
586 #[serde(default = "default_expose")]
588 pub expose: ExposeType,
589}
590
591fn default_expose() -> ExposeType {
592 ExposeType::Internal
593}
594
595#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
597#[serde(rename_all = "lowercase")]
598pub enum Protocol {
599 Http,
600 Https,
601 Tcp,
602 Udp,
603 Websocket,
604}
605
606#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
608#[serde(rename_all = "lowercase")]
609pub enum ExposeType {
610 Public,
611 Internal,
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
616#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
617pub enum ScaleSpec {
618 #[serde(rename = "adaptive")]
620 Adaptive {
621 min: u32,
623
624 max: u32,
626
627 #[serde(default, with = "duration::option")]
629 cooldown: Option<std::time::Duration>,
630
631 #[serde(default)]
633 targets: ScaleTargets,
634 },
635
636 #[serde(rename = "fixed")]
638 Fixed { replicas: u32 },
639
640 #[serde(rename = "manual")]
642 Manual,
643}
644
645impl Default for ScaleSpec {
646 fn default() -> Self {
647 Self::Adaptive {
648 min: 1,
649 max: 10,
650 cooldown: Some(std::time::Duration::from_secs(30)),
651 targets: Default::default(),
652 }
653 }
654}
655
656#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
658#[serde(deny_unknown_fields)]
659#[derive(Default)]
660pub struct ScaleTargets {
661 #[serde(default)]
663 pub cpu: Option<u8>,
664
665 #[serde(default)]
667 pub memory: Option<u8>,
668
669 #[serde(default)]
671 pub rps: Option<u32>,
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
676#[serde(deny_unknown_fields)]
677pub struct DependsSpec {
678 pub service: String,
680
681 #[serde(default = "default_condition")]
683 pub condition: DependencyCondition,
684
685 #[serde(default = "default_timeout", with = "duration::option")]
687 pub timeout: Option<std::time::Duration>,
688
689 #[serde(default = "default_on_timeout")]
691 pub on_timeout: TimeoutAction,
692}
693
694fn default_condition() -> DependencyCondition {
695 DependencyCondition::Healthy
696}
697
698fn default_timeout() -> Option<std::time::Duration> {
699 Some(std::time::Duration::from_secs(300))
700}
701
702fn default_on_timeout() -> TimeoutAction {
703 TimeoutAction::Fail
704}
705
706#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
708#[serde(rename_all = "lowercase")]
709pub enum DependencyCondition {
710 Started,
712 Healthy,
714 Ready,
716}
717
718#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
720#[serde(rename_all = "lowercase")]
721pub enum TimeoutAction {
722 Fail,
723 Warn,
724 Continue,
725}
726
727#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
729#[serde(deny_unknown_fields)]
730pub struct HealthSpec {
731 #[serde(default, with = "duration::option")]
733 pub start_grace: Option<std::time::Duration>,
734
735 #[serde(default, with = "duration::option")]
737 pub interval: Option<std::time::Duration>,
738
739 #[serde(default, with = "duration::option")]
741 pub timeout: Option<std::time::Duration>,
742
743 #[serde(default = "default_retries")]
745 pub retries: u32,
746
747 pub check: HealthCheck,
749}
750
751fn default_retries() -> u32 {
752 3
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
757#[serde(tag = "type", rename_all = "lowercase")]
758pub enum HealthCheck {
759 Tcp {
761 port: u16,
763 },
764
765 Http {
767 url: String,
769 #[serde(default = "default_expect_status")]
771 expect_status: u16,
772 },
773
774 Command {
776 command: String,
778 },
779}
780
781fn default_expect_status() -> u16 {
782 200
783}
784
785#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
787#[serde(deny_unknown_fields)]
788#[derive(Default)]
789pub struct InitSpec {
790 #[serde(default)]
792 pub steps: Vec<InitStep>,
793}
794
795#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
797#[serde(deny_unknown_fields)]
798pub struct InitStep {
799 pub id: String,
801
802 pub uses: String,
804
805 #[serde(default)]
807 pub with: InitParams,
808
809 #[serde(default)]
811 pub retry: Option<u32>,
812
813 #[serde(default, with = "duration::option")]
815 pub timeout: Option<std::time::Duration>,
816
817 #[serde(default = "default_on_failure")]
819 pub on_failure: FailureAction,
820}
821
822fn default_on_failure() -> FailureAction {
823 FailureAction::Fail
824}
825
826pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
828
829#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
831#[serde(rename_all = "lowercase")]
832pub enum FailureAction {
833 Fail,
834 Warn,
835 Continue,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
840#[serde(deny_unknown_fields)]
841#[derive(Default)]
842pub struct ErrorsSpec {
843 #[serde(default)]
845 pub on_init_failure: InitFailurePolicy,
846
847 #[serde(default)]
849 pub on_panic: PanicPolicy,
850}
851
852#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
854#[serde(deny_unknown_fields)]
855pub struct InitFailurePolicy {
856 #[serde(default = "default_init_action")]
857 pub action: InitFailureAction,
858}
859
860impl Default for InitFailurePolicy {
861 fn default() -> Self {
862 Self {
863 action: default_init_action(),
864 }
865 }
866}
867
868fn default_init_action() -> InitFailureAction {
869 InitFailureAction::Fail
870}
871
872#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
874#[serde(rename_all = "lowercase")]
875pub enum InitFailureAction {
876 Fail,
877 Restart,
878 Backoff,
879}
880
881#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
883#[serde(deny_unknown_fields)]
884pub struct PanicPolicy {
885 #[serde(default = "default_panic_action")]
886 pub action: PanicAction,
887}
888
889impl Default for PanicPolicy {
890 fn default() -> Self {
891 Self {
892 action: default_panic_action(),
893 }
894 }
895}
896
897fn default_panic_action() -> PanicAction {
898 PanicAction::Restart
899}
900
901#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
903#[serde(rename_all = "lowercase")]
904pub enum PanicAction {
905 Restart,
906 Shutdown,
907 Isolate,
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913
914 #[test]
915 fn test_parse_simple_spec() {
916 let yaml = r#"
917version: v1
918deployment: test
919services:
920 hello:
921 rtype: service
922 image:
923 name: hello-world:latest
924 endpoints:
925 - name: http
926 protocol: http
927 port: 8080
928 expose: public
929"#;
930
931 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
932 assert_eq!(spec.version, "v1");
933 assert_eq!(spec.deployment, "test");
934 assert!(spec.services.contains_key("hello"));
935 }
936
937 #[test]
938 fn test_parse_duration() {
939 let yaml = r#"
940version: v1
941deployment: test
942services:
943 test:
944 rtype: service
945 image:
946 name: test:latest
947 health:
948 timeout: 30s
949 interval: 1m
950 start_grace: 5s
951 check:
952 type: tcp
953 port: 8080
954"#;
955
956 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
957 let health = &spec.services["test"].health;
958 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
959 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
960 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
961 match &health.check {
962 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
963 _ => panic!("Expected TCP health check"),
964 }
965 }
966
967 #[test]
968 fn test_parse_adaptive_scale() {
969 let yaml = r#"
970version: v1
971deployment: test
972services:
973 test:
974 rtype: service
975 image:
976 name: test:latest
977 scale:
978 mode: adaptive
979 min: 2
980 max: 10
981 cooldown: 15s
982 targets:
983 cpu: 70
984 rps: 800
985"#;
986
987 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
988 let scale = &spec.services["test"].scale;
989 match scale {
990 ScaleSpec::Adaptive {
991 min,
992 max,
993 cooldown,
994 targets,
995 } => {
996 assert_eq!(*min, 2);
997 assert_eq!(*max, 10);
998 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
999 assert_eq!(targets.cpu, Some(70));
1000 assert_eq!(targets.rps, Some(800));
1001 }
1002 _ => panic!("Expected Adaptive scale mode"),
1003 }
1004 }
1005
1006 #[test]
1007 fn test_node_mode_default() {
1008 let yaml = r#"
1009version: v1
1010deployment: test
1011services:
1012 hello:
1013 rtype: service
1014 image:
1015 name: hello-world:latest
1016"#;
1017
1018 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1019 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
1020 assert!(spec.services["hello"].node_selector.is_none());
1021 }
1022
1023 #[test]
1024 fn test_node_mode_dedicated() {
1025 let yaml = r#"
1026version: v1
1027deployment: test
1028services:
1029 api:
1030 rtype: service
1031 image:
1032 name: api:latest
1033 node_mode: dedicated
1034"#;
1035
1036 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1037 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1038 }
1039
1040 #[test]
1041 fn test_node_mode_exclusive() {
1042 let yaml = r#"
1043version: v1
1044deployment: test
1045services:
1046 database:
1047 rtype: service
1048 image:
1049 name: postgres:15
1050 node_mode: exclusive
1051"#;
1052
1053 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1054 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1055 }
1056
1057 #[test]
1058 fn test_node_selector_with_labels() {
1059 let yaml = r#"
1060version: v1
1061deployment: test
1062services:
1063 ml-worker:
1064 rtype: service
1065 image:
1066 name: ml-worker:latest
1067 node_mode: dedicated
1068 node_selector:
1069 labels:
1070 gpu: "true"
1071 zone: us-east
1072 prefer_labels:
1073 storage: ssd
1074"#;
1075
1076 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1077 let service = &spec.services["ml-worker"];
1078 assert_eq!(service.node_mode, NodeMode::Dedicated);
1079
1080 let selector = service.node_selector.as_ref().unwrap();
1081 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
1082 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
1083 assert_eq!(
1084 selector.prefer_labels.get("storage"),
1085 Some(&"ssd".to_string())
1086 );
1087 }
1088
1089 #[test]
1090 fn test_node_mode_serialization_roundtrip() {
1091 use serde_json;
1092
1093 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
1095 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
1096
1097 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
1098 let json = serde_json::to_string(mode).unwrap();
1099 assert_eq!(&json, *expected, "Serialization failed for {:?}", mode);
1100
1101 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
1102 assert_eq!(deserialized, *mode, "Roundtrip failed for {:?}", mode);
1103 }
1104 }
1105
1106 #[test]
1107 fn test_node_selector_empty() {
1108 let yaml = r#"
1109version: v1
1110deployment: test
1111services:
1112 api:
1113 rtype: service
1114 image:
1115 name: api:latest
1116 node_selector:
1117 labels: {}
1118"#;
1119
1120 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1121 let selector = spec.services["api"].node_selector.as_ref().unwrap();
1122 assert!(selector.labels.is_empty());
1123 assert!(selector.prefer_labels.is_empty());
1124 }
1125
1126 #[test]
1127 fn test_mixed_node_modes_in_deployment() {
1128 let yaml = r#"
1129version: v1
1130deployment: test
1131services:
1132 redis:
1133 rtype: service
1134 image:
1135 name: redis:alpine
1136 # Default shared mode
1137 api:
1138 rtype: service
1139 image:
1140 name: api:latest
1141 node_mode: dedicated
1142 database:
1143 rtype: service
1144 image:
1145 name: postgres:15
1146 node_mode: exclusive
1147 node_selector:
1148 labels:
1149 storage: ssd
1150"#;
1151
1152 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1153 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
1154 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1155 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1156
1157 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
1158 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
1159 }
1160
1161 #[test]
1162 fn test_storage_bind_mount() {
1163 let yaml = r#"
1164version: v1
1165deployment: test
1166services:
1167 app:
1168 image:
1169 name: app:latest
1170 storage:
1171 - type: bind
1172 source: /host/data
1173 target: /app/data
1174 readonly: true
1175"#;
1176 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1177 let storage = &spec.services["app"].storage;
1178 assert_eq!(storage.len(), 1);
1179 match &storage[0] {
1180 StorageSpec::Bind {
1181 source,
1182 target,
1183 readonly,
1184 } => {
1185 assert_eq!(source, "/host/data");
1186 assert_eq!(target, "/app/data");
1187 assert!(*readonly);
1188 }
1189 _ => panic!("Expected Bind storage"),
1190 }
1191 }
1192
1193 #[test]
1194 fn test_storage_named_with_tier() {
1195 let yaml = r#"
1196version: v1
1197deployment: test
1198services:
1199 app:
1200 image:
1201 name: app:latest
1202 storage:
1203 - type: named
1204 name: my-data
1205 target: /app/data
1206 tier: cached
1207"#;
1208 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1209 let storage = &spec.services["app"].storage;
1210 match &storage[0] {
1211 StorageSpec::Named {
1212 name, target, tier, ..
1213 } => {
1214 assert_eq!(name, "my-data");
1215 assert_eq!(target, "/app/data");
1216 assert_eq!(*tier, StorageTier::Cached);
1217 }
1218 _ => panic!("Expected Named storage"),
1219 }
1220 }
1221
1222 #[test]
1223 fn test_storage_anonymous() {
1224 let yaml = r#"
1225version: v1
1226deployment: test
1227services:
1228 app:
1229 image:
1230 name: app:latest
1231 storage:
1232 - type: anonymous
1233 target: /app/cache
1234"#;
1235 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1236 let storage = &spec.services["app"].storage;
1237 match &storage[0] {
1238 StorageSpec::Anonymous { target, tier } => {
1239 assert_eq!(target, "/app/cache");
1240 assert_eq!(*tier, StorageTier::Local); }
1242 _ => panic!("Expected Anonymous storage"),
1243 }
1244 }
1245
1246 #[test]
1247 fn test_storage_tmpfs() {
1248 let yaml = r#"
1249version: v1
1250deployment: test
1251services:
1252 app:
1253 image:
1254 name: app:latest
1255 storage:
1256 - type: tmpfs
1257 target: /app/tmp
1258 size: 256Mi
1259 mode: 1777
1260"#;
1261 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1262 let storage = &spec.services["app"].storage;
1263 match &storage[0] {
1264 StorageSpec::Tmpfs { target, size, mode } => {
1265 assert_eq!(target, "/app/tmp");
1266 assert_eq!(size.as_deref(), Some("256Mi"));
1267 assert_eq!(*mode, Some(1777));
1268 }
1269 _ => panic!("Expected Tmpfs storage"),
1270 }
1271 }
1272
1273 #[test]
1274 fn test_storage_s3() {
1275 let yaml = r#"
1276version: v1
1277deployment: test
1278services:
1279 app:
1280 image:
1281 name: app:latest
1282 storage:
1283 - type: s3
1284 bucket: my-bucket
1285 prefix: models/
1286 target: /app/models
1287 readonly: true
1288 endpoint: https://s3.us-west-2.amazonaws.com
1289 credentials: aws-creds
1290"#;
1291 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1292 let storage = &spec.services["app"].storage;
1293 match &storage[0] {
1294 StorageSpec::S3 {
1295 bucket,
1296 prefix,
1297 target,
1298 readonly,
1299 endpoint,
1300 credentials,
1301 } => {
1302 assert_eq!(bucket, "my-bucket");
1303 assert_eq!(prefix.as_deref(), Some("models/"));
1304 assert_eq!(target, "/app/models");
1305 assert!(*readonly);
1306 assert_eq!(
1307 endpoint.as_deref(),
1308 Some("https://s3.us-west-2.amazonaws.com")
1309 );
1310 assert_eq!(credentials.as_deref(), Some("aws-creds"));
1311 }
1312 _ => panic!("Expected S3 storage"),
1313 }
1314 }
1315
1316 #[test]
1317 fn test_storage_multiple_types() {
1318 let yaml = r#"
1319version: v1
1320deployment: test
1321services:
1322 app:
1323 image:
1324 name: app:latest
1325 storage:
1326 - type: bind
1327 source: /etc/config
1328 target: /app/config
1329 readonly: true
1330 - type: named
1331 name: app-data
1332 target: /app/data
1333 - type: tmpfs
1334 target: /app/tmp
1335"#;
1336 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1337 let storage = &spec.services["app"].storage;
1338 assert_eq!(storage.len(), 3);
1339 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
1340 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
1341 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
1342 }
1343
1344 #[test]
1345 fn test_storage_tier_default() {
1346 let yaml = r#"
1347version: v1
1348deployment: test
1349services:
1350 app:
1351 image:
1352 name: app:latest
1353 storage:
1354 - type: named
1355 name: data
1356 target: /data
1357"#;
1358 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1359 match &spec.services["app"].storage[0] {
1360 StorageSpec::Named { tier, .. } => {
1361 assert_eq!(*tier, StorageTier::Local); }
1363 _ => panic!("Expected Named storage"),
1364 }
1365 }
1366}