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 = "snake_case")]
84pub enum ServiceType {
85    /// Standard long-running container service
86    #[default]
87    Standard,
88    /// WASM-based HTTP service (wasi:http/incoming-handler)
89    WasmHttp,
90    /// WASM-based general plugin (zlayer:plugin handler - full host access)
91    WasmPlugin,
92    /// WASM-based stateless request/response transformer
93    WasmTransformer,
94    /// WASM-based authenticator plugin (secrets + KV + HTTP)
95    WasmAuthenticator,
96    /// WASM-based rate limiter (KV + metrics)
97    WasmRateLimiter,
98    /// WASM-based request/response middleware
99    WasmMiddleware,
100    /// WASM-based custom router
101    WasmRouter,
102    /// Run-to-completion job
103    Job,
104}
105
106/// Storage performance tier
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
108#[serde(rename_all = "snake_case")]
109pub enum StorageTier {
110    /// Direct local filesystem (SSD/NVMe) - SQLite-safe, fast fsync
111    #[default]
112    Local,
113    /// bcache-backed tiered storage (SSD cache + slower backend)
114    Cached,
115    /// NFS/network storage - NOT SQLite-safe (will warn)
116    Network,
117}
118
119/// Node selection constraints for service placement
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct NodeSelector {
123    /// Required labels that nodes must have (all must match)
124    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
125    pub labels: HashMap<String, String>,
126    /// Preferred labels (soft constraint, nodes with these are preferred)
127    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
128    pub prefer_labels: HashMap<String, String>,
129}
130
131/// Explicit capability declarations for WASM modules.
132/// Controls which host interfaces are linked and available to the component.
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134#[serde(deny_unknown_fields)]
135#[allow(clippy::struct_excessive_bools)]
136pub struct WasmCapabilities {
137    /// Config interface access (zlayer:plugin/config)
138    #[serde(default = "default_true")]
139    pub config: bool,
140    /// Key-value storage access (zlayer:plugin/keyvalue)
141    #[serde(default = "default_true")]
142    pub keyvalue: bool,
143    /// Logging access (zlayer:plugin/logging)
144    #[serde(default = "default_true")]
145    pub logging: bool,
146    /// Secrets access (zlayer:plugin/secrets)
147    #[serde(default)]
148    pub secrets: bool,
149    /// Metrics emission (zlayer:plugin/metrics)
150    #[serde(default = "default_true")]
151    pub metrics: bool,
152    /// HTTP client for outgoing requests (wasi:http/outgoing-handler)
153    #[serde(default)]
154    pub http_client: bool,
155    /// WASI CLI access (args, env, stdio)
156    #[serde(default)]
157    pub cli: bool,
158    /// WASI filesystem access
159    #[serde(default)]
160    pub filesystem: bool,
161    /// WASI sockets access (TCP/UDP)
162    #[serde(default)]
163    pub sockets: bool,
164}
165
166impl Default for WasmCapabilities {
167    fn default() -> Self {
168        Self {
169            config: true,
170            keyvalue: true,
171            logging: true,
172            secrets: false,
173            metrics: true,
174            http_client: false,
175            cli: false,
176            filesystem: false,
177            sockets: false,
178        }
179    }
180}
181
182/// Pre-opened directory for WASM filesystem access
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184#[serde(deny_unknown_fields)]
185pub struct WasmPreopen {
186    /// Host path to mount
187    pub source: String,
188    /// Guest path (visible to WASM module)
189    pub target: String,
190    /// Read-only access (default: false)
191    #[serde(default)]
192    pub readonly: bool,
193}
194
195/// Comprehensive configuration for all WASM service types.
196///
197/// Replaces the previous `WasmHttpConfig` with resource limits, capability
198/// declarations, networking controls, and storage configuration.
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200#[serde(deny_unknown_fields)]
201#[allow(clippy::struct_excessive_bools)]
202pub struct WasmConfig {
203    // --- Instance Management ---
204    /// Minimum number of warm instances to keep ready
205    #[serde(default = "default_min_instances")]
206    pub min_instances: u32,
207    /// Maximum number of instances to scale to
208    #[serde(default = "default_max_instances")]
209    pub max_instances: u32,
210    /// Time before idle instances are terminated
211    #[serde(default = "default_idle_timeout", with = "duration::required")]
212    pub idle_timeout: std::time::Duration,
213    /// Maximum time for a single request
214    #[serde(default = "default_request_timeout", with = "duration::required")]
215    pub request_timeout: std::time::Duration,
216
217    // --- Resource Limits ---
218    /// Maximum linear memory (e.g., "64Mi", "256Mi")
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub max_memory: Option<String>,
221    /// Maximum fuel (instruction count limit, 0 = unlimited)
222    #[serde(default)]
223    pub max_fuel: u64,
224    /// Epoch interval for cooperative preemption
225    #[serde(
226        default,
227        skip_serializing_if = "Option::is_none",
228        with = "duration::option"
229    )]
230    pub epoch_interval: Option<std::time::Duration>,
231
232    // --- Capabilities ---
233    /// Explicit capability grants (overrides world defaults when restricting)
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub capabilities: Option<WasmCapabilities>,
236
237    // --- Networking ---
238    /// Allow outgoing HTTP requests (default: true)
239    #[serde(default = "default_true")]
240    pub allow_http_outgoing: bool,
241    /// Allowed outgoing HTTP hosts (empty = all allowed)
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub allowed_hosts: Vec<String>,
244    /// Allow raw TCP sockets (default: false)
245    #[serde(default)]
246    pub allow_tcp: bool,
247    /// Allow raw UDP sockets (default: false)
248    #[serde(default)]
249    pub allow_udp: bool,
250
251    // --- Storage ---
252    /// Pre-opened directories (host path -> guest path)
253    #[serde(default, skip_serializing_if = "Vec::is_empty")]
254    pub preopens: Vec<WasmPreopen>,
255    /// Enable KV store access (default: true)
256    #[serde(default = "default_true")]
257    pub kv_enabled: bool,
258    /// KV store namespace (default: service name)
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub kv_namespace: Option<String>,
261    /// KV store max value size in bytes (default: 1MB)
262    #[serde(default = "default_kv_max_value_size")]
263    pub kv_max_value_size: u64,
264
265    // --- Secrets ---
266    /// Secret names accessible to this WASM module
267    #[serde(default, skip_serializing_if = "Vec::is_empty")]
268    pub secrets: Vec<String>,
269
270    // --- Performance ---
271    /// Pre-compile on deploy to reduce cold start (default: true)
272    #[serde(default = "default_true")]
273    pub precompile: bool,
274}
275
276fn default_kv_max_value_size() -> u64 {
277    1_048_576 // 1MB
278}
279
280impl Default for WasmConfig {
281    fn default() -> Self {
282        Self {
283            min_instances: default_min_instances(),
284            max_instances: default_max_instances(),
285            idle_timeout: default_idle_timeout(),
286            request_timeout: default_request_timeout(),
287            max_memory: None,
288            max_fuel: 0,
289            epoch_interval: None,
290            capabilities: None,
291            allow_http_outgoing: true,
292            allowed_hosts: Vec::new(),
293            allow_tcp: false,
294            allow_udp: false,
295            preopens: Vec::new(),
296            kv_enabled: true,
297            kv_namespace: None,
298            kv_max_value_size: default_kv_max_value_size(),
299            secrets: Vec::new(),
300            precompile: true,
301        }
302    }
303}
304
305/// Configuration for WASM HTTP services with instance pooling
306#[deprecated(note = "Use WasmConfig instead")]
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
308#[serde(deny_unknown_fields)]
309pub struct WasmHttpConfig {
310    /// Minimum number of warm instances to keep ready
311    #[serde(default = "default_min_instances")]
312    pub min_instances: u32,
313    /// Maximum number of instances to scale to
314    #[serde(default = "default_max_instances")]
315    pub max_instances: u32,
316    /// Time before idle instances are terminated
317    #[serde(default = "default_idle_timeout", with = "duration::required")]
318    pub idle_timeout: std::time::Duration,
319    /// Maximum time for a single request
320    #[serde(default = "default_request_timeout", with = "duration::required")]
321    pub request_timeout: std::time::Duration,
322}
323
324fn default_min_instances() -> u32 {
325    0
326}
327
328fn default_max_instances() -> u32 {
329    10
330}
331
332fn default_idle_timeout() -> std::time::Duration {
333    std::time::Duration::from_secs(300)
334}
335
336fn default_request_timeout() -> std::time::Duration {
337    std::time::Duration::from_secs(30)
338}
339
340#[allow(deprecated)]
341impl Default for WasmHttpConfig {
342    fn default() -> Self {
343        Self {
344            min_instances: default_min_instances(),
345            max_instances: default_max_instances(),
346            idle_timeout: default_idle_timeout(),
347            request_timeout: default_request_timeout(),
348        }
349    }
350}
351
352#[allow(deprecated)]
353impl From<WasmHttpConfig> for WasmConfig {
354    fn from(old: WasmHttpConfig) -> Self {
355        Self {
356            min_instances: old.min_instances,
357            max_instances: old.max_instances,
358            idle_timeout: old.idle_timeout,
359            request_timeout: old.request_timeout,
360            ..Default::default()
361        }
362    }
363}
364
365impl ServiceType {
366    /// Returns true if this is any WASM service type
367    #[must_use]
368    pub fn is_wasm(&self) -> bool {
369        matches!(
370            self,
371            ServiceType::WasmHttp
372                | ServiceType::WasmPlugin
373                | ServiceType::WasmTransformer
374                | ServiceType::WasmAuthenticator
375                | ServiceType::WasmRateLimiter
376                | ServiceType::WasmMiddleware
377                | ServiceType::WasmRouter
378        )
379    }
380
381    /// Returns the default capabilities for this WASM service type.
382    /// Returns None for non-WASM types.
383    #[must_use]
384    pub fn default_wasm_capabilities(&self) -> Option<WasmCapabilities> {
385        match self {
386            ServiceType::WasmHttp | ServiceType::WasmRouter => Some(WasmCapabilities {
387                config: true,
388                keyvalue: true,
389                logging: true,
390                secrets: false,
391                metrics: false,
392                http_client: true,
393                cli: false,
394                filesystem: false,
395                sockets: false,
396            }),
397            ServiceType::WasmPlugin => Some(WasmCapabilities {
398                config: true,
399                keyvalue: true,
400                logging: true,
401                secrets: true,
402                metrics: true,
403                http_client: true,
404                cli: true,
405                filesystem: true,
406                sockets: false,
407            }),
408            ServiceType::WasmTransformer => Some(WasmCapabilities {
409                config: false,
410                keyvalue: false,
411                logging: true,
412                secrets: false,
413                metrics: false,
414                http_client: false,
415                cli: true,
416                filesystem: false,
417                sockets: false,
418            }),
419            ServiceType::WasmAuthenticator => Some(WasmCapabilities {
420                config: true,
421                keyvalue: false,
422                logging: true,
423                secrets: true,
424                metrics: false,
425                http_client: true,
426                cli: false,
427                filesystem: false,
428                sockets: false,
429            }),
430            ServiceType::WasmRateLimiter => Some(WasmCapabilities {
431                config: true,
432                keyvalue: true,
433                logging: true,
434                secrets: false,
435                metrics: true,
436                http_client: false,
437                cli: true,
438                filesystem: false,
439                sockets: false,
440            }),
441            ServiceType::WasmMiddleware => Some(WasmCapabilities {
442                config: true,
443                keyvalue: false,
444                logging: true,
445                secrets: false,
446                metrics: false,
447                http_client: true,
448                cli: false,
449                filesystem: false,
450                sockets: false,
451            }),
452            _ => None,
453        }
454    }
455}
456
457fn default_api_bind() -> String {
458    "0.0.0.0:3669".to_string()
459}
460
461/// API server configuration (embedded in deploy/up flows)
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
463pub struct ApiSpec {
464    /// Enable the API server (default: true)
465    #[serde(default = "default_true")]
466    pub enabled: bool,
467    /// Bind address (default: "0.0.0.0:3669")
468    #[serde(default = "default_api_bind")]
469    pub bind: String,
470    /// JWT secret (reads `ZLAYER_JWT_SECRET` env var if not set)
471    #[serde(default)]
472    pub jwt_secret: Option<String>,
473    /// Enable Swagger UI (default: true)
474    #[serde(default = "default_true")]
475    pub swagger: bool,
476}
477
478impl Default for ApiSpec {
479    fn default() -> Self {
480        Self {
481            enabled: true,
482            bind: default_api_bind(),
483            jwt_secret: None,
484            swagger: true,
485        }
486    }
487}
488
489/// Top-level deployment specification
490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
491#[serde(deny_unknown_fields)]
492pub struct DeploymentSpec {
493    /// Spec version (must be "v1")
494    #[validate(custom(function = "crate::validate::validate_version_wrapper"))]
495    pub version: String,
496
497    /// Deployment name (used for overlays, DNS)
498    #[validate(custom(function = "crate::validate::validate_deployment_name_wrapper"))]
499    pub deployment: String,
500
501    /// Service definitions
502    #[serde(default)]
503    #[validate(nested)]
504    pub services: HashMap<String, ServiceSpec>,
505
506    /// External service definitions (proxy backends without containers)
507    ///
508    /// External services register static backend addresses with the proxy
509    /// for host/path-based routing without starting any containers.
510    /// Useful for proxying to services running outside of `ZLayer`
511    /// (e.g., on other machines reachable via VPN).
512    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
513    #[validate(nested)]
514    pub externals: HashMap<String, ExternalSpec>,
515
516    /// Top-level tunnel definitions (not tied to service endpoints)
517    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
518    pub tunnels: HashMap<String, TunnelDefinition>,
519
520    /// API server configuration (enabled by default)
521    #[serde(default)]
522    pub api: ApiSpec,
523}
524
525/// External service specification (proxy backend without a container)
526///
527/// Defines a service that is not managed by `ZLayer` but should be proxied
528/// through `ZLayer`'s reverse proxy. The proxy registers static backend
529/// addresses and routes traffic based on endpoint host/path matching.
530#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
531#[serde(deny_unknown_fields)]
532pub struct ExternalSpec {
533    /// Static backend addresses (e.g., `["100.64.1.5:8096", "192.168.1.10:8096"]`)
534    ///
535    /// These are the upstream addresses the proxy will forward traffic to.
536    /// At least one backend is required.
537    #[validate(length(min = 1, message = "at least one backend address is required"))]
538    pub backends: Vec<String>,
539
540    /// Endpoint definitions (proxy bindings)
541    ///
542    /// Defines how public/internal traffic is routed to this external service.
543    #[serde(default)]
544    #[validate(nested)]
545    pub endpoints: Vec<EndpointSpec>,
546
547    /// Health check configuration
548    ///
549    /// When specified, the proxy will health-check backends and remove
550    /// unhealthy ones from the rotation.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub health: Option<HealthSpec>,
553}
554
555/// Top-level tunnel definition (not tied to a service endpoint)
556#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
557#[serde(deny_unknown_fields)]
558pub struct TunnelDefinition {
559    /// Source node
560    pub from: String,
561
562    /// Destination node
563    pub to: String,
564
565    /// Local port on source
566    pub local_port: u16,
567
568    /// Remote port on destination
569    pub remote_port: u16,
570
571    /// Protocol (tcp/udp, defaults to tcp)
572    #[serde(default)]
573    pub protocol: TunnelProtocol,
574
575    /// Exposure type (defaults to internal)
576    #[serde(default)]
577    pub expose: ExposeType,
578}
579
580/// Protocol for tunnel connections (tcp or udp only)
581#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
582#[serde(rename_all = "lowercase")]
583pub enum TunnelProtocol {
584    #[default]
585    Tcp,
586    Udp,
587}
588
589/// Log output configuration for services and jobs.
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
591pub struct LogsConfig {
592    /// Where to write logs: "disk" (default) or "memory"
593    #[serde(default = "default_logs_destination")]
594    pub destination: String,
595
596    /// Maximum log size in bytes (default: 100MB)
597    #[serde(default = "default_logs_max_size")]
598    pub max_size_bytes: u64,
599
600    /// Log retention in seconds (default: 7 days)
601    #[serde(default = "default_logs_retention")]
602    pub retention_secs: u64,
603}
604
605fn default_logs_destination() -> String {
606    "disk".to_string()
607}
608
609fn default_logs_max_size() -> u64 {
610    100 * 1024 * 1024 // 100MB
611}
612
613fn default_logs_retention() -> u64 {
614    7 * 24 * 60 * 60 // 7 days
615}
616
617impl Default for LogsConfig {
618    fn default() -> Self {
619        Self {
620            destination: default_logs_destination(),
621            max_size_bytes: default_logs_max_size(),
622            retention_secs: default_logs_retention(),
623        }
624    }
625}
626
627/// Per-service specification
628#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
629#[serde(deny_unknown_fields)]
630pub struct ServiceSpec {
631    /// Resource type (service, job, cron)
632    #[serde(default = "default_resource_type")]
633    pub rtype: ResourceType,
634
635    /// Cron schedule expression (only for rtype: cron)
636    /// Uses 7-field cron syntax: "sec min hour day-of-month month day-of-week year"
637    /// Examples:
638    ///   - "0 0 0 * * * *" (daily at midnight)
639    ///   - "0 */5 * * * * *" (every 5 minutes)
640    ///   - "0 0 12 * * MON-FRI *" (weekdays at noon)
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    #[validate(custom(function = "crate::validate::validate_schedule_wrapper"))]
643    pub schedule: Option<String>,
644
645    /// Container image specification
646    #[validate(nested)]
647    pub image: ImageSpec,
648
649    /// Resource limits
650    #[serde(default)]
651    #[validate(nested)]
652    pub resources: ResourcesSpec,
653
654    /// Environment variables for the service
655    ///
656    /// Values can be:
657    /// - Plain strings: `"value"`
658    /// - Host env refs: `$E:VAR_NAME`
659    /// - Secret refs: `$S:secret-name` or `$S:@service/secret-name`
660    #[serde(default)]
661    pub env: HashMap<String, String>,
662
663    /// Command override (entrypoint, args, workdir)
664    #[serde(default)]
665    pub command: CommandSpec,
666
667    /// Network configuration
668    #[serde(default)]
669    pub network: ServiceNetworkSpec,
670
671    /// Endpoint definitions (proxy bindings)
672    #[serde(default)]
673    #[validate(nested)]
674    pub endpoints: Vec<EndpointSpec>,
675
676    /// Scaling configuration
677    #[serde(default)]
678    #[validate(custom(function = "crate::validate::validate_scale_spec"))]
679    pub scale: ScaleSpec,
680
681    /// Dependency specifications
682    #[serde(default)]
683    pub depends: Vec<DependsSpec>,
684
685    /// Health check configuration
686    #[serde(default = "default_health")]
687    pub health: HealthSpec,
688
689    /// Init actions (pre-start lifecycle steps)
690    #[serde(default)]
691    pub init: InitSpec,
692
693    /// Error handling policies
694    #[serde(default)]
695    pub errors: ErrorsSpec,
696
697    /// Device passthrough (e.g., /dev/kvm for VMs)
698    #[serde(default)]
699    pub devices: Vec<DeviceSpec>,
700
701    /// Storage mounts for the container
702    #[serde(default, skip_serializing_if = "Vec::is_empty")]
703    pub storage: Vec<StorageSpec>,
704
705    /// Linux capabilities to add (e.g., `SYS_ADMIN`, `NET_ADMIN`)
706    #[serde(default)]
707    pub capabilities: Vec<String>,
708
709    /// Run container in privileged mode (all capabilities + all devices)
710    #[serde(default)]
711    pub privileged: bool,
712
713    /// Node allocation mode (shared, dedicated, exclusive)
714    #[serde(default)]
715    pub node_mode: NodeMode,
716
717    /// Node selection constraints (required/preferred labels)
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub node_selector: Option<NodeSelector>,
720
721    /// Service type (standard, `wasm_http`, `wasm_plugin`, etc.)
722    #[serde(default)]
723    pub service_type: ServiceType,
724
725    /// WASM configuration (used when `service_type` is any Wasm* variant)
726    /// Also accepts the deprecated `wasm_http` key for backward compatibility.
727    #[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
728    pub wasm: Option<WasmConfig>,
729
730    /// Log output configuration. If not set, uses platform defaults.
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub logs: Option<LogsConfig>,
733
734    /// Use host networking (container shares host network namespace)
735    ///
736    /// When true, the container will NOT get its own network namespace.
737    /// This is set programmatically via the `--host-network` CLI flag, not in YAML specs.
738    #[serde(skip)]
739    pub host_network: bool,
740}
741
742/// Command override specification (Section 5.5)
743#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
744#[serde(deny_unknown_fields)]
745pub struct CommandSpec {
746    /// Override image ENTRYPOINT
747    #[serde(default, skip_serializing_if = "Option::is_none")]
748    pub entrypoint: Option<Vec<String>>,
749
750    /// Override image CMD
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub args: Option<Vec<String>>,
753
754    /// Override working directory
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub workdir: Option<String>,
757}
758
759fn default_resource_type() -> ResourceType {
760    ResourceType::Service
761}
762
763fn default_health() -> HealthSpec {
764    HealthSpec {
765        start_grace: Some(std::time::Duration::from_secs(5)),
766        interval: None,
767        timeout: None,
768        retries: 3,
769        check: HealthCheck::Tcp { port: 0 },
770    }
771}
772
773/// Resource type - determines container lifecycle
774#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
775#[serde(rename_all = "lowercase")]
776pub enum ResourceType {
777    /// Long-running container, receives traffic, load-balanced
778    Service,
779    /// Run-to-completion, triggered by endpoint/CLI/internal system
780    Job,
781    /// Scheduled run-to-completion, time-triggered
782    Cron,
783}
784
785/// Container image specification
786#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
787#[serde(deny_unknown_fields)]
788pub struct ImageSpec {
789    /// Image name (e.g., "ghcr.io/org/api:latest")
790    #[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
791    pub name: String,
792
793    /// When to pull the image
794    #[serde(default = "default_pull_policy")]
795    pub pull_policy: PullPolicy,
796}
797
798fn default_pull_policy() -> PullPolicy {
799    PullPolicy::IfNotPresent
800}
801
802/// Image pull policy
803#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
804#[serde(rename_all = "snake_case")]
805pub enum PullPolicy {
806    /// Always pull the image
807    Always,
808    /// Pull only if not present locally
809    IfNotPresent,
810    /// Never pull, use local image only
811    Never,
812}
813
814/// Device passthrough specification
815#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
816#[serde(deny_unknown_fields)]
817pub struct DeviceSpec {
818    /// Host device path (e.g., /dev/kvm, /dev/net/tun)
819    #[validate(length(min = 1, message = "device path cannot be empty"))]
820    pub path: String,
821
822    /// Allow read access
823    #[serde(default = "default_true")]
824    pub read: bool,
825
826    /// Allow write access
827    #[serde(default = "default_true")]
828    pub write: bool,
829
830    /// Allow mknod (create device nodes)
831    #[serde(default)]
832    pub mknod: bool,
833}
834
835fn default_true() -> bool {
836    true
837}
838
839/// Storage mount specification
840#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
841#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
842pub enum StorageSpec {
843    /// Bind mount from host path to container
844    Bind {
845        source: String,
846        target: String,
847        #[serde(default)]
848        readonly: bool,
849    },
850    /// Named persistent storage volume
851    Named {
852        name: String,
853        target: String,
854        #[serde(default)]
855        readonly: bool,
856        /// Performance tier (default: local, SQLite-safe)
857        #[serde(default)]
858        tier: StorageTier,
859        /// Optional size limit (e.g., "1Gi", "512Mi")
860        #[serde(default, skip_serializing_if = "Option::is_none")]
861        size: Option<String>,
862    },
863    /// Anonymous storage (auto-named, container lifecycle)
864    Anonymous {
865        target: String,
866        /// Performance tier (default: local)
867        #[serde(default)]
868        tier: StorageTier,
869    },
870    /// Memory-backed tmpfs mount
871    Tmpfs {
872        target: String,
873        #[serde(default)]
874        size: Option<String>,
875        #[serde(default)]
876        mode: Option<u32>,
877    },
878    /// S3-backed FUSE mount
879    S3 {
880        bucket: String,
881        #[serde(default)]
882        prefix: Option<String>,
883        target: String,
884        #[serde(default)]
885        readonly: bool,
886        #[serde(default)]
887        endpoint: Option<String>,
888        #[serde(default)]
889        credentials: Option<String>,
890    },
891}
892
893/// Resource limits (upper bounds, not reservations)
894#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
895#[serde(deny_unknown_fields)]
896pub struct ResourcesSpec {
897    /// CPU limit (cores, e.g., 0.5, 1, 2)
898    #[serde(default)]
899    #[validate(custom(function = "crate::validate::validate_cpu_option_wrapper"))]
900    pub cpu: Option<f64>,
901
902    /// Memory limit (e.g., "512Mi", "1Gi", "2Gi")
903    #[serde(default)]
904    #[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
905    pub memory: Option<String>,
906
907    /// GPU resource request
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub gpu: Option<GpuSpec>,
910}
911
912/// Scheduling policy for GPU workloads
913#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
914#[serde(rename_all = "kebab-case")]
915pub enum SchedulingPolicy {
916    /// Place as many replicas as possible; partial placement is acceptable (default)
917    #[default]
918    BestEffort,
919    /// All replicas must be placed or none are; prevents partial GPU job deployment
920    Gang,
921    /// Spread replicas across nodes to maximize GPU distribution
922    Spread,
923}
924
925/// GPU sharing mode controlling how GPU resources are multiplexed.
926#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
927#[serde(rename_all = "kebab-case")]
928pub enum GpuSharingMode {
929    /// Whole GPU per container (default). No sharing.
930    #[default]
931    Exclusive,
932    /// NVIDIA Multi-Process Service: concurrent GPU compute sharing.
933    /// Multiple containers run GPU kernels simultaneously with hardware isolation.
934    Mps,
935    /// NVIDIA time-slicing: round-robin GPU access across containers.
936    /// Lower overhead than MPS but no concurrent execution.
937    TimeSlice,
938}
939
940/// Configuration for distributed GPU job coordination.
941///
942/// When enabled on a multi-replica GPU service, `ZLayer` injects standard
943/// distributed training environment variables (`MASTER_ADDR`, `MASTER_PORT`,
944/// `WORLD_SIZE`, `RANK`, `LOCAL_RANK`) so frameworks like `PyTorch`, `Horovod`,
945/// and `DeepSpeed` can coordinate automatically.
946#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
947#[serde(deny_unknown_fields)]
948pub struct DistributedConfig {
949    /// Communication backend: "nccl" (default), "gloo", or "mpi"
950    #[serde(default = "default_dist_backend")]
951    pub backend: String,
952    /// Port for rank-0 master coordination (default: 29500)
953    #[serde(default = "default_dist_port")]
954    pub master_port: u16,
955}
956
957fn default_dist_backend() -> String {
958    "nccl".to_string()
959}
960
961fn default_dist_port() -> u16 {
962    29500
963}
964
965/// GPU resource specification
966///
967/// Supported vendors:
968/// - `nvidia` - NVIDIA GPUs via NVIDIA Container Toolkit (default)
969/// - `amd` - AMD GPUs via `ROCm` (/dev/kfd + /dev/dri/renderD*)
970/// - `intel` - Intel GPUs via VAAPI/i915 (/dev/dri/renderD*)
971/// - `apple` - Apple Silicon GPUs via Metal/MPS (macOS only)
972///
973/// Unknown vendors fall back to DRI render node passthrough.
974///
975/// ## GPU mode (macOS only)
976///
977/// When `vendor` is `"apple"`, the `mode` field controls how GPU access is provided:
978/// - `"native"` -- Seatbelt sandbox with direct Metal/MPS access (lowest overhead)
979/// - `"vm"` -- libkrun micro-VM with GPU forwarding (stronger isolation)
980/// - `None` (default) -- Auto-select based on platform and vendor
981///
982/// On Linux, `mode` is ignored; GPU passthrough always uses device node binding.
983#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
984#[serde(deny_unknown_fields)]
985pub struct GpuSpec {
986    /// Number of GPUs to request
987    #[serde(default = "default_gpu_count")]
988    pub count: u32,
989    /// GPU vendor (`nvidia`, `amd`, `intel`, `apple`) - defaults to `nvidia`
990    #[serde(default = "default_gpu_vendor")]
991    pub vendor: String,
992    /// GPU access mode (macOS only): `"native"`, `"vm"`, or `None` for auto-select
993    #[serde(default, skip_serializing_if = "Option::is_none")]
994    pub mode: Option<String>,
995    /// Pin to a specific GPU model (e.g. "A100", "H100").
996    /// Substring match against detected GPU model names.
997    #[serde(default, skip_serializing_if = "Option::is_none")]
998    pub model: Option<String>,
999    /// Scheduling policy for GPU workloads.
1000    /// - `best-effort` (default): place what fits
1001    /// - `gang`: all-or-nothing for distributed jobs
1002    /// - `spread`: distribute across nodes
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub scheduling: Option<SchedulingPolicy>,
1005    /// Distributed GPU job coordination.
1006    /// When set, injects `MASTER_ADDR`, `WORLD_SIZE`, `RANK`, `LOCAL_RANK` env vars.
1007    #[serde(default, skip_serializing_if = "Option::is_none")]
1008    pub distributed: Option<DistributedConfig>,
1009    /// GPU sharing mode: exclusive (default), mps, or time-slice.
1010    #[serde(default, skip_serializing_if = "Option::is_none")]
1011    pub sharing: Option<GpuSharingMode>,
1012}
1013
1014fn default_gpu_count() -> u32 {
1015    1
1016}
1017
1018fn default_gpu_vendor() -> String {
1019    "nvidia".to_string()
1020}
1021
1022/// Per-service network configuration (overlay + join policy).
1023#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1024#[serde(deny_unknown_fields)]
1025#[derive(Default)]
1026pub struct ServiceNetworkSpec {
1027    /// Overlay network configuration
1028    #[serde(default)]
1029    pub overlays: OverlayConfig,
1030
1031    /// Join policy (who can join this service)
1032    #[serde(default)]
1033    pub join: JoinPolicy,
1034}
1035
1036/// Overlay network configuration
1037#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1038#[serde(deny_unknown_fields)]
1039pub struct OverlayConfig {
1040    /// Service-scoped overlay (service replicas only)
1041    #[serde(default)]
1042    pub service: OverlaySettings,
1043
1044    /// Global overlay (all services in deployment)
1045    #[serde(default)]
1046    pub global: OverlaySettings,
1047}
1048
1049impl Default for OverlayConfig {
1050    fn default() -> Self {
1051        Self {
1052            service: OverlaySettings {
1053                enabled: true,
1054                encrypted: true,
1055                isolated: true,
1056            },
1057            global: OverlaySettings {
1058                enabled: true,
1059                encrypted: true,
1060                isolated: false,
1061            },
1062        }
1063    }
1064}
1065
1066/// Overlay network settings
1067#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1068#[serde(deny_unknown_fields)]
1069pub struct OverlaySettings {
1070    /// Enable this overlay
1071    #[serde(default = "default_enabled")]
1072    pub enabled: bool,
1073
1074    /// Use encryption
1075    #[serde(default = "default_encrypted")]
1076    pub encrypted: bool,
1077
1078    /// Isolate from other services/groups
1079    #[serde(default)]
1080    pub isolated: bool,
1081}
1082
1083fn default_enabled() -> bool {
1084    true
1085}
1086
1087fn default_encrypted() -> bool {
1088    true
1089}
1090
1091/// Join policy - controls who can join a service
1092#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1093#[serde(deny_unknown_fields)]
1094pub struct JoinPolicy {
1095    /// Join mode
1096    #[serde(default = "default_join_mode")]
1097    pub mode: JoinMode,
1098
1099    /// Scope of join
1100    #[serde(default = "default_join_scope")]
1101    pub scope: JoinScope,
1102}
1103
1104impl Default for JoinPolicy {
1105    fn default() -> Self {
1106        Self {
1107            mode: default_join_mode(),
1108            scope: default_join_scope(),
1109        }
1110    }
1111}
1112
1113fn default_join_mode() -> JoinMode {
1114    JoinMode::Token
1115}
1116
1117fn default_join_scope() -> JoinScope {
1118    JoinScope::Service
1119}
1120
1121/// Join mode
1122#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1123#[serde(rename_all = "snake_case")]
1124pub enum JoinMode {
1125    /// Any trusted node in deployment can self-enroll
1126    Open,
1127    /// Requires a join key (recommended)
1128    Token,
1129    /// Only control-plane/scheduler can place replicas
1130    Closed,
1131}
1132
1133/// Join scope
1134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1135#[serde(rename_all = "snake_case")]
1136pub enum JoinScope {
1137    /// Join this specific service
1138    Service,
1139    /// Join all services in deployment
1140    Global,
1141}
1142
1143/// Endpoint specification (proxy binding)
1144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1145#[serde(deny_unknown_fields)]
1146pub struct EndpointSpec {
1147    /// Endpoint name (for routing)
1148    #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
1149    pub name: String,
1150
1151    /// Protocol
1152    pub protocol: Protocol,
1153
1154    /// Proxy listen port (external-facing port)
1155    #[validate(custom(function = "crate::validate::validate_port_wrapper"))]
1156    pub port: u16,
1157
1158    /// Container port the service actually listens on.
1159    /// Defaults to `port` when not specified.
1160    #[serde(default, skip_serializing_if = "Option::is_none")]
1161    pub target_port: Option<u16>,
1162
1163    /// URL path prefix (for http/https/websocket)
1164    pub path: Option<String>,
1165
1166    /// Host pattern for routing (e.g. "api.example.com" or "*.example.com").
1167    /// `None` means match any host.
1168    #[serde(default, skip_serializing_if = "Option::is_none")]
1169    pub host: Option<String>,
1170
1171    /// Exposure type
1172    #[serde(default = "default_expose")]
1173    pub expose: ExposeType,
1174
1175    /// Optional stream (L4) proxy configuration
1176    /// Only applicable when protocol is tcp or udp
1177    #[serde(default, skip_serializing_if = "Option::is_none")]
1178    pub stream: Option<StreamEndpointConfig>,
1179
1180    /// Optional tunnel configuration for this endpoint
1181    #[serde(default, skip_serializing_if = "Option::is_none")]
1182    pub tunnel: Option<EndpointTunnelConfig>,
1183}
1184
1185impl EndpointSpec {
1186    /// Returns the port the container actually listens on.
1187    /// Falls back to `port` when `target_port` is not specified.
1188    #[must_use]
1189    pub fn target_port(&self) -> u16 {
1190        self.target_port.unwrap_or(self.port)
1191    }
1192}
1193
1194/// Tunnel configuration for an endpoint
1195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1196#[serde(deny_unknown_fields)]
1197pub struct EndpointTunnelConfig {
1198    /// Enable tunneling for this endpoint
1199    #[serde(default)]
1200    pub enabled: bool,
1201
1202    /// Source node name (defaults to service's node)
1203    #[serde(default, skip_serializing_if = "Option::is_none")]
1204    pub from: Option<String>,
1205
1206    /// Destination node name (defaults to cluster ingress)
1207    #[serde(default, skip_serializing_if = "Option::is_none")]
1208    pub to: Option<String>,
1209
1210    /// Remote port to expose (0 = auto-assign)
1211    #[serde(default)]
1212    pub remote_port: u16,
1213
1214    /// Override exposure for tunnel (public/internal)
1215    #[serde(default, skip_serializing_if = "Option::is_none")]
1216    pub expose: Option<ExposeType>,
1217
1218    /// On-demand access configuration
1219    #[serde(default, skip_serializing_if = "Option::is_none")]
1220    pub access: Option<TunnelAccessConfig>,
1221}
1222
1223/// On-demand access settings for `zlayer tunnel access`
1224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1225#[serde(deny_unknown_fields)]
1226pub struct TunnelAccessConfig {
1227    /// Allow on-demand access via CLI
1228    #[serde(default)]
1229    pub enabled: bool,
1230
1231    /// Maximum session duration (e.g., "4h", "30m")
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    pub max_ttl: Option<String>,
1234
1235    /// Log all access sessions
1236    #[serde(default)]
1237    pub audit: bool,
1238}
1239
1240fn default_expose() -> ExposeType {
1241    ExposeType::Internal
1242}
1243
1244/// Protocol type
1245#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1246#[serde(rename_all = "lowercase")]
1247pub enum Protocol {
1248    Http,
1249    Https,
1250    Tcp,
1251    Udp,
1252    Websocket,
1253}
1254
1255/// Exposure type
1256#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1257#[serde(rename_all = "lowercase")]
1258pub enum ExposeType {
1259    Public,
1260    #[default]
1261    Internal,
1262}
1263
1264/// Stream (L4) proxy configuration for TCP/UDP endpoints
1265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1266#[serde(deny_unknown_fields)]
1267pub struct StreamEndpointConfig {
1268    /// Enable TLS termination for TCP (auto-provision cert)
1269    #[serde(default)]
1270    pub tls: bool,
1271
1272    /// Enable PROXY protocol for passing client IP
1273    #[serde(default)]
1274    pub proxy_protocol: bool,
1275
1276    /// Custom session timeout for UDP (default: 60s)
1277    /// Format: duration string like "60s", "5m"
1278    #[serde(default, skip_serializing_if = "Option::is_none")]
1279    pub session_timeout: Option<String>,
1280
1281    /// Health check configuration for L4
1282    #[serde(default, skip_serializing_if = "Option::is_none")]
1283    pub health_check: Option<StreamHealthCheck>,
1284}
1285
1286/// Health check types for stream (L4) endpoints
1287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1288#[serde(tag = "type", rename_all = "snake_case")]
1289pub enum StreamHealthCheck {
1290    /// TCP connect check - verifies port is accepting connections
1291    TcpConnect,
1292    /// UDP probe - sends request and optionally validates response
1293    UdpProbe {
1294        /// Request payload to send (can use hex escapes like \\xFF)
1295        request: String,
1296        /// Expected response pattern (optional regex)
1297        #[serde(default, skip_serializing_if = "Option::is_none")]
1298        expect: Option<String>,
1299    },
1300}
1301
1302/// Scaling configuration
1303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1304#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
1305pub enum ScaleSpec {
1306    /// Adaptive scaling with metrics
1307    #[serde(rename = "adaptive")]
1308    Adaptive {
1309        /// Minimum replicas
1310        min: u32,
1311
1312        /// Maximum replicas
1313        max: u32,
1314
1315        /// Cooldown period between scale events
1316        #[serde(default, with = "duration::option")]
1317        cooldown: Option<std::time::Duration>,
1318
1319        /// Target metrics for scaling
1320        #[serde(default)]
1321        targets: ScaleTargets,
1322    },
1323
1324    /// Fixed number of replicas
1325    #[serde(rename = "fixed")]
1326    Fixed { replicas: u32 },
1327
1328    /// Manual scaling (no automatic scaling)
1329    #[serde(rename = "manual")]
1330    Manual,
1331}
1332
1333impl Default for ScaleSpec {
1334    fn default() -> Self {
1335        Self::Adaptive {
1336            min: 1,
1337            max: 10,
1338            cooldown: Some(std::time::Duration::from_secs(30)),
1339            targets: ScaleTargets::default(),
1340        }
1341    }
1342}
1343
1344/// Target metrics for adaptive scaling
1345#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1346#[serde(deny_unknown_fields)]
1347#[derive(Default)]
1348pub struct ScaleTargets {
1349    /// CPU percentage threshold (0-100)
1350    #[serde(default)]
1351    pub cpu: Option<u8>,
1352
1353    /// Memory percentage threshold (0-100)
1354    #[serde(default)]
1355    pub memory: Option<u8>,
1356
1357    /// Requests per second threshold
1358    #[serde(default)]
1359    pub rps: Option<u32>,
1360}
1361
1362/// Dependency specification
1363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1364#[serde(deny_unknown_fields)]
1365pub struct DependsSpec {
1366    /// Service name to depend on
1367    pub service: String,
1368
1369    /// Condition for dependency
1370    #[serde(default = "default_condition")]
1371    pub condition: DependencyCondition,
1372
1373    /// Maximum time to wait
1374    #[serde(default = "default_timeout", with = "duration::option")]
1375    pub timeout: Option<std::time::Duration>,
1376
1377    /// Action on timeout
1378    #[serde(default = "default_on_timeout")]
1379    pub on_timeout: TimeoutAction,
1380}
1381
1382fn default_condition() -> DependencyCondition {
1383    DependencyCondition::Healthy
1384}
1385
1386#[allow(clippy::unnecessary_wraps)]
1387fn default_timeout() -> Option<std::time::Duration> {
1388    Some(std::time::Duration::from_secs(300))
1389}
1390
1391fn default_on_timeout() -> TimeoutAction {
1392    TimeoutAction::Fail
1393}
1394
1395/// Dependency condition
1396#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1397#[serde(rename_all = "lowercase")]
1398pub enum DependencyCondition {
1399    /// Container process exists
1400    Started,
1401    /// Health check passes
1402    Healthy,
1403    /// Service is available for routing
1404    Ready,
1405}
1406
1407/// Timeout action
1408#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1409#[serde(rename_all = "lowercase")]
1410pub enum TimeoutAction {
1411    Fail,
1412    Warn,
1413    Continue,
1414}
1415
1416/// Health check specification
1417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1418#[serde(deny_unknown_fields)]
1419pub struct HealthSpec {
1420    /// Grace period before first check
1421    #[serde(default, with = "duration::option")]
1422    pub start_grace: Option<std::time::Duration>,
1423
1424    /// Interval between checks
1425    #[serde(default, with = "duration::option")]
1426    pub interval: Option<std::time::Duration>,
1427
1428    /// Timeout per check
1429    #[serde(default, with = "duration::option")]
1430    pub timeout: Option<std::time::Duration>,
1431
1432    /// Number of retries before marking unhealthy
1433    #[serde(default = "default_retries")]
1434    pub retries: u32,
1435
1436    /// Health check type and parameters
1437    pub check: HealthCheck,
1438}
1439
1440fn default_retries() -> u32 {
1441    3
1442}
1443
1444/// Health check type
1445#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1446#[serde(tag = "type", rename_all = "lowercase")]
1447pub enum HealthCheck {
1448    /// TCP port check
1449    Tcp {
1450        /// Port to check (0 = use first endpoint)
1451        port: u16,
1452    },
1453
1454    /// HTTP check
1455    Http {
1456        /// URL to check
1457        url: String,
1458        /// Expected status code
1459        #[serde(default = "default_expect_status")]
1460        expect_status: u16,
1461    },
1462
1463    /// Command check
1464    Command {
1465        /// Command to run
1466        command: String,
1467    },
1468}
1469
1470fn default_expect_status() -> u16 {
1471    200
1472}
1473
1474/// Init actions specification
1475#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1476#[serde(deny_unknown_fields)]
1477#[derive(Default)]
1478pub struct InitSpec {
1479    /// Init steps to run before container starts
1480    #[serde(default)]
1481    pub steps: Vec<InitStep>,
1482}
1483
1484/// Init action step
1485#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1486#[serde(deny_unknown_fields)]
1487pub struct InitStep {
1488    /// Step identifier
1489    pub id: String,
1490
1491    /// Action to perform (e.g., "`init.wait_tcp`")
1492    pub uses: String,
1493
1494    /// Parameters for the action
1495    #[serde(default)]
1496    pub with: InitParams,
1497
1498    /// Number of retries
1499    #[serde(default)]
1500    pub retry: Option<u32>,
1501
1502    /// Maximum time for this step
1503    #[serde(default, with = "duration::option")]
1504    pub timeout: Option<std::time::Duration>,
1505
1506    /// Action on failure
1507    #[serde(default = "default_on_failure")]
1508    pub on_failure: FailureAction,
1509}
1510
1511fn default_on_failure() -> FailureAction {
1512    FailureAction::Fail
1513}
1514
1515/// Init action parameters
1516pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
1517
1518/// Failure action for init steps
1519#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1520#[serde(rename_all = "lowercase")]
1521pub enum FailureAction {
1522    Fail,
1523    Warn,
1524    Continue,
1525}
1526
1527/// Error handling policies
1528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1529#[serde(deny_unknown_fields)]
1530#[derive(Default)]
1531pub struct ErrorsSpec {
1532    /// Init failure policy
1533    #[serde(default)]
1534    pub on_init_failure: InitFailurePolicy,
1535
1536    /// Panic/restart policy
1537    #[serde(default)]
1538    pub on_panic: PanicPolicy,
1539}
1540
1541/// Init failure policy
1542#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1543#[serde(deny_unknown_fields)]
1544pub struct InitFailurePolicy {
1545    #[serde(default = "default_init_action")]
1546    pub action: InitFailureAction,
1547}
1548
1549impl Default for InitFailurePolicy {
1550    fn default() -> Self {
1551        Self {
1552            action: default_init_action(),
1553        }
1554    }
1555}
1556
1557fn default_init_action() -> InitFailureAction {
1558    InitFailureAction::Fail
1559}
1560
1561/// Init failure action
1562#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1563#[serde(rename_all = "lowercase")]
1564pub enum InitFailureAction {
1565    Fail,
1566    Restart,
1567    Backoff,
1568}
1569
1570/// Panic policy
1571#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1572#[serde(deny_unknown_fields)]
1573pub struct PanicPolicy {
1574    #[serde(default = "default_panic_action")]
1575    pub action: PanicAction,
1576}
1577
1578impl Default for PanicPolicy {
1579    fn default() -> Self {
1580        Self {
1581            action: default_panic_action(),
1582        }
1583    }
1584}
1585
1586fn default_panic_action() -> PanicAction {
1587    PanicAction::Restart
1588}
1589
1590/// Panic action
1591#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1592#[serde(rename_all = "lowercase")]
1593pub enum PanicAction {
1594    Restart,
1595    Shutdown,
1596    Isolate,
1597}
1598
1599// ==========================================================================
1600// Network / Access Control types
1601// ==========================================================================
1602
1603/// A network policy defines an access control group with membership rules
1604/// and service access policies.
1605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1606pub struct NetworkPolicySpec {
1607    /// Unique network name.
1608    pub name: String,
1609
1610    /// Human-readable description.
1611    #[serde(default, skip_serializing_if = "Option::is_none")]
1612    pub description: Option<String>,
1613
1614    /// CIDR ranges that belong to this network (e.g., "10.200.0.0/16", "192.168.1.0/24").
1615    #[serde(default)]
1616    pub cidrs: Vec<String>,
1617
1618    /// Named members (users, groups, nodes) of this network.
1619    #[serde(default)]
1620    pub members: Vec<NetworkMember>,
1621
1622    /// Access rules defining which services this network can reach.
1623    #[serde(default)]
1624    pub access_rules: Vec<AccessRule>,
1625}
1626
1627/// A member of a network.
1628#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1629pub struct NetworkMember {
1630    /// Member identifier (username, group name, node ID, or CIDR).
1631    pub name: String,
1632    /// Type of member.
1633    #[serde(default)]
1634    pub kind: MemberKind,
1635}
1636
1637/// Type of network member.
1638#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1639#[serde(rename_all = "lowercase")]
1640pub enum MemberKind {
1641    /// An individual user identity.
1642    #[default]
1643    User,
1644    /// A group of users.
1645    Group,
1646    /// A specific cluster node.
1647    Node,
1648    /// A CIDR range (redundant with NetworkPolicySpec.cidrs but allows per-member CIDR).
1649    Cidr,
1650}
1651
1652/// An access rule determining what a network can reach.
1653#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1654pub struct AccessRule {
1655    /// Target service name, or "*" for all services.
1656    #[serde(default = "wildcard")]
1657    pub service: String,
1658
1659    /// Target deployment name, or "*" for all deployments.
1660    #[serde(default = "wildcard")]
1661    pub deployment: String,
1662
1663    /// Specific ports allowed. None means all ports.
1664    #[serde(default, skip_serializing_if = "Option::is_none")]
1665    pub ports: Option<Vec<u16>>,
1666
1667    /// Whether to allow or deny access.
1668    #[serde(default)]
1669    pub action: AccessAction,
1670}
1671
1672fn wildcard() -> String {
1673    "*".to_string()
1674}
1675
1676/// Access control action.
1677#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1678#[serde(rename_all = "lowercase")]
1679pub enum AccessAction {
1680    /// Allow access (default).
1681    #[default]
1682    Allow,
1683    /// Deny access.
1684    Deny,
1685}
1686
1687#[cfg(test)]
1688mod tests {
1689    use super::*;
1690
1691    #[test]
1692    fn test_parse_simple_spec() {
1693        let yaml = r"
1694version: v1
1695deployment: test
1696services:
1697  hello:
1698    rtype: service
1699    image:
1700      name: hello-world:latest
1701    endpoints:
1702      - name: http
1703        protocol: http
1704        port: 8080
1705        expose: public
1706";
1707
1708        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1709        assert_eq!(spec.version, "v1");
1710        assert_eq!(spec.deployment, "test");
1711        assert!(spec.services.contains_key("hello"));
1712    }
1713
1714    #[test]
1715    fn test_parse_duration() {
1716        let yaml = r"
1717version: v1
1718deployment: test
1719services:
1720  test:
1721    rtype: service
1722    image:
1723      name: test:latest
1724    health:
1725      timeout: 30s
1726      interval: 1m
1727      start_grace: 5s
1728      check:
1729        type: tcp
1730        port: 8080
1731";
1732
1733        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1734        let health = &spec.services["test"].health;
1735        assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
1736        assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
1737        assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
1738        match &health.check {
1739            HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
1740            _ => panic!("Expected TCP health check"),
1741        }
1742    }
1743
1744    #[test]
1745    fn test_parse_adaptive_scale() {
1746        let yaml = r"
1747version: v1
1748deployment: test
1749services:
1750  test:
1751    rtype: service
1752    image:
1753      name: test:latest
1754    scale:
1755      mode: adaptive
1756      min: 2
1757      max: 10
1758      cooldown: 15s
1759      targets:
1760        cpu: 70
1761        rps: 800
1762";
1763
1764        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1765        let scale = &spec.services["test"].scale;
1766        match scale {
1767            ScaleSpec::Adaptive {
1768                min,
1769                max,
1770                cooldown,
1771                targets,
1772            } => {
1773                assert_eq!(*min, 2);
1774                assert_eq!(*max, 10);
1775                assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
1776                assert_eq!(targets.cpu, Some(70));
1777                assert_eq!(targets.rps, Some(800));
1778            }
1779            _ => panic!("Expected Adaptive scale mode"),
1780        }
1781    }
1782
1783    #[test]
1784    fn test_node_mode_default() {
1785        let yaml = r"
1786version: v1
1787deployment: test
1788services:
1789  hello:
1790    rtype: service
1791    image:
1792      name: hello-world:latest
1793";
1794
1795        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1796        assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
1797        assert!(spec.services["hello"].node_selector.is_none());
1798    }
1799
1800    #[test]
1801    fn test_node_mode_dedicated() {
1802        let yaml = r"
1803version: v1
1804deployment: test
1805services:
1806  api:
1807    rtype: service
1808    image:
1809      name: api:latest
1810    node_mode: dedicated
1811";
1812
1813        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1814        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1815    }
1816
1817    #[test]
1818    fn test_node_mode_exclusive() {
1819        let yaml = r"
1820version: v1
1821deployment: test
1822services:
1823  database:
1824    rtype: service
1825    image:
1826      name: postgres:15
1827    node_mode: exclusive
1828";
1829
1830        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1831        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1832    }
1833
1834    #[test]
1835    fn test_node_selector_with_labels() {
1836        let yaml = r#"
1837version: v1
1838deployment: test
1839services:
1840  ml-worker:
1841    rtype: service
1842    image:
1843      name: ml-worker:latest
1844    node_mode: dedicated
1845    node_selector:
1846      labels:
1847        gpu: "true"
1848        zone: us-east
1849      prefer_labels:
1850        storage: ssd
1851"#;
1852
1853        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1854        let service = &spec.services["ml-worker"];
1855        assert_eq!(service.node_mode, NodeMode::Dedicated);
1856
1857        let selector = service.node_selector.as_ref().unwrap();
1858        assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
1859        assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
1860        assert_eq!(
1861            selector.prefer_labels.get("storage"),
1862            Some(&"ssd".to_string())
1863        );
1864    }
1865
1866    #[test]
1867    fn test_node_mode_serialization_roundtrip() {
1868        use serde_json;
1869
1870        // Test all variants serialize/deserialize correctly
1871        let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
1872        let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
1873
1874        for (mode, expected) in modes.iter().zip(expected_json.iter()) {
1875            let json = serde_json::to_string(mode).unwrap();
1876            assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
1877
1878            let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
1879            assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
1880        }
1881    }
1882
1883    #[test]
1884    fn test_node_selector_empty() {
1885        let yaml = r"
1886version: v1
1887deployment: test
1888services:
1889  api:
1890    rtype: service
1891    image:
1892      name: api:latest
1893    node_selector:
1894      labels: {}
1895";
1896
1897        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1898        let selector = spec.services["api"].node_selector.as_ref().unwrap();
1899        assert!(selector.labels.is_empty());
1900        assert!(selector.prefer_labels.is_empty());
1901    }
1902
1903    #[test]
1904    fn test_mixed_node_modes_in_deployment() {
1905        let yaml = r"
1906version: v1
1907deployment: test
1908services:
1909  redis:
1910    rtype: service
1911    image:
1912      name: redis:alpine
1913    # Default shared mode
1914  api:
1915    rtype: service
1916    image:
1917      name: api:latest
1918    node_mode: dedicated
1919  database:
1920    rtype: service
1921    image:
1922      name: postgres:15
1923    node_mode: exclusive
1924    node_selector:
1925      labels:
1926        storage: ssd
1927";
1928
1929        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1930        assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
1931        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
1932        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
1933
1934        let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
1935        assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
1936    }
1937
1938    #[test]
1939    fn test_storage_bind_mount() {
1940        let yaml = r"
1941version: v1
1942deployment: test
1943services:
1944  app:
1945    image:
1946      name: app:latest
1947    storage:
1948      - type: bind
1949        source: /host/data
1950        target: /app/data
1951        readonly: true
1952";
1953        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1954        let storage = &spec.services["app"].storage;
1955        assert_eq!(storage.len(), 1);
1956        match &storage[0] {
1957            StorageSpec::Bind {
1958                source,
1959                target,
1960                readonly,
1961            } => {
1962                assert_eq!(source, "/host/data");
1963                assert_eq!(target, "/app/data");
1964                assert!(*readonly);
1965            }
1966            _ => panic!("Expected Bind storage"),
1967        }
1968    }
1969
1970    #[test]
1971    fn test_storage_named_with_tier() {
1972        let yaml = r"
1973version: v1
1974deployment: test
1975services:
1976  app:
1977    image:
1978      name: app:latest
1979    storage:
1980      - type: named
1981        name: my-data
1982        target: /app/data
1983        tier: cached
1984";
1985        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
1986        let storage = &spec.services["app"].storage;
1987        match &storage[0] {
1988            StorageSpec::Named {
1989                name, target, tier, ..
1990            } => {
1991                assert_eq!(name, "my-data");
1992                assert_eq!(target, "/app/data");
1993                assert_eq!(*tier, StorageTier::Cached);
1994            }
1995            _ => panic!("Expected Named storage"),
1996        }
1997    }
1998
1999    #[test]
2000    fn test_storage_anonymous() {
2001        let yaml = r"
2002version: v1
2003deployment: test
2004services:
2005  app:
2006    image:
2007      name: app:latest
2008    storage:
2009      - type: anonymous
2010        target: /app/cache
2011";
2012        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2013        let storage = &spec.services["app"].storage;
2014        match &storage[0] {
2015            StorageSpec::Anonymous { target, tier } => {
2016                assert_eq!(target, "/app/cache");
2017                assert_eq!(*tier, StorageTier::Local); // default
2018            }
2019            _ => panic!("Expected Anonymous storage"),
2020        }
2021    }
2022
2023    #[test]
2024    fn test_storage_tmpfs() {
2025        let yaml = r"
2026version: v1
2027deployment: test
2028services:
2029  app:
2030    image:
2031      name: app:latest
2032    storage:
2033      - type: tmpfs
2034        target: /app/tmp
2035        size: 256Mi
2036        mode: 1777
2037";
2038        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2039        let storage = &spec.services["app"].storage;
2040        match &storage[0] {
2041            StorageSpec::Tmpfs { target, size, mode } => {
2042                assert_eq!(target, "/app/tmp");
2043                assert_eq!(size.as_deref(), Some("256Mi"));
2044                assert_eq!(*mode, Some(1777));
2045            }
2046            _ => panic!("Expected Tmpfs storage"),
2047        }
2048    }
2049
2050    #[test]
2051    fn test_storage_s3() {
2052        let yaml = r"
2053version: v1
2054deployment: test
2055services:
2056  app:
2057    image:
2058      name: app:latest
2059    storage:
2060      - type: s3
2061        bucket: my-bucket
2062        prefix: models/
2063        target: /app/models
2064        readonly: true
2065        endpoint: https://s3.us-west-2.amazonaws.com
2066        credentials: aws-creds
2067";
2068        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2069        let storage = &spec.services["app"].storage;
2070        match &storage[0] {
2071            StorageSpec::S3 {
2072                bucket,
2073                prefix,
2074                target,
2075                readonly,
2076                endpoint,
2077                credentials,
2078            } => {
2079                assert_eq!(bucket, "my-bucket");
2080                assert_eq!(prefix.as_deref(), Some("models/"));
2081                assert_eq!(target, "/app/models");
2082                assert!(*readonly);
2083                assert_eq!(
2084                    endpoint.as_deref(),
2085                    Some("https://s3.us-west-2.amazonaws.com")
2086                );
2087                assert_eq!(credentials.as_deref(), Some("aws-creds"));
2088            }
2089            _ => panic!("Expected S3 storage"),
2090        }
2091    }
2092
2093    #[test]
2094    fn test_storage_multiple_types() {
2095        let yaml = r"
2096version: v1
2097deployment: test
2098services:
2099  app:
2100    image:
2101      name: app:latest
2102    storage:
2103      - type: bind
2104        source: /etc/config
2105        target: /app/config
2106        readonly: true
2107      - type: named
2108        name: app-data
2109        target: /app/data
2110      - type: tmpfs
2111        target: /app/tmp
2112";
2113        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2114        let storage = &spec.services["app"].storage;
2115        assert_eq!(storage.len(), 3);
2116        assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
2117        assert!(matches!(&storage[1], StorageSpec::Named { .. }));
2118        assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
2119    }
2120
2121    #[test]
2122    fn test_storage_tier_default() {
2123        let yaml = r"
2124version: v1
2125deployment: test
2126services:
2127  app:
2128    image:
2129      name: app:latest
2130    storage:
2131      - type: named
2132        name: data
2133        target: /data
2134";
2135        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2136        match &spec.services["app"].storage[0] {
2137            StorageSpec::Named { tier, .. } => {
2138                assert_eq!(*tier, StorageTier::Local); // default should be Local
2139            }
2140            _ => panic!("Expected Named storage"),
2141        }
2142    }
2143
2144    // ==========================================================================
2145    // Tunnel configuration tests
2146    // ==========================================================================
2147
2148    #[test]
2149    fn test_endpoint_tunnel_config_basic() {
2150        let yaml = r"
2151version: v1
2152deployment: test
2153services:
2154  api:
2155    image:
2156      name: api:latest
2157    endpoints:
2158      - name: http
2159        protocol: http
2160        port: 8080
2161        tunnel:
2162          enabled: true
2163          remote_port: 8080
2164";
2165        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2166        let endpoint = &spec.services["api"].endpoints[0];
2167        let tunnel = endpoint.tunnel.as_ref().unwrap();
2168        assert!(tunnel.enabled);
2169        assert_eq!(tunnel.remote_port, 8080);
2170        assert!(tunnel.from.is_none());
2171        assert!(tunnel.to.is_none());
2172    }
2173
2174    #[test]
2175    fn test_endpoint_tunnel_config_full() {
2176        let yaml = r"
2177version: v1
2178deployment: test
2179services:
2180  api:
2181    image:
2182      name: api:latest
2183    endpoints:
2184      - name: http
2185        protocol: http
2186        port: 8080
2187        tunnel:
2188          enabled: true
2189          from: node-1
2190          to: ingress-node
2191          remote_port: 9000
2192          expose: public
2193          access:
2194            enabled: true
2195            max_ttl: 4h
2196            audit: true
2197";
2198        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2199        let endpoint = &spec.services["api"].endpoints[0];
2200        let tunnel = endpoint.tunnel.as_ref().unwrap();
2201        assert!(tunnel.enabled);
2202        assert_eq!(tunnel.from, Some("node-1".to_string()));
2203        assert_eq!(tunnel.to, Some("ingress-node".to_string()));
2204        assert_eq!(tunnel.remote_port, 9000);
2205        assert_eq!(tunnel.expose, Some(ExposeType::Public));
2206
2207        let access = tunnel.access.as_ref().unwrap();
2208        assert!(access.enabled);
2209        assert_eq!(access.max_ttl, Some("4h".to_string()));
2210        assert!(access.audit);
2211    }
2212
2213    #[test]
2214    fn test_top_level_tunnel_definition() {
2215        let yaml = r"
2216version: v1
2217deployment: test
2218services: {}
2219tunnels:
2220  db-tunnel:
2221    from: app-node
2222    to: db-node
2223    local_port: 5432
2224    remote_port: 5432
2225    protocol: tcp
2226    expose: internal
2227";
2228        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2229        let tunnel = spec.tunnels.get("db-tunnel").unwrap();
2230        assert_eq!(tunnel.from, "app-node");
2231        assert_eq!(tunnel.to, "db-node");
2232        assert_eq!(tunnel.local_port, 5432);
2233        assert_eq!(tunnel.remote_port, 5432);
2234        assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
2235        assert_eq!(tunnel.expose, ExposeType::Internal);
2236    }
2237
2238    #[test]
2239    fn test_top_level_tunnel_defaults() {
2240        let yaml = r"
2241version: v1
2242deployment: test
2243services: {}
2244tunnels:
2245  simple-tunnel:
2246    from: node-a
2247    to: node-b
2248    local_port: 3000
2249    remote_port: 3000
2250";
2251        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2252        let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
2253        assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); // default
2254        assert_eq!(tunnel.expose, ExposeType::Internal); // default
2255    }
2256
2257    #[test]
2258    fn test_tunnel_protocol_udp() {
2259        let yaml = r"
2260version: v1
2261deployment: test
2262services: {}
2263tunnels:
2264  udp-tunnel:
2265    from: node-a
2266    to: node-b
2267    local_port: 5353
2268    remote_port: 5353
2269    protocol: udp
2270";
2271        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2272        let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
2273        assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
2274    }
2275
2276    #[test]
2277    fn test_endpoint_without_tunnel() {
2278        let yaml = r"
2279version: v1
2280deployment: test
2281services:
2282  api:
2283    image:
2284      name: api:latest
2285    endpoints:
2286      - name: http
2287        protocol: http
2288        port: 8080
2289";
2290        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2291        let endpoint = &spec.services["api"].endpoints[0];
2292        assert!(endpoint.tunnel.is_none());
2293    }
2294
2295    #[test]
2296    fn test_deployment_without_tunnels() {
2297        let yaml = r"
2298version: v1
2299deployment: test
2300services:
2301  api:
2302    image:
2303      name: api:latest
2304";
2305        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2306        assert!(spec.tunnels.is_empty());
2307    }
2308
2309    // ==========================================================================
2310    // ApiSpec tests
2311    // ==========================================================================
2312
2313    #[test]
2314    fn test_spec_without_api_block_uses_defaults() {
2315        let yaml = r"
2316version: v1
2317deployment: test
2318services:
2319  hello:
2320    image:
2321      name: hello-world:latest
2322";
2323        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2324        assert!(spec.api.enabled);
2325        assert_eq!(spec.api.bind, "0.0.0.0:3669");
2326        assert!(spec.api.jwt_secret.is_none());
2327        assert!(spec.api.swagger);
2328    }
2329
2330    #[test]
2331    fn test_spec_with_explicit_api_block() {
2332        let yaml = r#"
2333version: v1
2334deployment: test
2335services:
2336  hello:
2337    image:
2338      name: hello-world:latest
2339api:
2340  enabled: false
2341  bind: "127.0.0.1:9090"
2342  jwt_secret: "my-secret"
2343  swagger: false
2344"#;
2345        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2346        assert!(!spec.api.enabled);
2347        assert_eq!(spec.api.bind, "127.0.0.1:9090");
2348        assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
2349        assert!(!spec.api.swagger);
2350    }
2351
2352    #[test]
2353    fn test_spec_with_partial_api_block() {
2354        let yaml = r#"
2355version: v1
2356deployment: test
2357services:
2358  hello:
2359    image:
2360      name: hello-world:latest
2361api:
2362  bind: "0.0.0.0:3000"
2363"#;
2364        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2365        assert!(spec.api.enabled); // default true
2366        assert_eq!(spec.api.bind, "0.0.0.0:3000");
2367        assert!(spec.api.jwt_secret.is_none()); // default None
2368        assert!(spec.api.swagger); // default true
2369    }
2370
2371    // ==========================================================================
2372    // NetworkPolicySpec tests
2373    // ==========================================================================
2374
2375    #[test]
2376    fn test_network_policy_spec_roundtrip() {
2377        let spec = NetworkPolicySpec {
2378            name: "corp-vpn".to_string(),
2379            description: Some("Corporate VPN network".to_string()),
2380            cidrs: vec!["10.200.0.0/16".to_string()],
2381            members: vec![
2382                NetworkMember {
2383                    name: "alice".to_string(),
2384                    kind: MemberKind::User,
2385                },
2386                NetworkMember {
2387                    name: "ops-team".to_string(),
2388                    kind: MemberKind::Group,
2389                },
2390                NetworkMember {
2391                    name: "node-01".to_string(),
2392                    kind: MemberKind::Node,
2393                },
2394            ],
2395            access_rules: vec![
2396                AccessRule {
2397                    service: "api-gateway".to_string(),
2398                    deployment: "*".to_string(),
2399                    ports: Some(vec![443, 8080]),
2400                    action: AccessAction::Allow,
2401                },
2402                AccessRule {
2403                    service: "*".to_string(),
2404                    deployment: "staging".to_string(),
2405                    ports: None,
2406                    action: AccessAction::Deny,
2407                },
2408            ],
2409        };
2410
2411        let yaml = serde_yaml::to_string(&spec).unwrap();
2412        let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
2413        assert_eq!(spec, deserialized);
2414    }
2415
2416    #[test]
2417    fn test_network_policy_spec_defaults() {
2418        let yaml = r"
2419name: minimal
2420";
2421        let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
2422        assert_eq!(spec.name, "minimal");
2423        assert!(spec.description.is_none());
2424        assert!(spec.cidrs.is_empty());
2425        assert!(spec.members.is_empty());
2426        assert!(spec.access_rules.is_empty());
2427    }
2428
2429    #[test]
2430    fn test_access_rule_defaults() {
2431        let yaml = "{}";
2432        let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
2433        assert_eq!(rule.service, "*");
2434        assert_eq!(rule.deployment, "*");
2435        assert!(rule.ports.is_none());
2436        assert_eq!(rule.action, AccessAction::Allow);
2437    }
2438
2439    #[test]
2440    fn test_member_kind_defaults_to_user() {
2441        let yaml = r"
2442name: bob
2443";
2444        let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
2445        assert_eq!(member.name, "bob");
2446        assert_eq!(member.kind, MemberKind::User);
2447    }
2448
2449    #[test]
2450    fn test_member_kind_variants() {
2451        for (input, expected) in [
2452            ("user", MemberKind::User),
2453            ("group", MemberKind::Group),
2454            ("node", MemberKind::Node),
2455            ("cidr", MemberKind::Cidr),
2456        ] {
2457            let yaml = format!("name: test\nkind: {input}");
2458            let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
2459            assert_eq!(member.kind, expected);
2460        }
2461    }
2462
2463    #[test]
2464    fn test_access_action_variants() {
2465        // Test via a wrapper struct since bare enums need a YAML tag
2466        #[derive(Debug, Deserialize)]
2467        struct Wrapper {
2468            action: AccessAction,
2469        }
2470
2471        let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
2472        let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
2473
2474        assert_eq!(allow.action, AccessAction::Allow);
2475        assert_eq!(deny.action, AccessAction::Deny);
2476    }
2477
2478    #[test]
2479    fn test_network_policy_spec_default_impl() {
2480        let spec = NetworkPolicySpec::default();
2481        assert_eq!(spec.name, "");
2482        assert!(spec.description.is_none());
2483        assert!(spec.cidrs.is_empty());
2484        assert!(spec.members.is_empty());
2485        assert!(spec.access_rules.is_empty());
2486    }
2487}