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    #[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    /// Serde module for required (non-Option) Duration fields
40    pub mod required {
41        use humantime::format_duration;
42        use serde::{Deserialize, Deserializer, Serializer};
43        use std::time::Duration;
44
45        pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
46        where
47            S: Serializer,
48        {
49            serializer.serialize_str(&format_duration(*duration).to_string())
50        }
51
52        pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
53        where
54            D: Deserializer<'de>,
55        {
56            use serde::de::Error;
57            let s: String = String::deserialize(deserializer)?;
58            humantime::parse_duration(&s)
59                .map_err(|e| D::Error::custom(format!("invalid duration: {e}")))
60        }
61    }
62}
63
64use serde::{Deserialize, Serialize};
65use std::collections::HashMap;
66use validator::Validate;
67
68/// How service replicas are allocated to nodes
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum NodeMode {
72    /// Containers placed on any node with capacity (default, bin-packing)
73    #[default]
74    Shared,
75    /// Each replica gets its own dedicated node (1:1 mapping)
76    Dedicated,
77    /// Service is the ONLY thing on its nodes (no other services)
78    Exclusive,
79}
80
81/// Service type - determines runtime behavior and scaling model
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum ServiceType {
85    /// Standard long-running container service
86    #[default]
87    Standard,
88    /// WASM-based HTTP service with instance pooling
89    WasmHttp,
90    /// Run-to-completion job
91    Job,
92}
93
94/// Storage performance tier
95#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
96#[serde(rename_all = "snake_case")]
97pub enum StorageTier {
98    /// Direct local filesystem (SSD/NVMe) - SQLite-safe, fast fsync
99    #[default]
100    Local,
101    /// bcache-backed tiered storage (SSD cache + slower backend)
102    Cached,
103    /// NFS/network storage - NOT SQLite-safe (will warn)
104    Network,
105}
106
107/// Node selection constraints for service placement
108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct NodeSelector {
111    /// Required labels that nodes must have (all must match)
112    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
113    pub labels: HashMap<String, String>,
114    /// Preferred labels (soft constraint, nodes with these are preferred)
115    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
116    pub prefer_labels: HashMap<String, String>,
117}
118
119/// Configuration for WASM HTTP services with instance pooling
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(deny_unknown_fields)]
122pub struct WasmHttpConfig {
123    /// Minimum number of warm instances to keep ready
124    #[serde(default = "default_min_instances")]
125    pub min_instances: u32,
126    /// Maximum number of instances to scale to
127    #[serde(default = "default_max_instances")]
128    pub max_instances: u32,
129    /// Time before idle instances are terminated
130    #[serde(default = "default_idle_timeout", with = "duration::required")]
131    pub idle_timeout: std::time::Duration,
132    /// Maximum time for a single request
133    #[serde(default = "default_request_timeout", with = "duration::required")]
134    pub request_timeout: std::time::Duration,
135}
136
137fn default_min_instances() -> u32 {
138    0
139}
140
141fn default_max_instances() -> u32 {
142    10
143}
144
145fn default_idle_timeout() -> std::time::Duration {
146    std::time::Duration::from_secs(300)
147}
148
149fn default_request_timeout() -> std::time::Duration {
150    std::time::Duration::from_secs(30)
151}
152
153impl Default for WasmHttpConfig {
154    fn default() -> Self {
155        Self {
156            min_instances: default_min_instances(),
157            max_instances: default_max_instances(),
158            idle_timeout: default_idle_timeout(),
159            request_timeout: default_request_timeout(),
160        }
161    }
162}
163
164fn default_api_bind() -> String {
165    "0.0.0.0:3669".to_string()
166}
167
168/// API server configuration (embedded in deploy/up flows)
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170pub struct ApiSpec {
171    /// Enable the API server (default: true)
172    #[serde(default = "default_true")]
173    pub enabled: bool,
174    /// Bind address (default: "0.0.0.0:3669")
175    #[serde(default = "default_api_bind")]
176    pub bind: String,
177    /// JWT secret (reads `ZLAYER_JWT_SECRET` env var if not set)
178    #[serde(default)]
179    pub jwt_secret: Option<String>,
180    /// Enable Swagger UI (default: true)
181    #[serde(default = "default_true")]
182    pub swagger: bool,
183}
184
185impl Default for ApiSpec {
186    fn default() -> Self {
187        Self {
188            enabled: true,
189            bind: default_api_bind(),
190            jwt_secret: None,
191            swagger: true,
192        }
193    }
194}
195
196/// Top-level deployment specification
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
198#[serde(deny_unknown_fields)]
199pub struct DeploymentSpec {
200    /// Spec version (must be "v1")
201    #[validate(custom(function = "crate::validate::validate_version_wrapper"))]
202    pub version: String,
203
204    /// Deployment name (used for overlays, DNS)
205    #[validate(custom(function = "crate::validate::validate_deployment_name_wrapper"))]
206    pub deployment: String,
207
208    /// Service definitions
209    #[serde(default)]
210    #[validate(nested)]
211    pub services: HashMap<String, ServiceSpec>,
212
213    /// Top-level tunnel definitions (not tied to service endpoints)
214    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
215    pub tunnels: HashMap<String, TunnelDefinition>,
216
217    /// API server configuration (enabled by default)
218    #[serde(default)]
219    pub api: ApiSpec,
220}
221
222/// Top-level tunnel definition (not tied to a service endpoint)
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224#[serde(deny_unknown_fields)]
225pub struct TunnelDefinition {
226    /// Source node
227    pub from: String,
228
229    /// Destination node
230    pub to: String,
231
232    /// Local port on source
233    pub local_port: u16,
234
235    /// Remote port on destination
236    pub remote_port: u16,
237
238    /// Protocol (tcp/udp, defaults to tcp)
239    #[serde(default)]
240    pub protocol: TunnelProtocol,
241
242    /// Exposure type (defaults to internal)
243    #[serde(default)]
244    pub expose: ExposeType,
245}
246
247/// Protocol for tunnel connections (tcp or udp only)
248#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
249#[serde(rename_all = "lowercase")]
250pub enum TunnelProtocol {
251    #[default]
252    Tcp,
253    Udp,
254}
255
256/// Per-service specification
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
258#[serde(deny_unknown_fields)]
259pub struct ServiceSpec {
260    /// Resource type (service, job, cron)
261    #[serde(default = "default_resource_type")]
262    pub rtype: ResourceType,
263
264    /// Cron schedule expression (only for rtype: cron)
265    /// Uses 7-field cron syntax: "sec min hour day-of-month month day-of-week year"
266    /// Examples:
267    ///   - "0 0 0 * * * *" (daily at midnight)
268    ///   - "0 */5 * * * * *" (every 5 minutes)
269    ///   - "0 0 12 * * MON-FRI *" (weekdays at noon)
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    #[validate(custom(function = "crate::validate::validate_schedule_wrapper"))]
272    pub schedule: Option<String>,
273
274    /// Container image specification
275    #[validate(nested)]
276    pub image: ImageSpec,
277
278    /// Resource limits
279    #[serde(default)]
280    #[validate(nested)]
281    pub resources: ResourcesSpec,
282
283    /// Environment variables for the service
284    ///
285    /// Values can be:
286    /// - Plain strings: `"value"`
287    /// - Host env refs: `$E:VAR_NAME`
288    /// - Secret refs: `$S:secret-name` or `$S:@service/secret-name`
289    #[serde(default)]
290    pub env: HashMap<String, String>,
291
292    /// Command override (entrypoint, args, workdir)
293    #[serde(default)]
294    pub command: CommandSpec,
295
296    /// Network configuration
297    #[serde(default)]
298    pub network: NetworkSpec,
299
300    /// Endpoint definitions (proxy bindings)
301    #[serde(default)]
302    #[validate(nested)]
303    pub endpoints: Vec<EndpointSpec>,
304
305    /// Scaling configuration
306    #[serde(default)]
307    #[validate(custom(function = "crate::validate::validate_scale_spec"))]
308    pub scale: ScaleSpec,
309
310    /// Dependency specifications
311    #[serde(default)]
312    pub depends: Vec<DependsSpec>,
313
314    /// Health check configuration
315    #[serde(default = "default_health")]
316    pub health: HealthSpec,
317
318    /// Init actions (pre-start lifecycle steps)
319    #[serde(default)]
320    pub init: InitSpec,
321
322    /// Error handling policies
323    #[serde(default)]
324    pub errors: ErrorsSpec,
325
326    /// Device passthrough (e.g., /dev/kvm for VMs)
327    #[serde(default)]
328    pub devices: Vec<DeviceSpec>,
329
330    /// Storage mounts for the container
331    #[serde(default, skip_serializing_if = "Vec::is_empty")]
332    pub storage: Vec<StorageSpec>,
333
334    /// Linux capabilities to add (e.g., `SYS_ADMIN`, `NET_ADMIN`)
335    #[serde(default)]
336    pub capabilities: Vec<String>,
337
338    /// Run container in privileged mode (all capabilities + all devices)
339    #[serde(default)]
340    pub privileged: bool,
341
342    /// Node allocation mode (shared, dedicated, exclusive)
343    #[serde(default)]
344    pub node_mode: NodeMode,
345
346    /// Node selection constraints (required/preferred labels)
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub node_selector: Option<NodeSelector>,
349
350    /// Service type (standard, `wasm_http`, job)
351    #[serde(default)]
352    pub service_type: ServiceType,
353
354    /// WASM HTTP configuration (only used when `service_type` is `WasmHttp`)
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub wasm_http: Option<WasmHttpConfig>,
357
358    /// Use host networking (container shares host network namespace)
359    ///
360    /// When true, the container will NOT get its own network namespace.
361    /// This is set programmatically via the `--host-network` CLI flag, not in YAML specs.
362    #[serde(skip)]
363    pub host_network: bool,
364}
365
366/// Command override specification (Section 5.5)
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
368#[serde(deny_unknown_fields)]
369pub struct CommandSpec {
370    /// Override image ENTRYPOINT
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub entrypoint: Option<Vec<String>>,
373
374    /// Override image CMD
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub args: Option<Vec<String>>,
377
378    /// Override working directory
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub workdir: Option<String>,
381}
382
383fn default_resource_type() -> ResourceType {
384    ResourceType::Service
385}
386
387fn default_health() -> HealthSpec {
388    HealthSpec {
389        start_grace: Some(std::time::Duration::from_secs(5)),
390        interval: None,
391        timeout: None,
392        retries: 3,
393        check: HealthCheck::Tcp { port: 0 },
394    }
395}
396
397/// Resource type - determines container lifecycle
398#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
399#[serde(rename_all = "lowercase")]
400pub enum ResourceType {
401    /// Long-running container, receives traffic, load-balanced
402    Service,
403    /// Run-to-completion, triggered by endpoint/CLI/internal system
404    Job,
405    /// Scheduled run-to-completion, time-triggered
406    Cron,
407}
408
409/// Container image specification
410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
411#[serde(deny_unknown_fields)]
412pub struct ImageSpec {
413    /// Image name (e.g., "ghcr.io/org/api:latest")
414    #[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
415    pub name: String,
416
417    /// When to pull the image
418    #[serde(default = "default_pull_policy")]
419    pub pull_policy: PullPolicy,
420}
421
422fn default_pull_policy() -> PullPolicy {
423    PullPolicy::IfNotPresent
424}
425
426/// Image pull policy
427#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
428#[serde(rename_all = "snake_case")]
429pub enum PullPolicy {
430    /// Always pull the image
431    Always,
432    /// Pull only if not present locally
433    IfNotPresent,
434    /// Never pull, use local image only
435    Never,
436}
437
438/// Device passthrough specification
439#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
440#[serde(deny_unknown_fields)]
441pub struct DeviceSpec {
442    /// Host device path (e.g., /dev/kvm, /dev/net/tun)
443    #[validate(length(min = 1, message = "device path cannot be empty"))]
444    pub path: String,
445
446    /// Allow read access
447    #[serde(default = "default_true")]
448    pub read: bool,
449
450    /// Allow write access
451    #[serde(default = "default_true")]
452    pub write: bool,
453
454    /// Allow mknod (create device nodes)
455    #[serde(default)]
456    pub mknod: bool,
457}
458
459fn default_true() -> bool {
460    true
461}
462
463/// Storage mount specification
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
465#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
466pub enum StorageSpec {
467    /// Bind mount from host path to container
468    Bind {
469        source: String,
470        target: String,
471        #[serde(default)]
472        readonly: bool,
473    },
474    /// Named persistent storage volume
475    Named {
476        name: String,
477        target: String,
478        #[serde(default)]
479        readonly: bool,
480        /// Performance tier (default: local, SQLite-safe)
481        #[serde(default)]
482        tier: StorageTier,
483        /// Optional size limit (e.g., "1Gi", "512Mi")
484        #[serde(default, skip_serializing_if = "Option::is_none")]
485        size: Option<String>,
486    },
487    /// Anonymous storage (auto-named, container lifecycle)
488    Anonymous {
489        target: String,
490        /// Performance tier (default: local)
491        #[serde(default)]
492        tier: StorageTier,
493    },
494    /// Memory-backed tmpfs mount
495    Tmpfs {
496        target: String,
497        #[serde(default)]
498        size: Option<String>,
499        #[serde(default)]
500        mode: Option<u32>,
501    },
502    /// S3-backed FUSE mount
503    S3 {
504        bucket: String,
505        #[serde(default)]
506        prefix: Option<String>,
507        target: String,
508        #[serde(default)]
509        readonly: bool,
510        #[serde(default)]
511        endpoint: Option<String>,
512        #[serde(default)]
513        credentials: Option<String>,
514    },
515}
516
517/// Resource limits (upper bounds, not reservations)
518#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
519#[serde(deny_unknown_fields)]
520pub struct ResourcesSpec {
521    /// CPU limit (cores, e.g., 0.5, 1, 2)
522    #[serde(default)]
523    #[validate(custom(function = "crate::validate::validate_cpu_option_wrapper"))]
524    pub cpu: Option<f64>,
525
526    /// Memory limit (e.g., "512Mi", "1Gi", "2Gi")
527    #[serde(default)]
528    #[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
529    pub memory: Option<String>,
530
531    /// GPU resource request
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub gpu: Option<GpuSpec>,
534}
535
536/// GPU resource specification
537///
538/// Supported vendors:
539/// - `nvidia` - NVIDIA GPUs via NVIDIA Container Toolkit (default)
540/// - `amd` - AMD GPUs via `ROCm` (/dev/kfd + /dev/dri/renderD*)
541/// - `intel` - Intel GPUs via VAAPI/i915 (/dev/dri/renderD*)
542/// - `apple` - Apple Silicon GPUs via Metal/MPS (macOS only)
543///
544/// Unknown vendors fall back to DRI render node passthrough.
545///
546/// ## GPU mode (macOS only)
547///
548/// When `vendor` is `"apple"`, the `mode` field controls how GPU access is provided:
549/// - `"native"` -- Seatbelt sandbox with direct Metal/MPS access (lowest overhead)
550/// - `"vm"` -- libkrun micro-VM with GPU forwarding (stronger isolation)
551/// - `None` (default) -- Auto-select based on platform and vendor
552///
553/// On Linux, `mode` is ignored; GPU passthrough always uses device node binding.
554#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
555#[serde(deny_unknown_fields)]
556pub struct GpuSpec {
557    /// Number of GPUs to request
558    #[serde(default = "default_gpu_count")]
559    pub count: u32,
560    /// GPU vendor (`nvidia`, `amd`, `intel`, `apple`) - defaults to `nvidia`
561    #[serde(default = "default_gpu_vendor")]
562    pub vendor: String,
563    /// GPU access mode (macOS only): `"native"`, `"vm"`, or `None` for auto-select
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub mode: Option<String>,
566}
567
568fn default_gpu_count() -> u32 {
569    1
570}
571
572fn default_gpu_vendor() -> String {
573    "nvidia".to_string()
574}
575
576/// Network configuration
577#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
578#[serde(deny_unknown_fields)]
579#[derive(Default)]
580pub struct NetworkSpec {
581    /// Overlay network configuration
582    #[serde(default)]
583    pub overlays: OverlayConfig,
584
585    /// Join policy (who can join this service)
586    #[serde(default)]
587    pub join: JoinPolicy,
588}
589
590/// Overlay network configuration
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
592#[serde(deny_unknown_fields)]
593pub struct OverlayConfig {
594    /// Service-scoped overlay (service replicas only)
595    #[serde(default)]
596    pub service: OverlaySettings,
597
598    /// Global overlay (all services in deployment)
599    #[serde(default)]
600    pub global: OverlaySettings,
601}
602
603impl Default for OverlayConfig {
604    fn default() -> Self {
605        Self {
606            service: OverlaySettings {
607                enabled: true,
608                encrypted: true,
609                isolated: true,
610            },
611            global: OverlaySettings {
612                enabled: true,
613                encrypted: true,
614                isolated: false,
615            },
616        }
617    }
618}
619
620/// Overlay network settings
621#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
622#[serde(deny_unknown_fields)]
623pub struct OverlaySettings {
624    /// Enable this overlay
625    #[serde(default = "default_enabled")]
626    pub enabled: bool,
627
628    /// Use encryption
629    #[serde(default = "default_encrypted")]
630    pub encrypted: bool,
631
632    /// Isolate from other services/groups
633    #[serde(default)]
634    pub isolated: bool,
635}
636
637fn default_enabled() -> bool {
638    true
639}
640
641fn default_encrypted() -> bool {
642    true
643}
644
645/// Join policy - controls who can join a service
646#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
647#[serde(deny_unknown_fields)]
648pub struct JoinPolicy {
649    /// Join mode
650    #[serde(default = "default_join_mode")]
651    pub mode: JoinMode,
652
653    /// Scope of join
654    #[serde(default = "default_join_scope")]
655    pub scope: JoinScope,
656}
657
658impl Default for JoinPolicy {
659    fn default() -> Self {
660        Self {
661            mode: default_join_mode(),
662            scope: default_join_scope(),
663        }
664    }
665}
666
667fn default_join_mode() -> JoinMode {
668    JoinMode::Token
669}
670
671fn default_join_scope() -> JoinScope {
672    JoinScope::Service
673}
674
675/// Join mode
676#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
677#[serde(rename_all = "snake_case")]
678pub enum JoinMode {
679    /// Any trusted node in deployment can self-enroll
680    Open,
681    /// Requires a join key (recommended)
682    Token,
683    /// Only control-plane/scheduler can place replicas
684    Closed,
685}
686
687/// Join scope
688#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
689#[serde(rename_all = "snake_case")]
690pub enum JoinScope {
691    /// Join this specific service
692    Service,
693    /// Join all services in deployment
694    Global,
695}
696
697/// Endpoint specification (proxy binding)
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
699#[serde(deny_unknown_fields)]
700pub struct EndpointSpec {
701    /// Endpoint name (for routing)
702    #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
703    pub name: String,
704
705    /// Protocol
706    pub protocol: Protocol,
707
708    /// Proxy listen port (external-facing port)
709    #[validate(custom(function = "crate::validate::validate_port_wrapper"))]
710    pub port: u16,
711
712    /// Container port the service actually listens on.
713    /// Defaults to `port` when not specified.
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub target_port: Option<u16>,
716
717    /// URL path prefix (for http/https/websocket)
718    pub path: Option<String>,
719
720    /// Exposure type
721    #[serde(default = "default_expose")]
722    pub expose: ExposeType,
723
724    /// Optional stream (L4) proxy configuration
725    /// Only applicable when protocol is tcp or udp
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub stream: Option<StreamEndpointConfig>,
728
729    /// Optional tunnel configuration for this endpoint
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub tunnel: Option<EndpointTunnelConfig>,
732}
733
734impl EndpointSpec {
735    /// Returns the port the container actually listens on.
736    /// Falls back to `port` when `target_port` is not specified.
737    #[must_use]
738    pub fn target_port(&self) -> u16 {
739        self.target_port.unwrap_or(self.port)
740    }
741}
742
743/// Tunnel configuration for an endpoint
744#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
745#[serde(deny_unknown_fields)]
746pub struct EndpointTunnelConfig {
747    /// Enable tunneling for this endpoint
748    #[serde(default)]
749    pub enabled: bool,
750
751    /// Source node name (defaults to service's node)
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub from: Option<String>,
754
755    /// Destination node name (defaults to cluster ingress)
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub to: Option<String>,
758
759    /// Remote port to expose (0 = auto-assign)
760    #[serde(default)]
761    pub remote_port: u16,
762
763    /// Override exposure for tunnel (public/internal)
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub expose: Option<ExposeType>,
766
767    /// On-demand access configuration
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub access: Option<TunnelAccessConfig>,
770}
771
772/// On-demand access settings for `zlayer tunnel access`
773#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
774#[serde(deny_unknown_fields)]
775pub struct TunnelAccessConfig {
776    /// Allow on-demand access via CLI
777    #[serde(default)]
778    pub enabled: bool,
779
780    /// Maximum session duration (e.g., "4h", "30m")
781    #[serde(default, skip_serializing_if = "Option::is_none")]
782    pub max_ttl: Option<String>,
783
784    /// Log all access sessions
785    #[serde(default)]
786    pub audit: bool,
787}
788
789fn default_expose() -> ExposeType {
790    ExposeType::Internal
791}
792
793/// Protocol type
794#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
795#[serde(rename_all = "lowercase")]
796pub enum Protocol {
797    Http,
798    Https,
799    Tcp,
800    Udp,
801    Websocket,
802}
803
804/// Exposure type
805#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
806#[serde(rename_all = "lowercase")]
807pub enum ExposeType {
808    Public,
809    #[default]
810    Internal,
811}
812
813/// Stream (L4) proxy configuration for TCP/UDP endpoints
814#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
815#[serde(deny_unknown_fields)]
816pub struct StreamEndpointConfig {
817    /// Enable TLS termination for TCP (auto-provision cert)
818    #[serde(default)]
819    pub tls: bool,
820
821    /// Enable PROXY protocol for passing client IP
822    #[serde(default)]
823    pub proxy_protocol: bool,
824
825    /// Custom session timeout for UDP (default: 60s)
826    /// Format: duration string like "60s", "5m"
827    #[serde(default, skip_serializing_if = "Option::is_none")]
828    pub session_timeout: Option<String>,
829
830    /// Health check configuration for L4
831    #[serde(default, skip_serializing_if = "Option::is_none")]
832    pub health_check: Option<StreamHealthCheck>,
833}
834
835/// Health check types for stream (L4) endpoints
836#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
837#[serde(tag = "type", rename_all = "snake_case")]
838pub enum StreamHealthCheck {
839    /// TCP connect check - verifies port is accepting connections
840    TcpConnect,
841    /// UDP probe - sends request and optionally validates response
842    UdpProbe {
843        /// Request payload to send (can use hex escapes like \\xFF)
844        request: String,
845        /// Expected response pattern (optional regex)
846        #[serde(default, skip_serializing_if = "Option::is_none")]
847        expect: Option<String>,
848    },
849}
850
851/// Scaling configuration
852#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
853#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
854pub enum ScaleSpec {
855    /// Adaptive scaling with metrics
856    #[serde(rename = "adaptive")]
857    Adaptive {
858        /// Minimum replicas
859        min: u32,
860
861        /// Maximum replicas
862        max: u32,
863
864        /// Cooldown period between scale events
865        #[serde(default, with = "duration::option")]
866        cooldown: Option<std::time::Duration>,
867
868        /// Target metrics for scaling
869        #[serde(default)]
870        targets: ScaleTargets,
871    },
872
873    /// Fixed number of replicas
874    #[serde(rename = "fixed")]
875    Fixed { replicas: u32 },
876
877    /// Manual scaling (no automatic scaling)
878    #[serde(rename = "manual")]
879    Manual,
880}
881
882impl Default for ScaleSpec {
883    fn default() -> Self {
884        Self::Adaptive {
885            min: 1,
886            max: 10,
887            cooldown: Some(std::time::Duration::from_secs(30)),
888            targets: ScaleTargets::default(),
889        }
890    }
891}
892
893/// Target metrics for adaptive scaling
894#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
895#[serde(deny_unknown_fields)]
896#[derive(Default)]
897pub struct ScaleTargets {
898    /// CPU percentage threshold (0-100)
899    #[serde(default)]
900    pub cpu: Option<u8>,
901
902    /// Memory percentage threshold (0-100)
903    #[serde(default)]
904    pub memory: Option<u8>,
905
906    /// Requests per second threshold
907    #[serde(default)]
908    pub rps: Option<u32>,
909}
910
911/// Dependency specification
912#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
913#[serde(deny_unknown_fields)]
914pub struct DependsSpec {
915    /// Service name to depend on
916    pub service: String,
917
918    /// Condition for dependency
919    #[serde(default = "default_condition")]
920    pub condition: DependencyCondition,
921
922    /// Maximum time to wait
923    #[serde(default = "default_timeout", with = "duration::option")]
924    pub timeout: Option<std::time::Duration>,
925
926    /// Action on timeout
927    #[serde(default = "default_on_timeout")]
928    pub on_timeout: TimeoutAction,
929}
930
931fn default_condition() -> DependencyCondition {
932    DependencyCondition::Healthy
933}
934
935#[allow(clippy::unnecessary_wraps)]
936fn default_timeout() -> Option<std::time::Duration> {
937    Some(std::time::Duration::from_secs(300))
938}
939
940fn default_on_timeout() -> TimeoutAction {
941    TimeoutAction::Fail
942}
943
944/// Dependency condition
945#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
946#[serde(rename_all = "lowercase")]
947pub enum DependencyCondition {
948    /// Container process exists
949    Started,
950    /// Health check passes
951    Healthy,
952    /// Service is available for routing
953    Ready,
954}
955
956/// Timeout action
957#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
958#[serde(rename_all = "lowercase")]
959pub enum TimeoutAction {
960    Fail,
961    Warn,
962    Continue,
963}
964
965/// Health check specification
966#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
967#[serde(deny_unknown_fields)]
968pub struct HealthSpec {
969    /// Grace period before first check
970    #[serde(default, with = "duration::option")]
971    pub start_grace: Option<std::time::Duration>,
972
973    /// Interval between checks
974    #[serde(default, with = "duration::option")]
975    pub interval: Option<std::time::Duration>,
976
977    /// Timeout per check
978    #[serde(default, with = "duration::option")]
979    pub timeout: Option<std::time::Duration>,
980
981    /// Number of retries before marking unhealthy
982    #[serde(default = "default_retries")]
983    pub retries: u32,
984
985    /// Health check type and parameters
986    pub check: HealthCheck,
987}
988
989fn default_retries() -> u32 {
990    3
991}
992
993/// Health check type
994#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
995#[serde(tag = "type", rename_all = "lowercase")]
996pub enum HealthCheck {
997    /// TCP port check
998    Tcp {
999        /// Port to check (0 = use first endpoint)
1000        port: u16,
1001    },
1002
1003    /// HTTP check
1004    Http {
1005        /// URL to check
1006        url: String,
1007        /// Expected status code
1008        #[serde(default = "default_expect_status")]
1009        expect_status: u16,
1010    },
1011
1012    /// Command check
1013    Command {
1014        /// Command to run
1015        command: String,
1016    },
1017}
1018
1019fn default_expect_status() -> u16 {
1020    200
1021}
1022
1023/// Init actions specification
1024#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1025#[serde(deny_unknown_fields)]
1026#[derive(Default)]
1027pub struct InitSpec {
1028    /// Init steps to run before container starts
1029    #[serde(default)]
1030    pub steps: Vec<InitStep>,
1031}
1032
1033/// Init action step
1034#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1035#[serde(deny_unknown_fields)]
1036pub struct InitStep {
1037    /// Step identifier
1038    pub id: String,
1039
1040    /// Action to perform (e.g., "`init.wait_tcp`")
1041    pub uses: String,
1042
1043    /// Parameters for the action
1044    #[serde(default)]
1045    pub with: InitParams,
1046
1047    /// Number of retries
1048    #[serde(default)]
1049    pub retry: Option<u32>,
1050
1051    /// Maximum time for this step
1052    #[serde(default, with = "duration::option")]
1053    pub timeout: Option<std::time::Duration>,
1054
1055    /// Action on failure
1056    #[serde(default = "default_on_failure")]
1057    pub on_failure: FailureAction,
1058}
1059
1060fn default_on_failure() -> FailureAction {
1061    FailureAction::Fail
1062}
1063
1064/// Init action parameters
1065pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
1066
1067/// Failure action for init steps
1068#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1069#[serde(rename_all = "lowercase")]
1070pub enum FailureAction {
1071    Fail,
1072    Warn,
1073    Continue,
1074}
1075
1076/// Error handling policies
1077#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1078#[serde(deny_unknown_fields)]
1079#[derive(Default)]
1080pub struct ErrorsSpec {
1081    /// Init failure policy
1082    #[serde(default)]
1083    pub on_init_failure: InitFailurePolicy,
1084
1085    /// Panic/restart policy
1086    #[serde(default)]
1087    pub on_panic: PanicPolicy,
1088}
1089
1090/// Init failure policy
1091#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1092#[serde(deny_unknown_fields)]
1093pub struct InitFailurePolicy {
1094    #[serde(default = "default_init_action")]
1095    pub action: InitFailureAction,
1096}
1097
1098impl Default for InitFailurePolicy {
1099    fn default() -> Self {
1100        Self {
1101            action: default_init_action(),
1102        }
1103    }
1104}
1105
1106fn default_init_action() -> InitFailureAction {
1107    InitFailureAction::Fail
1108}
1109
1110/// Init failure action
1111#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1112#[serde(rename_all = "lowercase")]
1113pub enum InitFailureAction {
1114    Fail,
1115    Restart,
1116    Backoff,
1117}
1118
1119/// Panic policy
1120#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1121#[serde(deny_unknown_fields)]
1122pub struct PanicPolicy {
1123    #[serde(default = "default_panic_action")]
1124    pub action: PanicAction,
1125}
1126
1127impl Default for PanicPolicy {
1128    fn default() -> Self {
1129        Self {
1130            action: default_panic_action(),
1131        }
1132    }
1133}
1134
1135fn default_panic_action() -> PanicAction {
1136    PanicAction::Restart
1137}
1138
1139/// Panic action
1140#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1141#[serde(rename_all = "lowercase")]
1142pub enum PanicAction {
1143    Restart,
1144    Shutdown,
1145    Isolate,
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    use super::*;
1151
1152    #[test]
1153    fn test_parse_simple_spec() {
1154        let yaml = r#"
1155version: v1
1156deployment: test
1157services:
1158  hello:
1159    rtype: service
1160    image:
1161      name: hello-world:latest
1162    endpoints:
1163      - name: http
1164        protocol: http
1165        port: 8080
1166        expose: public
1167"#;
1168
1169        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1170        assert_eq!(spec.version, "v1");
1171        assert_eq!(spec.deployment, "test");
1172        assert!(spec.services.contains_key("hello"));
1173    }
1174
1175    #[test]
1176    fn test_parse_duration() {
1177        let yaml = r#"
1178version: v1
1179deployment: test
1180services:
1181  test:
1182    rtype: service
1183    image:
1184      name: test:latest
1185    health:
1186      timeout: 30s
1187      interval: 1m
1188      start_grace: 5s
1189      check:
1190        type: tcp
1191        port: 8080
1192"#;
1193
1194        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1195        let health = &spec.services["test"].health;
1196        assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
1197        assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
1198        assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
1199        match &health.check {
1200            HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
1201            _ => panic!("Expected TCP health check"),
1202        }
1203    }
1204
1205    #[test]
1206    fn test_parse_adaptive_scale() {
1207        let yaml = r#"
1208version: v1
1209deployment: test
1210services:
1211  test:
1212    rtype: service
1213    image:
1214      name: test:latest
1215    scale:
1216      mode: adaptive
1217      min: 2
1218      max: 10
1219      cooldown: 15s
1220      targets:
1221        cpu: 70
1222        rps: 800
1223"#;
1224
1225        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1226        let scale = &spec.services["test"].scale;
1227        match scale {
1228            ScaleSpec::Adaptive {
1229                min,
1230                max,
1231                cooldown,
1232                targets,
1233            } => {
1234                assert_eq!(*min, 2);
1235                assert_eq!(*max, 10);
1236                assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
1237                assert_eq!(targets.cpu, Some(70));
1238                assert_eq!(targets.rps, Some(800));
1239            }
1240            _ => panic!("Expected Adaptive scale mode"),
1241        }
1242    }
1243
1244    #[test]
1245    fn test_node_mode_default() {
1246        let yaml = r#"
1247version: v1
1248deployment: test
1249services:
1250  hello:
1251    rtype: service
1252    image:
1253      name: hello-world:latest
1254"#;
1255
1256        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1257        assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
1258        assert!(spec.services["hello"].node_selector.is_none());
1259    }
1260
1261    #[test]
1262    fn test_node_mode_dedicated() {
1263        let yaml = r#"
1264version: v1
1265deployment: test
1266services:
1267  api:
1268    rtype: service
1269    image:
1270      name: api:latest
1271    node_mode: dedicated
1272"#;
1273
1274        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1275        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1276    }
1277
1278    #[test]
1279    fn test_node_mode_exclusive() {
1280        let yaml = r#"
1281version: v1
1282deployment: test
1283services:
1284  database:
1285    rtype: service
1286    image:
1287      name: postgres:15
1288    node_mode: exclusive
1289"#;
1290
1291        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1292        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1293    }
1294
1295    #[test]
1296    fn test_node_selector_with_labels() {
1297        let yaml = r#"
1298version: v1
1299deployment: test
1300services:
1301  ml-worker:
1302    rtype: service
1303    image:
1304      name: ml-worker:latest
1305    node_mode: dedicated
1306    node_selector:
1307      labels:
1308        gpu: "true"
1309        zone: us-east
1310      prefer_labels:
1311        storage: ssd
1312"#;
1313
1314        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1315        let service = &spec.services["ml-worker"];
1316        assert_eq!(service.node_mode, NodeMode::Dedicated);
1317
1318        let selector = service.node_selector.as_ref().unwrap();
1319        assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
1320        assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
1321        assert_eq!(
1322            selector.prefer_labels.get("storage"),
1323            Some(&"ssd".to_string())
1324        );
1325    }
1326
1327    #[test]
1328    fn test_node_mode_serialization_roundtrip() {
1329        use serde_json;
1330
1331        // Test all variants serialize/deserialize correctly
1332        let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
1333        let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
1334
1335        for (mode, expected) in modes.iter().zip(expected_json.iter()) {
1336            let json = serde_json::to_string(mode).unwrap();
1337            assert_eq!(&json, *expected, "Serialization failed for {:?}", mode);
1338
1339            let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
1340            assert_eq!(deserialized, *mode, "Roundtrip failed for {:?}", mode);
1341        }
1342    }
1343
1344    #[test]
1345    fn test_node_selector_empty() {
1346        let yaml = r#"
1347version: v1
1348deployment: test
1349services:
1350  api:
1351    rtype: service
1352    image:
1353      name: api:latest
1354    node_selector:
1355      labels: {}
1356"#;
1357
1358        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1359        let selector = spec.services["api"].node_selector.as_ref().unwrap();
1360        assert!(selector.labels.is_empty());
1361        assert!(selector.prefer_labels.is_empty());
1362    }
1363
1364    #[test]
1365    fn test_mixed_node_modes_in_deployment() {
1366        let yaml = r#"
1367version: v1
1368deployment: test
1369services:
1370  redis:
1371    rtype: service
1372    image:
1373      name: redis:alpine
1374    # Default shared mode
1375  api:
1376    rtype: service
1377    image:
1378      name: api:latest
1379    node_mode: dedicated
1380  database:
1381    rtype: service
1382    image:
1383      name: postgres:15
1384    node_mode: exclusive
1385    node_selector:
1386      labels:
1387        storage: ssd
1388"#;
1389
1390        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1391        assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
1392        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1393        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1394
1395        let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
1396        assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
1397    }
1398
1399    #[test]
1400    fn test_storage_bind_mount() {
1401        let yaml = r#"
1402version: v1
1403deployment: test
1404services:
1405  app:
1406    image:
1407      name: app:latest
1408    storage:
1409      - type: bind
1410        source: /host/data
1411        target: /app/data
1412        readonly: true
1413"#;
1414        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1415        let storage = &spec.services["app"].storage;
1416        assert_eq!(storage.len(), 1);
1417        match &storage[0] {
1418            StorageSpec::Bind {
1419                source,
1420                target,
1421                readonly,
1422            } => {
1423                assert_eq!(source, "/host/data");
1424                assert_eq!(target, "/app/data");
1425                assert!(*readonly);
1426            }
1427            _ => panic!("Expected Bind storage"),
1428        }
1429    }
1430
1431    #[test]
1432    fn test_storage_named_with_tier() {
1433        let yaml = r#"
1434version: v1
1435deployment: test
1436services:
1437  app:
1438    image:
1439      name: app:latest
1440    storage:
1441      - type: named
1442        name: my-data
1443        target: /app/data
1444        tier: cached
1445"#;
1446        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1447        let storage = &spec.services["app"].storage;
1448        match &storage[0] {
1449            StorageSpec::Named {
1450                name, target, tier, ..
1451            } => {
1452                assert_eq!(name, "my-data");
1453                assert_eq!(target, "/app/data");
1454                assert_eq!(*tier, StorageTier::Cached);
1455            }
1456            _ => panic!("Expected Named storage"),
1457        }
1458    }
1459
1460    #[test]
1461    fn test_storage_anonymous() {
1462        let yaml = r#"
1463version: v1
1464deployment: test
1465services:
1466  app:
1467    image:
1468      name: app:latest
1469    storage:
1470      - type: anonymous
1471        target: /app/cache
1472"#;
1473        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1474        let storage = &spec.services["app"].storage;
1475        match &storage[0] {
1476            StorageSpec::Anonymous { target, tier } => {
1477                assert_eq!(target, "/app/cache");
1478                assert_eq!(*tier, StorageTier::Local); // default
1479            }
1480            _ => panic!("Expected Anonymous storage"),
1481        }
1482    }
1483
1484    #[test]
1485    fn test_storage_tmpfs() {
1486        let yaml = r#"
1487version: v1
1488deployment: test
1489services:
1490  app:
1491    image:
1492      name: app:latest
1493    storage:
1494      - type: tmpfs
1495        target: /app/tmp
1496        size: 256Mi
1497        mode: 1777
1498"#;
1499        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1500        let storage = &spec.services["app"].storage;
1501        match &storage[0] {
1502            StorageSpec::Tmpfs { target, size, mode } => {
1503                assert_eq!(target, "/app/tmp");
1504                assert_eq!(size.as_deref(), Some("256Mi"));
1505                assert_eq!(*mode, Some(1777));
1506            }
1507            _ => panic!("Expected Tmpfs storage"),
1508        }
1509    }
1510
1511    #[test]
1512    fn test_storage_s3() {
1513        let yaml = r#"
1514version: v1
1515deployment: test
1516services:
1517  app:
1518    image:
1519      name: app:latest
1520    storage:
1521      - type: s3
1522        bucket: my-bucket
1523        prefix: models/
1524        target: /app/models
1525        readonly: true
1526        endpoint: https://s3.us-west-2.amazonaws.com
1527        credentials: aws-creds
1528"#;
1529        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1530        let storage = &spec.services["app"].storage;
1531        match &storage[0] {
1532            StorageSpec::S3 {
1533                bucket,
1534                prefix,
1535                target,
1536                readonly,
1537                endpoint,
1538                credentials,
1539            } => {
1540                assert_eq!(bucket, "my-bucket");
1541                assert_eq!(prefix.as_deref(), Some("models/"));
1542                assert_eq!(target, "/app/models");
1543                assert!(*readonly);
1544                assert_eq!(
1545                    endpoint.as_deref(),
1546                    Some("https://s3.us-west-2.amazonaws.com")
1547                );
1548                assert_eq!(credentials.as_deref(), Some("aws-creds"));
1549            }
1550            _ => panic!("Expected S3 storage"),
1551        }
1552    }
1553
1554    #[test]
1555    fn test_storage_multiple_types() {
1556        let yaml = r#"
1557version: v1
1558deployment: test
1559services:
1560  app:
1561    image:
1562      name: app:latest
1563    storage:
1564      - type: bind
1565        source: /etc/config
1566        target: /app/config
1567        readonly: true
1568      - type: named
1569        name: app-data
1570        target: /app/data
1571      - type: tmpfs
1572        target: /app/tmp
1573"#;
1574        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1575        let storage = &spec.services["app"].storage;
1576        assert_eq!(storage.len(), 3);
1577        assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
1578        assert!(matches!(&storage[1], StorageSpec::Named { .. }));
1579        assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
1580    }
1581
1582    #[test]
1583    fn test_storage_tier_default() {
1584        let yaml = r#"
1585version: v1
1586deployment: test
1587services:
1588  app:
1589    image:
1590      name: app:latest
1591    storage:
1592      - type: named
1593        name: data
1594        target: /data
1595"#;
1596        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1597        match &spec.services["app"].storage[0] {
1598            StorageSpec::Named { tier, .. } => {
1599                assert_eq!(*tier, StorageTier::Local); // default should be Local
1600            }
1601            _ => panic!("Expected Named storage"),
1602        }
1603    }
1604
1605    // ==========================================================================
1606    // Tunnel configuration tests
1607    // ==========================================================================
1608
1609    #[test]
1610    fn test_endpoint_tunnel_config_basic() {
1611        let yaml = r#"
1612version: v1
1613deployment: test
1614services:
1615  api:
1616    image:
1617      name: api:latest
1618    endpoints:
1619      - name: http
1620        protocol: http
1621        port: 8080
1622        tunnel:
1623          enabled: true
1624          remote_port: 8080
1625"#;
1626        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1627        let endpoint = &spec.services["api"].endpoints[0];
1628        let tunnel = endpoint.tunnel.as_ref().unwrap();
1629        assert!(tunnel.enabled);
1630        assert_eq!(tunnel.remote_port, 8080);
1631        assert!(tunnel.from.is_none());
1632        assert!(tunnel.to.is_none());
1633    }
1634
1635    #[test]
1636    fn test_endpoint_tunnel_config_full() {
1637        let yaml = r#"
1638version: v1
1639deployment: test
1640services:
1641  api:
1642    image:
1643      name: api:latest
1644    endpoints:
1645      - name: http
1646        protocol: http
1647        port: 8080
1648        tunnel:
1649          enabled: true
1650          from: node-1
1651          to: ingress-node
1652          remote_port: 9000
1653          expose: public
1654          access:
1655            enabled: true
1656            max_ttl: 4h
1657            audit: true
1658"#;
1659        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1660        let endpoint = &spec.services["api"].endpoints[0];
1661        let tunnel = endpoint.tunnel.as_ref().unwrap();
1662        assert!(tunnel.enabled);
1663        assert_eq!(tunnel.from, Some("node-1".to_string()));
1664        assert_eq!(tunnel.to, Some("ingress-node".to_string()));
1665        assert_eq!(tunnel.remote_port, 9000);
1666        assert_eq!(tunnel.expose, Some(ExposeType::Public));
1667
1668        let access = tunnel.access.as_ref().unwrap();
1669        assert!(access.enabled);
1670        assert_eq!(access.max_ttl, Some("4h".to_string()));
1671        assert!(access.audit);
1672    }
1673
1674    #[test]
1675    fn test_top_level_tunnel_definition() {
1676        let yaml = r#"
1677version: v1
1678deployment: test
1679services: {}
1680tunnels:
1681  db-tunnel:
1682    from: app-node
1683    to: db-node
1684    local_port: 5432
1685    remote_port: 5432
1686    protocol: tcp
1687    expose: internal
1688"#;
1689        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1690        let tunnel = spec.tunnels.get("db-tunnel").unwrap();
1691        assert_eq!(tunnel.from, "app-node");
1692        assert_eq!(tunnel.to, "db-node");
1693        assert_eq!(tunnel.local_port, 5432);
1694        assert_eq!(tunnel.remote_port, 5432);
1695        assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
1696        assert_eq!(tunnel.expose, ExposeType::Internal);
1697    }
1698
1699    #[test]
1700    fn test_top_level_tunnel_defaults() {
1701        let yaml = r#"
1702version: v1
1703deployment: test
1704services: {}
1705tunnels:
1706  simple-tunnel:
1707    from: node-a
1708    to: node-b
1709    local_port: 3000
1710    remote_port: 3000
1711"#;
1712        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1713        let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
1714        assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); // default
1715        assert_eq!(tunnel.expose, ExposeType::Internal); // default
1716    }
1717
1718    #[test]
1719    fn test_tunnel_protocol_udp() {
1720        let yaml = r#"
1721version: v1
1722deployment: test
1723services: {}
1724tunnels:
1725  udp-tunnel:
1726    from: node-a
1727    to: node-b
1728    local_port: 5353
1729    remote_port: 5353
1730    protocol: udp
1731"#;
1732        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1733        let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
1734        assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
1735    }
1736
1737    #[test]
1738    fn test_endpoint_without_tunnel() {
1739        let yaml = r#"
1740version: v1
1741deployment: test
1742services:
1743  api:
1744    image:
1745      name: api:latest
1746    endpoints:
1747      - name: http
1748        protocol: http
1749        port: 8080
1750"#;
1751        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1752        let endpoint = &spec.services["api"].endpoints[0];
1753        assert!(endpoint.tunnel.is_none());
1754    }
1755
1756    #[test]
1757    fn test_deployment_without_tunnels() {
1758        let yaml = r#"
1759version: v1
1760deployment: test
1761services:
1762  api:
1763    image:
1764      name: api:latest
1765"#;
1766        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1767        assert!(spec.tunnels.is_empty());
1768    }
1769
1770    // ==========================================================================
1771    // ApiSpec tests
1772    // ==========================================================================
1773
1774    #[test]
1775    fn test_spec_without_api_block_uses_defaults() {
1776        let yaml = r#"
1777version: v1
1778deployment: test
1779services:
1780  hello:
1781    image:
1782      name: hello-world:latest
1783"#;
1784        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1785        assert!(spec.api.enabled);
1786        assert_eq!(spec.api.bind, "0.0.0.0:3669");
1787        assert!(spec.api.jwt_secret.is_none());
1788        assert!(spec.api.swagger);
1789    }
1790
1791    #[test]
1792    fn test_spec_with_explicit_api_block() {
1793        let yaml = r#"
1794version: v1
1795deployment: test
1796services:
1797  hello:
1798    image:
1799      name: hello-world:latest
1800api:
1801  enabled: false
1802  bind: "127.0.0.1:9090"
1803  jwt_secret: "my-secret"
1804  swagger: false
1805"#;
1806        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1807        assert!(!spec.api.enabled);
1808        assert_eq!(spec.api.bind, "127.0.0.1:9090");
1809        assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
1810        assert!(!spec.api.swagger);
1811    }
1812
1813    #[test]
1814    fn test_spec_with_partial_api_block() {
1815        let yaml = r#"
1816version: v1
1817deployment: test
1818services:
1819  hello:
1820    image:
1821      name: hello-world:latest
1822api:
1823  bind: "0.0.0.0:3000"
1824"#;
1825        let spec: DeploymentSpec = serde_yml::from_str(yaml).unwrap();
1826        assert!(spec.api.enabled); // default true
1827        assert_eq!(spec.api.bind, "0.0.0.0:3000");
1828        assert!(spec.api.jwt_secret.is_none()); // default None
1829        assert!(spec.api.swagger); // default true
1830    }
1831}