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