Skip to main content

zlayer_spec/
types.rs

1//! ZLayer V1 Service Specification Types
2//!
3//! This module defines all types for parsing and validating ZLayer deployment specs.
4
5mod duration {
6    use humantime::format_duration;
7    use serde::{Deserialize, Deserializer, Serializer};
8    use std::time::Duration;
9
10    pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
11    where
12        S: Serializer,
13    {
14        match duration {
15            Some(d) => serializer.serialize_str(&format_duration(*d).to_string()),
16            None => serializer.serialize_none(),
17        }
18    }
19
20    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
21    where
22        D: Deserializer<'de>,
23    {
24        use serde::de::Error;
25        let s: Option<String> = Option::deserialize(deserializer)?;
26        match s {
27            Some(s) => humantime::parse_duration(&s)
28                .map(Some)
29                .map_err(|e| D::Error::custom(format!("invalid duration: {}", e))),
30            None => Ok(None),
31        }
32    }
33
34    pub mod option {
35        pub use super::*;
36    }
37
38    /// Serde module for required (non-Option) Duration fields
39    pub mod required {
40        use humantime::format_duration;
41        use serde::{Deserialize, Deserializer, Serializer};
42        use std::time::Duration;
43
44        pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
45        where
46            S: Serializer,
47        {
48            serializer.serialize_str(&format_duration(*duration).to_string())
49        }
50
51        pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
52        where
53            D: Deserializer<'de>,
54        {
55            use serde::de::Error;
56            let s: String = String::deserialize(deserializer)?;
57            humantime::parse_duration(&s)
58                .map_err(|e| D::Error::custom(format!("invalid duration: {}", e)))
59        }
60    }
61}
62
63use serde::{Deserialize, Serialize};
64use std::collections::HashMap;
65use validator::Validate;
66
67/// How service replicas are allocated to nodes
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum NodeMode {
71    /// Containers placed on any node with capacity (default, bin-packing)
72    #[default]
73    Shared,
74    /// Each replica gets its own dedicated node (1:1 mapping)
75    Dedicated,
76    /// Service is the ONLY thing on its nodes (no other services)
77    Exclusive,
78}
79
80/// Service type - determines runtime behavior and scaling model
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum ServiceType {
84    /// Standard long-running container service
85    #[default]
86    Standard,
87    /// WASM-based HTTP service with instance pooling
88    WasmHttp,
89    /// Run-to-completion job
90    Job,
91}
92
93/// Storage performance tier
94#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
95#[serde(rename_all = "snake_case")]
96pub enum StorageTier {
97    /// Direct local filesystem (SSD/NVMe) - SQLite-safe, fast fsync
98    #[default]
99    Local,
100    /// bcache-backed tiered storage (SSD cache + slower backend)
101    Cached,
102    /// NFS/network storage - NOT SQLite-safe (will warn)
103    Network,
104}
105
106/// Node selection constraints for service placement
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct NodeSelector {
110    /// Required labels that nodes must have (all must match)
111    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
112    pub labels: HashMap<String, String>,
113    /// Preferred labels (soft constraint, nodes with these are preferred)
114    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
115    pub prefer_labels: HashMap<String, String>,
116}
117
118/// Configuration for WASM HTTP services with instance pooling
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(deny_unknown_fields)]
121pub struct WasmHttpConfig {
122    /// Minimum number of warm instances to keep ready
123    #[serde(default = "default_min_instances")]
124    pub min_instances: u32,
125    /// Maximum number of instances to scale to
126    #[serde(default = "default_max_instances")]
127    pub max_instances: u32,
128    /// Time before idle instances are terminated
129    #[serde(default = "default_idle_timeout", with = "duration::required")]
130    pub idle_timeout: std::time::Duration,
131    /// Maximum time for a single request
132    #[serde(default = "default_request_timeout", with = "duration::required")]
133    pub request_timeout: std::time::Duration,
134}
135
136fn default_min_instances() -> u32 {
137    0
138}
139
140fn default_max_instances() -> u32 {
141    10
142}
143
144fn default_idle_timeout() -> std::time::Duration {
145    std::time::Duration::from_secs(300)
146}
147
148fn default_request_timeout() -> std::time::Duration {
149    std::time::Duration::from_secs(30)
150}
151
152impl Default for WasmHttpConfig {
153    fn default() -> Self {
154        Self {
155            min_instances: default_min_instances(),
156            max_instances: default_max_instances(),
157            idle_timeout: default_idle_timeout(),
158            request_timeout: default_request_timeout(),
159        }
160    }
161}
162
163/// Top-level deployment specification
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
165#[serde(deny_unknown_fields)]
166pub struct DeploymentSpec {
167    /// Spec version (must be "v1")
168    #[validate(custom(function = "crate::validate::validate_version_wrapper"))]
169    pub version: String,
170
171    /// Deployment name (used for overlays, DNS)
172    #[validate(custom(function = "crate::validate::validate_deployment_name_wrapper"))]
173    pub deployment: String,
174
175    /// Service definitions
176    #[serde(default)]
177    #[validate(nested)]
178    pub services: HashMap<String, ServiceSpec>,
179}
180
181/// Per-service specification
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
183#[serde(deny_unknown_fields)]
184pub struct ServiceSpec {
185    /// Resource type (service, job, cron)
186    #[serde(default = "default_resource_type")]
187    pub rtype: ResourceType,
188
189    /// Cron schedule expression (only for rtype: cron)
190    /// Uses 7-field cron syntax: "sec min hour day-of-month month day-of-week year"
191    /// Examples:
192    ///   - "0 0 0 * * * *" (daily at midnight)
193    ///   - "0 */5 * * * * *" (every 5 minutes)
194    ///   - "0 0 12 * * MON-FRI *" (weekdays at noon)
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    #[validate(custom(function = "crate::validate::validate_schedule_wrapper"))]
197    pub schedule: Option<String>,
198
199    /// Container image specification
200    #[validate(nested)]
201    pub image: ImageSpec,
202
203    /// Resource limits
204    #[serde(default)]
205    #[validate(nested)]
206    pub resources: ResourcesSpec,
207
208    /// Environment variables for the service
209    ///
210    /// Values can be:
211    /// - Plain strings: `"value"`
212    /// - Host env refs: `$E:VAR_NAME`
213    /// - Secret refs: `$S:secret-name` or `$S:@service/secret-name`
214    #[serde(default)]
215    pub env: HashMap<String, String>,
216
217    /// Command override (entrypoint, args, workdir)
218    #[serde(default)]
219    pub command: CommandSpec,
220
221    /// Network configuration
222    #[serde(default)]
223    pub network: NetworkSpec,
224
225    /// Endpoint definitions (proxy bindings)
226    #[serde(default)]
227    #[validate(nested)]
228    pub endpoints: Vec<EndpointSpec>,
229
230    /// Scaling configuration
231    #[serde(default)]
232    #[validate(custom(function = "crate::validate::validate_scale_spec"))]
233    pub scale: ScaleSpec,
234
235    /// Dependency specifications
236    #[serde(default)]
237    pub depends: Vec<DependsSpec>,
238
239    /// Health check configuration
240    #[serde(default = "default_health")]
241    pub health: HealthSpec,
242
243    /// Init actions (pre-start lifecycle steps)
244    #[serde(default)]
245    pub init: InitSpec,
246
247    /// Error handling policies
248    #[serde(default)]
249    pub errors: ErrorsSpec,
250
251    /// Device passthrough (e.g., /dev/kvm for VMs)
252    #[serde(default)]
253    pub devices: Vec<DeviceSpec>,
254
255    /// Storage mounts for the container
256    #[serde(default, skip_serializing_if = "Vec::is_empty")]
257    pub storage: Vec<StorageSpec>,
258
259    /// Linux capabilities to add (e.g., SYS_ADMIN, NET_ADMIN)
260    #[serde(default)]
261    pub capabilities: Vec<String>,
262
263    /// Run container in privileged mode (all capabilities + all devices)
264    #[serde(default)]
265    pub privileged: bool,
266
267    /// Node allocation mode (shared, dedicated, exclusive)
268    #[serde(default)]
269    pub node_mode: NodeMode,
270
271    /// Node selection constraints (required/preferred labels)
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub node_selector: Option<NodeSelector>,
274
275    /// Service type (standard, wasm_http, job)
276    #[serde(default)]
277    pub service_type: ServiceType,
278
279    /// WASM HTTP configuration (only used when service_type is WasmHttp)
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub wasm_http: Option<WasmHttpConfig>,
282}
283
284/// Command override specification (Section 5.5)
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
286#[serde(deny_unknown_fields)]
287pub struct CommandSpec {
288    /// Override image ENTRYPOINT
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub entrypoint: Option<Vec<String>>,
291
292    /// Override image CMD
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub args: Option<Vec<String>>,
295
296    /// Override working directory
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub workdir: Option<String>,
299}
300
301fn default_resource_type() -> ResourceType {
302    ResourceType::Service
303}
304
305fn default_health() -> HealthSpec {
306    HealthSpec {
307        start_grace: None,
308        interval: None,
309        timeout: None,
310        retries: 3,
311        check: HealthCheck::Tcp { port: 0 },
312    }
313}
314
315/// Resource type - determines container lifecycle
316#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
317#[serde(rename_all = "lowercase")]
318pub enum ResourceType {
319    /// Long-running container, receives traffic, load-balanced
320    Service,
321    /// Run-to-completion, triggered by endpoint/CLI/internal system
322    Job,
323    /// Scheduled run-to-completion, time-triggered
324    Cron,
325}
326
327/// Container image specification
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
329#[serde(deny_unknown_fields)]
330pub struct ImageSpec {
331    /// Image name (e.g., "ghcr.io/org/api:latest")
332    #[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
333    pub name: String,
334
335    /// When to pull the image
336    #[serde(default = "default_pull_policy")]
337    pub pull_policy: PullPolicy,
338}
339
340fn default_pull_policy() -> PullPolicy {
341    PullPolicy::IfNotPresent
342}
343
344/// Image pull policy
345#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
346#[serde(rename_all = "snake_case")]
347pub enum PullPolicy {
348    /// Always pull the image
349    Always,
350    /// Pull only if not present locally
351    IfNotPresent,
352    /// Never pull, use local image only
353    Never,
354}
355
356/// Device passthrough specification
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
358#[serde(deny_unknown_fields)]
359pub struct DeviceSpec {
360    /// Host device path (e.g., /dev/kvm, /dev/net/tun)
361    #[validate(length(min = 1, message = "device path cannot be empty"))]
362    pub path: String,
363
364    /// Allow read access
365    #[serde(default = "default_true")]
366    pub read: bool,
367
368    /// Allow write access
369    #[serde(default = "default_true")]
370    pub write: bool,
371
372    /// Allow mknod (create device nodes)
373    #[serde(default)]
374    pub mknod: bool,
375}
376
377fn default_true() -> bool {
378    true
379}
380
381/// Storage mount specification
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
383#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
384pub enum StorageSpec {
385    /// Bind mount from host path to container
386    Bind {
387        source: String,
388        target: String,
389        #[serde(default)]
390        readonly: bool,
391    },
392    /// Named persistent storage volume
393    Named {
394        name: String,
395        target: String,
396        #[serde(default)]
397        readonly: bool,
398        /// Performance tier (default: local, SQLite-safe)
399        #[serde(default)]
400        tier: StorageTier,
401    },
402    /// Anonymous storage (auto-named, container lifecycle)
403    Anonymous {
404        target: String,
405        /// Performance tier (default: local)
406        #[serde(default)]
407        tier: StorageTier,
408    },
409    /// Memory-backed tmpfs mount
410    Tmpfs {
411        target: String,
412        #[serde(default)]
413        size: Option<String>,
414        #[serde(default)]
415        mode: Option<u32>,
416    },
417    /// S3-backed FUSE mount
418    S3 {
419        bucket: String,
420        #[serde(default)]
421        prefix: Option<String>,
422        target: String,
423        #[serde(default)]
424        readonly: bool,
425        #[serde(default)]
426        endpoint: Option<String>,
427        #[serde(default)]
428        credentials: Option<String>,
429    },
430}
431
432/// Resource limits (upper bounds, not reservations)
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
434#[serde(deny_unknown_fields)]
435pub struct ResourcesSpec {
436    /// CPU limit (cores, e.g., 0.5, 1, 2)
437    #[serde(default)]
438    #[validate(custom(function = "crate::validate::validate_cpu_option_wrapper"))]
439    pub cpu: Option<f64>,
440
441    /// Memory limit (e.g., "512Mi", "1Gi", "2Gi")
442    #[serde(default)]
443    #[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
444    pub memory: Option<String>,
445}
446
447/// Network configuration
448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
449#[serde(deny_unknown_fields)]
450#[derive(Default)]
451pub struct NetworkSpec {
452    /// Overlay network configuration
453    #[serde(default)]
454    pub overlays: OverlayConfig,
455
456    /// Join policy (who can join this service)
457    #[serde(default)]
458    pub join: JoinPolicy,
459}
460
461/// Overlay network configuration
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463#[serde(deny_unknown_fields)]
464pub struct OverlayConfig {
465    /// Service-scoped overlay (service replicas only)
466    #[serde(default)]
467    pub service: OverlaySettings,
468
469    /// Global overlay (all services in deployment)
470    #[serde(default)]
471    pub global: OverlaySettings,
472}
473
474impl Default for OverlayConfig {
475    fn default() -> Self {
476        Self {
477            service: OverlaySettings {
478                enabled: true,
479                encrypted: true,
480                isolated: true,
481            },
482            global: OverlaySettings {
483                enabled: true,
484                encrypted: true,
485                isolated: false,
486            },
487        }
488    }
489}
490
491/// Overlay network settings
492#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
493#[serde(deny_unknown_fields)]
494pub struct OverlaySettings {
495    /// Enable this overlay
496    #[serde(default = "default_enabled")]
497    pub enabled: bool,
498
499    /// Use encryption
500    #[serde(default = "default_encrypted")]
501    pub encrypted: bool,
502
503    /// Isolate from other services/groups
504    #[serde(default)]
505    pub isolated: bool,
506}
507
508fn default_enabled() -> bool {
509    true
510}
511
512fn default_encrypted() -> bool {
513    true
514}
515
516/// Join policy - controls who can join a service
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
518#[serde(deny_unknown_fields)]
519pub struct JoinPolicy {
520    /// Join mode
521    #[serde(default = "default_join_mode")]
522    pub mode: JoinMode,
523
524    /// Scope of join
525    #[serde(default = "default_join_scope")]
526    pub scope: JoinScope,
527}
528
529impl Default for JoinPolicy {
530    fn default() -> Self {
531        Self {
532            mode: default_join_mode(),
533            scope: default_join_scope(),
534        }
535    }
536}
537
538fn default_join_mode() -> JoinMode {
539    JoinMode::Token
540}
541
542fn default_join_scope() -> JoinScope {
543    JoinScope::Service
544}
545
546/// Join mode
547#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
548#[serde(rename_all = "snake_case")]
549pub enum JoinMode {
550    /// Any trusted node in deployment can self-enroll
551    Open,
552    /// Requires a join key (recommended)
553    Token,
554    /// Only control-plane/scheduler can place replicas
555    Closed,
556}
557
558/// Join scope
559#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
560#[serde(rename_all = "snake_case")]
561pub enum JoinScope {
562    /// Join this specific service
563    Service,
564    /// Join all services in deployment
565    Global,
566}
567
568/// Endpoint specification (proxy binding)
569#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
570#[serde(deny_unknown_fields)]
571pub struct EndpointSpec {
572    /// Endpoint name (for routing)
573    #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
574    pub name: String,
575
576    /// Protocol
577    pub protocol: Protocol,
578
579    /// Container port
580    #[validate(custom(function = "crate::validate::validate_port_wrapper"))]
581    pub port: u16,
582
583    /// URL path prefix (for http/https/websocket)
584    pub path: Option<String>,
585
586    /// Exposure type
587    #[serde(default = "default_expose")]
588    pub expose: ExposeType,
589}
590
591fn default_expose() -> ExposeType {
592    ExposeType::Internal
593}
594
595/// Protocol type
596#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
597#[serde(rename_all = "lowercase")]
598pub enum Protocol {
599    Http,
600    Https,
601    Tcp,
602    Udp,
603    Websocket,
604}
605
606/// Exposure type
607#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
608#[serde(rename_all = "lowercase")]
609pub enum ExposeType {
610    Public,
611    Internal,
612}
613
614/// Scaling configuration
615#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
616#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
617pub enum ScaleSpec {
618    /// Adaptive scaling with metrics
619    #[serde(rename = "adaptive")]
620    Adaptive {
621        /// Minimum replicas
622        min: u32,
623
624        /// Maximum replicas
625        max: u32,
626
627        /// Cooldown period between scale events
628        #[serde(default, with = "duration::option")]
629        cooldown: Option<std::time::Duration>,
630
631        /// Target metrics for scaling
632        #[serde(default)]
633        targets: ScaleTargets,
634    },
635
636    /// Fixed number of replicas
637    #[serde(rename = "fixed")]
638    Fixed { replicas: u32 },
639
640    /// Manual scaling (no automatic scaling)
641    #[serde(rename = "manual")]
642    Manual,
643}
644
645impl Default for ScaleSpec {
646    fn default() -> Self {
647        Self::Adaptive {
648            min: 1,
649            max: 10,
650            cooldown: Some(std::time::Duration::from_secs(30)),
651            targets: Default::default(),
652        }
653    }
654}
655
656/// Target metrics for adaptive scaling
657#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
658#[serde(deny_unknown_fields)]
659#[derive(Default)]
660pub struct ScaleTargets {
661    /// CPU percentage threshold (0-100)
662    #[serde(default)]
663    pub cpu: Option<u8>,
664
665    /// Memory percentage threshold (0-100)
666    #[serde(default)]
667    pub memory: Option<u8>,
668
669    /// Requests per second threshold
670    #[serde(default)]
671    pub rps: Option<u32>,
672}
673
674/// Dependency specification
675#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
676#[serde(deny_unknown_fields)]
677pub struct DependsSpec {
678    /// Service name to depend on
679    pub service: String,
680
681    /// Condition for dependency
682    #[serde(default = "default_condition")]
683    pub condition: DependencyCondition,
684
685    /// Maximum time to wait
686    #[serde(default = "default_timeout", with = "duration::option")]
687    pub timeout: Option<std::time::Duration>,
688
689    /// Action on timeout
690    #[serde(default = "default_on_timeout")]
691    pub on_timeout: TimeoutAction,
692}
693
694fn default_condition() -> DependencyCondition {
695    DependencyCondition::Healthy
696}
697
698fn default_timeout() -> Option<std::time::Duration> {
699    Some(std::time::Duration::from_secs(300))
700}
701
702fn default_on_timeout() -> TimeoutAction {
703    TimeoutAction::Fail
704}
705
706/// Dependency condition
707#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
708#[serde(rename_all = "lowercase")]
709pub enum DependencyCondition {
710    /// Container process exists
711    Started,
712    /// Health check passes
713    Healthy,
714    /// Service is available for routing
715    Ready,
716}
717
718/// Timeout action
719#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
720#[serde(rename_all = "lowercase")]
721pub enum TimeoutAction {
722    Fail,
723    Warn,
724    Continue,
725}
726
727/// Health check specification
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
729#[serde(deny_unknown_fields)]
730pub struct HealthSpec {
731    /// Grace period before first check
732    #[serde(default, with = "duration::option")]
733    pub start_grace: Option<std::time::Duration>,
734
735    /// Interval between checks
736    #[serde(default, with = "duration::option")]
737    pub interval: Option<std::time::Duration>,
738
739    /// Timeout per check
740    #[serde(default, with = "duration::option")]
741    pub timeout: Option<std::time::Duration>,
742
743    /// Number of retries before marking unhealthy
744    #[serde(default = "default_retries")]
745    pub retries: u32,
746
747    /// Health check type and parameters
748    pub check: HealthCheck,
749}
750
751fn default_retries() -> u32 {
752    3
753}
754
755/// Health check type
756#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
757#[serde(tag = "type", rename_all = "lowercase")]
758pub enum HealthCheck {
759    /// TCP port check
760    Tcp {
761        /// Port to check (0 = use first endpoint)
762        port: u16,
763    },
764
765    /// HTTP check
766    Http {
767        /// URL to check
768        url: String,
769        /// Expected status code
770        #[serde(default = "default_expect_status")]
771        expect_status: u16,
772    },
773
774    /// Command check
775    Command {
776        /// Command to run
777        command: String,
778    },
779}
780
781fn default_expect_status() -> u16 {
782    200
783}
784
785/// Init actions specification
786#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
787#[serde(deny_unknown_fields)]
788#[derive(Default)]
789pub struct InitSpec {
790    /// Init steps to run before container starts
791    #[serde(default)]
792    pub steps: Vec<InitStep>,
793}
794
795/// Init action step
796#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
797#[serde(deny_unknown_fields)]
798pub struct InitStep {
799    /// Step identifier
800    pub id: String,
801
802    /// Action to perform (e.g., "init.wait_tcp")
803    pub uses: String,
804
805    /// Parameters for the action
806    #[serde(default)]
807    pub with: InitParams,
808
809    /// Number of retries
810    #[serde(default)]
811    pub retry: Option<u32>,
812
813    /// Maximum time for this step
814    #[serde(default, with = "duration::option")]
815    pub timeout: Option<std::time::Duration>,
816
817    /// Action on failure
818    #[serde(default = "default_on_failure")]
819    pub on_failure: FailureAction,
820}
821
822fn default_on_failure() -> FailureAction {
823    FailureAction::Fail
824}
825
826/// Init action parameters
827pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
828
829/// Failure action for init steps
830#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
831#[serde(rename_all = "lowercase")]
832pub enum FailureAction {
833    Fail,
834    Warn,
835    Continue,
836}
837
838/// Error handling policies
839#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
840#[serde(deny_unknown_fields)]
841#[derive(Default)]
842pub struct ErrorsSpec {
843    /// Init failure policy
844    #[serde(default)]
845    pub on_init_failure: InitFailurePolicy,
846
847    /// Panic/restart policy
848    #[serde(default)]
849    pub on_panic: PanicPolicy,
850}
851
852/// Init failure policy
853#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
854#[serde(deny_unknown_fields)]
855pub struct InitFailurePolicy {
856    #[serde(default = "default_init_action")]
857    pub action: InitFailureAction,
858}
859
860impl Default for InitFailurePolicy {
861    fn default() -> Self {
862        Self {
863            action: default_init_action(),
864        }
865    }
866}
867
868fn default_init_action() -> InitFailureAction {
869    InitFailureAction::Fail
870}
871
872/// Init failure action
873#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
874#[serde(rename_all = "lowercase")]
875pub enum InitFailureAction {
876    Fail,
877    Restart,
878    Backoff,
879}
880
881/// Panic policy
882#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
883#[serde(deny_unknown_fields)]
884pub struct PanicPolicy {
885    #[serde(default = "default_panic_action")]
886    pub action: PanicAction,
887}
888
889impl Default for PanicPolicy {
890    fn default() -> Self {
891        Self {
892            action: default_panic_action(),
893        }
894    }
895}
896
897fn default_panic_action() -> PanicAction {
898    PanicAction::Restart
899}
900
901/// Panic action
902#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
903#[serde(rename_all = "lowercase")]
904pub enum PanicAction {
905    Restart,
906    Shutdown,
907    Isolate,
908}
909
910#[cfg(test)]
911mod tests {
912    use super::*;
913
914    #[test]
915    fn test_parse_simple_spec() {
916        let yaml = r#"
917version: v1
918deployment: test
919services:
920  hello:
921    rtype: service
922    image:
923      name: hello-world:latest
924    endpoints:
925      - name: http
926        protocol: http
927        port: 8080
928        expose: public
929"#;
930
931        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
932        assert_eq!(spec.version, "v1");
933        assert_eq!(spec.deployment, "test");
934        assert!(spec.services.contains_key("hello"));
935    }
936
937    #[test]
938    fn test_parse_duration() {
939        let yaml = r#"
940version: v1
941deployment: test
942services:
943  test:
944    rtype: service
945    image:
946      name: test:latest
947    health:
948      timeout: 30s
949      interval: 1m
950      start_grace: 5s
951      check:
952        type: tcp
953        port: 8080
954"#;
955
956        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
957        let health = &spec.services["test"].health;
958        assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
959        assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
960        assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
961        match &health.check {
962            HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
963            _ => panic!("Expected TCP health check"),
964        }
965    }
966
967    #[test]
968    fn test_parse_adaptive_scale() {
969        let yaml = r#"
970version: v1
971deployment: test
972services:
973  test:
974    rtype: service
975    image:
976      name: test:latest
977    scale:
978      mode: adaptive
979      min: 2
980      max: 10
981      cooldown: 15s
982      targets:
983        cpu: 70
984        rps: 800
985"#;
986
987        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
988        let scale = &spec.services["test"].scale;
989        match scale {
990            ScaleSpec::Adaptive {
991                min,
992                max,
993                cooldown,
994                targets,
995            } => {
996                assert_eq!(*min, 2);
997                assert_eq!(*max, 10);
998                assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
999                assert_eq!(targets.cpu, Some(70));
1000                assert_eq!(targets.rps, Some(800));
1001            }
1002            _ => panic!("Expected Adaptive scale mode"),
1003        }
1004    }
1005
1006    #[test]
1007    fn test_node_mode_default() {
1008        let yaml = r#"
1009version: v1
1010deployment: test
1011services:
1012  hello:
1013    rtype: service
1014    image:
1015      name: hello-world:latest
1016"#;
1017
1018        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1019        assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
1020        assert!(spec.services["hello"].node_selector.is_none());
1021    }
1022
1023    #[test]
1024    fn test_node_mode_dedicated() {
1025        let yaml = r#"
1026version: v1
1027deployment: test
1028services:
1029  api:
1030    rtype: service
1031    image:
1032      name: api:latest
1033    node_mode: dedicated
1034"#;
1035
1036        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1037        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1038    }
1039
1040    #[test]
1041    fn test_node_mode_exclusive() {
1042        let yaml = r#"
1043version: v1
1044deployment: test
1045services:
1046  database:
1047    rtype: service
1048    image:
1049      name: postgres:15
1050    node_mode: exclusive
1051"#;
1052
1053        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1054        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1055    }
1056
1057    #[test]
1058    fn test_node_selector_with_labels() {
1059        let yaml = r#"
1060version: v1
1061deployment: test
1062services:
1063  ml-worker:
1064    rtype: service
1065    image:
1066      name: ml-worker:latest
1067    node_mode: dedicated
1068    node_selector:
1069      labels:
1070        gpu: "true"
1071        zone: us-east
1072      prefer_labels:
1073        storage: ssd
1074"#;
1075
1076        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1077        let service = &spec.services["ml-worker"];
1078        assert_eq!(service.node_mode, NodeMode::Dedicated);
1079
1080        let selector = service.node_selector.as_ref().unwrap();
1081        assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
1082        assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
1083        assert_eq!(
1084            selector.prefer_labels.get("storage"),
1085            Some(&"ssd".to_string())
1086        );
1087    }
1088
1089    #[test]
1090    fn test_node_mode_serialization_roundtrip() {
1091        use serde_json;
1092
1093        // Test all variants serialize/deserialize correctly
1094        let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
1095        let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
1096
1097        for (mode, expected) in modes.iter().zip(expected_json.iter()) {
1098            let json = serde_json::to_string(mode).unwrap();
1099            assert_eq!(&json, *expected, "Serialization failed for {:?}", mode);
1100
1101            let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
1102            assert_eq!(deserialized, *mode, "Roundtrip failed for {:?}", mode);
1103        }
1104    }
1105
1106    #[test]
1107    fn test_node_selector_empty() {
1108        let yaml = r#"
1109version: v1
1110deployment: test
1111services:
1112  api:
1113    rtype: service
1114    image:
1115      name: api:latest
1116    node_selector:
1117      labels: {}
1118"#;
1119
1120        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1121        let selector = spec.services["api"].node_selector.as_ref().unwrap();
1122        assert!(selector.labels.is_empty());
1123        assert!(selector.prefer_labels.is_empty());
1124    }
1125
1126    #[test]
1127    fn test_mixed_node_modes_in_deployment() {
1128        let yaml = r#"
1129version: v1
1130deployment: test
1131services:
1132  redis:
1133    rtype: service
1134    image:
1135      name: redis:alpine
1136    # Default shared mode
1137  api:
1138    rtype: service
1139    image:
1140      name: api:latest
1141    node_mode: dedicated
1142  database:
1143    rtype: service
1144    image:
1145      name: postgres:15
1146    node_mode: exclusive
1147    node_selector:
1148      labels:
1149        storage: ssd
1150"#;
1151
1152        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1153        assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
1154        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1155        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1156
1157        let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
1158        assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
1159    }
1160
1161    #[test]
1162    fn test_storage_bind_mount() {
1163        let yaml = r#"
1164version: v1
1165deployment: test
1166services:
1167  app:
1168    image:
1169      name: app:latest
1170    storage:
1171      - type: bind
1172        source: /host/data
1173        target: /app/data
1174        readonly: true
1175"#;
1176        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1177        let storage = &spec.services["app"].storage;
1178        assert_eq!(storage.len(), 1);
1179        match &storage[0] {
1180            StorageSpec::Bind {
1181                source,
1182                target,
1183                readonly,
1184            } => {
1185                assert_eq!(source, "/host/data");
1186                assert_eq!(target, "/app/data");
1187                assert!(*readonly);
1188            }
1189            _ => panic!("Expected Bind storage"),
1190        }
1191    }
1192
1193    #[test]
1194    fn test_storage_named_with_tier() {
1195        let yaml = r#"
1196version: v1
1197deployment: test
1198services:
1199  app:
1200    image:
1201      name: app:latest
1202    storage:
1203      - type: named
1204        name: my-data
1205        target: /app/data
1206        tier: cached
1207"#;
1208        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1209        let storage = &spec.services["app"].storage;
1210        match &storage[0] {
1211            StorageSpec::Named {
1212                name, target, tier, ..
1213            } => {
1214                assert_eq!(name, "my-data");
1215                assert_eq!(target, "/app/data");
1216                assert_eq!(*tier, StorageTier::Cached);
1217            }
1218            _ => panic!("Expected Named storage"),
1219        }
1220    }
1221
1222    #[test]
1223    fn test_storage_anonymous() {
1224        let yaml = r#"
1225version: v1
1226deployment: test
1227services:
1228  app:
1229    image:
1230      name: app:latest
1231    storage:
1232      - type: anonymous
1233        target: /app/cache
1234"#;
1235        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1236        let storage = &spec.services["app"].storage;
1237        match &storage[0] {
1238            StorageSpec::Anonymous { target, tier } => {
1239                assert_eq!(target, "/app/cache");
1240                assert_eq!(*tier, StorageTier::Local); // default
1241            }
1242            _ => panic!("Expected Anonymous storage"),
1243        }
1244    }
1245
1246    #[test]
1247    fn test_storage_tmpfs() {
1248        let yaml = r#"
1249version: v1
1250deployment: test
1251services:
1252  app:
1253    image:
1254      name: app:latest
1255    storage:
1256      - type: tmpfs
1257        target: /app/tmp
1258        size: 256Mi
1259        mode: 1777
1260"#;
1261        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1262        let storage = &spec.services["app"].storage;
1263        match &storage[0] {
1264            StorageSpec::Tmpfs { target, size, mode } => {
1265                assert_eq!(target, "/app/tmp");
1266                assert_eq!(size.as_deref(), Some("256Mi"));
1267                assert_eq!(*mode, Some(1777));
1268            }
1269            _ => panic!("Expected Tmpfs storage"),
1270        }
1271    }
1272
1273    #[test]
1274    fn test_storage_s3() {
1275        let yaml = r#"
1276version: v1
1277deployment: test
1278services:
1279  app:
1280    image:
1281      name: app:latest
1282    storage:
1283      - type: s3
1284        bucket: my-bucket
1285        prefix: models/
1286        target: /app/models
1287        readonly: true
1288        endpoint: https://s3.us-west-2.amazonaws.com
1289        credentials: aws-creds
1290"#;
1291        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1292        let storage = &spec.services["app"].storage;
1293        match &storage[0] {
1294            StorageSpec::S3 {
1295                bucket,
1296                prefix,
1297                target,
1298                readonly,
1299                endpoint,
1300                credentials,
1301            } => {
1302                assert_eq!(bucket, "my-bucket");
1303                assert_eq!(prefix.as_deref(), Some("models/"));
1304                assert_eq!(target, "/app/models");
1305                assert!(*readonly);
1306                assert_eq!(
1307                    endpoint.as_deref(),
1308                    Some("https://s3.us-west-2.amazonaws.com")
1309                );
1310                assert_eq!(credentials.as_deref(), Some("aws-creds"));
1311            }
1312            _ => panic!("Expected S3 storage"),
1313        }
1314    }
1315
1316    #[test]
1317    fn test_storage_multiple_types() {
1318        let yaml = r#"
1319version: v1
1320deployment: test
1321services:
1322  app:
1323    image:
1324      name: app:latest
1325    storage:
1326      - type: bind
1327        source: /etc/config
1328        target: /app/config
1329        readonly: true
1330      - type: named
1331        name: app-data
1332        target: /app/data
1333      - type: tmpfs
1334        target: /app/tmp
1335"#;
1336        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1337        let storage = &spec.services["app"].storage;
1338        assert_eq!(storage.len(), 3);
1339        assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
1340        assert!(matches!(&storage[1], StorageSpec::Named { .. }));
1341        assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
1342    }
1343
1344    #[test]
1345    fn test_storage_tier_default() {
1346        let yaml = r#"
1347version: v1
1348deployment: test
1349services:
1350  app:
1351    image:
1352      name: app:latest
1353    storage:
1354      - type: named
1355        name: data
1356        target: /data
1357"#;
1358        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1359        match &spec.services["app"].storage[0] {
1360            StorageSpec::Named { tier, .. } => {
1361                assert_eq!(*tier, StorageTier::Local); // default should be Local
1362            }
1363            _ => panic!("Expected Named storage"),
1364        }
1365    }
1366}