Skip to main content

zlayer_spec/
types.rs

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