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
364impl CreateContainerRequest {
365 /// Whether the daemon should start the container right after creating it.
366 ///
367 /// Returns `true` unless the request explicitly set `start: false`, so the
368 /// native one-shot behaviour is the default and only the Docker-compat
369 /// create-only path opts out.
370 #[must_use]
371 pub fn should_start_on_create(&self) -> bool {
372 self.start != Some(false)
373 }
374}
375
376/// A request to attach a freshly-created container to a user-defined bridge
377/// or overlay network, mirroring the wire-shape used by `POST
378/// /api/v1/container-networks/{id_or_name}/connect`.
379///
380/// Included on [`CreateContainerRequest::networks`] so callers can wire up
381/// every attachment in a single call instead of issuing a separate connect
382/// request per network after container create.
383#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
384pub struct NetworkAttachmentRequest {
385 /// Bridge-network id or name to attach to.
386 pub network: String,
387 /// Optional DNS aliases for this container on the network.
388 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 pub aliases: Vec<String>,
390 /// Optional static IPv4 to pin this container to. Validated as
391 /// [`std::net::Ipv4Addr`] before the runtime is called.
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub ipv4_address: Option<String>,
394}
395
396/// Container information returned by the API
397#[derive(Debug, Serialize, Deserialize, ToSchema)]
398pub struct ContainerInfo {
399 /// Container identifier
400 pub id: String,
401 /// Human-readable name (if set)
402 #[serde(skip_serializing_if = "Option::is_none")]
403 pub name: Option<String>,
404 /// OCI image reference
405 pub image: String,
406 /// Container state (pending, running, exited, failed)
407 pub state: String,
408 /// Labels
409 pub labels: HashMap<String, String>,
410 /// Creation timestamp (ISO 8601)
411 pub created_at: String,
412 /// Process ID (if running)
413 #[serde(skip_serializing_if = "Option::is_none")]
414 pub pid: Option<u32>,
415 // -- §3.15: rich inspect fields -----------------------------------------
416 /// Published port mappings (container → host). Populated from the
417 /// runtime's inspect response; empty when the runtime doesn't expose
418 /// port-level detail or the container has no published ports.
419 #[serde(default, skip_serializing_if = "Vec::is_empty")]
420 pub ports: Vec<crate::spec::PortMapping>,
421 /// Networks this container is attached to, with per-network aliases
422 /// and IPv4. Empty when the runtime doesn't surface network detail.
423 #[serde(default, skip_serializing_if = "Vec::is_empty")]
424 pub networks: Vec<NetworkAttachmentInfo>,
425 /// Primary IPv4 address (first non-empty IP across attached networks).
426 /// Docker's `bridge` network is preferred when present.
427 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub ipv4: Option<String>,
429 /// Runtime-native health status, when the container image declares a
430 /// `HEALTHCHECK` (or equivalent). `None` when the runtime doesn't track
431 /// health for this container.
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub health: Option<ContainerHealthInfo>,
434 /// Most-recent exit code. `None` for containers still running and for
435 /// containers that have never exited.
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub exit_code: Option<i32>,
438}
439
440/// Per-network attachment entry on [`ContainerInfo::networks`].
441///
442/// Populated from the runtime's inspect response — mirrors the subset of
443/// bollard's `EndpointSettings` that API clients need to correlate a container
444/// with its `container_networks` entries.
445#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
446pub struct NetworkAttachmentInfo {
447 /// Network name as reported by the runtime. Matches the `name` field on
448 /// entries returned by `GET /api/v1/container-networks`.
449 pub network: String,
450 /// DNS aliases the container answers to on this network.
451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
452 pub aliases: Vec<String>,
453 /// Assigned IPv4 on this network, if any.
454 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub ipv4: Option<String>,
456}
457
458/// Runtime-native health snapshot on [`ContainerInfo::health`].
459///
460/// Sourced from bollard's `ContainerState.health` for Docker-backed
461/// containers. The internal `HealthMonitor` in
462/// `crates/zlayer-agent/src/health.rs` drives service-level health events
463/// against user-configured health specs; for standalone containers the API
464/// reports the runtime-native status instead so images with a baked-in
465/// `HEALTHCHECK` still surface correctly.
466#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
467pub struct ContainerHealthInfo {
468 /// One of `"none"`, `"starting"`, `"healthy"`, `"unhealthy"` (Docker
469 /// `HealthStatusEnum`). Empty / missing upstream values normalise to
470 /// `"none"`.
471 pub status: String,
472 /// Consecutive failing probe count, when the runtime tracks it.
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub failing_streak: Option<u32>,
475 /// Output from the most recent failing probe, when available.
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub last_output: Option<String>,
478}
479
480/// Query parameters for listing containers
481#[derive(Debug, Deserialize, IntoParams)]
482pub struct ListContainersQuery {
483 /// Filter by label (key=value format)
484 #[serde(default)]
485 pub label: Option<String>,
486}
487
488/// Query parameters for container logs.
489///
490/// Mirrors the Docker Engine API `GET /containers/{id}/logs` query string so
491/// the streaming handler can pass options through to
492/// [`zlayer_agent::runtime::Runtime::logs_stream`] with minimal translation.
493#[derive(Debug, Default, Deserialize, IntoParams)]
494pub struct ContainerLogQuery {
495 /// Number of tail lines to return. `0` and "all" map to "everything
496 /// available"; otherwise the runtime ships the last `tail` lines before
497 /// the live stream begins.
498 #[serde(default = "default_tail")]
499 pub tail: usize,
500 /// Follow logs after the current end-of-buffer marker.
501 #[serde(default)]
502 pub follow: bool,
503 /// Earliest log timestamp to include (Unix seconds). `None` means no
504 /// lower bound.
505 #[serde(default)]
506 pub since: Option<i64>,
507 /// Latest log timestamp to include (Unix seconds). `None` means no upper
508 /// bound.
509 #[serde(default)]
510 pub until: Option<i64>,
511 /// When `true`, the runtime is asked to populate per-chunk timestamps so
512 /// the wire-format includes them.
513 #[serde(default)]
514 pub timestamps: bool,
515 /// Include stdout chunks. When neither `stdout` nor `stderr` is set, the
516 /// handler defaults both to `true` (Docker parity).
517 #[serde(default)]
518 pub stdout: Option<bool>,
519 /// Include stderr chunks. See [`ContainerLogQuery::stdout`] for the
520 /// "neither set" default behavior.
521 #[serde(default)]
522 pub stderr: Option<bool>,
523 /// Wire format for the streamed body. `"json"` (the default) emits one
524 /// NDJSON `LogChunk` per line; `"raw"` emits Docker's multiplexed stdcopy
525 /// frames (`application/vnd.docker.raw-stream`).
526 #[serde(default)]
527 pub format: Option<ContainerLogFormat>,
528}
529
530/// Wire format for [`ContainerLogQuery::format`].
531#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
532#[serde(rename_all = "lowercase")]
533pub enum ContainerLogFormat {
534 /// Newline-delimited JSON, one `LogChunk` per line. The default.
535 #[default]
536 Json,
537 /// Docker multiplexed stdcopy framing.
538 Raw,
539}
540
541fn default_tail() -> usize {
542 100
543}
544
545/// Exec request for running a command in a container
546#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
547pub struct ContainerExecRequest {
548 /// Command and arguments to execute
549 pub command: Vec<String>,
550 /// Optional `user[:group]` to run the command as (Docker `--user`). A NAME
551 /// (e.g. `git`) is resolved against the container's `/etc/passwd` by the
552 /// runtime; numeric `uid` / `uid:gid` are used directly. `None` keeps the
553 /// container's configured user (root by default).
554 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub user: Option<String>,
556 /// Optional working directory inside the container (Docker `-w`/`--workdir`).
557 /// `None` keeps the container's default workdir.
558 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub working_dir: Option<String>,
560 /// Extra environment variables in `KEY=VALUE` form (Docker `-e`/`--env`),
561 /// merged on top of the container's env (later entries win).
562 #[serde(default, skip_serializing_if = "Vec::is_empty")]
563 pub env: Vec<String>,
564}
565
566/// Query parameters for the exec endpoint.
567///
568/// When `stream=true` the handler returns a Server-Sent Events stream with
569/// one `stdout` / `stderr` event per line of output and a final `exit` event
570/// carrying the exit code as JSON. When `stream=false` (the default) the
571/// handler buffers the whole output and returns a single JSON
572/// [`ContainerExecResponse`] body.
573#[derive(Debug, Default, Deserialize, IntoParams)]
574pub struct ExecQuery {
575 /// Stream exec events as SSE instead of returning a buffered JSON body.
576 #[serde(default)]
577 pub stream: bool,
578}
579
580/// Exec response with command output
581#[derive(Debug, Serialize, Deserialize, ToSchema)]
582pub struct ContainerExecResponse {
583 /// Exit code from the command
584 pub exit_code: i32,
585 /// Standard output
586 pub stdout: String,
587 /// Standard error
588 pub stderr: String,
589}
590
591/// Request body for stopping a container. Matches the Docker-compat
592/// `POST /containers/{id}/stop` shape.
593#[derive(Debug, Default, Deserialize, ToSchema)]
594pub struct StopContainerRequest {
595 /// Graceful shutdown timeout in seconds before the runtime force-kills
596 /// the container. Defaults to 30 seconds when omitted.
597 #[serde(default)]
598 pub timeout: Option<u64>,
599}
600
601/// Request body for restarting a container. Matches the Docker-compat
602/// `POST /containers/{id}/restart` shape.
603#[derive(Debug, Default, Deserialize, ToSchema)]
604pub struct RestartContainerRequest {
605 /// Graceful shutdown timeout in seconds before the runtime force-kills
606 /// the container. Defaults to 30 seconds when omitted.
607 #[serde(default)]
608 pub timeout: Option<u64>,
609}
610
611/// Request body for killing (sending a signal to) a container. Matches the
612/// Docker-compat `POST /containers/{id}/kill` shape.
613#[derive(Debug, Default, Deserialize, ToSchema)]
614pub struct KillContainerRequest {
615 /// Signal name to send (e.g. `"SIGTERM"`, `"SIGINT"`). Accepts both the
616 /// `SIG`-prefixed and bare forms. When omitted, defaults to `SIGKILL`.
617 #[serde(default)]
618 pub signal: Option<String>,
619}
620
621/// Restart policy entry for [`ContainerUpdateRequest`].
622///
623/// Mirrors Docker's `HostConfig.RestartPolicy` shape so the Docker compat
624/// layer can pass the wire payload through unchanged. `name` accepts the
625/// same set of strings as `docker run --restart`: `""`, `"no"`, `"always"`,
626/// `"unless-stopped"`, or `"on-failure"`. `maximum_retry_count` is only
627/// honoured when `name == "on-failure"`.
628#[derive(Debug, Default, Clone, Deserialize, Serialize, ToSchema, PartialEq, Eq)]
629pub struct ContainerUpdateRestartPolicy {
630 /// `"no"`, `"always"`, `"unless-stopped"`, or `"on-failure"`.
631 #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
632 pub name: Option<String>,
633 /// Maximum number of retries before giving up (only used with
634 /// `on-failure`). When `0` or omitted, retries are unbounded.
635 #[serde(
636 rename = "MaximumRetryCount",
637 default,
638 skip_serializing_if = "Option::is_none"
639 )]
640 pub maximum_retry_count: Option<i64>,
641}
642
643/// Request body for `POST /api/v1/containers/{id}/update`.
644///
645/// Mirrors Docker Engine's `POST /containers/{id}/update` body 1:1 so the
646/// `zlayer-docker` compatibility shim can pass the wire payload straight
647/// through. Every field is optional — only the fields present on the wire
648/// are applied; unset fields are left untouched on the running container.
649///
650/// Field naming uses Docker's `PascalCase` on the wire (`CpuShares`,
651/// `Memory`, ...) and `snake_case` on the Rust side. Subset of the full
652/// Docker schema: `ZLayer` supports the resource knobs (cpu, memory, pids,
653/// blkio) plus `RestartPolicy`. Windows-only fields (`CpuCount`,
654/// `IOMaximumIOps`) and ulimits/devices are accepted on the wire but
655/// silently ignored by the Linux runtimes.
656#[derive(Debug, Default, Clone, Deserialize, Serialize, ToSchema, PartialEq, Eq)]
657pub struct ContainerUpdateRequest {
658 /// Relative CPU weight (cgroup `cpu.weight` or `cpu.shares`). Range
659 /// 2-262144 on cgroup v2; 2-262144 mapped from 1-10000 on v1.
660 #[serde(rename = "CpuShares", default, skip_serializing_if = "Option::is_none")]
661 pub cpu_shares: Option<i64>,
662
663 /// Memory limit in bytes. Set `0` to remove the limit.
664 #[serde(rename = "Memory", default, skip_serializing_if = "Option::is_none")]
665 pub memory: Option<i64>,
666
667 /// CPU CFS period in microseconds.
668 #[serde(rename = "CpuPeriod", default, skip_serializing_if = "Option::is_none")]
669 pub cpu_period: Option<i64>,
670
671 /// CPU CFS quota in microseconds. Together with `cpu_period` defines
672 /// the fraction of a CPU the container may use.
673 #[serde(rename = "CpuQuota", default, skip_serializing_if = "Option::is_none")]
674 pub cpu_quota: Option<i64>,
675
676 /// CPU real-time period in microseconds.
677 #[serde(
678 rename = "CpuRealtimePeriod",
679 default,
680 skip_serializing_if = "Option::is_none"
681 )]
682 pub cpu_realtime_period: Option<i64>,
683
684 /// CPU real-time runtime in microseconds.
685 #[serde(
686 rename = "CpuRealtimeRuntime",
687 default,
688 skip_serializing_if = "Option::is_none"
689 )]
690 pub cpu_realtime_runtime: Option<i64>,
691
692 /// CPUs allowed for execution (e.g. `"0-3"`, `"0,1"`).
693 #[serde(
694 rename = "CpusetCpus",
695 default,
696 skip_serializing_if = "Option::is_none"
697 )]
698 pub cpuset_cpus: Option<String>,
699
700 /// Memory nodes (NUMA) allowed for execution (e.g. `"0-3"`).
701 #[serde(
702 rename = "CpusetMems",
703 default,
704 skip_serializing_if = "Option::is_none"
705 )]
706 pub cpuset_mems: Option<String>,
707
708 /// Soft memory limit in bytes. The kernel reclaims pages above this
709 /// reservation when the host comes under memory pressure.
710 #[serde(
711 rename = "MemoryReservation",
712 default,
713 skip_serializing_if = "Option::is_none"
714 )]
715 pub memory_reservation: Option<i64>,
716
717 /// Total memory limit (memory + swap) in bytes. `-1` removes the swap
718 /// limit, matching Docker semantics.
719 #[serde(
720 rename = "MemorySwap",
721 default,
722 skip_serializing_if = "Option::is_none"
723 )]
724 pub memory_swap: Option<i64>,
725
726 /// Kernel memory limit in bytes (deprecated upstream; accepted for
727 /// wire compatibility).
728 #[serde(
729 rename = "KernelMemory",
730 default,
731 skip_serializing_if = "Option::is_none"
732 )]
733 pub kernel_memory: Option<i64>,
734
735 /// Block IO weight (relative weight, range 10-1000).
736 #[serde(
737 rename = "BlkioWeight",
738 default,
739 skip_serializing_if = "Option::is_none"
740 )]
741 pub blkio_weight: Option<u16>,
742
743 /// PIDs limit. Set `0` or `-1` for unlimited.
744 #[serde(rename = "PidsLimit", default, skip_serializing_if = "Option::is_none")]
745 pub pids_limit: Option<i64>,
746
747 /// New restart policy. When present, replaces the container's stored
748 /// restart policy. Docker applies this asynchronously: the next time
749 /// the supervisor decides whether to restart, it consults the new
750 /// policy.
751 #[serde(
752 rename = "RestartPolicy",
753 default,
754 skip_serializing_if = "Option::is_none"
755 )]
756 pub restart_policy: Option<ContainerUpdateRestartPolicy>,
757}
758
759/// Response body for `POST /api/v1/containers/{id}/update`.
760///
761/// Mirrors Docker's `{"Warnings": [...]}` shape so the compat layer
762/// passes the body through verbatim. `Warnings` is always present (even
763/// if empty) for wire compatibility with clients that match the field
764/// presence, not just its contents.
765#[derive(Debug, Default, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
766pub struct ContainerUpdateResponse {
767 /// Human-readable warnings emitted by the runtime while applying the
768 /// update — e.g. `"kernel memory limit is deprecated"`.
769 #[serde(rename = "Warnings", default)]
770 pub warnings: Vec<String>,
771}
772
773/// Wait response with container exit code plus optional classification
774/// fields (added in §3.12 of the SDK-fixes spec).
775///
776/// The three optional fields (`reason`, `signal`, `finished_at`) are
777/// additive — clients that only read `exit_code` keep working unchanged.
778#[derive(Debug, Serialize, Deserialize, ToSchema)]
779pub struct ContainerWaitResponse {
780 /// Container identifier
781 pub id: String,
782 /// Exit code (0 = success). When the container was killed by signal
783 /// `N`, this is typically `128 + N`.
784 pub exit_code: i32,
785 /// Classification of the exit. One of `"exited"`, `"signal"`,
786 /// `"oom_killed"`, or `"runtime_error"`. Absent when the runtime
787 /// didn't classify the exit.
788 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub reason: Option<String>,
790 /// Signal name when `reason == "signal"`, e.g. `"SIGKILL"`. Absent
791 /// when the runtime couldn't determine it (or the exit wasn't a
792 /// signal death).
793 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub signal: Option<String>,
795 /// RFC3339 timestamp of when the container exited, if reported by
796 /// the runtime.
797 #[serde(default, skip_serializing_if = "Option::is_none")]
798 pub finished_at: Option<String>,
799}
800
801/// Docker-shaped wait response returned by
802/// `POST /api/v1/containers/{id}/wait`.
803///
804/// Mirrors Docker Engine's `/containers/{id}/wait` body 1:1: a
805/// `StatusCode` field plus an optional `Error` envelope. Used by the
806/// `zlayer-docker` compatibility shim and any SDK callers that consume
807/// the Docker shape directly. The richer
808/// [`ContainerWaitResponse`] (returned by the legacy `GET` endpoint) is
809/// preserved for clients that need the `reason` / `signal` / `finished_at`
810/// classification fields.
811#[derive(Debug, Serialize, Deserialize, ToSchema)]
812pub struct ContainerWaitDockerResponse {
813 /// Container exit code (0 = success). When killed by signal `N`,
814 /// this is typically `128 + N`, matching Docker's convention.
815 #[serde(rename = "StatusCode")]
816 pub status_code: i64,
817 /// Optional error envelope surfaced when the wait itself failed
818 /// (e.g. the container was removed before reaching `not-running`
819 /// when `condition=not-running` was requested). Absent on a normal
820 /// exit.
821 #[serde(rename = "Error", default, skip_serializing_if = "Option::is_none")]
822 pub error: Option<ContainerWaitDockerError>,
823}
824
825/// Error envelope nested inside [`ContainerWaitDockerResponse`].
826#[derive(Debug, Serialize, Deserialize, ToSchema)]
827pub struct ContainerWaitDockerError {
828 /// Human-readable description of why the wait failed.
829 #[serde(rename = "Message")]
830 pub message: String,
831}
832
833/// Query parameters for `POST /api/v1/containers/{id}/wait` —
834/// Docker's `condition=` query string.
835#[derive(Debug, Default, Deserialize, IntoParams)]
836pub struct WaitContainerQuery {
837 /// One of `"not-running"` (default), `"next-exit"`, or `"removed"`.
838 /// Matches Docker's `/containers/{id}/wait` semantics. Omitted
839 /// values default to `"not-running"`.
840 #[serde(default)]
841 pub condition: Option<String>,
842}
843
844/// Query parameters for `POST /api/v1/containers/{id}/rename` —
845/// Docker's `name=<new-name>` query string.
846#[derive(Debug, Default, Deserialize, IntoParams)]
847pub struct RenameContainerQuery {
848 /// New human-readable name to assign to the container. Required.
849 #[serde(default)]
850 pub name: Option<String>,
851}
852
853/// Container resource statistics
854#[derive(Debug, Serialize, Deserialize, ToSchema)]
855pub struct ContainerStatsResponse {
856 /// Container identifier
857 pub id: String,
858 /// CPU usage in microseconds
859 pub cpu_usage_usec: u64,
860 /// Current memory usage in bytes
861 pub memory_bytes: u64,
862 /// Memory limit in bytes (`u64::MAX` if unlimited)
863 pub memory_limit: u64,
864 /// Memory usage as percentage of limit
865 pub memory_percent: f64,
866}
867
868/// Query parameters for container stats.
869///
870/// When `stream=false` (default), the handler returns a single JSON
871/// [`ContainerStatsResponse`]. When `stream=true`, the handler switches to
872/// Server-Sent Events and emits one `ContainerStatsResponse` sample per
873/// `interval` seconds until the container exits or the client disconnects.
874///
875/// `interval` is clamped to `[1, 60]` seconds. Default interval is `2`.
876#[derive(Debug, Default, Deserialize, IntoParams)]
877pub struct StatsQuery {
878 /// Stream periodic samples as SSE events instead of a one-shot JSON
879 /// response.
880 #[serde(default)]
881 pub stream: bool,
882 /// Sample cadence in seconds (only used when `stream=true`). Clamped to
883 /// `[1, 60]`. Defaults to `2` seconds.
884 #[serde(default, alias = "interval_seconds")]
885 pub interval: Option<u32>,
886}
887
888/// Query parameters for `GET /api/v1/containers/{id}/top` —
889/// Docker's `ps_args=<...>` query string. Defaults to the runtime's
890/// own column set when omitted or empty.
891#[derive(Debug, Default, Deserialize, IntoParams)]
892pub struct ContainerTopQuery {
893 /// `ps`-style argument string, e.g. `"aux"` or `"-eo pid,user,cmd"`.
894 /// Empty / omitted means "use the runtime's defaults".
895 #[serde(default)]
896 pub ps_args: Option<String>,
897}
898
899/// Response body for `GET /api/v1/containers/{id}/top` (Docker compat shape).
900///
901/// Wire field names use Docker's `Titles` / `Processes` casing so the
902/// shim can pass the body through untouched.
903#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
904pub struct ContainerTopResponse {
905 /// `ps` column titles — e.g. `["UID", "PID", "PPID", "C", "STIME",
906 /// "TTY", "TIME", "CMD"]`.
907 #[serde(rename = "Titles")]
908 pub titles: Vec<String>,
909 /// One row per process inside the container. Each row has the same
910 /// length as `titles`.
911 #[serde(rename = "Processes")]
912 pub processes: Vec<Vec<String>>,
913}
914
915/// One row of `GET /api/v1/containers/{id}/changes` (Docker compat shape).
916///
917/// Mirrors Docker's `{"Path": "/foo", "Kind": 0}` body:
918/// `Kind` is a numeric enum where `0 = Modified`, `1 = Added`, `2 = Deleted`.
919#[derive(Debug, Serialize, Deserialize, ToSchema)]
920pub struct ContainerChangeEntry {
921 /// Path inside the container that changed (absolute, e.g. `/etc/hosts`).
922 #[serde(rename = "Path")]
923 pub path: String,
924 /// `0` = Modified, `1` = Added, `2` = Deleted (Docker's wire integer).
925 #[serde(rename = "Kind")]
926 pub kind: u8,
927}
928
929/// Response body for `GET /api/v1/containers/{id}/port` (Docker compat shape).
930///
931/// Mirrors Docker's `{"Ports": {"80/tcp": [{"HostIp":"...","HostPort":"..."}]}}`
932/// body. Each key is `<container_port>/<protocol>` and the value is the list
933/// of host bindings for that port (or `null` when the port is exposed but not
934/// published).
935#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
936pub struct ContainerPortResponse {
937 /// Map of `"<port>/<protocol>"` to host bindings.
938 #[serde(rename = "Ports")]
939 pub ports: HashMap<String, Option<Vec<ContainerPortBinding>>>,
940}
941
942/// One host binding inside a [`ContainerPortResponse`] entry.
943#[derive(Debug, Serialize, Deserialize, ToSchema)]
944pub struct ContainerPortBinding {
945 /// Host IP that maps to the container port. Empty / `"0.0.0.0"` means
946 /// "any IPv4 address".
947 #[serde(rename = "HostIp", default, skip_serializing_if = "Option::is_none")]
948 pub host_ip: Option<String>,
949 /// Host port (always serialised as a string in Docker's wire format).
950 #[serde(rename = "HostPort", default, skip_serializing_if = "Option::is_none")]
951 pub host_port: Option<String>,
952}
953
954/// Response body for `POST /api/v1/containers/prune` (Docker compat shape).
955///
956/// Docker uses `ContainersDeleted` / `SpaceReclaimed` `PascalCase` fields, so
957/// SDK consumers (and the docker shim) can read the body verbatim.
958#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
959pub struct ContainerPruneResponse {
960 /// Container IDs that were removed.
961 #[serde(rename = "ContainersDeleted")]
962 pub containers_deleted: Vec<String>,
963 /// Bytes reclaimed from the runtime's container storage.
964 #[serde(rename = "SpaceReclaimed")]
965 pub space_reclaimed: u64,
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971 use crate::spec::{DeviceSpec, NetworkMode, UlimitSpec};
972 use std::time::Duration;
973
974 /// Build a baseline request with only the required `image` field so each
975 /// round-trip test can override exactly the slice of fields it cares
976 /// about without listing the dozens of unrelated optional fields.
977 fn baseline_request() -> CreateContainerRequest {
978 CreateContainerRequest {
979 image: "nginx:latest".to_string(),
980 ..CreateContainerRequest::default()
981 }
982 }
983
984 #[test]
985 fn create_request_round_trips_placement_fields() {
986 use crate::spec::{ArchKind, NodeSelector, OsKind, TargetPlatform};
987
988 let mut req = baseline_request();
989 req.platform = Some(TargetPlatform::new(OsKind::Macos, ArchKind::Arm64));
990 req.node_selector = Some(NodeSelector {
991 labels: [("zone".to_string(), "us-east".to_string())]
992 .into_iter()
993 .collect(),
994 prefer_labels: std::collections::HashMap::new(),
995 });
996
997 let json = serde_json::to_string(&req).expect("serialize");
998 let back: CreateContainerRequest =
999 serde_json::from_str(&json).expect("deserialize round-trip");
1000
1001 let platform = back.platform.expect("platform present");
1002 assert_eq!(platform.os, OsKind::Macos);
1003 assert_eq!(platform.arch, ArchKind::Arm64);
1004 let selector = back.node_selector.expect("node_selector present");
1005 assert_eq!(
1006 selector.labels.get("zone").map(String::as_str),
1007 Some("us-east")
1008 );
1009
1010 // Omitted placement fields must round-trip as None (skip_serializing_if).
1011 let bare: CreateContainerRequest =
1012 serde_json::from_str(r#"{"image":"nginx:latest"}"#).expect("deserialize bare");
1013 assert!(bare.platform.is_none());
1014 assert!(bare.node_selector.is_none());
1015 }
1016
1017 #[test]
1018 fn start_on_create_defaults_to_true_and_honours_explicit_false() {
1019 // Omitted `start` (the common case for native callers and the SDK)
1020 // must mean "create and start" — anything else regresses
1021 // `zlayer run`-style one-shots.
1022 let bare: CreateContainerRequest =
1023 serde_json::from_str(r#"{"image":"nginx:latest"}"#).expect("deserialize bare");
1024 assert_eq!(bare.start, None);
1025 assert!(
1026 bare.should_start_on_create(),
1027 "omitted start must default to start-on-create"
1028 );
1029
1030 // `..Default::default()` (used by the CLI / compose run paths and by
1031 // every in-process struct construction) must also start.
1032 let dflt = CreateContainerRequest {
1033 image: "nginx:latest".to_string(),
1034 ..CreateContainerRequest::default()
1035 };
1036 assert!(
1037 dflt.should_start_on_create(),
1038 "Default::default() must default to start-on-create"
1039 );
1040
1041 // Explicit `start: true` starts.
1042 let yes: CreateContainerRequest =
1043 serde_json::from_str(r#"{"image":"nginx:latest","start":true}"#)
1044 .expect("deserialize start=true");
1045 assert!(yes.should_start_on_create());
1046
1047 // Only an explicit `start: false` (the Docker-compat create-only path)
1048 // suppresses the auto-start.
1049 let no: CreateContainerRequest =
1050 serde_json::from_str(r#"{"image":"nginx:latest","start":false}"#)
1051 .expect("deserialize start=false");
1052 assert_eq!(no.start, Some(false));
1053 assert!(
1054 !no.should_start_on_create(),
1055 "explicit start=false must suppress the auto-start"
1056 );
1057 }
1058
1059 #[test]
1060 fn buffered_exec_response_parses_native_wire_shape() {
1061 // The native buffered exec handler emits
1062 // `Json(ContainerExecResponse { exit_code, stdout, stderr })`. Lock the
1063 // exact wire shape so a future rename can't silently regress the
1064 // `DaemonClient::exec_in_container` parse path (which fails with
1065 // "missing field 'exit_code'" when pointed at the wrong endpoint).
1066 let resp = ContainerExecResponse {
1067 exit_code: 42,
1068 stdout: "hello".to_string(),
1069 stderr: "oops".to_string(),
1070 };
1071 let wire = serde_json::to_string(&resp).expect("serialize");
1072 assert_eq!(
1073 wire, r#"{"exit_code":42,"stdout":"hello","stderr":"oops"}"#,
1074 "buffered exec wire shape must stay snake_case exit_code/stdout/stderr"
1075 );
1076 let back: ContainerExecResponse = serde_json::from_str(&wire).expect("round-trip");
1077 assert_eq!(back.exit_code, 42);
1078 assert_eq!(back.stdout, "hello");
1079 assert_eq!(back.stderr, "oops");
1080
1081 // Guard the bug we fixed: the *interactive* create-exec endpoint
1082 // returns `{"Id":"<64-hex>"}`, which must NOT parse as a buffered
1083 // exec result. (This is the `missing field 'exit_code' at line 1
1084 // column 73` failure observed when the buffered client pointed at the
1085 // create-exec route.)
1086 let create_exec_body =
1087 r#"{"Id":"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}"#;
1088 assert!(
1089 serde_json::from_str::<ContainerExecResponse>(create_exec_body).is_err(),
1090 "create-exec `{{Id}}` body must not deserialize as a buffered exec result"
1091 );
1092 }
1093
1094 #[test]
1095 fn create_request_round_trips_security_fields() {
1096 let mut req = baseline_request();
1097 req.privileged = Some(true);
1098 req.cap_add = vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()];
1099 req.cap_drop = vec!["MKNOD".to_string()];
1100 req.devices = vec![DeviceSpec {
1101 path: "/dev/kvm".to_string(),
1102 read: true,
1103 write: true,
1104 mknod: false,
1105 }];
1106 req.network_mode = Some(NetworkMode::Host);
1107 req.security_opt = vec!["no-new-privileges:true".to_string()];
1108 req.pid_mode = Some("host".to_string());
1109 req.ipc_mode = Some("shareable".to_string());
1110 req.read_only_root_fs = true;
1111 req.init_container = Some(true);
1112
1113 let json = serde_json::to_string(&req).expect("serialize");
1114 let back: CreateContainerRequest =
1115 serde_json::from_str(&json).expect("deserialize round-trip");
1116
1117 assert_eq!(back.privileged, Some(true));
1118 assert_eq!(back.cap_add, vec!["NET_ADMIN", "SYS_PTRACE"]);
1119 assert_eq!(back.cap_drop, vec!["MKNOD"]);
1120 assert_eq!(back.devices.len(), 1);
1121 assert_eq!(back.devices[0].path, "/dev/kvm");
1122 assert!(back.devices[0].read);
1123 assert!(back.devices[0].write);
1124 assert!(!back.devices[0].mknod);
1125 assert_eq!(back.network_mode, Some(NetworkMode::Host));
1126 assert_eq!(back.security_opt, vec!["no-new-privileges:true"]);
1127 assert_eq!(back.pid_mode.as_deref(), Some("host"));
1128 assert_eq!(back.ipc_mode.as_deref(), Some("shareable"));
1129 assert!(back.read_only_root_fs);
1130 assert_eq!(back.init_container, Some(true));
1131 }
1132
1133 #[test]
1134 fn create_request_round_trips_metadata_fields() {
1135 let mut req = baseline_request();
1136 req.labels.insert("env".to_string(), "prod".to_string());
1137 req.labels.insert("team".to_string(), "core".to_string());
1138 req.user = Some("1000:1000".to_string());
1139 req.stop_signal = Some("SIGTERM".to_string());
1140 req.stop_grace_period = Some(Duration::from_secs(45));
1141 req.sysctls
1142 .insert("net.core.somaxconn".to_string(), "1024".to_string());
1143 req.ulimits.insert(
1144 "nofile".to_string(),
1145 UlimitSpec {
1146 soft: 4096,
1147 hard: 8192,
1148 },
1149 );
1150 req.extra_groups = vec!["docker".to_string(), "audio".to_string()];
1151
1152 let json = serde_json::to_string(&req).expect("serialize");
1153 // Confirm the humantime wire format is a string.
1154 assert!(
1155 json.contains("\"stop_grace_period\":\"45s\""),
1156 "expected humantime stop_grace_period in JSON, got: {json}"
1157 );
1158
1159 let back: CreateContainerRequest =
1160 serde_json::from_str(&json).expect("deserialize round-trip");
1161
1162 assert_eq!(back.labels.get("env").map(String::as_str), Some("prod"));
1163 assert_eq!(back.labels.get("team").map(String::as_str), Some("core"));
1164 assert_eq!(back.user.as_deref(), Some("1000:1000"));
1165 assert_eq!(back.stop_signal.as_deref(), Some("SIGTERM"));
1166 assert_eq!(back.stop_grace_period, Some(Duration::from_secs(45)));
1167 assert_eq!(
1168 back.sysctls.get("net.core.somaxconn").map(String::as_str),
1169 Some("1024")
1170 );
1171 let nofile = back.ulimits.get("nofile").expect("nofile ulimit present");
1172 assert_eq!(nofile.soft, 4096);
1173 assert_eq!(nofile.hard, 8192);
1174 assert_eq!(back.extra_groups, vec!["docker", "audio"]);
1175 }
1176
1177 #[test]
1178 fn create_request_round_trips_resource_knobs() {
1179 let mut req = baseline_request();
1180 req.pids_limit = Some(2048);
1181 req.cpuset = Some("0-3".to_string());
1182 req.cpu_shares = Some(1024);
1183 req.memory_swap = Some("2Gi".to_string());
1184 req.memory_reservation = Some("256Mi".to_string());
1185 req.memory_swappiness = Some(10);
1186 req.oom_score_adj = Some(-500);
1187 req.oom_kill_disable = Some(false);
1188 req.blkio_weight = Some(500);
1189
1190 let json = serde_json::to_string(&req).expect("serialize");
1191 let back: CreateContainerRequest =
1192 serde_json::from_str(&json).expect("deserialize round-trip");
1193
1194 assert_eq!(back.pids_limit, Some(2048));
1195 assert_eq!(back.cpuset.as_deref(), Some("0-3"));
1196 assert_eq!(back.cpu_shares, Some(1024));
1197 assert_eq!(back.memory_swap.as_deref(), Some("2Gi"));
1198 assert_eq!(back.memory_reservation.as_deref(), Some("256Mi"));
1199 assert_eq!(back.memory_swappiness, Some(10));
1200 assert_eq!(back.oom_score_adj, Some(-500));
1201 assert_eq!(back.oom_kill_disable, Some(false));
1202 assert_eq!(back.blkio_weight, Some(500));
1203 }
1204
1205 #[test]
1206 fn create_request_round_trips_network_mode_strings() {
1207 // The spec's `NetworkMode` deserialization happens via
1208 // `deserialize_network_mode`, but that helper is only attached to
1209 // `ServiceSpec.network_mode` — at the request layer we want the
1210 // derived `Deserialize` for `NetworkMode` (lowercase enum) to
1211 // accept the same wire shapes. Confirm each of the five Docker
1212 // forms round-trips through the request body.
1213 //
1214 // Note: at the request layer, `network_mode` accepts the
1215 // externally-tagged enum form (e.g. `{"bridge": {"name": "..."}}`),
1216 // matching what the derived `Serialize` for `NetworkMode` emits.
1217 let cases: &[(&str, NetworkMode)] = &[
1218 (r#""default""#, NetworkMode::Default),
1219 (r#""host""#, NetworkMode::Host),
1220 (r#""none""#, NetworkMode::None),
1221 (
1222 r#"{"bridge":{"name":null}}"#,
1223 NetworkMode::Bridge { name: None },
1224 ),
1225 (
1226 r#"{"bridge":{"name":"custom_net"}}"#,
1227 NetworkMode::Bridge {
1228 name: Some("custom_net".to_string()),
1229 },
1230 ),
1231 (
1232 r#"{"container":{"id":"abc"}}"#,
1233 NetworkMode::Container {
1234 id: "abc".to_string(),
1235 },
1236 ),
1237 ];
1238
1239 for (literal, expected) in cases {
1240 let body = format!(r#"{{"image":"nginx:latest","network_mode":{literal}}}"#);
1241 let req: CreateContainerRequest = serde_json::from_str(&body)
1242 .unwrap_or_else(|e| panic!("deserialize {literal}: {e}"));
1243 assert_eq!(
1244 req.network_mode.as_ref(),
1245 Some(expected),
1246 "wire form {literal} did not round-trip",
1247 );
1248
1249 // Re-serialize and parse again to confirm the emitted form
1250 // also round-trips back into the same variant.
1251 let reser = serde_json::to_string(&req).expect("re-serialize");
1252 let again: CreateContainerRequest =
1253 serde_json::from_str(&reser).expect("re-deserialize");
1254 assert_eq!(again.network_mode.as_ref(), Some(expected));
1255 }
1256 }
1257
1258 /// `ContainerUpdateRequest` must accept Docker Engine's `PascalCase`
1259 /// wire shape verbatim (`CpuShares`, `Memory`, `RestartPolicy`, ...)
1260 /// and round-trip every documented field. This pins the contract
1261 /// `zlayer-docker` relies on when forwarding `POST /containers/{id}/update`.
1262 #[test]
1263 fn container_update_request_round_trips_docker_wire_shape() {
1264 let body = serde_json::json!({
1265 "CpuShares": 512,
1266 "Memory": 314_572_800_i64,
1267 "CpuPeriod": 100_000,
1268 "CpuQuota": 50_000,
1269 "CpuRealtimePeriod": 1_000_000,
1270 "CpuRealtimeRuntime": 950_000,
1271 "CpusetCpus": "0-3",
1272 "CpusetMems": "0,1",
1273 "MemoryReservation": 268_435_456_i64,
1274 "MemorySwap": 629_145_600_i64,
1275 "KernelMemory": 67_108_864_i64,
1276 "BlkioWeight": 500,
1277 "PidsLimit": 2048,
1278 "RestartPolicy": {
1279 "Name": "on-failure",
1280 "MaximumRetryCount": 5
1281 }
1282 });
1283
1284 let req: ContainerUpdateRequest =
1285 serde_json::from_value(body.clone()).expect("deserialize update body");
1286
1287 assert_eq!(req.cpu_shares, Some(512));
1288 assert_eq!(req.memory, Some(314_572_800));
1289 assert_eq!(req.cpu_period, Some(100_000));
1290 assert_eq!(req.cpu_quota, Some(50_000));
1291 assert_eq!(req.cpu_realtime_period, Some(1_000_000));
1292 assert_eq!(req.cpu_realtime_runtime, Some(950_000));
1293 assert_eq!(req.cpuset_cpus.as_deref(), Some("0-3"));
1294 assert_eq!(req.cpuset_mems.as_deref(), Some("0,1"));
1295 assert_eq!(req.memory_reservation, Some(268_435_456));
1296 assert_eq!(req.memory_swap, Some(629_145_600));
1297 assert_eq!(req.kernel_memory, Some(67_108_864));
1298 assert_eq!(req.blkio_weight, Some(500));
1299 assert_eq!(req.pids_limit, Some(2048));
1300 let rp = req.restart_policy.as_ref().expect("restart_policy");
1301 assert_eq!(rp.name.as_deref(), Some("on-failure"));
1302 assert_eq!(rp.maximum_retry_count, Some(5));
1303
1304 // Round-trip through the wire shape unchanged: every field must
1305 // serialize back with its PascalCase Docker name.
1306 let reser = serde_json::to_value(&req).expect("re-serialize");
1307 assert_eq!(reser, body);
1308 }
1309
1310 /// An empty body must deserialize successfully — Docker accepts
1311 /// `POST /containers/{id}/update` with `{}` (a no-op update).
1312 #[test]
1313 fn container_update_request_empty_body_deserializes_to_default() {
1314 let req: ContainerUpdateRequest =
1315 serde_json::from_str("{}").expect("empty body must deserialize");
1316 assert_eq!(req, ContainerUpdateRequest::default());
1317 assert!(req.cpu_shares.is_none());
1318 assert!(req.memory.is_none());
1319 assert!(req.restart_policy.is_none());
1320 }
1321
1322 /// `ContainerUpdateResponse` must always emit `Warnings` (even empty)
1323 /// so clients that match on field presence don't break.
1324 #[test]
1325 fn container_update_response_always_emits_warnings_field() {
1326 let resp = ContainerUpdateResponse::default();
1327 let json = serde_json::to_value(&resp).expect("serialize");
1328 assert!(json.get("Warnings").is_some(), "Warnings must be present");
1329 assert_eq!(json["Warnings"], serde_json::json!([]));
1330 }
1331}