Skip to main content

zlayer_types/spec/
types.rs

1//! `ZLayer` V1 Service Specification Types
2//!
3//! This module defines all types for parsing and validating `ZLayer` deployment specs.
4
5mod duration {
6    use humantime::format_duration;
7    use serde::{Deserialize, Deserializer, Serializer};
8    use std::time::Duration;
9
10    #[allow(clippy::ref_option)]
11    pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
12    where
13        S: Serializer,
14    {
15        match duration {
16            Some(d) => serializer.serialize_str(&format_duration(*d).to_string()),
17            None => serializer.serialize_none(),
18        }
19    }
20
21    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
22    where
23        D: Deserializer<'de>,
24    {
25        use serde::de::Error;
26        let s: Option<String> = Option::deserialize(deserializer)?;
27        match s {
28            Some(s) => humantime::parse_duration(&s)
29                .map(Some)
30                .map_err(|e| D::Error::custom(format!("invalid duration: {e}"))),
31            None => Ok(None),
32        }
33    }
34
35    pub mod option {
36        pub use super::*;
37    }
38
39    /// Serde module for required (non-Option) Duration fields
40    pub mod required {
41        use humantime::format_duration;
42        use serde::{Deserialize, Deserializer, Serializer};
43        use std::time::Duration;
44
45        pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
46        where
47            S: Serializer,
48        {
49            serializer.serialize_str(&format_duration(*duration).to_string())
50        }
51
52        pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
53        where
54            D: Deserializer<'de>,
55        {
56            use serde::de::Error;
57            let s: String = String::deserialize(deserializer)?;
58            humantime::parse_duration(&s)
59                .map_err(|e| D::Error::custom(format!("invalid duration: {e}")))
60        }
61    }
62}
63
64use serde::{Deserialize, Serialize};
65use std::collections::HashMap;
66use validator::Validate;
67
68/// How service replicas are allocated to nodes
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum NodeMode {
72    /// Containers placed on any node with capacity (default, bin-packing)
73    #[default]
74    Shared,
75    /// Each replica gets its own dedicated node (1:1 mapping)
76    Dedicated,
77    /// Service is the ONLY thing on its nodes (no other services)
78    Exclusive,
79}
80
81/// Service type - determines runtime behavior and scaling model
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum ServiceType {
85    /// Standard long-running container service
86    #[default]
87    Standard,
88    /// WASM-based HTTP service (wasi:http/incoming-handler)
89    WasmHttp,
90    /// WASM-based general plugin (zlayer:plugin handler - full host access)
91    WasmPlugin,
92    /// WASM-based stateless request/response transformer
93    WasmTransformer,
94    /// WASM-based authenticator plugin (secrets + KV + HTTP)
95    WasmAuthenticator,
96    /// WASM-based rate limiter (KV + metrics)
97    WasmRateLimiter,
98    /// WASM-based request/response middleware
99    WasmMiddleware,
100    /// WASM-based custom router
101    WasmRouter,
102    /// Run-to-completion job
103    Job,
104}
105
106/// Storage performance tier
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
108#[serde(rename_all = "snake_case")]
109pub enum StorageTier {
110    /// Direct local filesystem (SSD/NVMe) - SQLite-safe, fast fsync
111    #[default]
112    Local,
113    /// bcache-backed tiered storage (SSD cache + slower backend)
114    Cached,
115    /// NFS/network storage - NOT SQLite-safe (will warn)
116    Network,
117}
118
119/// Node selection constraints for service placement
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct NodeSelector {
123    /// Required labels that nodes must have (all must match)
124    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
125    pub labels: HashMap<String, String>,
126    /// Preferred labels (soft constraint, nodes with these are preferred)
127    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
128    pub prefer_labels: HashMap<String, String>,
129}
130
131/// Operating system a service needs to run on.
132///
133/// Mirrors the OS half of an OCI platform descriptor. Canonical wire strings
134/// match Go's `GOOS` values (e.g. `"linux"`, `"windows"`, `"darwin"`).
135#[derive(
136    Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
137)]
138#[serde(rename_all = "lowercase")]
139pub enum OsKind {
140    Linux,
141    Windows,
142    Macos,
143}
144
145impl OsKind {
146    /// Canonical OCI-style string (`"linux"` / `"windows"` / `"darwin"`).
147    /// This is the same convention `Runtime.platform_resolver` uses.
148    #[must_use]
149    pub const fn as_oci_str(self) -> &'static str {
150        match self {
151            OsKind::Linux => "linux",
152            OsKind::Windows => "windows",
153            OsKind::Macos => "darwin",
154        }
155    }
156
157    /// Detect from `std::env::consts::OS`. Unknown values return `None`.
158    #[must_use]
159    pub fn from_rust_os(s: &str) -> Option<Self> {
160        match s {
161            "linux" => Some(Self::Linux),
162            "windows" => Some(Self::Windows),
163            "macos" => Some(Self::Macos),
164            _ => None,
165        }
166    }
167
168    /// Parse the OCI-canonical OS string as written in an image manifest's
169    /// `config.os` field (lowercase: `"linux"` / `"windows"` / `"darwin"`).
170    /// Unknown or empty values return `None`.
171    ///
172    /// This is the inverse of [`Self::as_oci_str`] and is used by the
173    /// registry's manifest-OS inspection (see `fetch_image_os`).
174    #[must_use]
175    pub fn from_oci_str(s: &str) -> Option<Self> {
176        match s {
177            "linux" => Some(Self::Linux),
178            "windows" => Some(Self::Windows),
179            "darwin" => Some(Self::Macos),
180            _ => None,
181        }
182    }
183}
184
185/// CPU architecture a service needs. Mirrors the arch half of an OCI platform.
186#[derive(
187    Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
188)]
189#[serde(rename_all = "lowercase")]
190pub enum ArchKind {
191    Amd64,
192    Arm64,
193}
194
195impl ArchKind {
196    /// Canonical OCI-style string (`"amd64"` / `"arm64"`).
197    #[must_use]
198    pub const fn as_oci_str(self) -> &'static str {
199        match self {
200            ArchKind::Amd64 => "amd64",
201            ArchKind::Arm64 => "arm64",
202        }
203    }
204
205    /// Detect from `std::env::consts::ARCH`. Unknown values return `None`.
206    #[must_use]
207    pub fn from_rust_arch(s: &str) -> Option<Self> {
208        match s {
209            "x86_64" => Some(Self::Amd64),
210            "aarch64" => Some(Self::Arm64),
211            _ => None,
212        }
213    }
214}
215
216/// Platform a service targets. `None` on `ServiceSpec.platform` means
217/// "any agent is acceptable" (preserves backward compatibility).
218//
219// NOTE: no `Copy`. `os_version: Option<String>` rules it out. `OsKind` / `ArchKind`
220// are still `Copy`, so field-level borrows stay ergonomic.
221#[derive(
222    Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
223)]
224pub struct TargetPlatform {
225    pub os: OsKind,
226    pub arch: ArchKind,
227    /// Optional OS version constraint — primarily for Windows multi-platform
228    /// images, where `platform.os.version` in the OCI index distinguishes build
229    /// families (e.g. `10.0.26100.*` for Server 2025 / Win11 24H2,
230    /// `10.0.20348.*` for Server 2022). When set on a Windows target the
231    /// registry platform resolver prefers manifest entries whose `os.version`
232    /// matches this value exactly or shares a `major.minor.build` prefix.
233    /// Unused on Linux/macOS platforms.
234    #[serde(default, rename = "osVersion", skip_serializing_if = "Option::is_none")]
235    pub os_version: Option<String>,
236}
237
238impl TargetPlatform {
239    #[must_use]
240    pub const fn new(os: OsKind, arch: ArchKind) -> Self {
241        Self {
242            os,
243            arch,
244            os_version: None,
245        }
246    }
247
248    /// Constrain the platform to a specific `os.version` string.
249    ///
250    /// Applies to Windows targets: the registry resolver matches manifest
251    /// entries whose `platform.os.version` equals this value or starts with it
252    /// (treated as a `major.minor.build` prefix). Has no effect on Linux/macOS.
253    #[must_use]
254    pub fn with_os_version(mut self, v: impl Into<String>) -> Self {
255        self.os_version = Some(v.into());
256        self
257    }
258
259    /// Canonical OCI-style string (`"linux/amd64"`, `"windows/arm64"`).
260    ///
261    /// Does NOT include `os_version` — use [`Self::as_detailed_str`] when the
262    /// version matters (e.g. for error/log messages that need to distinguish
263    /// between Windows build families).
264    #[must_use]
265    pub fn as_oci_str(self) -> String {
266        format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
267    }
268
269    /// Like [`Self::as_oci_str`] but appends ` (os.version=…)` when an
270    /// `os_version` constraint is set. Intended for diagnostics, not for
271    /// matching against manifest entries.
272    #[must_use]
273    pub fn as_detailed_str(&self) -> String {
274        match &self.os_version {
275            Some(v) => format!(
276                "{}/{} (os.version={v})",
277                self.os.as_oci_str(),
278                self.arch.as_oci_str()
279            ),
280            None => format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str()),
281        }
282    }
283}
284
285impl std::fmt::Display for TargetPlatform {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        write!(f, "{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
288    }
289}
290
291/// Explicit capability declarations for WASM modules.
292/// Controls which host interfaces are linked and available to the component.
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294#[serde(deny_unknown_fields)]
295#[allow(clippy::struct_excessive_bools)]
296pub struct WasmCapabilities {
297    /// Config interface access (zlayer:plugin/config)
298    #[serde(default = "default_true")]
299    pub config: bool,
300    /// Key-value storage access (zlayer:plugin/keyvalue)
301    #[serde(default = "default_true")]
302    pub keyvalue: bool,
303    /// Logging access (zlayer:plugin/logging)
304    #[serde(default = "default_true")]
305    pub logging: bool,
306    /// Secrets access (zlayer:plugin/secrets)
307    #[serde(default)]
308    pub secrets: bool,
309    /// Metrics emission (zlayer:plugin/metrics)
310    #[serde(default = "default_true")]
311    pub metrics: bool,
312    /// HTTP client for outgoing requests (wasi:http/outgoing-handler)
313    #[serde(default)]
314    pub http_client: bool,
315    /// WASI CLI access (args, env, stdio)
316    #[serde(default)]
317    pub cli: bool,
318    /// WASI filesystem access
319    #[serde(default)]
320    pub filesystem: bool,
321    /// WASI sockets access (TCP/UDP)
322    #[serde(default)]
323    pub sockets: bool,
324}
325
326impl Default for WasmCapabilities {
327    fn default() -> Self {
328        Self {
329            config: true,
330            keyvalue: true,
331            logging: true,
332            secrets: false,
333            metrics: true,
334            http_client: false,
335            cli: false,
336            filesystem: false,
337            sockets: false,
338        }
339    }
340}
341
342/// Pre-opened directory for WASM filesystem access
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
344#[serde(deny_unknown_fields)]
345pub struct WasmPreopen {
346    /// Host path to mount
347    pub source: String,
348    /// Guest path (visible to WASM module)
349    pub target: String,
350    /// Read-only access (default: false)
351    #[serde(default)]
352    pub readonly: bool,
353}
354
355/// Comprehensive configuration for all WASM service types.
356///
357/// Replaces the previous `WasmHttpConfig` with resource limits, capability
358/// declarations, networking controls, and storage configuration.
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
360#[serde(deny_unknown_fields)]
361#[allow(clippy::struct_excessive_bools)]
362pub struct WasmConfig {
363    // --- Instance Management ---
364    /// Minimum number of warm instances to keep ready
365    #[serde(default = "default_min_instances")]
366    pub min_instances: u32,
367    /// Maximum number of instances to scale to
368    #[serde(default = "default_max_instances")]
369    pub max_instances: u32,
370    /// Time before idle instances are terminated
371    #[serde(default = "default_idle_timeout", with = "duration::required")]
372    pub idle_timeout: std::time::Duration,
373    /// Maximum time for a single request
374    #[serde(default = "default_request_timeout", with = "duration::required")]
375    pub request_timeout: std::time::Duration,
376
377    // --- Resource Limits ---
378    /// Maximum linear memory (e.g., "64Mi", "256Mi")
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub max_memory: Option<String>,
381    /// Maximum fuel (instruction count limit, 0 = unlimited)
382    #[serde(default)]
383    pub max_fuel: u64,
384    /// Epoch interval for cooperative preemption
385    #[serde(
386        default,
387        skip_serializing_if = "Option::is_none",
388        with = "duration::option"
389    )]
390    pub epoch_interval: Option<std::time::Duration>,
391
392    // --- Capabilities ---
393    /// Explicit capability grants (overrides world defaults when restricting)
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub capabilities: Option<WasmCapabilities>,
396
397    // --- Networking ---
398    /// Allow outgoing HTTP requests (default: true)
399    #[serde(default = "default_true")]
400    pub allow_http_outgoing: bool,
401    /// Allowed outgoing HTTP hosts (empty = all allowed)
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub allowed_hosts: Vec<String>,
404    /// Allow raw TCP sockets (default: false)
405    #[serde(default)]
406    pub allow_tcp: bool,
407    /// Allow raw UDP sockets (default: false)
408    #[serde(default)]
409    pub allow_udp: bool,
410
411    // --- Storage ---
412    /// Pre-opened directories (host path -> guest path)
413    #[serde(default, skip_serializing_if = "Vec::is_empty")]
414    pub preopens: Vec<WasmPreopen>,
415    /// Enable KV store access (default: true)
416    #[serde(default = "default_true")]
417    pub kv_enabled: bool,
418    /// KV store namespace (default: service name)
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub kv_namespace: Option<String>,
421    /// KV store max value size in bytes (default: 1MB)
422    #[serde(default = "default_kv_max_value_size")]
423    pub kv_max_value_size: u64,
424
425    // --- Secrets ---
426    /// Secret names accessible to this WASM module
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub secrets: Vec<String>,
429
430    // --- Performance ---
431    /// Pre-compile on deploy to reduce cold start (default: true)
432    #[serde(default = "default_true")]
433    pub precompile: bool,
434}
435
436fn default_kv_max_value_size() -> u64 {
437    1_048_576 // 1MB
438}
439
440impl Default for WasmConfig {
441    fn default() -> Self {
442        Self {
443            min_instances: default_min_instances(),
444            max_instances: default_max_instances(),
445            idle_timeout: default_idle_timeout(),
446            request_timeout: default_request_timeout(),
447            max_memory: None,
448            max_fuel: 0,
449            epoch_interval: None,
450            capabilities: None,
451            allow_http_outgoing: true,
452            allowed_hosts: Vec::new(),
453            allow_tcp: false,
454            allow_udp: false,
455            preopens: Vec::new(),
456            kv_enabled: true,
457            kv_namespace: None,
458            kv_max_value_size: default_kv_max_value_size(),
459            secrets: Vec::new(),
460            precompile: true,
461        }
462    }
463}
464
465/// Configuration for WASM HTTP services with instance pooling
466#[deprecated(note = "Use WasmConfig instead")]
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
468#[serde(deny_unknown_fields)]
469pub struct WasmHttpConfig {
470    /// Minimum number of warm instances to keep ready
471    #[serde(default = "default_min_instances")]
472    pub min_instances: u32,
473    /// Maximum number of instances to scale to
474    #[serde(default = "default_max_instances")]
475    pub max_instances: u32,
476    /// Time before idle instances are terminated
477    #[serde(default = "default_idle_timeout", with = "duration::required")]
478    pub idle_timeout: std::time::Duration,
479    /// Maximum time for a single request
480    #[serde(default = "default_request_timeout", with = "duration::required")]
481    pub request_timeout: std::time::Duration,
482}
483
484fn default_min_instances() -> u32 {
485    0
486}
487
488fn default_max_instances() -> u32 {
489    10
490}
491
492fn default_idle_timeout() -> std::time::Duration {
493    std::time::Duration::from_secs(300)
494}
495
496fn default_request_timeout() -> std::time::Duration {
497    std::time::Duration::from_secs(30)
498}
499
500#[allow(deprecated)]
501impl Default for WasmHttpConfig {
502    fn default() -> Self {
503        Self {
504            min_instances: default_min_instances(),
505            max_instances: default_max_instances(),
506            idle_timeout: default_idle_timeout(),
507            request_timeout: default_request_timeout(),
508        }
509    }
510}
511
512#[allow(deprecated)]
513impl From<WasmHttpConfig> for WasmConfig {
514    fn from(old: WasmHttpConfig) -> Self {
515        Self {
516            min_instances: old.min_instances,
517            max_instances: old.max_instances,
518            idle_timeout: old.idle_timeout,
519            request_timeout: old.request_timeout,
520            ..Default::default()
521        }
522    }
523}
524
525impl ServiceType {
526    /// Returns true if this is any WASM service type
527    #[must_use]
528    pub fn is_wasm(&self) -> bool {
529        matches!(
530            self,
531            ServiceType::WasmHttp
532                | ServiceType::WasmPlugin
533                | ServiceType::WasmTransformer
534                | ServiceType::WasmAuthenticator
535                | ServiceType::WasmRateLimiter
536                | ServiceType::WasmMiddleware
537                | ServiceType::WasmRouter
538        )
539    }
540
541    /// Returns the default capabilities for this WASM service type.
542    /// Returns None for non-WASM types.
543    #[must_use]
544    pub fn default_wasm_capabilities(&self) -> Option<WasmCapabilities> {
545        match self {
546            ServiceType::WasmHttp | ServiceType::WasmRouter => Some(WasmCapabilities {
547                config: true,
548                keyvalue: true,
549                logging: true,
550                secrets: false,
551                metrics: false,
552                http_client: true,
553                cli: false,
554                filesystem: false,
555                sockets: false,
556            }),
557            ServiceType::WasmPlugin => Some(WasmCapabilities {
558                config: true,
559                keyvalue: true,
560                logging: true,
561                secrets: true,
562                metrics: true,
563                http_client: true,
564                cli: true,
565                filesystem: true,
566                sockets: false,
567            }),
568            ServiceType::WasmTransformer => Some(WasmCapabilities {
569                config: false,
570                keyvalue: false,
571                logging: true,
572                secrets: false,
573                metrics: false,
574                http_client: false,
575                cli: true,
576                filesystem: false,
577                sockets: false,
578            }),
579            ServiceType::WasmAuthenticator => Some(WasmCapabilities {
580                config: true,
581                keyvalue: false,
582                logging: true,
583                secrets: true,
584                metrics: false,
585                http_client: true,
586                cli: false,
587                filesystem: false,
588                sockets: false,
589            }),
590            ServiceType::WasmRateLimiter => Some(WasmCapabilities {
591                config: true,
592                keyvalue: true,
593                logging: true,
594                secrets: false,
595                metrics: true,
596                http_client: false,
597                cli: true,
598                filesystem: false,
599                sockets: false,
600            }),
601            ServiceType::WasmMiddleware => Some(WasmCapabilities {
602                config: true,
603                keyvalue: false,
604                logging: true,
605                secrets: false,
606                metrics: false,
607                http_client: true,
608                cli: false,
609                filesystem: false,
610                sockets: false,
611            }),
612            _ => None,
613        }
614    }
615}
616
617fn default_api_bind() -> String {
618    "0.0.0.0:3669".to_string()
619}
620
621/// API server configuration (embedded in deploy/up flows)
622#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
623pub struct ApiSpec {
624    /// Enable the API server (default: true)
625    #[serde(default = "default_true")]
626    pub enabled: bool,
627    /// Bind address (default: "0.0.0.0:3669")
628    #[serde(default = "default_api_bind")]
629    pub bind: String,
630    /// JWT secret (reads `ZLAYER_JWT_SECRET` env var if not set)
631    #[serde(default)]
632    pub jwt_secret: Option<String>,
633    /// Enable Swagger UI (default: true)
634    #[serde(default = "default_true")]
635    pub swagger: bool,
636}
637
638impl Default for ApiSpec {
639    fn default() -> Self {
640        Self {
641            enabled: true,
642            bind: default_api_bind(),
643            jwt_secret: None,
644            swagger: true,
645        }
646    }
647}
648
649/// Top-level deployment specification
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
651#[serde(deny_unknown_fields)]
652pub struct DeploymentSpec {
653    /// Spec version (must be "v1")
654    #[validate(custom(function = "crate::spec::validate::validate_version_wrapper"))]
655    pub version: String,
656
657    /// Deployment name (used for overlays, DNS)
658    #[validate(custom(function = "crate::spec::validate::validate_deployment_name_wrapper"))]
659    pub deployment: String,
660
661    /// Service definitions
662    #[serde(default)]
663    #[validate(nested)]
664    pub services: HashMap<String, ServiceSpec>,
665
666    /// External service definitions (proxy backends without containers)
667    ///
668    /// External services register static backend addresses with the proxy
669    /// for host/path-based routing without starting any containers.
670    /// Useful for proxying to services running outside of `ZLayer`
671    /// (e.g., on other machines reachable via VPN).
672    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
673    #[validate(nested)]
674    pub externals: HashMap<String, ExternalSpec>,
675
676    /// Top-level tunnel definitions (not tied to service endpoints)
677    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
678    pub tunnels: HashMap<String, TunnelDefinition>,
679
680    /// API server configuration (enabled by default)
681    #[serde(default)]
682    pub api: ApiSpec,
683}
684
685/// External service specification (proxy backend without a container)
686///
687/// Defines a service that is not managed by `ZLayer` but should be proxied
688/// through `ZLayer`'s reverse proxy. The proxy registers static backend
689/// addresses and routes traffic based on endpoint host/path matching.
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
691#[serde(deny_unknown_fields)]
692pub struct ExternalSpec {
693    /// Static backend addresses (e.g., `["100.64.1.5:8096", "192.168.1.10:8096"]`)
694    ///
695    /// These are the upstream addresses the proxy will forward traffic to.
696    /// At least one backend is required.
697    #[validate(length(min = 1, message = "at least one backend address is required"))]
698    pub backends: Vec<String>,
699
700    /// Endpoint definitions (proxy bindings)
701    ///
702    /// Defines how public/internal traffic is routed to this external service.
703    #[serde(default)]
704    #[validate(nested)]
705    pub endpoints: Vec<EndpointSpec>,
706
707    /// Health check configuration
708    ///
709    /// When specified, the proxy will health-check backends and remove
710    /// unhealthy ones from the rotation.
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    pub health: Option<HealthSpec>,
713}
714
715/// Top-level tunnel definition (not tied to a service endpoint)
716#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
717#[serde(deny_unknown_fields)]
718pub struct TunnelDefinition {
719    /// Source node
720    pub from: String,
721
722    /// Destination node
723    pub to: String,
724
725    /// Local port on source
726    pub local_port: u16,
727
728    /// Remote port on destination
729    pub remote_port: u16,
730
731    /// Protocol (tcp/udp, defaults to tcp)
732    #[serde(default)]
733    pub protocol: TunnelProtocol,
734
735    /// Exposure type (defaults to internal)
736    #[serde(default)]
737    pub expose: ExposeType,
738}
739
740/// Protocol for tunnel connections (tcp or udp only)
741#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
742#[serde(rename_all = "lowercase")]
743pub enum TunnelProtocol {
744    #[default]
745    Tcp,
746    Udp,
747}
748
749/// Log output configuration for services and jobs.
750#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
751pub struct LogsConfig {
752    /// Where to write logs: "disk" (default) or "memory"
753    #[serde(default = "default_logs_destination")]
754    pub destination: String,
755
756    /// Maximum log size in bytes (default: 100MB)
757    #[serde(default = "default_logs_max_size")]
758    pub max_size_bytes: u64,
759
760    /// Log retention in seconds (default: 7 days)
761    #[serde(default = "default_logs_retention")]
762    pub retention_secs: u64,
763}
764
765fn default_logs_destination() -> String {
766    "disk".to_string()
767}
768
769fn default_logs_max_size() -> u64 {
770    100 * 1024 * 1024 // 100MB
771}
772
773fn default_logs_retention() -> u64 {
774    7 * 24 * 60 * 60 // 7 days
775}
776
777impl Default for LogsConfig {
778    fn default() -> Self {
779        Self {
780            destination: default_logs_destination(),
781            max_size_bytes: default_logs_max_size(),
782            retention_secs: default_logs_retention(),
783        }
784    }
785}
786
787/// 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::spec::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::spec::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    #[serde(with = "crate::image_ref_serde")]
1000    pub name: crate::ImageReference,
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, even if cached
1016    Always,
1017    /// Resolve remote digest; pull and recreate when it differs from local/running
1018    Newer,
1019    /// Pull only if not present locally
1020    IfNotPresent,
1021    /// Never pull, use local image only
1022    Never,
1023}
1024
1025/// Resolve the effective pull policy for a deploy/scale operation.
1026///
1027/// The serde default for `pull_policy` is `IfNotPresent` (preserved for
1028/// backwards compatibility). When the user has not opted out (i.e. the policy
1029/// is the default `IfNotPresent`) AND the image tag is `:latest` or unspecified,
1030/// we auto-upgrade to `Newer` so freshly-pushed `:latest` images get picked up
1031/// on redeploy. Users who explicitly want immutable behaviour on a `:latest`
1032/// tag can set `pull_policy: never`.
1033#[must_use]
1034pub fn effective_pull_policy(image: &crate::ImageReference, spec_policy: PullPolicy) -> PullPolicy {
1035    match spec_policy {
1036        PullPolicy::Always | PullPolicy::Never | PullPolicy::Newer => spec_policy,
1037        PullPolicy::IfNotPresent => {
1038            // Auto-upgrade IfNotPresent to Newer for :latest / no-tag images
1039            if image_is_latest_or_untagged(image) {
1040                PullPolicy::Newer
1041            } else {
1042                PullPolicy::IfNotPresent
1043            }
1044        }
1045    }
1046}
1047
1048fn image_is_latest_or_untagged(image: &crate::ImageReference) -> bool {
1049    // `ImageReference` is `oci_spec::distribution::Reference`, which exposes a
1050    // clean `tag()` accessor returning `Option<&str>`. No tag, or tag "latest",
1051    // is treated as the rolling case.
1052    match image.tag() {
1053        None => true,
1054        Some(tag) => tag == "latest",
1055    }
1056}
1057
1058/// Device passthrough specification
1059#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1060#[serde(deny_unknown_fields)]
1061pub struct DeviceSpec {
1062    /// Host device path (e.g., /dev/kvm, /dev/net/tun)
1063    #[validate(length(min = 1, message = "device path cannot be empty"))]
1064    pub path: String,
1065
1066    /// Allow read access
1067    #[serde(default = "default_true")]
1068    pub read: bool,
1069
1070    /// Allow write access
1071    #[serde(default = "default_true")]
1072    pub write: bool,
1073
1074    /// Allow mknod (create device nodes)
1075    #[serde(default)]
1076    pub mknod: bool,
1077}
1078
1079fn default_true() -> bool {
1080    true
1081}
1082
1083/// Storage mount specification
1084#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1085#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
1086pub enum StorageSpec {
1087    /// Bind mount from host path to container
1088    Bind {
1089        source: String,
1090        target: String,
1091        #[serde(default)]
1092        readonly: bool,
1093    },
1094    /// Named persistent storage volume
1095    Named {
1096        name: String,
1097        target: String,
1098        #[serde(default)]
1099        readonly: bool,
1100        /// Performance tier (default: local, SQLite-safe)
1101        #[serde(default)]
1102        tier: StorageTier,
1103        /// Optional size limit (e.g., "1Gi", "512Mi")
1104        #[serde(default, skip_serializing_if = "Option::is_none")]
1105        size: Option<String>,
1106    },
1107    /// Anonymous storage (auto-named, container lifecycle)
1108    Anonymous {
1109        target: String,
1110        /// Performance tier (default: local)
1111        #[serde(default)]
1112        tier: StorageTier,
1113    },
1114    /// Memory-backed tmpfs mount
1115    Tmpfs {
1116        target: String,
1117        #[serde(default)]
1118        size: Option<String>,
1119        #[serde(default)]
1120        mode: Option<u32>,
1121    },
1122    /// S3-backed FUSE mount
1123    S3 {
1124        bucket: String,
1125        #[serde(default)]
1126        prefix: Option<String>,
1127        target: String,
1128        #[serde(default)]
1129        readonly: bool,
1130        #[serde(default)]
1131        endpoint: Option<String>,
1132        #[serde(default)]
1133        credentials: Option<String>,
1134    },
1135}
1136
1137/// Resource limits (upper bounds, not reservations)
1138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
1139#[serde(deny_unknown_fields)]
1140pub struct ResourcesSpec {
1141    /// CPU limit (cores, e.g., 0.5, 1, 2)
1142    #[serde(default)]
1143    #[validate(custom(function = "crate::spec::validate::validate_cpu_option_wrapper"))]
1144    pub cpu: Option<f64>,
1145
1146    /// Memory limit (e.g., "512Mi", "1Gi", "2Gi")
1147    #[serde(default)]
1148    #[validate(custom(function = "crate::spec::validate::validate_memory_option_wrapper"))]
1149    pub memory: Option<String>,
1150
1151    /// GPU resource request
1152    #[serde(default, skip_serializing_if = "Option::is_none")]
1153    pub gpu: Option<GpuSpec>,
1154}
1155
1156/// Scheduling policy for GPU workloads
1157#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1158#[serde(rename_all = "kebab-case")]
1159pub enum SchedulingPolicy {
1160    /// Place as many replicas as possible; partial placement is acceptable (default)
1161    #[default]
1162    BestEffort,
1163    /// All replicas must be placed or none are; prevents partial GPU job deployment
1164    Gang,
1165    /// Spread replicas across nodes to maximize GPU distribution
1166    Spread,
1167}
1168
1169/// GPU sharing mode controlling how GPU resources are multiplexed.
1170#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1171#[serde(rename_all = "kebab-case")]
1172pub enum GpuSharingMode {
1173    /// Whole GPU per container (default). No sharing.
1174    #[default]
1175    Exclusive,
1176    /// NVIDIA Multi-Process Service: concurrent GPU compute sharing.
1177    /// Multiple containers run GPU kernels simultaneously with hardware isolation.
1178    Mps,
1179    /// NVIDIA time-slicing: round-robin GPU access across containers.
1180    /// Lower overhead than MPS but no concurrent execution.
1181    TimeSlice,
1182}
1183
1184/// Configuration for distributed GPU job coordination.
1185///
1186/// When enabled on a multi-replica GPU service, `ZLayer` injects standard
1187/// distributed training environment variables (`MASTER_ADDR`, `MASTER_PORT`,
1188/// `WORLD_SIZE`, `RANK`, `LOCAL_RANK`) so frameworks like `PyTorch`, `Horovod`,
1189/// and `DeepSpeed` can coordinate automatically.
1190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1191#[serde(deny_unknown_fields)]
1192pub struct DistributedConfig {
1193    /// Communication backend: "nccl" (default), "gloo", or "mpi"
1194    #[serde(default = "default_dist_backend")]
1195    pub backend: String,
1196    /// Port for rank-0 master coordination (default: 29500)
1197    #[serde(default = "default_dist_port")]
1198    pub master_port: u16,
1199}
1200
1201fn default_dist_backend() -> String {
1202    "nccl".to_string()
1203}
1204
1205fn default_dist_port() -> u16 {
1206    29500
1207}
1208
1209/// GPU resource specification
1210///
1211/// Supported vendors:
1212/// - `nvidia` - NVIDIA GPUs via NVIDIA Container Toolkit (default)
1213/// - `amd` - AMD GPUs via `ROCm` (/dev/kfd + /dev/dri/renderD*)
1214/// - `intel` - Intel GPUs via VAAPI/i915 (/dev/dri/renderD*)
1215/// - `apple` - Apple Silicon GPUs via Metal/MPS (macOS only)
1216///
1217/// Unknown vendors fall back to DRI render node passthrough.
1218///
1219/// ## GPU mode (macOS only)
1220///
1221/// When `vendor` is `"apple"`, the `mode` field controls how GPU access is provided:
1222/// - `"native"` -- Seatbelt sandbox with direct Metal/MPS access (lowest overhead)
1223/// - `"vm"` -- libkrun micro-VM with GPU forwarding (stronger isolation)
1224/// - `None` (default) -- Auto-select based on platform and vendor
1225///
1226/// On Linux, `mode` is ignored; GPU passthrough always uses device node binding.
1227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1228#[serde(deny_unknown_fields)]
1229pub struct GpuSpec {
1230    /// Number of GPUs to request
1231    #[serde(default = "default_gpu_count")]
1232    pub count: u32,
1233    /// GPU vendor (`nvidia`, `amd`, `intel`, `apple`) - defaults to `nvidia`
1234    #[serde(default = "default_gpu_vendor")]
1235    pub vendor: String,
1236    /// GPU access mode (macOS only): `"native"`, `"vm"`, or `None` for auto-select
1237    #[serde(default, skip_serializing_if = "Option::is_none")]
1238    pub mode: Option<String>,
1239    /// Pin to a specific GPU model (e.g. "A100", "H100").
1240    /// Substring match against detected GPU model names.
1241    #[serde(default, skip_serializing_if = "Option::is_none")]
1242    pub model: Option<String>,
1243    /// Scheduling policy for GPU workloads.
1244    /// - `best-effort` (default): place what fits
1245    /// - `gang`: all-or-nothing for distributed jobs
1246    /// - `spread`: distribute across nodes
1247    #[serde(default, skip_serializing_if = "Option::is_none")]
1248    pub scheduling: Option<SchedulingPolicy>,
1249    /// Distributed GPU job coordination.
1250    /// When set, injects `MASTER_ADDR`, `WORLD_SIZE`, `RANK`, `LOCAL_RANK` env vars.
1251    #[serde(default, skip_serializing_if = "Option::is_none")]
1252    pub distributed: Option<DistributedConfig>,
1253    /// GPU sharing mode: exclusive (default), mps, or time-slice.
1254    #[serde(default, skip_serializing_if = "Option::is_none")]
1255    pub sharing: Option<GpuSharingMode>,
1256}
1257
1258fn default_gpu_count() -> u32 {
1259    1
1260}
1261
1262fn default_gpu_vendor() -> String {
1263    "nvidia".to_string()
1264}
1265
1266/// Per-service network configuration (overlay + join policy).
1267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1268#[serde(deny_unknown_fields)]
1269#[derive(Default)]
1270pub struct ServiceNetworkSpec {
1271    /// Overlay network configuration
1272    #[serde(default)]
1273    pub overlays: OverlayConfig,
1274
1275    /// Join policy (who can join this service)
1276    #[serde(default)]
1277    pub join: JoinPolicy,
1278}
1279
1280/// Overlay network configuration
1281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1282#[serde(deny_unknown_fields)]
1283pub struct OverlayConfig {
1284    /// Service-scoped overlay (service replicas only)
1285    #[serde(default)]
1286    pub service: OverlaySettings,
1287
1288    /// Global overlay (all services in deployment)
1289    #[serde(default)]
1290    pub global: OverlaySettings,
1291}
1292
1293impl Default for OverlayConfig {
1294    fn default() -> Self {
1295        Self {
1296            service: OverlaySettings {
1297                enabled: true,
1298                encrypted: true,
1299                isolated: true,
1300            },
1301            global: OverlaySettings {
1302                enabled: true,
1303                encrypted: true,
1304                isolated: false,
1305            },
1306        }
1307    }
1308}
1309
1310/// Overlay network settings
1311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1312#[serde(deny_unknown_fields)]
1313pub struct OverlaySettings {
1314    /// Enable this overlay
1315    #[serde(default = "default_enabled")]
1316    pub enabled: bool,
1317
1318    /// Use encryption
1319    #[serde(default = "default_encrypted")]
1320    pub encrypted: bool,
1321
1322    /// Isolate from other services/groups
1323    #[serde(default)]
1324    pub isolated: bool,
1325}
1326
1327fn default_enabled() -> bool {
1328    true
1329}
1330
1331fn default_encrypted() -> bool {
1332    true
1333}
1334
1335/// Join policy - controls who can join a service
1336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1337#[serde(deny_unknown_fields)]
1338pub struct JoinPolicy {
1339    /// Join mode
1340    #[serde(default = "default_join_mode")]
1341    pub mode: JoinMode,
1342
1343    /// Scope of join
1344    #[serde(default = "default_join_scope")]
1345    pub scope: JoinScope,
1346}
1347
1348impl Default for JoinPolicy {
1349    fn default() -> Self {
1350        Self {
1351            mode: default_join_mode(),
1352            scope: default_join_scope(),
1353        }
1354    }
1355}
1356
1357fn default_join_mode() -> JoinMode {
1358    JoinMode::Token
1359}
1360
1361fn default_join_scope() -> JoinScope {
1362    JoinScope::Service
1363}
1364
1365/// Join mode
1366#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1367#[serde(rename_all = "snake_case")]
1368pub enum JoinMode {
1369    /// Any trusted node in deployment can self-enroll
1370    Open,
1371    /// Requires a join key (recommended)
1372    Token,
1373    /// Only control-plane/scheduler can place replicas
1374    Closed,
1375}
1376
1377/// Join scope
1378#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1379#[serde(rename_all = "snake_case")]
1380pub enum JoinScope {
1381    /// Join this specific service
1382    Service,
1383    /// Join all services in deployment
1384    Global,
1385}
1386
1387/// Endpoint specification (proxy binding)
1388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
1389#[serde(deny_unknown_fields)]
1390pub struct EndpointSpec {
1391    /// Endpoint name (for routing)
1392    #[validate(length(min = 1, message = "endpoint name cannot be empty"))]
1393    pub name: String,
1394
1395    /// Protocol
1396    pub protocol: Protocol,
1397
1398    /// Proxy listen port (external-facing port)
1399    #[validate(custom(function = "crate::spec::validate::validate_port_wrapper"))]
1400    pub port: u16,
1401
1402    /// Container port the service actually listens on.
1403    /// Defaults to `port` when not specified.
1404    #[serde(default, skip_serializing_if = "Option::is_none")]
1405    pub target_port: Option<u16>,
1406
1407    /// URL path prefix (for http/https/websocket)
1408    pub path: Option<String>,
1409
1410    /// Host pattern for routing (e.g. "api.example.com" or "*.example.com").
1411    /// `None` means match any host.
1412    #[serde(default, skip_serializing_if = "Option::is_none")]
1413    pub host: Option<String>,
1414
1415    /// Exposure type
1416    #[serde(default = "default_expose")]
1417    pub expose: ExposeType,
1418
1419    /// Optional stream (L4) proxy configuration
1420    /// Only applicable when protocol is tcp or udp
1421    #[serde(default, skip_serializing_if = "Option::is_none")]
1422    pub stream: Option<StreamEndpointConfig>,
1423
1424    /// Optional tunnel configuration for this endpoint
1425    #[serde(default, skip_serializing_if = "Option::is_none")]
1426    pub tunnel: Option<EndpointTunnelConfig>,
1427}
1428
1429impl EndpointSpec {
1430    /// Returns the port the container actually listens on.
1431    /// Falls back to `port` when `target_port` is not specified.
1432    #[must_use]
1433    pub fn target_port(&self) -> u16 {
1434        self.target_port.unwrap_or(self.port)
1435    }
1436}
1437
1438/// Tunnel configuration for an endpoint
1439#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1440#[serde(deny_unknown_fields)]
1441pub struct EndpointTunnelConfig {
1442    /// Enable tunneling for this endpoint
1443    #[serde(default)]
1444    pub enabled: bool,
1445
1446    /// Source node name (defaults to service's node)
1447    #[serde(default, skip_serializing_if = "Option::is_none")]
1448    pub from: Option<String>,
1449
1450    /// Destination node name (defaults to cluster ingress)
1451    #[serde(default, skip_serializing_if = "Option::is_none")]
1452    pub to: Option<String>,
1453
1454    /// Remote port to expose (0 = auto-assign)
1455    #[serde(default)]
1456    pub remote_port: u16,
1457
1458    /// Override exposure for tunnel (public/internal)
1459    #[serde(default, skip_serializing_if = "Option::is_none")]
1460    pub expose: Option<ExposeType>,
1461
1462    /// On-demand access configuration
1463    #[serde(default, skip_serializing_if = "Option::is_none")]
1464    pub access: Option<TunnelAccessConfig>,
1465}
1466
1467/// On-demand access settings for `zlayer tunnel access`
1468#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1469#[serde(deny_unknown_fields)]
1470pub struct TunnelAccessConfig {
1471    /// Allow on-demand access via CLI
1472    #[serde(default)]
1473    pub enabled: bool,
1474
1475    /// Maximum session duration (e.g., "4h", "30m")
1476    #[serde(default, skip_serializing_if = "Option::is_none")]
1477    pub max_ttl: Option<String>,
1478
1479    /// Log all access sessions
1480    #[serde(default)]
1481    pub audit: bool,
1482}
1483
1484fn default_expose() -> ExposeType {
1485    ExposeType::Internal
1486}
1487
1488/// Protocol type
1489#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1490#[serde(rename_all = "lowercase")]
1491pub enum Protocol {
1492    Http,
1493    Https,
1494    Tcp,
1495    Udp,
1496    Websocket,
1497}
1498
1499/// Exposure type
1500#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1501#[serde(rename_all = "lowercase")]
1502pub enum ExposeType {
1503    Public,
1504    #[default]
1505    Internal,
1506}
1507
1508/// Stream (L4) proxy configuration for TCP/UDP endpoints
1509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1510#[serde(deny_unknown_fields)]
1511pub struct StreamEndpointConfig {
1512    /// Enable TLS termination for TCP (auto-provision cert)
1513    #[serde(default)]
1514    pub tls: bool,
1515
1516    /// Enable PROXY protocol for passing client IP
1517    #[serde(default)]
1518    pub proxy_protocol: bool,
1519
1520    /// Custom session timeout for UDP (default: 60s)
1521    /// Format: duration string like "60s", "5m"
1522    #[serde(default, skip_serializing_if = "Option::is_none")]
1523    pub session_timeout: Option<String>,
1524
1525    /// Health check configuration for L4
1526    #[serde(default, skip_serializing_if = "Option::is_none")]
1527    pub health_check: Option<StreamHealthCheck>,
1528}
1529
1530/// Health check types for stream (L4) endpoints
1531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1532#[serde(tag = "type", rename_all = "snake_case")]
1533pub enum StreamHealthCheck {
1534    /// TCP connect check - verifies port is accepting connections
1535    TcpConnect,
1536    /// UDP probe - sends request and optionally validates response
1537    UdpProbe {
1538        /// Request payload to send (can use hex escapes like \\xFF)
1539        request: String,
1540        /// Expected response pattern (optional regex)
1541        #[serde(default, skip_serializing_if = "Option::is_none")]
1542        expect: Option<String>,
1543    },
1544}
1545
1546/// Scaling configuration
1547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1548#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
1549pub enum ScaleSpec {
1550    /// Adaptive scaling with metrics
1551    #[serde(rename = "adaptive")]
1552    Adaptive {
1553        /// Minimum replicas
1554        min: u32,
1555
1556        /// Maximum replicas
1557        max: u32,
1558
1559        /// Cooldown period between scale events
1560        #[serde(default, with = "duration::option")]
1561        cooldown: Option<std::time::Duration>,
1562
1563        /// Target metrics for scaling
1564        #[serde(default)]
1565        targets: ScaleTargets,
1566    },
1567
1568    /// Fixed number of replicas
1569    #[serde(rename = "fixed")]
1570    Fixed { replicas: u32 },
1571
1572    /// Manual scaling (no automatic scaling)
1573    #[serde(rename = "manual")]
1574    Manual,
1575}
1576
1577impl Default for ScaleSpec {
1578    fn default() -> Self {
1579        Self::Adaptive {
1580            min: 1,
1581            max: 10,
1582            cooldown: Some(std::time::Duration::from_secs(30)),
1583            targets: ScaleTargets::default(),
1584        }
1585    }
1586}
1587
1588/// Target metrics for adaptive scaling
1589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1590#[serde(deny_unknown_fields)]
1591#[derive(Default)]
1592pub struct ScaleTargets {
1593    /// CPU percentage threshold (0-100)
1594    #[serde(default)]
1595    pub cpu: Option<u8>,
1596
1597    /// Memory percentage threshold (0-100)
1598    #[serde(default)]
1599    pub memory: Option<u8>,
1600
1601    /// Requests per second threshold
1602    #[serde(default)]
1603    pub rps: Option<u32>,
1604}
1605
1606/// Dependency specification
1607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1608#[serde(deny_unknown_fields)]
1609pub struct DependsSpec {
1610    /// Service name to depend on
1611    pub service: String,
1612
1613    /// Condition for dependency
1614    #[serde(default = "default_condition")]
1615    pub condition: DependencyCondition,
1616
1617    /// Maximum time to wait
1618    #[serde(default = "default_timeout", with = "duration::option")]
1619    pub timeout: Option<std::time::Duration>,
1620
1621    /// Action on timeout
1622    #[serde(default = "default_on_timeout")]
1623    pub on_timeout: TimeoutAction,
1624}
1625
1626fn default_condition() -> DependencyCondition {
1627    DependencyCondition::Healthy
1628}
1629
1630#[allow(clippy::unnecessary_wraps)]
1631fn default_timeout() -> Option<std::time::Duration> {
1632    Some(std::time::Duration::from_secs(300))
1633}
1634
1635fn default_on_timeout() -> TimeoutAction {
1636    TimeoutAction::Fail
1637}
1638
1639/// Dependency condition
1640#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1641#[serde(rename_all = "lowercase")]
1642pub enum DependencyCondition {
1643    /// Container process exists
1644    Started,
1645    /// Health check passes
1646    Healthy,
1647    /// Service is available for routing
1648    Ready,
1649}
1650
1651/// Timeout action
1652#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1653#[serde(rename_all = "lowercase")]
1654pub enum TimeoutAction {
1655    Fail,
1656    Warn,
1657    Continue,
1658}
1659
1660/// Health check specification
1661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1662#[serde(deny_unknown_fields)]
1663pub struct HealthSpec {
1664    /// Grace period before first check
1665    #[serde(default, with = "duration::option")]
1666    pub start_grace: Option<std::time::Duration>,
1667
1668    /// Interval between checks
1669    #[serde(default, with = "duration::option")]
1670    pub interval: Option<std::time::Duration>,
1671
1672    /// Timeout per check
1673    #[serde(default, with = "duration::option")]
1674    pub timeout: Option<std::time::Duration>,
1675
1676    /// Number of retries before marking unhealthy
1677    #[serde(default = "default_retries")]
1678    pub retries: u32,
1679
1680    /// Health check type and parameters
1681    pub check: HealthCheck,
1682}
1683
1684fn default_retries() -> u32 {
1685    3
1686}
1687
1688/// Health check type
1689#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1690#[serde(tag = "type", rename_all = "lowercase")]
1691pub enum HealthCheck {
1692    /// TCP port check
1693    Tcp {
1694        /// Port to check (0 = use first endpoint)
1695        port: u16,
1696    },
1697
1698    /// HTTP check
1699    Http {
1700        /// URL to check
1701        url: String,
1702        /// Expected status code
1703        #[serde(default = "default_expect_status")]
1704        expect_status: u16,
1705    },
1706
1707    /// Command check
1708    Command {
1709        /// Command to run
1710        command: String,
1711    },
1712}
1713
1714fn default_expect_status() -> u16 {
1715    200
1716}
1717
1718/// Init actions specification
1719#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1720#[serde(deny_unknown_fields)]
1721#[derive(Default)]
1722pub struct InitSpec {
1723    /// Init steps to run before container starts
1724    #[serde(default)]
1725    pub steps: Vec<InitStep>,
1726}
1727
1728/// Init action step
1729#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1730#[serde(deny_unknown_fields)]
1731pub struct InitStep {
1732    /// Step identifier
1733    pub id: String,
1734
1735    /// Action to perform (e.g., "`init.wait_tcp`")
1736    pub uses: String,
1737
1738    /// Parameters for the action
1739    #[serde(default)]
1740    pub with: InitParams,
1741
1742    /// Number of retries
1743    #[serde(default)]
1744    pub retry: Option<u32>,
1745
1746    /// Maximum time for this step
1747    #[serde(default, with = "duration::option")]
1748    pub timeout: Option<std::time::Duration>,
1749
1750    /// Action on failure
1751    #[serde(default = "default_on_failure")]
1752    pub on_failure: FailureAction,
1753}
1754
1755fn default_on_failure() -> FailureAction {
1756    FailureAction::Fail
1757}
1758
1759/// Init action parameters
1760pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
1761
1762/// Failure action for init steps
1763#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1764#[serde(rename_all = "lowercase")]
1765pub enum FailureAction {
1766    Fail,
1767    Warn,
1768    Continue,
1769}
1770
1771/// Error handling policies
1772#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1773#[serde(deny_unknown_fields)]
1774#[derive(Default)]
1775pub struct ErrorsSpec {
1776    /// Init failure policy
1777    #[serde(default)]
1778    pub on_init_failure: InitFailurePolicy,
1779
1780    /// Panic/restart policy
1781    #[serde(default)]
1782    pub on_panic: PanicPolicy,
1783}
1784
1785/// Init failure policy
1786#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1787#[serde(deny_unknown_fields)]
1788pub struct InitFailurePolicy {
1789    #[serde(default = "default_init_action")]
1790    pub action: InitFailureAction,
1791}
1792
1793impl Default for InitFailurePolicy {
1794    fn default() -> Self {
1795        Self {
1796            action: default_init_action(),
1797        }
1798    }
1799}
1800
1801fn default_init_action() -> InitFailureAction {
1802    InitFailureAction::Fail
1803}
1804
1805/// Init failure action
1806#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1807#[serde(rename_all = "lowercase")]
1808pub enum InitFailureAction {
1809    Fail,
1810    Restart,
1811    Backoff,
1812}
1813
1814/// Panic policy
1815#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1816#[serde(deny_unknown_fields)]
1817pub struct PanicPolicy {
1818    #[serde(default = "default_panic_action")]
1819    pub action: PanicAction,
1820}
1821
1822impl Default for PanicPolicy {
1823    fn default() -> Self {
1824        Self {
1825            action: default_panic_action(),
1826        }
1827    }
1828}
1829
1830fn default_panic_action() -> PanicAction {
1831    PanicAction::Restart
1832}
1833
1834/// Panic action
1835#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1836#[serde(rename_all = "lowercase")]
1837pub enum PanicAction {
1838    Restart,
1839    Shutdown,
1840    Isolate,
1841}
1842
1843// ==========================================================================
1844// Network / Access Control types
1845// ==========================================================================
1846
1847/// A network policy defines an access control group with membership rules
1848/// and service access policies.
1849#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1850pub struct NetworkPolicySpec {
1851    /// Unique network name.
1852    pub name: String,
1853
1854    /// Human-readable description.
1855    #[serde(default, skip_serializing_if = "Option::is_none")]
1856    pub description: Option<String>,
1857
1858    /// CIDR ranges that belong to this network (e.g., "10.200.0.0/16", "192.168.1.0/24").
1859    #[serde(default)]
1860    pub cidrs: Vec<String>,
1861
1862    /// Named members (users, groups, nodes) of this network.
1863    #[serde(default)]
1864    pub members: Vec<NetworkMember>,
1865
1866    /// Access rules defining which services this network can reach.
1867    #[serde(default)]
1868    pub access_rules: Vec<AccessRule>,
1869}
1870
1871/// A member of a network.
1872#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1873pub struct NetworkMember {
1874    /// Member identifier (username, group name, node ID, or CIDR).
1875    pub name: String,
1876    /// Type of member.
1877    #[serde(default)]
1878    pub kind: MemberKind,
1879}
1880
1881/// Type of network member.
1882#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1883#[serde(rename_all = "lowercase")]
1884pub enum MemberKind {
1885    /// An individual user identity.
1886    #[default]
1887    User,
1888    /// A group of users.
1889    Group,
1890    /// A specific cluster node.
1891    Node,
1892    /// A CIDR range (redundant with NetworkPolicySpec.cidrs but allows per-member CIDR).
1893    Cidr,
1894}
1895
1896/// An access rule determining what a network can reach.
1897#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1898pub struct AccessRule {
1899    /// Target service name, or "*" for all services.
1900    #[serde(default = "wildcard")]
1901    pub service: String,
1902
1903    /// Target deployment name, or "*" for all deployments.
1904    #[serde(default = "wildcard")]
1905    pub deployment: String,
1906
1907    /// Specific ports allowed. None means all ports.
1908    #[serde(default, skip_serializing_if = "Option::is_none")]
1909    pub ports: Option<Vec<u16>>,
1910
1911    /// Whether to allow or deny access.
1912    #[serde(default)]
1913    pub action: AccessAction,
1914}
1915
1916fn wildcard() -> String {
1917    "*".to_string()
1918}
1919
1920/// Access control action.
1921#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1922#[serde(rename_all = "lowercase")]
1923pub enum AccessAction {
1924    /// Allow access (default).
1925    #[default]
1926    Allow,
1927    /// Deny access.
1928    Deny,
1929}
1930
1931// ==========================================================================
1932// Container bridge / overlay network types (Docker-compatible)
1933// ==========================================================================
1934//
1935// These types model user-defined bridge or overlay networks that standalone
1936// containers can attach to — the Docker-style "docker network create" model.
1937// They are intentionally named `BridgeNetwork*` to avoid colliding with the
1938// CIDR-ACL `NetworkPolicySpec` types above, which model a completely
1939// different concept (access-control groups).
1940
1941/// A user-defined bridge or overlay network that containers can attach to.
1942#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1943pub struct BridgeNetwork {
1944    /// Opaque server-generated identifier (UUID v4).
1945    pub id: String,
1946
1947    /// Human-readable, unique name (must match `^[a-z0-9][a-z0-9_-]{0,63}$`).
1948    pub name: String,
1949
1950    /// Driver backing the network (bridge vs. overlay).
1951    #[serde(default)]
1952    pub driver: BridgeNetworkDriver,
1953
1954    /// IPv4/IPv6 subnet in CIDR notation (e.g. `"10.240.0.0/24"`).
1955    #[serde(default, skip_serializing_if = "Option::is_none")]
1956    pub subnet: Option<String>,
1957
1958    /// Arbitrary key/value labels for filtering and grouping.
1959    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1960    pub labels: HashMap<String, String>,
1961
1962    /// If true, containers attached to this network cannot reach the outside
1963    /// world — only other containers on the same network.
1964    #[serde(default)]
1965    pub internal: bool,
1966
1967    /// Creation timestamp (UTC, RFC 3339).
1968    #[schema(value_type = String, format = "date-time")]
1969    pub created_at: chrono::DateTime<chrono::Utc>,
1970}
1971
1972/// Backing driver for a [`BridgeNetwork`].
1973#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
1974#[serde(rename_all = "lowercase")]
1975pub enum BridgeNetworkDriver {
1976    /// Linux bridge on the local host (single-host, default).
1977    #[default]
1978    Bridge,
1979    /// Overlay network spanning multiple hosts.
1980    Overlay,
1981}
1982
1983/// A container attached to a [`BridgeNetwork`].
1984#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1985pub struct BridgeNetworkAttachment {
1986    /// Runtime-provided container id.
1987    pub container_id: String,
1988
1989    /// Container name, if known.
1990    #[serde(default, skip_serializing_if = "Option::is_none")]
1991    pub container_name: Option<String>,
1992
1993    /// DNS aliases the container can be reached by on this network.
1994    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1995    pub aliases: Vec<String>,
1996
1997    /// Assigned IPv4 address on the network (if any).
1998    #[serde(default, skip_serializing_if = "Option::is_none")]
1999    pub ipv4: Option<String>,
2000}
2001
2002// ==========================================================================
2003// Registry auth (inline, not persisted) — §3.10 of ZLAYER_SDK_FIXES.md
2004// ==========================================================================
2005//
2006// Inline credentials a client can attach to a single pull or container-create
2007// request without first POSTing them to `/api/v1/credentials/registry`. The
2008// daemon uses them exactly once — they are never logged, never persisted, and
2009// never echoed back on a response.
2010//
2011// For requests that instead want to reuse an already-stored credential, the
2012// `CreateContainerRequest` / `PullImageRequest` DTOs also accept a
2013// `registry_credential_id` pointing at the `RegistryCredentialStore`. Inline
2014// `RegistryAuth` takes precedence when both are provided.
2015
2016/// Inline Docker/OCI registry credentials attached to a single pull request.
2017///
2018/// Prefer persistent credentials via `/api/v1/credentials/registry` for
2019/// long-lived services. Use this inline form for one-off pulls (e.g. CI
2020/// runners fetching a private image for a single job) where persisting a
2021/// credential is undesirable.
2022#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2023pub struct RegistryAuth {
2024    /// Username for the registry (for basic auth) or a placeholder
2025    /// identifier when `auth_type == Token`.
2026    pub username: String,
2027    /// Password or bearer token. **Never** logged or returned on any
2028    /// response — consumed once and dropped.
2029    pub password: String,
2030    /// Which authentication scheme to use against the registry.
2031    #[serde(default = "default_registry_auth_type")]
2032    pub auth_type: RegistryAuthType,
2033}
2034
2035/// Authentication scheme for a [`RegistryAuth`].
2036#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
2037#[serde(rename_all = "snake_case")]
2038pub enum RegistryAuthType {
2039    /// HTTP Basic authentication (username + password). Default.
2040    #[default]
2041    Basic,
2042    /// Bearer token authentication. `password` carries the token; `username`
2043    /// is typically a placeholder such as `"oauth2accesstoken"` or `"<token>"`.
2044    Token,
2045}
2046
2047/// Serde default for [`RegistryAuth::auth_type`]. Kept as a free function so
2048/// `#[serde(default = "...")]` can reference it.
2049#[must_use]
2050pub fn default_registry_auth_type() -> RegistryAuthType {
2051    RegistryAuthType::Basic
2052}
2053
2054// ==========================================================================
2055// Container restart policy (Docker-style) — §3.4 of ZLAYER_SDK_FIXES.md
2056// ==========================================================================
2057//
2058// Named `ContainerRestartPolicy` / `ContainerRestartKind` rather than
2059// `RestartPolicy` / `RestartKind` to avoid colliding with ZLayer's existing
2060// `PanicPolicy`/`PanicAction` types and to make the runtime-level (as opposed
2061// to panic-driven) nature of this policy explicit.
2062
2063/// Container-runtime-level restart policy.
2064///
2065/// Maps onto Docker's `HostConfig.RestartPolicy`. Distinct from
2066/// [`PanicPolicy`], which governs what `ZLayer` does in response to an
2067/// application panic (it does not set a Docker restart policy).
2068#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2069#[serde(rename_all = "snake_case", deny_unknown_fields)]
2070pub struct ContainerRestartPolicy {
2071    /// Which restart policy to apply.
2072    pub kind: ContainerRestartKind,
2073
2074    /// For `on_failure` only: maximum number of restart attempts before
2075    /// giving up. Ignored by other kinds. `None` means "retry forever".
2076    #[serde(default, skip_serializing_if = "Option::is_none")]
2077    pub max_attempts: Option<u32>,
2078
2079    /// Humantime-formatted delay between restarts (e.g. `"500ms"`,
2080    /// `"2s"`). Accepted for forward-compatibility but currently ignored
2081    /// by the Docker backend: bollard's `RestartPolicy` has no per-kind
2082    /// delay field. When set, the runtime emits a warning.
2083    #[serde(default, skip_serializing_if = "Option::is_none")]
2084    pub delay: Option<String>,
2085}
2086
2087/// Which flavor of container restart policy to apply.
2088#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2089#[serde(rename_all = "snake_case")]
2090pub enum ContainerRestartKind {
2091    /// Never restart (Docker's `"no"`).
2092    No,
2093    /// Always restart (Docker's `"always"`).
2094    Always,
2095    /// Restart unless the user explicitly stopped the container
2096    /// (Docker's `"unless-stopped"`).
2097    UnlessStopped,
2098    /// Restart only when the container exits with a non-zero code
2099    /// (Docker's `"on-failure"`). Respects `max_attempts`.
2100    OnFailure,
2101}
2102
2103// ==========================================================================
2104// Port mappings (Docker-style container port publishing)
2105// ==========================================================================
2106
2107/// Transport protocol for a published container port.
2108#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2109#[serde(rename_all = "snake_case")]
2110pub enum PortProtocol {
2111    /// TCP (default).
2112    Tcp,
2113    /// UDP.
2114    Udp,
2115}
2116
2117impl Default for PortProtocol {
2118    fn default() -> Self {
2119        default_port_protocol()
2120    }
2121}
2122
2123impl PortProtocol {
2124    /// Return the lowercase string form Docker uses in port-binding keys
2125    /// (e.g. `"tcp"` or `"udp"`).
2126    #[must_use]
2127    pub fn as_str(&self) -> &'static str {
2128        match self {
2129            PortProtocol::Tcp => "tcp",
2130            PortProtocol::Udp => "udp",
2131        }
2132    }
2133}
2134
2135fn default_port_protocol() -> PortProtocol {
2136    PortProtocol::Tcp
2137}
2138
2139fn default_host_ip() -> String {
2140    "0.0.0.0".to_string()
2141}
2142
2143/// A single host-to-container port publish rule (Docker's `-p`).
2144///
2145/// When `host_port` is `None` (or explicitly `Some(0)`), the container runtime
2146/// assigns an ephemeral host port. `host_ip` defaults to `"0.0.0.0"` to bind
2147/// on all interfaces.
2148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
2149#[serde(rename_all = "snake_case")]
2150pub struct PortMapping {
2151    /// Host port. `None` (or zero) means "assign an ephemeral port".
2152    #[serde(default, skip_serializing_if = "Option::is_none")]
2153    pub host_port: Option<u16>,
2154    /// Container-side port.
2155    pub container_port: u16,
2156    /// Transport protocol (defaults to TCP).
2157    #[serde(default = "default_port_protocol")]
2158    pub protocol: PortProtocol,
2159    /// Host interface to bind on. Defaults to `"0.0.0.0"` (all interfaces).
2160    #[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
2161    pub host_ip: String,
2162}
2163
2164#[cfg(test)]
2165mod tests {
2166    use super::*;
2167
2168    #[test]
2169    fn port_mapping_defaults_via_serde() {
2170        // Minimal JSON: only container_port. host_port omitted, protocol defaults
2171        // to "tcp", host_ip defaults to "0.0.0.0".
2172        let json = r#"{"container_port": 8080}"#;
2173        let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
2174        assert_eq!(m.container_port, 8080);
2175        assert_eq!(m.host_port, None);
2176        assert_eq!(m.protocol, PortProtocol::Tcp);
2177        assert_eq!(m.host_ip, "0.0.0.0");
2178    }
2179
2180    #[test]
2181    fn port_mapping_skips_none_host_port_and_empty_host_ip() {
2182        let m = PortMapping {
2183            host_port: None,
2184            container_port: 443,
2185            protocol: PortProtocol::Tcp,
2186            host_ip: String::new(),
2187        };
2188        let s = serde_json::to_string(&m).expect("serialize");
2189        // host_port = None should be skipped, host_ip = "" should be skipped.
2190        assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
2191        assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
2192        assert!(s.contains("\"container_port\":443"));
2193        assert!(s.contains("\"protocol\":\"tcp\""));
2194    }
2195
2196    #[test]
2197    fn test_parse_simple_spec() {
2198        let yaml = r"
2199version: v1
2200deployment: test
2201services:
2202  hello:
2203    rtype: service
2204    image:
2205      name: hello-world:latest
2206    endpoints:
2207      - name: http
2208        protocol: http
2209        port: 8080
2210        expose: public
2211";
2212
2213        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2214        assert_eq!(spec.version, "v1");
2215        assert_eq!(spec.deployment, "test");
2216        assert!(spec.services.contains_key("hello"));
2217    }
2218
2219    #[test]
2220    fn test_parse_duration() {
2221        let yaml = r"
2222version: v1
2223deployment: test
2224services:
2225  test:
2226    rtype: service
2227    image:
2228      name: test:latest
2229    health:
2230      timeout: 30s
2231      interval: 1m
2232      start_grace: 5s
2233      check:
2234        type: tcp
2235        port: 8080
2236";
2237
2238        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2239        let health = &spec.services["test"].health;
2240        assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
2241        assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
2242        assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
2243        match &health.check {
2244            HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
2245            _ => panic!("Expected TCP health check"),
2246        }
2247    }
2248
2249    #[test]
2250    fn test_parse_adaptive_scale() {
2251        let yaml = r"
2252version: v1
2253deployment: test
2254services:
2255  test:
2256    rtype: service
2257    image:
2258      name: test:latest
2259    scale:
2260      mode: adaptive
2261      min: 2
2262      max: 10
2263      cooldown: 15s
2264      targets:
2265        cpu: 70
2266        rps: 800
2267";
2268
2269        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2270        let scale = &spec.services["test"].scale;
2271        match scale {
2272            ScaleSpec::Adaptive {
2273                min,
2274                max,
2275                cooldown,
2276                targets,
2277            } => {
2278                assert_eq!(*min, 2);
2279                assert_eq!(*max, 10);
2280                assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
2281                assert_eq!(targets.cpu, Some(70));
2282                assert_eq!(targets.rps, Some(800));
2283            }
2284            _ => panic!("Expected Adaptive scale mode"),
2285        }
2286    }
2287
2288    #[test]
2289    fn test_node_mode_default() {
2290        let yaml = r"
2291version: v1
2292deployment: test
2293services:
2294  hello:
2295    rtype: service
2296    image:
2297      name: hello-world:latest
2298";
2299
2300        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2301        assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
2302        assert!(spec.services["hello"].node_selector.is_none());
2303    }
2304
2305    #[test]
2306    fn test_node_mode_dedicated() {
2307        let yaml = r"
2308version: v1
2309deployment: test
2310services:
2311  api:
2312    rtype: service
2313    image:
2314      name: api:latest
2315    node_mode: dedicated
2316";
2317
2318        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2319        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2320    }
2321
2322    #[test]
2323    fn test_node_mode_exclusive() {
2324        let yaml = r"
2325version: v1
2326deployment: test
2327services:
2328  database:
2329    rtype: service
2330    image:
2331      name: postgres:15
2332    node_mode: exclusive
2333";
2334
2335        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2336        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2337    }
2338
2339    #[test]
2340    fn test_node_selector_with_labels() {
2341        let yaml = r#"
2342version: v1
2343deployment: test
2344services:
2345  ml-worker:
2346    rtype: service
2347    image:
2348      name: ml-worker:latest
2349    node_mode: dedicated
2350    node_selector:
2351      labels:
2352        gpu: "true"
2353        zone: us-east
2354      prefer_labels:
2355        storage: ssd
2356"#;
2357
2358        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2359        let service = &spec.services["ml-worker"];
2360        assert_eq!(service.node_mode, NodeMode::Dedicated);
2361
2362        let selector = service.node_selector.as_ref().unwrap();
2363        assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
2364        assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
2365        assert_eq!(
2366            selector.prefer_labels.get("storage"),
2367            Some(&"ssd".to_string())
2368        );
2369    }
2370
2371    #[test]
2372    fn test_node_mode_serialization_roundtrip() {
2373        use serde_json;
2374
2375        // Test all variants serialize/deserialize correctly
2376        let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
2377        let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
2378
2379        for (mode, expected) in modes.iter().zip(expected_json.iter()) {
2380            let json = serde_json::to_string(mode).unwrap();
2381            assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
2382
2383            let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
2384            assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
2385        }
2386    }
2387
2388    #[test]
2389    fn test_node_selector_empty() {
2390        let yaml = r"
2391version: v1
2392deployment: test
2393services:
2394  api:
2395    rtype: service
2396    image:
2397      name: api:latest
2398    node_selector:
2399      labels: {}
2400";
2401
2402        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2403        let selector = spec.services["api"].node_selector.as_ref().unwrap();
2404        assert!(selector.labels.is_empty());
2405        assert!(selector.prefer_labels.is_empty());
2406    }
2407
2408    #[test]
2409    fn test_mixed_node_modes_in_deployment() {
2410        let yaml = r"
2411version: v1
2412deployment: test
2413services:
2414  redis:
2415    rtype: service
2416    image:
2417      name: redis:alpine
2418    # Default shared mode
2419  api:
2420    rtype: service
2421    image:
2422      name: api:latest
2423    node_mode: dedicated
2424  database:
2425    rtype: service
2426    image:
2427      name: postgres:15
2428    node_mode: exclusive
2429    node_selector:
2430      labels:
2431        storage: ssd
2432";
2433
2434        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2435        assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
2436        assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
2437        assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
2438
2439        let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
2440        assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
2441    }
2442
2443    #[test]
2444    fn test_storage_bind_mount() {
2445        let yaml = r"
2446version: v1
2447deployment: test
2448services:
2449  app:
2450    image:
2451      name: app:latest
2452    storage:
2453      - type: bind
2454        source: /host/data
2455        target: /app/data
2456        readonly: true
2457";
2458        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2459        let storage = &spec.services["app"].storage;
2460        assert_eq!(storage.len(), 1);
2461        match &storage[0] {
2462            StorageSpec::Bind {
2463                source,
2464                target,
2465                readonly,
2466            } => {
2467                assert_eq!(source, "/host/data");
2468                assert_eq!(target, "/app/data");
2469                assert!(*readonly);
2470            }
2471            _ => panic!("Expected Bind storage"),
2472        }
2473    }
2474
2475    #[test]
2476    fn test_storage_named_with_tier() {
2477        let yaml = r"
2478version: v1
2479deployment: test
2480services:
2481  app:
2482    image:
2483      name: app:latest
2484    storage:
2485      - type: named
2486        name: my-data
2487        target: /app/data
2488        tier: cached
2489";
2490        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2491        let storage = &spec.services["app"].storage;
2492        match &storage[0] {
2493            StorageSpec::Named {
2494                name, target, tier, ..
2495            } => {
2496                assert_eq!(name, "my-data");
2497                assert_eq!(target, "/app/data");
2498                assert_eq!(*tier, StorageTier::Cached);
2499            }
2500            _ => panic!("Expected Named storage"),
2501        }
2502    }
2503
2504    #[test]
2505    fn test_storage_anonymous() {
2506        let yaml = r"
2507version: v1
2508deployment: test
2509services:
2510  app:
2511    image:
2512      name: app:latest
2513    storage:
2514      - type: anonymous
2515        target: /app/cache
2516";
2517        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2518        let storage = &spec.services["app"].storage;
2519        match &storage[0] {
2520            StorageSpec::Anonymous { target, tier } => {
2521                assert_eq!(target, "/app/cache");
2522                assert_eq!(*tier, StorageTier::Local); // default
2523            }
2524            _ => panic!("Expected Anonymous storage"),
2525        }
2526    }
2527
2528    #[test]
2529    fn test_storage_tmpfs() {
2530        let yaml = r"
2531version: v1
2532deployment: test
2533services:
2534  app:
2535    image:
2536      name: app:latest
2537    storage:
2538      - type: tmpfs
2539        target: /app/tmp
2540        size: 256Mi
2541        mode: 1777
2542";
2543        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2544        let storage = &spec.services["app"].storage;
2545        match &storage[0] {
2546            StorageSpec::Tmpfs { target, size, mode } => {
2547                assert_eq!(target, "/app/tmp");
2548                assert_eq!(size.as_deref(), Some("256Mi"));
2549                assert_eq!(*mode, Some(1777));
2550            }
2551            _ => panic!("Expected Tmpfs storage"),
2552        }
2553    }
2554
2555    #[test]
2556    fn test_storage_s3() {
2557        let yaml = r"
2558version: v1
2559deployment: test
2560services:
2561  app:
2562    image:
2563      name: app:latest
2564    storage:
2565      - type: s3
2566        bucket: my-bucket
2567        prefix: models/
2568        target: /app/models
2569        readonly: true
2570        endpoint: https://s3.us-west-2.amazonaws.com
2571        credentials: aws-creds
2572";
2573        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2574        let storage = &spec.services["app"].storage;
2575        match &storage[0] {
2576            StorageSpec::S3 {
2577                bucket,
2578                prefix,
2579                target,
2580                readonly,
2581                endpoint,
2582                credentials,
2583            } => {
2584                assert_eq!(bucket, "my-bucket");
2585                assert_eq!(prefix.as_deref(), Some("models/"));
2586                assert_eq!(target, "/app/models");
2587                assert!(*readonly);
2588                assert_eq!(
2589                    endpoint.as_deref(),
2590                    Some("https://s3.us-west-2.amazonaws.com")
2591                );
2592                assert_eq!(credentials.as_deref(), Some("aws-creds"));
2593            }
2594            _ => panic!("Expected S3 storage"),
2595        }
2596    }
2597
2598    #[test]
2599    fn test_storage_multiple_types() {
2600        let yaml = r"
2601version: v1
2602deployment: test
2603services:
2604  app:
2605    image:
2606      name: app:latest
2607    storage:
2608      - type: bind
2609        source: /etc/config
2610        target: /app/config
2611        readonly: true
2612      - type: named
2613        name: app-data
2614        target: /app/data
2615      - type: tmpfs
2616        target: /app/tmp
2617";
2618        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2619        let storage = &spec.services["app"].storage;
2620        assert_eq!(storage.len(), 3);
2621        assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
2622        assert!(matches!(&storage[1], StorageSpec::Named { .. }));
2623        assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
2624    }
2625
2626    #[test]
2627    fn test_storage_tier_default() {
2628        let yaml = r"
2629version: v1
2630deployment: test
2631services:
2632  app:
2633    image:
2634      name: app:latest
2635    storage:
2636      - type: named
2637        name: data
2638        target: /data
2639";
2640        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2641        match &spec.services["app"].storage[0] {
2642            StorageSpec::Named { tier, .. } => {
2643                assert_eq!(*tier, StorageTier::Local); // default should be Local
2644            }
2645            _ => panic!("Expected Named storage"),
2646        }
2647    }
2648
2649    // ==========================================================================
2650    // Tunnel configuration tests
2651    // ==========================================================================
2652
2653    #[test]
2654    fn test_endpoint_tunnel_config_basic() {
2655        let yaml = r"
2656version: v1
2657deployment: test
2658services:
2659  api:
2660    image:
2661      name: api:latest
2662    endpoints:
2663      - name: http
2664        protocol: http
2665        port: 8080
2666        tunnel:
2667          enabled: true
2668          remote_port: 8080
2669";
2670        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2671        let endpoint = &spec.services["api"].endpoints[0];
2672        let tunnel = endpoint.tunnel.as_ref().unwrap();
2673        assert!(tunnel.enabled);
2674        assert_eq!(tunnel.remote_port, 8080);
2675        assert!(tunnel.from.is_none());
2676        assert!(tunnel.to.is_none());
2677    }
2678
2679    #[test]
2680    fn test_endpoint_tunnel_config_full() {
2681        let yaml = r"
2682version: v1
2683deployment: test
2684services:
2685  api:
2686    image:
2687      name: api:latest
2688    endpoints:
2689      - name: http
2690        protocol: http
2691        port: 8080
2692        tunnel:
2693          enabled: true
2694          from: node-1
2695          to: ingress-node
2696          remote_port: 9000
2697          expose: public
2698          access:
2699            enabled: true
2700            max_ttl: 4h
2701            audit: true
2702";
2703        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2704        let endpoint = &spec.services["api"].endpoints[0];
2705        let tunnel = endpoint.tunnel.as_ref().unwrap();
2706        assert!(tunnel.enabled);
2707        assert_eq!(tunnel.from, Some("node-1".to_string()));
2708        assert_eq!(tunnel.to, Some("ingress-node".to_string()));
2709        assert_eq!(tunnel.remote_port, 9000);
2710        assert_eq!(tunnel.expose, Some(ExposeType::Public));
2711
2712        let access = tunnel.access.as_ref().unwrap();
2713        assert!(access.enabled);
2714        assert_eq!(access.max_ttl, Some("4h".to_string()));
2715        assert!(access.audit);
2716    }
2717
2718    #[test]
2719    fn test_top_level_tunnel_definition() {
2720        let yaml = r"
2721version: v1
2722deployment: test
2723services: {}
2724tunnels:
2725  db-tunnel:
2726    from: app-node
2727    to: db-node
2728    local_port: 5432
2729    remote_port: 5432
2730    protocol: tcp
2731    expose: internal
2732";
2733        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2734        let tunnel = spec.tunnels.get("db-tunnel").unwrap();
2735        assert_eq!(tunnel.from, "app-node");
2736        assert_eq!(tunnel.to, "db-node");
2737        assert_eq!(tunnel.local_port, 5432);
2738        assert_eq!(tunnel.remote_port, 5432);
2739        assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
2740        assert_eq!(tunnel.expose, ExposeType::Internal);
2741    }
2742
2743    #[test]
2744    fn test_top_level_tunnel_defaults() {
2745        let yaml = r"
2746version: v1
2747deployment: test
2748services: {}
2749tunnels:
2750  simple-tunnel:
2751    from: node-a
2752    to: node-b
2753    local_port: 3000
2754    remote_port: 3000
2755";
2756        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2757        let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
2758        assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); // default
2759        assert_eq!(tunnel.expose, ExposeType::Internal); // default
2760    }
2761
2762    #[test]
2763    fn test_tunnel_protocol_udp() {
2764        let yaml = r"
2765version: v1
2766deployment: test
2767services: {}
2768tunnels:
2769  udp-tunnel:
2770    from: node-a
2771    to: node-b
2772    local_port: 5353
2773    remote_port: 5353
2774    protocol: udp
2775";
2776        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2777        let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
2778        assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
2779    }
2780
2781    #[test]
2782    fn test_endpoint_without_tunnel() {
2783        let yaml = r"
2784version: v1
2785deployment: test
2786services:
2787  api:
2788    image:
2789      name: api:latest
2790    endpoints:
2791      - name: http
2792        protocol: http
2793        port: 8080
2794";
2795        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2796        let endpoint = &spec.services["api"].endpoints[0];
2797        assert!(endpoint.tunnel.is_none());
2798    }
2799
2800    #[test]
2801    fn test_deployment_without_tunnels() {
2802        let yaml = r"
2803version: v1
2804deployment: test
2805services:
2806  api:
2807    image:
2808      name: api:latest
2809";
2810        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2811        assert!(spec.tunnels.is_empty());
2812    }
2813
2814    // ==========================================================================
2815    // ApiSpec tests
2816    // ==========================================================================
2817
2818    #[test]
2819    fn test_spec_without_api_block_uses_defaults() {
2820        let yaml = r"
2821version: v1
2822deployment: test
2823services:
2824  hello:
2825    image:
2826      name: hello-world:latest
2827";
2828        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2829        assert!(spec.api.enabled);
2830        assert_eq!(spec.api.bind, "0.0.0.0:3669");
2831        assert!(spec.api.jwt_secret.is_none());
2832        assert!(spec.api.swagger);
2833    }
2834
2835    #[test]
2836    fn test_spec_with_explicit_api_block() {
2837        let yaml = r#"
2838version: v1
2839deployment: test
2840services:
2841  hello:
2842    image:
2843      name: hello-world:latest
2844api:
2845  enabled: false
2846  bind: "127.0.0.1:9090"
2847  jwt_secret: "my-secret"
2848  swagger: false
2849"#;
2850        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2851        assert!(!spec.api.enabled);
2852        assert_eq!(spec.api.bind, "127.0.0.1:9090");
2853        assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
2854        assert!(!spec.api.swagger);
2855    }
2856
2857    #[test]
2858    fn test_spec_with_partial_api_block() {
2859        let yaml = r#"
2860version: v1
2861deployment: test
2862services:
2863  hello:
2864    image:
2865      name: hello-world:latest
2866api:
2867  bind: "0.0.0.0:3000"
2868"#;
2869        let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
2870        assert!(spec.api.enabled); // default true
2871        assert_eq!(spec.api.bind, "0.0.0.0:3000");
2872        assert!(spec.api.jwt_secret.is_none()); // default None
2873        assert!(spec.api.swagger); // default true
2874    }
2875
2876    // ==========================================================================
2877    // NetworkPolicySpec tests
2878    // ==========================================================================
2879
2880    #[test]
2881    fn test_network_policy_spec_roundtrip() {
2882        let spec = NetworkPolicySpec {
2883            name: "corp-vpn".to_string(),
2884            description: Some("Corporate VPN network".to_string()),
2885            cidrs: vec!["10.200.0.0/16".to_string()],
2886            members: vec![
2887                NetworkMember {
2888                    name: "alice".to_string(),
2889                    kind: MemberKind::User,
2890                },
2891                NetworkMember {
2892                    name: "ops-team".to_string(),
2893                    kind: MemberKind::Group,
2894                },
2895                NetworkMember {
2896                    name: "node-01".to_string(),
2897                    kind: MemberKind::Node,
2898                },
2899            ],
2900            access_rules: vec![
2901                AccessRule {
2902                    service: "api-gateway".to_string(),
2903                    deployment: "*".to_string(),
2904                    ports: Some(vec![443, 8080]),
2905                    action: AccessAction::Allow,
2906                },
2907                AccessRule {
2908                    service: "*".to_string(),
2909                    deployment: "staging".to_string(),
2910                    ports: None,
2911                    action: AccessAction::Deny,
2912                },
2913            ],
2914        };
2915
2916        let yaml = serde_yaml::to_string(&spec).unwrap();
2917        let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
2918        assert_eq!(spec, deserialized);
2919    }
2920
2921    #[test]
2922    fn test_network_policy_spec_defaults() {
2923        let yaml = r"
2924name: minimal
2925";
2926        let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
2927        assert_eq!(spec.name, "minimal");
2928        assert!(spec.description.is_none());
2929        assert!(spec.cidrs.is_empty());
2930        assert!(spec.members.is_empty());
2931        assert!(spec.access_rules.is_empty());
2932    }
2933
2934    #[test]
2935    fn test_access_rule_defaults() {
2936        let yaml = "{}";
2937        let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
2938        assert_eq!(rule.service, "*");
2939        assert_eq!(rule.deployment, "*");
2940        assert!(rule.ports.is_none());
2941        assert_eq!(rule.action, AccessAction::Allow);
2942    }
2943
2944    #[test]
2945    fn test_member_kind_defaults_to_user() {
2946        let yaml = r"
2947name: bob
2948";
2949        let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
2950        assert_eq!(member.name, "bob");
2951        assert_eq!(member.kind, MemberKind::User);
2952    }
2953
2954    #[test]
2955    fn test_member_kind_variants() {
2956        for (input, expected) in [
2957            ("user", MemberKind::User),
2958            ("group", MemberKind::Group),
2959            ("node", MemberKind::Node),
2960            ("cidr", MemberKind::Cidr),
2961        ] {
2962            let yaml = format!("name: test\nkind: {input}");
2963            let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
2964            assert_eq!(member.kind, expected);
2965        }
2966    }
2967
2968    #[test]
2969    fn test_access_action_variants() {
2970        // Test via a wrapper struct since bare enums need a YAML tag
2971        #[derive(Debug, Deserialize)]
2972        struct Wrapper {
2973            action: AccessAction,
2974        }
2975
2976        let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
2977        let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
2978
2979        assert_eq!(allow.action, AccessAction::Allow);
2980        assert_eq!(deny.action, AccessAction::Deny);
2981    }
2982
2983    #[test]
2984    fn test_network_policy_spec_default_impl() {
2985        let spec = NetworkPolicySpec::default();
2986        assert_eq!(spec.name, "");
2987        assert!(spec.description.is_none());
2988        assert!(spec.cidrs.is_empty());
2989        assert!(spec.members.is_empty());
2990        assert!(spec.access_rules.is_empty());
2991    }
2992
2993    #[test]
2994    fn container_restart_policy_serde_roundtrip_all_kinds() {
2995        // Exercise every `ContainerRestartKind` variant via a JSON roundtrip.
2996        // Covers the `snake_case` rename (`unless_stopped`, `on_failure`) and
2997        // the optional `max_attempts` / `delay` fields. Validates the wire
2998        // format the API will expose under `/v1/containers`.
2999        let cases = [
3000            (
3001                ContainerRestartPolicy {
3002                    kind: ContainerRestartKind::No,
3003                    max_attempts: None,
3004                    delay: None,
3005                },
3006                r#"{"kind":"no"}"#,
3007            ),
3008            (
3009                ContainerRestartPolicy {
3010                    kind: ContainerRestartKind::Always,
3011                    max_attempts: None,
3012                    delay: Some("500ms".to_string()),
3013                },
3014                r#"{"kind":"always","delay":"500ms"}"#,
3015            ),
3016            (
3017                ContainerRestartPolicy {
3018                    kind: ContainerRestartKind::UnlessStopped,
3019                    max_attempts: None,
3020                    delay: None,
3021                },
3022                r#"{"kind":"unless_stopped"}"#,
3023            ),
3024            (
3025                ContainerRestartPolicy {
3026                    kind: ContainerRestartKind::OnFailure,
3027                    max_attempts: Some(5),
3028                    delay: None,
3029                },
3030                r#"{"kind":"on_failure","max_attempts":5}"#,
3031            ),
3032        ];
3033
3034        for (value, expected_json) in &cases {
3035            let serialized = serde_json::to_string(value).expect("serialize");
3036            assert_eq!(&serialized, expected_json, "serialize mismatch");
3037            let round: ContainerRestartPolicy =
3038                serde_json::from_str(&serialized).expect("deserialize");
3039            assert_eq!(&round, value, "roundtrip mismatch");
3040        }
3041    }
3042
3043    // -- §3.10: RegistryAuth ------------------------------------------------
3044
3045    #[test]
3046    fn registry_auth_type_serializes_snake_case() {
3047        assert_eq!(
3048            serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
3049            "\"basic\""
3050        );
3051        assert_eq!(
3052            serde_json::to_string(&RegistryAuthType::Token).unwrap(),
3053            "\"token\""
3054        );
3055    }
3056
3057    #[test]
3058    fn registry_auth_default_auth_type_is_basic() {
3059        // When `auth_type` is omitted on the wire, the serde default kicks in.
3060        let json = r#"{"username":"u","password":"p"}"#;
3061        let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3062        assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
3063        assert_eq!(parsed.username, "u");
3064        assert_eq!(parsed.password, "p");
3065    }
3066
3067    #[test]
3068    fn registry_auth_serde_roundtrip_both_variants() {
3069        for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
3070            let cred = RegistryAuth {
3071                username: "ci-bot".to_string(),
3072                password: "s3cret".to_string(),
3073                auth_type: variant,
3074            };
3075            let serialized = serde_json::to_string(&cred).expect("serialize");
3076            let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
3077            assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
3078        }
3079    }
3080
3081    #[test]
3082    fn registry_auth_explicit_token_type_parses() {
3083        let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
3084        let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
3085        assert_eq!(parsed.auth_type, RegistryAuthType::Token);
3086    }
3087
3088    #[test]
3089    fn target_platform_as_oci_str() {
3090        assert_eq!(
3091            TargetPlatform::new(OsKind::Linux, ArchKind::Amd64).as_oci_str(),
3092            "linux/amd64"
3093        );
3094        assert_eq!(
3095            TargetPlatform::new(OsKind::Windows, ArchKind::Arm64).as_oci_str(),
3096            "windows/arm64"
3097        );
3098        assert_eq!(
3099            TargetPlatform::new(OsKind::Macos, ArchKind::Arm64).as_oci_str(),
3100            "darwin/arm64"
3101        );
3102    }
3103
3104    #[test]
3105    fn os_kind_from_rust_consts() {
3106        assert_eq!(OsKind::from_rust_os("linux"), Some(OsKind::Linux));
3107        assert_eq!(OsKind::from_rust_os("windows"), Some(OsKind::Windows));
3108        assert_eq!(OsKind::from_rust_os("macos"), Some(OsKind::Macos));
3109        assert_eq!(OsKind::from_rust_os("freebsd"), None);
3110    }
3111
3112    #[test]
3113    fn arch_kind_from_rust_consts() {
3114        assert_eq!(ArchKind::from_rust_arch("x86_64"), Some(ArchKind::Amd64));
3115        assert_eq!(ArchKind::from_rust_arch("aarch64"), Some(ArchKind::Arm64));
3116        assert_eq!(ArchKind::from_rust_arch("riscv64"), None);
3117    }
3118
3119    #[test]
3120    fn service_spec_platform_yaml_round_trip_none() {
3121        // Omitting `platform` from YAML should deserialize as None without error,
3122        // even though ServiceSpec has `#[serde(deny_unknown_fields)]`.
3123        let yaml = r"
3124version: v1
3125deployment: test
3126services:
3127  app:
3128    rtype: service
3129    image:
3130      name: nginx:latest
3131";
3132        let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3133        assert!(spec.services["app"].platform.is_none());
3134    }
3135
3136    #[test]
3137    fn service_spec_platform_yaml_round_trip_some() {
3138        let yaml = r"
3139version: v1
3140deployment: test
3141services:
3142  app:
3143    rtype: service
3144    image:
3145      name: nginx:latest
3146    platform:
3147      os: windows
3148      arch: amd64
3149";
3150        let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3151        assert_eq!(
3152            spec.services["app"].platform,
3153            Some(TargetPlatform::new(OsKind::Windows, ArchKind::Amd64))
3154        );
3155    }
3156
3157    #[test]
3158    fn service_spec_platform_serializes_omitted_when_none() {
3159        // Build a minimal ServiceSpec via YAML to avoid enumerating every field
3160        // (ServiceSpec has no Default impl and no named-struct helper).
3161        let yaml = r"
3162version: v1
3163deployment: test
3164services:
3165  app:
3166    rtype: service
3167    image:
3168      name: nginx:latest
3169";
3170        let mut spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
3171        let service = spec.services.get_mut("app").expect("service present");
3172        service.platform = None;
3173        let rendered = serde_yaml::to_string(service).expect("render");
3174        assert!(
3175            !rendered.contains("platform"),
3176            "platform must be omitted when None: {rendered}"
3177        );
3178    }
3179
3180    #[test]
3181    fn target_platform_os_version_builder() {
3182        let p =
3183            TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3184        assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3185        assert_eq!(p.os, OsKind::Windows);
3186        assert_eq!(p.arch, ArchKind::Amd64);
3187    }
3188
3189    #[test]
3190    fn target_platform_os_version_yaml_roundtrip() {
3191        let yaml = "os: windows\narch: amd64\nosVersion: 10.0.26100.1\n";
3192        let p: TargetPlatform = serde_yaml::from_str(yaml).expect("yaml parse");
3193        assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
3194        assert_eq!(p.os, OsKind::Windows);
3195        assert_eq!(p.arch, ArchKind::Amd64);
3196    }
3197
3198    #[test]
3199    fn target_platform_os_version_yaml_omits_when_none() {
3200        let p = TargetPlatform::new(OsKind::Linux, ArchKind::Amd64);
3201        let rendered = serde_yaml::to_string(&p).expect("render");
3202        assert!(
3203            !rendered.contains("osVersion"),
3204            "osVersion must be omitted when None: {rendered}"
3205        );
3206    }
3207
3208    #[test]
3209    fn target_platform_as_detailed_str_includes_version() {
3210        let without = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).as_detailed_str();
3211        assert_eq!(without, "windows/amd64");
3212
3213        let with = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64)
3214            .with_os_version("10.0.26100.1")
3215            .as_detailed_str();
3216        assert_eq!(with, "windows/amd64 (os.version=10.0.26100.1)");
3217    }
3218
3219    #[test]
3220    fn target_platform_display_ignores_version() {
3221        // Display deliberately stays terse so existing log lines don't change.
3222        let p =
3223            TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
3224        assert_eq!(format!("{p}"), "windows/amd64");
3225    }
3226}