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 crate::secrets::SecretScope;
65use serde::{Deserialize, Serialize};
66use std::collections::HashMap;
67use validator::Validate;
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum NodeMode {
73 #[default]
75 Shared,
76 Dedicated,
78 Exclusive,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum ServiceType {
86 #[default]
88 Standard,
89 WasmHttp,
91 WasmPlugin,
93 WasmTransformer,
95 WasmAuthenticator,
97 WasmRateLimiter,
99 WasmMiddleware,
101 WasmRouter,
103 Job,
105}
106
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
109#[serde(rename_all = "snake_case")]
110pub enum StorageTier {
111 #[default]
113 Local,
114 Cached,
116 Network,
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
122#[serde(deny_unknown_fields)]
123pub struct NodeSelector {
124 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
126 pub labels: HashMap<String, String>,
127 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
129 pub prefer_labels: HashMap<String, String>,
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
140#[serde(rename_all = "snake_case", deny_unknown_fields)]
141pub enum GroupAffinity {
142 #[default]
144 Spread,
145 Pack,
147 Pin(String),
153}
154
155static REPLICA_GROUP_ROLE_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
159 regex::Regex::new(r"^[a-z]([a-z0-9-]{0,28}[a-z0-9])?$").expect("valid regex literal")
160});
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
173#[serde(deny_unknown_fields)]
174pub struct ReplicaGroup {
175 #[validate(length(min = 1, max = 30))]
179 #[validate(regex(path = *REPLICA_GROUP_ROLE_RE))]
180 pub role: String,
181
182 #[validate(range(min = 1))]
184 pub count: u32,
185
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub image: Option<ImageSpec>,
189
190 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
193 pub env: HashMap<String, String>,
194
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub command: Option<CommandSpec>,
198
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub resources: Option<ResourcesSpec>,
202
203 #[serde(default)]
205 pub affinity: GroupAffinity,
206}
207
208pub fn validate_unique_replica_group_roles(groups: &[ReplicaGroup]) -> Result<(), String> {
219 let mut seen = std::collections::HashSet::new();
220 for g in groups {
221 if !seen.insert(g.role.as_str()) {
222 return Err(g.role.clone());
223 }
224 }
225 Ok(())
226}
227
228#[derive(
233 Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
234)]
235#[serde(rename_all = "lowercase")]
236pub enum OsKind {
237 Linux,
238 Windows,
239 Macos,
240}
241
242impl OsKind {
243 #[must_use]
246 pub const fn as_oci_str(self) -> &'static str {
247 match self {
248 OsKind::Linux => "linux",
249 OsKind::Windows => "windows",
250 OsKind::Macos => "darwin",
251 }
252 }
253
254 #[must_use]
256 pub fn from_rust_os(s: &str) -> Option<Self> {
257 match s {
258 "linux" => Some(Self::Linux),
259 "windows" => Some(Self::Windows),
260 "macos" => Some(Self::Macos),
261 _ => None,
262 }
263 }
264
265 #[must_use]
272 pub fn from_oci_str(s: &str) -> Option<Self> {
273 match s {
274 "linux" => Some(Self::Linux),
275 "windows" => Some(Self::Windows),
276 "darwin" => Some(Self::Macos),
277 _ => None,
278 }
279 }
280}
281
282#[derive(
284 Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
285)]
286#[serde(rename_all = "lowercase")]
287pub enum ArchKind {
288 Amd64,
289 Arm64,
290}
291
292impl ArchKind {
293 #[must_use]
295 pub const fn as_oci_str(self) -> &'static str {
296 match self {
297 ArchKind::Amd64 => "amd64",
298 ArchKind::Arm64 => "arm64",
299 }
300 }
301
302 #[must_use]
304 pub fn from_rust_arch(s: &str) -> Option<Self> {
305 match s {
306 "x86_64" => Some(Self::Amd64),
307 "aarch64" => Some(Self::Arm64),
308 _ => None,
309 }
310 }
311}
312
313#[derive(
319 Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
320)]
321pub struct TargetPlatform {
322 pub os: OsKind,
323 pub arch: ArchKind,
324 #[serde(default, rename = "osVersion", skip_serializing_if = "Option::is_none")]
332 pub os_version: Option<String>,
333}
334
335impl TargetPlatform {
336 #[must_use]
337 pub const fn new(os: OsKind, arch: ArchKind) -> Self {
338 Self {
339 os,
340 arch,
341 os_version: None,
342 }
343 }
344
345 #[must_use]
351 pub fn with_os_version(mut self, v: impl Into<String>) -> Self {
352 self.os_version = Some(v.into());
353 self
354 }
355
356 #[must_use]
362 pub fn as_oci_str(self) -> String {
363 format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
364 }
365
366 #[must_use]
370 pub fn as_detailed_str(&self) -> String {
371 match &self.os_version {
372 Some(v) => format!(
373 "{}/{} (os.version={v})",
374 self.os.as_oci_str(),
375 self.arch.as_oci_str()
376 ),
377 None => format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str()),
378 }
379 }
380}
381
382impl std::fmt::Display for TargetPlatform {
383 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384 write!(f, "{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
385 }
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
391#[serde(deny_unknown_fields)]
392#[allow(clippy::struct_excessive_bools)]
393pub struct WasmCapabilities {
394 #[serde(default = "default_true")]
396 pub config: bool,
397 #[serde(default = "default_true")]
399 pub keyvalue: bool,
400 #[serde(default = "default_true")]
402 pub logging: bool,
403 #[serde(default)]
405 pub secrets: bool,
406 #[serde(default = "default_true")]
408 pub metrics: bool,
409 #[serde(default)]
411 pub http_client: bool,
412 #[serde(default)]
414 pub cli: bool,
415 #[serde(default)]
417 pub filesystem: bool,
418 #[serde(default)]
420 pub sockets: bool,
421}
422
423impl Default for WasmCapabilities {
424 fn default() -> Self {
425 Self {
426 config: true,
427 keyvalue: true,
428 logging: true,
429 secrets: false,
430 metrics: true,
431 http_client: false,
432 cli: false,
433 filesystem: false,
434 sockets: false,
435 }
436 }
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
441#[serde(deny_unknown_fields)]
442pub struct WasmPreopen {
443 pub source: String,
445 pub target: String,
447 #[serde(default)]
449 pub readonly: bool,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
457#[serde(deny_unknown_fields)]
458#[allow(clippy::struct_excessive_bools)]
459pub struct WasmConfig {
460 #[serde(default = "default_min_instances")]
463 pub min_instances: u32,
464 #[serde(default = "default_max_instances")]
466 pub max_instances: u32,
467 #[serde(default = "default_idle_timeout", with = "duration::required")]
469 pub idle_timeout: std::time::Duration,
470 #[serde(default = "default_request_timeout", with = "duration::required")]
472 pub request_timeout: std::time::Duration,
473
474 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub max_memory: Option<String>,
478 #[serde(default)]
480 pub max_fuel: u64,
481 #[serde(
483 default,
484 skip_serializing_if = "Option::is_none",
485 with = "duration::option"
486 )]
487 pub epoch_interval: Option<std::time::Duration>,
488
489 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub capabilities: Option<WasmCapabilities>,
493
494 #[serde(default = "default_true")]
497 pub allow_http_outgoing: bool,
498 #[serde(default, skip_serializing_if = "Vec::is_empty")]
500 pub allowed_hosts: Vec<String>,
501 #[serde(default)]
503 pub allow_tcp: bool,
504 #[serde(default)]
506 pub allow_udp: bool,
507
508 #[serde(default, skip_serializing_if = "Vec::is_empty")]
511 pub preopens: Vec<WasmPreopen>,
512 #[serde(default = "default_true")]
514 pub kv_enabled: bool,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub kv_namespace: Option<String>,
518 #[serde(default = "default_kv_max_value_size")]
520 pub kv_max_value_size: u64,
521
522 #[serde(default, skip_serializing_if = "Vec::is_empty")]
525 pub secrets: Vec<String>,
526
527 #[serde(default = "default_true")]
530 pub precompile: bool,
531}
532
533fn default_kv_max_value_size() -> u64 {
534 1_048_576 }
536
537impl Default for WasmConfig {
538 fn default() -> Self {
539 Self {
540 min_instances: default_min_instances(),
541 max_instances: default_max_instances(),
542 idle_timeout: default_idle_timeout(),
543 request_timeout: default_request_timeout(),
544 max_memory: None,
545 max_fuel: 0,
546 epoch_interval: None,
547 capabilities: None,
548 allow_http_outgoing: true,
549 allowed_hosts: Vec::new(),
550 allow_tcp: false,
551 allow_udp: false,
552 preopens: Vec::new(),
553 kv_enabled: true,
554 kv_namespace: None,
555 kv_max_value_size: default_kv_max_value_size(),
556 secrets: Vec::new(),
557 precompile: true,
558 }
559 }
560}
561
562#[deprecated(note = "Use WasmConfig instead")]
564#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
565#[serde(deny_unknown_fields)]
566pub struct WasmHttpConfig {
567 #[serde(default = "default_min_instances")]
569 pub min_instances: u32,
570 #[serde(default = "default_max_instances")]
572 pub max_instances: u32,
573 #[serde(default = "default_idle_timeout", with = "duration::required")]
575 pub idle_timeout: std::time::Duration,
576 #[serde(default = "default_request_timeout", with = "duration::required")]
578 pub request_timeout: std::time::Duration,
579}
580
581fn default_min_instances() -> u32 {
582 0
583}
584
585fn default_max_instances() -> u32 {
586 10
587}
588
589fn default_idle_timeout() -> std::time::Duration {
590 std::time::Duration::from_secs(300)
591}
592
593fn default_request_timeout() -> std::time::Duration {
594 std::time::Duration::from_secs(30)
595}
596
597#[allow(deprecated)]
598impl Default for WasmHttpConfig {
599 fn default() -> Self {
600 Self {
601 min_instances: default_min_instances(),
602 max_instances: default_max_instances(),
603 idle_timeout: default_idle_timeout(),
604 request_timeout: default_request_timeout(),
605 }
606 }
607}
608
609#[allow(deprecated)]
610impl From<WasmHttpConfig> for WasmConfig {
611 fn from(old: WasmHttpConfig) -> Self {
612 Self {
613 min_instances: old.min_instances,
614 max_instances: old.max_instances,
615 idle_timeout: old.idle_timeout,
616 request_timeout: old.request_timeout,
617 ..Default::default()
618 }
619 }
620}
621
622impl ServiceType {
623 #[must_use]
625 pub fn is_wasm(&self) -> bool {
626 matches!(
627 self,
628 ServiceType::WasmHttp
629 | ServiceType::WasmPlugin
630 | ServiceType::WasmTransformer
631 | ServiceType::WasmAuthenticator
632 | ServiceType::WasmRateLimiter
633 | ServiceType::WasmMiddleware
634 | ServiceType::WasmRouter
635 )
636 }
637
638 #[must_use]
641 pub fn default_wasm_capabilities(&self) -> Option<WasmCapabilities> {
642 match self {
643 ServiceType::WasmHttp | ServiceType::WasmRouter => Some(WasmCapabilities {
644 config: true,
645 keyvalue: true,
646 logging: true,
647 secrets: false,
648 metrics: false,
649 http_client: true,
650 cli: false,
651 filesystem: false,
652 sockets: false,
653 }),
654 ServiceType::WasmPlugin => Some(WasmCapabilities {
655 config: true,
656 keyvalue: true,
657 logging: true,
658 secrets: true,
659 metrics: true,
660 http_client: true,
661 cli: true,
662 filesystem: true,
663 sockets: false,
664 }),
665 ServiceType::WasmTransformer => Some(WasmCapabilities {
666 config: false,
667 keyvalue: false,
668 logging: true,
669 secrets: false,
670 metrics: false,
671 http_client: false,
672 cli: true,
673 filesystem: false,
674 sockets: false,
675 }),
676 ServiceType::WasmAuthenticator => Some(WasmCapabilities {
677 config: true,
678 keyvalue: false,
679 logging: true,
680 secrets: true,
681 metrics: false,
682 http_client: true,
683 cli: false,
684 filesystem: false,
685 sockets: false,
686 }),
687 ServiceType::WasmRateLimiter => Some(WasmCapabilities {
688 config: true,
689 keyvalue: true,
690 logging: true,
691 secrets: false,
692 metrics: true,
693 http_client: false,
694 cli: true,
695 filesystem: false,
696 sockets: false,
697 }),
698 ServiceType::WasmMiddleware => Some(WasmCapabilities {
699 config: true,
700 keyvalue: false,
701 logging: true,
702 secrets: false,
703 metrics: false,
704 http_client: true,
705 cli: false,
706 filesystem: false,
707 sockets: false,
708 }),
709 _ => None,
710 }
711 }
712}
713
714fn default_api_bind() -> String {
715 "0.0.0.0:3669".to_string()
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
720pub struct ApiSpec {
721 #[serde(default = "default_true")]
723 pub enabled: bool,
724 #[serde(default = "default_api_bind")]
726 pub bind: String,
727 #[serde(default)]
729 pub jwt_secret: Option<String>,
730 #[serde(default = "default_true")]
732 pub swagger: bool,
733}
734
735impl Default for ApiSpec {
736 fn default() -> Self {
737 Self {
738 enabled: true,
739 bind: default_api_bind(),
740 jwt_secret: None,
741 swagger: true,
742 }
743 }
744}
745
746#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
748#[serde(deny_unknown_fields)]
749pub struct DeploymentSpec {
750 #[validate(custom(function = "crate::spec::validate::validate_version_wrapper"))]
752 pub version: String,
753
754 #[validate(custom(function = "crate::spec::validate::validate_deployment_name_wrapper"))]
756 pub deployment: String,
757
758 #[serde(default)]
760 #[validate(nested)]
761 pub services: HashMap<String, ServiceSpec>,
762
763 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
770 #[validate(nested)]
771 pub externals: HashMap<String, ExternalSpec>,
772
773 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
775 pub tunnels: HashMap<String, TunnelDefinition>,
776
777 #[serde(default)]
779 pub api: ApiSpec,
780
781 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub environment: Option<String>,
785
786 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub project: Option<String>,
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
798#[serde(deny_unknown_fields)]
799pub struct ExternalSpec {
800 #[validate(length(min = 1, message = "at least one backend address is required"))]
805 pub backends: Vec<String>,
806
807 #[serde(default)]
811 #[validate(nested)]
812 pub endpoints: Vec<EndpointSpec>,
813
814 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub health: Option<HealthSpec>,
820}
821
822#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
824#[serde(deny_unknown_fields)]
825pub struct TunnelDefinition {
826 pub from: String,
828
829 pub to: String,
831
832 pub local_port: u16,
834
835 pub remote_port: u16,
837
838 #[serde(default)]
840 pub protocol: TunnelProtocol,
841
842 #[serde(default)]
844 pub expose: ExposeType,
845}
846
847#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
849#[serde(rename_all = "lowercase")]
850pub enum TunnelProtocol {
851 #[default]
852 Tcp,
853 Udp,
854}
855
856#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
858pub struct LogsConfig {
859 #[serde(default = "default_logs_destination")]
861 pub destination: String,
862
863 #[serde(default = "default_logs_max_size")]
865 pub max_size_bytes: u64,
866
867 #[serde(default = "default_logs_retention")]
869 pub retention_secs: u64,
870}
871
872fn default_logs_destination() -> String {
873 "disk".to_string()
874}
875
876fn default_logs_max_size() -> u64 {
877 100 * 1024 * 1024 }
879
880fn default_logs_retention() -> u64 {
881 7 * 24 * 60 * 60 }
883
884impl Default for LogsConfig {
885 fn default() -> Self {
886 Self {
887 destination: default_logs_destination(),
888 max_size_bytes: default_logs_max_size(),
889 retention_secs: default_logs_retention(),
890 }
891 }
892}
893
894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
900#[serde(rename_all = "lowercase")]
901pub enum NetworkMode {
902 #[default]
904 Default,
905 Host,
907 None,
909 Bridge {
912 #[serde(default)]
913 name: Option<String>,
914 },
915 Container { id: String },
918}
919
920#[allow(clippy::too_many_lines)]
927fn deserialize_network_mode<'de, D>(deserializer: D) -> Result<NetworkMode, D::Error>
928where
929 D: serde::Deserializer<'de>,
930{
931 use serde::de::Error;
932
933 fn from_str<E: Error>(s: &str) -> Result<NetworkMode, E> {
936 match s {
937 "default" => Ok(NetworkMode::Default),
938 "host" => Ok(NetworkMode::Host),
939 "none" => Ok(NetworkMode::None),
940 "bridge" => Ok(NetworkMode::Bridge { name: None }),
941 _ => {
942 if let Some(rest) = s.strip_prefix("bridge:") {
943 if rest.is_empty() {
944 Ok(NetworkMode::Bridge { name: None })
945 } else {
946 Ok(NetworkMode::Bridge {
947 name: Some(rest.to_string()),
948 })
949 }
950 } else if let Some(rest) = s.strip_prefix("container:") {
951 if rest.is_empty() {
952 Err(E::custom(
953 "network mode \"container:<id>\" requires a non-empty id",
954 ))
955 } else {
956 Ok(NetworkMode::Container {
957 id: rest.to_string(),
958 })
959 }
960 } else {
961 Err(E::custom(format!("unknown network mode: {s}")))
962 }
963 }
964 }
965 }
966
967 #[derive(Deserialize)]
979 struct BridgeFields {
980 #[serde(default)]
981 name: Option<String>,
982 }
983 #[derive(Deserialize)]
984 struct ContainerFields {
985 id: String,
986 }
987
988 struct NetworkModeVisitor;
989
990 impl<'de> serde::de::Visitor<'de> for NetworkModeVisitor {
991 type Value = NetworkMode;
992
993 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
994 f.write_str(
995 "a network mode string (\"host\", \"bridge:<name>\", …) or an \
996 externally-tagged map ({ bridge: { name } } / { container: { id } })",
997 )
998 }
999
1000 fn visit_str<E: Error>(self, s: &str) -> Result<NetworkMode, E> {
1001 from_str(s)
1002 }
1003
1004 fn visit_string<E: Error>(self, s: String) -> Result<NetworkMode, E> {
1005 from_str(&s)
1006 }
1007
1008 fn visit_map<A>(self, mut map: A) -> Result<NetworkMode, A::Error>
1010 where
1011 A: serde::de::MapAccess<'de>,
1012 {
1013 let key: String = map
1014 .next_key()?
1015 .ok_or_else(|| A::Error::custom("empty network mode map"))?;
1016 let mode = match key.as_str() {
1017 "bridge" => {
1018 let b: BridgeFields = map.next_value()?;
1019 NetworkMode::Bridge { name: b.name }
1020 }
1021 "container" => {
1022 let c: ContainerFields = map.next_value()?;
1023 NetworkMode::Container { id: c.id }
1024 }
1025 "default" => {
1028 let _: serde::de::IgnoredAny = map.next_value()?;
1029 NetworkMode::Default
1030 }
1031 "host" => {
1032 let _: serde::de::IgnoredAny = map.next_value()?;
1033 NetworkMode::Host
1034 }
1035 "none" => {
1036 let _: serde::de::IgnoredAny = map.next_value()?;
1037 NetworkMode::None
1038 }
1039 other => {
1040 return Err(A::Error::custom(format!(
1041 "unknown network mode variant: {other}"
1042 )));
1043 }
1044 };
1045 if map.next_key::<String>()?.is_some() {
1047 return Err(A::Error::custom(
1048 "network mode map must have exactly one variant key",
1049 ));
1050 }
1051 Ok(mode)
1052 }
1053
1054 fn visit_enum<A>(self, data: A) -> Result<NetworkMode, A::Error>
1057 where
1058 A: serde::de::EnumAccess<'de>,
1059 {
1060 use serde::de::VariantAccess;
1061 let (tag, va): (String, _) = data.variant()?;
1062 match tag.as_str() {
1063 "default" => {
1064 va.unit_variant()?;
1065 Ok(NetworkMode::Default)
1066 }
1067 "host" => {
1068 va.unit_variant()?;
1069 Ok(NetworkMode::Host)
1070 }
1071 "none" => {
1072 va.unit_variant()?;
1073 Ok(NetworkMode::None)
1074 }
1075 "bridge" => {
1076 let b: BridgeFields = va.newtype_variant()?;
1077 Ok(NetworkMode::Bridge { name: b.name })
1078 }
1079 "container" => {
1080 let c: ContainerFields = va.newtype_variant()?;
1081 Ok(NetworkMode::Container { id: c.id })
1082 }
1083 other => Err(<A::Error as Error>::custom(format!(
1084 "unknown network mode variant: {other}"
1085 ))),
1086 }
1087 }
1088 }
1089
1090 deserializer.deserialize_any(NetworkModeVisitor)
1091}
1092
1093#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1099#[serde(rename_all = "kebab-case")]
1100pub enum IsolationMode {
1101 #[default]
1102 Auto,
1103 Process,
1104 Hyperv,
1105}
1106
1107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1113#[serde(rename_all = "kebab-case")]
1114pub enum RuntimeIsolation {
1115 #[default]
1116 Auto,
1117 Sandbox,
1118 Vz,
1119 VzLinux,
1120 Vm,
1121}
1122
1123impl RuntimeIsolation {
1124 #[must_use]
1126 pub fn label_value(self) -> Option<&'static str> {
1127 match self {
1128 RuntimeIsolation::Auto => None,
1129 RuntimeIsolation::Sandbox => Some("sandbox"),
1130 RuntimeIsolation::Vz => Some("vz"),
1131 RuntimeIsolation::VzLinux => Some("vz-linux"),
1132 RuntimeIsolation::Vm => Some("vm"),
1133 }
1134 }
1135}
1136
1137#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq, utoipa::ToSchema)]
1155#[serde(deny_unknown_fields)]
1156pub struct UlimitSpec {
1157 #[serde(default)]
1160 pub soft: i64,
1161 #[serde(default)]
1165 pub hard: i64,
1166}
1167
1168impl<'de> Deserialize<'de> for UlimitSpec {
1169 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1170 where
1171 D: serde::Deserializer<'de>,
1172 {
1173 #[derive(Deserialize)]
1177 #[serde(deny_unknown_fields)]
1178 struct Shadow {
1179 #[serde(default)]
1180 soft: Option<i64>,
1181 #[serde(default)]
1182 hard: Option<i64>,
1183 }
1184
1185 let Shadow { soft, hard } = Shadow::deserialize(deserializer)?;
1186 Ok(match (soft, hard) {
1187 (Some(soft), Some(hard)) => Self { soft, hard },
1188 (Some(soft), None) => Self { soft, hard: soft },
1190 (None, Some(hard)) => Self { soft: hard, hard },
1192 (None, None) => Self::default(),
1193 })
1194 }
1195}
1196
1197#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Validate)]
1199#[serde(from = "ServiceSpecCompat")]
1200#[allow(clippy::struct_excessive_bools)]
1201pub struct ServiceSpec {
1202 #[serde(default, skip_serializing_if = "Option::is_none")]
1212 pub deployment: Option<String>,
1213
1214 #[serde(default, skip_serializing_if = "Option::is_none")]
1219 pub secret_scope: Option<SecretScope>,
1220
1221 #[serde(default = "default_resource_type")]
1223 pub rtype: ResourceType,
1224
1225 #[serde(default, skip_serializing_if = "Option::is_none")]
1232 #[validate(custom(function = "crate::spec::validate::validate_schedule_wrapper"))]
1233 pub schedule: Option<String>,
1234
1235 #[validate(nested)]
1237 pub image: ImageSpec,
1238
1239 #[serde(default)]
1241 #[validate(nested)]
1242 pub resources: ResourcesSpec,
1243
1244 #[serde(default)]
1251 pub env: HashMap<String, String>,
1252
1253 #[serde(default)]
1255 pub command: CommandSpec,
1256
1257 #[serde(default)]
1259 pub network: ServiceNetworkSpec,
1260
1261 #[serde(default)]
1263 #[validate(nested)]
1264 pub endpoints: Vec<EndpointSpec>,
1265
1266 #[serde(default)]
1268 #[validate(custom(function = "crate::spec::validate::validate_scale_spec"))]
1269 pub scale: ScaleSpec,
1270
1271 #[serde(default, skip_serializing_if = "Option::is_none")]
1286 #[validate(nested)]
1287 pub replica_groups: Option<Vec<ReplicaGroup>>,
1288
1289 #[serde(default)]
1291 pub depends: Vec<DependsSpec>,
1292
1293 #[serde(default = "default_health")]
1295 pub health: HealthSpec,
1296
1297 #[serde(default)]
1299 pub init: InitSpec,
1300
1301 #[serde(default)]
1303 pub errors: ErrorsSpec,
1304
1305 #[serde(default)]
1311 pub lifecycle: LifecycleSpec,
1312
1313 #[serde(default, skip_serializing_if = "Option::is_none")]
1315 pub isolation: Option<IsolationMode>,
1316
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1320 pub runtime: Option<RuntimeIsolation>,
1321
1322 #[serde(default)]
1324 pub devices: Vec<DeviceSpec>,
1325
1326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1328 pub storage: Vec<StorageSpec>,
1329
1330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1335 pub port_mappings: Vec<PortMapping>,
1336
1337 #[serde(default, alias = "cap_add", skip_serializing_if = "Vec::is_empty")]
1341 pub capabilities: Vec<String>,
1342
1343 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1345 pub cap_drop: Vec<String>,
1346
1347 #[serde(default)]
1349 pub privileged: bool,
1350
1351 #[serde(default)]
1353 pub node_mode: NodeMode,
1354
1355 #[serde(default, skip_serializing_if = "Option::is_none")]
1357 pub node_selector: Option<NodeSelector>,
1358
1359 #[serde(default, skip_serializing_if = "Option::is_none")]
1371 pub affinity: Option<GroupAffinity>,
1372
1373 #[serde(default, skip_serializing_if = "Option::is_none")]
1377 pub platform: Option<TargetPlatform>,
1378
1379 #[serde(default)]
1381 pub service_type: ServiceType,
1382
1383 #[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
1386 pub wasm: Option<WasmConfig>,
1387
1388 #[serde(default, skip_serializing_if = "Option::is_none")]
1390 pub logs: Option<LogsConfig>,
1391
1392 #[serde(skip)]
1397 pub host_network: bool,
1398
1399 #[serde(default, skip_serializing_if = "Option::is_none")]
1405 pub hostname: Option<String>,
1406
1407 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1413 pub dns: Vec<String>,
1414
1415 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1423 pub dns_search: Vec<String>,
1424
1425 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1433 pub extra_hosts: Vec<String>,
1434
1435 #[serde(default, skip_serializing_if = "Option::is_none")]
1443 pub restart_policy: Option<ContainerRestartPolicy>,
1444
1445 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1448 pub labels: HashMap<String, String>,
1449
1450 #[serde(default, skip_serializing_if = "Option::is_none")]
1453 pub user: Option<String>,
1454
1455 #[serde(default, skip_serializing_if = "Option::is_none")]
1458 pub stop_signal: Option<String>,
1459
1460 #[serde(
1463 default,
1464 with = "duration::option",
1465 skip_serializing_if = "Option::is_none"
1466 )]
1467 pub stop_grace_period: Option<std::time::Duration>,
1468
1469 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1471 pub sysctls: HashMap<String, String>,
1472
1473 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1475 pub ulimits: HashMap<String, UlimitSpec>,
1476
1477 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1480 pub security_opt: Vec<String>,
1481
1482 #[serde(default, skip_serializing_if = "Option::is_none")]
1485 pub pid_mode: Option<String>,
1486
1487 #[serde(default, skip_serializing_if = "Option::is_none")]
1490 pub ipc_mode: Option<String>,
1491
1492 #[serde(default, deserialize_with = "deserialize_network_mode")]
1496 pub network_mode: NetworkMode,
1497
1498 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1501 pub extra_groups: Vec<String>,
1502
1503 #[serde(default)]
1505 pub read_only_root_fs: bool,
1506
1507 #[serde(default, skip_serializing_if = "Option::is_none")]
1511 pub init_container: Option<bool>,
1512
1513 #[serde(default)]
1516 pub tty: bool,
1517
1518 #[serde(default)]
1521 pub stdin_open: bool,
1522
1523 #[serde(default, skip_serializing_if = "Option::is_none")]
1526 pub userns_mode: Option<String>,
1527
1528 #[serde(default, skip_serializing_if = "Option::is_none")]
1531 pub cgroup_parent: Option<String>,
1532
1533 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1539 pub expose: Vec<String>,
1540
1541 #[serde(default, skip_serializing_if = "Option::is_none")]
1548 pub overlay: Option<crate::overlay::OverlayConfig>,
1549
1550 #[serde(default, skip_serializing_if = "LocalhostReachability::is_default")]
1555 pub localhost_reachability: LocalhostReachability,
1556}
1557
1558#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1569#[serde(rename_all = "snake_case")]
1570pub enum LocalhostReachability {
1571 #[default]
1577 Auto,
1578 Always,
1580 Never,
1582}
1583
1584impl LocalhostReachability {
1585 #[must_use]
1588 pub fn is_default(&self) -> bool {
1589 matches!(self, Self::Auto)
1590 }
1591}
1592
1593#[derive(Deserialize)]
1600#[serde(deny_unknown_fields)]
1601#[allow(clippy::struct_excessive_bools)]
1602struct ServiceSpecCompat {
1603 #[serde(default)]
1604 deployment: Option<String>,
1605 #[serde(default)]
1606 secret_scope: Option<SecretScope>,
1607 #[serde(default = "default_resource_type")]
1608 rtype: ResourceType,
1609 #[serde(default)]
1610 schedule: Option<String>,
1611 image: ImageSpec,
1612 #[serde(default)]
1613 resources: ResourcesSpec,
1614 #[serde(default)]
1615 env: HashMap<String, String>,
1616 #[serde(default)]
1617 command: CommandSpec,
1618 #[serde(default)]
1619 network: ServiceNetworkSpec,
1620 #[serde(default)]
1621 endpoints: Vec<EndpointSpec>,
1622 #[serde(default)]
1623 scale: ScaleSpec,
1624 #[serde(default)]
1625 replica_groups: Option<Vec<ReplicaGroup>>,
1626 #[serde(default)]
1627 depends: Vec<DependsSpec>,
1628 #[serde(default = "default_health")]
1629 health: HealthSpec,
1630 #[serde(default)]
1631 init: InitSpec,
1632 #[serde(default)]
1633 errors: ErrorsSpec,
1634 #[serde(default)]
1635 lifecycle: LifecycleSpec,
1636 #[serde(default)]
1637 isolation: Option<IsolationMode>,
1638 #[serde(default, skip_serializing_if = "Option::is_none")]
1639 runtime: Option<RuntimeIsolation>,
1640 #[serde(default)]
1641 devices: Vec<DeviceSpec>,
1642 #[serde(default)]
1643 storage: Vec<StorageSpec>,
1644 #[serde(default)]
1645 port_mappings: Vec<PortMapping>,
1646 #[serde(default, alias = "cap_add")]
1647 capabilities: Vec<String>,
1648 #[serde(default)]
1649 cap_drop: Vec<String>,
1650 #[serde(default)]
1651 privileged: bool,
1652 #[serde(default)]
1653 node_mode: NodeMode,
1654 #[serde(default)]
1655 node_selector: Option<NodeSelector>,
1656 #[serde(default)]
1657 affinity: Option<GroupAffinity>,
1658 #[serde(default)]
1659 platform: Option<TargetPlatform>,
1660 #[serde(default)]
1661 service_type: ServiceType,
1662 #[serde(default, alias = "wasm_http")]
1663 wasm: Option<WasmConfig>,
1664 #[serde(default)]
1665 logs: Option<LogsConfig>,
1666 #[serde(default)]
1669 host_network: Option<bool>,
1670 #[serde(default)]
1671 hostname: Option<String>,
1672 #[serde(default)]
1673 dns: Vec<String>,
1674 #[serde(default)]
1675 extra_hosts: Vec<String>,
1676 #[serde(default)]
1677 restart_policy: Option<ContainerRestartPolicy>,
1678 #[serde(default)]
1679 labels: HashMap<String, String>,
1680 #[serde(default)]
1681 user: Option<String>,
1682 #[serde(default)]
1683 stop_signal: Option<String>,
1684 #[serde(default, with = "duration::option")]
1685 stop_grace_period: Option<std::time::Duration>,
1686 #[serde(default)]
1687 sysctls: HashMap<String, String>,
1688 #[serde(default)]
1689 ulimits: HashMap<String, UlimitSpec>,
1690 #[serde(default)]
1691 security_opt: Vec<String>,
1692 #[serde(default)]
1693 pid_mode: Option<String>,
1694 #[serde(default)]
1695 ipc_mode: Option<String>,
1696 #[serde(default, deserialize_with = "deserialize_network_mode")]
1697 network_mode: NetworkMode,
1698 #[serde(default)]
1699 extra_groups: Vec<String>,
1700 #[serde(default)]
1701 read_only_root_fs: bool,
1702 #[serde(default)]
1703 init_container: Option<bool>,
1704 #[serde(default)]
1705 tty: bool,
1706 #[serde(default)]
1707 stdin_open: bool,
1708 #[serde(default)]
1709 userns_mode: Option<String>,
1710 #[serde(default)]
1711 cgroup_parent: Option<String>,
1712 #[serde(default)]
1713 expose: Vec<String>,
1714 #[serde(default)]
1715 overlay: Option<crate::overlay::OverlayConfig>,
1716 #[serde(default)]
1717 localhost_reachability: LocalhostReachability,
1718}
1719
1720impl From<ServiceSpecCompat> for ServiceSpec {
1721 fn from(c: ServiceSpecCompat) -> Self {
1722 let network_mode = match (c.host_network, &c.network_mode) {
1727 (Some(true), NetworkMode::Default) => NetworkMode::Host,
1728 _ => c.network_mode,
1729 };
1730 let host_network = c.host_network.unwrap_or(false) || network_mode == NetworkMode::Host;
1731
1732 Self {
1733 deployment: c.deployment,
1734 secret_scope: c.secret_scope,
1735 rtype: c.rtype,
1736 schedule: c.schedule,
1737 image: c.image,
1738 resources: c.resources,
1739 env: c.env,
1740 command: c.command,
1741 network: c.network,
1742 endpoints: c.endpoints,
1743 scale: c.scale,
1744 replica_groups: c.replica_groups,
1745 depends: c.depends,
1746 health: c.health,
1747 init: c.init,
1748 errors: c.errors,
1749 lifecycle: c.lifecycle,
1750 isolation: c.isolation,
1751 runtime: c.runtime,
1752 devices: c.devices,
1753 storage: c.storage,
1754 port_mappings: c.port_mappings,
1755 capabilities: c.capabilities,
1756 cap_drop: c.cap_drop,
1757 privileged: c.privileged,
1758 node_mode: c.node_mode,
1759 node_selector: c.node_selector,
1760 affinity: c.affinity,
1761 platform: c.platform,
1762 service_type: c.service_type,
1763 wasm: c.wasm,
1764 logs: c.logs,
1765 host_network,
1766 hostname: c.hostname,
1767 dns: c.dns,
1768 dns_search: Vec::new(),
1772 extra_hosts: c.extra_hosts,
1773 restart_policy: c.restart_policy,
1774 labels: c.labels,
1775 user: c.user,
1776 stop_signal: c.stop_signal,
1777 stop_grace_period: c.stop_grace_period,
1778 sysctls: c.sysctls,
1779 ulimits: c.ulimits,
1780 security_opt: c.security_opt,
1781 pid_mode: c.pid_mode,
1782 ipc_mode: c.ipc_mode,
1783 network_mode,
1784 extra_groups: c.extra_groups,
1785 read_only_root_fs: c.read_only_root_fs,
1786 init_container: c.init_container,
1787 tty: c.tty,
1788 stdin_open: c.stdin_open,
1789 userns_mode: c.userns_mode,
1790 cgroup_parent: c.cgroup_parent,
1791 expose: c.expose,
1792 overlay: c.overlay,
1793 localhost_reachability: c.localhost_reachability,
1794 }
1795 }
1796}
1797
1798impl ServiceSpec {
1799 #[must_use]
1808 pub fn is_single_member(&self) -> bool {
1809 if let Some(groups) = &self.replica_groups {
1810 let total: u32 = groups.iter().map(|g| g.count).sum();
1811 return groups.len() <= 1 && total <= 1;
1812 }
1813 match &self.scale {
1814 ScaleSpec::Fixed { replicas } => *replicas <= 1,
1815 ScaleSpec::Adaptive { max, .. } => *max <= 1,
1816 ScaleSpec::Manual => true,
1817 }
1818 }
1819
1820 #[must_use]
1825 pub fn publish_to_node_loopback(&self) -> bool {
1826 match self.localhost_reachability {
1827 LocalhostReachability::Always => true,
1828 LocalhostReachability::Never => false,
1829 LocalhostReachability::Auto => self.is_single_member(),
1830 }
1831 }
1832
1833 #[must_use]
1855 pub fn minimal(_name: impl Into<String>, image: impl Into<String>) -> Self {
1856 use std::str::FromStr;
1857 let image_str = image.into();
1858 let image_ref = crate::ImageRef::from_str(&image_str).unwrap_or_else(|_| {
1859 crate::ImageRef::from_str("scratch:latest")
1860 .expect("'scratch:latest' is a valid image reference")
1861 });
1862 Self {
1863 image: ImageSpec {
1864 name: image_ref,
1865 pull_policy: default_pull_policy(),
1866 source_policy: None,
1867 },
1868 ..Self::default()
1869 }
1870 }
1871}
1872
1873#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1875#[serde(deny_unknown_fields)]
1876pub struct CommandSpec {
1877 #[serde(default, skip_serializing_if = "Option::is_none")]
1879 pub entrypoint: Option<Vec<String>>,
1880
1881 #[serde(default, skip_serializing_if = "Option::is_none")]
1883 pub args: Option<Vec<String>>,
1884
1885 #[serde(default, skip_serializing_if = "Option::is_none")]
1887 pub workdir: Option<String>,
1888}
1889
1890fn default_resource_type() -> ResourceType {
1891 ResourceType::Service
1892}
1893
1894fn default_health() -> HealthSpec {
1895 HealthSpec {
1896 start_grace: Some(std::time::Duration::from_secs(5)),
1897 interval: None,
1898 timeout: None,
1899 retries: 3,
1900 check: HealthCheck::Tcp { port: 0 },
1901 }
1902}
1903
1904#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1906#[serde(rename_all = "lowercase")]
1907pub enum ResourceType {
1908 #[default]
1910 Service,
1911 Job,
1913 Cron,
1915}
1916
1917#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1923#[serde(rename_all = "snake_case")]
1924pub enum SourcePolicy {
1925 #[default]
1927 LocalFirst,
1928 S3First,
1931 RemoteOnly,
1934 LocalOnly,
1937}
1938
1939#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1941#[serde(deny_unknown_fields)]
1942pub struct ImageSpec {
1943 pub name: crate::ImageRef,
1945
1946 #[serde(default = "default_pull_policy")]
1948 pub pull_policy: PullPolicy,
1949
1950 #[serde(default, skip_serializing_if = "Option::is_none")]
1953 pub source_policy: Option<SourcePolicy>,
1954}
1955
1956fn default_pull_policy() -> PullPolicy {
1957 PullPolicy::IfNotPresent
1958}
1959
1960impl Default for ImageSpec {
1961 fn default() -> Self {
1969 use std::str::FromStr;
1970 Self {
1971 name: crate::ImageRef::from_str("scratch:latest")
1972 .expect("'scratch:latest' is a valid image reference"),
1973 pull_policy: default_pull_policy(),
1974 source_policy: None,
1975 }
1976 }
1977}
1978
1979#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1981#[serde(rename_all = "snake_case")]
1982pub enum PullPolicy {
1983 Always,
1985 Newer,
1987 IfNotPresent,
1993 Never,
1995}
1996
1997#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate, utoipa::ToSchema)]
1999#[serde(deny_unknown_fields)]
2000pub struct DeviceSpec {
2001 #[validate(length(min = 1, message = "device path cannot be empty"))]
2003 pub path: String,
2004
2005 #[serde(default = "default_true")]
2007 pub read: bool,
2008
2009 #[serde(default = "default_true")]
2011 pub write: bool,
2012
2013 #[serde(default)]
2015 pub mknod: bool,
2016}
2017
2018fn default_true() -> bool {
2019 true
2020}
2021
2022#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2024#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
2025pub enum StorageSpec {
2026 Bind {
2028 source: String,
2029 target: String,
2030 #[serde(default)]
2031 readonly: bool,
2032 },
2033 Named {
2035 name: String,
2036 target: String,
2037 #[serde(default)]
2038 readonly: bool,
2039 #[serde(default)]
2041 tier: StorageTier,
2042 #[serde(default, skip_serializing_if = "Option::is_none")]
2044 size: Option<String>,
2045 },
2046 Anonymous {
2048 target: String,
2049 #[serde(default)]
2051 tier: StorageTier,
2052 },
2053 Tmpfs {
2055 target: String,
2056 #[serde(default)]
2057 size: Option<String>,
2058 #[serde(default)]
2059 mode: Option<u32>,
2060 },
2061 S3 {
2063 bucket: String,
2064 #[serde(default)]
2065 prefix: Option<String>,
2066 target: String,
2067 #[serde(default)]
2068 readonly: bool,
2069 #[serde(default)]
2070 endpoint: Option<String>,
2071 #[serde(default)]
2072 credentials: Option<String>,
2073 },
2074}
2075
2076#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
2078#[serde(deny_unknown_fields)]
2079pub struct ResourcesSpec {
2080 #[serde(default)]
2082 #[validate(custom(function = "crate::spec::validate::validate_cpu_option_wrapper"))]
2083 pub cpu: Option<f64>,
2084
2085 #[serde(default)]
2087 #[validate(custom(function = "crate::spec::validate::validate_memory_option_wrapper"))]
2088 pub memory: Option<String>,
2089
2090 #[serde(default, skip_serializing_if = "Option::is_none")]
2092 pub gpu: Option<GpuSpec>,
2093
2094 #[serde(default, skip_serializing_if = "Option::is_none")]
2097 pub pids_limit: Option<i64>,
2098
2099 #[serde(default, skip_serializing_if = "Option::is_none")]
2101 pub cpuset: Option<String>,
2102
2103 #[serde(default, skip_serializing_if = "Option::is_none")]
2105 pub cpu_shares: Option<u32>,
2106
2107 #[serde(default, skip_serializing_if = "Option::is_none")]
2109 pub memory_swap: Option<String>,
2110
2111 #[serde(default, skip_serializing_if = "Option::is_none")]
2113 pub memory_reservation: Option<String>,
2114
2115 #[serde(default, skip_serializing_if = "Option::is_none")]
2117 pub memory_swappiness: Option<u8>,
2118
2119 #[serde(default, skip_serializing_if = "Option::is_none")]
2121 pub oom_score_adj: Option<i32>,
2122
2123 #[serde(default, skip_serializing_if = "Option::is_none")]
2125 pub oom_kill_disable: Option<bool>,
2126
2127 #[serde(default, skip_serializing_if = "Option::is_none")]
2129 pub blkio_weight: Option<u16>,
2130}
2131
2132#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2134#[serde(rename_all = "kebab-case")]
2135pub enum SchedulingPolicy {
2136 #[default]
2138 BestEffort,
2139 Gang,
2141 Spread,
2143}
2144
2145#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2147#[serde(rename_all = "kebab-case")]
2148pub enum GpuSharingMode {
2149 #[default]
2151 Exclusive,
2152 Mps,
2155 TimeSlice,
2158}
2159
2160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
2167#[serde(deny_unknown_fields)]
2168pub struct DistributedConfig {
2169 #[serde(default = "default_dist_backend")]
2171 pub backend: String,
2172 #[serde(default = "default_dist_port")]
2174 pub master_port: u16,
2175}
2176
2177fn default_dist_backend() -> String {
2178 "nccl".to_string()
2179}
2180
2181fn default_dist_port() -> u16 {
2182 29500
2183}
2184
2185#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2187#[serde(rename_all = "kebab-case")]
2188pub enum SwarmRole {
2189 #[default]
2191 Stage,
2192 Coordinator,
2194}
2195
2196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
2198#[serde(deny_unknown_fields)]
2199pub struct SwarmPeer {
2200 pub service: String,
2202 pub layer_start: u32,
2204 pub layer_end: u32,
2206}
2207
2208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
2215#[serde(deny_unknown_fields)]
2216pub struct ShardingSpec {
2217 pub swarm_id: String,
2219 pub layer_start: u32,
2221 pub layer_end: u32,
2223 pub layer_count: u32,
2225 #[serde(default)]
2227 pub role: SwarmRole,
2228 #[serde(default, skip_serializing_if = "Option::is_none")]
2230 pub manifest_ref: Option<String>,
2231 #[serde(default)]
2233 #[validate(nested)]
2234 pub peers: Vec<SwarmPeer>,
2235 #[serde(default, skip_serializing_if = "Option::is_none")]
2237 pub coordinator: Option<String>,
2238}
2239
2240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
2259#[serde(deny_unknown_fields)]
2260pub struct GpuSpec {
2261 #[serde(default = "default_gpu_count")]
2263 pub count: u32,
2264 #[serde(default = "default_gpu_vendor")]
2266 pub vendor: String,
2267 #[serde(default, skip_serializing_if = "Option::is_none")]
2269 pub mode: Option<String>,
2270 #[serde(default, skip_serializing_if = "Option::is_none")]
2273 pub model: Option<String>,
2274 #[serde(default, skip_serializing_if = "Option::is_none")]
2279 pub scheduling: Option<SchedulingPolicy>,
2280 #[serde(default, skip_serializing_if = "Option::is_none")]
2283 pub distributed: Option<DistributedConfig>,
2284 #[serde(default, skip_serializing_if = "Option::is_none")]
2286 pub sharing: Option<GpuSharingMode>,
2287 #[serde(default, skip_serializing_if = "Option::is_none")]
2294 pub mps_pipe_dir: Option<String>,
2295 #[serde(default, skip_serializing_if = "Option::is_none")]
2301 pub mps_log_dir: Option<String>,
2302 #[serde(default, skip_serializing_if = "Option::is_none")]
2309 pub time_slice_index: Option<u32>,
2310 #[serde(default, skip_serializing_if = "Option::is_none")]
2318 pub time_slicing_config_path: Option<String>,
2319 #[serde(default, skip_serializing_if = "Option::is_none")]
2323 pub sharding: Option<ShardingSpec>,
2324}
2325
2326fn default_gpu_count() -> u32 {
2327 1
2328}
2329
2330fn default_gpu_vendor() -> String {
2331 "nvidia".to_string()
2332}
2333
2334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2336#[serde(deny_unknown_fields)]
2337#[derive(Default)]
2338pub struct ServiceNetworkSpec {
2339 #[serde(default)]
2341 pub overlays: OverlayConfig,
2342
2343 #[serde(default)]
2345 pub join: JoinPolicy,
2346}
2347
2348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2350#[serde(deny_unknown_fields)]
2351pub struct OverlayConfig {
2352 #[serde(default)]
2354 pub service: OverlaySettings,
2355
2356 #[serde(default)]
2358 pub global: OverlaySettings,
2359}
2360
2361impl Default for OverlayConfig {
2362 fn default() -> Self {
2363 Self {
2364 service: OverlaySettings {
2365 enabled: true,
2366 encrypted: true,
2367 isolated: true,
2368 },
2369 global: OverlaySettings {
2370 enabled: true,
2371 encrypted: true,
2372 isolated: false,
2373 },
2374 }
2375 }
2376}
2377
2378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2380#[serde(deny_unknown_fields)]
2381pub struct OverlaySettings {
2382 #[serde(default = "default_enabled")]
2384 pub enabled: bool,
2385
2386 #[serde(default = "default_encrypted")]
2388 pub encrypted: bool,
2389
2390 #[serde(default)]
2392 pub isolated: bool,
2393}
2394
2395fn default_enabled() -> bool {
2396 true
2397}
2398
2399fn default_encrypted() -> bool {
2400 true
2401}
2402
2403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2405#[serde(deny_unknown_fields)]
2406pub struct JoinPolicy {
2407 #[serde(default = "default_join_mode")]
2409 pub mode: JoinMode,
2410
2411 #[serde(default = "default_join_scope")]
2413 pub scope: JoinScope,
2414}
2415
2416impl Default for JoinPolicy {
2417 fn default() -> Self {
2418 Self {
2419 mode: default_join_mode(),
2420 scope: default_join_scope(),
2421 }
2422 }
2423}
2424
2425fn default_join_mode() -> JoinMode {
2426 JoinMode::Token
2427}
2428
2429fn default_join_scope() -> JoinScope {
2430 JoinScope::Service
2431}
2432
2433#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2435#[serde(rename_all = "snake_case")]
2436pub enum JoinMode {
2437 Open,
2439 Token,
2441 Closed,
2443}
2444
2445#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2447#[serde(rename_all = "snake_case")]
2448pub enum JoinScope {
2449 Service,
2451 Global,
2453}
2454
2455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
2457#[serde(deny_unknown_fields)]
2458pub struct EndpointSpec {
2459 #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
2461 pub name: String,
2462
2463 pub protocol: Protocol,
2465
2466 #[validate(custom(function = "crate::spec::validate::validate_port_wrapper"))]
2468 pub port: u16,
2469
2470 #[serde(default, skip_serializing_if = "Option::is_none")]
2473 pub target_port: Option<u16>,
2474
2475 pub path: Option<String>,
2477
2478 #[serde(default, skip_serializing_if = "Option::is_none")]
2481 pub host: Option<String>,
2482
2483 #[serde(default = "default_expose")]
2485 pub expose: ExposeType,
2486
2487 #[serde(default, skip_serializing_if = "Option::is_none")]
2490 pub stream: Option<StreamEndpointConfig>,
2491
2492 #[serde(default, skip_serializing_if = "Option::is_none")]
2516 pub target_role: Option<String>,
2517
2518 #[serde(default, skip_serializing_if = "Option::is_none")]
2520 pub tunnel: Option<EndpointTunnelConfig>,
2521}
2522
2523impl EndpointSpec {
2524 #[must_use]
2527 pub fn target_port(&self) -> u16 {
2528 self.target_port.unwrap_or(self.port)
2529 }
2530}
2531
2532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2534#[serde(deny_unknown_fields)]
2535pub struct EndpointTunnelConfig {
2536 #[serde(default)]
2538 pub enabled: bool,
2539
2540 #[serde(default, skip_serializing_if = "Option::is_none")]
2542 pub from: Option<String>,
2543
2544 #[serde(default, skip_serializing_if = "Option::is_none")]
2546 pub to: Option<String>,
2547
2548 #[serde(default)]
2550 pub remote_port: u16,
2551
2552 #[serde(default, skip_serializing_if = "Option::is_none")]
2554 pub expose: Option<ExposeType>,
2555
2556 #[serde(default, skip_serializing_if = "Option::is_none")]
2558 pub access: Option<TunnelAccessConfig>,
2559}
2560
2561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2563#[serde(deny_unknown_fields)]
2564pub struct TunnelAccessConfig {
2565 #[serde(default)]
2567 pub enabled: bool,
2568
2569 #[serde(default, skip_serializing_if = "Option::is_none")]
2571 pub max_ttl: Option<String>,
2572
2573 #[serde(default)]
2575 pub audit: bool,
2576}
2577
2578fn default_expose() -> ExposeType {
2579 ExposeType::Internal
2580}
2581
2582#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2584#[serde(rename_all = "lowercase")]
2585pub enum Protocol {
2586 Http,
2587 Https,
2588 Tcp,
2589 Udp,
2590 Websocket,
2591}
2592
2593#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2595#[serde(rename_all = "lowercase")]
2596pub enum ExposeType {
2597 Public,
2598 #[default]
2599 Internal,
2600}
2601
2602#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2604#[serde(deny_unknown_fields)]
2605pub struct StreamEndpointConfig {
2606 #[serde(default)]
2608 pub tls: bool,
2609
2610 #[serde(default)]
2612 pub proxy_protocol: bool,
2613
2614 #[serde(default, skip_serializing_if = "Option::is_none")]
2617 pub session_timeout: Option<String>,
2618
2619 #[serde(default, skip_serializing_if = "Option::is_none")]
2621 pub health_check: Option<StreamHealthCheck>,
2622}
2623
2624impl StreamEndpointConfig {
2625 #[must_use]
2632 pub fn session_timeout_duration(&self) -> Option<std::time::Duration> {
2633 self.session_timeout
2634 .as_deref()
2635 .and_then(|s| humantime::parse_duration(s).ok())
2636 }
2637}
2638
2639#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2641#[serde(tag = "type", rename_all = "snake_case")]
2642pub enum StreamHealthCheck {
2643 TcpConnect,
2645 UdpProbe {
2647 request: String,
2649 #[serde(default, skip_serializing_if = "Option::is_none")]
2651 expect: Option<String>,
2652 },
2653}
2654
2655#[allow(clippy::large_enum_variant)]
2660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2661#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
2662pub enum ScaleSpec {
2663 #[serde(rename = "adaptive")]
2665 Adaptive {
2666 min: u32,
2668
2669 max: u32,
2671
2672 #[serde(default, with = "duration::option")]
2674 cooldown: Option<std::time::Duration>,
2675
2676 #[serde(default)]
2678 targets: ScaleTargets,
2679
2680 #[serde(default, skip_serializing_if = "Option::is_none")]
2687 behavior: Option<ScaleBehavior>,
2688
2689 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2694 triggers: Vec<ScaleTrigger>,
2695
2696 #[serde(
2699 default,
2700 rename = "idleWindow",
2701 with = "duration::option",
2702 skip_serializing_if = "Option::is_none"
2703 )]
2704 idle_window: Option<std::time::Duration>,
2705
2706 #[serde(default, skip_serializing_if = "Option::is_none")]
2709 vertical: Option<VerticalScaleSpec>,
2710
2711 #[serde(default, skip_serializing_if = "Option::is_none")]
2714 predictive: Option<PredictiveSpec>,
2715 },
2716
2717 #[serde(rename = "fixed")]
2719 Fixed { replicas: u32 },
2720
2721 #[serde(rename = "manual")]
2723 Manual,
2724}
2725
2726impl Default for ScaleSpec {
2727 fn default() -> Self {
2728 Self::Adaptive {
2729 min: 1,
2730 max: 10,
2731 cooldown: Some(std::time::Duration::from_secs(30)),
2732 targets: ScaleTargets::default(),
2733 behavior: None,
2734 triggers: Vec::new(),
2735 idle_window: None,
2736 vertical: None,
2737 predictive: None,
2738 }
2739 }
2740}
2741
2742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2744#[serde(deny_unknown_fields)]
2745#[derive(Default)]
2746pub struct ScaleTargets {
2747 #[serde(default)]
2749 pub cpu: Option<u8>,
2750
2751 #[serde(default)]
2753 pub memory: Option<u8>,
2754
2755 #[serde(default)]
2757 pub rps: Option<u32>,
2758
2759 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2762 pub custom: Vec<MetricTarget>,
2763
2764 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2767 pub external: Vec<MetricTarget>,
2768}
2769
2770#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2772#[serde(rename_all = "snake_case")]
2773pub enum MetricKind {
2774 #[default]
2776 AverageValue,
2777 Value,
2779}
2780
2781#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2783#[serde(deny_unknown_fields)]
2784pub struct MetricTarget {
2785 pub name: String,
2787
2788 #[serde(default, skip_serializing_if = "Option::is_none")]
2790 pub source: Option<String>,
2791
2792 pub target: ordered_float::OrderedFloat<f64>,
2797
2798 #[serde(default)]
2800 pub kind: MetricKind,
2801}
2802
2803#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2805#[serde(deny_unknown_fields)]
2806pub struct ScaleBehavior {
2807 #[serde(default, rename = "scaleUp", skip_serializing_if = "Option::is_none")]
2809 pub scale_up: Option<ScaleDirection>,
2810
2811 #[serde(default, rename = "scaleDown", skip_serializing_if = "Option::is_none")]
2813 pub scale_down: Option<ScaleDirection>,
2814}
2815
2816#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2818#[serde(deny_unknown_fields)]
2819pub struct ScaleDirection {
2820 #[serde(
2824 default,
2825 rename = "stabilizationWindow",
2826 with = "duration::option",
2827 skip_serializing_if = "Option::is_none"
2828 )]
2829 pub stabilization_window: Option<std::time::Duration>,
2830
2831 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2833 pub policies: Vec<ScalePolicy>,
2834
2835 #[serde(default)]
2837 pub select: PolicySelect,
2838}
2839
2840#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2842#[serde(deny_unknown_fields)]
2843pub struct ScalePolicy {
2844 #[serde(rename = "type")]
2846 pub policy_type: PolicyType,
2847
2848 pub value: u32,
2850
2851 #[serde(
2853 default,
2854 with = "duration::option",
2855 skip_serializing_if = "Option::is_none"
2856 )]
2857 pub period: Option<std::time::Duration>,
2858}
2859
2860#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2862#[serde(rename_all = "snake_case")]
2863pub enum PolicyType {
2864 Pods,
2866 Percent,
2868}
2869
2870#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2872#[serde(rename_all = "snake_case")]
2873pub enum PolicySelect {
2874 #[default]
2876 Max,
2877 Min,
2879 Disabled,
2881}
2882
2883#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2889pub struct ScaleTrigger {
2890 #[serde(flatten)]
2892 pub kind: ScaleTriggerKind,
2893
2894 #[serde(default, skip_serializing_if = "Option::is_none")]
2897 pub min: Option<u32>,
2898}
2899
2900#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2902#[serde(tag = "type", rename_all = "snake_case")]
2903pub enum ScaleTriggerKind {
2904 Queue {
2906 source: String,
2908 key: String,
2910 target: u64,
2912 },
2913 Kafka {
2915 brokers: String,
2917 topic: String,
2919 group: String,
2921 target: u64,
2923 },
2924 HttpRps {
2926 target: u32,
2928 },
2929 Prometheus {
2931 source: String,
2933 query: String,
2935 target: ordered_float::OrderedFloat<f64>,
2937 },
2938 Cron {
2940 schedule: String,
2942 #[serde(with = "duration::option")]
2944 duration: Option<std::time::Duration>,
2945 },
2946}
2947
2948#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2950#[serde(deny_unknown_fields)]
2951pub struct VerticalScaleSpec {
2952 #[serde(default)]
2954 pub mode: VerticalMode,
2955
2956 #[serde(
2958 default,
2959 rename = "minCpuMillis",
2960 skip_serializing_if = "Option::is_none"
2961 )]
2962 pub min_cpu_millis: Option<u32>,
2963
2964 #[serde(
2966 default,
2967 rename = "maxCpuMillis",
2968 skip_serializing_if = "Option::is_none"
2969 )]
2970 pub max_cpu_millis: Option<u32>,
2971
2972 #[serde(
2974 default,
2975 rename = "minMemoryMib",
2976 skip_serializing_if = "Option::is_none"
2977 )]
2978 pub min_memory_mib: Option<u32>,
2979
2980 #[serde(
2982 default,
2983 rename = "maxMemoryMib",
2984 skip_serializing_if = "Option::is_none"
2985 )]
2986 pub max_memory_mib: Option<u32>,
2987
2988 #[serde(default = "default_vertical_percentile")]
2990 pub percentile: u8,
2991}
2992
2993fn default_vertical_percentile() -> u8 {
2994 90
2995}
2996
2997#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2999#[serde(rename_all = "snake_case")]
3000pub enum VerticalMode {
3001 #[default]
3003 Off,
3004 Recommend,
3006 Auto,
3008}
3009
3010#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3012#[serde(deny_unknown_fields)]
3013pub struct PredictiveSpec {
3014 #[serde(default)]
3016 pub enabled: bool,
3017
3018 #[serde(default)]
3020 pub method: ForecastMethod,
3021
3022 #[serde(
3024 default,
3025 with = "duration::option",
3026 skip_serializing_if = "Option::is_none"
3027 )]
3028 pub horizon: Option<std::time::Duration>,
3029}
3030
3031#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
3033#[serde(rename_all = "snake_case")]
3034pub enum ForecastMethod {
3035 #[default]
3037 Ewma,
3038 HoltWinters,
3040}
3041
3042#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3044#[serde(deny_unknown_fields)]
3045pub struct DependsSpec {
3046 pub service: String,
3048
3049 #[serde(default = "default_condition")]
3051 pub condition: DependencyCondition,
3052
3053 #[serde(default = "default_timeout", with = "duration::option")]
3055 pub timeout: Option<std::time::Duration>,
3056
3057 #[serde(default = "default_on_timeout")]
3059 pub on_timeout: TimeoutAction,
3060}
3061
3062fn default_condition() -> DependencyCondition {
3063 DependencyCondition::Healthy
3064}
3065
3066#[allow(clippy::unnecessary_wraps)]
3067fn default_timeout() -> Option<std::time::Duration> {
3068 Some(std::time::Duration::from_secs(300))
3069}
3070
3071fn default_on_timeout() -> TimeoutAction {
3072 TimeoutAction::Fail
3073}
3074
3075#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3077#[serde(rename_all = "lowercase")]
3078pub enum DependencyCondition {
3079 Started,
3081 Healthy,
3083 Ready,
3085}
3086
3087#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3089#[serde(rename_all = "lowercase")]
3090pub enum TimeoutAction {
3091 Fail,
3092 Warn,
3093 Continue,
3094}
3095
3096#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3098#[serde(deny_unknown_fields)]
3099pub struct HealthSpec {
3100 #[serde(default, with = "duration::option")]
3102 pub start_grace: Option<std::time::Duration>,
3103
3104 #[serde(default, with = "duration::option")]
3106 pub interval: Option<std::time::Duration>,
3107
3108 #[serde(default, with = "duration::option")]
3110 pub timeout: Option<std::time::Duration>,
3111
3112 #[serde(default = "default_retries")]
3114 pub retries: u32,
3115
3116 pub check: HealthCheck,
3118}
3119
3120fn default_retries() -> u32 {
3121 3
3122}
3123
3124impl Default for HealthSpec {
3125 fn default() -> Self {
3130 default_health()
3131 }
3132}
3133
3134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3136#[serde(tag = "type", rename_all = "lowercase")]
3137pub enum HealthCheck {
3138 Tcp {
3140 port: u16,
3142 },
3143
3144 Http {
3146 url: String,
3148 #[serde(default = "default_expect_status")]
3150 expect_status: u16,
3151 },
3152
3153 Command {
3155 command: String,
3157 },
3158}
3159
3160fn default_expect_status() -> u16 {
3161 200
3162}
3163
3164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3166#[serde(deny_unknown_fields)]
3167#[derive(Default)]
3168pub struct InitSpec {
3169 #[serde(default)]
3171 pub steps: Vec<InitStep>,
3172}
3173
3174#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
3181#[serde(deny_unknown_fields)]
3182pub struct LifecycleSpec {
3183 #[serde(default)]
3187 pub delete_on_exit: bool,
3188}
3189
3190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3192#[serde(deny_unknown_fields)]
3193pub struct InitStep {
3194 pub id: String,
3196
3197 pub uses: String,
3199
3200 #[serde(default)]
3202 pub with: InitParams,
3203
3204 #[serde(default)]
3206 pub retry: Option<u32>,
3207
3208 #[serde(default, with = "duration::option")]
3210 pub timeout: Option<std::time::Duration>,
3211
3212 #[serde(default = "default_on_failure")]
3214 pub on_failure: FailureAction,
3215}
3216
3217fn default_on_failure() -> FailureAction {
3218 FailureAction::Fail
3219}
3220
3221pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
3223
3224#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3226#[serde(rename_all = "lowercase")]
3227pub enum FailureAction {
3228 Fail,
3229 Warn,
3230 Continue,
3231}
3232
3233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3235#[serde(deny_unknown_fields)]
3236#[derive(Default)]
3237pub struct ErrorsSpec {
3238 #[serde(default)]
3240 pub on_init_failure: InitFailurePolicy,
3241
3242 #[serde(default)]
3244 pub on_panic: PanicPolicy,
3245}
3246
3247#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3249#[serde(deny_unknown_fields)]
3250pub struct InitFailurePolicy {
3251 #[serde(default = "default_init_action")]
3252 pub action: InitFailureAction,
3253}
3254
3255impl Default for InitFailurePolicy {
3256 fn default() -> Self {
3257 Self {
3258 action: default_init_action(),
3259 }
3260 }
3261}
3262
3263fn default_init_action() -> InitFailureAction {
3264 InitFailureAction::Fail
3265}
3266
3267#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3269#[serde(rename_all = "lowercase")]
3270pub enum InitFailureAction {
3271 Fail,
3272 Restart,
3273 Backoff,
3274}
3275
3276#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3278#[serde(deny_unknown_fields)]
3279pub struct PanicPolicy {
3280 #[serde(default = "default_panic_action")]
3281 pub action: PanicAction,
3282}
3283
3284impl Default for PanicPolicy {
3285 fn default() -> Self {
3286 Self {
3287 action: default_panic_action(),
3288 }
3289 }
3290}
3291
3292fn default_panic_action() -> PanicAction {
3293 PanicAction::Restart
3294}
3295
3296#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3298#[serde(rename_all = "lowercase")]
3299pub enum PanicAction {
3300 Restart,
3301 Shutdown,
3302 Isolate,
3303}
3304
3305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
3312pub struct NetworkPolicySpec {
3313 pub name: String,
3315
3316 #[serde(default, skip_serializing_if = "Option::is_none")]
3318 pub description: Option<String>,
3319
3320 #[serde(default)]
3322 pub cidrs: Vec<String>,
3323
3324 #[serde(default)]
3326 pub members: Vec<NetworkMember>,
3327
3328 #[serde(default)]
3330 pub access_rules: Vec<AccessRule>,
3331}
3332
3333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3335pub struct NetworkMember {
3336 pub name: String,
3338 #[serde(default)]
3340 pub kind: MemberKind,
3341}
3342
3343#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
3345#[serde(rename_all = "lowercase")]
3346pub enum MemberKind {
3347 #[default]
3349 User,
3350 Group,
3352 Node,
3354 Cidr,
3356}
3357
3358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3360pub struct AccessRule {
3361 #[serde(default = "wildcard")]
3363 pub service: String,
3364
3365 #[serde(default = "wildcard")]
3367 pub deployment: String,
3368
3369 #[serde(default, skip_serializing_if = "Option::is_none")]
3371 pub ports: Option<Vec<u16>>,
3372
3373 #[serde(default)]
3375 pub action: AccessAction,
3376}
3377
3378fn wildcard() -> String {
3379 "*".to_string()
3380}
3381
3382#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
3384#[serde(rename_all = "lowercase")]
3385pub enum AccessAction {
3386 #[default]
3388 Allow,
3389 Deny,
3391}
3392
3393#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
3405pub struct BridgeNetwork {
3406 pub id: String,
3408
3409 pub name: String,
3411
3412 #[serde(default)]
3414 pub driver: BridgeNetworkDriver,
3415
3416 #[serde(default, skip_serializing_if = "Option::is_none")]
3418 pub subnet: Option<String>,
3419
3420 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
3422 pub labels: HashMap<String, String>,
3423
3424 #[serde(default)]
3427 pub internal: bool,
3428
3429 #[serde(default)]
3435 pub isolated: bool,
3436
3437 #[schema(value_type = String, format = "date-time")]
3439 pub created_at: chrono::DateTime<chrono::Utc>,
3440}
3441
3442#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
3444#[serde(rename_all = "lowercase")]
3445pub enum BridgeNetworkDriver {
3446 #[default]
3448 Bridge,
3449 Overlay,
3451}
3452
3453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
3455pub struct BridgeNetworkAttachment {
3456 pub container_id: String,
3458
3459 #[serde(default, skip_serializing_if = "Option::is_none")]
3461 pub container_name: Option<String>,
3462
3463 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3465 pub aliases: Vec<String>,
3466
3467 #[serde(default, skip_serializing_if = "Option::is_none")]
3469 pub ipv4: Option<String>,
3470}
3471
3472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
3493pub struct RegistryAuth {
3494 pub username: String,
3497 pub password: String,
3500 #[serde(default = "default_registry_auth_type")]
3502 pub auth_type: RegistryAuthType,
3503}
3504
3505#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
3507#[serde(rename_all = "snake_case")]
3508pub enum RegistryAuthType {
3509 #[default]
3511 Basic,
3512 Token,
3515}
3516
3517#[must_use]
3520pub fn default_registry_auth_type() -> RegistryAuthType {
3521 RegistryAuthType::Basic
3522}
3523
3524#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
3539#[serde(rename_all = "snake_case", deny_unknown_fields)]
3540pub struct ContainerRestartPolicy {
3541 pub kind: ContainerRestartKind,
3543
3544 #[serde(default, skip_serializing_if = "Option::is_none")]
3547 pub max_attempts: Option<u32>,
3548
3549 #[serde(default, skip_serializing_if = "Option::is_none")]
3554 pub delay: Option<String>,
3555}
3556
3557#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
3559#[serde(rename_all = "snake_case")]
3560pub enum ContainerRestartKind {
3561 No,
3563 Always,
3565 UnlessStopped,
3568 OnFailure,
3571}
3572
3573#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
3579#[serde(rename_all = "snake_case")]
3580pub enum PortProtocol {
3581 Tcp,
3583 Udp,
3585}
3586
3587impl Default for PortProtocol {
3588 fn default() -> Self {
3589 default_port_protocol()
3590 }
3591}
3592
3593impl PortProtocol {
3594 #[must_use]
3597 pub fn as_str(&self) -> &'static str {
3598 match self {
3599 PortProtocol::Tcp => "tcp",
3600 PortProtocol::Udp => "udp",
3601 }
3602 }
3603}
3604
3605fn default_port_protocol() -> PortProtocol {
3606 PortProtocol::Tcp
3607}
3608
3609fn default_host_ip() -> String {
3610 "0.0.0.0".to_string()
3611}
3612
3613#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
3619#[serde(rename_all = "snake_case")]
3620pub struct PortMapping {
3621 #[serde(default, skip_serializing_if = "Option::is_none")]
3623 pub host_port: Option<u16>,
3624 pub container_port: u16,
3626 #[serde(default = "default_port_protocol")]
3628 pub protocol: PortProtocol,
3629 #[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
3631 pub host_ip: String,
3632}
3633
3634#[cfg(test)]
3635mod tests {
3636 use super::*;
3637
3638 #[test]
3639 fn runtime_isolation_serde_kebab_roundtrip() {
3640 assert_eq!(
3642 serde_json::to_string(&RuntimeIsolation::Auto).unwrap(),
3643 "\"auto\""
3644 );
3645 assert_eq!(
3646 serde_json::to_string(&RuntimeIsolation::Sandbox).unwrap(),
3647 "\"sandbox\""
3648 );
3649 assert_eq!(
3650 serde_json::to_string(&RuntimeIsolation::Vz).unwrap(),
3651 "\"vz\""
3652 );
3653 assert_eq!(
3654 serde_json::to_string(&RuntimeIsolation::VzLinux).unwrap(),
3655 "\"vz-linux\""
3656 );
3657 assert_eq!(
3658 serde_json::to_string(&RuntimeIsolation::Vm).unwrap(),
3659 "\"vm\""
3660 );
3661
3662 assert_eq!(
3663 serde_json::from_str::<RuntimeIsolation>("\"vz-linux\"").unwrap(),
3664 RuntimeIsolation::VzLinux
3665 );
3666 assert_eq!(
3667 serde_json::from_str::<RuntimeIsolation>("\"vm\"").unwrap(),
3668 RuntimeIsolation::Vm
3669 );
3670 assert_eq!(
3671 serde_json::from_str::<RuntimeIsolation>("\"sandbox\"").unwrap(),
3672 RuntimeIsolation::Sandbox
3673 );
3674 }
3675
3676 #[test]
3677 fn runtime_isolation_label_value_mapping() {
3678 assert_eq!(RuntimeIsolation::Auto.label_value(), None);
3679 assert_eq!(RuntimeIsolation::Sandbox.label_value(), Some("sandbox"));
3680 assert_eq!(RuntimeIsolation::Vz.label_value(), Some("vz"));
3681 assert_eq!(RuntimeIsolation::VzLinux.label_value(), Some("vz-linux"));
3682 assert_eq!(RuntimeIsolation::Vm.label_value(), Some("vm"));
3683 }
3684
3685 #[test]
3686 fn stream_endpoint_session_timeout_parses() {
3687 let cfg = StreamEndpointConfig {
3688 session_timeout: Some("5m".to_string()),
3689 ..Default::default()
3690 };
3691 assert_eq!(
3692 cfg.session_timeout_duration(),
3693 Some(std::time::Duration::from_secs(300))
3694 );
3695
3696 let cfg = StreamEndpointConfig {
3697 session_timeout: Some("90s".to_string()),
3698 ..Default::default()
3699 };
3700 assert_eq!(
3701 cfg.session_timeout_duration(),
3702 Some(std::time::Duration::from_secs(90))
3703 );
3704
3705 let cfg = StreamEndpointConfig::default();
3707 assert_eq!(cfg.session_timeout_duration(), None);
3708
3709 let cfg = StreamEndpointConfig {
3711 session_timeout: Some("not-a-duration".to_string()),
3712 ..Default::default()
3713 };
3714 assert_eq!(cfg.session_timeout_duration(), None);
3715 }
3716
3717 #[test]
3718 fn service_spec_secret_scope_survives_compat_roundtrip() {
3719 let spec = ServiceSpec {
3723 secret_scope: Some(SecretScope::for_env(Some("zatabase"), "env-uuid-1")),
3724 ..Default::default()
3725 };
3726
3727 let json = serde_json::to_string(&spec).expect("serialize service spec");
3728 let round: ServiceSpec = serde_json::from_str(&json).expect("deserialize service spec");
3729
3730 assert_eq!(
3731 round.secret_scope,
3732 Some(SecretScope::for_env(Some("zatabase"), "env-uuid-1")),
3733 "secret_scope lost across the ServiceSpecCompat path:\n{json}"
3734 );
3735 assert_eq!(
3736 spec, round,
3737 "full service spec round-trip mismatch:\n{json}"
3738 );
3739 }
3740
3741 #[test]
3742 fn deployment_spec_environment_project_round_trip() {
3743 let spec = DeploymentSpec {
3744 version: "v1".to_string(),
3745 deployment: "my-deploy".to_string(),
3746 services: HashMap::new(),
3747 externals: HashMap::new(),
3748 tunnels: HashMap::new(),
3749 api: ApiSpec::default(),
3750 environment: Some("dev".to_string()),
3751 project: Some("zatabase".to_string()),
3752 };
3753
3754 let json = serde_json::to_string(&spec).expect("serialize deployment spec");
3755 let round: DeploymentSpec =
3756 serde_json::from_str(&json).expect("deserialize deployment spec");
3757
3758 assert_eq!(round.environment, Some("dev".to_string()));
3759 assert_eq!(round.project, Some("zatabase".to_string()));
3760 assert_eq!(spec, round, "deployment spec round-trip mismatch:\n{json}");
3761
3762 let legacy = r#"{"version":"v1","deployment":"legacy"}"#;
3765 let parsed: DeploymentSpec =
3766 serde_json::from_str(legacy).expect("deserialize legacy deployment spec");
3767 assert!(parsed.environment.is_none());
3768 assert!(parsed.project.is_none());
3769 }
3770
3771 #[test]
3772 fn service_spec_default_round_trips_through_json() {
3773 let spec = ServiceSpec::default();
3778
3779 assert_eq!(spec.rtype, ResourceType::Service);
3781 assert_eq!(spec.image.pull_policy, PullPolicy::IfNotPresent);
3782 assert_eq!(spec.health.retries, 3);
3783 assert_eq!(spec.network_mode, NetworkMode::Default);
3784 assert!(spec.env.is_empty());
3785 assert!(spec.endpoints.is_empty());
3786 assert!(spec.overlay.is_none());
3787
3788 let json = serde_json::to_string(&spec).expect("serialize default ServiceSpec");
3789 let parsed: ServiceSpec =
3790 serde_json::from_str(&json).expect("re-parse default ServiceSpec");
3791 assert_eq!(spec, parsed);
3792 }
3793
3794 #[test]
3795 fn service_spec_deployment_field_serde_round_trips() {
3796 let yaml_without = "image:\n name: nginx:latest\n";
3799 let parsed: ServiceSpec =
3800 serde_yaml::from_str(yaml_without).expect("parse spec without deployment");
3801 assert_eq!(parsed.deployment, None);
3802 let reser = serde_json::to_string(&parsed).expect("serialize");
3803 assert!(
3804 !reser.contains("\"deployment\""),
3805 "absent deployment must not be serialized: {reser}"
3806 );
3807
3808 let yaml_with = "deployment: my-app\nimage:\n name: nginx:latest\n";
3810 let parsed_with: ServiceSpec =
3811 serde_yaml::from_str(yaml_with).expect("parse spec with deployment");
3812 assert_eq!(parsed_with.deployment.as_deref(), Some("my-app"));
3813 let json = serde_json::to_string(&parsed_with).expect("serialize with deployment");
3814 let reparsed: ServiceSpec = serde_json::from_str(&json).expect("re-parse");
3815 assert_eq!(reparsed.deployment.as_deref(), Some("my-app"));
3816 assert_eq!(parsed_with, reparsed);
3817 }
3818
3819 #[test]
3820 fn service_spec_minimal_sets_name_and_image() {
3821 let spec = ServiceSpec::minimal("api", "ghcr.io/acme/api:1.2");
3822 assert_eq!(spec.image.name.repository(), "acme/api");
3823 assert_eq!(spec.image.name.tag(), Some("1.2"));
3824 let baseline = ServiceSpec::default();
3826 assert_eq!(spec.rtype, baseline.rtype);
3827 assert_eq!(spec.scale, baseline.scale);
3828 assert_eq!(spec.network_mode, baseline.network_mode);
3829 }
3830
3831 #[test]
3832 fn port_mapping_defaults_via_serde() {
3833 let json = r#"{"container_port": 8080}"#;
3836 let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
3837 assert_eq!(m.container_port, 8080);
3838 assert_eq!(m.host_port, None);
3839 assert_eq!(m.protocol, PortProtocol::Tcp);
3840 assert_eq!(m.host_ip, "0.0.0.0");
3841 }
3842
3843 #[test]
3844 fn port_mapping_skips_none_host_port_and_empty_host_ip() {
3845 let m = PortMapping {
3846 host_port: None,
3847 container_port: 443,
3848 protocol: PortProtocol::Tcp,
3849 host_ip: String::new(),
3850 };
3851 let s = serde_json::to_string(&m).expect("serialize");
3852 assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
3854 assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
3855 assert!(s.contains("\"container_port\":443"));
3856 assert!(s.contains("\"protocol\":\"tcp\""));
3857 }
3858
3859 #[test]
3860 fn test_parse_simple_spec() {
3861 let yaml = r"
3862version: v1
3863deployment: test
3864services:
3865 hello:
3866 rtype: service
3867 image:
3868 name: hello-world:latest
3869 endpoints:
3870 - name: http
3871 protocol: http
3872 port: 8080
3873 expose: public
3874";
3875
3876 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3877 assert_eq!(spec.version, "v1");
3878 assert_eq!(spec.deployment, "test");
3879 assert!(spec.services.contains_key("hello"));
3880 }
3881
3882 #[test]
3883 fn test_parse_duration() {
3884 let yaml = r"
3885version: v1
3886deployment: test
3887services:
3888 test:
3889 rtype: service
3890 image:
3891 name: test:latest
3892 health:
3893 timeout: 30s
3894 interval: 1m
3895 start_grace: 5s
3896 check:
3897 type: tcp
3898 port: 8080
3899";
3900
3901 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3902 let health = &spec.services["test"].health;
3903 assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
3904 assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
3905 assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
3906 match &health.check {
3907 HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
3908 _ => panic!("Expected TCP health check"),
3909 }
3910 }
3911
3912 #[test]
3913 fn test_parse_adaptive_scale() {
3914 let yaml = r"
3915version: v1
3916deployment: test
3917services:
3918 test:
3919 rtype: service
3920 image:
3921 name: test:latest
3922 scale:
3923 mode: adaptive
3924 min: 2
3925 max: 10
3926 cooldown: 15s
3927 targets:
3928 cpu: 70
3929 rps: 800
3930";
3931
3932 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3933 let scale = &spec.services["test"].scale;
3934 match scale {
3935 ScaleSpec::Adaptive {
3936 min,
3937 max,
3938 cooldown,
3939 targets,
3940 ..
3941 } => {
3942 assert_eq!(*min, 2);
3943 assert_eq!(*max, 10);
3944 assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
3945 assert_eq!(targets.cpu, Some(70));
3946 assert_eq!(targets.rps, Some(800));
3947 }
3948 _ => panic!("Expected Adaptive scale mode"),
3949 }
3950 }
3951
3952 #[test]
3953 fn test_node_mode_default() {
3954 let yaml = r"
3955version: v1
3956deployment: test
3957services:
3958 hello:
3959 rtype: service
3960 image:
3961 name: hello-world:latest
3962";
3963
3964 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3965 assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
3966 assert!(spec.services["hello"].node_selector.is_none());
3967 }
3968
3969 #[test]
3970 fn test_node_mode_dedicated() {
3971 let yaml = r"
3972version: v1
3973deployment: test
3974services:
3975 api:
3976 rtype: service
3977 image:
3978 name: api:latest
3979 node_mode: dedicated
3980";
3981
3982 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3983 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
3984 }
3985
3986 #[test]
3987 fn test_node_mode_exclusive() {
3988 let yaml = r"
3989version: v1
3990deployment: test
3991services:
3992 database:
3993 rtype: service
3994 image:
3995 name: postgres:15
3996 node_mode: exclusive
3997";
3998
3999 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4000 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
4001 }
4002
4003 #[test]
4004 fn test_node_selector_with_labels() {
4005 let yaml = r#"
4006version: v1
4007deployment: test
4008services:
4009 ml-worker:
4010 rtype: service
4011 image:
4012 name: ml-worker:latest
4013 node_mode: dedicated
4014 node_selector:
4015 labels:
4016 gpu: "true"
4017 zone: us-east
4018 prefer_labels:
4019 storage: ssd
4020"#;
4021
4022 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4023 let service = &spec.services["ml-worker"];
4024 assert_eq!(service.node_mode, NodeMode::Dedicated);
4025
4026 let selector = service.node_selector.as_ref().unwrap();
4027 assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
4028 assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
4029 assert_eq!(
4030 selector.prefer_labels.get("storage"),
4031 Some(&"ssd".to_string())
4032 );
4033 }
4034
4035 #[test]
4036 fn test_node_mode_serialization_roundtrip() {
4037 use serde_json;
4038
4039 let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
4041 let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
4042
4043 for (mode, expected) in modes.iter().zip(expected_json.iter()) {
4044 let json = serde_json::to_string(mode).unwrap();
4045 assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
4046
4047 let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
4048 assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
4049 }
4050 }
4051
4052 #[test]
4053 fn test_node_selector_empty() {
4054 let yaml = r"
4055version: v1
4056deployment: test
4057services:
4058 api:
4059 rtype: service
4060 image:
4061 name: api:latest
4062 node_selector:
4063 labels: {}
4064";
4065
4066 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4067 let selector = spec.services["api"].node_selector.as_ref().unwrap();
4068 assert!(selector.labels.is_empty());
4069 assert!(selector.prefer_labels.is_empty());
4070 }
4071
4072 #[test]
4073 fn test_mixed_node_modes_in_deployment() {
4074 let yaml = r"
4075version: v1
4076deployment: test
4077services:
4078 redis:
4079 rtype: service
4080 image:
4081 name: redis:alpine
4082 # Default shared mode
4083 api:
4084 rtype: service
4085 image:
4086 name: api:latest
4087 node_mode: dedicated
4088 database:
4089 rtype: service
4090 image:
4091 name: postgres:15
4092 node_mode: exclusive
4093 node_selector:
4094 labels:
4095 storage: ssd
4096";
4097
4098 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4099 assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
4100 assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
4101 assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
4102
4103 let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
4104 assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
4105 }
4106
4107 #[test]
4108 fn test_storage_bind_mount() {
4109 let yaml = r"
4110version: v1
4111deployment: test
4112services:
4113 app:
4114 image:
4115 name: app:latest
4116 storage:
4117 - type: bind
4118 source: /host/data
4119 target: /app/data
4120 readonly: true
4121";
4122 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4123 let storage = &spec.services["app"].storage;
4124 assert_eq!(storage.len(), 1);
4125 match &storage[0] {
4126 StorageSpec::Bind {
4127 source,
4128 target,
4129 readonly,
4130 } => {
4131 assert_eq!(source, "/host/data");
4132 assert_eq!(target, "/app/data");
4133 assert!(*readonly);
4134 }
4135 _ => panic!("Expected Bind storage"),
4136 }
4137 }
4138
4139 #[test]
4140 fn test_storage_named_with_tier() {
4141 let yaml = r"
4142version: v1
4143deployment: test
4144services:
4145 app:
4146 image:
4147 name: app:latest
4148 storage:
4149 - type: named
4150 name: my-data
4151 target: /app/data
4152 tier: cached
4153";
4154 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4155 let storage = &spec.services["app"].storage;
4156 match &storage[0] {
4157 StorageSpec::Named {
4158 name, target, tier, ..
4159 } => {
4160 assert_eq!(name, "my-data");
4161 assert_eq!(target, "/app/data");
4162 assert_eq!(*tier, StorageTier::Cached);
4163 }
4164 _ => panic!("Expected Named storage"),
4165 }
4166 }
4167
4168 #[test]
4169 fn test_storage_anonymous() {
4170 let yaml = r"
4171version: v1
4172deployment: test
4173services:
4174 app:
4175 image:
4176 name: app:latest
4177 storage:
4178 - type: anonymous
4179 target: /app/cache
4180";
4181 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4182 let storage = &spec.services["app"].storage;
4183 match &storage[0] {
4184 StorageSpec::Anonymous { target, tier } => {
4185 assert_eq!(target, "/app/cache");
4186 assert_eq!(*tier, StorageTier::Local); }
4188 _ => panic!("Expected Anonymous storage"),
4189 }
4190 }
4191
4192 #[test]
4193 fn test_storage_tmpfs() {
4194 let yaml = r"
4195version: v1
4196deployment: test
4197services:
4198 app:
4199 image:
4200 name: app:latest
4201 storage:
4202 - type: tmpfs
4203 target: /app/tmp
4204 size: 256Mi
4205 mode: 1777
4206";
4207 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4208 let storage = &spec.services["app"].storage;
4209 match &storage[0] {
4210 StorageSpec::Tmpfs { target, size, mode } => {
4211 assert_eq!(target, "/app/tmp");
4212 assert_eq!(size.as_deref(), Some("256Mi"));
4213 assert_eq!(*mode, Some(1777));
4214 }
4215 _ => panic!("Expected Tmpfs storage"),
4216 }
4217 }
4218
4219 #[test]
4220 fn test_storage_s3() {
4221 let yaml = r"
4222version: v1
4223deployment: test
4224services:
4225 app:
4226 image:
4227 name: app:latest
4228 storage:
4229 - type: s3
4230 bucket: my-bucket
4231 prefix: models/
4232 target: /app/models
4233 readonly: true
4234 endpoint: https://s3.us-west-2.amazonaws.com
4235 credentials: aws-creds
4236";
4237 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4238 let storage = &spec.services["app"].storage;
4239 match &storage[0] {
4240 StorageSpec::S3 {
4241 bucket,
4242 prefix,
4243 target,
4244 readonly,
4245 endpoint,
4246 credentials,
4247 } => {
4248 assert_eq!(bucket, "my-bucket");
4249 assert_eq!(prefix.as_deref(), Some("models/"));
4250 assert_eq!(target, "/app/models");
4251 assert!(*readonly);
4252 assert_eq!(
4253 endpoint.as_deref(),
4254 Some("https://s3.us-west-2.amazonaws.com")
4255 );
4256 assert_eq!(credentials.as_deref(), Some("aws-creds"));
4257 }
4258 _ => panic!("Expected S3 storage"),
4259 }
4260 }
4261
4262 #[test]
4263 fn test_storage_multiple_types() {
4264 let yaml = r"
4265version: v1
4266deployment: test
4267services:
4268 app:
4269 image:
4270 name: app:latest
4271 storage:
4272 - type: bind
4273 source: /etc/config
4274 target: /app/config
4275 readonly: true
4276 - type: named
4277 name: app-data
4278 target: /app/data
4279 - type: tmpfs
4280 target: /app/tmp
4281";
4282 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4283 let storage = &spec.services["app"].storage;
4284 assert_eq!(storage.len(), 3);
4285 assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
4286 assert!(matches!(&storage[1], StorageSpec::Named { .. }));
4287 assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
4288 }
4289
4290 #[test]
4291 fn test_storage_tier_default() {
4292 let yaml = r"
4293version: v1
4294deployment: test
4295services:
4296 app:
4297 image:
4298 name: app:latest
4299 storage:
4300 - type: named
4301 name: data
4302 target: /data
4303";
4304 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4305 match &spec.services["app"].storage[0] {
4306 StorageSpec::Named { tier, .. } => {
4307 assert_eq!(*tier, StorageTier::Local); }
4309 _ => panic!("Expected Named storage"),
4310 }
4311 }
4312
4313 #[test]
4318 fn test_endpoint_tunnel_config_basic() {
4319 let yaml = r"
4320version: v1
4321deployment: test
4322services:
4323 api:
4324 image:
4325 name: api:latest
4326 endpoints:
4327 - name: http
4328 protocol: http
4329 port: 8080
4330 tunnel:
4331 enabled: true
4332 remote_port: 8080
4333";
4334 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4335 let endpoint = &spec.services["api"].endpoints[0];
4336 let tunnel = endpoint.tunnel.as_ref().unwrap();
4337 assert!(tunnel.enabled);
4338 assert_eq!(tunnel.remote_port, 8080);
4339 assert!(tunnel.from.is_none());
4340 assert!(tunnel.to.is_none());
4341 }
4342
4343 #[test]
4344 fn test_endpoint_tunnel_config_full() {
4345 let yaml = r"
4346version: v1
4347deployment: test
4348services:
4349 api:
4350 image:
4351 name: api:latest
4352 endpoints:
4353 - name: http
4354 protocol: http
4355 port: 8080
4356 tunnel:
4357 enabled: true
4358 from: node-1
4359 to: ingress-node
4360 remote_port: 9000
4361 expose: public
4362 access:
4363 enabled: true
4364 max_ttl: 4h
4365 audit: true
4366";
4367 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4368 let endpoint = &spec.services["api"].endpoints[0];
4369 let tunnel = endpoint.tunnel.as_ref().unwrap();
4370 assert!(tunnel.enabled);
4371 assert_eq!(tunnel.from, Some("node-1".to_string()));
4372 assert_eq!(tunnel.to, Some("ingress-node".to_string()));
4373 assert_eq!(tunnel.remote_port, 9000);
4374 assert_eq!(tunnel.expose, Some(ExposeType::Public));
4375
4376 let access = tunnel.access.as_ref().unwrap();
4377 assert!(access.enabled);
4378 assert_eq!(access.max_ttl, Some("4h".to_string()));
4379 assert!(access.audit);
4380 }
4381
4382 #[test]
4383 fn test_top_level_tunnel_definition() {
4384 let yaml = r"
4385version: v1
4386deployment: test
4387services: {}
4388tunnels:
4389 db-tunnel:
4390 from: app-node
4391 to: db-node
4392 local_port: 5432
4393 remote_port: 5432
4394 protocol: tcp
4395 expose: internal
4396";
4397 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4398 let tunnel = spec.tunnels.get("db-tunnel").unwrap();
4399 assert_eq!(tunnel.from, "app-node");
4400 assert_eq!(tunnel.to, "db-node");
4401 assert_eq!(tunnel.local_port, 5432);
4402 assert_eq!(tunnel.remote_port, 5432);
4403 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
4404 assert_eq!(tunnel.expose, ExposeType::Internal);
4405 }
4406
4407 #[test]
4408 fn test_top_level_tunnel_defaults() {
4409 let yaml = r"
4410version: v1
4411deployment: test
4412services: {}
4413tunnels:
4414 simple-tunnel:
4415 from: node-a
4416 to: node-b
4417 local_port: 3000
4418 remote_port: 3000
4419";
4420 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4421 let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
4422 assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
4425
4426 #[test]
4427 fn test_tunnel_protocol_udp() {
4428 let yaml = r"
4429version: v1
4430deployment: test
4431services: {}
4432tunnels:
4433 udp-tunnel:
4434 from: node-a
4435 to: node-b
4436 local_port: 5353
4437 remote_port: 5353
4438 protocol: udp
4439";
4440 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4441 let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
4442 assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
4443 }
4444
4445 #[test]
4446 fn test_endpoint_without_tunnel() {
4447 let yaml = r"
4448version: v1
4449deployment: test
4450services:
4451 api:
4452 image:
4453 name: api:latest
4454 endpoints:
4455 - name: http
4456 protocol: http
4457 port: 8080
4458";
4459 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4460 let endpoint = &spec.services["api"].endpoints[0];
4461 assert!(endpoint.tunnel.is_none());
4462 }
4463
4464 #[test]
4465 fn test_deployment_without_tunnels() {
4466 let yaml = r"
4467version: v1
4468deployment: test
4469services:
4470 api:
4471 image:
4472 name: api:latest
4473";
4474 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4475 assert!(spec.tunnels.is_empty());
4476 }
4477
4478 #[test]
4483 fn test_spec_without_api_block_uses_defaults() {
4484 let yaml = r"
4485version: v1
4486deployment: test
4487services:
4488 hello:
4489 image:
4490 name: hello-world:latest
4491";
4492 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4493 assert!(spec.api.enabled);
4494 assert_eq!(spec.api.bind, "0.0.0.0:3669");
4495 assert!(spec.api.jwt_secret.is_none());
4496 assert!(spec.api.swagger);
4497 }
4498
4499 #[test]
4500 fn test_spec_with_explicit_api_block() {
4501 let yaml = r#"
4502version: v1
4503deployment: test
4504services:
4505 hello:
4506 image:
4507 name: hello-world:latest
4508api:
4509 enabled: false
4510 bind: "127.0.0.1:9090"
4511 jwt_secret: "my-secret"
4512 swagger: false
4513"#;
4514 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4515 assert!(!spec.api.enabled);
4516 assert_eq!(spec.api.bind, "127.0.0.1:9090");
4517 assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
4518 assert!(!spec.api.swagger);
4519 }
4520
4521 #[test]
4522 fn test_spec_with_partial_api_block() {
4523 let yaml = r#"
4524version: v1
4525deployment: test
4526services:
4527 hello:
4528 image:
4529 name: hello-world:latest
4530api:
4531 bind: "0.0.0.0:3000"
4532"#;
4533 let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
4534 assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
4536 assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
4539
4540 #[test]
4545 fn test_network_policy_spec_roundtrip() {
4546 let spec = NetworkPolicySpec {
4547 name: "corp-vpn".to_string(),
4548 description: Some("Corporate VPN network".to_string()),
4549 cidrs: vec!["10.200.0.0/16".to_string()],
4550 members: vec![
4551 NetworkMember {
4552 name: "alice".to_string(),
4553 kind: MemberKind::User,
4554 },
4555 NetworkMember {
4556 name: "ops-team".to_string(),
4557 kind: MemberKind::Group,
4558 },
4559 NetworkMember {
4560 name: "node-01".to_string(),
4561 kind: MemberKind::Node,
4562 },
4563 ],
4564 access_rules: vec![
4565 AccessRule {
4566 service: "api-gateway".to_string(),
4567 deployment: "*".to_string(),
4568 ports: Some(vec![443, 8080]),
4569 action: AccessAction::Allow,
4570 },
4571 AccessRule {
4572 service: "*".to_string(),
4573 deployment: "staging".to_string(),
4574 ports: None,
4575 action: AccessAction::Deny,
4576 },
4577 ],
4578 };
4579
4580 let yaml = serde_yaml::to_string(&spec).unwrap();
4581 let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
4582 assert_eq!(spec, deserialized);
4583 }
4584
4585 #[test]
4586 fn test_network_policy_spec_defaults() {
4587 let yaml = r"
4588name: minimal
4589";
4590 let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
4591 assert_eq!(spec.name, "minimal");
4592 assert!(spec.description.is_none());
4593 assert!(spec.cidrs.is_empty());
4594 assert!(spec.members.is_empty());
4595 assert!(spec.access_rules.is_empty());
4596 }
4597
4598 #[test]
4599 fn test_access_rule_defaults() {
4600 let yaml = "{}";
4601 let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
4602 assert_eq!(rule.service, "*");
4603 assert_eq!(rule.deployment, "*");
4604 assert!(rule.ports.is_none());
4605 assert_eq!(rule.action, AccessAction::Allow);
4606 }
4607
4608 #[test]
4609 fn test_member_kind_defaults_to_user() {
4610 let yaml = r"
4611name: bob
4612";
4613 let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
4614 assert_eq!(member.name, "bob");
4615 assert_eq!(member.kind, MemberKind::User);
4616 }
4617
4618 #[test]
4619 fn test_member_kind_variants() {
4620 for (input, expected) in [
4621 ("user", MemberKind::User),
4622 ("group", MemberKind::Group),
4623 ("node", MemberKind::Node),
4624 ("cidr", MemberKind::Cidr),
4625 ] {
4626 let yaml = format!("name: test\nkind: {input}");
4627 let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
4628 assert_eq!(member.kind, expected);
4629 }
4630 }
4631
4632 #[test]
4633 fn test_access_action_variants() {
4634 #[derive(Debug, Deserialize)]
4636 struct Wrapper {
4637 action: AccessAction,
4638 }
4639
4640 let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
4641 let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
4642
4643 assert_eq!(allow.action, AccessAction::Allow);
4644 assert_eq!(deny.action, AccessAction::Deny);
4645 }
4646
4647 #[test]
4648 fn test_network_policy_spec_default_impl() {
4649 let spec = NetworkPolicySpec::default();
4650 assert_eq!(spec.name, "");
4651 assert!(spec.description.is_none());
4652 assert!(spec.cidrs.is_empty());
4653 assert!(spec.members.is_empty());
4654 assert!(spec.access_rules.is_empty());
4655 }
4656
4657 #[test]
4658 fn container_restart_policy_serde_roundtrip_all_kinds() {
4659 let cases = [
4664 (
4665 ContainerRestartPolicy {
4666 kind: ContainerRestartKind::No,
4667 max_attempts: None,
4668 delay: None,
4669 },
4670 r#"{"kind":"no"}"#,
4671 ),
4672 (
4673 ContainerRestartPolicy {
4674 kind: ContainerRestartKind::Always,
4675 max_attempts: None,
4676 delay: Some("500ms".to_string()),
4677 },
4678 r#"{"kind":"always","delay":"500ms"}"#,
4679 ),
4680 (
4681 ContainerRestartPolicy {
4682 kind: ContainerRestartKind::UnlessStopped,
4683 max_attempts: None,
4684 delay: None,
4685 },
4686 r#"{"kind":"unless_stopped"}"#,
4687 ),
4688 (
4689 ContainerRestartPolicy {
4690 kind: ContainerRestartKind::OnFailure,
4691 max_attempts: Some(5),
4692 delay: None,
4693 },
4694 r#"{"kind":"on_failure","max_attempts":5}"#,
4695 ),
4696 ];
4697
4698 for (value, expected_json) in &cases {
4699 let serialized = serde_json::to_string(value).expect("serialize");
4700 assert_eq!(&serialized, expected_json, "serialize mismatch");
4701 let round: ContainerRestartPolicy =
4702 serde_json::from_str(&serialized).expect("deserialize");
4703 assert_eq!(&round, value, "roundtrip mismatch");
4704 }
4705 }
4706
4707 #[test]
4710 fn registry_auth_type_serializes_snake_case() {
4711 assert_eq!(
4712 serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
4713 "\"basic\""
4714 );
4715 assert_eq!(
4716 serde_json::to_string(&RegistryAuthType::Token).unwrap(),
4717 "\"token\""
4718 );
4719 }
4720
4721 #[test]
4722 fn registry_auth_default_auth_type_is_basic() {
4723 let json = r#"{"username":"u","password":"p"}"#;
4725 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
4726 assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
4727 assert_eq!(parsed.username, "u");
4728 assert_eq!(parsed.password, "p");
4729 }
4730
4731 #[test]
4732 fn registry_auth_serde_roundtrip_both_variants() {
4733 for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
4734 let cred = RegistryAuth {
4735 username: "ci-bot".to_string(),
4736 password: "s3cret".to_string(),
4737 auth_type: variant,
4738 };
4739 let serialized = serde_json::to_string(&cred).expect("serialize");
4740 let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
4741 assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
4742 }
4743 }
4744
4745 #[test]
4746 fn registry_auth_explicit_token_type_parses() {
4747 let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
4748 let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
4749 assert_eq!(parsed.auth_type, RegistryAuthType::Token);
4750 }
4751
4752 #[test]
4753 fn target_platform_as_oci_str() {
4754 assert_eq!(
4755 TargetPlatform::new(OsKind::Linux, ArchKind::Amd64).as_oci_str(),
4756 "linux/amd64"
4757 );
4758 assert_eq!(
4759 TargetPlatform::new(OsKind::Windows, ArchKind::Arm64).as_oci_str(),
4760 "windows/arm64"
4761 );
4762 assert_eq!(
4763 TargetPlatform::new(OsKind::Macos, ArchKind::Arm64).as_oci_str(),
4764 "darwin/arm64"
4765 );
4766 }
4767
4768 #[test]
4769 fn os_kind_from_rust_consts() {
4770 assert_eq!(OsKind::from_rust_os("linux"), Some(OsKind::Linux));
4771 assert_eq!(OsKind::from_rust_os("windows"), Some(OsKind::Windows));
4772 assert_eq!(OsKind::from_rust_os("macos"), Some(OsKind::Macos));
4773 assert_eq!(OsKind::from_rust_os("freebsd"), None);
4774 }
4775
4776 #[test]
4777 fn arch_kind_from_rust_consts() {
4778 assert_eq!(ArchKind::from_rust_arch("x86_64"), Some(ArchKind::Amd64));
4779 assert_eq!(ArchKind::from_rust_arch("aarch64"), Some(ArchKind::Arm64));
4780 assert_eq!(ArchKind::from_rust_arch("riscv64"), None);
4781 }
4782
4783 #[test]
4784 fn service_spec_platform_yaml_round_trip_none() {
4785 let yaml = r"
4788version: v1
4789deployment: test
4790services:
4791 app:
4792 rtype: service
4793 image:
4794 name: nginx:latest
4795";
4796 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
4797 assert!(spec.services["app"].platform.is_none());
4798 }
4799
4800 #[test]
4801 fn service_spec_platform_yaml_round_trip_some() {
4802 let yaml = r"
4803version: v1
4804deployment: test
4805services:
4806 app:
4807 rtype: service
4808 image:
4809 name: nginx:latest
4810 platform:
4811 os: windows
4812 arch: amd64
4813";
4814 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
4815 assert_eq!(
4816 spec.services["app"].platform,
4817 Some(TargetPlatform::new(OsKind::Windows, ArchKind::Amd64))
4818 );
4819 }
4820
4821 #[test]
4822 fn service_spec_platform_serializes_omitted_when_none() {
4823 let yaml = r"
4826version: v1
4827deployment: test
4828services:
4829 app:
4830 rtype: service
4831 image:
4832 name: nginx:latest
4833";
4834 let mut spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
4835 let service = spec.services.get_mut("app").expect("service present");
4836 service.platform = None;
4837 let rendered = serde_yaml::to_string(service).expect("render");
4838 assert!(
4839 !rendered.contains("platform"),
4840 "platform must be omitted when None: {rendered}"
4841 );
4842 }
4843
4844 #[test]
4845 fn target_platform_os_version_builder() {
4846 let p =
4847 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
4848 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
4849 assert_eq!(p.os, OsKind::Windows);
4850 assert_eq!(p.arch, ArchKind::Amd64);
4851 }
4852
4853 #[test]
4854 fn target_platform_os_version_yaml_roundtrip() {
4855 let yaml = "os: windows\narch: amd64\nosVersion: 10.0.26100.1\n";
4856 let p: TargetPlatform = serde_yaml::from_str(yaml).expect("yaml parse");
4857 assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
4858 assert_eq!(p.os, OsKind::Windows);
4859 assert_eq!(p.arch, ArchKind::Amd64);
4860 }
4861
4862 #[test]
4863 fn target_platform_os_version_yaml_omits_when_none() {
4864 let p = TargetPlatform::new(OsKind::Linux, ArchKind::Amd64);
4865 let rendered = serde_yaml::to_string(&p).expect("render");
4866 assert!(
4867 !rendered.contains("osVersion"),
4868 "osVersion must be omitted when None: {rendered}"
4869 );
4870 }
4871
4872 #[test]
4873 fn target_platform_as_detailed_str_includes_version() {
4874 let without = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).as_detailed_str();
4875 assert_eq!(without, "windows/amd64");
4876
4877 let with = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64)
4878 .with_os_version("10.0.26100.1")
4879 .as_detailed_str();
4880 assert_eq!(with, "windows/amd64 (os.version=10.0.26100.1)");
4881 }
4882
4883 #[test]
4884 fn target_platform_display_ignores_version() {
4885 let p =
4887 TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
4888 assert_eq!(format!("{p}"), "windows/amd64");
4889 }
4890
4891 fn fixture_service_spec_full() -> ServiceSpec {
4897 let yaml = r"
4898version: v1
4899deployment: phase1-task1
4900services:
4901 hello:
4902 rtype: service
4903 image:
4904 name: hello-world:latest
4905";
4906 let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse fixture");
4907 spec.services.get("hello").expect("hello service").clone()
4908 }
4909
4910 #[test]
4911 fn service_spec_round_trip_with_all_new_fields() {
4912 let mut spec = fixture_service_spec_full();
4913 spec.labels
4914 .insert("zlayer.team".to_string(), "platform".to_string());
4915 spec.user = Some("1000:1000".to_string());
4916 spec.stop_signal = Some("SIGTERM".to_string());
4917 spec.stop_grace_period = Some(std::time::Duration::from_secs(30));
4918 spec.sysctls
4919 .insert("net.core.somaxconn".to_string(), "1024".to_string());
4920 spec.ulimits.insert(
4921 "nofile".to_string(),
4922 UlimitSpec {
4923 soft: 65_536,
4924 hard: 65_536,
4925 },
4926 );
4927 spec.security_opt.push("no-new-privileges:true".to_string());
4928 spec.pid_mode = Some("host".to_string());
4929 spec.ipc_mode = Some("private".to_string());
4930 spec.network_mode = NetworkMode::Bridge {
4931 name: Some("custom-net".to_string()),
4932 };
4933 spec.cap_drop.push("NET_RAW".to_string());
4934 spec.extra_groups.push("docker".to_string());
4935 spec.read_only_root_fs = true;
4936 spec.init_container = Some(true);
4937 spec.resources.pids_limit = Some(2048);
4938 spec.resources.cpuset = Some("0-3".to_string());
4939 spec.resources.cpu_shares = Some(1024);
4940 spec.resources.memory_swap = Some("2Gi".to_string());
4941 spec.resources.memory_reservation = Some("256Mi".to_string());
4942 spec.resources.memory_swappiness = Some(10);
4943 spec.resources.oom_score_adj = Some(-500);
4944 spec.resources.oom_kill_disable = Some(false);
4945 spec.resources.blkio_weight = Some(500);
4946
4947 let yaml = serde_yaml::to_string(&spec).expect("serialize");
4948 let round: ServiceSpec = serde_yaml::from_str(&yaml).expect("deserialize");
4949 assert_eq!(spec, round, "round-trip mismatch:\n{yaml}");
4950 }
4951
4952 #[test]
4953 fn network_mode_string_form_round_trip() {
4954 let cases: &[(&str, NetworkMode)] = &[
4955 ("default", NetworkMode::Default),
4956 ("host", NetworkMode::Host),
4957 ("none", NetworkMode::None),
4958 ("bridge", NetworkMode::Bridge { name: None }),
4959 (
4960 "bridge:custom",
4961 NetworkMode::Bridge {
4962 name: Some("custom".to_string()),
4963 },
4964 ),
4965 (
4966 "container:abc123",
4967 NetworkMode::Container {
4968 id: "abc123".to_string(),
4969 },
4970 ),
4971 ];
4972
4973 for (input, expected) in cases {
4974 #[derive(Deserialize)]
4975 struct Wrap {
4976 #[serde(deserialize_with = "deserialize_network_mode")]
4977 m: NetworkMode,
4978 }
4979 let yaml = format!("m: \"{input}\"\n");
4980 let parsed: Wrap = serde_yaml::from_str(&yaml).expect("parse network mode");
4981 assert_eq!(&parsed.m, expected, "mismatch for {input}");
4982 }
4983 }
4984
4985 #[test]
4993 fn network_mode_json_round_trip_all_variants() {
4994 #[derive(Serialize, Deserialize, PartialEq, Debug)]
4995 struct Wrap {
4996 #[serde(deserialize_with = "deserialize_network_mode")]
4997 m: NetworkMode,
4998 }
4999
5000 let cases = [
5001 NetworkMode::Default,
5002 NetworkMode::Host,
5003 NetworkMode::None,
5004 NetworkMode::Bridge { name: None },
5005 NetworkMode::Bridge {
5006 name: Some("custom-net".to_string()),
5007 },
5008 NetworkMode::Container {
5009 id: "abc123".to_string(),
5010 },
5011 ];
5012
5013 for m in cases {
5014 let wrap = Wrap { m: m.clone() };
5015 let json = serde_json::to_string(&wrap).expect("serialize json");
5016 let round: Wrap = serde_json::from_str(&json).expect("deserialize json");
5017 assert_eq!(round.m, m, "json round-trip mismatch:\n{json}");
5018 }
5019 }
5020
5021 #[test]
5024 fn service_spec_json_round_trip_with_struct_network_mode() {
5025 let spec = ServiceSpec {
5026 network_mode: NetworkMode::Bridge {
5027 name: Some("custom-net".to_string()),
5028 },
5029 ..Default::default()
5030 };
5031
5032 let json = serde_json::to_string(&spec).expect("serialize json");
5033 let round: ServiceSpec = serde_json::from_str(&json).expect("deserialize json");
5034 assert_eq!(spec, round, "json round-trip mismatch:\n{json}");
5035 }
5036
5037 #[test]
5038 fn ulimit_spec_round_trip() {
5039 let u = UlimitSpec {
5040 soft: 1024,
5041 hard: 65_536,
5042 };
5043 let yaml = serde_yaml::to_string(&u).expect("serialize");
5044 let parsed: UlimitSpec = serde_yaml::from_str(&yaml).expect("parse");
5045 assert_eq!(u, parsed);
5046 }
5047
5048 #[test]
5049 fn ulimit_spec_full_form() {
5050 let parsed: UlimitSpec =
5051 serde_yaml::from_str("soft: 100000\nhard: 200000\n").expect("parse");
5052 assert_eq!(
5053 parsed,
5054 UlimitSpec {
5055 soft: 100_000,
5056 hard: 200_000,
5057 }
5058 );
5059 }
5060
5061 #[test]
5062 fn ulimit_spec_soft_only_defaults_hard_to_soft() {
5063 let parsed: UlimitSpec = serde_yaml::from_str("soft: 100000\n").expect("parse");
5067 assert_eq!(
5068 parsed,
5069 UlimitSpec {
5070 soft: 100_000,
5071 hard: 100_000,
5072 }
5073 );
5074 }
5075
5076 #[test]
5077 fn ulimit_spec_hard_only_defaults_soft_to_hard() {
5078 let parsed: UlimitSpec = serde_yaml::from_str("hard: 100000\n").expect("parse");
5080 assert_eq!(
5081 parsed,
5082 UlimitSpec {
5083 soft: 100_000,
5084 hard: 100_000,
5085 }
5086 );
5087 }
5088
5089 #[test]
5090 fn ulimit_spec_both_absent_is_zero() {
5091 let parsed: UlimitSpec = serde_yaml::from_str("{}\n").expect("parse");
5092 assert_eq!(parsed, UlimitSpec { soft: 0, hard: 0 });
5093 }
5094
5095 #[test]
5096 fn ulimit_spec_explicit_zero_hard_is_preserved() {
5097 let parsed: UlimitSpec = serde_yaml::from_str("soft: 100000\nhard: 0\n").expect("parse");
5100 assert_eq!(
5101 parsed,
5102 UlimitSpec {
5103 soft: 100_000,
5104 hard: 0,
5105 }
5106 );
5107 }
5108
5109 #[test]
5110 fn ulimit_spec_in_service_map_soft_only() {
5111 #[derive(Deserialize)]
5114 struct Wrap {
5115 ulimits: std::collections::HashMap<String, UlimitSpec>,
5116 }
5117 let yaml = r"
5118ulimits:
5119 nofile:
5120 soft: 100000
5121";
5122 let parsed: Wrap = serde_yaml::from_str(yaml).expect("parse");
5123 assert_eq!(
5124 parsed.ulimits.get("nofile"),
5125 Some(&UlimitSpec {
5126 soft: 100_000,
5127 hard: 100_000,
5128 })
5129 );
5130 }
5131
5132 #[test]
5133 fn host_network_true_yaml_promotes_to_network_mode_host() {
5134 let yaml = r"
5135version: v1
5136deployment: bc-test
5137services:
5138 hello:
5139 rtype: service
5140 image:
5141 name: hello-world:latest
5142 host_network: true
5143";
5144 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse");
5145 let svc = dep.services.get("hello").expect("hello service");
5146 assert_eq!(svc.network_mode, NetworkMode::Host);
5147 assert!(svc.host_network);
5150 }
5151
5152 #[test]
5153 fn capabilities_yaml_alias_cap_add_round_trip() {
5154 let yaml = r"
5157version: v1
5158deployment: cap-test
5159services:
5160 hello:
5161 rtype: service
5162 image:
5163 name: hello-world:latest
5164 cap_add:
5165 - NET_ADMIN
5166 - SYS_PTRACE
5167";
5168 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse cap_add alias");
5169 let svc = dep.services.get("hello").expect("hello service");
5170 assert_eq!(
5171 svc.capabilities,
5172 vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()]
5173 );
5174 }
5175
5176 #[test]
5177 fn lifecycle_omitted_defaults_to_false() {
5178 let yaml = r"
5184version: v1
5185deployment: lifecycle-default-test
5186services:
5187 app:
5188 rtype: service
5189 image:
5190 name: hello-world:latest
5191";
5192 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec without lifecycle");
5193 let svc = dep.services.get("app").expect("app service");
5194 assert_eq!(svc.lifecycle, LifecycleSpec::default());
5195 assert!(!svc.lifecycle.delete_on_exit);
5196 }
5197
5198 #[test]
5199 fn lifecycle_delete_on_exit_round_trips() {
5200 let yaml = r"
5204version: v1
5205deployment: lifecycle-delete-test
5206services:
5207 app:
5208 rtype: service
5209 image:
5210 name: hello-world:latest
5211 lifecycle:
5212 delete_on_exit: true
5213";
5214 let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec with lifecycle");
5215 let svc = dep.services.get("app").expect("app service");
5216 assert!(svc.lifecycle.delete_on_exit);
5217
5218 let dumped = serde_yaml::to_string(&dep).expect("serialize spec with lifecycle");
5221 let reparsed: DeploymentSpec =
5222 serde_yaml::from_str(&dumped).expect("reparse round-tripped spec");
5223 let reparsed_svc = reparsed.services.get("app").expect("app service after rt");
5224 assert!(reparsed_svc.lifecycle.delete_on_exit);
5225 assert_eq!(svc.lifecycle, reparsed_svc.lifecycle);
5226 }
5227}
5228
5229#[cfg(test)]
5230mod replica_group_tests {
5231 use super::{
5232 validate_unique_replica_group_roles, EndpointSpec, GroupAffinity, LocalhostReachability,
5233 ReplicaGroup, ScaleSpec, ScaleTargets, ServiceSpec, REPLICA_GROUP_ROLE_RE,
5234 };
5235
5236 #[test]
5237 fn yaml_roundtrip_basic_group() {
5238 let yaml = r"
5239role: primary
5240count: 1
5241env:
5242 POSTGRES_REPLICATION_MODE: primary
5243affinity: spread
5244";
5245 let group: ReplicaGroup = serde_yaml::from_str(yaml).expect("parse basic group");
5246 assert_eq!(group.role, "primary");
5247 assert_eq!(group.count, 1);
5248 assert_eq!(group.affinity, GroupAffinity::Spread);
5249 assert_eq!(
5250 group.env.get("POSTGRES_REPLICATION_MODE"),
5251 Some(&"primary".to_string())
5252 );
5253 }
5254
5255 #[test]
5256 fn yaml_default_affinity_is_spread() {
5257 let yaml = "role: x\ncount: 2\n";
5258 let group: ReplicaGroup = serde_yaml::from_str(yaml).expect("parse minimal group");
5259 assert_eq!(group.affinity, GroupAffinity::Spread);
5260 }
5261
5262 #[test]
5263 fn role_regex_accepts_valid_labels() {
5264 for ok in ["a", "primary", "read-only", "x1", "ab-cd-ef"] {
5265 assert!(
5266 REPLICA_GROUP_ROLE_RE.is_match(ok),
5267 "regex should accept: {ok}"
5268 );
5269 }
5270 }
5271
5272 #[test]
5273 fn role_regex_rejects_invalid_labels() {
5274 for bad in [
5275 "",
5276 "-primary",
5277 "primary-",
5278 "Primary",
5279 "0primary",
5280 "primary_role",
5281 "this-is-way-too-long-of-a-role-name-here",
5282 ] {
5283 assert!(
5284 !REPLICA_GROUP_ROLE_RE.is_match(bad),
5285 "regex should reject: {bad}"
5286 );
5287 }
5288 }
5289
5290 #[test]
5291 fn group_affinity_pin_roundtrips_via_serde_yaml() {
5292 let pinned = GroupAffinity::Pin("id=2".to_string());
5295 let dumped = serde_yaml::to_string(&pinned).expect("serialize pin");
5296 let reparsed: GroupAffinity = serde_yaml::from_str(&dumped).expect("reparse pin");
5297 match reparsed {
5298 GroupAffinity::Pin(s) => assert_eq!(s, "id=2"),
5299 other => panic!("expected Pin, got {other:?}"),
5300 }
5301 }
5302
5303 #[test]
5304 fn unique_role_validator_rejects_duplicates() {
5305 let mk = |role: &str| ReplicaGroup {
5306 role: role.to_string(),
5307 count: 1,
5308 image: None,
5309 env: std::collections::HashMap::new(),
5310 command: None,
5311 resources: None,
5312 affinity: GroupAffinity::Spread,
5313 };
5314 assert!(validate_unique_replica_group_roles(&[mk("a"), mk("b")]).is_ok());
5315 let err = validate_unique_replica_group_roles(&[mk("a"), mk("a")])
5316 .expect_err("duplicate should fail");
5317 assert_eq!(err, "a");
5318 }
5319
5320 #[test]
5321 fn endpoint_target_role_yaml_roundtrip() {
5322 let yaml = "name: read\nprotocol: tcp\nport: 5433\ntarget_role: read\n";
5323 let ep: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
5324 assert_eq!(ep.target_role, Some("read".to_string()));
5325 }
5326
5327 #[test]
5328 fn endpoint_without_target_role_is_none() {
5329 let yaml = "name: any\nprotocol: tcp\nport: 5432\n";
5330 let ep: EndpointSpec = serde_yaml::from_str(yaml).unwrap();
5331 assert_eq!(ep.target_role, None);
5332 }
5333
5334 fn spec_with_scale(scale: ScaleSpec) -> ServiceSpec {
5339 let mut s = ServiceSpec::minimal("svc", "scratch:latest");
5340 s.scale = scale;
5341 s
5342 }
5343
5344 fn replica_group(role: &str, count: u32) -> ReplicaGroup {
5345 ReplicaGroup {
5346 role: role.to_string(),
5347 count,
5348 image: None,
5349 env: std::collections::HashMap::new(),
5350 command: None,
5351 resources: None,
5352 affinity: GroupAffinity::Spread,
5353 }
5354 }
5355
5356 #[test]
5357 fn is_single_member_across_scale_modes() {
5358 assert!(spec_with_scale(ScaleSpec::Fixed { replicas: 1 }).is_single_member());
5359 assert!(spec_with_scale(ScaleSpec::Fixed { replicas: 0 }).is_single_member());
5360 assert!(!spec_with_scale(ScaleSpec::Fixed { replicas: 3 }).is_single_member());
5361
5362 let adaptive = |min, max| ScaleSpec::Adaptive {
5363 min,
5364 max,
5365 cooldown: None,
5366 targets: ScaleTargets::default(),
5367 behavior: None,
5368 triggers: Vec::new(),
5369 idle_window: None,
5370 vertical: None,
5371 predictive: None,
5372 };
5373 assert!(spec_with_scale(adaptive(1, 1)).is_single_member());
5374 assert!(!spec_with_scale(adaptive(1, 5)).is_single_member());
5375
5376 assert!(spec_with_scale(ScaleSpec::Manual).is_single_member());
5377 }
5378
5379 #[test]
5380 fn is_single_member_with_replica_groups() {
5381 let mut s = ServiceSpec::minimal("svc", "scratch:latest");
5383 s.replica_groups = Some(vec![replica_group("only", 1)]);
5384 assert!(s.is_single_member());
5385
5386 s.replica_groups = Some(vec![replica_group("only", 2)]);
5388 assert!(!s.is_single_member());
5389
5390 s.replica_groups = Some(vec![replica_group("a", 1), replica_group("b", 1)]);
5392 assert!(!s.is_single_member());
5393
5394 s.scale = ScaleSpec::Fixed { replicas: 1 };
5396 s.replica_groups = Some(vec![replica_group("a", 1), replica_group("b", 1)]);
5397 assert!(!s.is_single_member());
5398 }
5399
5400 #[test]
5401 fn publish_to_node_loopback_override_matrix() {
5402 let single = spec_with_scale(ScaleSpec::Fixed { replicas: 1 });
5404 let multi = spec_with_scale(ScaleSpec::Fixed { replicas: 3 });
5406
5407 let mut s = single.clone();
5409 s.localhost_reachability = LocalhostReachability::Auto;
5410 assert!(s.publish_to_node_loopback());
5411 let mut m = multi.clone();
5412 m.localhost_reachability = LocalhostReachability::Auto;
5413 assert!(!m.publish_to_node_loopback());
5414
5415 let mut s = single.clone();
5417 s.localhost_reachability = LocalhostReachability::Always;
5418 assert!(s.publish_to_node_loopback());
5419 let mut m = multi.clone();
5420 m.localhost_reachability = LocalhostReachability::Always;
5421 assert!(m.publish_to_node_loopback());
5422
5423 let mut s = single;
5425 s.localhost_reachability = LocalhostReachability::Never;
5426 assert!(!s.publish_to_node_loopback());
5427 let mut m = multi;
5428 m.localhost_reachability = LocalhostReachability::Never;
5429 assert!(!m.publish_to_node_loopback());
5430 }
5431
5432 #[test]
5433 fn localhost_reachability_default_is_auto() {
5434 assert_eq!(
5435 LocalhostReachability::default(),
5436 LocalhostReachability::Auto
5437 );
5438 assert!(LocalhostReachability::Auto.is_default());
5439 assert!(!LocalhostReachability::Always.is_default());
5440 assert!(!LocalhostReachability::Never.is_default());
5441 let minimal = ServiceSpec::minimal("svc", "scratch:latest");
5444 assert_eq!(minimal.localhost_reachability, LocalhostReachability::Auto);
5445 assert!(!minimal.is_single_member());
5446 assert!(!minimal.publish_to_node_loopback());
5447 }
5448}