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 = "lowercase")]
84pub enum ServiceType {
85 #[default]
87 Standard,
88 WasmHttp,
90 Job,
92}
93
94#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
96#[serde(rename_all = "snake_case")]
97pub enum StorageTier {
98 #[default]
100 Local,
101 Cached,
103 Network,
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct NodeSelector {
111 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
113 pub labels: HashMap<String, String>,
114 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
116 pub prefer_labels: HashMap<String, String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(deny_unknown_fields)]
122pub struct WasmHttpConfig {
123 #[serde(default = "default_min_instances")]
125 pub min_instances: u32,
126 #[serde(default = "default_max_instances")]
128 pub max_instances: u32,
129 #[serde(default = "default_idle_timeout", with = "duration::required")]
131 pub idle_timeout: std::time::Duration,
132 #[serde(default = "default_request_timeout", with = "duration::required")]
134 pub request_timeout: std::time::Duration,
135}
136
137fn default_min_instances() -> u32 {
138 0
139}
140
141fn default_max_instances() -> u32 {
142 10
143}
144
145fn default_idle_timeout() -> std::time::Duration {
146 std::time::Duration::from_secs(300)
147}
148
149fn default_request_timeout() -> std::time::Duration {
150 std::time::Duration::from_secs(30)
151}
152
153impl Default for WasmHttpConfig {
154 fn default() -> Self {
155 Self {
156 min_instances: default_min_instances(),
157 max_instances: default_max_instances(),
158 idle_timeout: default_idle_timeout(),
159 request_timeout: default_request_timeout(),
160 }
161 }
162}
163
164fn default_api_bind() -> String {
165 "0.0.0.0:3669".to_string()
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170pub struct ApiSpec {
171 #[serde(default = "default_true")]
173 pub enabled: bool,
174 #[serde(default = "default_api_bind")]
176 pub bind: String,
177 #[serde(default)]
179 pub jwt_secret: Option<String>,
180 #[serde(default = "default_true")]
182 pub swagger: bool,
183}
184
185impl Default for ApiSpec {
186 fn default() -> Self {
187 Self {
188 enabled: true,
189 bind: default_api_bind(),
190 jwt_secret: None,
191 swagger: true,
192 }
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
198#[serde(deny_unknown_fields)]
199pub struct DeploymentSpec {
200 #[validate(custom(function = "crate::validate::validate_version_wrapper"))]
202 pub version: String,
203
204 #[validate(custom(function = "crate::validate::validate_deployment_name_wrapper"))]
206 pub deployment: String,
207
208 #[serde(default)]
210 #[validate(nested)]
211 pub services: HashMap<String, ServiceSpec>,
212
213 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
215 pub tunnels: HashMap<String, TunnelDefinition>,
216
217 #[serde(default)]
219 pub api: ApiSpec,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224#[serde(deny_unknown_fields)]
225pub struct TunnelDefinition {
226 pub from: String,
228
229 pub to: String,
231
232 pub local_port: u16,
234
235 pub remote_port: u16,
237
238 #[serde(default)]
240 pub protocol: TunnelProtocol,
241
242 #[serde(default)]
244 pub expose: ExposeType,
245}
246
247#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
249#[serde(rename_all = "lowercase")]
250pub enum TunnelProtocol {
251 #[default]
252 Tcp,
253 Udp,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
258#[serde(deny_unknown_fields)]
259pub struct ServiceSpec {
260 #[serde(default = "default_resource_type")]
262 pub rtype: ResourceType,
263
264 #[serde(default, skip_serializing_if = "Option::is_none")]
271 #[validate(custom(function = "crate::validate::validate_schedule_wrapper"))]
272 pub schedule: Option<String>,
273
274 #[validate(nested)]
276 pub image: ImageSpec,
277
278 #[serde(default)]
280 #[validate(nested)]
281 pub resources: ResourcesSpec,
282
283 #[serde(default)]
290 pub env: HashMap<String, String>,
291
292 #[serde(default)]
294 pub command: CommandSpec,
295
296 #[serde(default)]
298 pub network: NetworkSpec,
299
300 #[serde(default)]
302 #[validate(nested)]
303 pub endpoints: Vec<EndpointSpec>,
304
305 #[serde(default)]
307 #[validate(custom(function = "crate::validate::validate_scale_spec"))]
308 pub scale: ScaleSpec,
309
310 #[serde(default)]
312 pub depends: Vec<DependsSpec>,
313
314 #[serde(default = "default_health")]
316 pub health: HealthSpec,
317
318 #[serde(default)]
320 pub init: InitSpec,
321
322 #[serde(default)]
324 pub errors: ErrorsSpec,
325
326 #[serde(default)]
328 pub devices: Vec<DeviceSpec>,
329
330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
332 pub storage: Vec<StorageSpec>,
333
334 #[serde(default)]
336 pub capabilities: Vec<String>,
337
338 #[serde(default)]
340 pub privileged: bool,
341
342 #[serde(default)]
344 pub node_mode: NodeMode,
345
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub node_selector: Option<NodeSelector>,
349
350 #[serde(default)]
352 pub service_type: ServiceType,
353
354 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub wasm_http: Option<WasmHttpConfig>,
357
358 #[serde(skip)]
363 pub host_network: bool,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
368#[serde(deny_unknown_fields)]
369pub struct CommandSpec {
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub entrypoint: Option<Vec<String>>,
373
374 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub args: Option<Vec<String>>,
377
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub workdir: Option<String>,
381}
382
383fn default_resource_type() -> ResourceType {
384 ResourceType::Service
385}
386
387fn default_health() -> HealthSpec {
388 HealthSpec {
389 start_grace: Some(std::time::Duration::from_secs(5)),
390 interval: None,
391 timeout: None,
392 retries: 3,
393 check: HealthCheck::Tcp { port: 0 },
394 }
395}
396
397#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
399#[serde(rename_all = "lowercase")]
400pub enum ResourceType {
401 Service,
403 Job,
405 Cron,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
411#[serde(deny_unknown_fields)]
412pub struct ImageSpec {
413 #[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
415 pub name: String,
416
417 #[serde(default = "default_pull_policy")]
419 pub pull_policy: PullPolicy,
420}
421
422fn default_pull_policy() -> PullPolicy {
423 PullPolicy::IfNotPresent
424}
425
426#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
428#[serde(rename_all = "snake_case")]
429pub enum PullPolicy {
430 Always,
432 IfNotPresent,
434 Never,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
440#[serde(deny_unknown_fields)]
441pub struct DeviceSpec {
442 #[validate(length(min = 1, message = "device path cannot be empty"))]
444 pub path: String,
445
446 #[serde(default = "default_true")]
448 pub read: bool,
449
450 #[serde(default = "default_true")]
452 pub write: bool,
453
454 #[serde(default)]
456 pub mknod: bool,
457}
458
459fn default_true() -> bool {
460 true
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
465#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
466pub enum StorageSpec {
467 Bind {
469 source: String,
470 target: String,
471 #[serde(default)]
472 readonly: bool,
473 },
474 Named {
476 name: String,
477 target: String,
478 #[serde(default)]
479 readonly: bool,
480 #[serde(default)]
482 tier: StorageTier,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 size: Option<String>,
486 },
487 Anonymous {
489 target: String,
490 #[serde(default)]
492 tier: StorageTier,
493 },
494 Tmpfs {
496 target: String,
497 #[serde(default)]
498 size: Option<String>,
499 #[serde(default)]
500 mode: Option<u32>,
501 },
502 S3 {
504 bucket: String,
505 #[serde(default)]
506 prefix: Option<String>,
507 target: String,
508 #[serde(default)]
509 readonly: bool,
510 #[serde(default)]
511 endpoint: Option<String>,
512 #[serde(default)]
513 credentials: Option<String>,
514 },
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
519#[serde(deny_unknown_fields)]
520pub struct ResourcesSpec {
521 #[serde(default)]
523 #[validate(custom(function = "crate::validate::validate_cpu_option_wrapper"))]
524 pub cpu: Option<f64>,
525
526 #[serde(default)]
528 #[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
529 pub memory: Option<String>,
530
531 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub gpu: Option<GpuSpec>,
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
555#[serde(deny_unknown_fields)]
556pub struct GpuSpec {
557 #[serde(default = "default_gpu_count")]
559 pub count: u32,
560 #[serde(default = "default_gpu_vendor")]
562 pub vendor: String,
563 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub mode: Option<String>,
566}
567
568fn default_gpu_count() -> u32 {
569 1
570}
571
572fn default_gpu_vendor() -> String {
573 "nvidia".to_string()
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
578#[serde(deny_unknown_fields)]
579#[derive(Default)]
580pub struct NetworkSpec {
581 #[serde(default)]
583 pub overlays: OverlayConfig,
584
585 #[serde(default)]
587 pub join: JoinPolicy,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
592#[serde(deny_unknown_fields)]
593pub struct OverlayConfig {
594 #[serde(default)]
596 pub service: OverlaySettings,
597
598 #[serde(default)]
600 pub global: OverlaySettings,
601}
602
603impl Default for OverlayConfig {
604 fn default() -> Self {
605 Self {
606 service: OverlaySettings {
607 enabled: true,
608 encrypted: true,
609 isolated: true,
610 },
611 global: OverlaySettings {
612 enabled: true,
613 encrypted: true,
614 isolated: false,
615 },
616 }
617 }
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
622#[serde(deny_unknown_fields)]
623pub struct OverlaySettings {
624 #[serde(default = "default_enabled")]
626 pub enabled: bool,
627
628 #[serde(default = "default_encrypted")]
630 pub encrypted: bool,
631
632 #[serde(default)]
634 pub isolated: bool,
635}
636
637fn default_enabled() -> bool {
638 true
639}
640
641fn default_encrypted() -> bool {
642 true
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
647#[serde(deny_unknown_fields)]
648pub struct JoinPolicy {
649 #[serde(default = "default_join_mode")]
651 pub mode: JoinMode,
652
653 #[serde(default = "default_join_scope")]
655 pub scope: JoinScope,
656}
657
658impl Default for JoinPolicy {
659 fn default() -> Self {
660 Self {
661 mode: default_join_mode(),
662 scope: default_join_scope(),
663 }
664 }
665}
666
667fn default_join_mode() -> JoinMode {
668 JoinMode::Token
669}
670
671fn default_join_scope() -> JoinScope {
672 JoinScope::Service
673}
674
675#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
677#[serde(rename_all = "snake_case")]
678pub enum JoinMode {
679 Open,
681 Token,
683 Closed,
685}
686
687#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
689#[serde(rename_all = "snake_case")]
690pub enum JoinScope {
691 Service,
693 Global,
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
699#[serde(deny_unknown_fields)]
700pub struct EndpointSpec {
701 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
703 pub name: String,
704
705 pub protocol: Protocol,
707
708 #[validate(custom(function = "crate::validate::validate_port_wrapper"))]
710 pub port: u16,
711
712 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub target_port: Option<u16>,
716
717 pub path: Option<String>,
719
720 #[serde(default = "default_expose")]
722 pub expose: ExposeType,
723
724 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub stream: Option<StreamEndpointConfig>,
728
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub tunnel: Option<EndpointTunnelConfig>,
732}
733
734impl EndpointSpec {
735 #[must_use]
738 pub fn target_port(&self) -> u16 {
739 self.target_port.unwrap_or(self.port)
740 }
741}
742
743#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
745#[serde(deny_unknown_fields)]
746pub struct EndpointTunnelConfig {
747 #[serde(default)]
749 pub enabled: bool,
750
751 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub from: Option<String>,
754
755 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub to: Option<String>,
758
759 #[serde(default)]
761 pub remote_port: u16,
762
763 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub expose: Option<ExposeType>,
766
767 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub access: Option<TunnelAccessConfig>,
770}
771
772#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
774#[serde(deny_unknown_fields)]
775pub struct TunnelAccessConfig {
776 #[serde(default)]
778 pub enabled: bool,
779
780 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub max_ttl: Option<String>,
783
784 #[serde(default)]
786 pub audit: bool,
787}
788
789fn default_expose() -> ExposeType {
790 ExposeType::Internal
791}
792
793#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
795#[serde(rename_all = "lowercase")]
796pub enum Protocol {
797 Http,
798 Https,
799 Tcp,
800 Udp,
801 Websocket,
802}
803
804#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
806#[serde(rename_all = "lowercase")]
807pub enum ExposeType {
808 Public,
809 #[default]
810 Internal,
811}
812
813#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
815#[serde(deny_unknown_fields)]
816pub struct StreamEndpointConfig {
817 #[serde(default)]
819 pub tls: bool,
820
821 #[serde(default)]
823 pub proxy_protocol: bool,
824
825 #[serde(default, skip_serializing_if = "Option::is_none")]
828 pub session_timeout: Option<String>,
829
830 #[serde(default, skip_serializing_if = "Option::is_none")]
832 pub health_check: Option<StreamHealthCheck>,
833}
834
835#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
837#[serde(tag = "type", rename_all = "snake_case")]
838pub enum StreamHealthCheck {
839 TcpConnect,
841 UdpProbe {
843 request: String,
845 #[serde(default, skip_serializing_if = "Option::is_none")]
847 expect: Option<String>,
848 },
849}
850
851#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
853#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
854pub enum ScaleSpec {
855 #[serde(rename = "adaptive")]
857 Adaptive {
858 min: u32,
860
861 max: u32,
863
864 #[serde(default, with = "duration::option")]
866 cooldown: Option<std::time::Duration>,
867
868 #[serde(default)]
870 targets: ScaleTargets,
871 },
872
873 #[serde(rename = "fixed")]
875 Fixed { replicas: u32 },
876
877 #[serde(rename = "manual")]
879 Manual,
880}
881
882impl Default for ScaleSpec {
883 fn default() -> Self {
884 Self::Adaptive {
885 min: 1,
886 max: 10,
887 cooldown: Some(std::time::Duration::from_secs(30)),
888 targets: ScaleTargets::default(),
889 }
890 }
891}
892
893#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
895#[serde(deny_unknown_fields)]
896#[derive(Default)]
897pub struct ScaleTargets {
898 #[serde(default)]
900 pub cpu: Option<u8>,
901
902 #[serde(default)]
904 pub memory: Option<u8>,
905
906 #[serde(default)]
908 pub rps: Option<u32>,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
913#[serde(deny_unknown_fields)]
914pub struct DependsSpec {
915 pub service: String,
917
918 #[serde(default = "default_condition")]
920 pub condition: DependencyCondition,
921
922 #[serde(default = "default_timeout", with = "duration::option")]
924 pub timeout: Option<std::time::Duration>,
925
926 #[serde(default = "default_on_timeout")]
928 pub on_timeout: TimeoutAction,
929}
930
931fn default_condition() -> DependencyCondition {
932 DependencyCondition::Healthy
933}
934
935#[allow(clippy::unnecessary_wraps)]
936fn default_timeout() -> Option<std::time::Duration> {
937 Some(std::time::Duration::from_secs(300))
938}
939
940fn default_on_timeout() -> TimeoutAction {
941 TimeoutAction::Fail
942}
943
944#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
946#[serde(rename_all = "lowercase")]
947pub enum DependencyCondition {
948 Started,
950 Healthy,
952 Ready,
954}
955
956#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
958#[serde(rename_all = "lowercase")]
959pub enum TimeoutAction {
960 Fail,
961 Warn,
962 Continue,
963}
964
965#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
967#[serde(deny_unknown_fields)]
968pub struct HealthSpec {
969 #[serde(default, with = "duration::option")]
971 pub start_grace: Option<std::time::Duration>,
972
973 #[serde(default, with = "duration::option")]
975 pub interval: Option<std::time::Duration>,
976
977 #[serde(default, with = "duration::option")]
979 pub timeout: Option<std::time::Duration>,
980
981 #[serde(default = "default_retries")]
983 pub retries: u32,
984
985 pub check: HealthCheck,
987}
988
989fn default_retries() -> u32 {
990 3
991}
992
993#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
995#[serde(tag = "type", rename_all = "lowercase")]
996pub enum HealthCheck {
997 Tcp {
999 port: u16,
1001 },
1002
1003 Http {
1005 url: String,
1007 #[serde(default = "default_expect_status")]
1009 expect_status: u16,
1010 },
1011
1012 Command {
1014 command: String,
1016 },
1017}
1018
1019fn default_expect_status() -> u16 {
1020 200
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1025#[serde(deny_unknown_fields)]
1026#[derive(Default)]
1027pub struct InitSpec {
1028 #[serde(default)]
1030 pub steps: Vec<InitStep>,
1031}
1032
1033#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1035#[serde(deny_unknown_fields)]
1036pub struct InitStep {
1037 pub id: String,
1039
1040 pub uses: String,
1042
1043 #[serde(default)]
1045 pub with: InitParams,
1046
1047 #[serde(default)]
1049 pub retry: Option<u32>,
1050
1051 #[serde(default, with = "duration::option")]
1053 pub timeout: Option<std::time::Duration>,
1054
1055 #[serde(default = "default_on_failure")]
1057 pub on_failure: FailureAction,
1058}
1059
1060fn default_on_failure() -> FailureAction {
1061 FailureAction::Fail
1062}
1063
1064pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
1066
1067#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1069#[serde(rename_all = "lowercase")]
1070pub enum FailureAction {
1071 Fail,
1072 Warn,
1073 Continue,
1074}
1075
1076#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1078#[serde(deny_unknown_fields)]
1079#[derive(Default)]
1080pub struct ErrorsSpec {
1081 #[serde(default)]
1083 pub on_init_failure: InitFailurePolicy,
1084
1085 #[serde(default)]
1087 pub on_panic: PanicPolicy,
1088}
1089
1090#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1092#[serde(deny_unknown_fields)]
1093pub struct InitFailurePolicy {
1094 #[serde(default = "default_init_action")]
1095 pub action: InitFailureAction,
1096}
1097
1098impl Default for InitFailurePolicy {
1099 fn default() -> Self {
1100 Self {
1101 action: default_init_action(),
1102 }
1103 }
1104}
1105
1106fn default_init_action() -> InitFailureAction {
1107 InitFailureAction::Fail
1108}
1109
1110#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1112#[serde(rename_all = "lowercase")]
1113pub enum InitFailureAction {
1114 Fail,
1115 Restart,
1116 Backoff,
1117}
1118
1119#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1121#[serde(deny_unknown_fields)]
1122pub struct PanicPolicy {
1123 #[serde(default = "default_panic_action")]
1124 pub action: PanicAction,
1125}
1126
1127impl Default for PanicPolicy {
1128 fn default() -> Self {
1129 Self {
1130 action: default_panic_action(),
1131 }
1132 }
1133}
1134
1135fn default_panic_action() -> PanicAction {
1136 PanicAction::Restart
1137}
1138
1139#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1141#[serde(rename_all = "lowercase")]
1142pub enum PanicAction {
1143 Restart,
1144 Shutdown,
1145 Isolate,
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151
1152 #[test]
1153 fn test_parse_simple_spec() {
1154 let yaml = r#"
1155version: v1
1156deployment: test
1157services:
1158 hello:
1159 rtype: service
1160 image:
1161 name: hello-world:latest
1162 endpoints:
1163 - name: http
1164 protocol: http
1165 port: 8080
1166 expose: public
1167"#;
1168
1169 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1170 assert_eq!(spec.version, "v1");
1171 assert_eq!(spec.deployment, "test");
1172 assert!(spec.services.contains_key("hello"));
1173 }
1174
1175 #[test]
1176 fn test_parse_duration() {
1177 let yaml = r#"
1178version: v1
1179deployment: test
1180services:
1181 test:
1182 rtype: service
1183 image:
1184 name: test:latest
1185 health:
1186 timeout: 30s
1187 interval: 1m
1188 start_grace: 5s
1189 check:
1190 type: tcp
1191 port: 8080
1192"#;
1193
1194 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1195 let health = &spec.services["test"].health;
1196 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
1197 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
1198 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
1199 match &health.check {
1200 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
1201 _ => panic!("Expected TCP health check"),
1202 }
1203 }
1204
1205 #[test]
1206 fn test_parse_adaptive_scale() {
1207 let yaml = r#"
1208version: v1
1209deployment: test
1210services:
1211 test:
1212 rtype: service
1213 image:
1214 name: test:latest
1215 scale:
1216 mode: adaptive
1217 min: 2
1218 max: 10
1219 cooldown: 15s
1220 targets:
1221 cpu: 70
1222 rps: 800
1223"#;
1224
1225 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1226 let scale = &spec.services["test"].scale;
1227 match scale {
1228 ScaleSpec::Adaptive {
1229 min,
1230 max,
1231 cooldown,
1232 targets,
1233 } => {
1234 assert_eq!(*min, 2);
1235 assert_eq!(*max, 10);
1236 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
1237 assert_eq!(targets.cpu, Some(70));
1238 assert_eq!(targets.rps, Some(800));
1239 }
1240 _ => panic!("Expected Adaptive scale mode"),
1241 }
1242 }
1243
1244 #[test]
1245 fn test_node_mode_default() {
1246 let yaml = r#"
1247version: v1
1248deployment: test
1249services:
1250 hello:
1251 rtype: service
1252 image:
1253 name: hello-world:latest
1254"#;
1255
1256 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1257 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
1258 assert!(spec.services["hello"].node_selector.is_none());
1259 }
1260
1261 #[test]
1262 fn test_node_mode_dedicated() {
1263 let yaml = r#"
1264version: v1
1265deployment: test
1266services:
1267 api:
1268 rtype: service
1269 image:
1270 name: api:latest
1271 node_mode: dedicated
1272"#;
1273
1274 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1275 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1276 }
1277
1278 #[test]
1279 fn test_node_mode_exclusive() {
1280 let yaml = r#"
1281version: v1
1282deployment: test
1283services:
1284 database:
1285 rtype: service
1286 image:
1287 name: postgres:15
1288 node_mode: exclusive
1289"#;
1290
1291 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1292 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1293 }
1294
1295 #[test]
1296 fn test_node_selector_with_labels() {
1297 let yaml = r#"
1298version: v1
1299deployment: test
1300services:
1301 ml-worker:
1302 rtype: service
1303 image:
1304 name: ml-worker:latest
1305 node_mode: dedicated
1306 node_selector:
1307 labels:
1308 gpu: "true"
1309 zone: us-east
1310 prefer_labels:
1311 storage: ssd
1312"#;
1313
1314 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1315 let service = &spec.services["ml-worker"];
1316 assert_eq!(service.node_mode, NodeMode::Dedicated);
1317
1318 let selector = service.node_selector.as_ref().unwrap();
1319 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
1320 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
1321 assert_eq!(
1322 selector.prefer_labels.get("storage"),
1323 Some(&"ssd".to_string())
1324 );
1325 }
1326
1327 #[test]
1328 fn test_node_mode_serialization_roundtrip() {
1329 use serde_json;
1330
1331 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
1333 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
1334
1335 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
1336 let json = serde_json::to_string(mode).unwrap();
1337 assert_eq!(&json, *expected, "Serialization failed for {:?}", mode);
1338
1339 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
1340 assert_eq!(deserialized, *mode, "Roundtrip failed for {:?}", mode);
1341 }
1342 }
1343
1344 #[test]
1345 fn test_node_selector_empty() {
1346 let yaml = r#"
1347version: v1
1348deployment: test
1349services:
1350 api:
1351 rtype: service
1352 image:
1353 name: api:latest
1354 node_selector:
1355 labels: {}
1356"#;
1357
1358 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1359 let selector = spec.services["api"].node_selector.as_ref().unwrap();
1360 assert!(selector.labels.is_empty());
1361 assert!(selector.prefer_labels.is_empty());
1362 }
1363
1364 #[test]
1365 fn test_mixed_node_modes_in_deployment() {
1366 let yaml = r#"
1367version: v1
1368deployment: test
1369services:
1370 redis:
1371 rtype: service
1372 image:
1373 name: redis:alpine
1374 # Default shared mode
1375 api:
1376 rtype: service
1377 image:
1378 name: api:latest
1379 node_mode: dedicated
1380 database:
1381 rtype: service
1382 image:
1383 name: postgres:15
1384 node_mode: exclusive
1385 node_selector:
1386 labels:
1387 storage: ssd
1388"#;
1389
1390 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1391 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
1392 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1393 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1394
1395 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
1396 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
1397 }
1398
1399 #[test]
1400 fn test_storage_bind_mount() {
1401 let yaml = r#"
1402version: v1
1403deployment: test
1404services:
1405 app:
1406 image:
1407 name: app:latest
1408 storage:
1409 - type: bind
1410 source: /host/data
1411 target: /app/data
1412 readonly: true
1413"#;
1414 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1415 let storage = &spec.services["app"].storage;
1416 assert_eq!(storage.len(), 1);
1417 match &storage[0] {
1418 StorageSpec::Bind {
1419 source,
1420 target,
1421 readonly,
1422 } => {
1423 assert_eq!(source, "/host/data");
1424 assert_eq!(target, "/app/data");
1425 assert!(*readonly);
1426 }
1427 _ => panic!("Expected Bind storage"),
1428 }
1429 }
1430
1431 #[test]
1432 fn test_storage_named_with_tier() {
1433 let yaml = r#"
1434version: v1
1435deployment: test
1436services:
1437 app:
1438 image:
1439 name: app:latest
1440 storage:
1441 - type: named
1442 name: my-data
1443 target: /app/data
1444 tier: cached
1445"#;
1446 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1447 let storage = &spec.services["app"].storage;
1448 match &storage[0] {
1449 StorageSpec::Named {
1450 name, target, tier, ..
1451 } => {
1452 assert_eq!(name, "my-data");
1453 assert_eq!(target, "/app/data");
1454 assert_eq!(*tier, StorageTier::Cached);
1455 }
1456 _ => panic!("Expected Named storage"),
1457 }
1458 }
1459
1460 #[test]
1461 fn test_storage_anonymous() {
1462 let yaml = r#"
1463version: v1
1464deployment: test
1465services:
1466 app:
1467 image:
1468 name: app:latest
1469 storage:
1470 - type: anonymous
1471 target: /app/cache
1472"#;
1473 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1474 let storage = &spec.services["app"].storage;
1475 match &storage[0] {
1476 StorageSpec::Anonymous { target, tier } => {
1477 assert_eq!(target, "/app/cache");
1478 assert_eq!(*tier, StorageTier::Local); }
1480 _ => panic!("Expected Anonymous storage"),
1481 }
1482 }
1483
1484 #[test]
1485 fn test_storage_tmpfs() {
1486 let yaml = r#"
1487version: v1
1488deployment: test
1489services:
1490 app:
1491 image:
1492 name: app:latest
1493 storage:
1494 - type: tmpfs
1495 target: /app/tmp
1496 size: 256Mi
1497 mode: 1777
1498"#;
1499 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1500 let storage = &spec.services["app"].storage;
1501 match &storage[0] {
1502 StorageSpec::Tmpfs { target, size, mode } => {
1503 assert_eq!(target, "/app/tmp");
1504 assert_eq!(size.as_deref(), Some("256Mi"));
1505 assert_eq!(*mode, Some(1777));
1506 }
1507 _ => panic!("Expected Tmpfs storage"),
1508 }
1509 }
1510
1511 #[test]
1512 fn test_storage_s3() {
1513 let yaml = r#"
1514version: v1
1515deployment: test
1516services:
1517 app:
1518 image:
1519 name: app:latest
1520 storage:
1521 - type: s3
1522 bucket: my-bucket
1523 prefix: models/
1524 target: /app/models
1525 readonly: true
1526 endpoint: https://s3.us-west-2.amazonaws.com
1527 credentials: aws-creds
1528"#;
1529 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1530 let storage = &spec.services["app"].storage;
1531 match &storage[0] {
1532 StorageSpec::S3 {
1533 bucket,
1534 prefix,
1535 target,
1536 readonly,
1537 endpoint,
1538 credentials,
1539 } => {
1540 assert_eq!(bucket, "my-bucket");
1541 assert_eq!(prefix.as_deref(), Some("models/"));
1542 assert_eq!(target, "/app/models");
1543 assert!(*readonly);
1544 assert_eq!(
1545 endpoint.as_deref(),
1546 Some("https://s3.us-west-2.amazonaws.com")
1547 );
1548 assert_eq!(credentials.as_deref(), Some("aws-creds"));
1549 }
1550 _ => panic!("Expected S3 storage"),
1551 }
1552 }
1553
1554 #[test]
1555 fn test_storage_multiple_types() {
1556 let yaml = r#"
1557version: v1
1558deployment: test
1559services:
1560 app:
1561 image:
1562 name: app:latest
1563 storage:
1564 - type: bind
1565 source: /etc/config
1566 target: /app/config
1567 readonly: true
1568 - type: named
1569 name: app-data
1570 target: /app/data
1571 - type: tmpfs
1572 target: /app/tmp
1573"#;
1574 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1575 let storage = &spec.services["app"].storage;
1576 assert_eq!(storage.len(), 3);
1577 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
1578 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
1579 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
1580 }
1581
1582 #[test]
1583 fn test_storage_tier_default() {
1584 let yaml = r#"
1585version: v1
1586deployment: test
1587services:
1588 app:
1589 image:
1590 name: app:latest
1591 storage:
1592 - type: named
1593 name: data
1594 target: /data
1595"#;
1596 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1597 match &spec.services["app"].storage[0] {
1598 StorageSpec::Named { tier, .. } => {
1599 assert_eq!(*tier, StorageTier::Local); }
1601 _ => panic!("Expected Named storage"),
1602 }
1603 }
1604
1605 #[test]
1610 fn test_endpoint_tunnel_config_basic() {
1611 let yaml = r#"
1612version: v1
1613deployment: test
1614services:
1615 api:
1616 image:
1617 name: api:latest
1618 endpoints:
1619 - name: http
1620 protocol: http
1621 port: 8080
1622 tunnel:
1623 enabled: true
1624 remote_port: 8080
1625"#;
1626 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1627 let endpoint = &spec.services["api"].endpoints[0];
1628 let tunnel = endpoint.tunnel.as_ref().unwrap();
1629 assert!(tunnel.enabled);
1630 assert_eq!(tunnel.remote_port, 8080);
1631 assert!(tunnel.from.is_none());
1632 assert!(tunnel.to.is_none());
1633 }
1634
1635 #[test]
1636 fn test_endpoint_tunnel_config_full() {
1637 let yaml = r#"
1638version: v1
1639deployment: test
1640services:
1641 api:
1642 image:
1643 name: api:latest
1644 endpoints:
1645 - name: http
1646 protocol: http
1647 port: 8080
1648 tunnel:
1649 enabled: true
1650 from: node-1
1651 to: ingress-node
1652 remote_port: 9000
1653 expose: public
1654 access:
1655 enabled: true
1656 max_ttl: 4h
1657 audit: true
1658"#;
1659 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1660 let endpoint = &spec.services["api"].endpoints[0];
1661 let tunnel = endpoint.tunnel.as_ref().unwrap();
1662 assert!(tunnel.enabled);
1663 assert_eq!(tunnel.from, Some("node-1".to_string()));
1664 assert_eq!(tunnel.to, Some("ingress-node".to_string()));
1665 assert_eq!(tunnel.remote_port, 9000);
1666 assert_eq!(tunnel.expose, Some(ExposeType::Public));
1667
1668 let access = tunnel.access.as_ref().unwrap();
1669 assert!(access.enabled);
1670 assert_eq!(access.max_ttl, Some("4h".to_string()));
1671 assert!(access.audit);
1672 }
1673
1674 #[test]
1675 fn test_top_level_tunnel_definition() {
1676 let yaml = r#"
1677version: v1
1678deployment: test
1679services: {}
1680tunnels:
1681 db-tunnel:
1682 from: app-node
1683 to: db-node
1684 local_port: 5432
1685 remote_port: 5432
1686 protocol: tcp
1687 expose: internal
1688"#;
1689 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1690 let tunnel = spec.tunnels.get("db-tunnel").unwrap();
1691 assert_eq!(tunnel.from, "app-node");
1692 assert_eq!(tunnel.to, "db-node");
1693 assert_eq!(tunnel.local_port, 5432);
1694 assert_eq!(tunnel.remote_port, 5432);
1695 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
1696 assert_eq!(tunnel.expose, ExposeType::Internal);
1697 }
1698
1699 #[test]
1700 fn test_top_level_tunnel_defaults() {
1701 let yaml = r#"
1702version: v1
1703deployment: test
1704services: {}
1705tunnels:
1706 simple-tunnel:
1707 from: node-a
1708 to: node-b
1709 local_port: 3000
1710 remote_port: 3000
1711"#;
1712 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1713 let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
1714 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
1717
1718 #[test]
1719 fn test_tunnel_protocol_udp() {
1720 let yaml = r#"
1721version: v1
1722deployment: test
1723services: {}
1724tunnels:
1725 udp-tunnel:
1726 from: node-a
1727 to: node-b
1728 local_port: 5353
1729 remote_port: 5353
1730 protocol: udp
1731"#;
1732 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1733 let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
1734 assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
1735 }
1736
1737 #[test]
1738 fn test_endpoint_without_tunnel() {
1739 let yaml = r#"
1740version: v1
1741deployment: test
1742services:
1743 api:
1744 image:
1745 name: api:latest
1746 endpoints:
1747 - name: http
1748 protocol: http
1749 port: 8080
1750"#;
1751 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1752 let endpoint = &spec.services["api"].endpoints[0];
1753 assert!(endpoint.tunnel.is_none());
1754 }
1755
1756 #[test]
1757 fn test_deployment_without_tunnels() {
1758 let yaml = r#"
1759version: v1
1760deployment: test
1761services:
1762 api:
1763 image:
1764 name: api:latest
1765"#;
1766 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1767 assert!(spec.tunnels.is_empty());
1768 }
1769
1770 #[test]
1775 fn test_spec_without_api_block_uses_defaults() {
1776 let yaml = r#"
1777version: v1
1778deployment: test
1779services:
1780 hello:
1781 image:
1782 name: hello-world:latest
1783"#;
1784 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1785 assert!(spec.api.enabled);
1786 assert_eq!(spec.api.bind, "0.0.0.0:3669");
1787 assert!(spec.api.jwt_secret.is_none());
1788 assert!(spec.api.swagger);
1789 }
1790
1791 #[test]
1792 fn test_spec_with_explicit_api_block() {
1793 let yaml = r#"
1794version: v1
1795deployment: test
1796services:
1797 hello:
1798 image:
1799 name: hello-world:latest
1800api:
1801 enabled: false
1802 bind: "127.0.0.1:9090"
1803 jwt_secret: "my-secret"
1804 swagger: false
1805"#;
1806 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1807 assert!(!spec.api.enabled);
1808 assert_eq!(spec.api.bind, "127.0.0.1:9090");
1809 assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
1810 assert!(!spec.api.swagger);
1811 }
1812
1813 #[test]
1814 fn test_spec_with_partial_api_block() {
1815 let yaml = r#"
1816version: v1
1817deployment: test
1818services:
1819 hello:
1820 image:
1821 name: hello-world:latest
1822api:
1823 bind: "0.0.0.0:3000"
1824"#;
1825 let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1826 assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
1828 assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
1831}