Skip to main content

zlayer_types/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/// Operating system a service needs to run on.
132///
133/// Mirrors the OS half of an OCI platform descriptor. Canonical wire strings
134/// match Go's `GOOS` values (e.g. `"linux"`, `"windows"`, `"darwin"`).
135#[derive(
136    Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
137)]
138#[serde(rename_all = "lowercase")]
139pub enum OsKind {
140    Linux,
141    Windows,
142    Macos,
143}
144
145impl OsKind {
146    /// Canonical OCI-style string (`"linux"` / `"windows"` / `"darwin"`).
147    /// This is the same convention `Runtime.platform_resolver` uses.
148    #[must_use]
149    pub const fn as_oci_str(self) -> &'static str {
150        match self {
151            OsKind::Linux => "linux",
152            OsKind::Windows => "windows",
153            OsKind::Macos => "darwin",
154        }
155    }
156
157    /// Detect from `std::env::consts::OS`. Unknown values return `None`.
158    #[must_use]
159    pub fn from_rust_os(s: &str) -> Option<Self> {
160        match s {
161            "linux" => Some(Self::Linux),
162            "windows" => Some(Self::Windows),
163            "macos" => Some(Self::Macos),
164            _ => None,
165        }
166    }
167
168    /// Parse the OCI-canonical OS string as written in an image manifest's
169    /// `config.os` field (lowercase: `"linux"` / `"windows"` / `"darwin"`).
170    /// Unknown or empty values return `None`.
171    ///
172    /// This is the inverse of [`Self::as_oci_str`] and is used by the
173    /// registry's manifest-OS inspection (see `fetch_image_os`).
174    #[must_use]
175    pub fn from_oci_str(s: &str) -> Option<Self> {
176        match s {
177            "linux" => Some(Self::Linux),
178            "windows" => Some(Self::Windows),
179            "darwin" => Some(Self::Macos),
180            _ => None,
181        }
182    }
183}
184
185/// CPU architecture a service needs. Mirrors the arch half of an OCI platform.
186#[derive(
187    Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
188)]
189#[serde(rename_all = "lowercase")]
190pub enum ArchKind {
191    Amd64,
192    Arm64,
193}
194
195impl ArchKind {
196    /// Canonical OCI-style string (`"amd64"` / `"arm64"`).
197    #[must_use]
198    pub const fn as_oci_str(self) -> &'static str {
199        match self {
200            ArchKind::Amd64 => "amd64",
201            ArchKind::Arm64 => "arm64",
202        }
203    }
204
205    /// Detect from `std::env::consts::ARCH`. Unknown values return `None`.
206    #[must_use]
207    pub fn from_rust_arch(s: &str) -> Option<Self> {
208        match s {
209            "x86_64" => Some(Self::Amd64),
210            "aarch64" => Some(Self::Arm64),
211            _ => None,
212        }
213    }
214}
215
216/// Platform a service targets. `None` on `ServiceSpec.platform` means
217/// "any agent is acceptable" (preserves backward compatibility).
218//
219// NOTE: no `Copy`. `os_version: Option<String>` rules it out. `OsKind` / `ArchKind`
220// are still `Copy`, so field-level borrows stay ergonomic.
221#[derive(
222    Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
223)]
224pub struct TargetPlatform {
225    pub os: OsKind,
226    pub arch: ArchKind,
227    /// Optional OS version constraint — primarily for Windows multi-platform
228    /// images, where `platform.os.version` in the OCI index distinguishes build
229    /// families (e.g. `10.0.26100.*` for Server 2025 / Win11 24H2,
230    /// `10.0.20348.*` for Server 2022). When set on a Windows target the
231    /// registry platform resolver prefers manifest entries whose `os.version`
232    /// matches this value exactly or shares a `major.minor.build` prefix.
233    /// Unused on Linux/macOS platforms.
234    #[serde(default, rename = "osVersion", skip_serializing_if = "Option::is_none")]
235    pub os_version: Option<String>,
236}
237
238impl TargetPlatform {
239    #[must_use]
240    pub const fn new(os: OsKind, arch: ArchKind) -> Self {
241        Self {
242            os,
243            arch,
244            os_version: None,
245        }
246    }
247
248    /// Constrain the platform to a specific `os.version` string.
249    ///
250    /// Applies to Windows targets: the registry resolver matches manifest
251    /// entries whose `platform.os.version` equals this value or starts with it
252    /// (treated as a `major.minor.build` prefix). Has no effect on Linux/macOS.
253    #[must_use]
254    pub fn with_os_version(mut self, v: impl Into<String>) -> Self {
255        self.os_version = Some(v.into());
256        self
257    }
258
259    /// Canonical OCI-style string (`"linux/amd64"`, `"windows/arm64"`).
260    ///
261    /// Does NOT include `os_version` — use [`Self::as_detailed_str`] when the
262    /// version matters (e.g. for error/log messages that need to distinguish
263    /// between Windows build families).
264    #[must_use]
265    pub fn as_oci_str(self) -> String {
266        format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
267    }
268
269    /// Like [`Self::as_oci_str`] but appends ` (os.version=…)` when an
270    /// `os_version` constraint is set. Intended for diagnostics, not for
271    /// matching against manifest entries.
272    #[must_use]
273    pub fn as_detailed_str(&self) -> String {
274        match &self.os_version {
275            Some(v) => format!(
276                "{}/{} (os.version={v})",
277                self.os.as_oci_str(),
278                self.arch.as_oci_str()
279            ),
280            None => format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str()),
281        }
282    }
283}
284
285impl std::fmt::Display for TargetPlatform {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        write!(f, "{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
288    }
289}
290
291/// Explicit capability declarations for WASM modules.
292/// Controls which host interfaces are linked and available to the component.
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294#[serde(deny_unknown_fields)]
295#[allow(clippy::struct_excessive_bools)]
296pub struct WasmCapabilities {
297    /// Config interface access (zlayer:plugin/config)
298    #[serde(default = "default_true")]
299    pub config: bool,
300    /// Key-value storage access (zlayer:plugin/keyvalue)
301    #[serde(default = "default_true")]
302    pub keyvalue: bool,
303    /// Logging access (zlayer:plugin/logging)
304    #[serde(default = "default_true")]
305    pub logging: bool,
306    /// Secrets access (zlayer:plugin/secrets)
307    #[serde(default)]
308    pub secrets: bool,
309    /// Metrics emission (zlayer:plugin/metrics)
310    #[serde(default = "default_true")]
311    pub metrics: bool,
312    /// HTTP client for outgoing requests (wasi:http/outgoing-handler)
313    #[serde(default)]
314    pub http_client: bool,
315    /// WASI CLI access (args, env, stdio)
316    #[serde(default)]
317    pub cli: bool,
318    /// WASI filesystem access
319    #[serde(default)]
320    pub filesystem: bool,
321    /// WASI sockets access (TCP/UDP)
322    #[serde(default)]
323    pub sockets: bool,
324}
325
326impl Default for WasmCapabilities {
327    fn default() -> Self {
328        Self {
329            config: true,
330            keyvalue: true,
331            logging: true,
332            secrets: false,
333            metrics: true,
334            http_client: false,
335            cli: false,
336            filesystem: false,
337            sockets: false,
338        }
339    }
340}
341
342/// Pre-opened directory for WASM filesystem access
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
344#[serde(deny_unknown_fields)]
345pub struct WasmPreopen {
346    /// Host path to mount
347    pub source: String,
348    /// Guest path (visible to WASM module)
349    pub target: String,
350    /// Read-only access (default: false)
351    #[serde(default)]
352    pub readonly: bool,
353}
354
355/// Comprehensive configuration for all WASM service types.
356///
357/// Replaces the previous `WasmHttpConfig` with resource limits, capability
358/// declarations, networking controls, and storage configuration.
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
360#[serde(deny_unknown_fields)]
361#[allow(clippy::struct_excessive_bools)]
362pub struct WasmConfig {
363    // --- Instance Management ---
364    /// Minimum number of warm instances to keep ready
365    #[serde(default = "default_min_instances")]
366    pub min_instances: u32,
367    /// Maximum number of instances to scale to
368    #[serde(default = "default_max_instances")]
369    pub max_instances: u32,
370    /// Time before idle instances are terminated
371    #[serde(default = "default_idle_timeout", with = "duration::required")]
372    pub idle_timeout: std::time::Duration,
373    /// Maximum time for a single request
374    #[serde(default = "default_request_timeout", with = "duration::required")]
375    pub request_timeout: std::time::Duration,
376
377    // --- Resource Limits ---
378    /// Maximum linear memory (e.g., "64Mi", "256Mi")
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub max_memory: Option<String>,
381    /// Maximum fuel (instruction count limit, 0 = unlimited)
382    #[serde(default)]
383    pub max_fuel: u64,
384    /// Epoch interval for cooperative preemption
385    #[serde(
386        default,
387        skip_serializing_if = "Option::is_none",
388        with = "duration::option"
389    )]
390    pub epoch_interval: Option<std::time::Duration>,
391
392    // --- Capabilities ---
393    /// Explicit capability grants (overrides world defaults when restricting)
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub capabilities: Option<WasmCapabilities>,
396
397    // --- Networking ---
398    /// Allow outgoing HTTP requests (default: true)
399    #[serde(default = "default_true")]
400    pub allow_http_outgoing: bool,
401    /// Allowed outgoing HTTP hosts (empty = all allowed)
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub allowed_hosts: Vec<String>,
404    /// Allow raw TCP sockets (default: false)
405    #[serde(default)]
406    pub allow_tcp: bool,
407    /// Allow raw UDP sockets (default: false)
408    #[serde(default)]
409    pub allow_udp: bool,
410
411    // --- Storage ---
412    /// Pre-opened directories (host path -> guest path)
413    #[serde(default, skip_serializing_if = "Vec::is_empty")]
414    pub preopens: Vec<WasmPreopen>,
415    /// Enable KV store access (default: true)
416    #[serde(default = "default_true")]
417    pub kv_enabled: bool,
418    /// KV store namespace (default: service name)
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub kv_namespace: Option<String>,
421    /// KV store max value size in bytes (default: 1MB)
422    #[serde(default = "default_kv_max_value_size")]
423    pub kv_max_value_size: u64,
424
425    // --- Secrets ---
426    /// Secret names accessible to this WASM module
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub secrets: Vec<String>,
429
430    // --- Performance ---
431    /// Pre-compile on deploy to reduce cold start (default: true)
432    #[serde(default = "default_true")]
433    pub precompile: bool,
434}
435
436fn default_kv_max_value_size() -> u64 {
437    1_048_576 // 1MB
438}
439
440impl Default for WasmConfig {
441    fn default() -> Self {
442        Self {
443            min_instances: default_min_instances(),
444            max_instances: default_max_instances(),
445            idle_timeout: default_idle_timeout(),
446            request_timeout: default_request_timeout(),
447            max_memory: None,
448            max_fuel: 0,
449            epoch_interval: None,
450            capabilities: None,
451            allow_http_outgoing: true,
452            allowed_hosts: Vec::new(),
453            allow_tcp: false,
454            allow_udp: false,
455            preopens: Vec::new(),
456            kv_enabled: true,
457            kv_namespace: None,
458            kv_max_value_size: default_kv_max_value_size(),
459            secrets: Vec::new(),
460            precompile: true,
461        }
462    }
463}
464
465/// Configuration for WASM HTTP services with instance pooling
466#[deprecated(note = "Use WasmConfig instead")]
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
468#[serde(deny_unknown_fields)]
469pub struct WasmHttpConfig {
470    /// Minimum number of warm instances to keep ready
471    #[serde(default = "default_min_instances")]
472    pub min_instances: u32,
473    /// Maximum number of instances to scale to
474    #[serde(default = "default_max_instances")]
475    pub max_instances: u32,
476    /// Time before idle instances are terminated
477    #[serde(default = "default_idle_timeout", with = "duration::required")]
478    pub idle_timeout: std::time::Duration,
479    /// Maximum time for a single request
480    #[serde(default = "default_request_timeout", with = "duration::required")]
481    pub request_timeout: std::time::Duration,
482}
483
484fn default_min_instances() -> u32 {
485    0
486}
487
488fn default_max_instances() -> u32 {
489    10
490}
491
492fn default_idle_timeout() -> std::time::Duration {
493    std::time::Duration::from_secs(300)
494}
495
496fn default_request_timeout() -> std::time::Duration {
497    std::time::Duration::from_secs(30)
498}
499
500#[allow(deprecated)]
501impl Default for WasmHttpConfig {
502    fn default() -> Self {
503        Self {
504            min_instances: default_min_instances(),
505            max_instances: default_max_instances(),
506            idle_timeout: default_idle_timeout(),
507            request_timeout: default_request_timeout(),
508        }
509    }
510}
511
512#[allow(deprecated)]
513impl From<WasmHttpConfig> for WasmConfig {
514    fn from(old: WasmHttpConfig) -> Self {
515        Self {
516            min_instances: old.min_instances,
517            max_instances: old.max_instances,
518            idle_timeout: old.idle_timeout,
519            request_timeout: old.request_timeout,
520            ..Default::default()
521        }
522    }
523}
524
525impl ServiceType {
526    /// Returns true if this is any WASM service type
527    #[must_use]
528    pub fn is_wasm(&self) -> bool {
529        matches!(
530            self,
531            ServiceType::WasmHttp
532                | ServiceType::WasmPlugin
533                | ServiceType::WasmTransformer
534                | ServiceType::WasmAuthenticator
535                | ServiceType::WasmRateLimiter
536                | ServiceType::WasmMiddleware
537                | ServiceType::WasmRouter
538        )
539    }
540
541    /// Returns the default capabilities for this WASM service type.
542    /// Returns None for non-WASM types.
543    #[must_use]
544    pub fn default_wasm_capabilities(&self) -> Option<WasmCapabilities> {
545        match self {
546            ServiceType::WasmHttp | ServiceType::WasmRouter => Some(WasmCapabilities {
547                config: true,
548                keyvalue: true,
549                logging: true,
550                secrets: false,
551                metrics: false,
552                http_client: true,
553                cli: false,
554                filesystem: false,
555                sockets: false,
556            }),
557            ServiceType::WasmPlugin => Some(WasmCapabilities {
558                config: true,
559                keyvalue: true,
560                logging: true,
561                secrets: true,
562                metrics: true,
563                http_client: true,
564                cli: true,
565                filesystem: true,
566                sockets: false,
567            }),
568            ServiceType::WasmTransformer => Some(WasmCapabilities {
569                config: false,
570                keyvalue: false,
571                logging: true,
572                secrets: false,
573                metrics: false,
574                http_client: false,
575                cli: true,
576                filesystem: false,
577                sockets: false,
578            }),
579            ServiceType::WasmAuthenticator => Some(WasmCapabilities {
580                config: true,
581                keyvalue: false,
582                logging: true,
583                secrets: true,
584                metrics: false,
585                http_client: true,
586                cli: false,
587                filesystem: false,
588                sockets: false,
589            }),
590            ServiceType::WasmRateLimiter => Some(WasmCapabilities {
591                config: true,
592                keyvalue: true,
593                logging: true,
594                secrets: false,
595                metrics: true,
596                http_client: false,
597                cli: true,
598                filesystem: false,
599                sockets: false,
600            }),
601            ServiceType::WasmMiddleware => Some(WasmCapabilities {
602                config: true,
603                keyvalue: false,
604                logging: true,
605                secrets: false,
606                metrics: false,
607                http_client: true,
608                cli: false,
609                filesystem: false,
610                sockets: false,
611            }),
612            _ => None,
613        }
614    }
615}
616
617fn default_api_bind() -> String {
618    "0.0.0.0:3669".to_string()
619}
620
621/// API server configuration (embedded in deploy/up flows)
622#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
623pub struct ApiSpec {
624    /// Enable the API server (default: true)
625    #[serde(default = "default_true")]
626    pub enabled: bool,
627    /// Bind address (default: "0.0.0.0:3669")
628    #[serde(default = "default_api_bind")]
629    pub bind: String,
630    /// JWT secret (reads `ZLAYER_JWT_SECRET` env var if not set)
631    #[serde(default)]
632    pub jwt_secret: Option<String>,
633    /// Enable Swagger UI (default: true)
634    #[serde(default = "default_true")]
635    pub swagger: bool,
636}
637
638impl Default for ApiSpec {
639    fn default() -> Self {
640        Self {
641            enabled: true,
642            bind: default_api_bind(),
643            jwt_secret: None,
644            swagger: true,
645        }
646    }
647}
648
649/// Top-level deployment specification
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
651#[serde(deny_unknown_fields)]
652pub struct DeploymentSpec {
653    /// Spec version (must be "v1")
654    #[validate(custom(function = "crate::spec::validate::validate_version_wrapper"))]
655    pub version: String,
656
657    /// Deployment name (used for overlays, DNS)
658    #[validate(custom(function = "crate::spec::validate::validate_deployment_name_wrapper"))]
659    pub deployment: String,
660
661    /// Service definitions
662    #[serde(default)]
663    #[validate(nested)]
664    pub services: HashMap<String, ServiceSpec>,
665
666    /// External service definitions (proxy backends without containers)
667    ///
668    /// External services register static backend addresses with the proxy
669    /// for host/path-based routing without starting any containers.
670    /// Useful for proxying to services running outside of `ZLayer`
671    /// (e.g., on other machines reachable via VPN).
672    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
673    #[validate(nested)]
674    pub externals: HashMap<String, ExternalSpec>,
675
676    /// Top-level tunnel definitions (not tied to service endpoints)
677    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
678    pub tunnels: HashMap<String, TunnelDefinition>,
679
680    /// API server configuration (enabled by default)
681    #[serde(default)]
682    pub api: ApiSpec,
683}
684
685/// External service specification (proxy backend without a container)
686///
687/// Defines a service that is not managed by `ZLayer` but should be proxied
688/// through `ZLayer`'s reverse proxy. The proxy registers static backend
689/// addresses and routes traffic based on endpoint host/path matching.
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
691#[serde(deny_unknown_fields)]
692pub struct ExternalSpec {
693    /// Static backend addresses (e.g., `["100.64.1.5:8096", "192.168.1.10:8096"]`)
694    ///
695    /// These are the upstream addresses the proxy will forward traffic to.
696    /// At least one backend is required.
697    #[validate(length(min = 1, message = "at least one backend address is required"))]
698    pub backends: Vec<String>,
699
700    /// Endpoint definitions (proxy bindings)
701    ///
702    /// Defines how public/internal traffic is routed to this external service.
703    #[serde(default)]
704    #[validate(nested)]
705    pub endpoints: Vec<EndpointSpec>,
706
707    /// Health check configuration
708    ///
709    /// When specified, the proxy will health-check backends and remove
710    /// unhealthy ones from the rotation.
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    pub health: Option<HealthSpec>,
713}
714
715/// Top-level tunnel definition (not tied to a service endpoint)
716#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
717#[serde(deny_unknown_fields)]
718pub struct TunnelDefinition {
719    /// Source node
720    pub from: String,
721
722    /// Destination node
723    pub to: String,
724
725    /// Local port on source
726    pub local_port: u16,
727
728    /// Remote port on destination
729    pub remote_port: u16,
730
731    /// Protocol (tcp/udp, defaults to tcp)
732    #[serde(default)]
733    pub protocol: TunnelProtocol,
734
735    /// Exposure type (defaults to internal)
736    #[serde(default)]
737    pub expose: ExposeType,
738}
739
740/// Protocol for tunnel connections (tcp or udp only)
741#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
742#[serde(rename_all = "lowercase")]
743pub enum TunnelProtocol {
744    #[default]
745    Tcp,
746    Udp,
747}
748
749/// Log output configuration for services and jobs.
750#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
751pub struct LogsConfig {
752    /// Where to write logs: "disk" (default) or "memory"
753    #[serde(default = "default_logs_destination")]
754    pub destination: String,
755
756    /// Maximum log size in bytes (default: 100MB)
757    #[serde(default = "default_logs_max_size")]
758    pub max_size_bytes: u64,
759
760    /// Log retention in seconds (default: 7 days)
761    #[serde(default = "default_logs_retention")]
762    pub retention_secs: u64,
763}
764
765fn default_logs_destination() -> String {
766    "disk".to_string()
767}
768
769fn default_logs_max_size() -> u64 {
770    100 * 1024 * 1024 // 100MB
771}
772
773fn default_logs_retention() -> u64 {
774    7 * 24 * 60 * 60 // 7 days
775}
776
777impl Default for LogsConfig {
778    fn default() -> Self {
779        Self {
780            destination: default_logs_destination(),
781            max_size_bytes: default_logs_max_size(),
782            retention_secs: default_logs_retention(),
783        }
784    }
785}
786
787/// Network mode for a service container.
788///
789/// Mirrors Docker's `HostConfig.NetworkMode` semantics. Accepts both an
790/// enum-tagged form (e.g. `network_mode: { bridge: { name: my-net } }`) and a
791/// string form (e.g. `"host"`, `"bridge:my-net"`, `"container:abc123"`).
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
793#[serde(rename_all = "lowercase")]
794pub enum NetworkMode {
795    /// Default networking (overlay / bridge as configured by the platform).
796    #[default]
797    Default,
798    /// Share the host network namespace (Docker `--network host`).
799    Host,
800    /// Disable networking entirely (Docker `--network none`).
801    None,
802    /// Attach to a Docker bridge network. When `name` is `None`, uses the
803    /// default `bridge` network.
804    Bridge {
805        #[serde(default)]
806        name: Option<String>,
807    },
808    /// Attach to another container's network namespace
809    /// (Docker `--network container:<id>`).
810    Container { id: String },
811}
812
813/// String-or-enum deserializer for [`NetworkMode`].
814///
815/// Accepts the same strings Docker accepts on `HostConfig.NetworkMode`:
816/// `"default"`, `"host"`, `"none"`, `"bridge"`, `"bridge:<name>"`, and
817/// `"container:<id>"`. Also accepts the enum-tagged YAML/JSON form produced by
818/// the derived [`Serialize`] impl (e.g. `bridge: { name: my-net }`).
819fn deserialize_network_mode<'de, D>(deserializer: D) -> Result<NetworkMode, D::Error>
820where
821    D: serde::Deserializer<'de>,
822{
823    use serde::de::Error;
824
825    /// Inline mirror of [`NetworkMode`] used purely for the "object" form.
826    /// We re-deserialize the captured YAML/JSON value into this and then map
827    /// it back, which correctly drives `deserialize_enum` even when the input
828    /// originally came from a `deserialize_any` path.
829    #[derive(Deserialize)]
830    #[serde(rename_all = "lowercase")]
831    enum Inner {
832        Default,
833        Host,
834        None,
835        Bridge {
836            #[serde(default)]
837            name: Option<String>,
838        },
839        Container {
840            id: String,
841        },
842    }
843
844    impl From<Inner> for NetworkMode {
845        fn from(i: Inner) -> Self {
846            match i {
847                Inner::Default => Self::Default,
848                Inner::Host => Self::Host,
849                Inner::None => Self::None,
850                Inner::Bridge { name } => Self::Bridge { name },
851                Inner::Container { id } => Self::Container { id },
852            }
853        }
854    }
855
856    // Capture the input as a self-describing serde value so we can branch
857    // on whether it is a string (Docker-style) or an externally-tagged
858    // enum (`{ bridge: { name } }`-style).
859    let value = serde_yaml::Value::deserialize(deserializer)?;
860
861    if let Some(s) = value.as_str() {
862        return match s {
863            "default" => Ok(NetworkMode::Default),
864            "host" => Ok(NetworkMode::Host),
865            "none" => Ok(NetworkMode::None),
866            "bridge" => Ok(NetworkMode::Bridge { name: None }),
867            _ => {
868                if let Some(rest) = s.strip_prefix("bridge:") {
869                    if rest.is_empty() {
870                        Ok(NetworkMode::Bridge { name: None })
871                    } else {
872                        Ok(NetworkMode::Bridge {
873                            name: Some(rest.to_string()),
874                        })
875                    }
876                } else if let Some(rest) = s.strip_prefix("container:") {
877                    if rest.is_empty() {
878                        Err(D::Error::custom(
879                            "network mode \"container:<id>\" requires a non-empty id",
880                        ))
881                    } else {
882                        Ok(NetworkMode::Container {
883                            id: rest.to_string(),
884                        })
885                    }
886                } else {
887                    Err(D::Error::custom(format!("unknown network mode: {s}")))
888                }
889            }
890        };
891    }
892
893    let inner: Inner = serde_yaml::from_value(value).map_err(D::Error::custom)?;
894    Ok(NetworkMode::from(inner))
895}
896
897/// Per-process resource limit (Docker `--ulimit` style).
898#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
899#[serde(deny_unknown_fields)]
900pub struct UlimitSpec {
901    /// Soft limit.
902    #[serde(default)]
903    pub soft: i64,
904    /// Hard limit.
905    #[serde(default)]
906    pub hard: i64,
907}
908
909/// Per-service specification
910#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
911#[serde(from = "ServiceSpecCompat")]
912#[allow(clippy::struct_excessive_bools)]
913pub struct ServiceSpec {
914    /// Resource type (service, job, cron)
915    #[serde(default = "default_resource_type")]
916    pub rtype: ResourceType,
917
918    /// Cron schedule expression (only for rtype: cron)
919    /// Uses 7-field cron syntax: "sec min hour day-of-month month day-of-week year"
920    /// Examples:
921    ///   - "0 0 0 * * * *" (daily at midnight)
922    ///   - "0 */5 * * * * *" (every 5 minutes)
923    ///   - "0 0 12 * * MON-FRI *" (weekdays at noon)
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    #[validate(custom(function = "crate::spec::validate::validate_schedule_wrapper"))]
926    pub schedule: Option<String>,
927
928    /// Container image specification
929    #[validate(nested)]
930    pub image: ImageSpec,
931
932    /// Resource limits
933    #[serde(default)]
934    #[validate(nested)]
935    pub resources: ResourcesSpec,
936
937    /// Environment variables for the service
938    ///
939    /// Values can be:
940    /// - Plain strings: `"value"`
941    /// - Host env refs: `$E:VAR_NAME`
942    /// - Secret refs: `$S:secret-name` or `$S:@service/secret-name`
943    #[serde(default)]
944    pub env: HashMap<String, String>,
945
946    /// Command override (entrypoint, args, workdir)
947    #[serde(default)]
948    pub command: CommandSpec,
949
950    /// Network configuration
951    #[serde(default)]
952    pub network: ServiceNetworkSpec,
953
954    /// Endpoint definitions (proxy bindings)
955    #[serde(default)]
956    #[validate(nested)]
957    pub endpoints: Vec<EndpointSpec>,
958
959    /// Scaling configuration
960    #[serde(default)]
961    #[validate(custom(function = "crate::spec::validate::validate_scale_spec"))]
962    pub scale: ScaleSpec,
963
964    /// Dependency specifications
965    #[serde(default)]
966    pub depends: Vec<DependsSpec>,
967
968    /// Health check configuration
969    #[serde(default = "default_health")]
970    pub health: HealthSpec,
971
972    /// Init actions (pre-start lifecycle steps)
973    #[serde(default)]
974    pub init: InitSpec,
975
976    /// Error handling policies
977    #[serde(default)]
978    pub errors: ErrorsSpec,
979
980    /// Container lifecycle policy (e.g., delete-on-exit).
981    ///
982    /// Purely declarative on this type; downstream layers (agent / API /
983    /// scheduler) read this field to decide whether to clean up the
984    /// container record after termination.
985    #[serde(default)]
986    pub lifecycle: LifecycleSpec,
987
988    /// Device passthrough (e.g., /dev/kvm for VMs)
989    #[serde(default)]
990    pub devices: Vec<DeviceSpec>,
991
992    /// Storage mounts for the container
993    #[serde(default, skip_serializing_if = "Vec::is_empty")]
994    pub storage: Vec<StorageSpec>,
995
996    /// Host-to-container port mappings (Docker's `-p host:container/proto`).
997    ///
998    /// Each entry publishes a container port on the host. When `host_port` is
999    /// `None` (or zero), the daemon assigns an ephemeral host port.
1000    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1001    pub port_mappings: Vec<PortMapping>,
1002
1003    /// Linux capabilities to add (e.g., `SYS_ADMIN`, `NET_ADMIN`).
1004    ///
1005    /// Also accepts the Docker-compatible alias `cap_add` on input.
1006    #[serde(default, alias = "cap_add", skip_serializing_if = "Vec::is_empty")]
1007    pub capabilities: Vec<String>,
1008
1009    /// Linux capabilities to drop (Docker `--cap-drop`).
1010    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1011    pub cap_drop: Vec<String>,
1012
1013    /// Run container in privileged mode (all capabilities + all devices)
1014    #[serde(default)]
1015    pub privileged: bool,
1016
1017    /// Node allocation mode (shared, dedicated, exclusive)
1018    #[serde(default)]
1019    pub node_mode: NodeMode,
1020
1021    /// Node selection constraints (required/preferred labels)
1022    #[serde(default, skip_serializing_if = "Option::is_none")]
1023    pub node_selector: Option<NodeSelector>,
1024
1025    /// Target platform for this service. When `None` (default), the service is
1026    /// eligible to run on any agent regardless of OS/architecture. When `Some`,
1027    /// the scheduler will only place replicas on agents whose platform matches.
1028    #[serde(default, skip_serializing_if = "Option::is_none")]
1029    pub platform: Option<TargetPlatform>,
1030
1031    /// Service type (standard, `wasm_http`, `wasm_plugin`, etc.)
1032    #[serde(default)]
1033    pub service_type: ServiceType,
1034
1035    /// WASM configuration (used when `service_type` is any Wasm* variant)
1036    /// Also accepts the deprecated `wasm_http` key for backward compatibility.
1037    #[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
1038    pub wasm: Option<WasmConfig>,
1039
1040    /// Log output configuration. If not set, uses platform defaults.
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    pub logs: Option<LogsConfig>,
1043
1044    /// Use host networking (container shares host network namespace)
1045    ///
1046    /// When true, the container will NOT get its own network namespace.
1047    /// This is set programmatically via the `--host-network` CLI flag, not in YAML specs.
1048    #[serde(skip)]
1049    pub host_network: bool,
1050
1051    /// Container hostname (maps to Docker's `--hostname`).
1052    ///
1053    /// When set, the container's `/etc/hostname` and initial kernel hostname
1054    /// are configured to this value. Ignored when `host_network` is true
1055    /// (the container inherits the host's hostname).
1056    #[serde(default, skip_serializing_if = "Option::is_none")]
1057    pub hostname: Option<String>,
1058
1059    /// Additional DNS servers for the container (maps to Docker's `--dns`).
1060    ///
1061    /// Each entry must be a plausible IPv4 or IPv6 address. Forwarded to the
1062    /// container runtime as resolver addresses ahead of the platform defaults.
1063    /// Ignored when `host_network` is true.
1064    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1065    pub dns: Vec<String>,
1066
1067    /// Extra `hostname:ip` entries appended to `/etc/hosts` (maps to Docker's
1068    /// `--add-host`).
1069    ///
1070    /// Each entry must be in the form `"<hostname>:<ip>"`. The special literal
1071    /// `host-gateway` is accepted as the `<ip>` half (resolved by Docker /
1072    /// bollard to the host-visible gateway address, commonly used with
1073    /// `host.docker.internal:host-gateway`).
1074    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1075    pub extra_hosts: Vec<String>,
1076
1077    /// Container restart policy (Docker-style).
1078    ///
1079    /// Controls when the runtime should automatically restart the container
1080    /// after it exits. Maps to Docker's `HostConfig.RestartPolicy`. Named
1081    /// `ContainerRestartPolicy` to avoid colliding with `ZLayer`'s existing
1082    /// `PanicPolicy` (which controls post-panic behavior, not runtime-level
1083    /// restarts).
1084    #[serde(default, skip_serializing_if = "Option::is_none")]
1085    pub restart_policy: Option<ContainerRestartPolicy>,
1086
1087    /// Free-form key/value labels attached to the container
1088    /// (Docker `--label`).
1089    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1090    pub labels: HashMap<String, String>,
1091
1092    /// User and group override for the container's main process
1093    /// (Docker `--user uid:gid`).
1094    #[serde(default, skip_serializing_if = "Option::is_none")]
1095    pub user: Option<String>,
1096
1097    /// Signal sent to the container's main process to request a graceful
1098    /// shutdown (Docker `--stop-signal`). Accepts e.g. `"SIGTERM"` or `"15"`.
1099    #[serde(default, skip_serializing_if = "Option::is_none")]
1100    pub stop_signal: Option<String>,
1101
1102    /// Grace period to wait between the stop signal and a forced kill
1103    /// (Docker `--stop-timeout`).
1104    #[serde(
1105        default,
1106        with = "duration::option",
1107        skip_serializing_if = "Option::is_none"
1108    )]
1109    pub stop_grace_period: Option<std::time::Duration>,
1110
1111    /// Kernel sysctl overrides (Docker `--sysctl`).
1112    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1113    pub sysctls: HashMap<String, String>,
1114
1115    /// Per-process ulimits (Docker `--ulimit`).
1116    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1117    pub ulimits: HashMap<String, UlimitSpec>,
1118
1119    /// Security options such as `apparmor=...`, `seccomp=...`,
1120    /// `no-new-privileges:true` (Docker `--security-opt`).
1121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1122    pub security_opt: Vec<String>,
1123
1124    /// PID namespace mode (Docker `--pid`). Accepts e.g. `"host"` or
1125    /// `"container:<id>"`.
1126    #[serde(default, skip_serializing_if = "Option::is_none")]
1127    pub pid_mode: Option<String>,
1128
1129    /// IPC namespace mode (Docker `--ipc`). Accepts e.g. `"host"`,
1130    /// `"shareable"`, `"private"`, or `"container:<id>"`.
1131    #[serde(default, skip_serializing_if = "Option::is_none")]
1132    pub ipc_mode: Option<String>,
1133
1134    /// Network mode (Docker `--network`). Accepts both the enum-tagged form
1135    /// and the Docker-style strings (`"host"`, `"none"`, `"bridge"`,
1136    /// `"bridge:<name>"`, `"container:<id>"`).
1137    #[serde(default, deserialize_with = "deserialize_network_mode")]
1138    pub network_mode: NetworkMode,
1139
1140    /// Additional groups to add to the container process
1141    /// (Docker `--group-add`).
1142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1143    pub extra_groups: Vec<String>,
1144
1145    /// Mount the container's root filesystem read-only (Docker `--read-only`).
1146    #[serde(default)]
1147    pub read_only_root_fs: bool,
1148
1149    /// Run a Docker-supplied init process (PID 1) inside the container
1150    /// (Docker `--init`). Distinct from [`ServiceSpec::init`] which controls
1151    /// `ZLayer`'s pre-start init actions.
1152    #[serde(default, skip_serializing_if = "Option::is_none")]
1153    pub init_container: Option<bool>,
1154
1155    /// Allocate a TTY for the container's main process (Docker `--tty`,
1156    /// compose `tty: true`).
1157    #[serde(default)]
1158    pub tty: bool,
1159
1160    /// Keep STDIN open even when nothing is attached (Docker `--interactive`,
1161    /// compose `stdin_open: true`).
1162    #[serde(default)]
1163    pub stdin_open: bool,
1164
1165    /// User namespace mode (Docker `--userns`). Accepts e.g. `"host"` or
1166    /// a remap-spec name configured on the daemon.
1167    #[serde(default, skip_serializing_if = "Option::is_none")]
1168    pub userns_mode: Option<String>,
1169
1170    /// Cgroup parent path (Docker `--cgroup-parent`). When set, the runtime
1171    /// places the container under the given cgroup hierarchy.
1172    #[serde(default, skip_serializing_if = "Option::is_none")]
1173    pub cgroup_parent: Option<String>,
1174
1175    /// Container ports exposed but not published to the host (compose
1176    /// `expose:`). Each entry is a port string, optionally `port/proto`
1177    /// (e.g. `"3000"`, `"8080/tcp"`). Treated as documentation by the
1178    /// runtime; downstream networking layers may use this list to allow
1179    /// inter-service traffic without publishing to the host.
1180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1181    pub expose: Vec<String>,
1182}
1183
1184/// Deserialization shim for [`ServiceSpec`].
1185///
1186/// Mirrors `ServiceSpec`'s field shape so that the derived `Deserialize` impl
1187/// can pick up the YAML/JSON value, then [`From::from`] folds the deprecated
1188/// `host_network: bool` flag into the typed [`NetworkMode`] before handing the
1189/// finalized struct back to the caller.
1190#[derive(Deserialize)]
1191#[serde(deny_unknown_fields)]
1192#[allow(clippy::struct_excessive_bools)]
1193struct ServiceSpecCompat {
1194    #[serde(default = "default_resource_type")]
1195    rtype: ResourceType,
1196    #[serde(default)]
1197    schedule: Option<String>,
1198    image: ImageSpec,
1199    #[serde(default)]
1200    resources: ResourcesSpec,
1201    #[serde(default)]
1202    env: HashMap<String, String>,
1203    #[serde(default)]
1204    command: CommandSpec,
1205    #[serde(default)]
1206    network: ServiceNetworkSpec,
1207    #[serde(default)]
1208    endpoints: Vec<EndpointSpec>,
1209    #[serde(default)]
1210    scale: ScaleSpec,
1211    #[serde(default)]
1212    depends: Vec<DependsSpec>,
1213    #[serde(default = "default_health")]
1214    health: HealthSpec,
1215    #[serde(default)]
1216    init: InitSpec,
1217    #[serde(default)]
1218    errors: ErrorsSpec,
1219    #[serde(default)]
1220    lifecycle: LifecycleSpec,
1221    #[serde(default)]
1222    devices: Vec<DeviceSpec>,
1223    #[serde(default)]
1224    storage: Vec<StorageSpec>,
1225    #[serde(default)]
1226    port_mappings: Vec<PortMapping>,
1227    #[serde(default, alias = "cap_add")]
1228    capabilities: Vec<String>,
1229    #[serde(default)]
1230    cap_drop: Vec<String>,
1231    #[serde(default)]
1232    privileged: bool,
1233    #[serde(default)]
1234    node_mode: NodeMode,
1235    #[serde(default)]
1236    node_selector: Option<NodeSelector>,
1237    #[serde(default)]
1238    platform: Option<TargetPlatform>,
1239    #[serde(default)]
1240    service_type: ServiceType,
1241    #[serde(default, alias = "wasm_http")]
1242    wasm: Option<WasmConfig>,
1243    #[serde(default)]
1244    logs: Option<LogsConfig>,
1245    /// Backwards-compat shim: when `host_network: true` is present in the input,
1246    /// it is folded into `network_mode = NetworkMode::Host` during conversion.
1247    #[serde(default)]
1248    host_network: Option<bool>,
1249    #[serde(default)]
1250    hostname: Option<String>,
1251    #[serde(default)]
1252    dns: Vec<String>,
1253    #[serde(default)]
1254    extra_hosts: Vec<String>,
1255    #[serde(default)]
1256    restart_policy: Option<ContainerRestartPolicy>,
1257    #[serde(default)]
1258    labels: HashMap<String, String>,
1259    #[serde(default)]
1260    user: Option<String>,
1261    #[serde(default)]
1262    stop_signal: Option<String>,
1263    #[serde(default, with = "duration::option")]
1264    stop_grace_period: Option<std::time::Duration>,
1265    #[serde(default)]
1266    sysctls: HashMap<String, String>,
1267    #[serde(default)]
1268    ulimits: HashMap<String, UlimitSpec>,
1269    #[serde(default)]
1270    security_opt: Vec<String>,
1271    #[serde(default)]
1272    pid_mode: Option<String>,
1273    #[serde(default)]
1274    ipc_mode: Option<String>,
1275    #[serde(default, deserialize_with = "deserialize_network_mode")]
1276    network_mode: NetworkMode,
1277    #[serde(default)]
1278    extra_groups: Vec<String>,
1279    #[serde(default)]
1280    read_only_root_fs: bool,
1281    #[serde(default)]
1282    init_container: Option<bool>,
1283    #[serde(default)]
1284    tty: bool,
1285    #[serde(default)]
1286    stdin_open: bool,
1287    #[serde(default)]
1288    userns_mode: Option<String>,
1289    #[serde(default)]
1290    cgroup_parent: Option<String>,
1291    #[serde(default)]
1292    expose: Vec<String>,
1293}
1294
1295impl From<ServiceSpecCompat> for ServiceSpec {
1296    fn from(c: ServiceSpecCompat) -> Self {
1297        // If the deprecated `host_network: true` flag is set, fold it into
1298        // the typed network mode unless the caller already supplied a
1299        // non-default value. This keeps existing in-process callers and
1300        // any legacy YAML that still emits `host_network: true` working.
1301        let network_mode = match (c.host_network, &c.network_mode) {
1302            (Some(true), NetworkMode::Default) => NetworkMode::Host,
1303            _ => c.network_mode,
1304        };
1305        let host_network = c.host_network.unwrap_or(false) || network_mode == NetworkMode::Host;
1306
1307        Self {
1308            rtype: c.rtype,
1309            schedule: c.schedule,
1310            image: c.image,
1311            resources: c.resources,
1312            env: c.env,
1313            command: c.command,
1314            network: c.network,
1315            endpoints: c.endpoints,
1316            scale: c.scale,
1317            depends: c.depends,
1318            health: c.health,
1319            init: c.init,
1320            errors: c.errors,
1321            lifecycle: c.lifecycle,
1322            devices: c.devices,
1323            storage: c.storage,
1324            port_mappings: c.port_mappings,
1325            capabilities: c.capabilities,
1326            cap_drop: c.cap_drop,
1327            privileged: c.privileged,
1328            node_mode: c.node_mode,
1329            node_selector: c.node_selector,
1330            platform: c.platform,
1331            service_type: c.service_type,
1332            wasm: c.wasm,
1333            logs: c.logs,
1334            host_network,
1335            hostname: c.hostname,
1336            dns: c.dns,
1337            extra_hosts: c.extra_hosts,
1338            restart_policy: c.restart_policy,
1339            labels: c.labels,
1340            user: c.user,
1341            stop_signal: c.stop_signal,
1342            stop_grace_period: c.stop_grace_period,
1343            sysctls: c.sysctls,
1344            ulimits: c.ulimits,
1345            security_opt: c.security_opt,
1346            pid_mode: c.pid_mode,
1347            ipc_mode: c.ipc_mode,
1348            network_mode,
1349            extra_groups: c.extra_groups,
1350            read_only_root_fs: c.read_only_root_fs,
1351            init_container: c.init_container,
1352            tty: c.tty,
1353            stdin_open: c.stdin_open,
1354            userns_mode: c.userns_mode,
1355            cgroup_parent: c.cgroup_parent,
1356            expose: c.expose,
1357        }
1358    }
1359}
1360
1361/// Command override specification (Section 5.5)
1362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1363#[serde(deny_unknown_fields)]
1364pub struct CommandSpec {
1365    /// Override image ENTRYPOINT
1366    #[serde(default, skip_serializing_if = "Option::is_none")]
1367    pub entrypoint: Option<Vec<String>>,
1368
1369    /// Override image CMD
1370    #[serde(default, skip_serializing_if = "Option::is_none")]
1371    pub args: Option<Vec<String>>,
1372
1373    /// Override working directory
1374    #[serde(default, skip_serializing_if = "Option::is_none")]
1375    pub workdir: Option<String>,
1376}
1377
1378fn default_resource_type() -> ResourceType {
1379    ResourceType::Service
1380}
1381
1382fn default_health() -> HealthSpec {
1383    HealthSpec {
1384        start_grace: Some(std::time::Duration::from_secs(5)),
1385        interval: None,
1386        timeout: None,
1387        retries: 3,
1388        check: HealthCheck::Tcp { port: 0 },
1389    }
1390}
1391
1392/// Resource type - determines container lifecycle
1393#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1394#[serde(rename_all = "lowercase")]
1395pub enum ResourceType {
1396    /// Long-running container, receives traffic, load-balanced
1397    Service,
1398    /// Run-to-completion, triggered by endpoint/CLI/internal system
1399    Job,
1400    /// Scheduled run-to-completion, time-triggered
1401    Cron,
1402}
1403
1404/// Container image specification
1405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1406#[serde(deny_unknown_fields)]
1407pub struct ImageSpec {
1408    /// Image name (e.g., "ghcr.io/org/api:latest")
1409    #[serde(with = "crate::image_ref_serde")]
1410    pub name: crate::ImageReference,
1411
1412    /// When to pull the image
1413    #[serde(default = "default_pull_policy")]
1414    pub pull_policy: PullPolicy,
1415}
1416
1417fn default_pull_policy() -> PullPolicy {
1418    PullPolicy::IfNotPresent
1419}
1420
1421/// Image pull policy
1422#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1423#[serde(rename_all = "snake_case")]
1424pub enum PullPolicy {
1425    /// Always pull the image, even if cached
1426    Always,
1427    /// Resolve remote digest; pull and recreate when it differs from local/running
1428    Newer,
1429    /// Pull only if not present locally
1430    IfNotPresent,
1431    /// Never pull, use local image only
1432    Never,
1433}
1434
1435/// Resolve the effective pull policy for a deploy/scale operation.
1436///
1437/// The serde default for `pull_policy` is `IfNotPresent` (preserved for
1438/// backwards compatibility). When the user has not opted out (i.e. the policy
1439/// is the default `IfNotPresent`) AND the image tag is `:latest` or unspecified,
1440/// we auto-upgrade to `Newer` so freshly-pushed `:latest` images get picked up
1441/// on redeploy. Users who explicitly want immutable behaviour on a `:latest`
1442/// tag can set `pull_policy: never`.
1443#[must_use]
1444pub fn effective_pull_policy(image: &crate::ImageReference, spec_policy: PullPolicy) -> PullPolicy {
1445    match spec_policy {
1446        PullPolicy::Always | PullPolicy::Never | PullPolicy::Newer => spec_policy,
1447        PullPolicy::IfNotPresent => {
1448            // Auto-upgrade IfNotPresent to Newer for :latest / no-tag images
1449            if image_is_latest_or_untagged(image) {
1450                PullPolicy::Newer
1451            } else {
1452                PullPolicy::IfNotPresent
1453            }
1454        }
1455    }
1456}
1457
1458fn image_is_latest_or_untagged(image: &crate::ImageReference) -> bool {
1459    // `ImageReference` is `oci_spec::distribution::Reference`, which exposes a
1460    // clean `tag()` accessor returning `Option<&str>`. No tag, or tag "latest",
1461    // is treated as the rolling case.
1462    match image.tag() {
1463        None => true,
1464        Some(tag) => tag == "latest",
1465    }
1466}
1467
1468/// Device passthrough specification
1469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate, utoipa::ToSchema)]
1470#[serde(deny_unknown_fields)]
1471pub struct DeviceSpec {
1472    /// Host device path (e.g., /dev/kvm, /dev/net/tun)
1473    #[validate(length(min = 1, message = "device path cannot be empty"))]
1474    pub path: String,
1475
1476    /// Allow read access
1477    #[serde(default = "default_true")]
1478    pub read: bool,
1479
1480    /// Allow write access
1481    #[serde(default = "default_true")]
1482    pub write: bool,
1483
1484    /// Allow mknod (create device nodes)
1485    #[serde(default)]
1486    pub mknod: bool,
1487}
1488
1489fn default_true() -> bool {
1490    true
1491}
1492
1493/// Storage mount specification
1494#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1495#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
1496pub enum StorageSpec {
1497    /// Bind mount from host path to container
1498    Bind {
1499        source: String,
1500        target: String,
1501        #[serde(default)]
1502        readonly: bool,
1503    },
1504    /// Named persistent storage volume
1505    Named {
1506        name: String,
1507        target: String,
1508        #[serde(default)]
1509        readonly: bool,
1510        /// Performance tier (default: local, SQLite-safe)
1511        #[serde(default)]
1512        tier: StorageTier,
1513        /// Optional size limit (e.g., "1Gi", "512Mi")
1514        #[serde(default, skip_serializing_if = "Option::is_none")]
1515        size: Option<String>,
1516    },
1517    /// Anonymous storage (auto-named, container lifecycle)
1518    Anonymous {
1519        target: String,
1520        /// Performance tier (default: local)
1521        #[serde(default)]
1522        tier: StorageTier,
1523    },
1524    /// Memory-backed tmpfs mount
1525    Tmpfs {
1526        target: String,
1527        #[serde(default)]
1528        size: Option<String>,
1529        #[serde(default)]
1530        mode: Option<u32>,
1531    },
1532    /// S3-backed FUSE mount
1533    S3 {
1534        bucket: String,
1535        #[serde(default)]
1536        prefix: Option<String>,
1537        target: String,
1538        #[serde(default)]
1539        readonly: bool,
1540        #[serde(default)]
1541        endpoint: Option<String>,
1542        #[serde(default)]
1543        credentials: Option<String>,
1544    },
1545}
1546
1547/// Resource limits (upper bounds, not reservations)
1548#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
1549#[serde(deny_unknown_fields)]
1550pub struct ResourcesSpec {
1551    /// CPU limit (cores, e.g., 0.5, 1, 2)
1552    #[serde(default)]
1553    #[validate(custom(function = "crate::spec::validate::validate_cpu_option_wrapper"))]
1554    pub cpu: Option<f64>,
1555
1556    /// Memory limit (e.g., "512Mi", "1Gi", "2Gi")
1557    #[serde(default)]
1558    #[validate(custom(function = "crate::spec::validate::validate_memory_option_wrapper"))]
1559    pub memory: Option<String>,
1560
1561    /// GPU resource request
1562    #[serde(default, skip_serializing_if = "Option::is_none")]
1563    pub gpu: Option<GpuSpec>,
1564
1565    /// Maximum number of processes the container may spawn
1566    /// (Docker `--pids-limit`).
1567    #[serde(default, skip_serializing_if = "Option::is_none")]
1568    pub pids_limit: Option<i64>,
1569
1570    /// CPUs that the container is allowed to execute on (Docker `--cpuset-cpus`).
1571    #[serde(default, skip_serializing_if = "Option::is_none")]
1572    pub cpuset: Option<String>,
1573
1574    /// Relative CPU shares (Docker `--cpu-shares`). Default weight is 1024.
1575    #[serde(default, skip_serializing_if = "Option::is_none")]
1576    pub cpu_shares: Option<u32>,
1577
1578    /// Total memory limit including swap (Docker `--memory-swap`).
1579    #[serde(default, skip_serializing_if = "Option::is_none")]
1580    pub memory_swap: Option<String>,
1581
1582    /// Soft memory limit (Docker `--memory-reservation`).
1583    #[serde(default, skip_serializing_if = "Option::is_none")]
1584    pub memory_reservation: Option<String>,
1585
1586    /// Container memory swappiness, 0-100 (Docker `--memory-swappiness`).
1587    #[serde(default, skip_serializing_if = "Option::is_none")]
1588    pub memory_swappiness: Option<u8>,
1589
1590    /// OOM-killer score adjustment (Docker `--oom-score-adj`).
1591    #[serde(default, skip_serializing_if = "Option::is_none")]
1592    pub oom_score_adj: Option<i32>,
1593
1594    /// Disable the OOM killer for the container (Docker `--oom-kill-disable`).
1595    #[serde(default, skip_serializing_if = "Option::is_none")]
1596    pub oom_kill_disable: Option<bool>,
1597
1598    /// Block IO weight, 10-1000 (Docker `--blkio-weight`).
1599    #[serde(default, skip_serializing_if = "Option::is_none")]
1600    pub blkio_weight: Option<u16>,
1601}
1602
1603/// Scheduling policy for GPU workloads
1604#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1605#[serde(rename_all = "kebab-case")]
1606pub enum SchedulingPolicy {
1607    /// Place as many replicas as possible; partial placement is acceptable (default)
1608    #[default]
1609    BestEffort,
1610    /// All replicas must be placed or none are; prevents partial GPU job deployment
1611    Gang,
1612    /// Spread replicas across nodes to maximize GPU distribution
1613    Spread,
1614}
1615
1616/// GPU sharing mode controlling how GPU resources are multiplexed.
1617#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1618#[serde(rename_all = "kebab-case")]
1619pub enum GpuSharingMode {
1620    /// Whole GPU per container (default). No sharing.
1621    #[default]
1622    Exclusive,
1623    /// NVIDIA Multi-Process Service: concurrent GPU compute sharing.
1624    /// Multiple containers run GPU kernels simultaneously with hardware isolation.
1625    Mps,
1626    /// NVIDIA time-slicing: round-robin GPU access across containers.
1627    /// Lower overhead than MPS but no concurrent execution.
1628    TimeSlice,
1629}
1630
1631/// Configuration for distributed GPU job coordination.
1632///
1633/// When enabled on a multi-replica GPU service, `ZLayer` injects standard
1634/// distributed training environment variables (`MASTER_ADDR`, `MASTER_PORT`,
1635/// `WORLD_SIZE`, `RANK`, `LOCAL_RANK`) so frameworks like `PyTorch`, `Horovod`,
1636/// and `DeepSpeed` can coordinate automatically.
1637#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1638#[serde(deny_unknown_fields)]
1639pub struct DistributedConfig {
1640    /// Communication backend: "nccl" (default), "gloo", or "mpi"
1641    #[serde(default = "default_dist_backend")]
1642    pub backend: String,
1643    /// Port for rank-0 master coordination (default: 29500)
1644    #[serde(default = "default_dist_port")]
1645    pub master_port: u16,
1646}
1647
1648fn default_dist_backend() -> String {
1649    "nccl".to_string()
1650}
1651
1652fn default_dist_port() -> u16 {
1653    29500
1654}
1655
1656/// GPU resource specification
1657///
1658/// Supported vendors:
1659/// - `nvidia` - NVIDIA GPUs via NVIDIA Container Toolkit (default)
1660/// - `amd` - AMD GPUs via `ROCm` (/dev/kfd + /dev/dri/renderD*)
1661/// - `intel` - Intel GPUs via VAAPI/i915 (/dev/dri/renderD*)
1662/// - `apple` - Apple Silicon GPUs via Metal/MPS (macOS only)
1663///
1664/// Unknown vendors fall back to DRI render node passthrough.
1665///
1666/// ## GPU mode (macOS only)
1667///
1668/// When `vendor` is `"apple"`, the `mode` field controls how GPU access is provided:
1669/// - `"native"` -- Seatbelt sandbox with direct Metal/MPS access (lowest overhead)
1670/// - `"vm"` -- libkrun micro-VM with GPU forwarding (stronger isolation)
1671/// - `None` (default) -- Auto-select based on platform and vendor
1672///
1673/// On Linux, `mode` is ignored; GPU passthrough always uses device node binding.
1674#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1675#[serde(deny_unknown_fields)]
1676pub struct GpuSpec {
1677    /// Number of GPUs to request
1678    #[serde(default = "default_gpu_count")]
1679    pub count: u32,
1680    /// GPU vendor (`nvidia`, `amd`, `intel`, `apple`) - defaults to `nvidia`
1681    #[serde(default = "default_gpu_vendor")]
1682    pub vendor: String,
1683    /// GPU access mode (macOS only): `"native"`, `"vm"`, or `None` for auto-select
1684    #[serde(default, skip_serializing_if = "Option::is_none")]
1685    pub mode: Option<String>,
1686    /// Pin to a specific GPU model (e.g. "A100", "H100").
1687    /// Substring match against detected GPU model names.
1688    #[serde(default, skip_serializing_if = "Option::is_none")]
1689    pub model: Option<String>,
1690    /// Scheduling policy for GPU workloads.
1691    /// - `best-effort` (default): place what fits
1692    /// - `gang`: all-or-nothing for distributed jobs
1693    /// - `spread`: distribute across nodes
1694    #[serde(default, skip_serializing_if = "Option::is_none")]
1695    pub scheduling: Option<SchedulingPolicy>,
1696    /// Distributed GPU job coordination.
1697    /// When set, injects `MASTER_ADDR`, `WORLD_SIZE`, `RANK`, `LOCAL_RANK` env vars.
1698    #[serde(default, skip_serializing_if = "Option::is_none")]
1699    pub distributed: Option<DistributedConfig>,
1700    /// GPU sharing mode: exclusive (default), mps, or time-slice.
1701    #[serde(default, skip_serializing_if = "Option::is_none")]
1702    pub sharing: Option<GpuSharingMode>,
1703}
1704
1705fn default_gpu_count() -> u32 {
1706    1
1707}
1708
1709fn default_gpu_vendor() -> String {
1710    "nvidia".to_string()
1711}
1712
1713/// Per-service network configuration (overlay + join policy).
1714#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1715#[serde(deny_unknown_fields)]
1716#[derive(Default)]
1717pub struct ServiceNetworkSpec {
1718    /// Overlay network configuration
1719    #[serde(default)]
1720    pub overlays: OverlayConfig,
1721
1722    /// Join policy (who can join this service)
1723    #[serde(default)]
1724    pub join: JoinPolicy,
1725}
1726
1727/// Overlay network configuration
1728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1729#[serde(deny_unknown_fields)]
1730pub struct OverlayConfig {
1731    /// Service-scoped overlay (service replicas only)
1732    #[serde(default)]
1733    pub service: OverlaySettings,
1734
1735    /// Global overlay (all services in deployment)
1736    #[serde(default)]
1737    pub global: OverlaySettings,
1738}
1739
1740impl Default for OverlayConfig {
1741    fn default() -> Self {
1742        Self {
1743            service: OverlaySettings {
1744                enabled: true,
1745                encrypted: true,
1746                isolated: true,
1747            },
1748            global: OverlaySettings {
1749                enabled: true,
1750                encrypted: true,
1751                isolated: false,
1752            },
1753        }
1754    }
1755}
1756
1757/// Overlay network settings
1758#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1759#[serde(deny_unknown_fields)]
1760pub struct OverlaySettings {
1761    /// Enable this overlay
1762    #[serde(default = "default_enabled")]
1763    pub enabled: bool,
1764
1765    /// Use encryption
1766    #[serde(default = "default_encrypted")]
1767    pub encrypted: bool,
1768
1769    /// Isolate from other services/groups
1770    #[serde(default)]
1771    pub isolated: bool,
1772}
1773
1774fn default_enabled() -> bool {
1775    true
1776}
1777
1778fn default_encrypted() -> bool {
1779    true
1780}
1781
1782/// Join policy - controls who can join a service
1783#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1784#[serde(deny_unknown_fields)]
1785pub struct JoinPolicy {
1786    /// Join mode
1787    #[serde(default = "default_join_mode")]
1788    pub mode: JoinMode,
1789
1790    /// Scope of join
1791    #[serde(default = "default_join_scope")]
1792    pub scope: JoinScope,
1793}
1794
1795impl Default for JoinPolicy {
1796    fn default() -> Self {
1797        Self {
1798            mode: default_join_mode(),
1799            scope: default_join_scope(),
1800        }
1801    }
1802}
1803
1804fn default_join_mode() -> JoinMode {
1805    JoinMode::Token
1806}
1807
1808fn default_join_scope() -> JoinScope {
1809    JoinScope::Service
1810}
1811
1812/// Join mode
1813#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1814#[serde(rename_all = "snake_case")]
1815pub enum JoinMode {
1816    /// Any trusted node in deployment can self-enroll
1817    Open,
1818    /// Requires a join key (recommended)
1819    Token,
1820    /// Only control-plane/scheduler can place replicas
1821    Closed,
1822}
1823
1824/// Join scope
1825#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1826#[serde(rename_all = "snake_case")]
1827pub enum JoinScope {
1828    /// Join this specific service
1829    Service,
1830    /// Join all services in deployment
1831    Global,
1832}
1833
1834/// Endpoint specification (proxy binding)
1835#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1836#[serde(deny_unknown_fields)]
1837pub struct EndpointSpec {
1838    /// Endpoint name (for routing)
1839    #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
1840    pub name: String,
1841
1842    /// Protocol
1843    pub protocol: Protocol,
1844
1845    /// Proxy listen port (external-facing port)
1846    #[validate(custom(function = "crate::spec::validate::validate_port_wrapper"))]
1847    pub port: u16,
1848
1849    /// Container port the service actually listens on.
1850    /// Defaults to `port` when not specified.
1851    #[serde(default, skip_serializing_if = "Option::is_none")]
1852    pub target_port: Option<u16>,
1853
1854    /// URL path prefix (for http/https/websocket)
1855    pub path: Option<String>,
1856
1857    /// Host pattern for routing (e.g. "api.example.com" or "*.example.com").
1858    /// `None` means match any host.
1859    #[serde(default, skip_serializing_if = "Option::is_none")]
1860    pub host: Option<String>,
1861
1862    /// Exposure type
1863    #[serde(default = "default_expose")]
1864    pub expose: ExposeType,
1865
1866    /// Optional stream (L4) proxy configuration
1867    /// Only applicable when protocol is tcp or udp
1868    #[serde(default, skip_serializing_if = "Option::is_none")]
1869    pub stream: Option<StreamEndpointConfig>,
1870
1871    /// Optional tunnel configuration for this endpoint
1872    #[serde(default, skip_serializing_if = "Option::is_none")]
1873    pub tunnel: Option<EndpointTunnelConfig>,
1874}
1875
1876impl EndpointSpec {
1877    /// Returns the port the container actually listens on.
1878    /// Falls back to `port` when `target_port` is not specified.
1879    #[must_use]
1880    pub fn target_port(&self) -> u16 {
1881        self.target_port.unwrap_or(self.port)
1882    }
1883}
1884
1885/// Tunnel configuration for an endpoint
1886#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1887#[serde(deny_unknown_fields)]
1888pub struct EndpointTunnelConfig {
1889    /// Enable tunneling for this endpoint
1890    #[serde(default)]
1891    pub enabled: bool,
1892
1893    /// Source node name (defaults to service's node)
1894    #[serde(default, skip_serializing_if = "Option::is_none")]
1895    pub from: Option<String>,
1896
1897    /// Destination node name (defaults to cluster ingress)
1898    #[serde(default, skip_serializing_if = "Option::is_none")]
1899    pub to: Option<String>,
1900
1901    /// Remote port to expose (0 = auto-assign)
1902    #[serde(default)]
1903    pub remote_port: u16,
1904
1905    /// Override exposure for tunnel (public/internal)
1906    #[serde(default, skip_serializing_if = "Option::is_none")]
1907    pub expose: Option<ExposeType>,
1908
1909    /// On-demand access configuration
1910    #[serde(default, skip_serializing_if = "Option::is_none")]
1911    pub access: Option<TunnelAccessConfig>,
1912}
1913
1914/// On-demand access settings for `zlayer tunnel access`
1915#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1916#[serde(deny_unknown_fields)]
1917pub struct TunnelAccessConfig {
1918    /// Allow on-demand access via CLI
1919    #[serde(default)]
1920    pub enabled: bool,
1921
1922    /// Maximum session duration (e.g., "4h", "30m")
1923    #[serde(default, skip_serializing_if = "Option::is_none")]
1924    pub max_ttl: Option<String>,
1925
1926    /// Log all access sessions
1927    #[serde(default)]
1928    pub audit: bool,
1929}
1930
1931fn default_expose() -> ExposeType {
1932    ExposeType::Internal
1933}
1934
1935/// Protocol type
1936#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1937#[serde(rename_all = "lowercase")]
1938pub enum Protocol {
1939    Http,
1940    Https,
1941    Tcp,
1942    Udp,
1943    Websocket,
1944}
1945
1946/// Exposure type
1947#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1948#[serde(rename_all = "lowercase")]
1949pub enum ExposeType {
1950    Public,
1951    #[default]
1952    Internal,
1953}
1954
1955/// Stream (L4) proxy configuration for TCP/UDP endpoints
1956#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1957#[serde(deny_unknown_fields)]
1958pub struct StreamEndpointConfig {
1959    /// Enable TLS termination for TCP (auto-provision cert)
1960    #[serde(default)]
1961    pub tls: bool,
1962
1963    /// Enable PROXY protocol for passing client IP
1964    #[serde(default)]
1965    pub proxy_protocol: bool,
1966
1967    /// Custom session timeout for UDP (default: 60s)
1968    /// Format: duration string like "60s", "5m"
1969    #[serde(default, skip_serializing_if = "Option::is_none")]
1970    pub session_timeout: Option<String>,
1971
1972    /// Health check configuration for L4
1973    #[serde(default, skip_serializing_if = "Option::is_none")]
1974    pub health_check: Option<StreamHealthCheck>,
1975}
1976
1977/// Health check types for stream (L4) endpoints
1978#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1979#[serde(tag = "type", rename_all = "snake_case")]
1980pub enum StreamHealthCheck {
1981    /// TCP connect check - verifies port is accepting connections
1982    TcpConnect,
1983    /// UDP probe - sends request and optionally validates response
1984    UdpProbe {
1985        /// Request payload to send (can use hex escapes like \\xFF)
1986        request: String,
1987        /// Expected response pattern (optional regex)
1988        #[serde(default, skip_serializing_if = "Option::is_none")]
1989        expect: Option<String>,
1990    },
1991}
1992
1993/// Scaling configuration
1994#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1995#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
1996pub enum ScaleSpec {
1997    /// Adaptive scaling with metrics
1998    #[serde(rename = "adaptive")]
1999    Adaptive {
2000        /// Minimum replicas
2001        min: u32,
2002
2003        /// Maximum replicas
2004        max: u32,
2005
2006        /// Cooldown period between scale events
2007        #[serde(default, with = "duration::option")]
2008        cooldown: Option<std::time::Duration>,
2009
2010        /// Target metrics for scaling
2011        #[serde(default)]
2012        targets: ScaleTargets,
2013    },
2014
2015    /// Fixed number of replicas
2016    #[serde(rename = "fixed")]
2017    Fixed { replicas: u32 },
2018
2019    /// Manual scaling (no automatic scaling)
2020    #[serde(rename = "manual")]
2021    Manual,
2022}
2023
2024impl Default for ScaleSpec {
2025    fn default() -> Self {
2026        Self::Adaptive {
2027            min: 1,
2028            max: 10,
2029            cooldown: Some(std::time::Duration::from_secs(30)),
2030            targets: ScaleTargets::default(),
2031        }
2032    }
2033}
2034
2035/// Target metrics for adaptive scaling
2036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2037#[serde(deny_unknown_fields)]
2038#[derive(Default)]
2039pub struct ScaleTargets {
2040    /// CPU percentage threshold (0-100)
2041    #[serde(default)]
2042    pub cpu: Option<u8>,
2043
2044    /// Memory percentage threshold (0-100)
2045    #[serde(default)]
2046    pub memory: Option<u8>,
2047
2048    /// Requests per second threshold
2049    #[serde(default)]
2050    pub rps: Option<u32>,
2051}
2052
2053/// Dependency specification
2054#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2055#[serde(deny_unknown_fields)]
2056pub struct DependsSpec {
2057    /// Service name to depend on
2058    pub service: String,
2059
2060    /// Condition for dependency
2061    #[serde(default = "default_condition")]
2062    pub condition: DependencyCondition,
2063
2064    /// Maximum time to wait
2065    #[serde(default = "default_timeout", with = "duration::option")]
2066    pub timeout: Option<std::time::Duration>,
2067
2068    /// Action on timeout
2069    #[serde(default = "default_on_timeout")]
2070    pub on_timeout: TimeoutAction,
2071}
2072
2073fn default_condition() -> DependencyCondition {
2074    DependencyCondition::Healthy
2075}
2076
2077#[allow(clippy::unnecessary_wraps)]
2078fn default_timeout() -> Option<std::time::Duration> {
2079    Some(std::time::Duration::from_secs(300))
2080}
2081
2082fn default_on_timeout() -> TimeoutAction {
2083    TimeoutAction::Fail
2084}
2085
2086/// Dependency condition
2087#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2088#[serde(rename_all = "lowercase")]
2089pub enum DependencyCondition {
2090    /// Container process exists
2091    Started,
2092    /// Health check passes
2093    Healthy,
2094    /// Service is available for routing
2095    Ready,
2096}
2097
2098/// Timeout action
2099#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2100#[serde(rename_all = "lowercase")]
2101pub enum TimeoutAction {
2102    Fail,
2103    Warn,
2104    Continue,
2105}
2106
2107/// Health check specification
2108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2109#[serde(deny_unknown_fields)]
2110pub struct HealthSpec {
2111    /// Grace period before first check
2112    #[serde(default, with = "duration::option")]
2113    pub start_grace: Option<std::time::Duration>,
2114
2115    /// Interval between checks
2116    #[serde(default, with = "duration::option")]
2117    pub interval: Option<std::time::Duration>,
2118
2119    /// Timeout per check
2120    #[serde(default, with = "duration::option")]
2121    pub timeout: Option<std::time::Duration>,
2122
2123    /// Number of retries before marking unhealthy
2124    #[serde(default = "default_retries")]
2125    pub retries: u32,
2126
2127    /// Health check type and parameters
2128    pub check: HealthCheck,
2129}
2130
2131fn default_retries() -> u32 {
2132    3
2133}
2134
2135/// Health check type
2136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2137#[serde(tag = "type", rename_all = "lowercase")]
2138pub enum HealthCheck {
2139    /// TCP port check
2140    Tcp {
2141        /// Port to check (0 = use first endpoint)
2142        port: u16,
2143    },
2144
2145    /// HTTP check
2146    Http {
2147        /// URL to check
2148        url: String,
2149        /// Expected status code
2150        #[serde(default = "default_expect_status")]
2151        expect_status: u16,
2152    },
2153
2154    /// Command check
2155    Command {
2156        /// Command to run
2157        command: String,
2158    },
2159}
2160
2161fn default_expect_status() -> u16 {
2162    200
2163}
2164
2165/// Init actions specification
2166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2167#[serde(deny_unknown_fields)]
2168#[derive(Default)]
2169pub struct InitSpec {
2170    /// Init steps to run before container starts
2171    #[serde(default)]
2172    pub steps: Vec<InitStep>,
2173}
2174
2175/// Lifecycle policy for service / job / cron containers.
2176///
2177/// Currently exposes a single `delete_on_exit` knob that, when `true`,
2178/// instructs higher layers to remove the container record (and its bundle)
2179/// once it has terminated. Other layers consume this field; this type is
2180/// purely descriptive.
2181#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
2182#[serde(deny_unknown_fields)]
2183pub struct LifecycleSpec {
2184    /// When true, terminated containers (and their bundles) are removed
2185    /// automatically rather than retained for inspection. Defaults to
2186    /// `false`, preserving the historical retain-on-exit behavior.
2187    #[serde(default)]
2188    pub delete_on_exit: bool,
2189}
2190
2191/// Init action step
2192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2193#[serde(deny_unknown_fields)]
2194pub struct InitStep {
2195    /// Step identifier
2196    pub id: String,
2197
2198    /// Action to perform (e.g., "`init.wait_tcp`")
2199    pub uses: String,
2200
2201    /// Parameters for the action
2202    #[serde(default)]
2203    pub with: InitParams,
2204
2205    /// Number of retries
2206    #[serde(default)]
2207    pub retry: Option<u32>,
2208
2209    /// Maximum time for this step
2210    #[serde(default, with = "duration::option")]
2211    pub timeout: Option<std::time::Duration>,
2212
2213    /// Action on failure
2214    #[serde(default = "default_on_failure")]
2215    pub on_failure: FailureAction,
2216}
2217
2218fn default_on_failure() -> FailureAction {
2219    FailureAction::Fail
2220}
2221
2222/// Init action parameters
2223pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
2224
2225/// Failure action for init steps
2226#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2227#[serde(rename_all = "lowercase")]
2228pub enum FailureAction {
2229    Fail,
2230    Warn,
2231    Continue,
2232}
2233
2234/// Error handling policies
2235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2236#[serde(deny_unknown_fields)]
2237#[derive(Default)]
2238pub struct ErrorsSpec {
2239    /// Init failure policy
2240    #[serde(default)]
2241    pub on_init_failure: InitFailurePolicy,
2242
2243    /// Panic/restart policy
2244    #[serde(default)]
2245    pub on_panic: PanicPolicy,
2246}
2247
2248/// Init failure policy
2249#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2250#[serde(deny_unknown_fields)]
2251pub struct InitFailurePolicy {
2252    #[serde(default = "default_init_action")]
2253    pub action: InitFailureAction,
2254}
2255
2256impl Default for InitFailurePolicy {
2257    fn default() -> Self {
2258        Self {
2259            action: default_init_action(),
2260        }
2261    }
2262}
2263
2264fn default_init_action() -> InitFailureAction {
2265    InitFailureAction::Fail
2266}
2267
2268/// Init failure action
2269#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2270#[serde(rename_all = "lowercase")]
2271pub enum InitFailureAction {
2272    Fail,
2273    Restart,
2274    Backoff,
2275}
2276
2277/// Panic policy
2278#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2279#[serde(deny_unknown_fields)]
2280pub struct PanicPolicy {
2281    #[serde(default = "default_panic_action")]
2282    pub action: PanicAction,
2283}
2284
2285impl Default for PanicPolicy {
2286    fn default() -> Self {
2287        Self {
2288            action: default_panic_action(),
2289        }
2290    }
2291}
2292
2293fn default_panic_action() -> PanicAction {
2294    PanicAction::Restart
2295}
2296
2297/// Panic action
2298#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2299#[serde(rename_all = "lowercase")]
2300pub enum PanicAction {
2301    Restart,
2302    Shutdown,
2303    Isolate,
2304}
2305
2306// ==========================================================================
2307// Network / Access Control types
2308// ==========================================================================
2309
2310/// A network policy defines an access control group with membership rules
2311/// and service access policies.
2312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
2313pub struct NetworkPolicySpec {
2314    /// Unique network name.
2315    pub name: String,
2316
2317    /// Human-readable description.
2318    #[serde(default, skip_serializing_if = "Option::is_none")]
2319    pub description: Option<String>,
2320
2321    /// CIDR ranges that belong to this network (e.g., "10.200.0.0/16", "192.168.1.0/24").
2322    #[serde(default)]
2323    pub cidrs: Vec<String>,
2324
2325    /// Named members (users, groups, nodes) of this network.
2326    #[serde(default)]
2327    pub members: Vec<NetworkMember>,
2328
2329    /// Access rules defining which services this network can reach.
2330    #[serde(default)]
2331    pub access_rules: Vec<AccessRule>,
2332}
2333
2334/// A member of a network.
2335#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2336pub struct NetworkMember {
2337    /// Member identifier (username, group name, node ID, or CIDR).
2338    pub name: String,
2339    /// Type of member.
2340    #[serde(default)]
2341    pub kind: MemberKind,
2342}
2343
2344/// Type of network member.
2345#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2346#[serde(rename_all = "lowercase")]
2347pub enum MemberKind {
2348    /// An individual user identity.
2349    #[default]
2350    User,
2351    /// A group of users.
2352    Group,
2353    /// A specific cluster node.
2354    Node,
2355    /// A CIDR range (redundant with NetworkPolicySpec.cidrs but allows per-member CIDR).
2356    Cidr,
2357}
2358
2359/// An access rule determining what a network can reach.
2360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2361pub struct AccessRule {
2362    /// Target service name, or "*" for all services.
2363    #[serde(default = "wildcard")]
2364    pub service: String,
2365
2366    /// Target deployment name, or "*" for all deployments.
2367    #[serde(default = "wildcard")]
2368    pub deployment: String,
2369
2370    /// Specific ports allowed. None means all ports.
2371    #[serde(default, skip_serializing_if = "Option::is_none")]
2372    pub ports: Option<Vec<u16>>,
2373
2374    /// Whether to allow or deny access.
2375    #[serde(default)]
2376    pub action: AccessAction,
2377}
2378
2379fn wildcard() -> String {
2380    "*".to_string()
2381}
2382
2383/// Access control action.
2384#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2385#[serde(rename_all = "lowercase")]
2386pub enum AccessAction {
2387    /// Allow access (default).
2388    #[default]
2389    Allow,
2390    /// Deny access.
2391    Deny,
2392}
2393
2394// ==========================================================================
2395// Container bridge / overlay network types (Docker-compatible)
2396// ==========================================================================
2397//
2398// These types model user-defined bridge or overlay networks that standalone
2399// containers can attach to — the Docker-style "docker network create" model.
2400// They are intentionally named `BridgeNetwork*` to avoid colliding with the
2401// CIDR-ACL `NetworkPolicySpec` types above, which model a completely
2402// different concept (access-control groups).
2403
2404/// A user-defined bridge or overlay network that containers can attach to.
2405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2406pub struct BridgeNetwork {
2407    /// Opaque server-generated identifier (UUID v4).
2408    pub id: String,
2409
2410    /// Human-readable, unique name (must match `^[a-z0-9][a-z0-9_-]{0,63}$`).
2411    pub name: String,
2412
2413    /// Driver backing the network (bridge vs. overlay).
2414    #[serde(default)]
2415    pub driver: BridgeNetworkDriver,
2416
2417    /// IPv4/IPv6 subnet in CIDR notation (e.g. `"10.240.0.0/24"`).
2418    #[serde(default, skip_serializing_if = "Option::is_none")]
2419    pub subnet: Option<String>,
2420
2421    /// Arbitrary key/value labels for filtering and grouping.
2422    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2423    pub labels: HashMap<String, String>,
2424
2425    /// If true, containers attached to this network cannot reach the outside
2426    /// world — only other containers on the same network.
2427    #[serde(default)]
2428    pub internal: bool,
2429
2430    /// Creation timestamp (UTC, RFC 3339).
2431    #[schema(value_type = String, format = "date-time")]
2432    pub created_at: chrono::DateTime<chrono::Utc>,
2433}
2434
2435/// Backing driver for a [`BridgeNetwork`].
2436#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2437#[serde(rename_all = "lowercase")]
2438pub enum BridgeNetworkDriver {
2439    /// Linux bridge on the local host (single-host, default).
2440    #[default]
2441    Bridge,
2442    /// Overlay network spanning multiple hosts.
2443    Overlay,
2444}
2445
2446/// A container attached to a [`BridgeNetwork`].
2447#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2448pub struct BridgeNetworkAttachment {
2449    /// Runtime-provided container id.
2450    pub container_id: String,
2451
2452    /// Container name, if known.
2453    #[serde(default, skip_serializing_if = "Option::is_none")]
2454    pub container_name: Option<String>,
2455
2456    /// DNS aliases the container can be reached by on this network.
2457    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2458    pub aliases: Vec<String>,
2459
2460    /// Assigned IPv4 address on the network (if any).
2461    #[serde(default, skip_serializing_if = "Option::is_none")]
2462    pub ipv4: Option<String>,
2463}
2464
2465// ==========================================================================
2466// Registry auth (inline, not persisted) — §3.10 of ZLAYER_SDK_FIXES.md
2467// ==========================================================================
2468//
2469// Inline credentials a client can attach to a single pull or container-create
2470// request without first POSTing them to `/api/v1/credentials/registry`. The
2471// daemon uses them exactly once — they are never logged, never persisted, and
2472// never echoed back on a response.
2473//
2474// For requests that instead want to reuse an already-stored credential, the
2475// `CreateContainerRequest` / `PullImageRequest` DTOs also accept a
2476// `registry_credential_id` pointing at the `RegistryCredentialStore`. Inline
2477// `RegistryAuth` takes precedence when both are provided.
2478
2479/// Inline Docker/OCI registry credentials attached to a single pull request.
2480///
2481/// Prefer persistent credentials via `/api/v1/credentials/registry` for
2482/// long-lived services. Use this inline form for one-off pulls (e.g. CI
2483/// runners fetching a private image for a single job) where persisting a
2484/// credential is undesirable.
2485#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2486pub struct RegistryAuth {
2487    /// Username for the registry (for basic auth) or a placeholder
2488    /// identifier when `auth_type == Token`.
2489    pub username: String,
2490    /// Password or bearer token. **Never** logged or returned on any
2491    /// response — consumed once and dropped.
2492    pub password: String,
2493    /// Which authentication scheme to use against the registry.
2494    #[serde(default = "default_registry_auth_type")]
2495    pub auth_type: RegistryAuthType,
2496}
2497
2498/// Authentication scheme for a [`RegistryAuth`].
2499#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2500#[serde(rename_all = "snake_case")]
2501pub enum RegistryAuthType {
2502    /// HTTP Basic authentication (username + password). Default.
2503    #[default]
2504    Basic,
2505    /// Bearer token authentication. `password` carries the token; `username`
2506    /// is typically a placeholder such as `"oauth2accesstoken"` or `"<token>"`.
2507    Token,
2508}
2509
2510/// Serde default for [`RegistryAuth::auth_type`]. Kept as a free function so
2511/// `#[serde(default = "...")]` can reference it.
2512#[must_use]
2513pub fn default_registry_auth_type() -> RegistryAuthType {
2514    RegistryAuthType::Basic
2515}
2516
2517// ==========================================================================
2518// Container restart policy (Docker-style) — §3.4 of ZLAYER_SDK_FIXES.md
2519// ==========================================================================
2520//
2521// Named `ContainerRestartPolicy` / `ContainerRestartKind` rather than
2522// `RestartPolicy` / `RestartKind` to avoid colliding with ZLayer's existing
2523// `PanicPolicy`/`PanicAction` types and to make the runtime-level (as opposed
2524// to panic-driven) nature of this policy explicit.
2525
2526/// Container-runtime-level restart policy.
2527///
2528/// Maps onto Docker's `HostConfig.RestartPolicy`. Distinct from
2529/// [`PanicPolicy`], which governs what `ZLayer` does in response to an
2530/// application panic (it does not set a Docker restart policy).
2531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2532#[serde(rename_all = "snake_case", deny_unknown_fields)]
2533pub struct ContainerRestartPolicy {
2534    /// Which restart policy to apply.
2535    pub kind: ContainerRestartKind,
2536
2537    /// For `on_failure` only: maximum number of restart attempts before
2538    /// giving up. Ignored by other kinds. `None` means "retry forever".
2539    #[serde(default, skip_serializing_if = "Option::is_none")]
2540    pub max_attempts: Option<u32>,
2541
2542    /// Humantime-formatted delay between restarts (e.g. `"500ms"`,
2543    /// `"2s"`). Accepted for forward-compatibility but currently ignored
2544    /// by the Docker backend: bollard's `RestartPolicy` has no per-kind
2545    /// delay field. When set, the runtime emits a warning.
2546    #[serde(default, skip_serializing_if = "Option::is_none")]
2547    pub delay: Option<String>,
2548}
2549
2550/// Which flavor of container restart policy to apply.
2551#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2552#[serde(rename_all = "snake_case")]
2553pub enum ContainerRestartKind {
2554    /// Never restart (Docker's `"no"`).
2555    No,
2556    /// Always restart (Docker's `"always"`).
2557    Always,
2558    /// Restart unless the user explicitly stopped the container
2559    /// (Docker's `"unless-stopped"`).
2560    UnlessStopped,
2561    /// Restart only when the container exits with a non-zero code
2562    /// (Docker's `"on-failure"`). Respects `max_attempts`.
2563    OnFailure,
2564}
2565
2566// ==========================================================================
2567// Port mappings (Docker-style container port publishing)
2568// ==========================================================================
2569
2570/// Transport protocol for a published container port.
2571#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2572#[serde(rename_all = "snake_case")]
2573pub enum PortProtocol {
2574    /// TCP (default).
2575    Tcp,
2576    /// UDP.
2577    Udp,
2578}
2579
2580impl Default for PortProtocol {
2581    fn default() -> Self {
2582        default_port_protocol()
2583    }
2584}
2585
2586impl PortProtocol {
2587    /// Return the lowercase string form Docker uses in port-binding keys
2588    /// (e.g. `"tcp"` or `"udp"`).
2589    #[must_use]
2590    pub fn as_str(&self) -> &'static str {
2591        match self {
2592            PortProtocol::Tcp => "tcp",
2593            PortProtocol::Udp => "udp",
2594        }
2595    }
2596}
2597
2598fn default_port_protocol() -> PortProtocol {
2599    PortProtocol::Tcp
2600}
2601
2602fn default_host_ip() -> String {
2603    "0.0.0.0".to_string()
2604}
2605
2606/// A single host-to-container port publish rule (Docker's `-p`).
2607///
2608/// When `host_port` is `None` (or explicitly `Some(0)`), the container runtime
2609/// assigns an ephemeral host port. `host_ip` defaults to `"0.0.0.0"` to bind
2610/// on all interfaces.
2611#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2612#[serde(rename_all = "snake_case")]
2613pub struct PortMapping {
2614    /// Host port. `None` (or zero) means "assign an ephemeral port".
2615    #[serde(default, skip_serializing_if = "Option::is_none")]
2616    pub host_port: Option<u16>,
2617    /// Container-side port.
2618    pub container_port: u16,
2619    /// Transport protocol (defaults to TCP).
2620    #[serde(default = "default_port_protocol")]
2621    pub protocol: PortProtocol,
2622    /// Host interface to bind on. Defaults to `"0.0.0.0"` (all interfaces).
2623    #[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
2624    pub host_ip: String,
2625}
2626
2627#[cfg(test)]
2628mod tests {
2629    use super::*;
2630
2631    #[test]
2632    fn port_mapping_defaults_via_serde() {
2633        // Minimal JSON: only container_port. host_port omitted, protocol defaults
2634        // to "tcp", host_ip defaults to "0.0.0.0".
2635        let json = r#"{"container_port": 8080}"#;
2636        let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
2637        assert_eq!(m.container_port, 8080);
2638        assert_eq!(m.host_port, None);
2639        assert_eq!(m.protocol, PortProtocol::Tcp);
2640        assert_eq!(m.host_ip, "0.0.0.0");
2641    }
2642
2643    #[test]
2644    fn port_mapping_skips_none_host_port_and_empty_host_ip() {
2645        let m = PortMapping {
2646            host_port: None,
2647            container_port: 443,
2648            protocol: PortProtocol::Tcp,
2649            host_ip: String::new(),
2650        };
2651        let s = serde_json::to_string(&m).expect("serialize");
2652        // host_port = None should be skipped, host_ip = "" should be skipped.
2653        assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
2654        assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
2655        assert!(s.contains("\"container_port\":443"));
2656        assert!(s.contains("\"protocol\":\"tcp\""));
2657    }
2658
2659    #[test]
2660    fn test_parse_simple_spec() {
2661        let yaml = r"
2662version: v1
2663deployment: test
2664services:
2665  hello:
2666    rtype: service
2667    image:
2668      name: hello-world:latest
2669    endpoints:
2670      - name: http
2671        protocol: http
2672        port: 8080
2673        expose: public
2674";
2675
2676        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2677        assert_eq!(spec.version, "v1");
2678        assert_eq!(spec.deployment, "test");
2679        assert!(spec.services.contains_key("hello"));
2680    }
2681
2682    #[test]
2683    fn test_parse_duration() {
2684        let yaml = r"
2685version: v1
2686deployment: test
2687services:
2688  test:
2689    rtype: service
2690    image:
2691      name: test:latest
2692    health:
2693      timeout: 30s
2694      interval: 1m
2695      start_grace: 5s
2696      check:
2697        type: tcp
2698        port: 8080
2699";
2700
2701        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2702        let health = &spec.services["test"].health;
2703        assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
2704        assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
2705        assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
2706        match &health.check {
2707            HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
2708            _ => panic!("Expected TCP health check"),
2709        }
2710    }
2711
2712    #[test]
2713    fn test_parse_adaptive_scale() {
2714        let yaml = r"
2715version: v1
2716deployment: test
2717services:
2718  test:
2719    rtype: service
2720    image:
2721      name: test:latest
2722    scale:
2723      mode: adaptive
2724      min: 2
2725      max: 10
2726      cooldown: 15s
2727      targets:
2728        cpu: 70
2729        rps: 800
2730";
2731
2732        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2733        let scale = &spec.services["test"].scale;
2734        match scale {
2735            ScaleSpec::Adaptive {
2736                min,
2737                max,
2738                cooldown,
2739                targets,
2740            } => {
2741                assert_eq!(*min, 2);
2742                assert_eq!(*max, 10);
2743                assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
2744                assert_eq!(targets.cpu, Some(70));
2745                assert_eq!(targets.rps, Some(800));
2746            }
2747            _ => panic!("Expected Adaptive scale mode"),
2748        }
2749    }
2750
2751    #[test]
2752    fn test_node_mode_default() {
2753        let yaml = r"
2754version: v1
2755deployment: test
2756services:
2757  hello:
2758    rtype: service
2759    image:
2760      name: hello-world:latest
2761";
2762
2763        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2764        assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
2765        assert!(spec.services["hello"].node_selector.is_none());
2766    }
2767
2768    #[test]
2769    fn test_node_mode_dedicated() {
2770        let yaml = r"
2771version: v1
2772deployment: test
2773services:
2774  api:
2775    rtype: service
2776    image:
2777      name: api:latest
2778    node_mode: dedicated
2779";
2780
2781        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2782        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2783    }
2784
2785    #[test]
2786    fn test_node_mode_exclusive() {
2787        let yaml = r"
2788version: v1
2789deployment: test
2790services:
2791  database:
2792    rtype: service
2793    image:
2794      name: postgres:15
2795    node_mode: exclusive
2796";
2797
2798        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2799        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2800    }
2801
2802    #[test]
2803    fn test_node_selector_with_labels() {
2804        let yaml = r#"
2805version: v1
2806deployment: test
2807services:
2808  ml-worker:
2809    rtype: service
2810    image:
2811      name: ml-worker:latest
2812    node_mode: dedicated
2813    node_selector:
2814      labels:
2815        gpu: "true"
2816        zone: us-east
2817      prefer_labels:
2818        storage: ssd
2819"#;
2820
2821        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2822        let service = &spec.services["ml-worker"];
2823        assert_eq!(service.node_mode, NodeMode::Dedicated);
2824
2825        let selector = service.node_selector.as_ref().unwrap();
2826        assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
2827        assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
2828        assert_eq!(
2829            selector.prefer_labels.get("storage"),
2830            Some(&"ssd".to_string())
2831        );
2832    }
2833
2834    #[test]
2835    fn test_node_mode_serialization_roundtrip() {
2836        use serde_json;
2837
2838        // Test all variants serialize/deserialize correctly
2839        let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
2840        let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
2841
2842        for (mode, expected) in modes.iter().zip(expected_json.iter()) {
2843            let json = serde_json::to_string(mode).unwrap();
2844            assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
2845
2846            let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
2847            assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
2848        }
2849    }
2850
2851    #[test]
2852    fn test_node_selector_empty() {
2853        let yaml = r"
2854version: v1
2855deployment: test
2856services:
2857  api:
2858    rtype: service
2859    image:
2860      name: api:latest
2861    node_selector:
2862      labels: {}
2863";
2864
2865        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2866        let selector = spec.services["api"].node_selector.as_ref().unwrap();
2867        assert!(selector.labels.is_empty());
2868        assert!(selector.prefer_labels.is_empty());
2869    }
2870
2871    #[test]
2872    fn test_mixed_node_modes_in_deployment() {
2873        let yaml = r"
2874version: v1
2875deployment: test
2876services:
2877  redis:
2878    rtype: service
2879    image:
2880      name: redis:alpine
2881    # Default shared mode
2882  api:
2883    rtype: service
2884    image:
2885      name: api:latest
2886    node_mode: dedicated
2887  database:
2888    rtype: service
2889    image:
2890      name: postgres:15
2891    node_mode: exclusive
2892    node_selector:
2893      labels:
2894        storage: ssd
2895";
2896
2897        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2898        assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
2899        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2900        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2901
2902        let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
2903        assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
2904    }
2905
2906    #[test]
2907    fn test_storage_bind_mount() {
2908        let yaml = r"
2909version: v1
2910deployment: test
2911services:
2912  app:
2913    image:
2914      name: app:latest
2915    storage:
2916      - type: bind
2917        source: /host/data
2918        target: /app/data
2919        readonly: true
2920";
2921        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2922        let storage = &spec.services["app"].storage;
2923        assert_eq!(storage.len(), 1);
2924        match &storage[0] {
2925            StorageSpec::Bind {
2926                source,
2927                target,
2928                readonly,
2929            } => {
2930                assert_eq!(source, "/host/data");
2931                assert_eq!(target, "/app/data");
2932                assert!(*readonly);
2933            }
2934            _ => panic!("Expected Bind storage"),
2935        }
2936    }
2937
2938    #[test]
2939    fn test_storage_named_with_tier() {
2940        let yaml = r"
2941version: v1
2942deployment: test
2943services:
2944  app:
2945    image:
2946      name: app:latest
2947    storage:
2948      - type: named
2949        name: my-data
2950        target: /app/data
2951        tier: cached
2952";
2953        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2954        let storage = &spec.services["app"].storage;
2955        match &storage[0] {
2956            StorageSpec::Named {
2957                name, target, tier, ..
2958            } => {
2959                assert_eq!(name, "my-data");
2960                assert_eq!(target, "/app/data");
2961                assert_eq!(*tier, StorageTier::Cached);
2962            }
2963            _ => panic!("Expected Named storage"),
2964        }
2965    }
2966
2967    #[test]
2968    fn test_storage_anonymous() {
2969        let yaml = r"
2970version: v1
2971deployment: test
2972services:
2973  app:
2974    image:
2975      name: app:latest
2976    storage:
2977      - type: anonymous
2978        target: /app/cache
2979";
2980        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2981        let storage = &spec.services["app"].storage;
2982        match &storage[0] {
2983            StorageSpec::Anonymous { target, tier } => {
2984                assert_eq!(target, "/app/cache");
2985                assert_eq!(*tier, StorageTier::Local); // default
2986            }
2987            _ => panic!("Expected Anonymous storage"),
2988        }
2989    }
2990
2991    #[test]
2992    fn test_storage_tmpfs() {
2993        let yaml = r"
2994version: v1
2995deployment: test
2996services:
2997  app:
2998    image:
2999      name: app:latest
3000    storage:
3001      - type: tmpfs
3002        target: /app/tmp
3003        size: 256Mi
3004        mode: 1777
3005";
3006        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3007        let storage = &spec.services["app"].storage;
3008        match &storage[0] {
3009            StorageSpec::Tmpfs { target, size, mode } => {
3010                assert_eq!(target, "/app/tmp");
3011                assert_eq!(size.as_deref(), Some("256Mi"));
3012                assert_eq!(*mode, Some(1777));
3013            }
3014            _ => panic!("Expected Tmpfs storage"),
3015        }
3016    }
3017
3018    #[test]
3019    fn test_storage_s3() {
3020        let yaml = r"
3021version: v1
3022deployment: test
3023services:
3024  app:
3025    image:
3026      name: app:latest
3027    storage:
3028      - type: s3
3029        bucket: my-bucket
3030        prefix: models/
3031        target: /app/models
3032        readonly: true
3033        endpoint: https://s3.us-west-2.amazonaws.com
3034        credentials: aws-creds
3035";
3036        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3037        let storage = &spec.services["app"].storage;
3038        match &storage[0] {
3039            StorageSpec::S3 {
3040                bucket,
3041                prefix,
3042                target,
3043                readonly,
3044                endpoint,
3045                credentials,
3046            } => {
3047                assert_eq!(bucket, "my-bucket");
3048                assert_eq!(prefix.as_deref(), Some("models/"));
3049                assert_eq!(target, "/app/models");
3050                assert!(*readonly);
3051                assert_eq!(
3052                    endpoint.as_deref(),
3053                    Some("https://s3.us-west-2.amazonaws.com")
3054                );
3055                assert_eq!(credentials.as_deref(), Some("aws-creds"));
3056            }
3057            _ => panic!("Expected S3 storage"),
3058        }
3059    }
3060
3061    #[test]
3062    fn test_storage_multiple_types() {
3063        let yaml = r"
3064version: v1
3065deployment: test
3066services:
3067  app:
3068    image:
3069      name: app:latest
3070    storage:
3071      - type: bind
3072        source: /etc/config
3073        target: /app/config
3074        readonly: true
3075      - type: named
3076        name: app-data
3077        target: /app/data
3078      - type: tmpfs
3079        target: /app/tmp
3080";
3081        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3082        let storage = &spec.services["app"].storage;
3083        assert_eq!(storage.len(), 3);
3084        assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
3085        assert!(matches!(&storage[1], StorageSpec::Named { .. }));
3086        assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
3087    }
3088
3089    #[test]
3090    fn test_storage_tier_default() {
3091        let yaml = r"
3092version: v1
3093deployment: test
3094services:
3095  app:
3096    image:
3097      name: app:latest
3098    storage:
3099      - type: named
3100        name: data
3101        target: /data
3102";
3103        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3104        match &spec.services["app"].storage[0] {
3105            StorageSpec::Named { tier, .. } => {
3106                assert_eq!(*tier, StorageTier::Local); // default should be Local
3107            }
3108            _ => panic!("Expected Named storage"),
3109        }
3110    }
3111
3112    // ==========================================================================
3113    // Tunnel configuration tests
3114    // ==========================================================================
3115
3116    #[test]
3117    fn test_endpoint_tunnel_config_basic() {
3118        let yaml = r"
3119version: v1
3120deployment: test
3121services:
3122  api:
3123    image:
3124      name: api:latest
3125    endpoints:
3126      - name: http
3127        protocol: http
3128        port: 8080
3129        tunnel:
3130          enabled: true
3131          remote_port: 8080
3132";
3133        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3134        let endpoint = &spec.services["api"].endpoints[0];
3135        let tunnel = endpoint.tunnel.as_ref().unwrap();
3136        assert!(tunnel.enabled);
3137        assert_eq!(tunnel.remote_port, 8080);
3138        assert!(tunnel.from.is_none());
3139        assert!(tunnel.to.is_none());
3140    }
3141
3142    #[test]
3143    fn test_endpoint_tunnel_config_full() {
3144        let yaml = r"
3145version: v1
3146deployment: test
3147services:
3148  api:
3149    image:
3150      name: api:latest
3151    endpoints:
3152      - name: http
3153        protocol: http
3154        port: 8080
3155        tunnel:
3156          enabled: true
3157          from: node-1
3158          to: ingress-node
3159          remote_port: 9000
3160          expose: public
3161          access:
3162            enabled: true
3163            max_ttl: 4h
3164            audit: true
3165";
3166        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3167        let endpoint = &spec.services["api"].endpoints[0];
3168        let tunnel = endpoint.tunnel.as_ref().unwrap();
3169        assert!(tunnel.enabled);
3170        assert_eq!(tunnel.from, Some("node-1".to_string()));
3171        assert_eq!(tunnel.to, Some("ingress-node".to_string()));
3172        assert_eq!(tunnel.remote_port, 9000);
3173        assert_eq!(tunnel.expose, Some(ExposeType::Public));
3174
3175        let access = tunnel.access.as_ref().unwrap();
3176        assert!(access.enabled);
3177        assert_eq!(access.max_ttl, Some("4h".to_string()));
3178        assert!(access.audit);
3179    }
3180
3181    #[test]
3182    fn test_top_level_tunnel_definition() {
3183        let yaml = r"
3184version: v1
3185deployment: test
3186services: {}
3187tunnels:
3188  db-tunnel:
3189    from: app-node
3190    to: db-node
3191    local_port: 5432
3192    remote_port: 5432
3193    protocol: tcp
3194    expose: internal
3195";
3196        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3197        let tunnel = spec.tunnels.get("db-tunnel").unwrap();
3198        assert_eq!(tunnel.from, "app-node");
3199        assert_eq!(tunnel.to, "db-node");
3200        assert_eq!(tunnel.local_port, 5432);
3201        assert_eq!(tunnel.remote_port, 5432);
3202        assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
3203        assert_eq!(tunnel.expose, ExposeType::Internal);
3204    }
3205
3206    #[test]
3207    fn test_top_level_tunnel_defaults() {
3208        let yaml = r"
3209version: v1
3210deployment: test
3211services: {}
3212tunnels:
3213  simple-tunnel:
3214    from: node-a
3215    to: node-b
3216    local_port: 3000
3217    remote_port: 3000
3218";
3219        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3220        let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
3221        assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); // default
3222        assert_eq!(tunnel.expose, ExposeType::Internal); // default
3223    }
3224
3225    #[test]
3226    fn test_tunnel_protocol_udp() {
3227        let yaml = r"
3228version: v1
3229deployment: test
3230services: {}
3231tunnels:
3232  udp-tunnel:
3233    from: node-a
3234    to: node-b
3235    local_port: 5353
3236    remote_port: 5353
3237    protocol: udp
3238";
3239        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3240        let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
3241        assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
3242    }
3243
3244    #[test]
3245    fn test_endpoint_without_tunnel() {
3246        let yaml = r"
3247version: v1
3248deployment: test
3249services:
3250  api:
3251    image:
3252      name: api:latest
3253    endpoints:
3254      - name: http
3255        protocol: http
3256        port: 8080
3257";
3258        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3259        let endpoint = &spec.services["api"].endpoints[0];
3260        assert!(endpoint.tunnel.is_none());
3261    }
3262
3263    #[test]
3264    fn test_deployment_without_tunnels() {
3265        let yaml = r"
3266version: v1
3267deployment: test
3268services:
3269  api:
3270    image:
3271      name: api:latest
3272";
3273        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3274        assert!(spec.tunnels.is_empty());
3275    }
3276
3277    // ==========================================================================
3278    // ApiSpec tests
3279    // ==========================================================================
3280
3281    #[test]
3282    fn test_spec_without_api_block_uses_defaults() {
3283        let yaml = r"
3284version: v1
3285deployment: test
3286services:
3287  hello:
3288    image:
3289      name: hello-world:latest
3290";
3291        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3292        assert!(spec.api.enabled);
3293        assert_eq!(spec.api.bind, "0.0.0.0:3669");
3294        assert!(spec.api.jwt_secret.is_none());
3295        assert!(spec.api.swagger);
3296    }
3297
3298    #[test]
3299    fn test_spec_with_explicit_api_block() {
3300        let yaml = r#"
3301version: v1
3302deployment: test
3303services:
3304  hello:
3305    image:
3306      name: hello-world:latest
3307api:
3308  enabled: false
3309  bind: "127.0.0.1:9090"
3310  jwt_secret: "my-secret"
3311  swagger: false
3312"#;
3313        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3314        assert!(!spec.api.enabled);
3315        assert_eq!(spec.api.bind, "127.0.0.1:9090");
3316        assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
3317        assert!(!spec.api.swagger);
3318    }
3319
3320    #[test]
3321    fn test_spec_with_partial_api_block() {
3322        let yaml = r#"
3323version: v1
3324deployment: test
3325services:
3326  hello:
3327    image:
3328      name: hello-world:latest
3329api:
3330  bind: "0.0.0.0:3000"
3331"#;
3332        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
3333        assert!(spec.api.enabled); // default true
3334        assert_eq!(spec.api.bind, "0.0.0.0:3000");
3335        assert!(spec.api.jwt_secret.is_none()); // default None
3336        assert!(spec.api.swagger); // default true
3337    }
3338
3339    // ==========================================================================
3340    // NetworkPolicySpec tests
3341    // ==========================================================================
3342
3343    #[test]
3344    fn test_network_policy_spec_roundtrip() {
3345        let spec = NetworkPolicySpec {
3346            name: "corp-vpn".to_string(),
3347            description: Some("Corporate VPN network".to_string()),
3348            cidrs: vec!["10.200.0.0/16".to_string()],
3349            members: vec![
3350                NetworkMember {
3351                    name: "alice".to_string(),
3352                    kind: MemberKind::User,
3353                },
3354                NetworkMember {
3355                    name: "ops-team".to_string(),
3356                    kind: MemberKind::Group,
3357                },
3358                NetworkMember {
3359                    name: "node-01".to_string(),
3360                    kind: MemberKind::Node,
3361                },
3362            ],
3363            access_rules: vec![
3364                AccessRule {
3365                    service: "api-gateway".to_string(),
3366                    deployment: "*".to_string(),
3367                    ports: Some(vec![443, 8080]),
3368                    action: AccessAction::Allow,
3369                },
3370                AccessRule {
3371                    service: "*".to_string(),
3372                    deployment: "staging".to_string(),
3373                    ports: None,
3374                    action: AccessAction::Deny,
3375                },
3376            ],
3377        };
3378
3379        let yaml = serde_yaml::to_string(&spec).unwrap();
3380        let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
3381        assert_eq!(spec, deserialized);
3382    }
3383
3384    #[test]
3385    fn test_network_policy_spec_defaults() {
3386        let yaml = r"
3387name: minimal
3388";
3389        let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
3390        assert_eq!(spec.name, "minimal");
3391        assert!(spec.description.is_none());
3392        assert!(spec.cidrs.is_empty());
3393        assert!(spec.members.is_empty());
3394        assert!(spec.access_rules.is_empty());
3395    }
3396
3397    #[test]
3398    fn test_access_rule_defaults() {
3399        let yaml = "{}";
3400        let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
3401        assert_eq!(rule.service, "*");
3402        assert_eq!(rule.deployment, "*");
3403        assert!(rule.ports.is_none());
3404        assert_eq!(rule.action, AccessAction::Allow);
3405    }
3406
3407    #[test]
3408    fn test_member_kind_defaults_to_user() {
3409        let yaml = r"
3410name: bob
3411";
3412        let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
3413        assert_eq!(member.name, "bob");
3414        assert_eq!(member.kind, MemberKind::User);
3415    }
3416
3417    #[test]
3418    fn test_member_kind_variants() {
3419        for (input, expected) in [
3420            ("user", MemberKind::User),
3421            ("group", MemberKind::Group),
3422            ("node", MemberKind::Node),
3423            ("cidr", MemberKind::Cidr),
3424        ] {
3425            let yaml = format!("name: test\nkind: {input}");
3426            let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
3427            assert_eq!(member.kind, expected);
3428        }
3429    }
3430
3431    #[test]
3432    fn test_access_action_variants() {
3433        // Test via a wrapper struct since bare enums need a YAML tag
3434        #[derive(Debug, Deserialize)]
3435        struct Wrapper {
3436            action: AccessAction,
3437        }
3438
3439        let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
3440        let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
3441
3442        assert_eq!(allow.action, AccessAction::Allow);
3443        assert_eq!(deny.action, AccessAction::Deny);
3444    }
3445
3446    #[test]
3447    fn test_network_policy_spec_default_impl() {
3448        let spec = NetworkPolicySpec::default();
3449        assert_eq!(spec.name, "");
3450        assert!(spec.description.is_none());
3451        assert!(spec.cidrs.is_empty());
3452        assert!(spec.members.is_empty());
3453        assert!(spec.access_rules.is_empty());
3454    }
3455
3456    #[test]
3457    fn container_restart_policy_serde_roundtrip_all_kinds() {
3458        // Exercise every `ContainerRestartKind` variant via a JSON roundtrip.
3459        // Covers the `snake_case` rename (`unless_stopped`, `on_failure`) and
3460        // the optional `max_attempts` / `delay` fields. Validates the wire
3461        // format the API will expose under `/v1/containers`.
3462        let cases = [
3463            (
3464                ContainerRestartPolicy {
3465                    kind: ContainerRestartKind::No,
3466                    max_attempts: None,
3467                    delay: None,
3468                },
3469                r#"{"kind":"no"}"#,
3470            ),
3471            (
3472                ContainerRestartPolicy {
3473                    kind: ContainerRestartKind::Always,
3474                    max_attempts: None,
3475                    delay: Some("500ms".to_string()),
3476                },
3477                r#"{"kind":"always","delay":"500ms"}"#,
3478            ),
3479            (
3480                ContainerRestartPolicy {
3481                    kind: ContainerRestartKind::UnlessStopped,
3482                    max_attempts: None,
3483                    delay: None,
3484                },
3485                r#"{"kind":"unless_stopped"}"#,
3486            ),
3487            (
3488                ContainerRestartPolicy {
3489                    kind: ContainerRestartKind::OnFailure,
3490                    max_attempts: Some(5),
3491                    delay: None,
3492                },
3493                r#"{"kind":"on_failure","max_attempts":5}"#,
3494            ),
3495        ];
3496
3497        for (value, expected_json) in &cases {
3498            let serialized = serde_json::to_string(value).expect("serialize");
3499            assert_eq!(&serialized, expected_json, "serialize mismatch");
3500            let round: ContainerRestartPolicy =
3501                serde_json::from_str(&serialized).expect("deserialize");
3502            assert_eq!(&round, value, "roundtrip mismatch");
3503        }
3504    }
3505
3506    // -- §3.10: RegistryAuth ------------------------------------------------
3507
3508    #[test]
3509    fn registry_auth_type_serializes_snake_case() {
3510        assert_eq!(
3511            serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
3512            "\"basic\""
3513        );
3514        assert_eq!(
3515            serde_json::to_string(&RegistryAuthType::Token).unwrap(),
3516            "\"token\""
3517        );
3518    }
3519
3520    #[test]
3521    fn registry_auth_default_auth_type_is_basic() {
3522        // When `auth_type` is omitted on the wire, the serde default kicks in.
3523        let json = r#"{"username":"u","password":"p"}"#;
3524        let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3525        assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
3526        assert_eq!(parsed.username, "u");
3527        assert_eq!(parsed.password, "p");
3528    }
3529
3530    #[test]
3531    fn registry_auth_serde_roundtrip_both_variants() {
3532        for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
3533            let cred = RegistryAuth {
3534                username: "ci-bot".to_string(),
3535                password: "s3cret".to_string(),
3536                auth_type: variant,
3537            };
3538            let serialized = serde_json::to_string(&cred).expect("serialize");
3539            let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
3540            assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
3541        }
3542    }
3543
3544    #[test]
3545    fn registry_auth_explicit_token_type_parses() {
3546        let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
3547        let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3548        assert_eq!(parsed.auth_type, RegistryAuthType::Token);
3549    }
3550
3551    #[test]
3552    fn target_platform_as_oci_str() {
3553        assert_eq!(
3554            TargetPlatform::new(OsKind::Linux, ArchKind::Amd64).as_oci_str(),
3555            "linux/amd64"
3556        );
3557        assert_eq!(
3558            TargetPlatform::new(OsKind::Windows, ArchKind::Arm64).as_oci_str(),
3559            "windows/arm64"
3560        );
3561        assert_eq!(
3562            TargetPlatform::new(OsKind::Macos, ArchKind::Arm64).as_oci_str(),
3563            "darwin/arm64"
3564        );
3565    }
3566
3567    #[test]
3568    fn os_kind_from_rust_consts() {
3569        assert_eq!(OsKind::from_rust_os("linux"), Some(OsKind::Linux));
3570        assert_eq!(OsKind::from_rust_os("windows"), Some(OsKind::Windows));
3571        assert_eq!(OsKind::from_rust_os("macos"), Some(OsKind::Macos));
3572        assert_eq!(OsKind::from_rust_os("freebsd"), None);
3573    }
3574
3575    #[test]
3576    fn arch_kind_from_rust_consts() {
3577        assert_eq!(ArchKind::from_rust_arch("x86_64"), Some(ArchKind::Amd64));
3578        assert_eq!(ArchKind::from_rust_arch("aarch64"), Some(ArchKind::Arm64));
3579        assert_eq!(ArchKind::from_rust_arch("riscv64"), None);
3580    }
3581
3582    #[test]
3583    fn service_spec_platform_yaml_round_trip_none() {
3584        // Omitting `platform` from YAML should deserialize as None without error,
3585        // even though ServiceSpec has `#[serde(deny_unknown_fields)]`.
3586        let yaml = r"
3587version: v1
3588deployment: test
3589services:
3590  app:
3591    rtype: service
3592    image:
3593      name: nginx:latest
3594";
3595        let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3596        assert!(spec.services["app"].platform.is_none());
3597    }
3598
3599    #[test]
3600    fn service_spec_platform_yaml_round_trip_some() {
3601        let yaml = r"
3602version: v1
3603deployment: test
3604services:
3605  app:
3606    rtype: service
3607    image:
3608      name: nginx:latest
3609    platform:
3610      os: windows
3611      arch: amd64
3612";
3613        let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3614        assert_eq!(
3615            spec.services["app"].platform,
3616            Some(TargetPlatform::new(OsKind::Windows, ArchKind::Amd64))
3617        );
3618    }
3619
3620    #[test]
3621    fn service_spec_platform_serializes_omitted_when_none() {
3622        // Build a minimal ServiceSpec via YAML to avoid enumerating every field
3623        // (ServiceSpec has no Default impl and no named-struct helper).
3624        let yaml = r"
3625version: v1
3626deployment: test
3627services:
3628  app:
3629    rtype: service
3630    image:
3631      name: nginx:latest
3632";
3633        let mut spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3634        let service = spec.services.get_mut("app").expect("service present");
3635        service.platform = None;
3636        let rendered = serde_yaml::to_string(service).expect("render");
3637        assert!(
3638            !rendered.contains("platform"),
3639            "platform must be omitted when None: {rendered}"
3640        );
3641    }
3642
3643    #[test]
3644    fn target_platform_os_version_builder() {
3645        let p =
3646            TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3647        assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3648        assert_eq!(p.os, OsKind::Windows);
3649        assert_eq!(p.arch, ArchKind::Amd64);
3650    }
3651
3652    #[test]
3653    fn target_platform_os_version_yaml_roundtrip() {
3654        let yaml = "os: windows\narch: amd64\nosVersion: 10.0.26100.1\n";
3655        let p: TargetPlatform = serde_yaml::from_str(yaml).expect("yaml parse");
3656        assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3657        assert_eq!(p.os, OsKind::Windows);
3658        assert_eq!(p.arch, ArchKind::Amd64);
3659    }
3660
3661    #[test]
3662    fn target_platform_os_version_yaml_omits_when_none() {
3663        let p = TargetPlatform::new(OsKind::Linux, ArchKind::Amd64);
3664        let rendered = serde_yaml::to_string(&p).expect("render");
3665        assert!(
3666            !rendered.contains("osVersion"),
3667            "osVersion must be omitted when None: {rendered}"
3668        );
3669    }
3670
3671    #[test]
3672    fn target_platform_as_detailed_str_includes_version() {
3673        let without = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).as_detailed_str();
3674        assert_eq!(without, "windows/amd64");
3675
3676        let with = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64)
3677            .with_os_version("10.0.26100.1")
3678            .as_detailed_str();
3679        assert_eq!(with, "windows/amd64 (os.version=10.0.26100.1)");
3680    }
3681
3682    #[test]
3683    fn target_platform_display_ignores_version() {
3684        // Display deliberately stays terse so existing log lines don't change.
3685        let p =
3686            TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3687        assert_eq!(format!("{p}"), "windows/amd64");
3688    }
3689
3690    // ----------------------------------------------------------------------
3691    // Phase 1 Task 1.1: Docker-compat ServiceSpec/ResourcesSpec extensions.
3692    // ----------------------------------------------------------------------
3693
3694    /// Build a minimal-but-valid `ServiceSpec` for round-trip tests.
3695    fn fixture_service_spec_full() -> ServiceSpec {
3696        let yaml = r"
3697version: v1
3698deployment: phase1-task1
3699services:
3700  hello:
3701    rtype: service
3702    image:
3703      name: hello-world:latest
3704";
3705        let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse fixture");
3706        spec.services.get("hello").expect("hello service").clone()
3707    }
3708
3709    #[test]
3710    fn service_spec_round_trip_with_all_new_fields() {
3711        let mut spec = fixture_service_spec_full();
3712        spec.labels
3713            .insert("zlayer.team".to_string(), "platform".to_string());
3714        spec.user = Some("1000:1000".to_string());
3715        spec.stop_signal = Some("SIGTERM".to_string());
3716        spec.stop_grace_period = Some(std::time::Duration::from_secs(30));
3717        spec.sysctls
3718            .insert("net.core.somaxconn".to_string(), "1024".to_string());
3719        spec.ulimits.insert(
3720            "nofile".to_string(),
3721            UlimitSpec {
3722                soft: 65_536,
3723                hard: 65_536,
3724            },
3725        );
3726        spec.security_opt.push("no-new-privileges:true".to_string());
3727        spec.pid_mode = Some("host".to_string());
3728        spec.ipc_mode = Some("private".to_string());
3729        spec.network_mode = NetworkMode::Bridge {
3730            name: Some("custom-net".to_string()),
3731        };
3732        spec.cap_drop.push("NET_RAW".to_string());
3733        spec.extra_groups.push("docker".to_string());
3734        spec.read_only_root_fs = true;
3735        spec.init_container = Some(true);
3736        spec.resources.pids_limit = Some(2048);
3737        spec.resources.cpuset = Some("0-3".to_string());
3738        spec.resources.cpu_shares = Some(1024);
3739        spec.resources.memory_swap = Some("2Gi".to_string());
3740        spec.resources.memory_reservation = Some("256Mi".to_string());
3741        spec.resources.memory_swappiness = Some(10);
3742        spec.resources.oom_score_adj = Some(-500);
3743        spec.resources.oom_kill_disable = Some(false);
3744        spec.resources.blkio_weight = Some(500);
3745
3746        let yaml = serde_yaml::to_string(&spec).expect("serialize");
3747        let round: ServiceSpec = serde_yaml::from_str(&yaml).expect("deserialize");
3748        assert_eq!(spec, round, "round-trip mismatch:\n{yaml}");
3749    }
3750
3751    #[test]
3752    fn network_mode_string_form_round_trip() {
3753        let cases: &[(&str, NetworkMode)] = &[
3754            ("default", NetworkMode::Default),
3755            ("host", NetworkMode::Host),
3756            ("none", NetworkMode::None),
3757            ("bridge", NetworkMode::Bridge { name: None }),
3758            (
3759                "bridge:custom",
3760                NetworkMode::Bridge {
3761                    name: Some("custom".to_string()),
3762                },
3763            ),
3764            (
3765                "container:abc123",
3766                NetworkMode::Container {
3767                    id: "abc123".to_string(),
3768                },
3769            ),
3770        ];
3771
3772        for (input, expected) in cases {
3773            #[derive(Deserialize)]
3774            struct Wrap {
3775                #[serde(deserialize_with = "deserialize_network_mode")]
3776                m: NetworkMode,
3777            }
3778            let yaml = format!("m: \"{input}\"\n");
3779            let parsed: Wrap = serde_yaml::from_str(&yaml).expect("parse network mode");
3780            assert_eq!(&parsed.m, expected, "mismatch for {input}");
3781        }
3782    }
3783
3784    #[test]
3785    fn ulimit_spec_round_trip() {
3786        let u = UlimitSpec {
3787            soft: 1024,
3788            hard: 65_536,
3789        };
3790        let yaml = serde_yaml::to_string(&u).expect("serialize");
3791        let parsed: UlimitSpec = serde_yaml::from_str(&yaml).expect("parse");
3792        assert_eq!(u, parsed);
3793    }
3794
3795    #[test]
3796    fn host_network_true_yaml_promotes_to_network_mode_host() {
3797        let yaml = r"
3798version: v1
3799deployment: bc-test
3800services:
3801  hello:
3802    rtype: service
3803    image:
3804      name: hello-world:latest
3805    host_network: true
3806";
3807        let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse");
3808        let svc = dep.services.get("hello").expect("hello service");
3809        assert_eq!(svc.network_mode, NetworkMode::Host);
3810        // The legacy bool stays mirrored so in-process callers that still
3811        // read `host_network` continue to work.
3812        assert!(svc.host_network);
3813    }
3814
3815    #[test]
3816    fn capabilities_yaml_alias_cap_add_round_trip() {
3817        // Forward-compat: ZLayer keeps the field named `capabilities`, but the
3818        // Docker-style key `cap_add` must also deserialize into it.
3819        let yaml = r"
3820version: v1
3821deployment: cap-test
3822services:
3823  hello:
3824    rtype: service
3825    image:
3826      name: hello-world:latest
3827    cap_add:
3828      - NET_ADMIN
3829      - SYS_PTRACE
3830";
3831        let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse cap_add alias");
3832        let svc = dep.services.get("hello").expect("hello service");
3833        assert_eq!(
3834            svc.capabilities,
3835            vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()]
3836        );
3837    }
3838
3839    #[test]
3840    fn lifecycle_omitted_defaults_to_false() {
3841        // When `lifecycle` is absent from the YAML/JSON entirely, the
3842        // deserialized service must fall back to `LifecycleSpec::default()`,
3843        // i.e. `delete_on_exit: false` — the historical retain-on-exit
3844        // behavior. This guards against accidental policy flips when the
3845        // field is added to existing specs.
3846        let yaml = r"
3847version: v1
3848deployment: lifecycle-default-test
3849services:
3850  app:
3851    rtype: service
3852    image:
3853      name: hello-world:latest
3854";
3855        let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec without lifecycle");
3856        let svc = dep.services.get("app").expect("app service");
3857        assert_eq!(svc.lifecycle, LifecycleSpec::default());
3858        assert!(!svc.lifecycle.delete_on_exit);
3859    }
3860
3861    #[test]
3862    fn lifecycle_delete_on_exit_round_trips() {
3863        // `lifecycle.delete_on_exit: true` must survive a full YAML
3864        // deserialize → serialize → deserialize cycle, and the explicit
3865        // value must propagate into the parsed `ServiceSpec`.
3866        let yaml = r"
3867version: v1
3868deployment: lifecycle-delete-test
3869services:
3870  app:
3871    rtype: service
3872    image:
3873      name: hello-world:latest
3874    lifecycle:
3875      delete_on_exit: true
3876";
3877        let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec with lifecycle");
3878        let svc = dep.services.get("app").expect("app service");
3879        assert!(svc.lifecycle.delete_on_exit);
3880
3881        // Round-trip via YAML to confirm Serialize emits the field and
3882        // Deserialize folds it back identically.
3883        let dumped = serde_yaml::to_string(&dep).expect("serialize spec with lifecycle");
3884        let reparsed: DeploymentSpec =
3885            serde_yaml::from_str(&dumped).expect("reparse round-tripped spec");
3886        let reparsed_svc = reparsed.services.get("app").expect("app service after rt");
3887        assert!(reparsed_svc.lifecycle.delete_on_exit);
3888        assert_eq!(svc.lifecycle, reparsed_svc.lifecycle);
3889    }
3890}