1mod duration {
6 use humantime::format_duration;
7 use serde::{Deserialize, Deserializer, Serializer};
8 use std::time::Duration;
9
10 #[allow(clippy::ref_option)]
11 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
12 where
13 S: Serializer,
14 {
15 match duration {
16 Some(d) => serializer.serialize_str(&format_duration(*d).to_string()),
17 None => serializer.serialize_none(),
18 }
19 }
20
21 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
22 where
23 D: Deserializer<'de>,
24 {
25 use serde::de::Error;
26 let s: Option<String> = Option::deserialize(deserializer)?;
27 match s {
28 Some(s) => humantime::parse_duration(&s)
29 .map(Some)
30 .map_err(|e| D::Error::custom(format!("invalid duration: {e}"))),
31 None => Ok(None),
32 }
33 }
34
35 pub mod option {
36 pub use super::*;
37 }
38
39 pub mod required {
41 use humantime::format_duration;
42 use serde::{Deserialize, Deserializer, Serializer};
43 use std::time::Duration;
44
45 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
46 where
47 S: Serializer,
48 {
49 serializer.serialize_str(&format_duration(*duration).to_string())
50 }
51
52 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
53 where
54 D: Deserializer<'de>,
55 {
56 use serde::de::Error;
57 let s: String = String::deserialize(deserializer)?;
58 humantime::parse_duration(&s)
59 .map_err(|e| D::Error::custom(format!("invalid duration: {e}")))
60 }
61 }
62}
63
64use serde::{Deserialize, Serialize};
65use std::collections::HashMap;
66use validator::Validate;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum NodeMode {
72 #[default]
74 Shared,
75 Dedicated,
77 Exclusive,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum ServiceType {
85 #[default]
87 Standard,
88 WasmHttp,
90 WasmPlugin,
92 WasmTransformer,
94 WasmAuthenticator,
96 WasmRateLimiter,
98 WasmMiddleware,
100 WasmRouter,
102 Job,
104}
105
106#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
108#[serde(rename_all = "snake_case")]
109pub enum StorageTier {
110 #[default]
112 Local,
113 Cached,
115 Network,
117}
118
119#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct NodeSelector {
123 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
125 pub labels: HashMap<String, String>,
126 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
128 pub prefer_labels: HashMap<String, String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134#[serde(deny_unknown_fields)]
135#[allow(clippy::struct_excessive_bools)]
136pub struct WasmCapabilities {
137 #[serde(default = "default_true")]
139 pub config: bool,
140 #[serde(default = "default_true")]
142 pub keyvalue: bool,
143 #[serde(default = "default_true")]
145 pub logging: bool,
146 #[serde(default)]
148 pub secrets: bool,
149 #[serde(default = "default_true")]
151 pub metrics: bool,
152 #[serde(default)]
154 pub http_client: bool,
155 #[serde(default)]
157 pub cli: bool,
158 #[serde(default)]
160 pub filesystem: bool,
161 #[serde(default)]
163 pub sockets: bool,
164}
165
166impl Default for WasmCapabilities {
167 fn default() -> Self {
168 Self {
169 config: true,
170 keyvalue: true,
171 logging: true,
172 secrets: false,
173 metrics: true,
174 http_client: false,
175 cli: false,
176 filesystem: false,
177 sockets: false,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184#[serde(deny_unknown_fields)]
185pub struct WasmPreopen {
186 pub source: String,
188 pub target: String,
190 #[serde(default)]
192 pub readonly: bool,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200#[serde(deny_unknown_fields)]
201#[allow(clippy::struct_excessive_bools)]
202pub struct WasmConfig {
203 #[serde(default = "default_min_instances")]
206 pub min_instances: u32,
207 #[serde(default = "default_max_instances")]
209 pub max_instances: u32,
210 #[serde(default = "default_idle_timeout", with = "duration::required")]
212 pub idle_timeout: std::time::Duration,
213 #[serde(default = "default_request_timeout", with = "duration::required")]
215 pub request_timeout: std::time::Duration,
216
217 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub max_memory: Option<String>,
221 #[serde(default)]
223 pub max_fuel: u64,
224 #[serde(
226 default,
227 skip_serializing_if = "Option::is_none",
228 with = "duration::option"
229 )]
230 pub epoch_interval: Option<std::time::Duration>,
231
232 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub capabilities: Option<WasmCapabilities>,
236
237 #[serde(default = "default_true")]
240 pub allow_http_outgoing: bool,
241 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub allowed_hosts: Vec<String>,
244 #[serde(default)]
246 pub allow_tcp: bool,
247 #[serde(default)]
249 pub allow_udp: bool,
250
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub preopens: Vec<WasmPreopen>,
255 #[serde(default = "default_true")]
257 pub kv_enabled: bool,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub kv_namespace: Option<String>,
261 #[serde(default = "default_kv_max_value_size")]
263 pub kv_max_value_size: u64,
264
265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
268 pub secrets: Vec<String>,
269
270 #[serde(default = "default_true")]
273 pub precompile: bool,
274}
275
276fn default_kv_max_value_size() -> u64 {
277 1_048_576 }
279
280impl Default for WasmConfig {
281 fn default() -> Self {
282 Self {
283 min_instances: default_min_instances(),
284 max_instances: default_max_instances(),
285 idle_timeout: default_idle_timeout(),
286 request_timeout: default_request_timeout(),
287 max_memory: None,
288 max_fuel: 0,
289 epoch_interval: None,
290 capabilities: None,
291 allow_http_outgoing: true,
292 allowed_hosts: Vec::new(),
293 allow_tcp: false,
294 allow_udp: false,
295 preopens: Vec::new(),
296 kv_enabled: true,
297 kv_namespace: None,
298 kv_max_value_size: default_kv_max_value_size(),
299 secrets: Vec::new(),
300 precompile: true,
301 }
302 }
303}
304
305#[deprecated(note = "Use WasmConfig instead")]
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
308#[serde(deny_unknown_fields)]
309pub struct WasmHttpConfig {
310 #[serde(default = "default_min_instances")]
312 pub min_instances: u32,
313 #[serde(default = "default_max_instances")]
315 pub max_instances: u32,
316 #[serde(default = "default_idle_timeout", with = "duration::required")]
318 pub idle_timeout: std::time::Duration,
319 #[serde(default = "default_request_timeout", with = "duration::required")]
321 pub request_timeout: std::time::Duration,
322}
323
324fn default_min_instances() -> u32 {
325 0
326}
327
328fn default_max_instances() -> u32 {
329 10
330}
331
332fn default_idle_timeout() -> std::time::Duration {
333 std::time::Duration::from_secs(300)
334}
335
336fn default_request_timeout() -> std::time::Duration {
337 std::time::Duration::from_secs(30)
338}
339
340#[allow(deprecated)]
341impl Default for WasmHttpConfig {
342 fn default() -> Self {
343 Self {
344 min_instances: default_min_instances(),
345 max_instances: default_max_instances(),
346 idle_timeout: default_idle_timeout(),
347 request_timeout: default_request_timeout(),
348 }
349 }
350}
351
352#[allow(deprecated)]
353impl From<WasmHttpConfig> for WasmConfig {
354 fn from(old: WasmHttpConfig) -> Self {
355 Self {
356 min_instances: old.min_instances,
357 max_instances: old.max_instances,
358 idle_timeout: old.idle_timeout,
359 request_timeout: old.request_timeout,
360 ..Default::default()
361 }
362 }
363}
364
365impl ServiceType {
366 #[must_use]
368 pub fn is_wasm(&self) -> bool {
369 matches!(
370 self,
371 ServiceType::WasmHttp
372 | ServiceType::WasmPlugin
373 | ServiceType::WasmTransformer
374 | ServiceType::WasmAuthenticator
375 | ServiceType::WasmRateLimiter
376 | ServiceType::WasmMiddleware
377 | ServiceType::WasmRouter
378 )
379 }
380
381 #[must_use]
384 pub fn default_wasm_capabilities(&self) -> Option<WasmCapabilities> {
385 match self {
386 ServiceType::WasmHttp | ServiceType::WasmRouter => Some(WasmCapabilities {
387 config: true,
388 keyvalue: true,
389 logging: true,
390 secrets: false,
391 metrics: false,
392 http_client: true,
393 cli: false,
394 filesystem: false,
395 sockets: false,
396 }),
397 ServiceType::WasmPlugin => Some(WasmCapabilities {
398 config: true,
399 keyvalue: true,
400 logging: true,
401 secrets: true,
402 metrics: true,
403 http_client: true,
404 cli: true,
405 filesystem: true,
406 sockets: false,
407 }),
408 ServiceType::WasmTransformer => Some(WasmCapabilities {
409 config: false,
410 keyvalue: false,
411 logging: true,
412 secrets: false,
413 metrics: false,
414 http_client: false,
415 cli: true,
416 filesystem: false,
417 sockets: false,
418 }),
419 ServiceType::WasmAuthenticator => Some(WasmCapabilities {
420 config: true,
421 keyvalue: false,
422 logging: true,
423 secrets: true,
424 metrics: false,
425 http_client: true,
426 cli: false,
427 filesystem: false,
428 sockets: false,
429 }),
430 ServiceType::WasmRateLimiter => Some(WasmCapabilities {
431 config: true,
432 keyvalue: true,
433 logging: true,
434 secrets: false,
435 metrics: true,
436 http_client: false,
437 cli: true,
438 filesystem: false,
439 sockets: false,
440 }),
441 ServiceType::WasmMiddleware => Some(WasmCapabilities {
442 config: true,
443 keyvalue: false,
444 logging: true,
445 secrets: false,
446 metrics: false,
447 http_client: true,
448 cli: false,
449 filesystem: false,
450 sockets: false,
451 }),
452 _ => None,
453 }
454 }
455}
456
457fn default_api_bind() -> String {
458 "0.0.0.0:3669".to_string()
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
463pub struct ApiSpec {
464 #[serde(default = "default_true")]
466 pub enabled: bool,
467 #[serde(default = "default_api_bind")]
469 pub bind: String,
470 #[serde(default)]
472 pub jwt_secret: Option<String>,
473 #[serde(default = "default_true")]
475 pub swagger: bool,
476}
477
478impl Default for ApiSpec {
479 fn default() -> Self {
480 Self {
481 enabled: true,
482 bind: default_api_bind(),
483 jwt_secret: None,
484 swagger: true,
485 }
486 }
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
491#[serde(deny_unknown_fields)]
492pub struct DeploymentSpec {
493 #[validate(custom(function = "crate::validate::validate_version_wrapper"))]
495 pub version: String,
496
497 #[validate(custom(function = "crate::validate::validate_deployment_name_wrapper"))]
499 pub deployment: String,
500
501 #[serde(default)]
503 #[validate(nested)]
504 pub services: HashMap<String, ServiceSpec>,
505
506 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
513 #[validate(nested)]
514 pub externals: HashMap<String, ExternalSpec>,
515
516 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
518 pub tunnels: HashMap<String, TunnelDefinition>,
519
520 #[serde(default)]
522 pub api: ApiSpec,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
531#[serde(deny_unknown_fields)]
532pub struct ExternalSpec {
533 #[validate(length(min = 1, message = "at least one backend address is required"))]
538 pub backends: Vec<String>,
539
540 #[serde(default)]
544 #[validate(nested)]
545 pub endpoints: Vec<EndpointSpec>,
546
547 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub health: Option<HealthSpec>,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
557#[serde(deny_unknown_fields)]
558pub struct TunnelDefinition {
559 pub from: String,
561
562 pub to: String,
564
565 pub local_port: u16,
567
568 pub remote_port: u16,
570
571 #[serde(default)]
573 pub protocol: TunnelProtocol,
574
575 #[serde(default)]
577 pub expose: ExposeType,
578}
579
580#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
582#[serde(rename_all = "lowercase")]
583pub enum TunnelProtocol {
584 #[default]
585 Tcp,
586 Udp,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
591pub struct LogsConfig {
592 #[serde(default = "default_logs_destination")]
594 pub destination: String,
595
596 #[serde(default = "default_logs_max_size")]
598 pub max_size_bytes: u64,
599
600 #[serde(default = "default_logs_retention")]
602 pub retention_secs: u64,
603}
604
605fn default_logs_destination() -> String {
606 "disk".to_string()
607}
608
609fn default_logs_max_size() -> u64 {
610 100 * 1024 * 1024 }
612
613fn default_logs_retention() -> u64 {
614 7 * 24 * 60 * 60 }
616
617impl Default for LogsConfig {
618 fn default() -> Self {
619 Self {
620 destination: default_logs_destination(),
621 max_size_bytes: default_logs_max_size(),
622 retention_secs: default_logs_retention(),
623 }
624 }
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
629#[serde(deny_unknown_fields)]
630pub struct ServiceSpec {
631 #[serde(default = "default_resource_type")]
633 pub rtype: ResourceType,
634
635 #[serde(default, skip_serializing_if = "Option::is_none")]
642 #[validate(custom(function = "crate::validate::validate_schedule_wrapper"))]
643 pub schedule: Option<String>,
644
645 #[validate(nested)]
647 pub image: ImageSpec,
648
649 #[serde(default)]
651 #[validate(nested)]
652 pub resources: ResourcesSpec,
653
654 #[serde(default)]
661 pub env: HashMap<String, String>,
662
663 #[serde(default)]
665 pub command: CommandSpec,
666
667 #[serde(default)]
669 pub network: ServiceNetworkSpec,
670
671 #[serde(default)]
673 #[validate(nested)]
674 pub endpoints: Vec<EndpointSpec>,
675
676 #[serde(default)]
678 #[validate(custom(function = "crate::validate::validate_scale_spec"))]
679 pub scale: ScaleSpec,
680
681 #[serde(default)]
683 pub depends: Vec<DependsSpec>,
684
685 #[serde(default = "default_health")]
687 pub health: HealthSpec,
688
689 #[serde(default)]
691 pub init: InitSpec,
692
693 #[serde(default)]
695 pub errors: ErrorsSpec,
696
697 #[serde(default)]
699 pub devices: Vec<DeviceSpec>,
700
701 #[serde(default, skip_serializing_if = "Vec::is_empty")]
703 pub storage: Vec<StorageSpec>,
704
705 #[serde(default, skip_serializing_if = "Vec::is_empty")]
710 pub port_mappings: Vec<PortMapping>,
711
712 #[serde(default)]
714 pub capabilities: Vec<String>,
715
716 #[serde(default)]
718 pub privileged: bool,
719
720 #[serde(default)]
722 pub node_mode: NodeMode,
723
724 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub node_selector: Option<NodeSelector>,
727
728 #[serde(default)]
730 pub service_type: ServiceType,
731
732 #[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
735 pub wasm: Option<WasmConfig>,
736
737 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub logs: Option<LogsConfig>,
740
741 #[serde(skip)]
746 pub host_network: bool,
747
748 #[serde(default, skip_serializing_if = "Option::is_none")]
754 pub hostname: Option<String>,
755
756 #[serde(default, skip_serializing_if = "Vec::is_empty")]
762 pub dns: Vec<String>,
763
764 #[serde(default, skip_serializing_if = "Vec::is_empty")]
772 pub extra_hosts: Vec<String>,
773
774 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub restart_policy: Option<ContainerRestartPolicy>,
783}
784
785#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
787#[serde(deny_unknown_fields)]
788pub struct CommandSpec {
789 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub entrypoint: Option<Vec<String>>,
792
793 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub args: Option<Vec<String>>,
796
797 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub workdir: Option<String>,
800}
801
802fn default_resource_type() -> ResourceType {
803 ResourceType::Service
804}
805
806fn default_health() -> HealthSpec {
807 HealthSpec {
808 start_grace: Some(std::time::Duration::from_secs(5)),
809 interval: None,
810 timeout: None,
811 retries: 3,
812 check: HealthCheck::Tcp { port: 0 },
813 }
814}
815
816#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
818#[serde(rename_all = "lowercase")]
819pub enum ResourceType {
820 Service,
822 Job,
824 Cron,
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
830#[serde(deny_unknown_fields)]
831pub struct ImageSpec {
832 #[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
834 pub name: String,
835
836 #[serde(default = "default_pull_policy")]
838 pub pull_policy: PullPolicy,
839}
840
841fn default_pull_policy() -> PullPolicy {
842 PullPolicy::IfNotPresent
843}
844
845#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
847#[serde(rename_all = "snake_case")]
848pub enum PullPolicy {
849 Always,
851 IfNotPresent,
853 Never,
855}
856
857#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
859#[serde(deny_unknown_fields)]
860pub struct DeviceSpec {
861 #[validate(length(min = 1, message = "device path cannot be empty"))]
863 pub path: String,
864
865 #[serde(default = "default_true")]
867 pub read: bool,
868
869 #[serde(default = "default_true")]
871 pub write: bool,
872
873 #[serde(default)]
875 pub mknod: bool,
876}
877
878fn default_true() -> bool {
879 true
880}
881
882#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
884#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
885pub enum StorageSpec {
886 Bind {
888 source: String,
889 target: String,
890 #[serde(default)]
891 readonly: bool,
892 },
893 Named {
895 name: String,
896 target: String,
897 #[serde(default)]
898 readonly: bool,
899 #[serde(default)]
901 tier: StorageTier,
902 #[serde(default, skip_serializing_if = "Option::is_none")]
904 size: Option<String>,
905 },
906 Anonymous {
908 target: String,
909 #[serde(default)]
911 tier: StorageTier,
912 },
913 Tmpfs {
915 target: String,
916 #[serde(default)]
917 size: Option<String>,
918 #[serde(default)]
919 mode: Option<u32>,
920 },
921 S3 {
923 bucket: String,
924 #[serde(default)]
925 prefix: Option<String>,
926 target: String,
927 #[serde(default)]
928 readonly: bool,
929 #[serde(default)]
930 endpoint: Option<String>,
931 #[serde(default)]
932 credentials: Option<String>,
933 },
934}
935
936#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
938#[serde(deny_unknown_fields)]
939pub struct ResourcesSpec {
940 #[serde(default)]
942 #[validate(custom(function = "crate::validate::validate_cpu_option_wrapper"))]
943 pub cpu: Option<f64>,
944
945 #[serde(default)]
947 #[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
948 pub memory: Option<String>,
949
950 #[serde(default, skip_serializing_if = "Option::is_none")]
952 pub gpu: Option<GpuSpec>,
953}
954
955#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
957#[serde(rename_all = "kebab-case")]
958pub enum SchedulingPolicy {
959 #[default]
961 BestEffort,
962 Gang,
964 Spread,
966}
967
968#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
970#[serde(rename_all = "kebab-case")]
971pub enum GpuSharingMode {
972 #[default]
974 Exclusive,
975 Mps,
978 TimeSlice,
981}
982
983#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
990#[serde(deny_unknown_fields)]
991pub struct DistributedConfig {
992 #[serde(default = "default_dist_backend")]
994 pub backend: String,
995 #[serde(default = "default_dist_port")]
997 pub master_port: u16,
998}
999
1000fn default_dist_backend() -> String {
1001 "nccl".to_string()
1002}
1003
1004fn default_dist_port() -> u16 {
1005 29500
1006}
1007
1008#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1027#[serde(deny_unknown_fields)]
1028pub struct GpuSpec {
1029 #[serde(default = "default_gpu_count")]
1031 pub count: u32,
1032 #[serde(default = "default_gpu_vendor")]
1034 pub vendor: String,
1035 #[serde(default, skip_serializing_if = "Option::is_none")]
1037 pub mode: Option<String>,
1038 #[serde(default, skip_serializing_if = "Option::is_none")]
1041 pub model: Option<String>,
1042 #[serde(default, skip_serializing_if = "Option::is_none")]
1047 pub scheduling: Option<SchedulingPolicy>,
1048 #[serde(default, skip_serializing_if = "Option::is_none")]
1051 pub distributed: Option<DistributedConfig>,
1052 #[serde(default, skip_serializing_if = "Option::is_none")]
1054 pub sharing: Option<GpuSharingMode>,
1055}
1056
1057fn default_gpu_count() -> u32 {
1058 1
1059}
1060
1061fn default_gpu_vendor() -> String {
1062 "nvidia".to_string()
1063}
1064
1065#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1067#[serde(deny_unknown_fields)]
1068#[derive(Default)]
1069pub struct ServiceNetworkSpec {
1070 #[serde(default)]
1072 pub overlays: OverlayConfig,
1073
1074 #[serde(default)]
1076 pub join: JoinPolicy,
1077}
1078
1079#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1081#[serde(deny_unknown_fields)]
1082pub struct OverlayConfig {
1083 #[serde(default)]
1085 pub service: OverlaySettings,
1086
1087 #[serde(default)]
1089 pub global: OverlaySettings,
1090}
1091
1092impl Default for OverlayConfig {
1093 fn default() -> Self {
1094 Self {
1095 service: OverlaySettings {
1096 enabled: true,
1097 encrypted: true,
1098 isolated: true,
1099 },
1100 global: OverlaySettings {
1101 enabled: true,
1102 encrypted: true,
1103 isolated: false,
1104 },
1105 }
1106 }
1107}
1108
1109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1111#[serde(deny_unknown_fields)]
1112pub struct OverlaySettings {
1113 #[serde(default = "default_enabled")]
1115 pub enabled: bool,
1116
1117 #[serde(default = "default_encrypted")]
1119 pub encrypted: bool,
1120
1121 #[serde(default)]
1123 pub isolated: bool,
1124}
1125
1126fn default_enabled() -> bool {
1127 true
1128}
1129
1130fn default_encrypted() -> bool {
1131 true
1132}
1133
1134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1136#[serde(deny_unknown_fields)]
1137pub struct JoinPolicy {
1138 #[serde(default = "default_join_mode")]
1140 pub mode: JoinMode,
1141
1142 #[serde(default = "default_join_scope")]
1144 pub scope: JoinScope,
1145}
1146
1147impl Default for JoinPolicy {
1148 fn default() -> Self {
1149 Self {
1150 mode: default_join_mode(),
1151 scope: default_join_scope(),
1152 }
1153 }
1154}
1155
1156fn default_join_mode() -> JoinMode {
1157 JoinMode::Token
1158}
1159
1160fn default_join_scope() -> JoinScope {
1161 JoinScope::Service
1162}
1163
1164#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1166#[serde(rename_all = "snake_case")]
1167pub enum JoinMode {
1168 Open,
1170 Token,
1172 Closed,
1174}
1175
1176#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1178#[serde(rename_all = "snake_case")]
1179pub enum JoinScope {
1180 Service,
1182 Global,
1184}
1185
1186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1188#[serde(deny_unknown_fields)]
1189pub struct EndpointSpec {
1190 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
1192 pub name: String,
1193
1194 pub protocol: Protocol,
1196
1197 #[validate(custom(function = "crate::validate::validate_port_wrapper"))]
1199 pub port: u16,
1200
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1204 pub target_port: Option<u16>,
1205
1206 pub path: Option<String>,
1208
1209 #[serde(default, skip_serializing_if = "Option::is_none")]
1212 pub host: Option<String>,
1213
1214 #[serde(default = "default_expose")]
1216 pub expose: ExposeType,
1217
1218 #[serde(default, skip_serializing_if = "Option::is_none")]
1221 pub stream: Option<StreamEndpointConfig>,
1222
1223 #[serde(default, skip_serializing_if = "Option::is_none")]
1225 pub tunnel: Option<EndpointTunnelConfig>,
1226}
1227
1228impl EndpointSpec {
1229 #[must_use]
1232 pub fn target_port(&self) -> u16 {
1233 self.target_port.unwrap_or(self.port)
1234 }
1235}
1236
1237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1239#[serde(deny_unknown_fields)]
1240pub struct EndpointTunnelConfig {
1241 #[serde(default)]
1243 pub enabled: bool,
1244
1245 #[serde(default, skip_serializing_if = "Option::is_none")]
1247 pub from: Option<String>,
1248
1249 #[serde(default, skip_serializing_if = "Option::is_none")]
1251 pub to: Option<String>,
1252
1253 #[serde(default)]
1255 pub remote_port: u16,
1256
1257 #[serde(default, skip_serializing_if = "Option::is_none")]
1259 pub expose: Option<ExposeType>,
1260
1261 #[serde(default, skip_serializing_if = "Option::is_none")]
1263 pub access: Option<TunnelAccessConfig>,
1264}
1265
1266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1268#[serde(deny_unknown_fields)]
1269pub struct TunnelAccessConfig {
1270 #[serde(default)]
1272 pub enabled: bool,
1273
1274 #[serde(default, skip_serializing_if = "Option::is_none")]
1276 pub max_ttl: Option<String>,
1277
1278 #[serde(default)]
1280 pub audit: bool,
1281}
1282
1283fn default_expose() -> ExposeType {
1284 ExposeType::Internal
1285}
1286
1287#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1289#[serde(rename_all = "lowercase")]
1290pub enum Protocol {
1291 Http,
1292 Https,
1293 Tcp,
1294 Udp,
1295 Websocket,
1296}
1297
1298#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1300#[serde(rename_all = "lowercase")]
1301pub enum ExposeType {
1302 Public,
1303 #[default]
1304 Internal,
1305}
1306
1307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1309#[serde(deny_unknown_fields)]
1310pub struct StreamEndpointConfig {
1311 #[serde(default)]
1313 pub tls: bool,
1314
1315 #[serde(default)]
1317 pub proxy_protocol: bool,
1318
1319 #[serde(default, skip_serializing_if = "Option::is_none")]
1322 pub session_timeout: Option<String>,
1323
1324 #[serde(default, skip_serializing_if = "Option::is_none")]
1326 pub health_check: Option<StreamHealthCheck>,
1327}
1328
1329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1331#[serde(tag = "type", rename_all = "snake_case")]
1332pub enum StreamHealthCheck {
1333 TcpConnect,
1335 UdpProbe {
1337 request: String,
1339 #[serde(default, skip_serializing_if = "Option::is_none")]
1341 expect: Option<String>,
1342 },
1343}
1344
1345#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1347#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
1348pub enum ScaleSpec {
1349 #[serde(rename = "adaptive")]
1351 Adaptive {
1352 min: u32,
1354
1355 max: u32,
1357
1358 #[serde(default, with = "duration::option")]
1360 cooldown: Option<std::time::Duration>,
1361
1362 #[serde(default)]
1364 targets: ScaleTargets,
1365 },
1366
1367 #[serde(rename = "fixed")]
1369 Fixed { replicas: u32 },
1370
1371 #[serde(rename = "manual")]
1373 Manual,
1374}
1375
1376impl Default for ScaleSpec {
1377 fn default() -> Self {
1378 Self::Adaptive {
1379 min: 1,
1380 max: 10,
1381 cooldown: Some(std::time::Duration::from_secs(30)),
1382 targets: ScaleTargets::default(),
1383 }
1384 }
1385}
1386
1387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1389#[serde(deny_unknown_fields)]
1390#[derive(Default)]
1391pub struct ScaleTargets {
1392 #[serde(default)]
1394 pub cpu: Option<u8>,
1395
1396 #[serde(default)]
1398 pub memory: Option<u8>,
1399
1400 #[serde(default)]
1402 pub rps: Option<u32>,
1403}
1404
1405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1407#[serde(deny_unknown_fields)]
1408pub struct DependsSpec {
1409 pub service: String,
1411
1412 #[serde(default = "default_condition")]
1414 pub condition: DependencyCondition,
1415
1416 #[serde(default = "default_timeout", with = "duration::option")]
1418 pub timeout: Option<std::time::Duration>,
1419
1420 #[serde(default = "default_on_timeout")]
1422 pub on_timeout: TimeoutAction,
1423}
1424
1425fn default_condition() -> DependencyCondition {
1426 DependencyCondition::Healthy
1427}
1428
1429#[allow(clippy::unnecessary_wraps)]
1430fn default_timeout() -> Option<std::time::Duration> {
1431 Some(std::time::Duration::from_secs(300))
1432}
1433
1434fn default_on_timeout() -> TimeoutAction {
1435 TimeoutAction::Fail
1436}
1437
1438#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1440#[serde(rename_all = "lowercase")]
1441pub enum DependencyCondition {
1442 Started,
1444 Healthy,
1446 Ready,
1448}
1449
1450#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1452#[serde(rename_all = "lowercase")]
1453pub enum TimeoutAction {
1454 Fail,
1455 Warn,
1456 Continue,
1457}
1458
1459#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1461#[serde(deny_unknown_fields)]
1462pub struct HealthSpec {
1463 #[serde(default, with = "duration::option")]
1465 pub start_grace: Option<std::time::Duration>,
1466
1467 #[serde(default, with = "duration::option")]
1469 pub interval: Option<std::time::Duration>,
1470
1471 #[serde(default, with = "duration::option")]
1473 pub timeout: Option<std::time::Duration>,
1474
1475 #[serde(default = "default_retries")]
1477 pub retries: u32,
1478
1479 pub check: HealthCheck,
1481}
1482
1483fn default_retries() -> u32 {
1484 3
1485}
1486
1487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1489#[serde(tag = "type", rename_all = "lowercase")]
1490pub enum HealthCheck {
1491 Tcp {
1493 port: u16,
1495 },
1496
1497 Http {
1499 url: String,
1501 #[serde(default = "default_expect_status")]
1503 expect_status: u16,
1504 },
1505
1506 Command {
1508 command: String,
1510 },
1511}
1512
1513fn default_expect_status() -> u16 {
1514 200
1515}
1516
1517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1519#[serde(deny_unknown_fields)]
1520#[derive(Default)]
1521pub struct InitSpec {
1522 #[serde(default)]
1524 pub steps: Vec<InitStep>,
1525}
1526
1527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1529#[serde(deny_unknown_fields)]
1530pub struct InitStep {
1531 pub id: String,
1533
1534 pub uses: String,
1536
1537 #[serde(default)]
1539 pub with: InitParams,
1540
1541 #[serde(default)]
1543 pub retry: Option<u32>,
1544
1545 #[serde(default, with = "duration::option")]
1547 pub timeout: Option<std::time::Duration>,
1548
1549 #[serde(default = "default_on_failure")]
1551 pub on_failure: FailureAction,
1552}
1553
1554fn default_on_failure() -> FailureAction {
1555 FailureAction::Fail
1556}
1557
1558pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
1560
1561#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1563#[serde(rename_all = "lowercase")]
1564pub enum FailureAction {
1565 Fail,
1566 Warn,
1567 Continue,
1568}
1569
1570#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1572#[serde(deny_unknown_fields)]
1573#[derive(Default)]
1574pub struct ErrorsSpec {
1575 #[serde(default)]
1577 pub on_init_failure: InitFailurePolicy,
1578
1579 #[serde(default)]
1581 pub on_panic: PanicPolicy,
1582}
1583
1584#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1586#[serde(deny_unknown_fields)]
1587pub struct InitFailurePolicy {
1588 #[serde(default = "default_init_action")]
1589 pub action: InitFailureAction,
1590}
1591
1592impl Default for InitFailurePolicy {
1593 fn default() -> Self {
1594 Self {
1595 action: default_init_action(),
1596 }
1597 }
1598}
1599
1600fn default_init_action() -> InitFailureAction {
1601 InitFailureAction::Fail
1602}
1603
1604#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1606#[serde(rename_all = "lowercase")]
1607pub enum InitFailureAction {
1608 Fail,
1609 Restart,
1610 Backoff,
1611}
1612
1613#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1615#[serde(deny_unknown_fields)]
1616pub struct PanicPolicy {
1617 #[serde(default = "default_panic_action")]
1618 pub action: PanicAction,
1619}
1620
1621impl Default for PanicPolicy {
1622 fn default() -> Self {
1623 Self {
1624 action: default_panic_action(),
1625 }
1626 }
1627}
1628
1629fn default_panic_action() -> PanicAction {
1630 PanicAction::Restart
1631}
1632
1633#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1635#[serde(rename_all = "lowercase")]
1636pub enum PanicAction {
1637 Restart,
1638 Shutdown,
1639 Isolate,
1640}
1641
1642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1649pub struct NetworkPolicySpec {
1650 pub name: String,
1652
1653 #[serde(default, skip_serializing_if = "Option::is_none")]
1655 pub description: Option<String>,
1656
1657 #[serde(default)]
1659 pub cidrs: Vec<String>,
1660
1661 #[serde(default)]
1663 pub members: Vec<NetworkMember>,
1664
1665 #[serde(default)]
1667 pub access_rules: Vec<AccessRule>,
1668}
1669
1670#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1672pub struct NetworkMember {
1673 pub name: String,
1675 #[serde(default)]
1677 pub kind: MemberKind,
1678}
1679
1680#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1682#[serde(rename_all = "lowercase")]
1683pub enum MemberKind {
1684 #[default]
1686 User,
1687 Group,
1689 Node,
1691 Cidr,
1693}
1694
1695#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1697pub struct AccessRule {
1698 #[serde(default = "wildcard")]
1700 pub service: String,
1701
1702 #[serde(default = "wildcard")]
1704 pub deployment: String,
1705
1706 #[serde(default, skip_serializing_if = "Option::is_none")]
1708 pub ports: Option<Vec<u16>>,
1709
1710 #[serde(default)]
1712 pub action: AccessAction,
1713}
1714
1715fn wildcard() -> String {
1716 "*".to_string()
1717}
1718
1719#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1721#[serde(rename_all = "lowercase")]
1722pub enum AccessAction {
1723 #[default]
1725 Allow,
1726 Deny,
1728}
1729
1730#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1742pub struct BridgeNetwork {
1743 pub id: String,
1745
1746 pub name: String,
1748
1749 #[serde(default)]
1751 pub driver: BridgeNetworkDriver,
1752
1753 #[serde(default, skip_serializing_if = "Option::is_none")]
1755 pub subnet: Option<String>,
1756
1757 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1759 pub labels: HashMap<String, String>,
1760
1761 #[serde(default)]
1764 pub internal: bool,
1765
1766 #[schema(value_type = String, format = "date-time")]
1768 pub created_at: chrono::DateTime<chrono::Utc>,
1769}
1770
1771#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
1773#[serde(rename_all = "lowercase")]
1774pub enum BridgeNetworkDriver {
1775 #[default]
1777 Bridge,
1778 Overlay,
1780}
1781
1782#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1784pub struct BridgeNetworkAttachment {
1785 pub container_id: String,
1787
1788 #[serde(default, skip_serializing_if = "Option::is_none")]
1790 pub container_name: Option<String>,
1791
1792 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1794 pub aliases: Vec<String>,
1795
1796 #[serde(default, skip_serializing_if = "Option::is_none")]
1798 pub ipv4: Option<String>,
1799}
1800
1801#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1822pub struct RegistryAuth {
1823 pub username: String,
1826 pub password: String,
1829 #[serde(default = "default_registry_auth_type")]
1831 pub auth_type: RegistryAuthType,
1832}
1833
1834#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
1836#[serde(rename_all = "snake_case")]
1837pub enum RegistryAuthType {
1838 #[default]
1840 Basic,
1841 Token,
1844}
1845
1846#[must_use]
1849pub fn default_registry_auth_type() -> RegistryAuthType {
1850 RegistryAuthType::Basic
1851}
1852
1853#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1868#[serde(rename_all = "snake_case", deny_unknown_fields)]
1869pub struct ContainerRestartPolicy {
1870 pub kind: ContainerRestartKind,
1872
1873 #[serde(default, skip_serializing_if = "Option::is_none")]
1876 pub max_attempts: Option<u32>,
1877
1878 #[serde(default, skip_serializing_if = "Option::is_none")]
1883 pub delay: Option<String>,
1884}
1885
1886#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1888#[serde(rename_all = "snake_case")]
1889pub enum ContainerRestartKind {
1890 No,
1892 Always,
1894 UnlessStopped,
1897 OnFailure,
1900}
1901
1902#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1908#[serde(rename_all = "snake_case")]
1909pub enum PortProtocol {
1910 Tcp,
1912 Udp,
1914}
1915
1916impl Default for PortProtocol {
1917 fn default() -> Self {
1918 default_port_protocol()
1919 }
1920}
1921
1922impl PortProtocol {
1923 #[must_use]
1926 pub fn as_str(&self) -> &'static str {
1927 match self {
1928 PortProtocol::Tcp => "tcp",
1929 PortProtocol::Udp => "udp",
1930 }
1931 }
1932}
1933
1934fn default_port_protocol() -> PortProtocol {
1935 PortProtocol::Tcp
1936}
1937
1938fn default_host_ip() -> String {
1939 "0.0.0.0".to_string()
1940}
1941
1942#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1948#[serde(rename_all = "snake_case")]
1949pub struct PortMapping {
1950 #[serde(default, skip_serializing_if = "Option::is_none")]
1952 pub host_port: Option<u16>,
1953 pub container_port: u16,
1955 #[serde(default = "default_port_protocol")]
1957 pub protocol: PortProtocol,
1958 #[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
1960 pub host_ip: String,
1961}
1962
1963#[cfg(test)]
1964mod tests {
1965 use super::*;
1966
1967 #[test]
1968 fn port_mapping_defaults_via_serde() {
1969 let json = r#"{"container_port": 8080}"#;
1972 let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
1973 assert_eq!(m.container_port, 8080);
1974 assert_eq!(m.host_port, None);
1975 assert_eq!(m.protocol, PortProtocol::Tcp);
1976 assert_eq!(m.host_ip, "0.0.0.0");
1977 }
1978
1979 #[test]
1980 fn port_mapping_skips_none_host_port_and_empty_host_ip() {
1981 let m = PortMapping {
1982 host_port: None,
1983 container_port: 443,
1984 protocol: PortProtocol::Tcp,
1985 host_ip: String::new(),
1986 };
1987 let s = serde_json::to_string(&m).expect("serialize");
1988 assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
1990 assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
1991 assert!(s.contains("\"container_port\":443"));
1992 assert!(s.contains("\"protocol\":\"tcp\""));
1993 }
1994
1995 #[test]
1996 fn test_parse_simple_spec() {
1997 let yaml = r"
1998version: v1
1999deployment: test
2000services:
2001 hello:
2002 rtype: service
2003 image:
2004 name: hello-world:latest
2005 endpoints:
2006 - name: http
2007 protocol: http
2008 port: 8080
2009 expose: public
2010";
2011
2012 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2013 assert_eq!(spec.version, "v1");
2014 assert_eq!(spec.deployment, "test");
2015 assert!(spec.services.contains_key("hello"));
2016 }
2017
2018 #[test]
2019 fn test_parse_duration() {
2020 let yaml = r"
2021version: v1
2022deployment: test
2023services:
2024 test:
2025 rtype: service
2026 image:
2027 name: test:latest
2028 health:
2029 timeout: 30s
2030 interval: 1m
2031 start_grace: 5s
2032 check:
2033 type: tcp
2034 port: 8080
2035";
2036
2037 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2038 let health = &spec.services["test"].health;
2039 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
2040 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
2041 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
2042 match &health.check {
2043 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
2044 _ => panic!("Expected TCP health check"),
2045 }
2046 }
2047
2048 #[test]
2049 fn test_parse_adaptive_scale() {
2050 let yaml = r"
2051version: v1
2052deployment: test
2053services:
2054 test:
2055 rtype: service
2056 image:
2057 name: test:latest
2058 scale:
2059 mode: adaptive
2060 min: 2
2061 max: 10
2062 cooldown: 15s
2063 targets:
2064 cpu: 70
2065 rps: 800
2066";
2067
2068 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2069 let scale = &spec.services["test"].scale;
2070 match scale {
2071 ScaleSpec::Adaptive {
2072 min,
2073 max,
2074 cooldown,
2075 targets,
2076 } => {
2077 assert_eq!(*min, 2);
2078 assert_eq!(*max, 10);
2079 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
2080 assert_eq!(targets.cpu, Some(70));
2081 assert_eq!(targets.rps, Some(800));
2082 }
2083 _ => panic!("Expected Adaptive scale mode"),
2084 }
2085 }
2086
2087 #[test]
2088 fn test_node_mode_default() {
2089 let yaml = r"
2090version: v1
2091deployment: test
2092services:
2093 hello:
2094 rtype: service
2095 image:
2096 name: hello-world:latest
2097";
2098
2099 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2100 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
2101 assert!(spec.services["hello"].node_selector.is_none());
2102 }
2103
2104 #[test]
2105 fn test_node_mode_dedicated() {
2106 let yaml = r"
2107version: v1
2108deployment: test
2109services:
2110 api:
2111 rtype: service
2112 image:
2113 name: api:latest
2114 node_mode: dedicated
2115";
2116
2117 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2118 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2119 }
2120
2121 #[test]
2122 fn test_node_mode_exclusive() {
2123 let yaml = r"
2124version: v1
2125deployment: test
2126services:
2127 database:
2128 rtype: service
2129 image:
2130 name: postgres:15
2131 node_mode: exclusive
2132";
2133
2134 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2135 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2136 }
2137
2138 #[test]
2139 fn test_node_selector_with_labels() {
2140 let yaml = r#"
2141version: v1
2142deployment: test
2143services:
2144 ml-worker:
2145 rtype: service
2146 image:
2147 name: ml-worker:latest
2148 node_mode: dedicated
2149 node_selector:
2150 labels:
2151 gpu: "true"
2152 zone: us-east
2153 prefer_labels:
2154 storage: ssd
2155"#;
2156
2157 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2158 let service = &spec.services["ml-worker"];
2159 assert_eq!(service.node_mode, NodeMode::Dedicated);
2160
2161 let selector = service.node_selector.as_ref().unwrap();
2162 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
2163 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
2164 assert_eq!(
2165 selector.prefer_labels.get("storage"),
2166 Some(&"ssd".to_string())
2167 );
2168 }
2169
2170 #[test]
2171 fn test_node_mode_serialization_roundtrip() {
2172 use serde_json;
2173
2174 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
2176 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
2177
2178 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
2179 let json = serde_json::to_string(mode).unwrap();
2180 assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
2181
2182 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
2183 assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
2184 }
2185 }
2186
2187 #[test]
2188 fn test_node_selector_empty() {
2189 let yaml = r"
2190version: v1
2191deployment: test
2192services:
2193 api:
2194 rtype: service
2195 image:
2196 name: api:latest
2197 node_selector:
2198 labels: {}
2199";
2200
2201 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2202 let selector = spec.services["api"].node_selector.as_ref().unwrap();
2203 assert!(selector.labels.is_empty());
2204 assert!(selector.prefer_labels.is_empty());
2205 }
2206
2207 #[test]
2208 fn test_mixed_node_modes_in_deployment() {
2209 let yaml = r"
2210version: v1
2211deployment: test
2212services:
2213 redis:
2214 rtype: service
2215 image:
2216 name: redis:alpine
2217 # Default shared mode
2218 api:
2219 rtype: service
2220 image:
2221 name: api:latest
2222 node_mode: dedicated
2223 database:
2224 rtype: service
2225 image:
2226 name: postgres:15
2227 node_mode: exclusive
2228 node_selector:
2229 labels:
2230 storage: ssd
2231";
2232
2233 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2234 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
2235 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2236 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2237
2238 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
2239 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
2240 }
2241
2242 #[test]
2243 fn test_storage_bind_mount() {
2244 let yaml = r"
2245version: v1
2246deployment: test
2247services:
2248 app:
2249 image:
2250 name: app:latest
2251 storage:
2252 - type: bind
2253 source: /host/data
2254 target: /app/data
2255 readonly: true
2256";
2257 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2258 let storage = &spec.services["app"].storage;
2259 assert_eq!(storage.len(), 1);
2260 match &storage[0] {
2261 StorageSpec::Bind {
2262 source,
2263 target,
2264 readonly,
2265 } => {
2266 assert_eq!(source, "/host/data");
2267 assert_eq!(target, "/app/data");
2268 assert!(*readonly);
2269 }
2270 _ => panic!("Expected Bind storage"),
2271 }
2272 }
2273
2274 #[test]
2275 fn test_storage_named_with_tier() {
2276 let yaml = r"
2277version: v1
2278deployment: test
2279services:
2280 app:
2281 image:
2282 name: app:latest
2283 storage:
2284 - type: named
2285 name: my-data
2286 target: /app/data
2287 tier: cached
2288";
2289 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2290 let storage = &spec.services["app"].storage;
2291 match &storage[0] {
2292 StorageSpec::Named {
2293 name, target, tier, ..
2294 } => {
2295 assert_eq!(name, "my-data");
2296 assert_eq!(target, "/app/data");
2297 assert_eq!(*tier, StorageTier::Cached);
2298 }
2299 _ => panic!("Expected Named storage"),
2300 }
2301 }
2302
2303 #[test]
2304 fn test_storage_anonymous() {
2305 let yaml = r"
2306version: v1
2307deployment: test
2308services:
2309 app:
2310 image:
2311 name: app:latest
2312 storage:
2313 - type: anonymous
2314 target: /app/cache
2315";
2316 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2317 let storage = &spec.services["app"].storage;
2318 match &storage[0] {
2319 StorageSpec::Anonymous { target, tier } => {
2320 assert_eq!(target, "/app/cache");
2321 assert_eq!(*tier, StorageTier::Local); }
2323 _ => panic!("Expected Anonymous storage"),
2324 }
2325 }
2326
2327 #[test]
2328 fn test_storage_tmpfs() {
2329 let yaml = r"
2330version: v1
2331deployment: test
2332services:
2333 app:
2334 image:
2335 name: app:latest
2336 storage:
2337 - type: tmpfs
2338 target: /app/tmp
2339 size: 256Mi
2340 mode: 1777
2341";
2342 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2343 let storage = &spec.services["app"].storage;
2344 match &storage[0] {
2345 StorageSpec::Tmpfs { target, size, mode } => {
2346 assert_eq!(target, "/app/tmp");
2347 assert_eq!(size.as_deref(), Some("256Mi"));
2348 assert_eq!(*mode, Some(1777));
2349 }
2350 _ => panic!("Expected Tmpfs storage"),
2351 }
2352 }
2353
2354 #[test]
2355 fn test_storage_s3() {
2356 let yaml = r"
2357version: v1
2358deployment: test
2359services:
2360 app:
2361 image:
2362 name: app:latest
2363 storage:
2364 - type: s3
2365 bucket: my-bucket
2366 prefix: models/
2367 target: /app/models
2368 readonly: true
2369 endpoint: https://s3.us-west-2.amazonaws.com
2370 credentials: aws-creds
2371";
2372 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2373 let storage = &spec.services["app"].storage;
2374 match &storage[0] {
2375 StorageSpec::S3 {
2376 bucket,
2377 prefix,
2378 target,
2379 readonly,
2380 endpoint,
2381 credentials,
2382 } => {
2383 assert_eq!(bucket, "my-bucket");
2384 assert_eq!(prefix.as_deref(), Some("models/"));
2385 assert_eq!(target, "/app/models");
2386 assert!(*readonly);
2387 assert_eq!(
2388 endpoint.as_deref(),
2389 Some("https://s3.us-west-2.amazonaws.com")
2390 );
2391 assert_eq!(credentials.as_deref(), Some("aws-creds"));
2392 }
2393 _ => panic!("Expected S3 storage"),
2394 }
2395 }
2396
2397 #[test]
2398 fn test_storage_multiple_types() {
2399 let yaml = r"
2400version: v1
2401deployment: test
2402services:
2403 app:
2404 image:
2405 name: app:latest
2406 storage:
2407 - type: bind
2408 source: /etc/config
2409 target: /app/config
2410 readonly: true
2411 - type: named
2412 name: app-data
2413 target: /app/data
2414 - type: tmpfs
2415 target: /app/tmp
2416";
2417 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2418 let storage = &spec.services["app"].storage;
2419 assert_eq!(storage.len(), 3);
2420 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
2421 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
2422 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
2423 }
2424
2425 #[test]
2426 fn test_storage_tier_default() {
2427 let yaml = r"
2428version: v1
2429deployment: test
2430services:
2431 app:
2432 image:
2433 name: app:latest
2434 storage:
2435 - type: named
2436 name: data
2437 target: /data
2438";
2439 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2440 match &spec.services["app"].storage[0] {
2441 StorageSpec::Named { tier, .. } => {
2442 assert_eq!(*tier, StorageTier::Local); }
2444 _ => panic!("Expected Named storage"),
2445 }
2446 }
2447
2448 #[test]
2453 fn test_endpoint_tunnel_config_basic() {
2454 let yaml = r"
2455version: v1
2456deployment: test
2457services:
2458 api:
2459 image:
2460 name: api:latest
2461 endpoints:
2462 - name: http
2463 protocol: http
2464 port: 8080
2465 tunnel:
2466 enabled: true
2467 remote_port: 8080
2468";
2469 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2470 let endpoint = &spec.services["api"].endpoints[0];
2471 let tunnel = endpoint.tunnel.as_ref().unwrap();
2472 assert!(tunnel.enabled);
2473 assert_eq!(tunnel.remote_port, 8080);
2474 assert!(tunnel.from.is_none());
2475 assert!(tunnel.to.is_none());
2476 }
2477
2478 #[test]
2479 fn test_endpoint_tunnel_config_full() {
2480 let yaml = r"
2481version: v1
2482deployment: test
2483services:
2484 api:
2485 image:
2486 name: api:latest
2487 endpoints:
2488 - name: http
2489 protocol: http
2490 port: 8080
2491 tunnel:
2492 enabled: true
2493 from: node-1
2494 to: ingress-node
2495 remote_port: 9000
2496 expose: public
2497 access:
2498 enabled: true
2499 max_ttl: 4h
2500 audit: true
2501";
2502 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2503 let endpoint = &spec.services["api"].endpoints[0];
2504 let tunnel = endpoint.tunnel.as_ref().unwrap();
2505 assert!(tunnel.enabled);
2506 assert_eq!(tunnel.from, Some("node-1".to_string()));
2507 assert_eq!(tunnel.to, Some("ingress-node".to_string()));
2508 assert_eq!(tunnel.remote_port, 9000);
2509 assert_eq!(tunnel.expose, Some(ExposeType::Public));
2510
2511 let access = tunnel.access.as_ref().unwrap();
2512 assert!(access.enabled);
2513 assert_eq!(access.max_ttl, Some("4h".to_string()));
2514 assert!(access.audit);
2515 }
2516
2517 #[test]
2518 fn test_top_level_tunnel_definition() {
2519 let yaml = r"
2520version: v1
2521deployment: test
2522services: {}
2523tunnels:
2524 db-tunnel:
2525 from: app-node
2526 to: db-node
2527 local_port: 5432
2528 remote_port: 5432
2529 protocol: tcp
2530 expose: internal
2531";
2532 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2533 let tunnel = spec.tunnels.get("db-tunnel").unwrap();
2534 assert_eq!(tunnel.from, "app-node");
2535 assert_eq!(tunnel.to, "db-node");
2536 assert_eq!(tunnel.local_port, 5432);
2537 assert_eq!(tunnel.remote_port, 5432);
2538 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
2539 assert_eq!(tunnel.expose, ExposeType::Internal);
2540 }
2541
2542 #[test]
2543 fn test_top_level_tunnel_defaults() {
2544 let yaml = r"
2545version: v1
2546deployment: test
2547services: {}
2548tunnels:
2549 simple-tunnel:
2550 from: node-a
2551 to: node-b
2552 local_port: 3000
2553 remote_port: 3000
2554";
2555 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2556 let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
2557 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
2560
2561 #[test]
2562 fn test_tunnel_protocol_udp() {
2563 let yaml = r"
2564version: v1
2565deployment: test
2566services: {}
2567tunnels:
2568 udp-tunnel:
2569 from: node-a
2570 to: node-b
2571 local_port: 5353
2572 remote_port: 5353
2573 protocol: udp
2574";
2575 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2576 let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
2577 assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
2578 }
2579
2580 #[test]
2581 fn test_endpoint_without_tunnel() {
2582 let yaml = r"
2583version: v1
2584deployment: test
2585services:
2586 api:
2587 image:
2588 name: api:latest
2589 endpoints:
2590 - name: http
2591 protocol: http
2592 port: 8080
2593";
2594 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2595 let endpoint = &spec.services["api"].endpoints[0];
2596 assert!(endpoint.tunnel.is_none());
2597 }
2598
2599 #[test]
2600 fn test_deployment_without_tunnels() {
2601 let yaml = r"
2602version: v1
2603deployment: test
2604services:
2605 api:
2606 image:
2607 name: api:latest
2608";
2609 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2610 assert!(spec.tunnels.is_empty());
2611 }
2612
2613 #[test]
2618 fn test_spec_without_api_block_uses_defaults() {
2619 let yaml = r"
2620version: v1
2621deployment: test
2622services:
2623 hello:
2624 image:
2625 name: hello-world:latest
2626";
2627 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2628 assert!(spec.api.enabled);
2629 assert_eq!(spec.api.bind, "0.0.0.0:3669");
2630 assert!(spec.api.jwt_secret.is_none());
2631 assert!(spec.api.swagger);
2632 }
2633
2634 #[test]
2635 fn test_spec_with_explicit_api_block() {
2636 let yaml = r#"
2637version: v1
2638deployment: test
2639services:
2640 hello:
2641 image:
2642 name: hello-world:latest
2643api:
2644 enabled: false
2645 bind: "127.0.0.1:9090"
2646 jwt_secret: "my-secret"
2647 swagger: false
2648"#;
2649 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2650 assert!(!spec.api.enabled);
2651 assert_eq!(spec.api.bind, "127.0.0.1:9090");
2652 assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
2653 assert!(!spec.api.swagger);
2654 }
2655
2656 #[test]
2657 fn test_spec_with_partial_api_block() {
2658 let yaml = r#"
2659version: v1
2660deployment: test
2661services:
2662 hello:
2663 image:
2664 name: hello-world:latest
2665api:
2666 bind: "0.0.0.0:3000"
2667"#;
2668 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2669 assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
2671 assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
2674
2675 #[test]
2680 fn test_network_policy_spec_roundtrip() {
2681 let spec = NetworkPolicySpec {
2682 name: "corp-vpn".to_string(),
2683 description: Some("Corporate VPN network".to_string()),
2684 cidrs: vec!["10.200.0.0/16".to_string()],
2685 members: vec![
2686 NetworkMember {
2687 name: "alice".to_string(),
2688 kind: MemberKind::User,
2689 },
2690 NetworkMember {
2691 name: "ops-team".to_string(),
2692 kind: MemberKind::Group,
2693 },
2694 NetworkMember {
2695 name: "node-01".to_string(),
2696 kind: MemberKind::Node,
2697 },
2698 ],
2699 access_rules: vec![
2700 AccessRule {
2701 service: "api-gateway".to_string(),
2702 deployment: "*".to_string(),
2703 ports: Some(vec![443, 8080]),
2704 action: AccessAction::Allow,
2705 },
2706 AccessRule {
2707 service: "*".to_string(),
2708 deployment: "staging".to_string(),
2709 ports: None,
2710 action: AccessAction::Deny,
2711 },
2712 ],
2713 };
2714
2715 let yaml = serde_yaml::to_string(&spec).unwrap();
2716 let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
2717 assert_eq!(spec, deserialized);
2718 }
2719
2720 #[test]
2721 fn test_network_policy_spec_defaults() {
2722 let yaml = r"
2723name: minimal
2724";
2725 let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
2726 assert_eq!(spec.name, "minimal");
2727 assert!(spec.description.is_none());
2728 assert!(spec.cidrs.is_empty());
2729 assert!(spec.members.is_empty());
2730 assert!(spec.access_rules.is_empty());
2731 }
2732
2733 #[test]
2734 fn test_access_rule_defaults() {
2735 let yaml = "{}";
2736 let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
2737 assert_eq!(rule.service, "*");
2738 assert_eq!(rule.deployment, "*");
2739 assert!(rule.ports.is_none());
2740 assert_eq!(rule.action, AccessAction::Allow);
2741 }
2742
2743 #[test]
2744 fn test_member_kind_defaults_to_user() {
2745 let yaml = r"
2746name: bob
2747";
2748 let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
2749 assert_eq!(member.name, "bob");
2750 assert_eq!(member.kind, MemberKind::User);
2751 }
2752
2753 #[test]
2754 fn test_member_kind_variants() {
2755 for (input, expected) in [
2756 ("user", MemberKind::User),
2757 ("group", MemberKind::Group),
2758 ("node", MemberKind::Node),
2759 ("cidr", MemberKind::Cidr),
2760 ] {
2761 let yaml = format!("name: test\nkind: {input}");
2762 let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
2763 assert_eq!(member.kind, expected);
2764 }
2765 }
2766
2767 #[test]
2768 fn test_access_action_variants() {
2769 #[derive(Debug, Deserialize)]
2771 struct Wrapper {
2772 action: AccessAction,
2773 }
2774
2775 let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
2776 let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
2777
2778 assert_eq!(allow.action, AccessAction::Allow);
2779 assert_eq!(deny.action, AccessAction::Deny);
2780 }
2781
2782 #[test]
2783 fn test_network_policy_spec_default_impl() {
2784 let spec = NetworkPolicySpec::default();
2785 assert_eq!(spec.name, "");
2786 assert!(spec.description.is_none());
2787 assert!(spec.cidrs.is_empty());
2788 assert!(spec.members.is_empty());
2789 assert!(spec.access_rules.is_empty());
2790 }
2791
2792 #[test]
2793 fn container_restart_policy_serde_roundtrip_all_kinds() {
2794 let cases = [
2799 (
2800 ContainerRestartPolicy {
2801 kind: ContainerRestartKind::No,
2802 max_attempts: None,
2803 delay: None,
2804 },
2805 r#"{"kind":"no"}"#,
2806 ),
2807 (
2808 ContainerRestartPolicy {
2809 kind: ContainerRestartKind::Always,
2810 max_attempts: None,
2811 delay: Some("500ms".to_string()),
2812 },
2813 r#"{"kind":"always","delay":"500ms"}"#,
2814 ),
2815 (
2816 ContainerRestartPolicy {
2817 kind: ContainerRestartKind::UnlessStopped,
2818 max_attempts: None,
2819 delay: None,
2820 },
2821 r#"{"kind":"unless_stopped"}"#,
2822 ),
2823 (
2824 ContainerRestartPolicy {
2825 kind: ContainerRestartKind::OnFailure,
2826 max_attempts: Some(5),
2827 delay: None,
2828 },
2829 r#"{"kind":"on_failure","max_attempts":5}"#,
2830 ),
2831 ];
2832
2833 for (value, expected_json) in &cases {
2834 let serialized = serde_json::to_string(value).expect("serialize");
2835 assert_eq!(&serialized, expected_json, "serialize mismatch");
2836 let round: ContainerRestartPolicy =
2837 serde_json::from_str(&serialized).expect("deserialize");
2838 assert_eq!(&round, value, "roundtrip mismatch");
2839 }
2840 }
2841
2842 #[test]
2845 fn registry_auth_type_serializes_snake_case() {
2846 assert_eq!(
2847 serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
2848 "\"basic\""
2849 );
2850 assert_eq!(
2851 serde_json::to_string(&RegistryAuthType::Token).unwrap(),
2852 "\"token\""
2853 );
2854 }
2855
2856 #[test]
2857 fn registry_auth_default_auth_type_is_basic() {
2858 let json = r#"{"username":"u","password":"p"}"#;
2860 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
2861 assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
2862 assert_eq!(parsed.username, "u");
2863 assert_eq!(parsed.password, "p");
2864 }
2865
2866 #[test]
2867 fn registry_auth_serde_roundtrip_both_variants() {
2868 for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
2869 let cred = RegistryAuth {
2870 username: "ci-bot".to_string(),
2871 password: "s3cret".to_string(),
2872 auth_type: variant,
2873 };
2874 let serialized = serde_json::to_string(&cred).expect("serialize");
2875 let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
2876 assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
2877 }
2878 }
2879
2880 #[test]
2881 fn registry_auth_explicit_token_type_parses() {
2882 let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
2883 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
2884 assert_eq!(parsed.auth_type, RegistryAuthType::Token);
2885 }
2886}