use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
mod duration_opt {
use humantime::format_duration;
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
#[allow(clippy::ref_option)]
pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match duration {
Some(d) => serializer.serialize_str(&format_duration(*d).to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(s) => humantime::parse_duration(&s)
.map(Some)
.map_err(|e| D::Error::custom(format!("invalid duration: {e}"))),
None => Ok(None),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct ContainerResourceLimits {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum VolumeMountType {
Bind,
Volume,
Tmpfs,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct VolumeMount {
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub mount_type: Option<VolumeMountType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
pub target: String,
#[serde(default)]
pub readonly: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct HealthCheckRequest {
#[serde(rename = "type")]
pub check_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expect_status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retries: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_period: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize, ToSchema)]
pub struct CreateContainerRequest {
pub image: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub pull_policy: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub command: Option<Vec<String>>,
#[serde(default)]
pub labels: HashMap<String, String>,
#[serde(default)]
pub resources: Option<ContainerResourceLimits>,
#[serde(default)]
pub volumes: Vec<VolumeMount>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ports: Vec<crate::spec::PortMapping>,
#[serde(default)]
pub work_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_check: Option<HealthCheckRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dns: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_hosts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_policy: Option<crate::spec::ContainerRestartPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub networks: Vec<NetworkAttachmentRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_credential_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_auth: Option<crate::spec::RegistryAuth>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub privileged: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cap_add: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cap_drop: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub devices: Vec<crate::spec::DeviceSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_mode: Option<crate::spec::NetworkMode>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub security_opt: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ipc_mode: Option<String>,
#[serde(default)]
pub read_only_root_fs: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub init_container: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_signal: Option<String>,
#[serde(
default,
with = "duration_opt",
skip_serializing_if = "Option::is_none"
)]
#[schema(value_type = Option<String>, example = "30s")]
pub stop_grace_period: Option<std::time::Duration>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub sysctls: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub ulimits: HashMap<String, crate::spec::UlimitSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_groups: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pids_limit: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpuset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_shares: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_swap: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_reservation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_swappiness: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oom_score_adj: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oom_kill_disable: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blkio_weight: Option<u16>,
#[serde(default)]
pub lifecycle: crate::spec::LifecycleSpec,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct NetworkAttachmentRequest {
pub network: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ipv4_address: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ContainerInfo {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub image: String,
pub state: String,
pub labels: HashMap<String, String>,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ports: Vec<crate::spec::PortMapping>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub networks: Vec<NetworkAttachmentInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ipv4: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health: Option<ContainerHealthInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct NetworkAttachmentInfo {
pub network: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ipv4: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ContainerHealthInfo {
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failing_streak: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_output: Option<String>,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ListContainersQuery {
#[serde(default)]
pub label: Option<String>,
}
#[derive(Debug, Default, Deserialize, IntoParams)]
pub struct ContainerLogQuery {
#[serde(default = "default_tail")]
pub tail: usize,
#[serde(default)]
pub follow: bool,
#[serde(default)]
pub since: Option<i64>,
#[serde(default)]
pub until: Option<i64>,
#[serde(default)]
pub timestamps: bool,
#[serde(default)]
pub stdout: Option<bool>,
#[serde(default)]
pub stderr: Option<bool>,
#[serde(default)]
pub format: Option<ContainerLogFormat>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ContainerLogFormat {
#[default]
Json,
Raw,
}
fn default_tail() -> usize {
100
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ContainerExecRequest {
pub command: Vec<String>,
}
#[derive(Debug, Default, Deserialize, IntoParams)]
pub struct ExecQuery {
#[serde(default)]
pub stream: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ContainerExecResponse {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Default, Deserialize, ToSchema)]
pub struct StopContainerRequest {
#[serde(default)]
pub timeout: Option<u64>,
}
#[derive(Debug, Default, Deserialize, ToSchema)]
pub struct RestartContainerRequest {
#[serde(default)]
pub timeout: Option<u64>,
}
#[derive(Debug, Default, Deserialize, ToSchema)]
pub struct KillContainerRequest {
#[serde(default)]
pub signal: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, ToSchema, PartialEq, Eq)]
pub struct ContainerUpdateRestartPolicy {
#[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(
rename = "MaximumRetryCount",
default,
skip_serializing_if = "Option::is_none"
)]
pub maximum_retry_count: Option<i64>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, ToSchema, PartialEq, Eq)]
pub struct ContainerUpdateRequest {
#[serde(rename = "CpuShares", default, skip_serializing_if = "Option::is_none")]
pub cpu_shares: Option<i64>,
#[serde(rename = "Memory", default, skip_serializing_if = "Option::is_none")]
pub memory: Option<i64>,
#[serde(rename = "CpuPeriod", default, skip_serializing_if = "Option::is_none")]
pub cpu_period: Option<i64>,
#[serde(rename = "CpuQuota", default, skip_serializing_if = "Option::is_none")]
pub cpu_quota: Option<i64>,
#[serde(
rename = "CpuRealtimePeriod",
default,
skip_serializing_if = "Option::is_none"
)]
pub cpu_realtime_period: Option<i64>,
#[serde(
rename = "CpuRealtimeRuntime",
default,
skip_serializing_if = "Option::is_none"
)]
pub cpu_realtime_runtime: Option<i64>,
#[serde(
rename = "CpusetCpus",
default,
skip_serializing_if = "Option::is_none"
)]
pub cpuset_cpus: Option<String>,
#[serde(
rename = "CpusetMems",
default,
skip_serializing_if = "Option::is_none"
)]
pub cpuset_mems: Option<String>,
#[serde(
rename = "MemoryReservation",
default,
skip_serializing_if = "Option::is_none"
)]
pub memory_reservation: Option<i64>,
#[serde(
rename = "MemorySwap",
default,
skip_serializing_if = "Option::is_none"
)]
pub memory_swap: Option<i64>,
#[serde(
rename = "KernelMemory",
default,
skip_serializing_if = "Option::is_none"
)]
pub kernel_memory: Option<i64>,
#[serde(
rename = "BlkioWeight",
default,
skip_serializing_if = "Option::is_none"
)]
pub blkio_weight: Option<u16>,
#[serde(rename = "PidsLimit", default, skip_serializing_if = "Option::is_none")]
pub pids_limit: Option<i64>,
#[serde(
rename = "RestartPolicy",
default,
skip_serializing_if = "Option::is_none"
)]
pub restart_policy: Option<ContainerUpdateRestartPolicy>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
pub struct ContainerUpdateResponse {
#[serde(rename = "Warnings", default)]
pub warnings: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ContainerWaitResponse {
pub id: String,
pub exit_code: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ContainerWaitDockerResponse {
#[serde(rename = "StatusCode")]
pub status_code: i64,
#[serde(rename = "Error", default, skip_serializing_if = "Option::is_none")]
pub error: Option<ContainerWaitDockerError>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ContainerWaitDockerError {
#[serde(rename = "Message")]
pub message: String,
}
#[derive(Debug, Default, Deserialize, IntoParams)]
pub struct WaitContainerQuery {
#[serde(default)]
pub condition: Option<String>,
}
#[derive(Debug, Default, Deserialize, IntoParams)]
pub struct RenameContainerQuery {
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ContainerStatsResponse {
pub id: String,
pub cpu_usage_usec: u64,
pub memory_bytes: u64,
pub memory_limit: u64,
pub memory_percent: f64,
}
#[derive(Debug, Default, Deserialize, IntoParams)]
pub struct StatsQuery {
#[serde(default)]
pub stream: bool,
#[serde(default, alias = "interval_seconds")]
pub interval: Option<u32>,
}
#[derive(Debug, Default, Deserialize, IntoParams)]
pub struct ContainerTopQuery {
#[serde(default)]
pub ps_args: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct ContainerTopResponse {
#[serde(rename = "Titles")]
pub titles: Vec<String>,
#[serde(rename = "Processes")]
pub processes: Vec<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ContainerChangeEntry {
#[serde(rename = "Path")]
pub path: String,
#[serde(rename = "Kind")]
pub kind: u8,
}
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct ContainerPortResponse {
#[serde(rename = "Ports")]
pub ports: HashMap<String, Option<Vec<ContainerPortBinding>>>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ContainerPortBinding {
#[serde(rename = "HostIp", default, skip_serializing_if = "Option::is_none")]
pub host_ip: Option<String>,
#[serde(rename = "HostPort", default, skip_serializing_if = "Option::is_none")]
pub host_port: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct ContainerPruneResponse {
#[serde(rename = "ContainersDeleted")]
pub containers_deleted: Vec<String>,
#[serde(rename = "SpaceReclaimed")]
pub space_reclaimed: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::{DeviceSpec, NetworkMode, UlimitSpec};
use std::time::Duration;
fn baseline_request() -> CreateContainerRequest {
CreateContainerRequest {
image: "nginx:latest".to_string(),
..CreateContainerRequest::default()
}
}
#[test]
fn create_request_round_trips_security_fields() {
let mut req = baseline_request();
req.privileged = Some(true);
req.cap_add = vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()];
req.cap_drop = vec!["MKNOD".to_string()];
req.devices = vec![DeviceSpec {
path: "/dev/kvm".to_string(),
read: true,
write: true,
mknod: false,
}];
req.network_mode = Some(NetworkMode::Host);
req.security_opt = vec!["no-new-privileges:true".to_string()];
req.pid_mode = Some("host".to_string());
req.ipc_mode = Some("shareable".to_string());
req.read_only_root_fs = true;
req.init_container = Some(true);
let json = serde_json::to_string(&req).expect("serialize");
let back: CreateContainerRequest =
serde_json::from_str(&json).expect("deserialize round-trip");
assert_eq!(back.privileged, Some(true));
assert_eq!(back.cap_add, vec!["NET_ADMIN", "SYS_PTRACE"]);
assert_eq!(back.cap_drop, vec!["MKNOD"]);
assert_eq!(back.devices.len(), 1);
assert_eq!(back.devices[0].path, "/dev/kvm");
assert!(back.devices[0].read);
assert!(back.devices[0].write);
assert!(!back.devices[0].mknod);
assert_eq!(back.network_mode, Some(NetworkMode::Host));
assert_eq!(back.security_opt, vec!["no-new-privileges:true"]);
assert_eq!(back.pid_mode.as_deref(), Some("host"));
assert_eq!(back.ipc_mode.as_deref(), Some("shareable"));
assert!(back.read_only_root_fs);
assert_eq!(back.init_container, Some(true));
}
#[test]
fn create_request_round_trips_metadata_fields() {
let mut req = baseline_request();
req.labels.insert("env".to_string(), "prod".to_string());
req.labels.insert("team".to_string(), "core".to_string());
req.user = Some("1000:1000".to_string());
req.stop_signal = Some("SIGTERM".to_string());
req.stop_grace_period = Some(Duration::from_secs(45));
req.sysctls
.insert("net.core.somaxconn".to_string(), "1024".to_string());
req.ulimits.insert(
"nofile".to_string(),
UlimitSpec {
soft: 4096,
hard: 8192,
},
);
req.extra_groups = vec!["docker".to_string(), "audio".to_string()];
let json = serde_json::to_string(&req).expect("serialize");
assert!(
json.contains("\"stop_grace_period\":\"45s\""),
"expected humantime stop_grace_period in JSON, got: {json}"
);
let back: CreateContainerRequest =
serde_json::from_str(&json).expect("deserialize round-trip");
assert_eq!(back.labels.get("env").map(String::as_str), Some("prod"));
assert_eq!(back.labels.get("team").map(String::as_str), Some("core"));
assert_eq!(back.user.as_deref(), Some("1000:1000"));
assert_eq!(back.stop_signal.as_deref(), Some("SIGTERM"));
assert_eq!(back.stop_grace_period, Some(Duration::from_secs(45)));
assert_eq!(
back.sysctls.get("net.core.somaxconn").map(String::as_str),
Some("1024")
);
let nofile = back.ulimits.get("nofile").expect("nofile ulimit present");
assert_eq!(nofile.soft, 4096);
assert_eq!(nofile.hard, 8192);
assert_eq!(back.extra_groups, vec!["docker", "audio"]);
}
#[test]
fn create_request_round_trips_resource_knobs() {
let mut req = baseline_request();
req.pids_limit = Some(2048);
req.cpuset = Some("0-3".to_string());
req.cpu_shares = Some(1024);
req.memory_swap = Some("2Gi".to_string());
req.memory_reservation = Some("256Mi".to_string());
req.memory_swappiness = Some(10);
req.oom_score_adj = Some(-500);
req.oom_kill_disable = Some(false);
req.blkio_weight = Some(500);
let json = serde_json::to_string(&req).expect("serialize");
let back: CreateContainerRequest =
serde_json::from_str(&json).expect("deserialize round-trip");
assert_eq!(back.pids_limit, Some(2048));
assert_eq!(back.cpuset.as_deref(), Some("0-3"));
assert_eq!(back.cpu_shares, Some(1024));
assert_eq!(back.memory_swap.as_deref(), Some("2Gi"));
assert_eq!(back.memory_reservation.as_deref(), Some("256Mi"));
assert_eq!(back.memory_swappiness, Some(10));
assert_eq!(back.oom_score_adj, Some(-500));
assert_eq!(back.oom_kill_disable, Some(false));
assert_eq!(back.blkio_weight, Some(500));
}
#[test]
fn create_request_round_trips_network_mode_strings() {
let cases: &[(&str, NetworkMode)] = &[
(r#""default""#, NetworkMode::Default),
(r#""host""#, NetworkMode::Host),
(r#""none""#, NetworkMode::None),
(
r#"{"bridge":{"name":null}}"#,
NetworkMode::Bridge { name: None },
),
(
r#"{"bridge":{"name":"custom_net"}}"#,
NetworkMode::Bridge {
name: Some("custom_net".to_string()),
},
),
(
r#"{"container":{"id":"abc"}}"#,
NetworkMode::Container {
id: "abc".to_string(),
},
),
];
for (literal, expected) in cases {
let body = format!(r#"{{"image":"nginx:latest","network_mode":{literal}}}"#);
let req: CreateContainerRequest = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("deserialize {literal}: {e}"));
assert_eq!(
req.network_mode.as_ref(),
Some(expected),
"wire form {literal} did not round-trip",
);
let reser = serde_json::to_string(&req).expect("re-serialize");
let again: CreateContainerRequest =
serde_json::from_str(&reser).expect("re-deserialize");
assert_eq!(again.network_mode.as_ref(), Some(expected));
}
}
#[test]
fn container_update_request_round_trips_docker_wire_shape() {
let body = serde_json::json!({
"CpuShares": 512,
"Memory": 314_572_800_i64,
"CpuPeriod": 100_000,
"CpuQuota": 50_000,
"CpuRealtimePeriod": 1_000_000,
"CpuRealtimeRuntime": 950_000,
"CpusetCpus": "0-3",
"CpusetMems": "0,1",
"MemoryReservation": 268_435_456_i64,
"MemorySwap": 629_145_600_i64,
"KernelMemory": 67_108_864_i64,
"BlkioWeight": 500,
"PidsLimit": 2048,
"RestartPolicy": {
"Name": "on-failure",
"MaximumRetryCount": 5
}
});
let req: ContainerUpdateRequest =
serde_json::from_value(body.clone()).expect("deserialize update body");
assert_eq!(req.cpu_shares, Some(512));
assert_eq!(req.memory, Some(314_572_800));
assert_eq!(req.cpu_period, Some(100_000));
assert_eq!(req.cpu_quota, Some(50_000));
assert_eq!(req.cpu_realtime_period, Some(1_000_000));
assert_eq!(req.cpu_realtime_runtime, Some(950_000));
assert_eq!(req.cpuset_cpus.as_deref(), Some("0-3"));
assert_eq!(req.cpuset_mems.as_deref(), Some("0,1"));
assert_eq!(req.memory_reservation, Some(268_435_456));
assert_eq!(req.memory_swap, Some(629_145_600));
assert_eq!(req.kernel_memory, Some(67_108_864));
assert_eq!(req.blkio_weight, Some(500));
assert_eq!(req.pids_limit, Some(2048));
let rp = req.restart_policy.as_ref().expect("restart_policy");
assert_eq!(rp.name.as_deref(), Some("on-failure"));
assert_eq!(rp.maximum_retry_count, Some(5));
let reser = serde_json::to_value(&req).expect("re-serialize");
assert_eq!(reser, body);
}
#[test]
fn container_update_request_empty_body_deserializes_to_default() {
let req: ContainerUpdateRequest =
serde_json::from_str("{}").expect("empty body must deserialize");
assert_eq!(req, ContainerUpdateRequest::default());
assert!(req.cpu_shares.is_none());
assert!(req.memory.is_none());
assert!(req.restart_policy.is_none());
}
#[test]
fn container_update_response_always_emits_warnings_field() {
let resp = ContainerUpdateResponse::default();
let json = serde_json::to_value(&resp).expect("serialize");
assert!(json.get("Warnings").is_some(), "Warnings must be present");
assert_eq!(json["Warnings"], serde_json::json!([]));
}
}