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