Skip to main content

zlayer_types/api/
containers.rs

1//! Raw container lifecycle API DTOs.
2//!
3//! Wire-format types shared between the daemon's `/api/v1/containers`
4//! endpoints and SDK clients. Moved out of `zlayer-api` so SDK crates can
5//! depend on them without pulling in the full server stack.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10use utoipa::{IntoParams, ToSchema};
11
12/// Inline serde shim for `Option<Duration>` ↔ humantime strings.
13///
14/// Mirrors the `duration::option` module in `spec/types.rs` so the request
15/// types here can accept the same wire format (e.g. `"30s"`, `"500ms"`,
16/// `"1m"`) as the spec's [`crate::spec::ServiceSpec::stop_grace_period`]
17/// without taking on a `humantime_serde` dependency.
18mod duration_opt {
19    use humantime::format_duration;
20    use serde::{Deserialize, Deserializer, Serializer};
21    use std::time::Duration;
22
23    #[allow(clippy::ref_option)]
24    pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
25    where
26        S: Serializer,
27    {
28        match duration {
29            Some(d) => serializer.serialize_str(&format_duration(*d).to_string()),
30            None => serializer.serialize_none(),
31        }
32    }
33
34    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
35    where
36        D: Deserializer<'de>,
37    {
38        use serde::de::Error;
39        let s: Option<String> = Option::deserialize(deserializer)?;
40        match s {
41            Some(s) => humantime::parse_duration(&s)
42                .map(Some)
43                .map_err(|e| D::Error::custom(format!("invalid duration: {e}"))),
44            None => Ok(None),
45        }
46    }
47}
48
49/// Resource limits for a container
50#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
51pub struct ContainerResourceLimits {
52    /// CPU limit in cores (e.g., 0.5, 1.0, 2.0)
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub cpu: Option<f64>,
55    /// Memory limit (e.g., "256Mi", "1Gi")
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub memory: Option<String>,
58}
59
60/// Volume mount kind discriminator.
61///
62/// Selects which [`zlayer_spec::StorageSpec`] variant [`VolumeMount`] is
63/// translated into by [`build_service_spec`]. When omitted on the wire,
64/// defaults to [`VolumeMountType::Bind`] (legacy behavior).
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
66#[serde(rename_all = "snake_case")]
67pub enum VolumeMountType {
68    /// Host-path bind mount. `source` is an absolute host path.
69    Bind,
70    /// Named persistent volume. `source` is the volume name (managed by
71    /// `/api/v1/volumes`), not a host path.
72    Volume,
73    /// Memory-backed tmpfs mount. `source` must be empty/omitted.
74    Tmpfs,
75}
76
77/// Volume mount specification.
78///
79/// The `type` field (a Docker-compatible discriminator) selects how `source`
80/// is interpreted:
81/// - `"bind"` (default): `source` is an absolute host path.
82/// - `"volume"`: `source` is a named-volume identifier.
83/// - `"tmpfs"`: no `source`; a memory-backed mount is provisioned.
84#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
85pub struct VolumeMount {
86    /// Mount kind. Omit (or `"bind"`) for legacy host-path binds.
87    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
88    pub mount_type: Option<VolumeMountType>,
89    /// Host path (bind), volume name (volume), or unused (tmpfs).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub source: Option<String>,
92    /// Container mount path
93    pub target: String,
94    /// Mount as read-only
95    #[serde(default)]
96    pub readonly: bool,
97}
98
99/// Container health check request.
100///
101/// Mirrors the on-disk `HealthCheck` enum (see `zlayer_spec::HealthCheck`) as a
102/// discriminated union keyed on `type`. Translated to `zlayer_spec::HealthSpec`
103/// by `HealthCheckRequest::to_health_spec`. Durations are humantime strings
104/// (for example `"10s"`, `"500ms"`, `"1m"`).
105///
106/// ## Variants
107/// - `type: "tcp"` — requires `port` (1-65535).
108/// - `type: "http"` — requires `url`; `expect_status` defaults to 200.
109/// - `type: "command"` — requires `command` (array of argv tokens; joined with
110///   spaces and passed to `sh -c` by the health monitor, matching the existing
111///   compose-to-ZLayer conversion in `zlayer-docker`).
112#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
113pub struct HealthCheckRequest {
114    /// Check variant: `"tcp"`, `"http"`, or `"command"`.
115    #[serde(rename = "type")]
116    pub check_type: String,
117    /// TCP port (required when `type == "tcp"`).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub port: Option<u16>,
120    /// HTTP URL (required when `type == "http"`).
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub url: Option<String>,
123    /// HTTP status code expected from `url` (defaults to 200).
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub expect_status: Option<u16>,
126    /// Command argv (required when `type == "command"`). Joined with spaces
127    /// and passed to `sh -c`.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub command: Option<Vec<String>>,
130    /// Interval between checks, humantime format (e.g. `"30s"`). Defaults to 30s.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub interval: Option<String>,
133    /// Timeout per individual check, humantime format.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub timeout: Option<String>,
136    /// Number of consecutive failures before marking unhealthy. Defaults to 3.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub retries: Option<u32>,
139    /// Grace period before the first check runs, humantime format. Maps to
140    /// `HealthSpec::start_grace`.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub start_period: Option<String>,
143}
144
145/// Request to create and start a container
146#[derive(Debug, Default, Deserialize, Serialize, ToSchema)]
147pub struct CreateContainerRequest {
148    /// OCI image reference (e.g., "nginx:latest", "ubuntu:22.04")
149    pub image: String,
150    /// Optional human-readable name
151    #[serde(default)]
152    pub name: Option<String>,
153    /// Image pull policy: "always", "`if_not_present`", or "never"
154    #[serde(default)]
155    pub pull_policy: Option<String>,
156    /// Environment variables
157    #[serde(default)]
158    pub env: HashMap<String, String>,
159    /// Command to run (overrides image entrypoint)
160    #[serde(default)]
161    pub command: Option<Vec<String>>,
162    /// Labels for filtering and grouping
163    #[serde(default)]
164    pub labels: HashMap<String, String>,
165    /// Resource limits (CPU, memory)
166    #[serde(default)]
167    pub resources: Option<ContainerResourceLimits>,
168    /// Volume mounts
169    #[serde(default)]
170    pub volumes: Vec<VolumeMount>,
171    /// Published ports (Docker's `-p host:container/proto`). When omitted,
172    /// the container is created without any host port publishing.
173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174    pub ports: Vec<crate::spec::PortMapping>,
175    /// Working directory inside the container
176    #[serde(default)]
177    pub work_dir: Option<String>,
178    /// Optional health check. When omitted, the daemon installs a no-op
179    /// placeholder (`HealthCheck::Tcp { port: 0 }`) matching the current
180    /// default; the health monitor treats `port == 0` as "skip".
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub health_check: Option<HealthCheckRequest>,
183    /// Optional container hostname (maps to Docker's `--hostname`).
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub hostname: Option<String>,
186    /// Additional DNS servers (maps to Docker's `--dns`). Each entry must be
187    /// a plausible IPv4 or IPv6 address.
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub dns: Vec<String>,
190    /// Extra `hostname:ip` entries appended to `/etc/hosts` (maps to Docker's
191    /// `--add-host`). The special literal `host-gateway` is accepted as the
192    /// `ip` half.
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    pub extra_hosts: Vec<String>,
195    /// Container restart policy (Docker-style). When omitted, the runtime
196    /// applies no explicit restart policy (Docker default: `"no"`).
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub restart_policy: Option<crate::spec::ContainerRestartPolicy>,
199    /// User-defined bridge/overlay networks to attach the newly-created
200    /// container to. Each entry references a network by id or name and is
201    /// attached after the container is successfully started. If any
202    /// attachment fails, the partially-started container is rolled back
203    /// (stopped + removed) and the request is failed.
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub networks: Vec<NetworkAttachmentRequest>,
206    // -- §3.10: registry auth ------------------------------------------------
207    /// Id of a persisted registry credential (from
208    /// `POST /api/v1/credentials/registry`) to use when pulling the image.
209    /// Ignored when [`Self::registry_auth`] is also supplied (inline auth
210    /// wins). Requires the daemon to be configured with a credential store
211    /// — otherwise the request is rejected with `400`.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub registry_credential_id: Option<String>,
214    /// Inline Docker/OCI registry credentials used for this pull only. Not
215    /// persisted, never logged, never echoed back on a response. When both
216    /// `registry_credential_id` and `registry_auth` are set, this field
217    /// takes precedence.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub registry_auth: Option<crate::spec::RegistryAuth>,
220
221    // -- Docker lifecycle / security ----------------------------------------
222    /// Run the container in privileged mode (Docker `--privileged`). When
223    /// omitted, defaults to `false`.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub privileged: Option<bool>,
226    /// Linux capabilities to add (Docker `--cap-add`). Maps to
227    /// `ServiceSpec::capabilities`.
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub cap_add: Vec<String>,
230    /// Linux capabilities to drop (Docker `--cap-drop`).
231    #[serde(default, skip_serializing_if = "Vec::is_empty")]
232    pub cap_drop: Vec<String>,
233    /// Host devices to expose to the container (Docker `--device`).
234    #[serde(default, skip_serializing_if = "Vec::is_empty")]
235    pub devices: Vec<crate::spec::DeviceSpec>,
236    /// Network mode (Docker `--network`). Accepts `"default"`, `"host"`,
237    /// `"none"`, `"bridge"`, `"bridge:<name>"`, or `"container:<id>"`. When
238    /// omitted, defaults to [`crate::spec::NetworkMode::Default`].
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub network_mode: Option<crate::spec::NetworkMode>,
241    /// Security options such as `apparmor=...`, `seccomp=...`,
242    /// `no-new-privileges:true` (Docker `--security-opt`).
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    pub security_opt: Vec<String>,
245    /// PID namespace mode (Docker `--pid`). Accepts e.g. `"host"` or
246    /// `"container:<id>"`.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub pid_mode: Option<String>,
249    /// IPC namespace mode (Docker `--ipc`). Accepts e.g. `"host"`,
250    /// `"shareable"`, `"private"`, or `"container:<id>"`.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub ipc_mode: Option<String>,
253    /// Mount the container's root filesystem read-only (Docker `--read-only`).
254    #[serde(default)]
255    pub read_only_root_fs: bool,
256    /// Run a Docker-supplied init process (PID 1) inside the container
257    /// (Docker `--init`). Distinct from `ZLayer`'s pre-start init actions.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub init_container: Option<bool>,
260
261    // -- Docker metadata ----------------------------------------------------
262    /// User and group override for the container's main process
263    /// (Docker `--user uid:gid`).
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub user: Option<String>,
266    /// Signal sent to the container's main process to request a graceful
267    /// shutdown (Docker `--stop-signal`). Accepts e.g. `"SIGTERM"` or `"15"`.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub stop_signal: Option<String>,
270    /// Grace period to wait between the stop signal and a forced kill
271    /// (Docker `--stop-timeout`). Wire format is a humantime string
272    /// (e.g. `"30s"`, `"500ms"`, `"1m"`).
273    #[serde(
274        default,
275        with = "duration_opt",
276        skip_serializing_if = "Option::is_none"
277    )]
278    #[schema(value_type = Option<String>, example = "30s")]
279    pub stop_grace_period: Option<std::time::Duration>,
280    /// Kernel sysctl overrides (Docker `--sysctl`).
281    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
282    pub sysctls: HashMap<String, String>,
283    /// Per-process ulimits (Docker `--ulimit`).
284    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
285    pub ulimits: HashMap<String, crate::spec::UlimitSpec>,
286    /// Additional groups to add to the container process
287    /// (Docker `--group-add`).
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub extra_groups: Vec<String>,
290
291    // -- Docker resource knobs (folded into `ServiceSpec::resources`) -------
292    /// Maximum number of processes the container may spawn
293    /// (Docker `--pids-limit`).
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub pids_limit: Option<i64>,
296    /// CPUs that the container is allowed to execute on
297    /// (Docker `--cpuset-cpus`).
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub cpuset: Option<String>,
300    /// Relative CPU shares (Docker `--cpu-shares`). Default weight is 1024.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub cpu_shares: Option<u32>,
303    /// Total memory limit including swap (Docker `--memory-swap`).
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub memory_swap: Option<String>,
306    /// Soft memory limit (Docker `--memory-reservation`).
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub memory_reservation: Option<String>,
309    /// Container memory swappiness, 0-100 (Docker `--memory-swappiness`).
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub memory_swappiness: Option<u8>,
312    /// OOM-killer score adjustment (Docker `--oom-score-adj`).
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub oom_score_adj: Option<i32>,
315    /// Disable the OOM killer for the container (Docker `--oom-kill-disable`).
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub oom_kill_disable: Option<bool>,
318    /// Block IO weight, 10-1000 (Docker `--blkio-weight`).
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub blkio_weight: Option<u16>,
321
322    // -- Lifecycle ----------------------------------------------------------
323    /// Container lifecycle policy. Carries the `delete_on_exit` knob (Docker
324    /// `--rm` / `HostConfig.AutoRemove`) so the daemon can remove terminated
325    /// container records and bundles once they exit. Defaults to
326    /// [`crate::spec::LifecycleSpec::default()`] (i.e. retain on exit), which
327    /// matches the historical behavior for callers that omit the field.
328    #[serde(default)]
329    pub lifecycle: crate::spec::LifecycleSpec,
330
331    // -- Placement ----------------------------------------------------------
332    /// Node selection constraints (required / preferred labels). When set on a
333    /// daemon that has a cluster handle, the leader places the container on a
334    /// node whose labels satisfy the required set; otherwise the field is
335    /// ignored and the container is created locally.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub node_selector: Option<crate::spec::NodeSelector>,
338    /// Target platform (OS + arch) the container must run on, e.g.
339    /// `darwin/arm64`. When set on a clustered daemon, the leader places the
340    /// container on a node whose reported platform matches; when no node
341    /// matches, the request is rejected. Ignored on single-node daemons.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub platform: Option<crate::spec::TargetPlatform>,
344
345    // -- Lifecycle: start-on-create -----------------------------------------
346    /// Whether the daemon should start the container immediately after
347    /// creating it.
348    ///
349    /// `None` (the default, and what `..Default::default()` / an omitted wire
350    /// field both produce) and `Some(true)` both mean "create and start",
351    /// preserving the historical `zlayer run`-style one-shot behaviour for the
352    /// native REST API and every existing caller.
353    ///
354    /// The Docker-compat shim sets this to `Some(false)`: Docker's
355    /// `POST /containers/create` is create-only and the client is expected to
356    /// follow up with an explicit `POST /containers/{id}/start`. Without
357    /// honouring this, a Docker `create` would auto-start the container,
358    /// leaving it in `running` state when the Docker contract requires
359    /// `created`.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub start: Option<bool>,
362
363    // -- Secrets ------------------------------------------------------------
364    /// Secret scope for resolving `$S:<name>` env references at spawn (e.g.
365    /// `env:<id>`, `project:<pid>:env:<id>`, or an opaque scope like `task:<id>`).
366    /// Parsed via `SecretScope::from_storage_scope`; `None` disables `$S:` resolution.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub secret_scope: Option<String>,
369}
370
371impl CreateContainerRequest {
372    /// Whether the daemon should start the container right after creating it.
373    ///
374    /// Returns `true` unless the request explicitly set `start: false`, so the
375    /// native one-shot behaviour is the default and only the Docker-compat
376    /// create-only path opts out.
377    #[must_use]
378    pub fn should_start_on_create(&self) -> bool {
379        self.start != Some(false)
380    }
381}
382
383/// A request to attach a freshly-created container to a user-defined bridge
384/// or overlay network, mirroring the wire-shape used by `POST
385/// /api/v1/container-networks/{id_or_name}/connect`.
386///
387/// Included on [`CreateContainerRequest::networks`] so callers can wire up
388/// every attachment in a single call instead of issuing a separate connect
389/// request per network after container create.
390#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
391pub struct NetworkAttachmentRequest {
392    /// Bridge-network id or name to attach to.
393    pub network: String,
394    /// Optional DNS aliases for this container on the network.
395    #[serde(default, skip_serializing_if = "Vec::is_empty")]
396    pub aliases: Vec<String>,
397    /// Optional static IPv4 to pin this container to. Validated as
398    /// [`std::net::Ipv4Addr`] before the runtime is called.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub ipv4_address: Option<String>,
401}
402
403/// Container information returned by the API
404#[derive(Debug, Serialize, Deserialize, ToSchema)]
405pub struct ContainerInfo {
406    /// Container identifier
407    pub id: String,
408    /// Human-readable name (if set)
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub name: Option<String>,
411    /// OCI image reference
412    pub image: String,
413    /// Container state (pending, running, exited, failed)
414    pub state: String,
415    /// Labels
416    pub labels: HashMap<String, String>,
417    /// Creation timestamp (ISO 8601)
418    pub created_at: String,
419    /// Process ID (if running)
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub pid: Option<u32>,
422    // -- §3.15: rich inspect fields -----------------------------------------
423    /// Published port mappings (container → host). Populated from the
424    /// runtime's inspect response; empty when the runtime doesn't expose
425    /// port-level detail or the container has no published ports.
426    #[serde(default, skip_serializing_if = "Vec::is_empty")]
427    pub ports: Vec<crate::spec::PortMapping>,
428    /// Networks this container is attached to, with per-network aliases
429    /// and IPv4. Empty when the runtime doesn't surface network detail.
430    #[serde(default, skip_serializing_if = "Vec::is_empty")]
431    pub networks: Vec<NetworkAttachmentInfo>,
432    /// Primary IPv4 address (first non-empty IP across attached networks).
433    /// Docker's `bridge` network is preferred when present.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub ipv4: Option<String>,
436    /// Runtime-native health status, when the container image declares a
437    /// `HEALTHCHECK` (or equivalent). `None` when the runtime doesn't track
438    /// health for this container.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub health: Option<ContainerHealthInfo>,
441    /// Most-recent exit code. `None` for containers still running and for
442    /// containers that have never exited.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub exit_code: Option<i32>,
445    /// Container environment variables (KEY -> VALUE). Surfaced in docker-compat
446    /// inspect as `Config.Env`. Empty when unknown.
447    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
448    pub env: HashMap<String, String>,
449}
450
451/// Per-network attachment entry on [`ContainerInfo::networks`].
452///
453/// Populated from the runtime's inspect response — mirrors the subset of
454/// bollard's `EndpointSettings` that API clients need to correlate a container
455/// with its `container_networks` entries.
456#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
457pub struct NetworkAttachmentInfo {
458    /// Network name as reported by the runtime. Matches the `name` field on
459    /// entries returned by `GET /api/v1/container-networks`.
460    pub network: String,
461    /// DNS aliases the container answers to on this network.
462    #[serde(default, skip_serializing_if = "Vec::is_empty")]
463    pub aliases: Vec<String>,
464    /// Assigned IPv4 on this network, if any.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub ipv4: Option<String>,
467}
468
469/// Runtime-native health snapshot on [`ContainerInfo::health`].
470///
471/// Sourced from bollard's `ContainerState.health` for Docker-backed
472/// containers. The internal `HealthMonitor` in
473/// `crates/zlayer-agent/src/health.rs` drives service-level health events
474/// against user-configured health specs; for standalone containers the API
475/// reports the runtime-native status instead so images with a baked-in
476/// `HEALTHCHECK` still surface correctly.
477#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
478pub struct ContainerHealthInfo {
479    /// One of `"none"`, `"starting"`, `"healthy"`, `"unhealthy"` (Docker
480    /// `HealthStatusEnum`). Empty / missing upstream values normalise to
481    /// `"none"`.
482    pub status: String,
483    /// Consecutive failing probe count, when the runtime tracks it.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub failing_streak: Option<u32>,
486    /// Output from the most recent failing probe, when available.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub last_output: Option<String>,
489}
490
491/// Query parameters for listing containers
492#[derive(Debug, Deserialize, IntoParams)]
493pub struct ListContainersQuery {
494    /// Filter by label (key=value format)
495    #[serde(default)]
496    pub label: Option<String>,
497}
498
499/// Query parameters for container logs.
500///
501/// Mirrors the Docker Engine API `GET /containers/{id}/logs` query string so
502/// the streaming handler can pass options through to
503/// [`zlayer_agent::runtime::Runtime::logs_stream`] with minimal translation.
504#[derive(Debug, Default, Deserialize, IntoParams)]
505pub struct ContainerLogQuery {
506    /// Number of tail lines to return. `0` and "all" map to "everything
507    /// available"; otherwise the runtime ships the last `tail` lines before
508    /// the live stream begins.
509    #[serde(default = "default_tail")]
510    pub tail: usize,
511    /// Follow logs after the current end-of-buffer marker.
512    #[serde(default)]
513    pub follow: bool,
514    /// Earliest log timestamp to include (Unix seconds). `None` means no
515    /// lower bound.
516    #[serde(default)]
517    pub since: Option<i64>,
518    /// Latest log timestamp to include (Unix seconds). `None` means no upper
519    /// bound.
520    #[serde(default)]
521    pub until: Option<i64>,
522    /// When `true`, the runtime is asked to populate per-chunk timestamps so
523    /// the wire-format includes them.
524    #[serde(default)]
525    pub timestamps: bool,
526    /// Include stdout chunks. When neither `stdout` nor `stderr` is set, the
527    /// handler defaults both to `true` (Docker parity).
528    #[serde(default)]
529    pub stdout: Option<bool>,
530    /// Include stderr chunks. See [`ContainerLogQuery::stdout`] for the
531    /// "neither set" default behavior.
532    #[serde(default)]
533    pub stderr: Option<bool>,
534    /// Wire format for the streamed body. `"json"` (the default) emits one
535    /// NDJSON `LogChunk` per line (`application/x-ndjson`); `"raw"` emits
536    /// Docker multiplexed-stream frames
537    /// (`application/vnd.docker.multiplexed-stream`), consumed by the Docker
538    /// socket compat shim; `"sse"` emits the same records as Server-Sent
539    /// Events frames (`text/event-stream`). The SSE form can also be selected
540    /// via an `Accept: text/event-stream` request header, but an explicit
541    /// `format=raw` always wins over the `Accept` header.
542    #[serde(default)]
543    pub format: Option<ContainerLogFormat>,
544}
545
546/// Wire format for [`ContainerLogQuery::format`].
547#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
548#[serde(rename_all = "lowercase")]
549pub enum ContainerLogFormat {
550    /// Newline-delimited JSON, one `LogChunk` per line. The default.
551    #[default]
552    Json,
553    /// Docker multiplexed-stream frames (an 8-byte stdio header per record),
554    /// `application/vnd.docker.multiplexed-stream`. Consumed by the Docker
555    /// socket compat shim.
556    Raw,
557    /// Server-Sent Events: each `LogChunk` as a `data: <json>\n\n` frame.
558    Sse,
559}
560
561fn default_tail() -> usize {
562    100
563}
564
565/// Exec request for running a command in a container
566#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
567pub struct ContainerExecRequest {
568    /// Command and arguments to execute
569    pub command: Vec<String>,
570    /// Optional `user[:group]` to run the command as (Docker `--user`). A NAME
571    /// (e.g. `git`) is resolved against the container's `/etc/passwd` by the
572    /// runtime; numeric `uid` / `uid:gid` are used directly. `None` keeps the
573    /// container's configured user (root by default).
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub user: Option<String>,
576    /// Optional working directory inside the container (Docker `-w`/`--workdir`).
577    /// `None` keeps the container's default workdir.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub working_dir: Option<String>,
580    /// Extra environment variables in `KEY=VALUE` form (Docker `-e`/`--env`),
581    /// merged on top of the container's env (later entries win).
582    #[serde(default, skip_serializing_if = "Vec::is_empty")]
583    pub env: Vec<String>,
584}
585
586/// Query parameters for the exec endpoint.
587///
588/// When `stream=true` the handler returns a Server-Sent Events stream with
589/// one `stdout` / `stderr` event per line of output and a final `exit` event
590/// carrying the exit code as JSON. When `stream=false` (the default) the
591/// handler buffers the whole output and returns a single JSON
592/// [`ContainerExecResponse`] body.
593#[derive(Debug, Default, Deserialize, IntoParams)]
594pub struct ExecQuery {
595    /// Stream exec events as SSE instead of returning a buffered JSON body.
596    #[serde(default)]
597    pub stream: bool,
598}
599
600/// Exec response with command output
601#[derive(Debug, Serialize, Deserialize, ToSchema)]
602pub struct ContainerExecResponse {
603    /// Exit code from the command
604    pub exit_code: i32,
605    /// Standard output
606    pub stdout: String,
607    /// Standard error
608    pub stderr: String,
609}
610
611/// Request body for stopping a container. Matches the Docker-compat
612/// `POST /containers/{id}/stop` shape.
613#[derive(Debug, Default, Deserialize, ToSchema)]
614pub struct StopContainerRequest {
615    /// Graceful shutdown timeout in seconds before the runtime force-kills
616    /// the container. Defaults to 30 seconds when omitted.
617    #[serde(default)]
618    pub timeout: Option<u64>,
619}
620
621/// Request body for restarting a container. Matches the Docker-compat
622/// `POST /containers/{id}/restart` shape.
623#[derive(Debug, Default, Deserialize, ToSchema)]
624pub struct RestartContainerRequest {
625    /// Graceful shutdown timeout in seconds before the runtime force-kills
626    /// the container. Defaults to 30 seconds when omitted.
627    #[serde(default)]
628    pub timeout: Option<u64>,
629}
630
631/// Request body for killing (sending a signal to) a container. Matches the
632/// Docker-compat `POST /containers/{id}/kill` shape.
633#[derive(Debug, Default, Deserialize, ToSchema)]
634pub struct KillContainerRequest {
635    /// Signal name to send (e.g. `"SIGTERM"`, `"SIGINT"`). Accepts both the
636    /// `SIG`-prefixed and bare forms. When omitted, defaults to `SIGKILL`.
637    #[serde(default)]
638    pub signal: Option<String>,
639}
640
641/// Restart policy entry for [`ContainerUpdateRequest`].
642///
643/// Mirrors Docker's `HostConfig.RestartPolicy` shape so the Docker compat
644/// layer can pass the wire payload through unchanged. `name` accepts the
645/// same set of strings as `docker run --restart`: `""`, `"no"`, `"always"`,
646/// `"unless-stopped"`, or `"on-failure"`. `maximum_retry_count` is only
647/// honoured when `name == "on-failure"`.
648#[derive(Debug, Default, Clone, Deserialize, Serialize, ToSchema, PartialEq, Eq)]
649pub struct ContainerUpdateRestartPolicy {
650    /// `"no"`, `"always"`, `"unless-stopped"`, or `"on-failure"`.
651    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
652    pub name: Option<String>,
653    /// Maximum number of retries before giving up (only used with
654    /// `on-failure`). When `0` or omitted, retries are unbounded.
655    #[serde(
656        rename = "MaximumRetryCount",
657        default,
658        skip_serializing_if = "Option::is_none"
659    )]
660    pub maximum_retry_count: Option<i64>,
661}
662
663/// Request body for `POST /api/v1/containers/{id}/update`.
664///
665/// Mirrors Docker Engine's `POST /containers/{id}/update` body 1:1 so the
666/// `zlayer-docker` compatibility shim can pass the wire payload straight
667/// through. Every field is optional — only the fields present on the wire
668/// are applied; unset fields are left untouched on the running container.
669///
670/// Field naming uses Docker's `PascalCase` on the wire (`CpuShares`,
671/// `Memory`, ...) and `snake_case` on the Rust side. Subset of the full
672/// Docker schema: `ZLayer` supports the resource knobs (cpu, memory, pids,
673/// blkio) plus `RestartPolicy`. Windows-only fields (`CpuCount`,
674/// `IOMaximumIOps`) and ulimits/devices are accepted on the wire but
675/// silently ignored by the Linux runtimes.
676#[derive(Debug, Default, Clone, Deserialize, Serialize, ToSchema, PartialEq, Eq)]
677pub struct ContainerUpdateRequest {
678    /// Relative CPU weight (cgroup `cpu.weight` or `cpu.shares`). Range
679    /// 2-262144 on cgroup v2; 2-262144 mapped from 1-10000 on v1.
680    #[serde(rename = "CpuShares", default, skip_serializing_if = "Option::is_none")]
681    pub cpu_shares: Option<i64>,
682
683    /// Memory limit in bytes. Set `0` to remove the limit.
684    #[serde(rename = "Memory", default, skip_serializing_if = "Option::is_none")]
685    pub memory: Option<i64>,
686
687    /// CPU CFS period in microseconds.
688    #[serde(rename = "CpuPeriod", default, skip_serializing_if = "Option::is_none")]
689    pub cpu_period: Option<i64>,
690
691    /// CPU CFS quota in microseconds. Together with `cpu_period` defines
692    /// the fraction of a CPU the container may use.
693    #[serde(rename = "CpuQuota", default, skip_serializing_if = "Option::is_none")]
694    pub cpu_quota: Option<i64>,
695
696    /// CPU real-time period in microseconds.
697    #[serde(
698        rename = "CpuRealtimePeriod",
699        default,
700        skip_serializing_if = "Option::is_none"
701    )]
702    pub cpu_realtime_period: Option<i64>,
703
704    /// CPU real-time runtime in microseconds.
705    #[serde(
706        rename = "CpuRealtimeRuntime",
707        default,
708        skip_serializing_if = "Option::is_none"
709    )]
710    pub cpu_realtime_runtime: Option<i64>,
711
712    /// CPUs allowed for execution (e.g. `"0-3"`, `"0,1"`).
713    #[serde(
714        rename = "CpusetCpus",
715        default,
716        skip_serializing_if = "Option::is_none"
717    )]
718    pub cpuset_cpus: Option<String>,
719
720    /// Memory nodes (NUMA) allowed for execution (e.g. `"0-3"`).
721    #[serde(
722        rename = "CpusetMems",
723        default,
724        skip_serializing_if = "Option::is_none"
725    )]
726    pub cpuset_mems: Option<String>,
727
728    /// Soft memory limit in bytes. The kernel reclaims pages above this
729    /// reservation when the host comes under memory pressure.
730    #[serde(
731        rename = "MemoryReservation",
732        default,
733        skip_serializing_if = "Option::is_none"
734    )]
735    pub memory_reservation: Option<i64>,
736
737    /// Total memory limit (memory + swap) in bytes. `-1` removes the swap
738    /// limit, matching Docker semantics.
739    #[serde(
740        rename = "MemorySwap",
741        default,
742        skip_serializing_if = "Option::is_none"
743    )]
744    pub memory_swap: Option<i64>,
745
746    /// Kernel memory limit in bytes (deprecated upstream; accepted for
747    /// wire compatibility).
748    #[serde(
749        rename = "KernelMemory",
750        default,
751        skip_serializing_if = "Option::is_none"
752    )]
753    pub kernel_memory: Option<i64>,
754
755    /// Block IO weight (relative weight, range 10-1000).
756    #[serde(
757        rename = "BlkioWeight",
758        default,
759        skip_serializing_if = "Option::is_none"
760    )]
761    pub blkio_weight: Option<u16>,
762
763    /// PIDs limit. Set `0` or `-1` for unlimited.
764    #[serde(rename = "PidsLimit", default, skip_serializing_if = "Option::is_none")]
765    pub pids_limit: Option<i64>,
766
767    /// New restart policy. When present, replaces the container's stored
768    /// restart policy. Docker applies this asynchronously: the next time
769    /// the supervisor decides whether to restart, it consults the new
770    /// policy.
771    #[serde(
772        rename = "RestartPolicy",
773        default,
774        skip_serializing_if = "Option::is_none"
775    )]
776    pub restart_policy: Option<ContainerUpdateRestartPolicy>,
777}
778
779/// Response body for `POST /api/v1/containers/{id}/update`.
780///
781/// Mirrors Docker's `{"Warnings": [...]}` shape so the compat layer
782/// passes the body through verbatim. `Warnings` is always present (even
783/// if empty) for wire compatibility with clients that match the field
784/// presence, not just its contents.
785#[derive(Debug, Default, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
786pub struct ContainerUpdateResponse {
787    /// Human-readable warnings emitted by the runtime while applying the
788    /// update — e.g. `"kernel memory limit is deprecated"`.
789    #[serde(rename = "Warnings", default)]
790    pub warnings: Vec<String>,
791}
792
793/// Wait response with container exit code plus optional classification
794/// fields (added in §3.12 of the SDK-fixes spec).
795///
796/// The three optional fields (`reason`, `signal`, `finished_at`) are
797/// additive — clients that only read `exit_code` keep working unchanged.
798#[derive(Debug, Serialize, Deserialize, ToSchema)]
799pub struct ContainerWaitResponse {
800    /// Container identifier
801    pub id: String,
802    /// Exit code (0 = success). When the container was killed by signal
803    /// `N`, this is typically `128 + N`.
804    pub exit_code: i32,
805    /// Classification of the exit. One of `"exited"`, `"signal"`,
806    /// `"oom_killed"`, or `"runtime_error"`. Absent when the runtime
807    /// didn't classify the exit.
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub reason: Option<String>,
810    /// Signal name when `reason == "signal"`, e.g. `"SIGKILL"`. Absent
811    /// when the runtime couldn't determine it (or the exit wasn't a
812    /// signal death).
813    #[serde(default, skip_serializing_if = "Option::is_none")]
814    pub signal: Option<String>,
815    /// RFC3339 timestamp of when the container exited, if reported by
816    /// the runtime.
817    #[serde(default, skip_serializing_if = "Option::is_none")]
818    pub finished_at: Option<String>,
819}
820
821/// Docker-shaped wait response returned by
822/// `POST /api/v1/containers/{id}/wait`.
823///
824/// Mirrors Docker Engine's `/containers/{id}/wait` body 1:1: a
825/// `StatusCode` field plus an optional `Error` envelope. Used by the
826/// `zlayer-docker` compatibility shim and any SDK callers that consume
827/// the Docker shape directly. The richer
828/// [`ContainerWaitResponse`] (returned by the legacy `GET` endpoint) is
829/// preserved for clients that need the `reason` / `signal` / `finished_at`
830/// classification fields.
831#[derive(Debug, Serialize, Deserialize, ToSchema)]
832pub struct ContainerWaitDockerResponse {
833    /// Container exit code (0 = success). When killed by signal `N`,
834    /// this is typically `128 + N`, matching Docker's convention.
835    #[serde(rename = "StatusCode")]
836    pub status_code: i64,
837    /// Optional error envelope surfaced when the wait itself failed
838    /// (e.g. the container was removed before reaching `not-running`
839    /// when `condition=not-running` was requested). Absent on a normal
840    /// exit.
841    #[serde(rename = "Error", default, skip_serializing_if = "Option::is_none")]
842    pub error: Option<ContainerWaitDockerError>,
843}
844
845/// Error envelope nested inside [`ContainerWaitDockerResponse`].
846#[derive(Debug, Serialize, Deserialize, ToSchema)]
847pub struct ContainerWaitDockerError {
848    /// Human-readable description of why the wait failed.
849    #[serde(rename = "Message")]
850    pub message: String,
851}
852
853/// Query parameters for `POST /api/v1/containers/{id}/wait` —
854/// Docker's `condition=` query string.
855#[derive(Debug, Default, Deserialize, IntoParams)]
856pub struct WaitContainerQuery {
857    /// One of `"not-running"` (default), `"next-exit"`, or `"removed"`.
858    /// Matches Docker's `/containers/{id}/wait` semantics. Omitted
859    /// values default to `"not-running"`.
860    #[serde(default)]
861    pub condition: Option<String>,
862}
863
864/// Query parameters for `POST /api/v1/containers/{id}/rename` —
865/// Docker's `name=<new-name>` query string.
866#[derive(Debug, Default, Deserialize, IntoParams)]
867pub struct RenameContainerQuery {
868    /// New human-readable name to assign to the container. Required.
869    #[serde(default)]
870    pub name: Option<String>,
871}
872
873/// Container resource statistics
874#[derive(Debug, Serialize, Deserialize, ToSchema)]
875pub struct ContainerStatsResponse {
876    /// Container identifier
877    pub id: String,
878    /// CPU usage in microseconds
879    pub cpu_usage_usec: u64,
880    /// Current memory usage in bytes
881    pub memory_bytes: u64,
882    /// Memory limit in bytes (`u64::MAX` if unlimited)
883    pub memory_limit: u64,
884    /// Memory usage as percentage of limit
885    pub memory_percent: f64,
886}
887
888/// Query parameters for container stats.
889///
890/// When `stream=false` (default), the handler returns a single JSON
891/// [`ContainerStatsResponse`]. When `stream=true`, the handler switches to
892/// Server-Sent Events and emits one `ContainerStatsResponse` sample per
893/// `interval` seconds until the container exits or the client disconnects.
894///
895/// `interval` is clamped to `[1, 60]` seconds. Default interval is `2`.
896#[derive(Debug, Default, Deserialize, IntoParams)]
897pub struct StatsQuery {
898    /// Stream periodic samples as SSE events instead of a one-shot JSON
899    /// response.
900    #[serde(default)]
901    pub stream: bool,
902    /// Sample cadence in seconds (only used when `stream=true`). Clamped to
903    /// `[1, 60]`. Defaults to `2` seconds.
904    #[serde(default, alias = "interval_seconds")]
905    pub interval: Option<u32>,
906}
907
908/// Query parameters for `GET /api/v1/containers/{id}/top` —
909/// Docker's `ps_args=<...>` query string. Defaults to the runtime's
910/// own column set when omitted or empty.
911#[derive(Debug, Default, Deserialize, IntoParams)]
912pub struct ContainerTopQuery {
913    /// `ps`-style argument string, e.g. `"aux"` or `"-eo pid,user,cmd"`.
914    /// Empty / omitted means "use the runtime's defaults".
915    #[serde(default)]
916    pub ps_args: Option<String>,
917}
918
919/// Response body for `GET /api/v1/containers/{id}/top` (Docker compat shape).
920///
921/// Wire field names use Docker's `Titles` / `Processes` casing so the
922/// shim can pass the body through untouched.
923#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
924pub struct ContainerTopResponse {
925    /// `ps` column titles — e.g. `["UID", "PID", "PPID", "C", "STIME",
926    /// "TTY", "TIME", "CMD"]`.
927    #[serde(rename = "Titles")]
928    pub titles: Vec<String>,
929    /// One row per process inside the container. Each row has the same
930    /// length as `titles`.
931    #[serde(rename = "Processes")]
932    pub processes: Vec<Vec<String>>,
933}
934
935/// One row of `GET /api/v1/containers/{id}/changes` (Docker compat shape).
936///
937/// Mirrors Docker's `{"Path": "/foo", "Kind": 0}` body:
938/// `Kind` is a numeric enum where `0 = Modified`, `1 = Added`, `2 = Deleted`.
939#[derive(Debug, Serialize, Deserialize, ToSchema)]
940pub struct ContainerChangeEntry {
941    /// Path inside the container that changed (absolute, e.g. `/etc/hosts`).
942    #[serde(rename = "Path")]
943    pub path: String,
944    /// `0` = Modified, `1` = Added, `2` = Deleted (Docker's wire integer).
945    #[serde(rename = "Kind")]
946    pub kind: u8,
947}
948
949/// Response body for `GET /api/v1/containers/{id}/port` (Docker compat shape).
950///
951/// Mirrors Docker's `{"Ports": {"80/tcp": [{"HostIp":"...","HostPort":"..."}]}}`
952/// body. Each key is `<container_port>/<protocol>` and the value is the list
953/// of host bindings for that port (or `null` when the port is exposed but not
954/// published).
955#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
956pub struct ContainerPortResponse {
957    /// Map of `"<port>/<protocol>"` to host bindings.
958    #[serde(rename = "Ports")]
959    pub ports: HashMap<String, Option<Vec<ContainerPortBinding>>>,
960}
961
962/// One host binding inside a [`ContainerPortResponse`] entry.
963#[derive(Debug, Serialize, Deserialize, ToSchema)]
964pub struct ContainerPortBinding {
965    /// Host IP that maps to the container port. Empty / `"0.0.0.0"` means
966    /// "any IPv4 address".
967    #[serde(rename = "HostIp", default, skip_serializing_if = "Option::is_none")]
968    pub host_ip: Option<String>,
969    /// Host port (always serialised as a string in Docker's wire format).
970    #[serde(rename = "HostPort", default, skip_serializing_if = "Option::is_none")]
971    pub host_port: Option<String>,
972}
973
974/// Response body for `POST /api/v1/containers/prune` (Docker compat shape).
975///
976/// Docker uses `ContainersDeleted` / `SpaceReclaimed` `PascalCase` fields, so
977/// SDK consumers (and the docker shim) can read the body verbatim.
978#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
979pub struct ContainerPruneResponse {
980    /// Container IDs that were removed.
981    #[serde(rename = "ContainersDeleted")]
982    pub containers_deleted: Vec<String>,
983    /// Bytes reclaimed from the runtime's container storage.
984    #[serde(rename = "SpaceReclaimed")]
985    pub space_reclaimed: u64,
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use crate::spec::{DeviceSpec, NetworkMode, UlimitSpec};
992    use std::time::Duration;
993
994    /// Build a baseline request with only the required `image` field so each
995    /// round-trip test can override exactly the slice of fields it cares
996    /// about without listing the dozens of unrelated optional fields.
997    fn baseline_request() -> CreateContainerRequest {
998        CreateContainerRequest {
999            image: "nginx:latest".to_string(),
1000            ..CreateContainerRequest::default()
1001        }
1002    }
1003
1004    #[test]
1005    fn create_request_round_trips_placement_fields() {
1006        use crate::spec::{ArchKind, NodeSelector, OsKind, TargetPlatform};
1007
1008        let mut req = baseline_request();
1009        req.platform = Some(TargetPlatform::new(OsKind::Macos, ArchKind::Arm64));
1010        req.node_selector = Some(NodeSelector {
1011            labels: [("zone".to_string(), "us-east".to_string())]
1012                .into_iter()
1013                .collect(),
1014            prefer_labels: std::collections::HashMap::new(),
1015        });
1016
1017        let json = serde_json::to_string(&req).expect("serialize");
1018        let back: CreateContainerRequest =
1019            serde_json::from_str(&json).expect("deserialize round-trip");
1020
1021        let platform = back.platform.expect("platform present");
1022        assert_eq!(platform.os, OsKind::Macos);
1023        assert_eq!(platform.arch, ArchKind::Arm64);
1024        let selector = back.node_selector.expect("node_selector present");
1025        assert_eq!(
1026            selector.labels.get("zone").map(String::as_str),
1027            Some("us-east")
1028        );
1029
1030        // Omitted placement fields must round-trip as None (skip_serializing_if).
1031        let bare: CreateContainerRequest =
1032            serde_json::from_str(r#"{"image":"nginx:latest"}"#).expect("deserialize bare");
1033        assert!(bare.platform.is_none());
1034        assert!(bare.node_selector.is_none());
1035    }
1036
1037    #[test]
1038    fn start_on_create_defaults_to_true_and_honours_explicit_false() {
1039        // Omitted `start` (the common case for native callers and the SDK)
1040        // must mean "create and start" — anything else regresses
1041        // `zlayer run`-style one-shots.
1042        let bare: CreateContainerRequest =
1043            serde_json::from_str(r#"{"image":"nginx:latest"}"#).expect("deserialize bare");
1044        assert_eq!(bare.start, None);
1045        assert!(
1046            bare.should_start_on_create(),
1047            "omitted start must default to start-on-create"
1048        );
1049
1050        // `..Default::default()` (used by the CLI / compose run paths and by
1051        // every in-process struct construction) must also start.
1052        let dflt = CreateContainerRequest {
1053            image: "nginx:latest".to_string(),
1054            ..CreateContainerRequest::default()
1055        };
1056        assert!(
1057            dflt.should_start_on_create(),
1058            "Default::default() must default to start-on-create"
1059        );
1060
1061        // Explicit `start: true` starts.
1062        let yes: CreateContainerRequest =
1063            serde_json::from_str(r#"{"image":"nginx:latest","start":true}"#)
1064                .expect("deserialize start=true");
1065        assert!(yes.should_start_on_create());
1066
1067        // Only an explicit `start: false` (the Docker-compat create-only path)
1068        // suppresses the auto-start.
1069        let no: CreateContainerRequest =
1070            serde_json::from_str(r#"{"image":"nginx:latest","start":false}"#)
1071                .expect("deserialize start=false");
1072        assert_eq!(no.start, Some(false));
1073        assert!(
1074            !no.should_start_on_create(),
1075            "explicit start=false must suppress the auto-start"
1076        );
1077    }
1078
1079    #[test]
1080    fn buffered_exec_response_parses_native_wire_shape() {
1081        // The native buffered exec handler emits
1082        // `Json(ContainerExecResponse { exit_code, stdout, stderr })`. Lock the
1083        // exact wire shape so a future rename can't silently regress the
1084        // `DaemonClient::exec_in_container` parse path (which fails with
1085        // "missing field 'exit_code'" when pointed at the wrong endpoint).
1086        let resp = ContainerExecResponse {
1087            exit_code: 42,
1088            stdout: "hello".to_string(),
1089            stderr: "oops".to_string(),
1090        };
1091        let wire = serde_json::to_string(&resp).expect("serialize");
1092        assert_eq!(
1093            wire, r#"{"exit_code":42,"stdout":"hello","stderr":"oops"}"#,
1094            "buffered exec wire shape must stay snake_case exit_code/stdout/stderr"
1095        );
1096        let back: ContainerExecResponse = serde_json::from_str(&wire).expect("round-trip");
1097        assert_eq!(back.exit_code, 42);
1098        assert_eq!(back.stdout, "hello");
1099        assert_eq!(back.stderr, "oops");
1100
1101        // Guard the bug we fixed: the *interactive* create-exec endpoint
1102        // returns `{"Id":"<64-hex>"}`, which must NOT parse as a buffered
1103        // exec result. (This is the `missing field 'exit_code' at line 1
1104        // column 73` failure observed when the buffered client pointed at the
1105        // create-exec route.)
1106        let create_exec_body =
1107            r#"{"Id":"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}"#;
1108        assert!(
1109            serde_json::from_str::<ContainerExecResponse>(create_exec_body).is_err(),
1110            "create-exec `{{Id}}` body must not deserialize as a buffered exec result"
1111        );
1112    }
1113
1114    #[test]
1115    fn create_request_round_trips_security_fields() {
1116        let mut req = baseline_request();
1117        req.privileged = Some(true);
1118        req.cap_add = vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()];
1119        req.cap_drop = vec!["MKNOD".to_string()];
1120        req.devices = vec![DeviceSpec {
1121            path: "/dev/kvm".to_string(),
1122            read: true,
1123            write: true,
1124            mknod: false,
1125        }];
1126        req.network_mode = Some(NetworkMode::Host);
1127        req.security_opt = vec!["no-new-privileges:true".to_string()];
1128        req.pid_mode = Some("host".to_string());
1129        req.ipc_mode = Some("shareable".to_string());
1130        req.read_only_root_fs = true;
1131        req.init_container = Some(true);
1132
1133        let json = serde_json::to_string(&req).expect("serialize");
1134        let back: CreateContainerRequest =
1135            serde_json::from_str(&json).expect("deserialize round-trip");
1136
1137        assert_eq!(back.privileged, Some(true));
1138        assert_eq!(back.cap_add, vec!["NET_ADMIN", "SYS_PTRACE"]);
1139        assert_eq!(back.cap_drop, vec!["MKNOD"]);
1140        assert_eq!(back.devices.len(), 1);
1141        assert_eq!(back.devices[0].path, "/dev/kvm");
1142        assert!(back.devices[0].read);
1143        assert!(back.devices[0].write);
1144        assert!(!back.devices[0].mknod);
1145        assert_eq!(back.network_mode, Some(NetworkMode::Host));
1146        assert_eq!(back.security_opt, vec!["no-new-privileges:true"]);
1147        assert_eq!(back.pid_mode.as_deref(), Some("host"));
1148        assert_eq!(back.ipc_mode.as_deref(), Some("shareable"));
1149        assert!(back.read_only_root_fs);
1150        assert_eq!(back.init_container, Some(true));
1151    }
1152
1153    #[test]
1154    fn create_request_round_trips_metadata_fields() {
1155        let mut req = baseline_request();
1156        req.labels.insert("env".to_string(), "prod".to_string());
1157        req.labels.insert("team".to_string(), "core".to_string());
1158        req.user = Some("1000:1000".to_string());
1159        req.stop_signal = Some("SIGTERM".to_string());
1160        req.stop_grace_period = Some(Duration::from_secs(45));
1161        req.sysctls
1162            .insert("net.core.somaxconn".to_string(), "1024".to_string());
1163        req.ulimits.insert(
1164            "nofile".to_string(),
1165            UlimitSpec {
1166                soft: 4096,
1167                hard: 8192,
1168            },
1169        );
1170        req.extra_groups = vec!["docker".to_string(), "audio".to_string()];
1171
1172        let json = serde_json::to_string(&req).expect("serialize");
1173        // Confirm the humantime wire format is a string.
1174        assert!(
1175            json.contains("\"stop_grace_period\":\"45s\""),
1176            "expected humantime stop_grace_period in JSON, got: {json}"
1177        );
1178
1179        let back: CreateContainerRequest =
1180            serde_json::from_str(&json).expect("deserialize round-trip");
1181
1182        assert_eq!(back.labels.get("env").map(String::as_str), Some("prod"));
1183        assert_eq!(back.labels.get("team").map(String::as_str), Some("core"));
1184        assert_eq!(back.user.as_deref(), Some("1000:1000"));
1185        assert_eq!(back.stop_signal.as_deref(), Some("SIGTERM"));
1186        assert_eq!(back.stop_grace_period, Some(Duration::from_secs(45)));
1187        assert_eq!(
1188            back.sysctls.get("net.core.somaxconn").map(String::as_str),
1189            Some("1024")
1190        );
1191        let nofile = back.ulimits.get("nofile").expect("nofile ulimit present");
1192        assert_eq!(nofile.soft, 4096);
1193        assert_eq!(nofile.hard, 8192);
1194        assert_eq!(back.extra_groups, vec!["docker", "audio"]);
1195    }
1196
1197    #[test]
1198    fn create_request_round_trips_resource_knobs() {
1199        let mut req = baseline_request();
1200        req.pids_limit = Some(2048);
1201        req.cpuset = Some("0-3".to_string());
1202        req.cpu_shares = Some(1024);
1203        req.memory_swap = Some("2Gi".to_string());
1204        req.memory_reservation = Some("256Mi".to_string());
1205        req.memory_swappiness = Some(10);
1206        req.oom_score_adj = Some(-500);
1207        req.oom_kill_disable = Some(false);
1208        req.blkio_weight = Some(500);
1209
1210        let json = serde_json::to_string(&req).expect("serialize");
1211        let back: CreateContainerRequest =
1212            serde_json::from_str(&json).expect("deserialize round-trip");
1213
1214        assert_eq!(back.pids_limit, Some(2048));
1215        assert_eq!(back.cpuset.as_deref(), Some("0-3"));
1216        assert_eq!(back.cpu_shares, Some(1024));
1217        assert_eq!(back.memory_swap.as_deref(), Some("2Gi"));
1218        assert_eq!(back.memory_reservation.as_deref(), Some("256Mi"));
1219        assert_eq!(back.memory_swappiness, Some(10));
1220        assert_eq!(back.oom_score_adj, Some(-500));
1221        assert_eq!(back.oom_kill_disable, Some(false));
1222        assert_eq!(back.blkio_weight, Some(500));
1223    }
1224
1225    #[test]
1226    fn create_request_round_trips_network_mode_strings() {
1227        // The spec's `NetworkMode` deserialization happens via
1228        // `deserialize_network_mode`, but that helper is only attached to
1229        // `ServiceSpec.network_mode` — at the request layer we want the
1230        // derived `Deserialize` for `NetworkMode` (lowercase enum) to
1231        // accept the same wire shapes. Confirm each of the five Docker
1232        // forms round-trips through the request body.
1233        //
1234        // Note: at the request layer, `network_mode` accepts the
1235        // externally-tagged enum form (e.g. `{"bridge": {"name": "..."}}`),
1236        // matching what the derived `Serialize` for `NetworkMode` emits.
1237        let cases: &[(&str, NetworkMode)] = &[
1238            (r#""default""#, NetworkMode::Default),
1239            (r#""host""#, NetworkMode::Host),
1240            (r#""none""#, NetworkMode::None),
1241            (
1242                r#"{"bridge":{"name":null}}"#,
1243                NetworkMode::Bridge { name: None },
1244            ),
1245            (
1246                r#"{"bridge":{"name":"custom_net"}}"#,
1247                NetworkMode::Bridge {
1248                    name: Some("custom_net".to_string()),
1249                },
1250            ),
1251            (
1252                r#"{"container":{"id":"abc"}}"#,
1253                NetworkMode::Container {
1254                    id: "abc".to_string(),
1255                },
1256            ),
1257        ];
1258
1259        for (literal, expected) in cases {
1260            let body = format!(r#"{{"image":"nginx:latest","network_mode":{literal}}}"#);
1261            let req: CreateContainerRequest = serde_json::from_str(&body)
1262                .unwrap_or_else(|e| panic!("deserialize {literal}: {e}"));
1263            assert_eq!(
1264                req.network_mode.as_ref(),
1265                Some(expected),
1266                "wire form {literal} did not round-trip",
1267            );
1268
1269            // Re-serialize and parse again to confirm the emitted form
1270            // also round-trips back into the same variant.
1271            let reser = serde_json::to_string(&req).expect("re-serialize");
1272            let again: CreateContainerRequest =
1273                serde_json::from_str(&reser).expect("re-deserialize");
1274            assert_eq!(again.network_mode.as_ref(), Some(expected));
1275        }
1276    }
1277
1278    /// `ContainerUpdateRequest` must accept Docker Engine's `PascalCase`
1279    /// wire shape verbatim (`CpuShares`, `Memory`, `RestartPolicy`, ...)
1280    /// and round-trip every documented field. This pins the contract
1281    /// `zlayer-docker` relies on when forwarding `POST /containers/{id}/update`.
1282    #[test]
1283    fn container_update_request_round_trips_docker_wire_shape() {
1284        let body = serde_json::json!({
1285            "CpuShares": 512,
1286            "Memory": 314_572_800_i64,
1287            "CpuPeriod": 100_000,
1288            "CpuQuota": 50_000,
1289            "CpuRealtimePeriod": 1_000_000,
1290            "CpuRealtimeRuntime": 950_000,
1291            "CpusetCpus": "0-3",
1292            "CpusetMems": "0,1",
1293            "MemoryReservation": 268_435_456_i64,
1294            "MemorySwap": 629_145_600_i64,
1295            "KernelMemory": 67_108_864_i64,
1296            "BlkioWeight": 500,
1297            "PidsLimit": 2048,
1298            "RestartPolicy": {
1299                "Name": "on-failure",
1300                "MaximumRetryCount": 5
1301            }
1302        });
1303
1304        let req: ContainerUpdateRequest =
1305            serde_json::from_value(body.clone()).expect("deserialize update body");
1306
1307        assert_eq!(req.cpu_shares, Some(512));
1308        assert_eq!(req.memory, Some(314_572_800));
1309        assert_eq!(req.cpu_period, Some(100_000));
1310        assert_eq!(req.cpu_quota, Some(50_000));
1311        assert_eq!(req.cpu_realtime_period, Some(1_000_000));
1312        assert_eq!(req.cpu_realtime_runtime, Some(950_000));
1313        assert_eq!(req.cpuset_cpus.as_deref(), Some("0-3"));
1314        assert_eq!(req.cpuset_mems.as_deref(), Some("0,1"));
1315        assert_eq!(req.memory_reservation, Some(268_435_456));
1316        assert_eq!(req.memory_swap, Some(629_145_600));
1317        assert_eq!(req.kernel_memory, Some(67_108_864));
1318        assert_eq!(req.blkio_weight, Some(500));
1319        assert_eq!(req.pids_limit, Some(2048));
1320        let rp = req.restart_policy.as_ref().expect("restart_policy");
1321        assert_eq!(rp.name.as_deref(), Some("on-failure"));
1322        assert_eq!(rp.maximum_retry_count, Some(5));
1323
1324        // Round-trip through the wire shape unchanged: every field must
1325        // serialize back with its PascalCase Docker name.
1326        let reser = serde_json::to_value(&req).expect("re-serialize");
1327        assert_eq!(reser, body);
1328    }
1329
1330    /// An empty body must deserialize successfully — Docker accepts
1331    /// `POST /containers/{id}/update` with `{}` (a no-op update).
1332    #[test]
1333    fn container_update_request_empty_body_deserializes_to_default() {
1334        let req: ContainerUpdateRequest =
1335            serde_json::from_str("{}").expect("empty body must deserialize");
1336        assert_eq!(req, ContainerUpdateRequest::default());
1337        assert!(req.cpu_shares.is_none());
1338        assert!(req.memory.is_none());
1339        assert!(req.restart_policy.is_none());
1340    }
1341
1342    /// `ContainerUpdateResponse` must always emit `Warnings` (even empty)
1343    /// so clients that match on field presence don't break.
1344    #[test]
1345    fn container_update_response_always_emits_warnings_field() {
1346        let resp = ContainerUpdateResponse::default();
1347        let json = serde_json::to_value(&resp).expect("serialize");
1348        assert!(json.get("Warnings").is_some(), "Warnings must be present");
1349        assert_eq!(json["Warnings"], serde_json::json!([]));
1350    }
1351}