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