Skip to main content

zlayer_spec/
types.rs

1//! `ZLayer` V1 Service Specification Types
2//!
3//! This module defines all types for parsing and validating `ZLayer` deployment specs.
4
5mod duration {
6    use humantime::format_duration;
7    use serde::{Deserialize, Deserializer, Serializer};
8    use std::time::Duration;
9
10    #[allow(clippy::ref_option)]
11    pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
12    where
13        S: Serializer,
14    {
15        match duration {
16            Some(d) => serializer.serialize_str(&format_duration(*d).to_string()),
17            None => serializer.serialize_none(),
18        }
19    }
20
21    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
22    where
23        D: Deserializer<'de>,
24    {
25        use serde::de::Error;
26        let s: Option<String> = Option::deserialize(deserializer)?;
27        match s {
28            Some(s) => humantime::parse_duration(&s)
29                .map(Some)
30                .map_err(|e| D::Error::custom(format!("invalid duration: {e}"))),
31            None => Ok(None),
32        }
33    }
34
35    pub mod option {
36        pub use super::*;
37    }
38
39    /// Serde module for required (non-Option) Duration fields
40    pub mod required {
41        use humantime::format_duration;
42        use serde::{Deserialize, Deserializer, Serializer};
43        use std::time::Duration;
44
45        pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
46        where
47            S: Serializer,
48        {
49            serializer.serialize_str(&format_duration(*duration).to_string())
50        }
51
52        pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
53        where
54            D: Deserializer<'de>,
55        {
56            use serde::de::Error;
57            let s: String = String::deserialize(deserializer)?;
58            humantime::parse_duration(&s)
59                .map_err(|e| D::Error::custom(format!("invalid duration: {e}")))
60        }
61    }
62}
63
64use serde::{Deserialize, Serialize};
65use std::collections::HashMap;
66use validator::Validate;
67
68/// How service replicas are allocated to nodes
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum NodeMode {
72    /// Containers placed on any node with capacity (default, bin-packing)
73    #[default]
74    Shared,
75    /// Each replica gets its own dedicated node (1:1 mapping)
76    Dedicated,
77    /// Service is the ONLY thing on its nodes (no other services)
78    Exclusive,
79}
80
81/// Service type - determines runtime behavior and scaling model
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum ServiceType {
85    /// Standard long-running container service
86    #[default]
87    Standard,
88    /// WASM-based HTTP service (wasi:http/incoming-handler)
89    WasmHttp,
90    /// WASM-based general plugin (zlayer:plugin handler - full host access)
91    WasmPlugin,
92    /// WASM-based stateless request/response transformer
93    WasmTransformer,
94    /// WASM-based authenticator plugin (secrets + KV + HTTP)
95    WasmAuthenticator,
96    /// WASM-based rate limiter (KV + metrics)
97    WasmRateLimiter,
98    /// WASM-based request/response middleware
99    WasmMiddleware,
100    /// WASM-based custom router
101    WasmRouter,
102    /// Run-to-completion job
103    Job,
104}
105
106/// Storage performance tier
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
108#[serde(rename_all = "snake_case")]
109pub enum StorageTier {
110    /// Direct local filesystem (SSD/NVMe) - SQLite-safe, fast fsync
111    #[default]
112    Local,
113    /// bcache-backed tiered storage (SSD cache + slower backend)
114    Cached,
115    /// NFS/network storage - NOT SQLite-safe (will warn)
116    Network,
117}
118
119/// Node selection constraints for service placement
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct NodeSelector {
123    /// Required labels that nodes must have (all must match)
124    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
125    pub labels: HashMap<String, String>,
126    /// Preferred labels (soft constraint, nodes with these are preferred)
127    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
128    pub prefer_labels: HashMap<String, String>,
129}
130
131/// 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::validate::validate_version_wrapper"))]
655    pub version: String,
656
657    /// Deployment name (used for overlays, DNS)
658    #[validate(custom(function = "crate::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/// Per-service specification
788#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
789#[serde(deny_unknown_fields)]
790pub struct ServiceSpec {
791    /// Resource type (service, job, cron)
792    #[serde(default = "default_resource_type")]
793    pub rtype: ResourceType,
794
795    /// Cron schedule expression (only for rtype: cron)
796    /// Uses 7-field cron syntax: "sec min hour day-of-month month day-of-week year"
797    /// Examples:
798    ///   - "0 0 0 * * * *" (daily at midnight)
799    ///   - "0 */5 * * * * *" (every 5 minutes)
800    ///   - "0 0 12 * * MON-FRI *" (weekdays at noon)
801    #[serde(default, skip_serializing_if = "Option::is_none")]
802    #[validate(custom(function = "crate::validate::validate_schedule_wrapper"))]
803    pub schedule: Option<String>,
804
805    /// Container image specification
806    #[validate(nested)]
807    pub image: ImageSpec,
808
809    /// Resource limits
810    #[serde(default)]
811    #[validate(nested)]
812    pub resources: ResourcesSpec,
813
814    /// Environment variables for the service
815    ///
816    /// Values can be:
817    /// - Plain strings: `"value"`
818    /// - Host env refs: `$E:VAR_NAME`
819    /// - Secret refs: `$S:secret-name` or `$S:@service/secret-name`
820    #[serde(default)]
821    pub env: HashMap<String, String>,
822
823    /// Command override (entrypoint, args, workdir)
824    #[serde(default)]
825    pub command: CommandSpec,
826
827    /// Network configuration
828    #[serde(default)]
829    pub network: ServiceNetworkSpec,
830
831    /// Endpoint definitions (proxy bindings)
832    #[serde(default)]
833    #[validate(nested)]
834    pub endpoints: Vec<EndpointSpec>,
835
836    /// Scaling configuration
837    #[serde(default)]
838    #[validate(custom(function = "crate::validate::validate_scale_spec"))]
839    pub scale: ScaleSpec,
840
841    /// Dependency specifications
842    #[serde(default)]
843    pub depends: Vec<DependsSpec>,
844
845    /// Health check configuration
846    #[serde(default = "default_health")]
847    pub health: HealthSpec,
848
849    /// Init actions (pre-start lifecycle steps)
850    #[serde(default)]
851    pub init: InitSpec,
852
853    /// Error handling policies
854    #[serde(default)]
855    pub errors: ErrorsSpec,
856
857    /// Device passthrough (e.g., /dev/kvm for VMs)
858    #[serde(default)]
859    pub devices: Vec<DeviceSpec>,
860
861    /// Storage mounts for the container
862    #[serde(default, skip_serializing_if = "Vec::is_empty")]
863    pub storage: Vec<StorageSpec>,
864
865    /// Host-to-container port mappings (Docker's `-p host:container/proto`).
866    ///
867    /// Each entry publishes a container port on the host. When `host_port` is
868    /// `None` (or zero), the daemon assigns an ephemeral host port.
869    #[serde(default, skip_serializing_if = "Vec::is_empty")]
870    pub port_mappings: Vec<PortMapping>,
871
872    /// Linux capabilities to add (e.g., `SYS_ADMIN`, `NET_ADMIN`)
873    #[serde(default)]
874    pub capabilities: Vec<String>,
875
876    /// Run container in privileged mode (all capabilities + all devices)
877    #[serde(default)]
878    pub privileged: bool,
879
880    /// Node allocation mode (shared, dedicated, exclusive)
881    #[serde(default)]
882    pub node_mode: NodeMode,
883
884    /// Node selection constraints (required/preferred labels)
885    #[serde(default, skip_serializing_if = "Option::is_none")]
886    pub node_selector: Option<NodeSelector>,
887
888    /// Target platform for this service. When `None` (default), the service is
889    /// eligible to run on any agent regardless of OS/architecture. When `Some`,
890    /// the scheduler will only place replicas on agents whose platform matches.
891    #[serde(default, skip_serializing_if = "Option::is_none")]
892    pub platform: Option<TargetPlatform>,
893
894    /// Service type (standard, `wasm_http`, `wasm_plugin`, etc.)
895    #[serde(default)]
896    pub service_type: ServiceType,
897
898    /// WASM configuration (used when `service_type` is any Wasm* variant)
899    /// Also accepts the deprecated `wasm_http` key for backward compatibility.
900    #[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
901    pub wasm: Option<WasmConfig>,
902
903    /// Log output configuration. If not set, uses platform defaults.
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    pub logs: Option<LogsConfig>,
906
907    /// Use host networking (container shares host network namespace)
908    ///
909    /// When true, the container will NOT get its own network namespace.
910    /// This is set programmatically via the `--host-network` CLI flag, not in YAML specs.
911    #[serde(skip)]
912    pub host_network: bool,
913
914    /// Container hostname (maps to Docker's `--hostname`).
915    ///
916    /// When set, the container's `/etc/hostname` and initial kernel hostname
917    /// are configured to this value. Ignored when `host_network` is true
918    /// (the container inherits the host's hostname).
919    #[serde(default, skip_serializing_if = "Option::is_none")]
920    pub hostname: Option<String>,
921
922    /// Additional DNS servers for the container (maps to Docker's `--dns`).
923    ///
924    /// Each entry must be a plausible IPv4 or IPv6 address. Forwarded to the
925    /// container runtime as resolver addresses ahead of the platform defaults.
926    /// Ignored when `host_network` is true.
927    #[serde(default, skip_serializing_if = "Vec::is_empty")]
928    pub dns: Vec<String>,
929
930    /// Extra `hostname:ip` entries appended to `/etc/hosts` (maps to Docker's
931    /// `--add-host`).
932    ///
933    /// Each entry must be in the form `"<hostname>:<ip>"`. The special literal
934    /// `host-gateway` is accepted as the `<ip>` half (resolved by Docker /
935    /// bollard to the host-visible gateway address, commonly used with
936    /// `host.docker.internal:host-gateway`).
937    #[serde(default, skip_serializing_if = "Vec::is_empty")]
938    pub extra_hosts: Vec<String>,
939
940    /// Container restart policy (Docker-style).
941    ///
942    /// Controls when the runtime should automatically restart the container
943    /// after it exits. Maps to Docker's `HostConfig.RestartPolicy`. Named
944    /// `ContainerRestartPolicy` to avoid colliding with `ZLayer`'s existing
945    /// `PanicPolicy` (which controls post-panic behavior, not runtime-level
946    /// restarts).
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    pub restart_policy: Option<ContainerRestartPolicy>,
949}
950
951/// Command override specification (Section 5.5)
952#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
953#[serde(deny_unknown_fields)]
954pub struct CommandSpec {
955    /// Override image ENTRYPOINT
956    #[serde(default, skip_serializing_if = "Option::is_none")]
957    pub entrypoint: Option<Vec<String>>,
958
959    /// Override image CMD
960    #[serde(default, skip_serializing_if = "Option::is_none")]
961    pub args: Option<Vec<String>>,
962
963    /// Override working directory
964    #[serde(default, skip_serializing_if = "Option::is_none")]
965    pub workdir: Option<String>,
966}
967
968fn default_resource_type() -> ResourceType {
969    ResourceType::Service
970}
971
972fn default_health() -> HealthSpec {
973    HealthSpec {
974        start_grace: Some(std::time::Duration::from_secs(5)),
975        interval: None,
976        timeout: None,
977        retries: 3,
978        check: HealthCheck::Tcp { port: 0 },
979    }
980}
981
982/// Resource type - determines container lifecycle
983#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
984#[serde(rename_all = "lowercase")]
985pub enum ResourceType {
986    /// Long-running container, receives traffic, load-balanced
987    Service,
988    /// Run-to-completion, triggered by endpoint/CLI/internal system
989    Job,
990    /// Scheduled run-to-completion, time-triggered
991    Cron,
992}
993
994/// Container image specification
995#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
996#[serde(deny_unknown_fields)]
997pub struct ImageSpec {
998    /// Image name (e.g., "ghcr.io/org/api:latest")
999    #[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
1000    pub name: String,
1001
1002    /// When to pull the image
1003    #[serde(default = "default_pull_policy")]
1004    pub pull_policy: PullPolicy,
1005}
1006
1007fn default_pull_policy() -> PullPolicy {
1008    PullPolicy::IfNotPresent
1009}
1010
1011/// Image pull policy
1012#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1013#[serde(rename_all = "snake_case")]
1014pub enum PullPolicy {
1015    /// Always pull the image
1016    Always,
1017    /// Pull only if not present locally
1018    IfNotPresent,
1019    /// Never pull, use local image only
1020    Never,
1021}
1022
1023/// Device passthrough specification
1024#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1025#[serde(deny_unknown_fields)]
1026pub struct DeviceSpec {
1027    /// Host device path (e.g., /dev/kvm, /dev/net/tun)
1028    #[validate(length(min = 1, message = "device path cannot be empty"))]
1029    pub path: String,
1030
1031    /// Allow read access
1032    #[serde(default = "default_true")]
1033    pub read: bool,
1034
1035    /// Allow write access
1036    #[serde(default = "default_true")]
1037    pub write: bool,
1038
1039    /// Allow mknod (create device nodes)
1040    #[serde(default)]
1041    pub mknod: bool,
1042}
1043
1044fn default_true() -> bool {
1045    true
1046}
1047
1048/// Storage mount specification
1049#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1050#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
1051pub enum StorageSpec {
1052    /// Bind mount from host path to container
1053    Bind {
1054        source: String,
1055        target: String,
1056        #[serde(default)]
1057        readonly: bool,
1058    },
1059    /// Named persistent storage volume
1060    Named {
1061        name: String,
1062        target: String,
1063        #[serde(default)]
1064        readonly: bool,
1065        /// Performance tier (default: local, SQLite-safe)
1066        #[serde(default)]
1067        tier: StorageTier,
1068        /// Optional size limit (e.g., "1Gi", "512Mi")
1069        #[serde(default, skip_serializing_if = "Option::is_none")]
1070        size: Option<String>,
1071    },
1072    /// Anonymous storage (auto-named, container lifecycle)
1073    Anonymous {
1074        target: String,
1075        /// Performance tier (default: local)
1076        #[serde(default)]
1077        tier: StorageTier,
1078    },
1079    /// Memory-backed tmpfs mount
1080    Tmpfs {
1081        target: String,
1082        #[serde(default)]
1083        size: Option<String>,
1084        #[serde(default)]
1085        mode: Option<u32>,
1086    },
1087    /// S3-backed FUSE mount
1088    S3 {
1089        bucket: String,
1090        #[serde(default)]
1091        prefix: Option<String>,
1092        target: String,
1093        #[serde(default)]
1094        readonly: bool,
1095        #[serde(default)]
1096        endpoint: Option<String>,
1097        #[serde(default)]
1098        credentials: Option<String>,
1099    },
1100}
1101
1102/// Resource limits (upper bounds, not reservations)
1103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
1104#[serde(deny_unknown_fields)]
1105pub struct ResourcesSpec {
1106    /// CPU limit (cores, e.g., 0.5, 1, 2)
1107    #[serde(default)]
1108    #[validate(custom(function = "crate::validate::validate_cpu_option_wrapper"))]
1109    pub cpu: Option<f64>,
1110
1111    /// Memory limit (e.g., "512Mi", "1Gi", "2Gi")
1112    #[serde(default)]
1113    #[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
1114    pub memory: Option<String>,
1115
1116    /// GPU resource request
1117    #[serde(default, skip_serializing_if = "Option::is_none")]
1118    pub gpu: Option<GpuSpec>,
1119}
1120
1121/// Scheduling policy for GPU workloads
1122#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1123#[serde(rename_all = "kebab-case")]
1124pub enum SchedulingPolicy {
1125    /// Place as many replicas as possible; partial placement is acceptable (default)
1126    #[default]
1127    BestEffort,
1128    /// All replicas must be placed or none are; prevents partial GPU job deployment
1129    Gang,
1130    /// Spread replicas across nodes to maximize GPU distribution
1131    Spread,
1132}
1133
1134/// GPU sharing mode controlling how GPU resources are multiplexed.
1135#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1136#[serde(rename_all = "kebab-case")]
1137pub enum GpuSharingMode {
1138    /// Whole GPU per container (default). No sharing.
1139    #[default]
1140    Exclusive,
1141    /// NVIDIA Multi-Process Service: concurrent GPU compute sharing.
1142    /// Multiple containers run GPU kernels simultaneously with hardware isolation.
1143    Mps,
1144    /// NVIDIA time-slicing: round-robin GPU access across containers.
1145    /// Lower overhead than MPS but no concurrent execution.
1146    TimeSlice,
1147}
1148
1149/// Configuration for distributed GPU job coordination.
1150///
1151/// When enabled on a multi-replica GPU service, `ZLayer` injects standard
1152/// distributed training environment variables (`MASTER_ADDR`, `MASTER_PORT`,
1153/// `WORLD_SIZE`, `RANK`, `LOCAL_RANK`) so frameworks like `PyTorch`, `Horovod`,
1154/// and `DeepSpeed` can coordinate automatically.
1155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1156#[serde(deny_unknown_fields)]
1157pub struct DistributedConfig {
1158    /// Communication backend: "nccl" (default), "gloo", or "mpi"
1159    #[serde(default = "default_dist_backend")]
1160    pub backend: String,
1161    /// Port for rank-0 master coordination (default: 29500)
1162    #[serde(default = "default_dist_port")]
1163    pub master_port: u16,
1164}
1165
1166fn default_dist_backend() -> String {
1167    "nccl".to_string()
1168}
1169
1170fn default_dist_port() -> u16 {
1171    29500
1172}
1173
1174/// GPU resource specification
1175///
1176/// Supported vendors:
1177/// - `nvidia` - NVIDIA GPUs via NVIDIA Container Toolkit (default)
1178/// - `amd` - AMD GPUs via `ROCm` (/dev/kfd + /dev/dri/renderD*)
1179/// - `intel` - Intel GPUs via VAAPI/i915 (/dev/dri/renderD*)
1180/// - `apple` - Apple Silicon GPUs via Metal/MPS (macOS only)
1181///
1182/// Unknown vendors fall back to DRI render node passthrough.
1183///
1184/// ## GPU mode (macOS only)
1185///
1186/// When `vendor` is `"apple"`, the `mode` field controls how GPU access is provided:
1187/// - `"native"` -- Seatbelt sandbox with direct Metal/MPS access (lowest overhead)
1188/// - `"vm"` -- libkrun micro-VM with GPU forwarding (stronger isolation)
1189/// - `None` (default) -- Auto-select based on platform and vendor
1190///
1191/// On Linux, `mode` is ignored; GPU passthrough always uses device node binding.
1192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1193#[serde(deny_unknown_fields)]
1194pub struct GpuSpec {
1195    /// Number of GPUs to request
1196    #[serde(default = "default_gpu_count")]
1197    pub count: u32,
1198    /// GPU vendor (`nvidia`, `amd`, `intel`, `apple`) - defaults to `nvidia`
1199    #[serde(default = "default_gpu_vendor")]
1200    pub vendor: String,
1201    /// GPU access mode (macOS only): `"native"`, `"vm"`, or `None` for auto-select
1202    #[serde(default, skip_serializing_if = "Option::is_none")]
1203    pub mode: Option<String>,
1204    /// Pin to a specific GPU model (e.g. "A100", "H100").
1205    /// Substring match against detected GPU model names.
1206    #[serde(default, skip_serializing_if = "Option::is_none")]
1207    pub model: Option<String>,
1208    /// Scheduling policy for GPU workloads.
1209    /// - `best-effort` (default): place what fits
1210    /// - `gang`: all-or-nothing for distributed jobs
1211    /// - `spread`: distribute across nodes
1212    #[serde(default, skip_serializing_if = "Option::is_none")]
1213    pub scheduling: Option<SchedulingPolicy>,
1214    /// Distributed GPU job coordination.
1215    /// When set, injects `MASTER_ADDR`, `WORLD_SIZE`, `RANK`, `LOCAL_RANK` env vars.
1216    #[serde(default, skip_serializing_if = "Option::is_none")]
1217    pub distributed: Option<DistributedConfig>,
1218    /// GPU sharing mode: exclusive (default), mps, or time-slice.
1219    #[serde(default, skip_serializing_if = "Option::is_none")]
1220    pub sharing: Option<GpuSharingMode>,
1221}
1222
1223fn default_gpu_count() -> u32 {
1224    1
1225}
1226
1227fn default_gpu_vendor() -> String {
1228    "nvidia".to_string()
1229}
1230
1231/// Per-service network configuration (overlay + join policy).
1232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1233#[serde(deny_unknown_fields)]
1234#[derive(Default)]
1235pub struct ServiceNetworkSpec {
1236    /// Overlay network configuration
1237    #[serde(default)]
1238    pub overlays: OverlayConfig,
1239
1240    /// Join policy (who can join this service)
1241    #[serde(default)]
1242    pub join: JoinPolicy,
1243}
1244
1245/// Overlay network configuration
1246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1247#[serde(deny_unknown_fields)]
1248pub struct OverlayConfig {
1249    /// Service-scoped overlay (service replicas only)
1250    #[serde(default)]
1251    pub service: OverlaySettings,
1252
1253    /// Global overlay (all services in deployment)
1254    #[serde(default)]
1255    pub global: OverlaySettings,
1256}
1257
1258impl Default for OverlayConfig {
1259    fn default() -> Self {
1260        Self {
1261            service: OverlaySettings {
1262                enabled: true,
1263                encrypted: true,
1264                isolated: true,
1265            },
1266            global: OverlaySettings {
1267                enabled: true,
1268                encrypted: true,
1269                isolated: false,
1270            },
1271        }
1272    }
1273}
1274
1275/// Overlay network settings
1276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1277#[serde(deny_unknown_fields)]
1278pub struct OverlaySettings {
1279    /// Enable this overlay
1280    #[serde(default = "default_enabled")]
1281    pub enabled: bool,
1282
1283    /// Use encryption
1284    #[serde(default = "default_encrypted")]
1285    pub encrypted: bool,
1286
1287    /// Isolate from other services/groups
1288    #[serde(default)]
1289    pub isolated: bool,
1290}
1291
1292fn default_enabled() -> bool {
1293    true
1294}
1295
1296fn default_encrypted() -> bool {
1297    true
1298}
1299
1300/// Join policy - controls who can join a service
1301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1302#[serde(deny_unknown_fields)]
1303pub struct JoinPolicy {
1304    /// Join mode
1305    #[serde(default = "default_join_mode")]
1306    pub mode: JoinMode,
1307
1308    /// Scope of join
1309    #[serde(default = "default_join_scope")]
1310    pub scope: JoinScope,
1311}
1312
1313impl Default for JoinPolicy {
1314    fn default() -> Self {
1315        Self {
1316            mode: default_join_mode(),
1317            scope: default_join_scope(),
1318        }
1319    }
1320}
1321
1322fn default_join_mode() -> JoinMode {
1323    JoinMode::Token
1324}
1325
1326fn default_join_scope() -> JoinScope {
1327    JoinScope::Service
1328}
1329
1330/// Join mode
1331#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1332#[serde(rename_all = "snake_case")]
1333pub enum JoinMode {
1334    /// Any trusted node in deployment can self-enroll
1335    Open,
1336    /// Requires a join key (recommended)
1337    Token,
1338    /// Only control-plane/scheduler can place replicas
1339    Closed,
1340}
1341
1342/// Join scope
1343#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1344#[serde(rename_all = "snake_case")]
1345pub enum JoinScope {
1346    /// Join this specific service
1347    Service,
1348    /// Join all services in deployment
1349    Global,
1350}
1351
1352/// Endpoint specification (proxy binding)
1353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1354#[serde(deny_unknown_fields)]
1355pub struct EndpointSpec {
1356    /// Endpoint name (for routing)
1357    #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
1358    pub name: String,
1359
1360    /// Protocol
1361    pub protocol: Protocol,
1362
1363    /// Proxy listen port (external-facing port)
1364    #[validate(custom(function = "crate::validate::validate_port_wrapper"))]
1365    pub port: u16,
1366
1367    /// Container port the service actually listens on.
1368    /// Defaults to `port` when not specified.
1369    #[serde(default, skip_serializing_if = "Option::is_none")]
1370    pub target_port: Option<u16>,
1371
1372    /// URL path prefix (for http/https/websocket)
1373    pub path: Option<String>,
1374
1375    /// Host pattern for routing (e.g. "api.example.com" or "*.example.com").
1376    /// `None` means match any host.
1377    #[serde(default, skip_serializing_if = "Option::is_none")]
1378    pub host: Option<String>,
1379
1380    /// Exposure type
1381    #[serde(default = "default_expose")]
1382    pub expose: ExposeType,
1383
1384    /// Optional stream (L4) proxy configuration
1385    /// Only applicable when protocol is tcp or udp
1386    #[serde(default, skip_serializing_if = "Option::is_none")]
1387    pub stream: Option<StreamEndpointConfig>,
1388
1389    /// Optional tunnel configuration for this endpoint
1390    #[serde(default, skip_serializing_if = "Option::is_none")]
1391    pub tunnel: Option<EndpointTunnelConfig>,
1392}
1393
1394impl EndpointSpec {
1395    /// Returns the port the container actually listens on.
1396    /// Falls back to `port` when `target_port` is not specified.
1397    #[must_use]
1398    pub fn target_port(&self) -> u16 {
1399        self.target_port.unwrap_or(self.port)
1400    }
1401}
1402
1403/// Tunnel configuration for an endpoint
1404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1405#[serde(deny_unknown_fields)]
1406pub struct EndpointTunnelConfig {
1407    /// Enable tunneling for this endpoint
1408    #[serde(default)]
1409    pub enabled: bool,
1410
1411    /// Source node name (defaults to service's node)
1412    #[serde(default, skip_serializing_if = "Option::is_none")]
1413    pub from: Option<String>,
1414
1415    /// Destination node name (defaults to cluster ingress)
1416    #[serde(default, skip_serializing_if = "Option::is_none")]
1417    pub to: Option<String>,
1418
1419    /// Remote port to expose (0 = auto-assign)
1420    #[serde(default)]
1421    pub remote_port: u16,
1422
1423    /// Override exposure for tunnel (public/internal)
1424    #[serde(default, skip_serializing_if = "Option::is_none")]
1425    pub expose: Option<ExposeType>,
1426
1427    /// On-demand access configuration
1428    #[serde(default, skip_serializing_if = "Option::is_none")]
1429    pub access: Option<TunnelAccessConfig>,
1430}
1431
1432/// On-demand access settings for `zlayer tunnel access`
1433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1434#[serde(deny_unknown_fields)]
1435pub struct TunnelAccessConfig {
1436    /// Allow on-demand access via CLI
1437    #[serde(default)]
1438    pub enabled: bool,
1439
1440    /// Maximum session duration (e.g., "4h", "30m")
1441    #[serde(default, skip_serializing_if = "Option::is_none")]
1442    pub max_ttl: Option<String>,
1443
1444    /// Log all access sessions
1445    #[serde(default)]
1446    pub audit: bool,
1447}
1448
1449fn default_expose() -> ExposeType {
1450    ExposeType::Internal
1451}
1452
1453/// Protocol type
1454#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1455#[serde(rename_all = "lowercase")]
1456pub enum Protocol {
1457    Http,
1458    Https,
1459    Tcp,
1460    Udp,
1461    Websocket,
1462}
1463
1464/// Exposure type
1465#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1466#[serde(rename_all = "lowercase")]
1467pub enum ExposeType {
1468    Public,
1469    #[default]
1470    Internal,
1471}
1472
1473/// Stream (L4) proxy configuration for TCP/UDP endpoints
1474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1475#[serde(deny_unknown_fields)]
1476pub struct StreamEndpointConfig {
1477    /// Enable TLS termination for TCP (auto-provision cert)
1478    #[serde(default)]
1479    pub tls: bool,
1480
1481    /// Enable PROXY protocol for passing client IP
1482    #[serde(default)]
1483    pub proxy_protocol: bool,
1484
1485    /// Custom session timeout for UDP (default: 60s)
1486    /// Format: duration string like "60s", "5m"
1487    #[serde(default, skip_serializing_if = "Option::is_none")]
1488    pub session_timeout: Option<String>,
1489
1490    /// Health check configuration for L4
1491    #[serde(default, skip_serializing_if = "Option::is_none")]
1492    pub health_check: Option<StreamHealthCheck>,
1493}
1494
1495/// Health check types for stream (L4) endpoints
1496#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1497#[serde(tag = "type", rename_all = "snake_case")]
1498pub enum StreamHealthCheck {
1499    /// TCP connect check - verifies port is accepting connections
1500    TcpConnect,
1501    /// UDP probe - sends request and optionally validates response
1502    UdpProbe {
1503        /// Request payload to send (can use hex escapes like \\xFF)
1504        request: String,
1505        /// Expected response pattern (optional regex)
1506        #[serde(default, skip_serializing_if = "Option::is_none")]
1507        expect: Option<String>,
1508    },
1509}
1510
1511/// Scaling configuration
1512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1513#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
1514pub enum ScaleSpec {
1515    /// Adaptive scaling with metrics
1516    #[serde(rename = "adaptive")]
1517    Adaptive {
1518        /// Minimum replicas
1519        min: u32,
1520
1521        /// Maximum replicas
1522        max: u32,
1523
1524        /// Cooldown period between scale events
1525        #[serde(default, with = "duration::option")]
1526        cooldown: Option<std::time::Duration>,
1527
1528        /// Target metrics for scaling
1529        #[serde(default)]
1530        targets: ScaleTargets,
1531    },
1532
1533    /// Fixed number of replicas
1534    #[serde(rename = "fixed")]
1535    Fixed { replicas: u32 },
1536
1537    /// Manual scaling (no automatic scaling)
1538    #[serde(rename = "manual")]
1539    Manual,
1540}
1541
1542impl Default for ScaleSpec {
1543    fn default() -> Self {
1544        Self::Adaptive {
1545            min: 1,
1546            max: 10,
1547            cooldown: Some(std::time::Duration::from_secs(30)),
1548            targets: ScaleTargets::default(),
1549        }
1550    }
1551}
1552
1553/// Target metrics for adaptive scaling
1554#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1555#[serde(deny_unknown_fields)]
1556#[derive(Default)]
1557pub struct ScaleTargets {
1558    /// CPU percentage threshold (0-100)
1559    #[serde(default)]
1560    pub cpu: Option<u8>,
1561
1562    /// Memory percentage threshold (0-100)
1563    #[serde(default)]
1564    pub memory: Option<u8>,
1565
1566    /// Requests per second threshold
1567    #[serde(default)]
1568    pub rps: Option<u32>,
1569}
1570
1571/// Dependency specification
1572#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1573#[serde(deny_unknown_fields)]
1574pub struct DependsSpec {
1575    /// Service name to depend on
1576    pub service: String,
1577
1578    /// Condition for dependency
1579    #[serde(default = "default_condition")]
1580    pub condition: DependencyCondition,
1581
1582    /// Maximum time to wait
1583    #[serde(default = "default_timeout", with = "duration::option")]
1584    pub timeout: Option<std::time::Duration>,
1585
1586    /// Action on timeout
1587    #[serde(default = "default_on_timeout")]
1588    pub on_timeout: TimeoutAction,
1589}
1590
1591fn default_condition() -> DependencyCondition {
1592    DependencyCondition::Healthy
1593}
1594
1595#[allow(clippy::unnecessary_wraps)]
1596fn default_timeout() -> Option<std::time::Duration> {
1597    Some(std::time::Duration::from_secs(300))
1598}
1599
1600fn default_on_timeout() -> TimeoutAction {
1601    TimeoutAction::Fail
1602}
1603
1604/// Dependency condition
1605#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1606#[serde(rename_all = "lowercase")]
1607pub enum DependencyCondition {
1608    /// Container process exists
1609    Started,
1610    /// Health check passes
1611    Healthy,
1612    /// Service is available for routing
1613    Ready,
1614}
1615
1616/// Timeout action
1617#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1618#[serde(rename_all = "lowercase")]
1619pub enum TimeoutAction {
1620    Fail,
1621    Warn,
1622    Continue,
1623}
1624
1625/// Health check specification
1626#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1627#[serde(deny_unknown_fields)]
1628pub struct HealthSpec {
1629    /// Grace period before first check
1630    #[serde(default, with = "duration::option")]
1631    pub start_grace: Option<std::time::Duration>,
1632
1633    /// Interval between checks
1634    #[serde(default, with = "duration::option")]
1635    pub interval: Option<std::time::Duration>,
1636
1637    /// Timeout per check
1638    #[serde(default, with = "duration::option")]
1639    pub timeout: Option<std::time::Duration>,
1640
1641    /// Number of retries before marking unhealthy
1642    #[serde(default = "default_retries")]
1643    pub retries: u32,
1644
1645    /// Health check type and parameters
1646    pub check: HealthCheck,
1647}
1648
1649fn default_retries() -> u32 {
1650    3
1651}
1652
1653/// Health check type
1654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1655#[serde(tag = "type", rename_all = "lowercase")]
1656pub enum HealthCheck {
1657    /// TCP port check
1658    Tcp {
1659        /// Port to check (0 = use first endpoint)
1660        port: u16,
1661    },
1662
1663    /// HTTP check
1664    Http {
1665        /// URL to check
1666        url: String,
1667        /// Expected status code
1668        #[serde(default = "default_expect_status")]
1669        expect_status: u16,
1670    },
1671
1672    /// Command check
1673    Command {
1674        /// Command to run
1675        command: String,
1676    },
1677}
1678
1679fn default_expect_status() -> u16 {
1680    200
1681}
1682
1683/// Init actions specification
1684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1685#[serde(deny_unknown_fields)]
1686#[derive(Default)]
1687pub struct InitSpec {
1688    /// Init steps to run before container starts
1689    #[serde(default)]
1690    pub steps: Vec<InitStep>,
1691}
1692
1693/// Init action step
1694#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1695#[serde(deny_unknown_fields)]
1696pub struct InitStep {
1697    /// Step identifier
1698    pub id: String,
1699
1700    /// Action to perform (e.g., "`init.wait_tcp`")
1701    pub uses: String,
1702
1703    /// Parameters for the action
1704    #[serde(default)]
1705    pub with: InitParams,
1706
1707    /// Number of retries
1708    #[serde(default)]
1709    pub retry: Option<u32>,
1710
1711    /// Maximum time for this step
1712    #[serde(default, with = "duration::option")]
1713    pub timeout: Option<std::time::Duration>,
1714
1715    /// Action on failure
1716    #[serde(default = "default_on_failure")]
1717    pub on_failure: FailureAction,
1718}
1719
1720fn default_on_failure() -> FailureAction {
1721    FailureAction::Fail
1722}
1723
1724/// Init action parameters
1725pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
1726
1727/// Failure action for init steps
1728#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1729#[serde(rename_all = "lowercase")]
1730pub enum FailureAction {
1731    Fail,
1732    Warn,
1733    Continue,
1734}
1735
1736/// Error handling policies
1737#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1738#[serde(deny_unknown_fields)]
1739#[derive(Default)]
1740pub struct ErrorsSpec {
1741    /// Init failure policy
1742    #[serde(default)]
1743    pub on_init_failure: InitFailurePolicy,
1744
1745    /// Panic/restart policy
1746    #[serde(default)]
1747    pub on_panic: PanicPolicy,
1748}
1749
1750/// Init failure policy
1751#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1752#[serde(deny_unknown_fields)]
1753pub struct InitFailurePolicy {
1754    #[serde(default = "default_init_action")]
1755    pub action: InitFailureAction,
1756}
1757
1758impl Default for InitFailurePolicy {
1759    fn default() -> Self {
1760        Self {
1761            action: default_init_action(),
1762        }
1763    }
1764}
1765
1766fn default_init_action() -> InitFailureAction {
1767    InitFailureAction::Fail
1768}
1769
1770/// Init failure action
1771#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1772#[serde(rename_all = "lowercase")]
1773pub enum InitFailureAction {
1774    Fail,
1775    Restart,
1776    Backoff,
1777}
1778
1779/// Panic policy
1780#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1781#[serde(deny_unknown_fields)]
1782pub struct PanicPolicy {
1783    #[serde(default = "default_panic_action")]
1784    pub action: PanicAction,
1785}
1786
1787impl Default for PanicPolicy {
1788    fn default() -> Self {
1789        Self {
1790            action: default_panic_action(),
1791        }
1792    }
1793}
1794
1795fn default_panic_action() -> PanicAction {
1796    PanicAction::Restart
1797}
1798
1799/// Panic action
1800#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1801#[serde(rename_all = "lowercase")]
1802pub enum PanicAction {
1803    Restart,
1804    Shutdown,
1805    Isolate,
1806}
1807
1808// ==========================================================================
1809// Network / Access Control types
1810// ==========================================================================
1811
1812/// A network policy defines an access control group with membership rules
1813/// and service access policies.
1814#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1815pub struct NetworkPolicySpec {
1816    /// Unique network name.
1817    pub name: String,
1818
1819    /// Human-readable description.
1820    #[serde(default, skip_serializing_if = "Option::is_none")]
1821    pub description: Option<String>,
1822
1823    /// CIDR ranges that belong to this network (e.g., "10.200.0.0/16", "192.168.1.0/24").
1824    #[serde(default)]
1825    pub cidrs: Vec<String>,
1826
1827    /// Named members (users, groups, nodes) of this network.
1828    #[serde(default)]
1829    pub members: Vec<NetworkMember>,
1830
1831    /// Access rules defining which services this network can reach.
1832    #[serde(default)]
1833    pub access_rules: Vec<AccessRule>,
1834}
1835
1836/// A member of a network.
1837#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1838pub struct NetworkMember {
1839    /// Member identifier (username, group name, node ID, or CIDR).
1840    pub name: String,
1841    /// Type of member.
1842    #[serde(default)]
1843    pub kind: MemberKind,
1844}
1845
1846/// Type of network member.
1847#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1848#[serde(rename_all = "lowercase")]
1849pub enum MemberKind {
1850    /// An individual user identity.
1851    #[default]
1852    User,
1853    /// A group of users.
1854    Group,
1855    /// A specific cluster node.
1856    Node,
1857    /// A CIDR range (redundant with NetworkPolicySpec.cidrs but allows per-member CIDR).
1858    Cidr,
1859}
1860
1861/// An access rule determining what a network can reach.
1862#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1863pub struct AccessRule {
1864    /// Target service name, or "*" for all services.
1865    #[serde(default = "wildcard")]
1866    pub service: String,
1867
1868    /// Target deployment name, or "*" for all deployments.
1869    #[serde(default = "wildcard")]
1870    pub deployment: String,
1871
1872    /// Specific ports allowed. None means all ports.
1873    #[serde(default, skip_serializing_if = "Option::is_none")]
1874    pub ports: Option<Vec<u16>>,
1875
1876    /// Whether to allow or deny access.
1877    #[serde(default)]
1878    pub action: AccessAction,
1879}
1880
1881fn wildcard() -> String {
1882    "*".to_string()
1883}
1884
1885/// Access control action.
1886#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1887#[serde(rename_all = "lowercase")]
1888pub enum AccessAction {
1889    /// Allow access (default).
1890    #[default]
1891    Allow,
1892    /// Deny access.
1893    Deny,
1894}
1895
1896// ==========================================================================
1897// Container bridge / overlay network types (Docker-compatible)
1898// ==========================================================================
1899//
1900// These types model user-defined bridge or overlay networks that standalone
1901// containers can attach to — the Docker-style "docker network create" model.
1902// They are intentionally named `BridgeNetwork*` to avoid colliding with the
1903// CIDR-ACL `NetworkPolicySpec` types above, which model a completely
1904// different concept (access-control groups).
1905
1906/// A user-defined bridge or overlay network that containers can attach to.
1907#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1908pub struct BridgeNetwork {
1909    /// Opaque server-generated identifier (UUID v4).
1910    pub id: String,
1911
1912    /// Human-readable, unique name (must match `^[a-z0-9][a-z0-9_-]{0,63}$`).
1913    pub name: String,
1914
1915    /// Driver backing the network (bridge vs. overlay).
1916    #[serde(default)]
1917    pub driver: BridgeNetworkDriver,
1918
1919    /// IPv4/IPv6 subnet in CIDR notation (e.g. `"10.240.0.0/24"`).
1920    #[serde(default, skip_serializing_if = "Option::is_none")]
1921    pub subnet: Option<String>,
1922
1923    /// Arbitrary key/value labels for filtering and grouping.
1924    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1925    pub labels: HashMap<String, String>,
1926
1927    /// If true, containers attached to this network cannot reach the outside
1928    /// world — only other containers on the same network.
1929    #[serde(default)]
1930    pub internal: bool,
1931
1932    /// Creation timestamp (UTC, RFC 3339).
1933    #[schema(value_type = String, format = "date-time")]
1934    pub created_at: chrono::DateTime<chrono::Utc>,
1935}
1936
1937/// Backing driver for a [`BridgeNetwork`].
1938#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
1939#[serde(rename_all = "lowercase")]
1940pub enum BridgeNetworkDriver {
1941    /// Linux bridge on the local host (single-host, default).
1942    #[default]
1943    Bridge,
1944    /// Overlay network spanning multiple hosts.
1945    Overlay,
1946}
1947
1948/// A container attached to a [`BridgeNetwork`].
1949#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1950pub struct BridgeNetworkAttachment {
1951    /// Runtime-provided container id.
1952    pub container_id: String,
1953
1954    /// Container name, if known.
1955    #[serde(default, skip_serializing_if = "Option::is_none")]
1956    pub container_name: Option<String>,
1957
1958    /// DNS aliases the container can be reached by on this network.
1959    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1960    pub aliases: Vec<String>,
1961
1962    /// Assigned IPv4 address on the network (if any).
1963    #[serde(default, skip_serializing_if = "Option::is_none")]
1964    pub ipv4: Option<String>,
1965}
1966
1967// ==========================================================================
1968// Registry auth (inline, not persisted) — §3.10 of ZLAYER_SDK_FIXES.md
1969// ==========================================================================
1970//
1971// Inline credentials a client can attach to a single pull or container-create
1972// request without first POSTing them to `/api/v1/credentials/registry`. The
1973// daemon uses them exactly once — they are never logged, never persisted, and
1974// never echoed back on a response.
1975//
1976// For requests that instead want to reuse an already-stored credential, the
1977// `CreateContainerRequest` / `PullImageRequest` DTOs also accept a
1978// `registry_credential_id` pointing at the `RegistryCredentialStore`. Inline
1979// `RegistryAuth` takes precedence when both are provided.
1980
1981/// Inline Docker/OCI registry credentials attached to a single pull request.
1982///
1983/// Prefer persistent credentials via `/api/v1/credentials/registry` for
1984/// long-lived services. Use this inline form for one-off pulls (e.g. CI
1985/// runners fetching a private image for a single job) where persisting a
1986/// credential is undesirable.
1987#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1988pub struct RegistryAuth {
1989    /// Username for the registry (for basic auth) or a placeholder
1990    /// identifier when `auth_type == Token`.
1991    pub username: String,
1992    /// Password or bearer token. **Never** logged or returned on any
1993    /// response — consumed once and dropped.
1994    pub password: String,
1995    /// Which authentication scheme to use against the registry.
1996    #[serde(default = "default_registry_auth_type")]
1997    pub auth_type: RegistryAuthType,
1998}
1999
2000/// Authentication scheme for a [`RegistryAuth`].
2001#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2002#[serde(rename_all = "snake_case")]
2003pub enum RegistryAuthType {
2004    /// HTTP Basic authentication (username + password). Default.
2005    #[default]
2006    Basic,
2007    /// Bearer token authentication. `password` carries the token; `username`
2008    /// is typically a placeholder such as `"oauth2accesstoken"` or `"<token>"`.
2009    Token,
2010}
2011
2012/// Serde default for [`RegistryAuth::auth_type`]. Kept as a free function so
2013/// `#[serde(default = "...")]` can reference it.
2014#[must_use]
2015pub fn default_registry_auth_type() -> RegistryAuthType {
2016    RegistryAuthType::Basic
2017}
2018
2019// ==========================================================================
2020// Container restart policy (Docker-style) — §3.4 of ZLAYER_SDK_FIXES.md
2021// ==========================================================================
2022//
2023// Named `ContainerRestartPolicy` / `ContainerRestartKind` rather than
2024// `RestartPolicy` / `RestartKind` to avoid colliding with ZLayer's existing
2025// `PanicPolicy`/`PanicAction` types and to make the runtime-level (as opposed
2026// to panic-driven) nature of this policy explicit.
2027
2028/// Container-runtime-level restart policy.
2029///
2030/// Maps onto Docker's `HostConfig.RestartPolicy`. Distinct from
2031/// [`PanicPolicy`], which governs what `ZLayer` does in response to an
2032/// application panic (it does not set a Docker restart policy).
2033#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2034#[serde(rename_all = "snake_case", deny_unknown_fields)]
2035pub struct ContainerRestartPolicy {
2036    /// Which restart policy to apply.
2037    pub kind: ContainerRestartKind,
2038
2039    /// For `on_failure` only: maximum number of restart attempts before
2040    /// giving up. Ignored by other kinds. `None` means "retry forever".
2041    #[serde(default, skip_serializing_if = "Option::is_none")]
2042    pub max_attempts: Option<u32>,
2043
2044    /// Humantime-formatted delay between restarts (e.g. `"500ms"`,
2045    /// `"2s"`). Accepted for forward-compatibility but currently ignored
2046    /// by the Docker backend: bollard's `RestartPolicy` has no per-kind
2047    /// delay field. When set, the runtime emits a warning.
2048    #[serde(default, skip_serializing_if = "Option::is_none")]
2049    pub delay: Option<String>,
2050}
2051
2052/// Which flavor of container restart policy to apply.
2053#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2054#[serde(rename_all = "snake_case")]
2055pub enum ContainerRestartKind {
2056    /// Never restart (Docker's `"no"`).
2057    No,
2058    /// Always restart (Docker's `"always"`).
2059    Always,
2060    /// Restart unless the user explicitly stopped the container
2061    /// (Docker's `"unless-stopped"`).
2062    UnlessStopped,
2063    /// Restart only when the container exits with a non-zero code
2064    /// (Docker's `"on-failure"`). Respects `max_attempts`.
2065    OnFailure,
2066}
2067
2068// ==========================================================================
2069// Port mappings (Docker-style container port publishing)
2070// ==========================================================================
2071
2072/// Transport protocol for a published container port.
2073#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2074#[serde(rename_all = "snake_case")]
2075pub enum PortProtocol {
2076    /// TCP (default).
2077    Tcp,
2078    /// UDP.
2079    Udp,
2080}
2081
2082impl Default for PortProtocol {
2083    fn default() -> Self {
2084        default_port_protocol()
2085    }
2086}
2087
2088impl PortProtocol {
2089    /// Return the lowercase string form Docker uses in port-binding keys
2090    /// (e.g. `"tcp"` or `"udp"`).
2091    #[must_use]
2092    pub fn as_str(&self) -> &'static str {
2093        match self {
2094            PortProtocol::Tcp => "tcp",
2095            PortProtocol::Udp => "udp",
2096        }
2097    }
2098}
2099
2100fn default_port_protocol() -> PortProtocol {
2101    PortProtocol::Tcp
2102}
2103
2104fn default_host_ip() -> String {
2105    "0.0.0.0".to_string()
2106}
2107
2108/// A single host-to-container port publish rule (Docker's `-p`).
2109///
2110/// When `host_port` is `None` (or explicitly `Some(0)`), the container runtime
2111/// assigns an ephemeral host port. `host_ip` defaults to `"0.0.0.0"` to bind
2112/// on all interfaces.
2113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2114#[serde(rename_all = "snake_case")]
2115pub struct PortMapping {
2116    /// Host port. `None` (or zero) means "assign an ephemeral port".
2117    #[serde(default, skip_serializing_if = "Option::is_none")]
2118    pub host_port: Option<u16>,
2119    /// Container-side port.
2120    pub container_port: u16,
2121    /// Transport protocol (defaults to TCP).
2122    #[serde(default = "default_port_protocol")]
2123    pub protocol: PortProtocol,
2124    /// Host interface to bind on. Defaults to `"0.0.0.0"` (all interfaces).
2125    #[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
2126    pub host_ip: String,
2127}
2128
2129#[cfg(test)]
2130mod tests {
2131    use super::*;
2132
2133    #[test]
2134    fn port_mapping_defaults_via_serde() {
2135        // Minimal JSON: only container_port. host_port omitted, protocol defaults
2136        // to "tcp", host_ip defaults to "0.0.0.0".
2137        let json = r#"{"container_port": 8080}"#;
2138        let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
2139        assert_eq!(m.container_port, 8080);
2140        assert_eq!(m.host_port, None);
2141        assert_eq!(m.protocol, PortProtocol::Tcp);
2142        assert_eq!(m.host_ip, "0.0.0.0");
2143    }
2144
2145    #[test]
2146    fn port_mapping_skips_none_host_port_and_empty_host_ip() {
2147        let m = PortMapping {
2148            host_port: None,
2149            container_port: 443,
2150            protocol: PortProtocol::Tcp,
2151            host_ip: String::new(),
2152        };
2153        let s = serde_json::to_string(&m).expect("serialize");
2154        // host_port = None should be skipped, host_ip = "" should be skipped.
2155        assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
2156        assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
2157        assert!(s.contains("\"container_port\":443"));
2158        assert!(s.contains("\"protocol\":\"tcp\""));
2159    }
2160
2161    #[test]
2162    fn test_parse_simple_spec() {
2163        let yaml = r"
2164version: v1
2165deployment: test
2166services:
2167  hello:
2168    rtype: service
2169    image:
2170      name: hello-world:latest
2171    endpoints:
2172      - name: http
2173        protocol: http
2174        port: 8080
2175        expose: public
2176";
2177
2178        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2179        assert_eq!(spec.version, "v1");
2180        assert_eq!(spec.deployment, "test");
2181        assert!(spec.services.contains_key("hello"));
2182    }
2183
2184    #[test]
2185    fn test_parse_duration() {
2186        let yaml = r"
2187version: v1
2188deployment: test
2189services:
2190  test:
2191    rtype: service
2192    image:
2193      name: test:latest
2194    health:
2195      timeout: 30s
2196      interval: 1m
2197      start_grace: 5s
2198      check:
2199        type: tcp
2200        port: 8080
2201";
2202
2203        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2204        let health = &spec.services["test"].health;
2205        assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
2206        assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
2207        assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
2208        match &health.check {
2209            HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
2210            _ => panic!("Expected TCP health check"),
2211        }
2212    }
2213
2214    #[test]
2215    fn test_parse_adaptive_scale() {
2216        let yaml = r"
2217version: v1
2218deployment: test
2219services:
2220  test:
2221    rtype: service
2222    image:
2223      name: test:latest
2224    scale:
2225      mode: adaptive
2226      min: 2
2227      max: 10
2228      cooldown: 15s
2229      targets:
2230        cpu: 70
2231        rps: 800
2232";
2233
2234        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2235        let scale = &spec.services["test"].scale;
2236        match scale {
2237            ScaleSpec::Adaptive {
2238                min,
2239                max,
2240                cooldown,
2241                targets,
2242            } => {
2243                assert_eq!(*min, 2);
2244                assert_eq!(*max, 10);
2245                assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
2246                assert_eq!(targets.cpu, Some(70));
2247                assert_eq!(targets.rps, Some(800));
2248            }
2249            _ => panic!("Expected Adaptive scale mode"),
2250        }
2251    }
2252
2253    #[test]
2254    fn test_node_mode_default() {
2255        let yaml = r"
2256version: v1
2257deployment: test
2258services:
2259  hello:
2260    rtype: service
2261    image:
2262      name: hello-world:latest
2263";
2264
2265        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2266        assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
2267        assert!(spec.services["hello"].node_selector.is_none());
2268    }
2269
2270    #[test]
2271    fn test_node_mode_dedicated() {
2272        let yaml = r"
2273version: v1
2274deployment: test
2275services:
2276  api:
2277    rtype: service
2278    image:
2279      name: api:latest
2280    node_mode: dedicated
2281";
2282
2283        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2284        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2285    }
2286
2287    #[test]
2288    fn test_node_mode_exclusive() {
2289        let yaml = r"
2290version: v1
2291deployment: test
2292services:
2293  database:
2294    rtype: service
2295    image:
2296      name: postgres:15
2297    node_mode: exclusive
2298";
2299
2300        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2301        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2302    }
2303
2304    #[test]
2305    fn test_node_selector_with_labels() {
2306        let yaml = r#"
2307version: v1
2308deployment: test
2309services:
2310  ml-worker:
2311    rtype: service
2312    image:
2313      name: ml-worker:latest
2314    node_mode: dedicated
2315    node_selector:
2316      labels:
2317        gpu: "true"
2318        zone: us-east
2319      prefer_labels:
2320        storage: ssd
2321"#;
2322
2323        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2324        let service = &spec.services["ml-worker"];
2325        assert_eq!(service.node_mode, NodeMode::Dedicated);
2326
2327        let selector = service.node_selector.as_ref().unwrap();
2328        assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
2329        assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
2330        assert_eq!(
2331            selector.prefer_labels.get("storage"),
2332            Some(&"ssd".to_string())
2333        );
2334    }
2335
2336    #[test]
2337    fn test_node_mode_serialization_roundtrip() {
2338        use serde_json;
2339
2340        // Test all variants serialize/deserialize correctly
2341        let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
2342        let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
2343
2344        for (mode, expected) in modes.iter().zip(expected_json.iter()) {
2345            let json = serde_json::to_string(mode).unwrap();
2346            assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
2347
2348            let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
2349            assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
2350        }
2351    }
2352
2353    #[test]
2354    fn test_node_selector_empty() {
2355        let yaml = r"
2356version: v1
2357deployment: test
2358services:
2359  api:
2360    rtype: service
2361    image:
2362      name: api:latest
2363    node_selector:
2364      labels: {}
2365";
2366
2367        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2368        let selector = spec.services["api"].node_selector.as_ref().unwrap();
2369        assert!(selector.labels.is_empty());
2370        assert!(selector.prefer_labels.is_empty());
2371    }
2372
2373    #[test]
2374    fn test_mixed_node_modes_in_deployment() {
2375        let yaml = r"
2376version: v1
2377deployment: test
2378services:
2379  redis:
2380    rtype: service
2381    image:
2382      name: redis:alpine
2383    # Default shared mode
2384  api:
2385    rtype: service
2386    image:
2387      name: api:latest
2388    node_mode: dedicated
2389  database:
2390    rtype: service
2391    image:
2392      name: postgres:15
2393    node_mode: exclusive
2394    node_selector:
2395      labels:
2396        storage: ssd
2397";
2398
2399        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2400        assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
2401        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2402        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2403
2404        let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
2405        assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
2406    }
2407
2408    #[test]
2409    fn test_storage_bind_mount() {
2410        let yaml = r"
2411version: v1
2412deployment: test
2413services:
2414  app:
2415    image:
2416      name: app:latest
2417    storage:
2418      - type: bind
2419        source: /host/data
2420        target: /app/data
2421        readonly: true
2422";
2423        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2424        let storage = &spec.services["app"].storage;
2425        assert_eq!(storage.len(), 1);
2426        match &storage[0] {
2427            StorageSpec::Bind {
2428                source,
2429                target,
2430                readonly,
2431            } => {
2432                assert_eq!(source, "/host/data");
2433                assert_eq!(target, "/app/data");
2434                assert!(*readonly);
2435            }
2436            _ => panic!("Expected Bind storage"),
2437        }
2438    }
2439
2440    #[test]
2441    fn test_storage_named_with_tier() {
2442        let yaml = r"
2443version: v1
2444deployment: test
2445services:
2446  app:
2447    image:
2448      name: app:latest
2449    storage:
2450      - type: named
2451        name: my-data
2452        target: /app/data
2453        tier: cached
2454";
2455        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2456        let storage = &spec.services["app"].storage;
2457        match &storage[0] {
2458            StorageSpec::Named {
2459                name, target, tier, ..
2460            } => {
2461                assert_eq!(name, "my-data");
2462                assert_eq!(target, "/app/data");
2463                assert_eq!(*tier, StorageTier::Cached);
2464            }
2465            _ => panic!("Expected Named storage"),
2466        }
2467    }
2468
2469    #[test]
2470    fn test_storage_anonymous() {
2471        let yaml = r"
2472version: v1
2473deployment: test
2474services:
2475  app:
2476    image:
2477      name: app:latest
2478    storage:
2479      - type: anonymous
2480        target: /app/cache
2481";
2482        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2483        let storage = &spec.services["app"].storage;
2484        match &storage[0] {
2485            StorageSpec::Anonymous { target, tier } => {
2486                assert_eq!(target, "/app/cache");
2487                assert_eq!(*tier, StorageTier::Local); // default
2488            }
2489            _ => panic!("Expected Anonymous storage"),
2490        }
2491    }
2492
2493    #[test]
2494    fn test_storage_tmpfs() {
2495        let yaml = r"
2496version: v1
2497deployment: test
2498services:
2499  app:
2500    image:
2501      name: app:latest
2502    storage:
2503      - type: tmpfs
2504        target: /app/tmp
2505        size: 256Mi
2506        mode: 1777
2507";
2508        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2509        let storage = &spec.services["app"].storage;
2510        match &storage[0] {
2511            StorageSpec::Tmpfs { target, size, mode } => {
2512                assert_eq!(target, "/app/tmp");
2513                assert_eq!(size.as_deref(), Some("256Mi"));
2514                assert_eq!(*mode, Some(1777));
2515            }
2516            _ => panic!("Expected Tmpfs storage"),
2517        }
2518    }
2519
2520    #[test]
2521    fn test_storage_s3() {
2522        let yaml = r"
2523version: v1
2524deployment: test
2525services:
2526  app:
2527    image:
2528      name: app:latest
2529    storage:
2530      - type: s3
2531        bucket: my-bucket
2532        prefix: models/
2533        target: /app/models
2534        readonly: true
2535        endpoint: https://s3.us-west-2.amazonaws.com
2536        credentials: aws-creds
2537";
2538        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2539        let storage = &spec.services["app"].storage;
2540        match &storage[0] {
2541            StorageSpec::S3 {
2542                bucket,
2543                prefix,
2544                target,
2545                readonly,
2546                endpoint,
2547                credentials,
2548            } => {
2549                assert_eq!(bucket, "my-bucket");
2550                assert_eq!(prefix.as_deref(), Some("models/"));
2551                assert_eq!(target, "/app/models");
2552                assert!(*readonly);
2553                assert_eq!(
2554                    endpoint.as_deref(),
2555                    Some("https://s3.us-west-2.amazonaws.com")
2556                );
2557                assert_eq!(credentials.as_deref(), Some("aws-creds"));
2558            }
2559            _ => panic!("Expected S3 storage"),
2560        }
2561    }
2562
2563    #[test]
2564    fn test_storage_multiple_types() {
2565        let yaml = r"
2566version: v1
2567deployment: test
2568services:
2569  app:
2570    image:
2571      name: app:latest
2572    storage:
2573      - type: bind
2574        source: /etc/config
2575        target: /app/config
2576        readonly: true
2577      - type: named
2578        name: app-data
2579        target: /app/data
2580      - type: tmpfs
2581        target: /app/tmp
2582";
2583        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2584        let storage = &spec.services["app"].storage;
2585        assert_eq!(storage.len(), 3);
2586        assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
2587        assert!(matches!(&storage[1], StorageSpec::Named { .. }));
2588        assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
2589    }
2590
2591    #[test]
2592    fn test_storage_tier_default() {
2593        let yaml = r"
2594version: v1
2595deployment: test
2596services:
2597  app:
2598    image:
2599      name: app:latest
2600    storage:
2601      - type: named
2602        name: data
2603        target: /data
2604";
2605        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2606        match &spec.services["app"].storage[0] {
2607            StorageSpec::Named { tier, .. } => {
2608                assert_eq!(*tier, StorageTier::Local); // default should be Local
2609            }
2610            _ => panic!("Expected Named storage"),
2611        }
2612    }
2613
2614    // ==========================================================================
2615    // Tunnel configuration tests
2616    // ==========================================================================
2617
2618    #[test]
2619    fn test_endpoint_tunnel_config_basic() {
2620        let yaml = r"
2621version: v1
2622deployment: test
2623services:
2624  api:
2625    image:
2626      name: api:latest
2627    endpoints:
2628      - name: http
2629        protocol: http
2630        port: 8080
2631        tunnel:
2632          enabled: true
2633          remote_port: 8080
2634";
2635        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2636        let endpoint = &spec.services["api"].endpoints[0];
2637        let tunnel = endpoint.tunnel.as_ref().unwrap();
2638        assert!(tunnel.enabled);
2639        assert_eq!(tunnel.remote_port, 8080);
2640        assert!(tunnel.from.is_none());
2641        assert!(tunnel.to.is_none());
2642    }
2643
2644    #[test]
2645    fn test_endpoint_tunnel_config_full() {
2646        let yaml = r"
2647version: v1
2648deployment: test
2649services:
2650  api:
2651    image:
2652      name: api:latest
2653    endpoints:
2654      - name: http
2655        protocol: http
2656        port: 8080
2657        tunnel:
2658          enabled: true
2659          from: node-1
2660          to: ingress-node
2661          remote_port: 9000
2662          expose: public
2663          access:
2664            enabled: true
2665            max_ttl: 4h
2666            audit: true
2667";
2668        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2669        let endpoint = &spec.services["api"].endpoints[0];
2670        let tunnel = endpoint.tunnel.as_ref().unwrap();
2671        assert!(tunnel.enabled);
2672        assert_eq!(tunnel.from, Some("node-1".to_string()));
2673        assert_eq!(tunnel.to, Some("ingress-node".to_string()));
2674        assert_eq!(tunnel.remote_port, 9000);
2675        assert_eq!(tunnel.expose, Some(ExposeType::Public));
2676
2677        let access = tunnel.access.as_ref().unwrap();
2678        assert!(access.enabled);
2679        assert_eq!(access.max_ttl, Some("4h".to_string()));
2680        assert!(access.audit);
2681    }
2682
2683    #[test]
2684    fn test_top_level_tunnel_definition() {
2685        let yaml = r"
2686version: v1
2687deployment: test
2688services: {}
2689tunnels:
2690  db-tunnel:
2691    from: app-node
2692    to: db-node
2693    local_port: 5432
2694    remote_port: 5432
2695    protocol: tcp
2696    expose: internal
2697";
2698        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2699        let tunnel = spec.tunnels.get("db-tunnel").unwrap();
2700        assert_eq!(tunnel.from, "app-node");
2701        assert_eq!(tunnel.to, "db-node");
2702        assert_eq!(tunnel.local_port, 5432);
2703        assert_eq!(tunnel.remote_port, 5432);
2704        assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
2705        assert_eq!(tunnel.expose, ExposeType::Internal);
2706    }
2707
2708    #[test]
2709    fn test_top_level_tunnel_defaults() {
2710        let yaml = r"
2711version: v1
2712deployment: test
2713services: {}
2714tunnels:
2715  simple-tunnel:
2716    from: node-a
2717    to: node-b
2718    local_port: 3000
2719    remote_port: 3000
2720";
2721        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2722        let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
2723        assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); // default
2724        assert_eq!(tunnel.expose, ExposeType::Internal); // default
2725    }
2726
2727    #[test]
2728    fn test_tunnel_protocol_udp() {
2729        let yaml = r"
2730version: v1
2731deployment: test
2732services: {}
2733tunnels:
2734  udp-tunnel:
2735    from: node-a
2736    to: node-b
2737    local_port: 5353
2738    remote_port: 5353
2739    protocol: udp
2740";
2741        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2742        let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
2743        assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
2744    }
2745
2746    #[test]
2747    fn test_endpoint_without_tunnel() {
2748        let yaml = r"
2749version: v1
2750deployment: test
2751services:
2752  api:
2753    image:
2754      name: api:latest
2755    endpoints:
2756      - name: http
2757        protocol: http
2758        port: 8080
2759";
2760        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2761        let endpoint = &spec.services["api"].endpoints[0];
2762        assert!(endpoint.tunnel.is_none());
2763    }
2764
2765    #[test]
2766    fn test_deployment_without_tunnels() {
2767        let yaml = r"
2768version: v1
2769deployment: test
2770services:
2771  api:
2772    image:
2773      name: api:latest
2774";
2775        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2776        assert!(spec.tunnels.is_empty());
2777    }
2778
2779    // ==========================================================================
2780    // ApiSpec tests
2781    // ==========================================================================
2782
2783    #[test]
2784    fn test_spec_without_api_block_uses_defaults() {
2785        let yaml = r"
2786version: v1
2787deployment: test
2788services:
2789  hello:
2790    image:
2791      name: hello-world:latest
2792";
2793        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2794        assert!(spec.api.enabled);
2795        assert_eq!(spec.api.bind, "0.0.0.0:3669");
2796        assert!(spec.api.jwt_secret.is_none());
2797        assert!(spec.api.swagger);
2798    }
2799
2800    #[test]
2801    fn test_spec_with_explicit_api_block() {
2802        let yaml = r#"
2803version: v1
2804deployment: test
2805services:
2806  hello:
2807    image:
2808      name: hello-world:latest
2809api:
2810  enabled: false
2811  bind: "127.0.0.1:9090"
2812  jwt_secret: "my-secret"
2813  swagger: false
2814"#;
2815        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2816        assert!(!spec.api.enabled);
2817        assert_eq!(spec.api.bind, "127.0.0.1:9090");
2818        assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
2819        assert!(!spec.api.swagger);
2820    }
2821
2822    #[test]
2823    fn test_spec_with_partial_api_block() {
2824        let yaml = r#"
2825version: v1
2826deployment: test
2827services:
2828  hello:
2829    image:
2830      name: hello-world:latest
2831api:
2832  bind: "0.0.0.0:3000"
2833"#;
2834        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2835        assert!(spec.api.enabled); // default true
2836        assert_eq!(spec.api.bind, "0.0.0.0:3000");
2837        assert!(spec.api.jwt_secret.is_none()); // default None
2838        assert!(spec.api.swagger); // default true
2839    }
2840
2841    // ==========================================================================
2842    // NetworkPolicySpec tests
2843    // ==========================================================================
2844
2845    #[test]
2846    fn test_network_policy_spec_roundtrip() {
2847        let spec = NetworkPolicySpec {
2848            name: "corp-vpn".to_string(),
2849            description: Some("Corporate VPN network".to_string()),
2850            cidrs: vec!["10.200.0.0/16".to_string()],
2851            members: vec![
2852                NetworkMember {
2853                    name: "alice".to_string(),
2854                    kind: MemberKind::User,
2855                },
2856                NetworkMember {
2857                    name: "ops-team".to_string(),
2858                    kind: MemberKind::Group,
2859                },
2860                NetworkMember {
2861                    name: "node-01".to_string(),
2862                    kind: MemberKind::Node,
2863                },
2864            ],
2865            access_rules: vec![
2866                AccessRule {
2867                    service: "api-gateway".to_string(),
2868                    deployment: "*".to_string(),
2869                    ports: Some(vec![443, 8080]),
2870                    action: AccessAction::Allow,
2871                },
2872                AccessRule {
2873                    service: "*".to_string(),
2874                    deployment: "staging".to_string(),
2875                    ports: None,
2876                    action: AccessAction::Deny,
2877                },
2878            ],
2879        };
2880
2881        let yaml = serde_yaml::to_string(&spec).unwrap();
2882        let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
2883        assert_eq!(spec, deserialized);
2884    }
2885
2886    #[test]
2887    fn test_network_policy_spec_defaults() {
2888        let yaml = r"
2889name: minimal
2890";
2891        let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
2892        assert_eq!(spec.name, "minimal");
2893        assert!(spec.description.is_none());
2894        assert!(spec.cidrs.is_empty());
2895        assert!(spec.members.is_empty());
2896        assert!(spec.access_rules.is_empty());
2897    }
2898
2899    #[test]
2900    fn test_access_rule_defaults() {
2901        let yaml = "{}";
2902        let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
2903        assert_eq!(rule.service, "*");
2904        assert_eq!(rule.deployment, "*");
2905        assert!(rule.ports.is_none());
2906        assert_eq!(rule.action, AccessAction::Allow);
2907    }
2908
2909    #[test]
2910    fn test_member_kind_defaults_to_user() {
2911        let yaml = r"
2912name: bob
2913";
2914        let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
2915        assert_eq!(member.name, "bob");
2916        assert_eq!(member.kind, MemberKind::User);
2917    }
2918
2919    #[test]
2920    fn test_member_kind_variants() {
2921        for (input, expected) in [
2922            ("user", MemberKind::User),
2923            ("group", MemberKind::Group),
2924            ("node", MemberKind::Node),
2925            ("cidr", MemberKind::Cidr),
2926        ] {
2927            let yaml = format!("name: test\nkind: {input}");
2928            let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
2929            assert_eq!(member.kind, expected);
2930        }
2931    }
2932
2933    #[test]
2934    fn test_access_action_variants() {
2935        // Test via a wrapper struct since bare enums need a YAML tag
2936        #[derive(Debug, Deserialize)]
2937        struct Wrapper {
2938            action: AccessAction,
2939        }
2940
2941        let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
2942        let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
2943
2944        assert_eq!(allow.action, AccessAction::Allow);
2945        assert_eq!(deny.action, AccessAction::Deny);
2946    }
2947
2948    #[test]
2949    fn test_network_policy_spec_default_impl() {
2950        let spec = NetworkPolicySpec::default();
2951        assert_eq!(spec.name, "");
2952        assert!(spec.description.is_none());
2953        assert!(spec.cidrs.is_empty());
2954        assert!(spec.members.is_empty());
2955        assert!(spec.access_rules.is_empty());
2956    }
2957
2958    #[test]
2959    fn container_restart_policy_serde_roundtrip_all_kinds() {
2960        // Exercise every `ContainerRestartKind` variant via a JSON roundtrip.
2961        // Covers the `snake_case` rename (`unless_stopped`, `on_failure`) and
2962        // the optional `max_attempts` / `delay` fields. Validates the wire
2963        // format the API will expose under `/v1/containers`.
2964        let cases = [
2965            (
2966                ContainerRestartPolicy {
2967                    kind: ContainerRestartKind::No,
2968                    max_attempts: None,
2969                    delay: None,
2970                },
2971                r#"{"kind":"no"}"#,
2972            ),
2973            (
2974                ContainerRestartPolicy {
2975                    kind: ContainerRestartKind::Always,
2976                    max_attempts: None,
2977                    delay: Some("500ms".to_string()),
2978                },
2979                r#"{"kind":"always","delay":"500ms"}"#,
2980            ),
2981            (
2982                ContainerRestartPolicy {
2983                    kind: ContainerRestartKind::UnlessStopped,
2984                    max_attempts: None,
2985                    delay: None,
2986                },
2987                r#"{"kind":"unless_stopped"}"#,
2988            ),
2989            (
2990                ContainerRestartPolicy {
2991                    kind: ContainerRestartKind::OnFailure,
2992                    max_attempts: Some(5),
2993                    delay: None,
2994                },
2995                r#"{"kind":"on_failure","max_attempts":5}"#,
2996            ),
2997        ];
2998
2999        for (value, expected_json) in &cases {
3000            let serialized = serde_json::to_string(value).expect("serialize");
3001            assert_eq!(&serialized, expected_json, "serialize mismatch");
3002            let round: ContainerRestartPolicy =
3003                serde_json::from_str(&serialized).expect("deserialize");
3004            assert_eq!(&round, value, "roundtrip mismatch");
3005        }
3006    }
3007
3008    // -- §3.10: RegistryAuth ------------------------------------------------
3009
3010    #[test]
3011    fn registry_auth_type_serializes_snake_case() {
3012        assert_eq!(
3013            serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
3014            "\"basic\""
3015        );
3016        assert_eq!(
3017            serde_json::to_string(&RegistryAuthType::Token).unwrap(),
3018            "\"token\""
3019        );
3020    }
3021
3022    #[test]
3023    fn registry_auth_default_auth_type_is_basic() {
3024        // When `auth_type` is omitted on the wire, the serde default kicks in.
3025        let json = r#"{"username":"u","password":"p"}"#;
3026        let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3027        assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
3028        assert_eq!(parsed.username, "u");
3029        assert_eq!(parsed.password, "p");
3030    }
3031
3032    #[test]
3033    fn registry_auth_serde_roundtrip_both_variants() {
3034        for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
3035            let cred = RegistryAuth {
3036                username: "ci-bot".to_string(),
3037                password: "s3cret".to_string(),
3038                auth_type: variant,
3039            };
3040            let serialized = serde_json::to_string(&cred).expect("serialize");
3041            let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
3042            assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
3043        }
3044    }
3045
3046    #[test]
3047    fn registry_auth_explicit_token_type_parses() {
3048        let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
3049        let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3050        assert_eq!(parsed.auth_type, RegistryAuthType::Token);
3051    }
3052
3053    #[test]
3054    fn target_platform_as_oci_str() {
3055        assert_eq!(
3056            TargetPlatform::new(OsKind::Linux, ArchKind::Amd64).as_oci_str(),
3057            "linux/amd64"
3058        );
3059        assert_eq!(
3060            TargetPlatform::new(OsKind::Windows, ArchKind::Arm64).as_oci_str(),
3061            "windows/arm64"
3062        );
3063        assert_eq!(
3064            TargetPlatform::new(OsKind::Macos, ArchKind::Arm64).as_oci_str(),
3065            "darwin/arm64"
3066        );
3067    }
3068
3069    #[test]
3070    fn os_kind_from_rust_consts() {
3071        assert_eq!(OsKind::from_rust_os("linux"), Some(OsKind::Linux));
3072        assert_eq!(OsKind::from_rust_os("windows"), Some(OsKind::Windows));
3073        assert_eq!(OsKind::from_rust_os("macos"), Some(OsKind::Macos));
3074        assert_eq!(OsKind::from_rust_os("freebsd"), None);
3075    }
3076
3077    #[test]
3078    fn arch_kind_from_rust_consts() {
3079        assert_eq!(ArchKind::from_rust_arch("x86_64"), Some(ArchKind::Amd64));
3080        assert_eq!(ArchKind::from_rust_arch("aarch64"), Some(ArchKind::Arm64));
3081        assert_eq!(ArchKind::from_rust_arch("riscv64"), None);
3082    }
3083
3084    #[test]
3085    fn service_spec_platform_yaml_round_trip_none() {
3086        // Omitting `platform` from YAML should deserialize as None without error,
3087        // even though ServiceSpec has `#[serde(deny_unknown_fields)]`.
3088        let yaml = r"
3089version: v1
3090deployment: test
3091services:
3092  app:
3093    rtype: service
3094    image:
3095      name: nginx:latest
3096";
3097        let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3098        assert!(spec.services["app"].platform.is_none());
3099    }
3100
3101    #[test]
3102    fn service_spec_platform_yaml_round_trip_some() {
3103        let yaml = r"
3104version: v1
3105deployment: test
3106services:
3107  app:
3108    rtype: service
3109    image:
3110      name: nginx:latest
3111    platform:
3112      os: windows
3113      arch: amd64
3114";
3115        let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3116        assert_eq!(
3117            spec.services["app"].platform,
3118            Some(TargetPlatform::new(OsKind::Windows, ArchKind::Amd64))
3119        );
3120    }
3121
3122    #[test]
3123    fn service_spec_platform_serializes_omitted_when_none() {
3124        // Build a minimal ServiceSpec via YAML to avoid enumerating every field
3125        // (ServiceSpec has no Default impl and no named-struct helper).
3126        let yaml = r"
3127version: v1
3128deployment: test
3129services:
3130  app:
3131    rtype: service
3132    image:
3133      name: nginx:latest
3134";
3135        let mut spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3136        let service = spec.services.get_mut("app").expect("service present");
3137        service.platform = None;
3138        let rendered = serde_yaml::to_string(service).expect("render");
3139        assert!(
3140            !rendered.contains("platform"),
3141            "platform must be omitted when None: {rendered}"
3142        );
3143    }
3144
3145    #[test]
3146    fn target_platform_os_version_builder() {
3147        let p =
3148            TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3149        assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3150        assert_eq!(p.os, OsKind::Windows);
3151        assert_eq!(p.arch, ArchKind::Amd64);
3152    }
3153
3154    #[test]
3155    fn target_platform_os_version_yaml_roundtrip() {
3156        let yaml = "os: windows\narch: amd64\nosVersion: 10.0.26100.1\n";
3157        let p: TargetPlatform = serde_yaml::from_str(yaml).expect("yaml parse");
3158        assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3159        assert_eq!(p.os, OsKind::Windows);
3160        assert_eq!(p.arch, ArchKind::Amd64);
3161    }
3162
3163    #[test]
3164    fn target_platform_os_version_yaml_omits_when_none() {
3165        let p = TargetPlatform::new(OsKind::Linux, ArchKind::Amd64);
3166        let rendered = serde_yaml::to_string(&p).expect("render");
3167        assert!(
3168            !rendered.contains("osVersion"),
3169            "osVersion must be omitted when None: {rendered}"
3170        );
3171    }
3172
3173    #[test]
3174    fn target_platform_as_detailed_str_includes_version() {
3175        let without = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).as_detailed_str();
3176        assert_eq!(without, "windows/amd64");
3177
3178        let with = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64)
3179            .with_os_version("10.0.26100.1")
3180            .as_detailed_str();
3181        assert_eq!(with, "windows/amd64 (os.version=10.0.26100.1)");
3182    }
3183
3184    #[test]
3185    fn target_platform_display_ignores_version() {
3186        // Display deliberately stays terse so existing log lines don't change.
3187        let p =
3188            TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3189        assert_eq!(format!("{p}"), "windows/amd64");
3190    }
3191}