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
163fn default_api_bind() -> String {
164 "0.0.0.0:3669".to_string()
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169pub struct ApiSpec {
170 #[serde(default = "default_true")]
172 pub enabled: bool,
173 #[serde(default = "default_api_bind")]
175 pub bind: String,
176 #[serde(default)]
178 pub jwt_secret: Option<String>,
179 #[serde(default = "default_true")]
181 pub swagger: bool,
182}
183
184impl Default for ApiSpec {
185 fn default() -> Self {
186 Self {
187 enabled: true,
188 bind: default_api_bind(),
189 jwt_secret: None,
190 swagger: true,
191 }
192 }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
197#[serde(deny_unknown_fields)]
198pub struct DeploymentSpec {
199 #[validate(custom(function = "crate::validate::validate_version_wrapper"))]
201 pub version: String,
202
203 #[validate(custom(function = "crate::validate::validate_deployment_name_wrapper"))]
205 pub deployment: String,
206
207 #[serde(default)]
209 #[validate(nested)]
210 pub services: HashMap<String, ServiceSpec>,
211
212 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
214 pub tunnels: HashMap<String, TunnelDefinition>,
215
216 #[serde(default)]
218 pub api: ApiSpec,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[serde(deny_unknown_fields)]
224pub struct TunnelDefinition {
225 pub from: String,
227
228 pub to: String,
230
231 pub local_port: u16,
233
234 pub remote_port: u16,
236
237 #[serde(default)]
239 pub protocol: TunnelProtocol,
240
241 #[serde(default)]
243 pub expose: ExposeType,
244}
245
246#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
248#[serde(rename_all = "lowercase")]
249pub enum TunnelProtocol {
250 #[default]
251 Tcp,
252 Udp,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
257#[serde(deny_unknown_fields)]
258pub struct ServiceSpec {
259 #[serde(default = "default_resource_type")]
261 pub rtype: ResourceType,
262
263 #[serde(default, skip_serializing_if = "Option::is_none")]
270 #[validate(custom(function = "crate::validate::validate_schedule_wrapper"))]
271 pub schedule: Option<String>,
272
273 #[validate(nested)]
275 pub image: ImageSpec,
276
277 #[serde(default)]
279 #[validate(nested)]
280 pub resources: ResourcesSpec,
281
282 #[serde(default)]
289 pub env: HashMap<String, String>,
290
291 #[serde(default)]
293 pub command: CommandSpec,
294
295 #[serde(default)]
297 pub network: NetworkSpec,
298
299 #[serde(default)]
301 #[validate(nested)]
302 pub endpoints: Vec<EndpointSpec>,
303
304 #[serde(default)]
306 #[validate(custom(function = "crate::validate::validate_scale_spec"))]
307 pub scale: ScaleSpec,
308
309 #[serde(default)]
311 pub depends: Vec<DependsSpec>,
312
313 #[serde(default = "default_health")]
315 pub health: HealthSpec,
316
317 #[serde(default)]
319 pub init: InitSpec,
320
321 #[serde(default)]
323 pub errors: ErrorsSpec,
324
325 #[serde(default)]
327 pub devices: Vec<DeviceSpec>,
328
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
331 pub storage: Vec<StorageSpec>,
332
333 #[serde(default)]
335 pub capabilities: Vec<String>,
336
337 #[serde(default)]
339 pub privileged: bool,
340
341 #[serde(default)]
343 pub node_mode: NodeMode,
344
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub node_selector: Option<NodeSelector>,
348
349 #[serde(default)]
351 pub service_type: ServiceType,
352
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub wasm_http: Option<WasmHttpConfig>,
356
357 #[serde(skip)]
362 pub host_network: bool,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
367#[serde(deny_unknown_fields)]
368pub struct CommandSpec {
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub entrypoint: Option<Vec<String>>,
372
373 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub args: Option<Vec<String>>,
376
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub workdir: Option<String>,
380}
381
382fn default_resource_type() -> ResourceType {
383 ResourceType::Service
384}
385
386fn default_health() -> HealthSpec {
387 HealthSpec {
388 start_grace: None,
389 interval: None,
390 timeout: None,
391 retries: 3,
392 check: HealthCheck::Tcp { port: 0 },
393 }
394}
395
396#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
398#[serde(rename_all = "lowercase")]
399pub enum ResourceType {
400 Service,
402 Job,
404 Cron,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
410#[serde(deny_unknown_fields)]
411pub struct ImageSpec {
412 #[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
414 pub name: String,
415
416 #[serde(default = "default_pull_policy")]
418 pub pull_policy: PullPolicy,
419}
420
421fn default_pull_policy() -> PullPolicy {
422 PullPolicy::IfNotPresent
423}
424
425#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
427#[serde(rename_all = "snake_case")]
428pub enum PullPolicy {
429 Always,
431 IfNotPresent,
433 Never,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
439#[serde(deny_unknown_fields)]
440pub struct DeviceSpec {
441 #[validate(length(min = 1, message = "device path cannot be empty"))]
443 pub path: String,
444
445 #[serde(default = "default_true")]
447 pub read: bool,
448
449 #[serde(default = "default_true")]
451 pub write: bool,
452
453 #[serde(default)]
455 pub mknod: bool,
456}
457
458fn default_true() -> bool {
459 true
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
464#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
465pub enum StorageSpec {
466 Bind {
468 source: String,
469 target: String,
470 #[serde(default)]
471 readonly: bool,
472 },
473 Named {
475 name: String,
476 target: String,
477 #[serde(default)]
478 readonly: bool,
479 #[serde(default)]
481 tier: StorageTier,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
484 size: Option<String>,
485 },
486 Anonymous {
488 target: String,
489 #[serde(default)]
491 tier: StorageTier,
492 },
493 Tmpfs {
495 target: String,
496 #[serde(default)]
497 size: Option<String>,
498 #[serde(default)]
499 mode: Option<u32>,
500 },
501 S3 {
503 bucket: String,
504 #[serde(default)]
505 prefix: Option<String>,
506 target: String,
507 #[serde(default)]
508 readonly: bool,
509 #[serde(default)]
510 endpoint: Option<String>,
511 #[serde(default)]
512 credentials: Option<String>,
513 },
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
518#[serde(deny_unknown_fields)]
519pub struct ResourcesSpec {
520 #[serde(default)]
522 #[validate(custom(function = "crate::validate::validate_cpu_option_wrapper"))]
523 pub cpu: Option<f64>,
524
525 #[serde(default)]
527 #[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
528 pub memory: Option<String>,
529
530 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub gpu: Option<GpuSpec>,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
554#[serde(deny_unknown_fields)]
555pub struct GpuSpec {
556 #[serde(default = "default_gpu_count")]
558 pub count: u32,
559 #[serde(default = "default_gpu_vendor")]
561 pub vendor: String,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub mode: Option<String>,
565}
566
567fn default_gpu_count() -> u32 {
568 1
569}
570
571fn default_gpu_vendor() -> String {
572 "nvidia".to_string()
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
577#[serde(deny_unknown_fields)]
578#[derive(Default)]
579pub struct NetworkSpec {
580 #[serde(default)]
582 pub overlays: OverlayConfig,
583
584 #[serde(default)]
586 pub join: JoinPolicy,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
591#[serde(deny_unknown_fields)]
592pub struct OverlayConfig {
593 #[serde(default)]
595 pub service: OverlaySettings,
596
597 #[serde(default)]
599 pub global: OverlaySettings,
600}
601
602impl Default for OverlayConfig {
603 fn default() -> Self {
604 Self {
605 service: OverlaySettings {
606 enabled: true,
607 encrypted: true,
608 isolated: true,
609 },
610 global: OverlaySettings {
611 enabled: true,
612 encrypted: true,
613 isolated: false,
614 },
615 }
616 }
617}
618
619#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
621#[serde(deny_unknown_fields)]
622pub struct OverlaySettings {
623 #[serde(default = "default_enabled")]
625 pub enabled: bool,
626
627 #[serde(default = "default_encrypted")]
629 pub encrypted: bool,
630
631 #[serde(default)]
633 pub isolated: bool,
634}
635
636fn default_enabled() -> bool {
637 true
638}
639
640fn default_encrypted() -> bool {
641 true
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
646#[serde(deny_unknown_fields)]
647pub struct JoinPolicy {
648 #[serde(default = "default_join_mode")]
650 pub mode: JoinMode,
651
652 #[serde(default = "default_join_scope")]
654 pub scope: JoinScope,
655}
656
657impl Default for JoinPolicy {
658 fn default() -> Self {
659 Self {
660 mode: default_join_mode(),
661 scope: default_join_scope(),
662 }
663 }
664}
665
666fn default_join_mode() -> JoinMode {
667 JoinMode::Token
668}
669
670fn default_join_scope() -> JoinScope {
671 JoinScope::Service
672}
673
674#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
676#[serde(rename_all = "snake_case")]
677pub enum JoinMode {
678 Open,
680 Token,
682 Closed,
684}
685
686#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
688#[serde(rename_all = "snake_case")]
689pub enum JoinScope {
690 Service,
692 Global,
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
698#[serde(deny_unknown_fields)]
699pub struct EndpointSpec {
700 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
702 pub name: String,
703
704 pub protocol: Protocol,
706
707 #[validate(custom(function = "crate::validate::validate_port_wrapper"))]
709 pub port: u16,
710
711 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub target_port: Option<u16>,
715
716 pub path: Option<String>,
718
719 #[serde(default = "default_expose")]
721 pub expose: ExposeType,
722
723 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub stream: Option<StreamEndpointConfig>,
727
728 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub tunnel: Option<EndpointTunnelConfig>,
731}
732
733impl EndpointSpec {
734 pub fn target_port(&self) -> u16 {
737 self.target_port.unwrap_or(self.port)
738 }
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
743#[serde(deny_unknown_fields)]
744pub struct EndpointTunnelConfig {
745 #[serde(default)]
747 pub enabled: bool,
748
749 #[serde(default, skip_serializing_if = "Option::is_none")]
751 pub from: Option<String>,
752
753 #[serde(default, skip_serializing_if = "Option::is_none")]
755 pub to: Option<String>,
756
757 #[serde(default)]
759 pub remote_port: u16,
760
761 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub expose: Option<ExposeType>,
764
765 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub access: Option<TunnelAccessConfig>,
768}
769
770#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
772#[serde(deny_unknown_fields)]
773pub struct TunnelAccessConfig {
774 #[serde(default)]
776 pub enabled: bool,
777
778 #[serde(default, skip_serializing_if = "Option::is_none")]
780 pub max_ttl: Option<String>,
781
782 #[serde(default)]
784 pub audit: bool,
785}
786
787fn default_expose() -> ExposeType {
788 ExposeType::Internal
789}
790
791#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
793#[serde(rename_all = "lowercase")]
794pub enum Protocol {
795 Http,
796 Https,
797 Tcp,
798 Udp,
799 Websocket,
800}
801
802#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
804#[serde(rename_all = "lowercase")]
805pub enum ExposeType {
806 Public,
807 #[default]
808 Internal,
809}
810
811#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
813#[serde(deny_unknown_fields)]
814pub struct StreamEndpointConfig {
815 #[serde(default)]
817 pub tls: bool,
818
819 #[serde(default)]
821 pub proxy_protocol: bool,
822
823 #[serde(default, skip_serializing_if = "Option::is_none")]
826 pub session_timeout: Option<String>,
827
828 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub health_check: Option<StreamHealthCheck>,
831}
832
833#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
835#[serde(tag = "type", rename_all = "snake_case")]
836pub enum StreamHealthCheck {
837 TcpConnect,
839 UdpProbe {
841 request: String,
843 #[serde(default, skip_serializing_if = "Option::is_none")]
845 expect: Option<String>,
846 },
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
851#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
852pub enum ScaleSpec {
853 #[serde(rename = "adaptive")]
855 Adaptive {
856 min: u32,
858
859 max: u32,
861
862 #[serde(default, with = "duration::option")]
864 cooldown: Option<std::time::Duration>,
865
866 #[serde(default)]
868 targets: ScaleTargets,
869 },
870
871 #[serde(rename = "fixed")]
873 Fixed { replicas: u32 },
874
875 #[serde(rename = "manual")]
877 Manual,
878}
879
880impl Default for ScaleSpec {
881 fn default() -> Self {
882 Self::Adaptive {
883 min: 1,
884 max: 10,
885 cooldown: Some(std::time::Duration::from_secs(30)),
886 targets: Default::default(),
887 }
888 }
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
893#[serde(deny_unknown_fields)]
894#[derive(Default)]
895pub struct ScaleTargets {
896 #[serde(default)]
898 pub cpu: Option<u8>,
899
900 #[serde(default)]
902 pub memory: Option<u8>,
903
904 #[serde(default)]
906 pub rps: Option<u32>,
907}
908
909#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
911#[serde(deny_unknown_fields)]
912pub struct DependsSpec {
913 pub service: String,
915
916 #[serde(default = "default_condition")]
918 pub condition: DependencyCondition,
919
920 #[serde(default = "default_timeout", with = "duration::option")]
922 pub timeout: Option<std::time::Duration>,
923
924 #[serde(default = "default_on_timeout")]
926 pub on_timeout: TimeoutAction,
927}
928
929fn default_condition() -> DependencyCondition {
930 DependencyCondition::Healthy
931}
932
933fn default_timeout() -> Option<std::time::Duration> {
934 Some(std::time::Duration::from_secs(300))
935}
936
937fn default_on_timeout() -> TimeoutAction {
938 TimeoutAction::Fail
939}
940
941#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
943#[serde(rename_all = "lowercase")]
944pub enum DependencyCondition {
945 Started,
947 Healthy,
949 Ready,
951}
952
953#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
955#[serde(rename_all = "lowercase")]
956pub enum TimeoutAction {
957 Fail,
958 Warn,
959 Continue,
960}
961
962#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
964#[serde(deny_unknown_fields)]
965pub struct HealthSpec {
966 #[serde(default, with = "duration::option")]
968 pub start_grace: Option<std::time::Duration>,
969
970 #[serde(default, with = "duration::option")]
972 pub interval: Option<std::time::Duration>,
973
974 #[serde(default, with = "duration::option")]
976 pub timeout: Option<std::time::Duration>,
977
978 #[serde(default = "default_retries")]
980 pub retries: u32,
981
982 pub check: HealthCheck,
984}
985
986fn default_retries() -> u32 {
987 3
988}
989
990#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
992#[serde(tag = "type", rename_all = "lowercase")]
993pub enum HealthCheck {
994 Tcp {
996 port: u16,
998 },
999
1000 Http {
1002 url: String,
1004 #[serde(default = "default_expect_status")]
1006 expect_status: u16,
1007 },
1008
1009 Command {
1011 command: String,
1013 },
1014}
1015
1016fn default_expect_status() -> u16 {
1017 200
1018}
1019
1020#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1022#[serde(deny_unknown_fields)]
1023#[derive(Default)]
1024pub struct InitSpec {
1025 #[serde(default)]
1027 pub steps: Vec<InitStep>,
1028}
1029
1030#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1032#[serde(deny_unknown_fields)]
1033pub struct InitStep {
1034 pub id: String,
1036
1037 pub uses: String,
1039
1040 #[serde(default)]
1042 pub with: InitParams,
1043
1044 #[serde(default)]
1046 pub retry: Option<u32>,
1047
1048 #[serde(default, with = "duration::option")]
1050 pub timeout: Option<std::time::Duration>,
1051
1052 #[serde(default = "default_on_failure")]
1054 pub on_failure: FailureAction,
1055}
1056
1057fn default_on_failure() -> FailureAction {
1058 FailureAction::Fail
1059}
1060
1061pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
1063
1064#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1066#[serde(rename_all = "lowercase")]
1067pub enum FailureAction {
1068 Fail,
1069 Warn,
1070 Continue,
1071}
1072
1073#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1075#[serde(deny_unknown_fields)]
1076#[derive(Default)]
1077pub struct ErrorsSpec {
1078 #[serde(default)]
1080 pub on_init_failure: InitFailurePolicy,
1081
1082 #[serde(default)]
1084 pub on_panic: PanicPolicy,
1085}
1086
1087#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1089#[serde(deny_unknown_fields)]
1090pub struct InitFailurePolicy {
1091 #[serde(default = "default_init_action")]
1092 pub action: InitFailureAction,
1093}
1094
1095impl Default for InitFailurePolicy {
1096 fn default() -> Self {
1097 Self {
1098 action: default_init_action(),
1099 }
1100 }
1101}
1102
1103fn default_init_action() -> InitFailureAction {
1104 InitFailureAction::Fail
1105}
1106
1107#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1109#[serde(rename_all = "lowercase")]
1110pub enum InitFailureAction {
1111 Fail,
1112 Restart,
1113 Backoff,
1114}
1115
1116#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1118#[serde(deny_unknown_fields)]
1119pub struct PanicPolicy {
1120 #[serde(default = "default_panic_action")]
1121 pub action: PanicAction,
1122}
1123
1124impl Default for PanicPolicy {
1125 fn default() -> Self {
1126 Self {
1127 action: default_panic_action(),
1128 }
1129 }
1130}
1131
1132fn default_panic_action() -> PanicAction {
1133 PanicAction::Restart
1134}
1135
1136#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1138#[serde(rename_all = "lowercase")]
1139pub enum PanicAction {
1140 Restart,
1141 Shutdown,
1142 Isolate,
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148
1149 #[test]
1150 fn test_parse_simple_spec() {
1151 let yaml = r#"
1152version: v1
1153deployment: test
1154services:
1155 hello:
1156 rtype: service
1157 image:
1158 name: hello-world:latest
1159 endpoints:
1160 - name: http
1161 protocol: http
1162 port: 8080
1163 expose: public
1164"#;
1165
1166 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1167 assert_eq!(spec.version, "v1");
1168 assert_eq!(spec.deployment, "test");
1169 assert!(spec.services.contains_key("hello"));
1170 }
1171
1172 #[test]
1173 fn test_parse_duration() {
1174 let yaml = r#"
1175version: v1
1176deployment: test
1177services:
1178 test:
1179 rtype: service
1180 image:
1181 name: test:latest
1182 health:
1183 timeout: 30s
1184 interval: 1m
1185 start_grace: 5s
1186 check:
1187 type: tcp
1188 port: 8080
1189"#;
1190
1191 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1192 let health = &spec.services["test"].health;
1193 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
1194 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
1195 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
1196 match &health.check {
1197 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
1198 _ => panic!("Expected TCP health check"),
1199 }
1200 }
1201
1202 #[test]
1203 fn test_parse_adaptive_scale() {
1204 let yaml = r#"
1205version: v1
1206deployment: test
1207services:
1208 test:
1209 rtype: service
1210 image:
1211 name: test:latest
1212 scale:
1213 mode: adaptive
1214 min: 2
1215 max: 10
1216 cooldown: 15s
1217 targets:
1218 cpu: 70
1219 rps: 800
1220"#;
1221
1222 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1223 let scale = &spec.services["test"].scale;
1224 match scale {
1225 ScaleSpec::Adaptive {
1226 min,
1227 max,
1228 cooldown,
1229 targets,
1230 } => {
1231 assert_eq!(*min, 2);
1232 assert_eq!(*max, 10);
1233 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
1234 assert_eq!(targets.cpu, Some(70));
1235 assert_eq!(targets.rps, Some(800));
1236 }
1237 _ => panic!("Expected Adaptive scale mode"),
1238 }
1239 }
1240
1241 #[test]
1242 fn test_node_mode_default() {
1243 let yaml = r#"
1244version: v1
1245deployment: test
1246services:
1247 hello:
1248 rtype: service
1249 image:
1250 name: hello-world:latest
1251"#;
1252
1253 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1254 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
1255 assert!(spec.services["hello"].node_selector.is_none());
1256 }
1257
1258 #[test]
1259 fn test_node_mode_dedicated() {
1260 let yaml = r#"
1261version: v1
1262deployment: test
1263services:
1264 api:
1265 rtype: service
1266 image:
1267 name: api:latest
1268 node_mode: dedicated
1269"#;
1270
1271 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1272 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1273 }
1274
1275 #[test]
1276 fn test_node_mode_exclusive() {
1277 let yaml = r#"
1278version: v1
1279deployment: test
1280services:
1281 database:
1282 rtype: service
1283 image:
1284 name: postgres:15
1285 node_mode: exclusive
1286"#;
1287
1288 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1289 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1290 }
1291
1292 #[test]
1293 fn test_node_selector_with_labels() {
1294 let yaml = r#"
1295version: v1
1296deployment: test
1297services:
1298 ml-worker:
1299 rtype: service
1300 image:
1301 name: ml-worker:latest
1302 node_mode: dedicated
1303 node_selector:
1304 labels:
1305 gpu: "true"
1306 zone: us-east
1307 prefer_labels:
1308 storage: ssd
1309"#;
1310
1311 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1312 let service = &spec.services["ml-worker"];
1313 assert_eq!(service.node_mode, NodeMode::Dedicated);
1314
1315 let selector = service.node_selector.as_ref().unwrap();
1316 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
1317 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
1318 assert_eq!(
1319 selector.prefer_labels.get("storage"),
1320 Some(&"ssd".to_string())
1321 );
1322 }
1323
1324 #[test]
1325 fn test_node_mode_serialization_roundtrip() {
1326 use serde_json;
1327
1328 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
1330 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
1331
1332 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
1333 let json = serde_json::to_string(mode).unwrap();
1334 assert_eq!(&json, *expected, "Serialization failed for {:?}", mode);
1335
1336 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
1337 assert_eq!(deserialized, *mode, "Roundtrip failed for {:?}", mode);
1338 }
1339 }
1340
1341 #[test]
1342 fn test_node_selector_empty() {
1343 let yaml = r#"
1344version: v1
1345deployment: test
1346services:
1347 api:
1348 rtype: service
1349 image:
1350 name: api:latest
1351 node_selector:
1352 labels: {}
1353"#;
1354
1355 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1356 let selector = spec.services["api"].node_selector.as_ref().unwrap();
1357 assert!(selector.labels.is_empty());
1358 assert!(selector.prefer_labels.is_empty());
1359 }
1360
1361 #[test]
1362 fn test_mixed_node_modes_in_deployment() {
1363 let yaml = r#"
1364version: v1
1365deployment: test
1366services:
1367 redis:
1368 rtype: service
1369 image:
1370 name: redis:alpine
1371 # Default shared mode
1372 api:
1373 rtype: service
1374 image:
1375 name: api:latest
1376 node_mode: dedicated
1377 database:
1378 rtype: service
1379 image:
1380 name: postgres:15
1381 node_mode: exclusive
1382 node_selector:
1383 labels:
1384 storage: ssd
1385"#;
1386
1387 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1388 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
1389 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1390 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1391
1392 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
1393 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
1394 }
1395
1396 #[test]
1397 fn test_storage_bind_mount() {
1398 let yaml = r#"
1399version: v1
1400deployment: test
1401services:
1402 app:
1403 image:
1404 name: app:latest
1405 storage:
1406 - type: bind
1407 source: /host/data
1408 target: /app/data
1409 readonly: true
1410"#;
1411 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1412 let storage = &spec.services["app"].storage;
1413 assert_eq!(storage.len(), 1);
1414 match &storage[0] {
1415 StorageSpec::Bind {
1416 source,
1417 target,
1418 readonly,
1419 } => {
1420 assert_eq!(source, "/host/data");
1421 assert_eq!(target, "/app/data");
1422 assert!(*readonly);
1423 }
1424 _ => panic!("Expected Bind storage"),
1425 }
1426 }
1427
1428 #[test]
1429 fn test_storage_named_with_tier() {
1430 let yaml = r#"
1431version: v1
1432deployment: test
1433services:
1434 app:
1435 image:
1436 name: app:latest
1437 storage:
1438 - type: named
1439 name: my-data
1440 target: /app/data
1441 tier: cached
1442"#;
1443 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1444 let storage = &spec.services["app"].storage;
1445 match &storage[0] {
1446 StorageSpec::Named {
1447 name, target, tier, ..
1448 } => {
1449 assert_eq!(name, "my-data");
1450 assert_eq!(target, "/app/data");
1451 assert_eq!(*tier, StorageTier::Cached);
1452 }
1453 _ => panic!("Expected Named storage"),
1454 }
1455 }
1456
1457 #[test]
1458 fn test_storage_anonymous() {
1459 let yaml = r#"
1460version: v1
1461deployment: test
1462services:
1463 app:
1464 image:
1465 name: app:latest
1466 storage:
1467 - type: anonymous
1468 target: /app/cache
1469"#;
1470 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1471 let storage = &spec.services["app"].storage;
1472 match &storage[0] {
1473 StorageSpec::Anonymous { target, tier } => {
1474 assert_eq!(target, "/app/cache");
1475 assert_eq!(*tier, StorageTier::Local); }
1477 _ => panic!("Expected Anonymous storage"),
1478 }
1479 }
1480
1481 #[test]
1482 fn test_storage_tmpfs() {
1483 let yaml = r#"
1484version: v1
1485deployment: test
1486services:
1487 app:
1488 image:
1489 name: app:latest
1490 storage:
1491 - type: tmpfs
1492 target: /app/tmp
1493 size: 256Mi
1494 mode: 1777
1495"#;
1496 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1497 let storage = &spec.services["app"].storage;
1498 match &storage[0] {
1499 StorageSpec::Tmpfs { target, size, mode } => {
1500 assert_eq!(target, "/app/tmp");
1501 assert_eq!(size.as_deref(), Some("256Mi"));
1502 assert_eq!(*mode, Some(1777));
1503 }
1504 _ => panic!("Expected Tmpfs storage"),
1505 }
1506 }
1507
1508 #[test]
1509 fn test_storage_s3() {
1510 let yaml = r#"
1511version: v1
1512deployment: test
1513services:
1514 app:
1515 image:
1516 name: app:latest
1517 storage:
1518 - type: s3
1519 bucket: my-bucket
1520 prefix: models/
1521 target: /app/models
1522 readonly: true
1523 endpoint: https://s3.us-west-2.amazonaws.com
1524 credentials: aws-creds
1525"#;
1526 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1527 let storage = &spec.services["app"].storage;
1528 match &storage[0] {
1529 StorageSpec::S3 {
1530 bucket,
1531 prefix,
1532 target,
1533 readonly,
1534 endpoint,
1535 credentials,
1536 } => {
1537 assert_eq!(bucket, "my-bucket");
1538 assert_eq!(prefix.as_deref(), Some("models/"));
1539 assert_eq!(target, "/app/models");
1540 assert!(*readonly);
1541 assert_eq!(
1542 endpoint.as_deref(),
1543 Some("https://s3.us-west-2.amazonaws.com")
1544 );
1545 assert_eq!(credentials.as_deref(), Some("aws-creds"));
1546 }
1547 _ => panic!("Expected S3 storage"),
1548 }
1549 }
1550
1551 #[test]
1552 fn test_storage_multiple_types() {
1553 let yaml = r#"
1554version: v1
1555deployment: test
1556services:
1557 app:
1558 image:
1559 name: app:latest
1560 storage:
1561 - type: bind
1562 source: /etc/config
1563 target: /app/config
1564 readonly: true
1565 - type: named
1566 name: app-data
1567 target: /app/data
1568 - type: tmpfs
1569 target: /app/tmp
1570"#;
1571 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1572 let storage = &spec.services["app"].storage;
1573 assert_eq!(storage.len(), 3);
1574 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
1575 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
1576 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
1577 }
1578
1579 #[test]
1580 fn test_storage_tier_default() {
1581 let yaml = r#"
1582version: v1
1583deployment: test
1584services:
1585 app:
1586 image:
1587 name: app:latest
1588 storage:
1589 - type: named
1590 name: data
1591 target: /data
1592"#;
1593 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1594 match &spec.services["app"].storage[0] {
1595 StorageSpec::Named { tier, .. } => {
1596 assert_eq!(*tier, StorageTier::Local); }
1598 _ => panic!("Expected Named storage"),
1599 }
1600 }
1601
1602 #[test]
1607 fn test_endpoint_tunnel_config_basic() {
1608 let yaml = r#"
1609version: v1
1610deployment: test
1611services:
1612 api:
1613 image:
1614 name: api:latest
1615 endpoints:
1616 - name: http
1617 protocol: http
1618 port: 8080
1619 tunnel:
1620 enabled: true
1621 remote_port: 8080
1622"#;
1623 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1624 let endpoint = &spec.services["api"].endpoints[0];
1625 let tunnel = endpoint.tunnel.as_ref().unwrap();
1626 assert!(tunnel.enabled);
1627 assert_eq!(tunnel.remote_port, 8080);
1628 assert!(tunnel.from.is_none());
1629 assert!(tunnel.to.is_none());
1630 }
1631
1632 #[test]
1633 fn test_endpoint_tunnel_config_full() {
1634 let yaml = r#"
1635version: v1
1636deployment: test
1637services:
1638 api:
1639 image:
1640 name: api:latest
1641 endpoints:
1642 - name: http
1643 protocol: http
1644 port: 8080
1645 tunnel:
1646 enabled: true
1647 from: node-1
1648 to: ingress-node
1649 remote_port: 9000
1650 expose: public
1651 access:
1652 enabled: true
1653 max_ttl: 4h
1654 audit: true
1655"#;
1656 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1657 let endpoint = &spec.services["api"].endpoints[0];
1658 let tunnel = endpoint.tunnel.as_ref().unwrap();
1659 assert!(tunnel.enabled);
1660 assert_eq!(tunnel.from, Some("node-1".to_string()));
1661 assert_eq!(tunnel.to, Some("ingress-node".to_string()));
1662 assert_eq!(tunnel.remote_port, 9000);
1663 assert_eq!(tunnel.expose, Some(ExposeType::Public));
1664
1665 let access = tunnel.access.as_ref().unwrap();
1666 assert!(access.enabled);
1667 assert_eq!(access.max_ttl, Some("4h".to_string()));
1668 assert!(access.audit);
1669 }
1670
1671 #[test]
1672 fn test_top_level_tunnel_definition() {
1673 let yaml = r#"
1674version: v1
1675deployment: test
1676services: {}
1677tunnels:
1678 db-tunnel:
1679 from: app-node
1680 to: db-node
1681 local_port: 5432
1682 remote_port: 5432
1683 protocol: tcp
1684 expose: internal
1685"#;
1686 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1687 let tunnel = spec.tunnels.get("db-tunnel").unwrap();
1688 assert_eq!(tunnel.from, "app-node");
1689 assert_eq!(tunnel.to, "db-node");
1690 assert_eq!(tunnel.local_port, 5432);
1691 assert_eq!(tunnel.remote_port, 5432);
1692 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
1693 assert_eq!(tunnel.expose, ExposeType::Internal);
1694 }
1695
1696 #[test]
1697 fn test_top_level_tunnel_defaults() {
1698 let yaml = r#"
1699version: v1
1700deployment: test
1701services: {}
1702tunnels:
1703 simple-tunnel:
1704 from: node-a
1705 to: node-b
1706 local_port: 3000
1707 remote_port: 3000
1708"#;
1709 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1710 let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
1711 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
1714
1715 #[test]
1716 fn test_tunnel_protocol_udp() {
1717 let yaml = r#"
1718version: v1
1719deployment: test
1720services: {}
1721tunnels:
1722 udp-tunnel:
1723 from: node-a
1724 to: node-b
1725 local_port: 5353
1726 remote_port: 5353
1727 protocol: udp
1728"#;
1729 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1730 let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
1731 assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
1732 }
1733
1734 #[test]
1735 fn test_endpoint_without_tunnel() {
1736 let yaml = r#"
1737version: v1
1738deployment: test
1739services:
1740 api:
1741 image:
1742 name: api:latest
1743 endpoints:
1744 - name: http
1745 protocol: http
1746 port: 8080
1747"#;
1748 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1749 let endpoint = &spec.services["api"].endpoints[0];
1750 assert!(endpoint.tunnel.is_none());
1751 }
1752
1753 #[test]
1754 fn test_deployment_without_tunnels() {
1755 let yaml = r#"
1756version: v1
1757deployment: test
1758services:
1759 api:
1760 image:
1761 name: api:latest
1762"#;
1763 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1764 assert!(spec.tunnels.is_empty());
1765 }
1766
1767 #[test]
1772 fn test_spec_without_api_block_uses_defaults() {
1773 let yaml = r#"
1774version: v1
1775deployment: test
1776services:
1777 hello:
1778 image:
1779 name: hello-world:latest
1780"#;
1781 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1782 assert!(spec.api.enabled);
1783 assert_eq!(spec.api.bind, "0.0.0.0:3669");
1784 assert!(spec.api.jwt_secret.is_none());
1785 assert!(spec.api.swagger);
1786 }
1787
1788 #[test]
1789 fn test_spec_with_explicit_api_block() {
1790 let yaml = r#"
1791version: v1
1792deployment: test
1793services:
1794 hello:
1795 image:
1796 name: hello-world:latest
1797api:
1798 enabled: false
1799 bind: "127.0.0.1:9090"
1800 jwt_secret: "my-secret"
1801 swagger: false
1802"#;
1803 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1804 assert!(!spec.api.enabled);
1805 assert_eq!(spec.api.bind, "127.0.0.1:9090");
1806 assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
1807 assert!(!spec.api.swagger);
1808 }
1809
1810 #[test]
1811 fn test_spec_with_partial_api_block() {
1812 let yaml = r#"
1813version: v1
1814deployment: test
1815services:
1816 hello:
1817 image:
1818 name: hello-world:latest
1819api:
1820 bind: "0.0.0.0:3000"
1821"#;
1822 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1823 assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
1825 assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
1828}