Skip to main content

regent_sdk/state/attribute/container/
docker.rs

1//! Docker container attribute
2//!
3//! This module provides the `DockerExpectedState` type for managing Docker containers
4//! through the `docker` CLI available on the managed host.
5//!
6//! **Compatible OS:** Linux and macOS (any host where the `docker` CLI is available)
7//!
8//! # Compliance is assessed using "docker container inspect"
9//!
10//! "Env" values can be secrets, resolved through the secret providers but be carefull
11//! because they will still end up in the commands and "docker container inspect" output
12//!
13//! # Examples
14//!
15//! ## Rust API
16//!
17//! ```no_run
18//! use regent_sdk::state::attribute::container::docker::{DockerContainerConfig, DockerExpectedState};
19//! use regent_sdk::state::expected_state::Parameter;
20//! use regent_sdk::{Attribute, ExpectedState, Privilege, RegentError};
21//!
22//! fn web_server() -> Result<ExpectedState, RegentError> {
23//!     let config = DockerContainerConfig::new("nginx:1.27".parse()?)
24//!         .with_network("frontend".parse()?)
25//!         .with_published_port("8080:80".parse()?)
26//!         .with_published_port("127.0.0.1:8443:443/tcp".parse()?)
27//!         .with_volumes(vec!["/srv/www:/usr/share/nginx/html:ro".parse()?])
28//!         .with_env("TZ".parse()?, Parameter::Clear("Europe/Paris".to_string()))
29//!         .with_restart_unless_stopped();
30//!
31//!     let web = DockerExpectedState::running("web".parse()?, config);
32//!
33//!     Ok(ExpectedState::new()
34//!         .with_attribute(Attribute::docker(web, Privilege::WithSudo, None))
35//!         .build())
36//! }
37//! ```
38//!
39//! ## YAML API
40//!
41//! ```yaml
42//! Attributes:
43//!   - Name: Web server container must be running
44//!     Privilege: !WithSudo
45//!     Detail: !Docker
46//!       ContainerName: web
47//!       State: !Running
48//!         Image: nginx:1.27
49//!         Pull: Missing
50//!         Entrypoint: /docker-entrypoint.sh
51//!         Command: ["nginx", "-g", "daemon off;"]
52//!         Env:
53//!           TZ: Europe/Paris
54//!           API_TOKEN:
55//!             SecRef: web_api_token
56//!             Provider: files
57//!         Volumes:
58//!           - /srv/www:/usr/share/nginx/html:ro
59//!           - nginx_cache:/var/cache/nginx
60//!         Networking: !Attached    # Host | Isolated | !Attached
61//!           Networks: [frontend, backend]
62//!           Ports:
63//!             - "8080:80"
64//!             - "127.0.0.1:8443:443/tcp"
65//!         RestartPolicy: UnlessStopped
66//!         Hostname: web01
67//!         User: "101:101"
68//!         WorkingDir: /usr/share/nginx
69//!         Labels:
70//!           traefik.enable: "true"
71//!         Privileged: false
72//!         ReadOnly: false
73//!         Init: true
74//!         CapAdd: [NET_BIND_SERVICE]
75//!         CapDrop: [ALL]
76//!         Memory: 512m
77//!         Cpus: 1.5
78//!         ExtraHosts: ["db.local:10.0.0.12"]
79//!         Dns: [1.1.1.1]
80//!         Devices: ["/dev/snd:/dev/snd:rw"]
81//!         Gpus: All
82//!         HealthCheck:
83//!           Test: curl -f http://localhost || exit 1
84//!           Interval: 30s
85//!           Timeout: 5s
86//!           Retries: 3
87//!           StartPeriod: 10s
88//!           StartInterval: 1s
89//!         LogDriver: json-file
90//!         LogOptions:
91//!           max-size: 10m
92//! ```
93//!
94//! To make sure a container does not exist:
95//!
96//! ```yaml
97//! Attributes:
98//!   - Name: Old container must be removed
99//!     Privilege: !WithSudo
100//!     Detail: !Docker
101//!       ContainerName: legacy-app
102//!       State: Absent
103//! ```
104
105use crate::error::RegentError;
106use crate::hosts::managed_host::InternalApiCallOutcome;
107use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
108use crate::hosts::properties::{HostProperties, OsKind};
109use crate::secrets::SecretProvidersPool;
110use crate::state::Check;
111use crate::state::attribute::HostHandler;
112use crate::state::attribute::Privilege;
113use crate::state::attribute::Remediation;
114use crate::state::attribute::RemediationsList;
115use crate::state::compliance::AttributeComplianceAssessment;
116use crate::state::expected_state::Parameter;
117use serde::{Deserialize, Serialize};
118use serde_json::Value;
119use std::collections::{BTreeMap, BTreeSet};
120use std::net::IpAddr;
121use std::str::FromStr;
122use std::time::Duration;
123
124use super::container_spec::{
125    Capability, ContainerCommand, ContainerImage, ContainerName, ContainerVolume, CpuCount,
126    EnvVarName, ExtraHost, GpuId, GpuSelection, HostDevice, IpcNamespace, MemorySize, NetworkList,
127    NetworkName, PortBinding, PublishedPort, PullPolicy, RestartPolicy, compare_sets, invalid,
128    normalize_capability, parse_inspect_output, pointer, quoted_command, string_list, to_strings,
129};
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133#[serde(rename_all = "PascalCase")]
134pub struct DockerExpectedState {
135    name: ContainerName,
136    state: DockerContainerState,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(rename_all = "PascalCase")]
141pub enum DockerContainerState {
142    Running(Box<DockerContainerConfig>),
143    Stopped(Box<DockerContainerConfig>),
144    Absent,
145}
146
147impl DockerExpectedState {
148    pub fn running(name: ContainerName, config: DockerContainerConfig) -> Self {
149        Self {
150            name,
151            state: DockerContainerState::Running(Box::new(config)),
152        }
153    }
154
155    pub fn stopped(name: ContainerName, config: DockerContainerConfig) -> Self {
156        Self {
157            name,
158            state: DockerContainerState::Stopped(Box::new(config)),
159        }
160    }
161
162    pub fn absent(name: ContainerName) -> Self {
163        Self {
164            name,
165            state: DockerContainerState::Absent,
166        }
167    }
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[serde(rename_all = "PascalCase")]
172pub enum DockerNetworking {
173    /// Share the host network stack (`--network host`), ports can't be published
174    Host,
175    /// No network at all (`--network none`), ports can't be published
176    Isolated,
177    /// Attached to networks, with published ports
178    Attached(DockerAttachedNetworking),
179}
180
181impl DockerNetworking {
182    pub fn attached(networks: Option<NetworkList>, ports: Option<Vec<PublishedPort>>) -> Self {
183        DockerNetworking::Attached(DockerAttachedNetworking { networks, ports })
184    }
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189#[serde(rename_all = "PascalCase")]
190pub struct DockerAttachedNetworking {
191    networks: Option<NetworkList>,
192    ports: Option<Vec<PublishedPort>>,
193}
194
195/// The test command runs inside the container through `/bin/sh -c`.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198#[serde(rename_all = "PascalCase")]
199pub struct HealthCheck {
200    test: HealthCheckTest,
201    interval: Option<HealthDuration>,
202    timeout: Option<HealthDuration>,
203    start_period: Option<HealthDuration>,
204    start_interval: Option<HealthDuration>,
205    retries: Option<u32>,
206}
207
208impl HealthCheck {
209    pub fn new(test: HealthCheckTest) -> Self {
210        Self {
211            test,
212            interval: None,
213            timeout: None,
214            start_period: None,
215            start_interval: None,
216            retries: None,
217        }
218    }
219
220    pub fn with_interval(mut self, interval: HealthDuration) -> Self {
221        self.interval = Some(interval);
222        self
223    }
224
225    pub fn with_timeout(mut self, timeout: HealthDuration) -> Self {
226        self.timeout = Some(timeout);
227        self
228    }
229
230    pub fn with_start_period(mut self, start_period: HealthDuration) -> Self {
231        self.start_period = Some(start_period);
232        self
233    }
234
235    pub fn with_start_interval(mut self, start_interval: HealthDuration) -> Self {
236        self.start_interval = Some(start_interval);
237        self
238    }
239
240    pub fn with_retries(mut self, retries: u32) -> Self {
241        self.retries = Some(retries);
242        self
243    }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(try_from = "String", into = "String")]
248pub struct HealthCheckTest(String);
249
250impl TryFrom<String> for HealthCheckTest {
251    type Error = RegentError;
252
253    fn try_from(test: String) -> Result<Self, Self::Error> {
254        if test.trim().is_empty() {
255            Err(invalid("health check test", &test, "must be non-empty"))
256        } else {
257            Ok(Self(test))
258        }
259    }
260}
261
262impl HealthCheckTest {
263    fn as_string(&self) -> String {
264        self.0.clone()
265    }
266}
267
268impl std::fmt::Display for HealthCheckTest {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        write!(f, "{}", self.as_string())
271    }
272}
273
274impl FromStr for HealthCheckTest {
275    type Err = RegentError;
276
277    fn from_str(test: &str) -> Result<Self, Self::Err> {
278        Self::try_from(test.to_string())
279    }
280}
281
282impl From<HealthCheckTest> for String {
283    fn from(test: HealthCheckTest) -> String {
284        test.as_string()
285    }
286}
287
288/// Duration in the Go syntax of the Docker CLI (`30s`, `1m30s`, `500ms`), strictly positive
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(try_from = "String", into = "String")]
291pub struct HealthDuration {
292    spec: String,
293    nanos: i64,
294}
295
296impl TryFrom<String> for HealthDuration {
297    type Error = RegentError;
298
299    fn try_from(spec: String) -> Result<Self, Self::Error> {
300        let error = || {
301            invalid(
302                "health check duration",
303                &spec,
304                "expected a Go duration like 30s, 1m30s or 500ms",
305            )
306        };
307        // One or more <number><unit> pairs, units ns, us, ms, s, m, h (Go syntax)
308        let mut nanos: f64 = 0.0;
309        let mut rest = spec.as_str();
310        let mut pairs = 0;
311        while !rest.is_empty() {
312            let number_len = rest
313                .find(|c: char| !(c.is_ascii_digit() || c == '.'))
314                .unwrap_or(rest.len());
315            if number_len == 0 {
316                return Err(error());
317            }
318            let number: f64 = rest[..number_len].parse().map_err(|_| error())?;
319            rest = &rest[number_len..];
320            let unit_len = rest
321                .find(|c: char| c.is_ascii_digit() || c == '.')
322                .unwrap_or(rest.len());
323            let (unit, tail) = rest.split_at(unit_len);
324            let multiplier: f64 = match unit {
325                "ns" => 1.0,
326                "us" | "µs" | "μs" => 1e3,
327                "ms" => 1e6,
328                "s" => 1e9,
329                "m" => 60e9,
330                "h" => 3600e9,
331                _ => return Err(error()),
332            };
333            nanos += number * multiplier;
334            rest = tail;
335            pairs += 1;
336        }
337        let nanos = nanos.round() as i64;
338        if pairs == 0 || nanos <= 0 {
339            return Err(error());
340        }
341        Ok(Self { spec, nanos })
342    }
343}
344
345impl HealthDuration {
346    fn as_string(&self) -> String {
347        self.spec.clone()
348    }
349
350    fn nanos(&self) -> i64 {
351        self.nanos
352    }
353}
354
355impl std::fmt::Display for HealthDuration {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        write!(f, "{}", self.as_string())
358    }
359}
360
361impl FromStr for HealthDuration {
362    type Err = RegentError;
363
364    fn from_str(spec: &str) -> Result<Self, Self::Err> {
365        Self::try_from(spec.to_string())
366    }
367}
368
369impl From<HealthDuration> for String {
370    fn from(duration: HealthDuration) -> String {
371        duration.as_string()
372    }
373}
374
375/// Every optional field left out is not managed (no regent's defaults here, only docker's)
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377#[serde(deny_unknown_fields)]
378#[serde(rename_all = "PascalCase")]
379pub struct DockerContainerConfig {
380    image: ContainerImage,
381    #[serde(default)]
382    pull: PullPolicy,
383    command: Option<ContainerCommand>,
384    entrypoint: Option<String>,
385    env: Option<BTreeMap<EnvVarName, Parameter<String>>>,
386    volumes: Option<Vec<ContainerVolume>>,
387    networking: Option<DockerNetworking>,
388    restart_policy: Option<RestartPolicy>,
389    hostname: Option<String>,
390    /// User (`name|uid[:group|gid]`)
391    user: Option<String>,
392    working_dir: Option<String>,
393    labels: Option<BTreeMap<String, String>>,
394    privileged: Option<bool>,
395    read_only: Option<bool>,
396    init: Option<bool>,
397    cap_add: Option<Vec<Capability>>,
398    cap_drop: Option<Vec<Capability>>,
399    memory: Option<MemorySize>,
400    cpus: Option<CpuCount>,
401    extra_hosts: Option<Vec<ExtraHost>>,
402    dns: Option<Vec<IpAddr>>,
403    devices: Option<Vec<HostDevice>>,
404    /// GPUs (`--gpus`, requires the NVIDIA Container Toolkit)
405    gpus: Option<GpuSelection>,
406    ipc: Option<IpcNamespace>,
407    health_check: Option<HealthCheck>,
408    log_driver: Option<String>,
409    log_options: Option<BTreeMap<String, String>>,
410}
411
412impl DockerContainerConfig {
413    pub fn new(image: ContainerImage) -> Self {
414        Self {
415            image,
416            pull: PullPolicy::default(),
417            command: None,
418            entrypoint: None,
419            env: None,
420            volumes: None,
421            networking: None,
422            restart_policy: None,
423            hostname: None,
424            user: None,
425            working_dir: None,
426            labels: None,
427            privileged: None,
428            read_only: None,
429            init: None,
430            cap_add: None,
431            cap_drop: None,
432            memory: None,
433            cpus: None,
434            extra_hosts: None,
435            dns: None,
436            devices: None,
437            gpus: None,
438            ipc: None,
439            health_check: None,
440            log_driver: None,
441            log_options: None,
442        }
443    }
444
445    pub fn with_pull_policy(mut self, pull: PullPolicy) -> Self {
446        self.pull = pull;
447        self
448    }
449
450    /// Pull the image only if it is not present on the host (default, `PullPolicy::Missing`)
451    pub fn with_pull_if_missing(self) -> Self {
452        self.with_pull_policy(PullPolicy::Missing)
453    }
454
455    /// Never pull the image: a missing image makes the attribute non-compliant (`PullPolicy::Never`)
456    pub fn with_pull_never(self) -> Self {
457        self.with_pull_policy(PullPolicy::Never)
458    }
459
460    pub fn with_command(mut self, command: ContainerCommand) -> Self {
461        self.command = Some(command);
462        self
463    }
464
465    pub fn with_entrypoint(mut self, entrypoint: &str) -> Self {
466        self.entrypoint = Some(entrypoint.to_string());
467        self
468    }
469
470    pub fn with_env(mut self, key: EnvVarName, value: Parameter<String>) -> Self {
471        self.env
472            .get_or_insert_with(BTreeMap::new)
473            .insert(key, value);
474        self
475    }
476
477    pub fn with_volumes(mut self, volumes: Vec<ContainerVolume>) -> Self {
478        self.volumes = Some(volumes);
479        self
480    }
481
482    pub fn with_networking(mut self, networking: DockerNetworking) -> Self {
483        self.networking = Some(networking);
484        self
485    }
486
487    /// Share the host network stack (`--network host`). Replaces the networks and published
488    /// ports set before.
489    pub fn with_host_networking(self) -> Self {
490        self.with_networking(DockerNetworking::Host)
491    }
492
493    /// No network at all (`--network none`). Replaces the networks and published ports set
494    /// before.
495    pub fn with_isolated_networking(self) -> Self {
496        self.with_networking(DockerNetworking::Isolated)
497    }
498
499    /// Attach the container to a network, after the networks added before (the first one is
500    /// used at creation). Replaces `Host` or `Isolated` networking.
501    pub fn with_network(mut self, network: NetworkName) -> Self {
502        let mut attached = self.take_attached_networking();
503        attached.networks = Some(NetworkList::add(attached.networks, network));
504        self.with_networking(DockerNetworking::Attached(attached))
505    }
506
507    /// Publish a port, in addition to the ports published before. Ports can only be published
508    /// on attached networking: replaces `Host` or `Isolated` networking.
509    pub fn with_published_port(mut self, port: PublishedPort) -> Self {
510        let mut attached = self.take_attached_networking();
511        attached.ports.get_or_insert_with(Vec::new).push(port);
512        self.with_networking(DockerNetworking::Attached(attached))
513    }
514
515    /// Current attached networking, or an empty one if networking is `Host`, `Isolated` or not set
516    fn take_attached_networking(&mut self) -> DockerAttachedNetworking {
517        match self.networking.take() {
518            Some(DockerNetworking::Attached(attached)) => attached,
519            _ => DockerAttachedNetworking {
520                networks: None,
521                ports: None,
522            },
523        }
524    }
525
526    pub fn with_restart_policy(mut self, restart_policy: RestartPolicy) -> Self {
527        self.restart_policy = Some(restart_policy);
528        self
529    }
530
531    /// Never restart the container (`--restart no`)
532    pub fn with_restart_never(self) -> Self {
533        self.with_restart_policy(RestartPolicy::No)
534    }
535
536    /// Always restart the container (`--restart always`)
537    pub fn with_restart_always(self) -> Self {
538        self.with_restart_policy(RestartPolicy::Always)
539    }
540
541    /// Restart the container unless it was stopped (`--restart unless-stopped`)
542    pub fn with_restart_unless_stopped(self) -> Self {
543        self.with_restart_policy(RestartPolicy::UnlessStopped)
544    }
545
546    /// Restart the container when it fails, at most `max_retries` times (0 means unlimited,
547    /// `--restart on-failure[:max-retries]`)
548    pub fn with_restart_on_failure(self, max_retries: u32) -> Self {
549        self.with_restart_policy(RestartPolicy::OnFailure(max_retries))
550    }
551
552    pub fn with_hostname(mut self, hostname: &str) -> Self {
553        self.hostname = Some(hostname.to_string());
554        self
555    }
556
557    pub fn with_user(mut self, user: &str) -> Self {
558        self.user = Some(user.to_string());
559        self
560    }
561
562    pub fn with_working_dir(mut self, working_dir: &str) -> Self {
563        self.working_dir = Some(working_dir.to_string());
564        self
565    }
566
567    pub fn with_label(mut self, key: &str, value: &str) -> Self {
568        self.labels
569            .get_or_insert_with(BTreeMap::new)
570            .insert(key.to_string(), value.to_string());
571        self
572    }
573
574    pub fn with_privileged(mut self, privileged: bool) -> Self {
575        self.privileged = Some(privileged);
576        self
577    }
578
579    pub fn with_read_only(mut self, read_only: bool) -> Self {
580        self.read_only = Some(read_only);
581        self
582    }
583
584    pub fn with_init(mut self, init: bool) -> Self {
585        self.init = Some(init);
586        self
587    }
588
589    pub fn with_cap_add(mut self, capabilities: Vec<Capability>) -> Self {
590        self.cap_add = Some(capabilities);
591        self
592    }
593
594    pub fn with_cap_drop(mut self, capabilities: Vec<Capability>) -> Self {
595        self.cap_drop = Some(capabilities);
596        self
597    }
598
599    pub fn with_memory(mut self, memory: MemorySize) -> Self {
600        self.memory = Some(memory);
601        self
602    }
603
604    pub fn with_cpus(mut self, cpus: CpuCount) -> Self {
605        self.cpus = Some(cpus);
606        self
607    }
608
609    pub fn with_extra_hosts(mut self, extra_hosts: Vec<ExtraHost>) -> Self {
610        self.extra_hosts = Some(extra_hosts);
611        self
612    }
613
614    pub fn with_dns(mut self, dns: Vec<IpAddr>) -> Self {
615        self.dns = Some(dns);
616        self
617    }
618
619    pub fn with_devices(mut self, devices: Vec<HostDevice>) -> Self {
620        self.devices = Some(devices);
621        self
622    }
623
624    pub fn with_gpus(mut self, gpus: GpuSelection) -> Self {
625        self.gpus = Some(gpus);
626        self
627    }
628
629    /// Give all the GPUs to the container (`--gpus all`). Replaces the devices added before.
630    pub fn with_all_gpus(self) -> Self {
631        self.with_gpus(GpuSelection::All)
632    }
633
634    /// Give a GPU to the container, in addition to the devices added before. Replaces
635    /// `with_all_gpus()`.
636    pub fn with_gpu_device(mut self, gpu: GpuId) -> Self {
637        self.gpus = Some(GpuSelection::add_device(self.gpus.take(), gpu));
638        self
639    }
640
641    pub fn with_ipc(mut self, ipc: IpcNamespace) -> Self {
642        self.ipc = Some(ipc);
643        self
644    }
645
646    /// Share the host IPC namespace and `/dev/shm` (`--ipc host`). Replaces a private namespace
647    /// and its `/dev/shm` size.
648    pub fn with_host_ipc(self) -> Self {
649        self.with_ipc(IpcNamespace::Host)
650    }
651
652    /// Own IPC namespace (`--ipc private`). Replaces the host namespace, keeps the `/dev/shm`
653    /// size set before.
654    pub fn with_private_ipc(mut self) -> Self {
655        self.ipc = Some(IpcNamespace::private(self.ipc.take()));
656        self
657    }
658
659    /// Own IPC namespace which other containers can join (`--ipc shareable`). Replaces the host
660    /// namespace, keeps the `/dev/shm` size set before.
661    pub fn with_shareable_ipc(mut self) -> Self {
662        self.ipc = Some(IpcNamespace::shareable(self.ipc.take()));
663        self
664    }
665
666    /// Size of `/dev/shm` (`--shm-size`), which only exists in the container's own IPC
667    /// namespace: replaces the host namespace by a private one (a shareable namespace stays
668    /// shareable).
669    pub fn with_shm_size(mut self, shm_size: MemorySize) -> Self {
670        self.ipc = Some(IpcNamespace::with_shm_size(self.ipc.take(), shm_size));
671        self
672    }
673
674    pub fn with_health_check(mut self, health_check: HealthCheck) -> Self {
675        self.health_check = Some(health_check);
676        self
677    }
678
679    pub fn with_log_driver(mut self, log_driver: &str) -> Self {
680        self.log_driver = Some(log_driver.to_string());
681        self
682    }
683
684    pub fn with_log_option(mut self, key: &str, value: &str) -> Self {
685        self.log_options
686            .get_or_insert_with(BTreeMap::new)
687            .insert(key.to_string(), value.to_string());
688        self
689    }
690
691    fn additional_networks(&self) -> &[NetworkName] {
692        match &self.networking {
693            Some(DockerNetworking::Attached(DockerAttachedNetworking {
694                networks: Some(networks),
695                ..
696            })) => &networks.networks()[1..],
697            _ => &[],
698        }
699    }
700
701    async fn resolve_env(
702        &self,
703        optional_secret_provider: &Option<SecretProvidersPool>,
704    ) -> Result<Vec<(String, String)>, RegentError> {
705        let mut resolved = Vec::new();
706        if let Some(env) = &self.env {
707            for (key, value) in env {
708                resolved.push((
709                    key.to_string(),
710                    value.clone().inner_raw(optional_secret_provider).await?,
711                ));
712            }
713        }
714        Ok(resolved)
715    }
716
717    fn create_args(&self, name: &ContainerName, env: &[(String, String)]) -> Vec<String> {
718        let mut args = to_strings(&["docker", "create", "--name", name.as_str()]);
719
720        let mut push_option = |flag: &str, value: String| {
721            args.push(flag.to_string());
722            args.push(value);
723        };
724
725        if let Some(hostname) = &self.hostname {
726            push_option("--hostname", hostname.clone());
727        }
728        if let Some(user) = &self.user {
729            push_option("--user", user.clone());
730        }
731        if let Some(working_dir) = &self.working_dir {
732            push_option("--workdir", working_dir.clone());
733        }
734        if let Some(entrypoint) = &self.entrypoint {
735            push_option("--entrypoint", entrypoint.clone());
736        }
737        for (key, value) in env {
738            push_option("--env", format!("{}={}", key, value));
739        }
740        for volume in self.volumes.iter().flatten() {
741            push_option("--volume", volume.to_string());
742        }
743        match &self.networking {
744            Some(DockerNetworking::Host) => push_option("--network", "host".to_string()),
745            Some(DockerNetworking::Isolated) => push_option("--network", "none".to_string()),
746            Some(DockerNetworking::Attached(attached)) => {
747                if let Some(networks) = &attached.networks {
748                    push_option("--network", networks.networks()[0].to_string());
749                }
750                for port in attached.ports.iter().flatten() {
751                    push_option("--publish", port.to_string());
752                }
753            }
754            None => {}
755        }
756        if let Some(restart_policy) = &self.restart_policy {
757            push_option("--restart", restart_policy.as_cli_arg());
758        }
759        for (key, value) in self.labels.iter().flatten() {
760            push_option("--label", format!("{}={}", key, value));
761        }
762        for capability in self.cap_add.iter().flatten() {
763            push_option("--cap-add", capability.to_string());
764        }
765        for capability in self.cap_drop.iter().flatten() {
766            push_option("--cap-drop", capability.to_string());
767        }
768        if let Some(memory) = &self.memory {
769            push_option("--memory", memory.to_string());
770        }
771        if let Some(cpus) = &self.cpus {
772            push_option("--cpus", cpus.to_string());
773        }
774        for extra_host in self.extra_hosts.iter().flatten() {
775            push_option("--add-host", extra_host.to_string());
776        }
777        for dns in self.dns.iter().flatten() {
778            push_option("--dns", dns.to_string());
779        }
780        for device in self.devices.iter().flatten() {
781            push_option("--device", device.to_string());
782        }
783        match &self.gpus {
784            Some(GpuSelection::All) => push_option("--gpus", "all".to_string()),
785            Some(GpuSelection::Devices(gpus)) => {
786                // The value is parsed as CSV: the list needs quotes around it
787                let ids: Vec<String> = gpus.gpus().iter().map(|gpu| gpu.to_string()).collect();
788                push_option("--gpus", format!("\"device={}\"", ids.join(",")));
789            }
790            None => {}
791        }
792        if let Some(ipc) = &self.ipc {
793            push_option("--ipc", ipc.mode().to_string());
794            if let Some(shm_size) = ipc.shm_size() {
795                push_option("--shm-size", shm_size.to_string());
796            }
797        }
798        if let Some(health_check) = &self.health_check {
799            push_option("--health-cmd", health_check.test.to_string());
800            if let Some(interval) = &health_check.interval {
801                push_option("--health-interval", interval.to_string());
802            }
803            if let Some(timeout) = &health_check.timeout {
804                push_option("--health-timeout", timeout.to_string());
805            }
806            if let Some(start_period) = &health_check.start_period {
807                push_option("--health-start-period", start_period.to_string());
808            }
809            if let Some(start_interval) = &health_check.start_interval {
810                push_option("--health-start-interval", start_interval.to_string());
811            }
812            if let Some(retries) = &health_check.retries {
813                push_option("--health-retries", retries.to_string());
814            }
815        }
816        if let Some(log_driver) = &self.log_driver {
817            push_option("--log-driver", log_driver.clone());
818        }
819        for (key, value) in self.log_options.iter().flatten() {
820            push_option("--log-opt", format!("{}={}", key, value));
821        }
822
823        if self.privileged == Some(true) {
824            args.push("--privileged".to_string());
825        }
826        if self.read_only == Some(true) {
827            args.push("--read-only".to_string());
828        }
829        if self.init == Some(true) {
830            args.push("--init".to_string());
831        }
832
833        args.push(self.image.to_string());
834        args.extend(
835            self.command
836                .iter()
837                .flat_map(|command| command.args().iter().cloned()),
838        );
839
840        args
841    }
842
843    /// Returns the list of differences requiring the container to be recreated (empty if none).
844    /// Messages never contain environment variable values, as they might be secrets.
845    fn recreation_reasons(&self, inspect: &Value, env: &[(String, String)]) -> Vec<String> {
846        let mut reasons: Vec<String> = Vec::new();
847
848        let current_image = pointer(inspect, "/Config/Image")
849            .as_str()
850            .unwrap_or_default();
851        if current_image != self.image.as_str() {
852            reasons.push(format!(
853                "image is {}, expected {}",
854                current_image, self.image
855            ));
856        }
857
858        if let Some(command) = &self.command {
859            let current = string_list(pointer(inspect, "/Config/Cmd"));
860            if current != command.args() {
861                reasons.push(format!(
862                    "command is {:?}, expected {:?}",
863                    current,
864                    command.args()
865                ));
866            }
867        }
868
869        if let Some(entrypoint) = &self.entrypoint {
870            let current = string_list(pointer(inspect, "/Config/Entrypoint"));
871            let is_matching = if entrypoint.is_empty() {
872                current.iter().all(|part| part.is_empty())
873            } else {
874                current.len() == 1 && &current[0] == entrypoint
875            };
876            if !is_matching {
877                reasons.push(format!(
878                    "entrypoint is {:?}, expected {:?}",
879                    current, entrypoint
880                ));
881            }
882        }
883
884        if !env.is_empty() {
885            let current: BTreeMap<String, String> = string_list(pointer(inspect, "/Config/Env"))
886                .into_iter()
887                .filter_map(|entry| {
888                    entry
889                        .split_once('=')
890                        .map(|(key, value)| (key.to_string(), value.to_string()))
891                })
892                .collect();
893            for (key, value) in env {
894                if current.get(key) != Some(value) {
895                    reasons.push(format!("environment variable {} differs", key));
896                }
897            }
898        }
899
900        if let Some(volumes) = &self.volumes {
901            // Binds are compared exactly, anonymous volumes as a subset (the image may declare some)
902            compare_sets(
903                &mut reasons,
904                "volumes",
905                volumes
906                    .iter()
907                    .filter(|volume| matches!(volume, ContainerVolume::Bind { .. }))
908                    .map(|volume| volume.to_string())
909                    .collect(),
910                string_list(pointer(inspect, "/HostConfig/Binds"))
911                    .into_iter()
912                    .collect(),
913            );
914            let current_anonymous = pointer(inspect, "/Config/Volumes");
915            for volume in volumes {
916                if let ContainerVolume::Anonymous { target } = volume
917                    && current_anonymous.get(target).is_none()
918                {
919                    reasons.push(format!("anonymous volume {} is missing", target));
920                }
921            }
922        }
923
924        if let Some(networking) = &self.networking {
925            let current_mode = pointer(inspect, "/HostConfig/NetworkMode")
926                .as_str()
927                .unwrap_or_default();
928            match networking {
929                DockerNetworking::Host | DockerNetworking::Isolated => {
930                    let expected_mode = match networking {
931                        DockerNetworking::Host => "host",
932                        _ => "none",
933                    };
934                    if current_mode != expected_mode {
935                        reasons.push(format!(
936                            "network mode is {}, expected {}",
937                            current_mode, expected_mode
938                        ));
939                    }
940                }
941                DockerNetworking::Attached(attached) => {
942                    if current_mode == "host" || current_mode == "none" {
943                        reasons.push(format!(
944                            "network mode is {}, expected attached networks",
945                            current_mode
946                        ));
947                    }
948                    if let Some(networks) = &attached.networks {
949                        compare_sets(
950                            &mut reasons,
951                            "networks",
952                            networks
953                                .networks()
954                                .iter()
955                                .map(|network| network.to_string())
956                                .collect(),
957                            pointer(inspect, "/NetworkSettings/Networks")
958                                .as_object()
959                                .map(|networks| networks.keys().cloned().collect())
960                                .unwrap_or_default(),
961                        );
962                    }
963                    if let Some(ports) = &attached.ports {
964                        let expected: BTreeSet<PortBinding> = ports
965                            .iter()
966                            .flat_map(|port| port.bindings().iter().cloned())
967                            .collect();
968                        let current = current_port_bindings(inspect);
969                        if expected != current {
970                            reasons.push(format!(
971                                "published ports are {:?}, expected {:?}",
972                                current, expected
973                            ));
974                        }
975                    }
976                }
977            }
978        }
979
980        for (option_name, expected, path) in [
981            ("hostname", &self.hostname, "/Config/Hostname"),
982            ("user", &self.user, "/Config/User"),
983            ("working directory", &self.working_dir, "/Config/WorkingDir"),
984            ("log driver", &self.log_driver, "/HostConfig/LogConfig/Type"),
985        ] {
986            if let Some(expected) = expected {
987                let current = pointer(inspect, path).as_str().unwrap_or_default();
988                if current != expected {
989                    reasons.push(format!(
990                        "{} is {:?}, expected {:?}",
991                        option_name, current, expected
992                    ));
993                }
994            }
995        }
996
997        for (option_name, expected, path) in [
998            ("labels", &self.labels, "/Config/Labels"),
999            (
1000                "log options",
1001                &self.log_options,
1002                "/HostConfig/LogConfig/Config",
1003            ),
1004        ] {
1005            if let Some(expected) = expected {
1006                let current = pointer(inspect, path);
1007                for (key, value) in expected {
1008                    if current.get(key).and_then(Value::as_str) != Some(value.as_str()) {
1009                        reasons.push(format!("{} entry {} differs", option_name, key));
1010                    }
1011                }
1012            }
1013        }
1014
1015        for (option_name, expected, path) in [
1016            ("privileged", &self.privileged, "/HostConfig/Privileged"),
1017            ("read-only", &self.read_only, "/HostConfig/ReadonlyRootfs"),
1018            ("init", &self.init, "/HostConfig/Init"),
1019        ] {
1020            if let Some(expected) = expected {
1021                // null means the option was not set, which is equivalent to false
1022                let current = pointer(inspect, path).as_bool().unwrap_or(false);
1023                if current != *expected {
1024                    reasons.push(format!(
1025                        "{} is {}, expected {}",
1026                        option_name, current, expected
1027                    ));
1028                }
1029            }
1030        }
1031
1032        for (option_name, expected, path) in [
1033            ("added capabilities", &self.cap_add, "/HostConfig/CapAdd"),
1034            (
1035                "dropped capabilities",
1036                &self.cap_drop,
1037                "/HostConfig/CapDrop",
1038            ),
1039        ] {
1040            if let Some(expected) = expected {
1041                compare_sets(
1042                    &mut reasons,
1043                    option_name,
1044                    expected.iter().map(|cap| cap.to_string()).collect(),
1045                    string_list(pointer(inspect, path))
1046                        .iter()
1047                        .map(|cap| normalize_capability(cap))
1048                        .collect(),
1049                );
1050            }
1051        }
1052
1053        if let Some(extra_hosts) = &self.extra_hosts {
1054            compare_sets(
1055                &mut reasons,
1056                "extra hosts",
1057                extra_hosts.iter().map(|host| host.to_string()).collect(),
1058                string_list(pointer(inspect, "/HostConfig/ExtraHosts"))
1059                    .into_iter()
1060                    .collect(),
1061            );
1062        }
1063
1064        if let Some(dns) = &self.dns {
1065            compare_sets(
1066                &mut reasons,
1067                "DNS servers",
1068                dns.iter().map(|server| server.to_string()).collect(),
1069                string_list(pointer(inspect, "/HostConfig/Dns"))
1070                    .into_iter()
1071                    .collect(),
1072            );
1073        }
1074
1075        if let Some(memory) = &self.memory {
1076            let current = pointer(inspect, "/HostConfig/Memory")
1077                .as_i64()
1078                .unwrap_or_default();
1079            if current != memory.bytes() {
1080                reasons.push(format!(
1081                    "memory limit is {} bytes, expected {} bytes",
1082                    current,
1083                    memory.bytes()
1084                ));
1085            }
1086        }
1087
1088        if let Some(cpus) = &self.cpus {
1089            let current = pointer(inspect, "/HostConfig/NanoCpus")
1090                .as_i64()
1091                .unwrap_or_default();
1092            if current != cpus.nano_cpus() {
1093                reasons.push(format!(
1094                    "CPU limit is {}, expected {}",
1095                    current as f64 / 1e9,
1096                    cpus
1097                ));
1098            }
1099        }
1100
1101        if let Some(devices) = &self.devices {
1102            compare_sets(
1103                &mut reasons,
1104                "devices",
1105                devices.iter().map(|device| device.to_string()).collect(),
1106                pointer(inspect, "/HostConfig/Devices")
1107                    .as_array()
1108                    .into_iter()
1109                    .flatten()
1110                    .map(|device| {
1111                        format!(
1112                            "{}:{}:{}",
1113                            pointer(device, "/PathOnHost").as_str().unwrap_or_default(),
1114                            pointer(device, "/PathInContainer")
1115                                .as_str()
1116                                .unwrap_or_default(),
1117                            pointer(device, "/CgroupPermissions")
1118                                .as_str()
1119                                .unwrap_or_default()
1120                        )
1121                    })
1122                    .collect(),
1123            );
1124        }
1125
1126        if let Some(gpus) = &self.gpus {
1127            let current = current_gpus(inspect);
1128            let expected = gpus.ids();
1129            if current != expected {
1130                reasons.push(format!("GPUs are {:?}, expected {:?}", current, expected));
1131            }
1132        }
1133
1134        if let Some(ipc) = &self.ipc {
1135            let current = pointer(inspect, "/HostConfig/IpcMode")
1136                .as_str()
1137                .unwrap_or_default();
1138            if current != ipc.mode() {
1139                reasons.push(format!(
1140                    "IPC mode is {:?}, expected {:?}",
1141                    current,
1142                    ipc.mode()
1143                ));
1144            }
1145            if let Some(shm_size) = ipc.shm_size() {
1146                let current = pointer(inspect, "/HostConfig/ShmSize")
1147                    .as_i64()
1148                    .unwrap_or_default();
1149                if current != shm_size.bytes() {
1150                    reasons.push(format!(
1151                        "/dev/shm size is {} bytes, expected {} bytes",
1152                        current,
1153                        shm_size.bytes()
1154                    ));
1155                }
1156            }
1157        }
1158
1159        if let Some(health_check) = &self.health_check {
1160            let current = pointer(inspect, "/Config/Healthcheck");
1161            let expected_test = vec!["CMD-SHELL".to_string(), health_check.test.to_string()];
1162            let current_test = string_list(pointer(current, "/Test"));
1163            if current_test != expected_test {
1164                reasons.push(format!(
1165                    "health check command is {:?}, expected {:?}",
1166                    current_test, expected_test
1167                ));
1168            }
1169            for (option_name, expected, path) in [
1170                ("health check interval", &health_check.interval, "/Interval"),
1171                ("health check timeout", &health_check.timeout, "/Timeout"),
1172                (
1173                    "health check start period",
1174                    &health_check.start_period,
1175                    "/StartPeriod",
1176                ),
1177                (
1178                    "health check start interval",
1179                    &health_check.start_interval,
1180                    "/StartInterval",
1181                ),
1182            ] {
1183                if let Some(expected) = expected {
1184                    let current = pointer(current, path).as_i64().unwrap_or_default();
1185                    if current != expected.nanos() {
1186                        reasons.push(format!(
1187                            "{} is {} nanoseconds, expected {} nanoseconds",
1188                            option_name,
1189                            current,
1190                            expected.nanos()
1191                        ));
1192                    }
1193                }
1194            }
1195            if let Some(retries) = &health_check.retries {
1196                let current = pointer(current, "/Retries").as_i64().unwrap_or_default();
1197                if current != *retries as i64 {
1198                    reasons.push(format!(
1199                        "health check retries is {}, expected {}",
1200                        current, retries
1201                    ));
1202                }
1203            }
1204        }
1205
1206        reasons
1207    }
1208
1209    fn is_restart_policy_compliant(&self, inspect: &Value) -> bool {
1210        match &self.restart_policy {
1211            Some(restart_policy) => restart_policy.matches(
1212                pointer(inspect, "/HostConfig/RestartPolicy/Name")
1213                    .as_str()
1214                    .unwrap_or_default(),
1215                pointer(inspect, "/HostConfig/RestartPolicy/MaximumRetryCount")
1216                    .as_u64()
1217                    .unwrap_or_default(),
1218            ),
1219            None => true,
1220        }
1221    }
1222}
1223
1224impl Timeout for DockerExpectedState {
1225    fn default_timeout(&self) -> Duration {
1226        // Pulling an image can take a while
1227        Duration::from_secs(300)
1228    }
1229}
1230
1231impl Check for DockerExpectedState {
1232    fn check(&self) -> Result<(), RegentError> {
1233        // Every value is validated when its type is built: an existing DockerExpectedState is valid
1234        Ok(())
1235    }
1236
1237    fn check_host_compatibility(
1238        &self,
1239        host_properties: &HostProperties,
1240    ) -> Result<(), RegentError> {
1241        check_docker_host_compatibility(host_properties)
1242    }
1243}
1244
1245impl<Handler: HostHandler> AssessCompliance<Handler> for DockerExpectedState {
1246    async fn assess_compliance(
1247        &self,
1248        host_handler: &mut Handler,
1249        host_properties: &Option<HostProperties>,
1250        privilege: &Privilege,
1251        optional_secret_provider: &Option<SecretProvidersPool>,
1252    ) -> Result<AttributeComplianceAssessment, RegentError> {
1253        // Early check: verify we're on a compatible host
1254        if let Some(props) = host_properties {
1255            self.check_host_compatibility(props)?;
1256        }
1257
1258        let command_available = host_handler
1259            .is_this_command_available("docker", privilege)
1260            .await
1261            .unwrap_or(false);
1262        if !command_available {
1263            return Err(RegentError::FailedDryRunEvaluation(
1264                "Docker CLI (docker) is not available on this host".to_string(),
1265            ));
1266        }
1267
1268        let container = inspect_container(host_handler, &self.name, privilege).await?;
1269
1270        let mut remediations: Vec<Remediation> = Vec::new();
1271        let mut push = |api_call: DockerInternalApiCall| {
1272            remediations.push(Remediation::Docker(DockerApiCall::from(
1273                api_call,
1274                privilege.clone(),
1275            )));
1276        };
1277
1278        let (config, should_run) = match &self.state {
1279            DockerContainerState::Absent => {
1280                if container.is_some() {
1281                    push(DockerInternalApiCall::Remove(self.name.clone()));
1282                }
1283                (None, false)
1284            }
1285            DockerContainerState::Running(config) => (Some(config), true),
1286            DockerContainerState::Stopped(config) => (Some(config), false),
1287        };
1288
1289        if let Some(config) = config {
1290            let image_id = inspect_image_id(host_handler, &config.image, privilege).await?;
1291
1292            if image_id.is_none() {
1293                match config.pull {
1294                    PullPolicy::Missing => push(DockerInternalApiCall::Pull(config.image.clone())),
1295                    PullPolicy::Never => {
1296                        return Ok(AttributeComplianceAssessment::NonCompliantFatal(format!(
1297                            "image {} is not present on the host and Pull is Never",
1298                            config.image
1299                        )));
1300                    }
1301                }
1302            }
1303
1304            match container {
1305                None => {
1306                    push(DockerInternalApiCall::Create {
1307                        name: self.name.clone(),
1308                        config: config.clone(),
1309                    });
1310                    if should_run {
1311                        push(DockerInternalApiCall::Start(self.name.clone()));
1312                    }
1313                }
1314                Some(inspect) => {
1315                    let env = config.resolve_env(optional_secret_provider).await?;
1316                    let mut reasons = config.recreation_reasons(&inspect, &env);
1317
1318                    // Same image reference but the local image changed (e.g. new pull of a tag)
1319                    let current_image_id = pointer(&inspect, "/Image").as_str();
1320                    match &image_id {
1321                        Some(image_id)
1322                            if reasons.is_empty()
1323                                && current_image_id != Some(image_id.as_str()) =>
1324                        {
1325                            reasons.push(format!("image {} has been updated", config.image));
1326                        }
1327                        None => reasons.push(format!(
1328                            "image {} is not present locally, it will be pulled",
1329                            config.image
1330                        )),
1331                        _ => {}
1332                    }
1333
1334                    if !reasons.is_empty() {
1335                        push(DockerInternalApiCall::Recreate {
1336                            name: self.name.clone(),
1337                            config: config.clone(),
1338                            reasons,
1339                        });
1340                        if should_run {
1341                            push(DockerInternalApiCall::Start(self.name.clone()));
1342                        }
1343                    } else {
1344                        if let Some(restart_policy) = &config.restart_policy
1345                            && !config.is_restart_policy_compliant(&inspect)
1346                        {
1347                            push(DockerInternalApiCall::UpdateRestartPolicy {
1348                                name: self.name.clone(),
1349                                restart_policy: restart_policy.clone(),
1350                            });
1351                        }
1352
1353                        let is_running = pointer(&inspect, "/State/Running")
1354                            .as_bool()
1355                            .unwrap_or(false);
1356                        let is_paused = pointer(&inspect, "/State/Paused")
1357                            .as_bool()
1358                            .unwrap_or(false);
1359                        match (should_run, is_running, is_paused) {
1360                            (true, true, true) => {
1361                                push(DockerInternalApiCall::Unpause(self.name.clone()))
1362                            }
1363                            (true, false, _) => {
1364                                push(DockerInternalApiCall::Start(self.name.clone()))
1365                            }
1366                            (false, true, _) => {
1367                                push(DockerInternalApiCall::Stop(self.name.clone()))
1368                            }
1369                            _ => {
1370                                // Already in the expected running state
1371                            }
1372                        }
1373                    }
1374                }
1375            }
1376        }
1377
1378        if remediations.is_empty() {
1379            Ok(AttributeComplianceAssessment::Compliant)
1380        } else {
1381            Ok(AttributeComplianceAssessment::NonCompliant(
1382                RemediationsList::from(remediations)?,
1383            ))
1384        }
1385    }
1386}
1387
1388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1389#[serde(rename_all = "PascalCase", rename_all_fields = "PascalCase")]
1390pub enum DockerInternalApiCall {
1391    Pull(ContainerImage),
1392    /// Create a container (not started)
1393    Create {
1394        name: ContainerName,
1395        config: Box<DockerContainerConfig>,
1396    },
1397    /// Remove then create a container, with the reasons why
1398    Recreate {
1399        name: ContainerName,
1400        config: Box<DockerContainerConfig>,
1401        reasons: Vec<String>,
1402    },
1403    /// Remove a container (forced)
1404    Remove(ContainerName),
1405    Start(ContainerName),
1406    Stop(ContainerName),
1407    Unpause(ContainerName),
1408    UpdateRestartPolicy {
1409        name: ContainerName,
1410        restart_policy: RestartPolicy,
1411    },
1412}
1413
1414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1415pub struct DockerApiCall {
1416    pub api_call: DockerInternalApiCall,
1417    privilege: Privilege,
1418}
1419
1420impl DockerApiCall {
1421    pub fn display(&self) -> String {
1422        match &self.api_call {
1423            DockerInternalApiCall::Pull(image) => format!("Pull image {}", image),
1424            DockerInternalApiCall::Create { name, config } => {
1425                format!("Create container {} from {}", name, config.image)
1426            }
1427            DockerInternalApiCall::Recreate { name, reasons, .. } => {
1428                format!("Recreate container {} ({})", name, reasons.join("; "))
1429            }
1430            DockerInternalApiCall::Remove(name) => format!("Remove container {}", name),
1431            DockerInternalApiCall::Start(name) => format!("Start container {}", name),
1432            DockerInternalApiCall::Stop(name) => format!("Stop container {}", name),
1433            DockerInternalApiCall::Unpause(name) => format!("Unpause container {}", name),
1434            DockerInternalApiCall::UpdateRestartPolicy {
1435                name,
1436                restart_policy,
1437            } => format!(
1438                "Set restart policy of container {} to {}",
1439                name,
1440                restart_policy.as_cli_arg()
1441            ),
1442        }
1443    }
1444
1445    fn from(api_call: DockerInternalApiCall, privilege: Privilege) -> DockerApiCall {
1446        DockerApiCall {
1447            api_call,
1448            privilege,
1449        }
1450    }
1451}
1452
1453impl Timeout for DockerApiCall {
1454    fn default_timeout(&self) -> Duration {
1455        Duration::from_secs(300)
1456    }
1457}
1458
1459impl Check for DockerApiCall {
1460    fn check(&self) -> Result<(), RegentError> {
1461        Ok(())
1462    }
1463
1464    fn check_host_compatibility(
1465        &self,
1466        host_properties: &HostProperties,
1467    ) -> Result<(), RegentError> {
1468        check_docker_host_compatibility(host_properties)
1469    }
1470}
1471
1472impl<Handler: HostHandler> ReachCompliance<Handler> for DockerApiCall {
1473    async fn call(
1474        &self,
1475        host_handler: &mut Handler,
1476        host_properties: &Option<HostProperties>,
1477        optional_secret_provider: &Option<SecretProvidersPool>,
1478    ) -> Result<InternalApiCallOutcome, RegentError> {
1479        // Early check: verify we're on a compatible host
1480        if let Some(props) = host_properties {
1481            self.check_host_compatibility(props)?;
1482        }
1483
1484        match &self.api_call {
1485            DockerInternalApiCall::Pull(image) => {
1486                run_docker(
1487                    host_handler,
1488                    &["docker", "pull", image.as_str()],
1489                    &self.privilege,
1490                )
1491                .await
1492            }
1493            DockerInternalApiCall::Create { name, config } => {
1494                create_container(
1495                    host_handler,
1496                    name,
1497                    config,
1498                    optional_secret_provider,
1499                    &self.privilege,
1500                )
1501                .await
1502            }
1503            DockerInternalApiCall::Recreate { name, config, .. } => {
1504                let outcome = run_docker(
1505                    host_handler,
1506                    &["docker", "rm", "--force", name.as_str()],
1507                    &self.privilege,
1508                )
1509                .await?;
1510                if let InternalApiCallOutcome::Failure(_) = outcome {
1511                    return Ok(outcome);
1512                }
1513                create_container(
1514                    host_handler,
1515                    name,
1516                    config,
1517                    optional_secret_provider,
1518                    &self.privilege,
1519                )
1520                .await
1521            }
1522            DockerInternalApiCall::Remove(name) => {
1523                run_docker(
1524                    host_handler,
1525                    &["docker", "rm", "--force", name.as_str()],
1526                    &self.privilege,
1527                )
1528                .await
1529            }
1530            DockerInternalApiCall::Start(name) => {
1531                run_docker(
1532                    host_handler,
1533                    &["docker", "start", name.as_str()],
1534                    &self.privilege,
1535                )
1536                .await
1537            }
1538            DockerInternalApiCall::Stop(name) => {
1539                run_docker(
1540                    host_handler,
1541                    &["docker", "stop", name.as_str()],
1542                    &self.privilege,
1543                )
1544                .await
1545            }
1546            DockerInternalApiCall::Unpause(name) => {
1547                run_docker(
1548                    host_handler,
1549                    &["docker", "unpause", name.as_str()],
1550                    &self.privilege,
1551                )
1552                .await
1553            }
1554            DockerInternalApiCall::UpdateRestartPolicy {
1555                name,
1556                restart_policy,
1557            } => {
1558                run_docker(
1559                    host_handler,
1560                    &[
1561                        "docker",
1562                        "update",
1563                        "--restart",
1564                        &restart_policy.as_cli_arg(),
1565                        name.as_str(),
1566                    ],
1567                    &self.privilege,
1568                )
1569                .await
1570            }
1571        }
1572    }
1573}
1574
1575fn check_docker_host_compatibility(host_properties: &HostProperties) -> Result<(), RegentError> {
1576    match host_properties.os_kind() {
1577        OsKind::Linux(_) | OsKind::MacOs(_) => Ok(()),
1578        incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
1579            "Host is {:?} but Docker container management is only supported on Linux and macOS",
1580            incompatible_os_kind
1581        ))),
1582    }
1583}
1584
1585/// Create the container, then connect it to the additional networks
1586async fn create_container<Handler: HostHandler>(
1587    host_handler: &mut Handler,
1588    name: &ContainerName,
1589    config: &DockerContainerConfig,
1590    optional_secret_provider: &Option<SecretProvidersPool>,
1591    privilege: &Privilege,
1592) -> Result<InternalApiCallOutcome, RegentError> {
1593    let env = config.resolve_env(optional_secret_provider).await?;
1594    let args = config.create_args(name, &env);
1595    let args: Vec<&str> = args.iter().map(String::as_str).collect();
1596
1597    let outcome = run_docker(host_handler, &args, privilege).await?;
1598    if let InternalApiCallOutcome::Failure(_) = outcome {
1599        return Ok(outcome);
1600    }
1601
1602    for network in config.additional_networks() {
1603        let outcome = run_docker(
1604            host_handler,
1605            &[
1606                "docker",
1607                "network",
1608                "connect",
1609                network.as_str(),
1610                name.as_str(),
1611            ],
1612            privilege,
1613        )
1614        .await?;
1615        if let InternalApiCallOutcome::Failure(_) = outcome {
1616            return Ok(outcome);
1617        }
1618    }
1619
1620    Ok(InternalApiCallOutcome::Success(None))
1621}
1622
1623async fn run_docker<Handler: HostHandler>(
1624    host_handler: &mut Handler,
1625    args: &[&str],
1626    privilege: &Privilege,
1627) -> Result<InternalApiCallOutcome, RegentError> {
1628    match host_handler
1629        .run_command(&quoted_command(args), privilege)
1630        .await
1631    {
1632        Ok(result) => {
1633            if result.return_code == 0 {
1634                Ok(InternalApiCallOutcome::Success(None))
1635            } else {
1636                Ok(InternalApiCallOutcome::Failure(format!(
1637                    "RC: {}, STDOUT: {}, STDERR: {}",
1638                    result.return_code, result.stdout, result.stderr
1639                )))
1640            }
1641        }
1642        Err(e) => Ok(InternalApiCallOutcome::Failure(format!(
1643            "Command execution failed: {:?}",
1644            e
1645        ))),
1646    }
1647}
1648
1649/// Inspect a container by name. Returns `None` if it doesn't exist.
1650async fn inspect_container<Handler: HostHandler>(
1651    host_handler: &mut Handler,
1652    name: &ContainerName,
1653    privilege: &Privilege,
1654) -> Result<Option<Value>, RegentError> {
1655    let result = host_handler
1656        .run_command(
1657            &quoted_command(&["docker", "container", "inspect", name.as_str()]),
1658            privilege,
1659        )
1660        .await
1661        .map_err(|e| {
1662            RegentError::FailedDryRunEvaluation(format!(
1663                "Unable to inspect container {}: {:?}",
1664                name, e
1665            ))
1666        })?;
1667
1668    if result.return_code != 0 {
1669        let output = format!("{}{}", result.stdout, result.stderr);
1670        if output.contains("No such container") || output.contains("No such object") {
1671            return Ok(None);
1672        }
1673        return Err(RegentError::FailedDryRunEvaluation(format!(
1674            "Unable to inspect container {}: {:?}",
1675            name, result
1676        )));
1677    }
1678
1679    let inspect = parse_inspect_output(&result.stdout).ok_or_else(|| {
1680        RegentError::FailureToParseContent(format!(
1681            "Unexpected output of docker container inspect: {}",
1682            result.stdout
1683        ))
1684    })?;
1685
1686    // Inspecting by name also matches container IDs: make sure this is the right container
1687    if pointer(&inspect, "/Name").as_str() == Some(&format!("/{}", name)) {
1688        Ok(Some(inspect))
1689    } else {
1690        Ok(None)
1691    }
1692}
1693
1694/// Get the ID of a local image. Returns `None` if the image is not present.
1695async fn inspect_image_id<Handler: HostHandler>(
1696    host_handler: &mut Handler,
1697    image: &ContainerImage,
1698    privilege: &Privilege,
1699) -> Result<Option<String>, RegentError> {
1700    let result = host_handler
1701        .run_command(
1702            &quoted_command(&[
1703                "docker",
1704                "image",
1705                "inspect",
1706                "--format",
1707                "{{.Id}}",
1708                image.as_str(),
1709            ]),
1710            privilege,
1711        )
1712        .await
1713        .map_err(|e| {
1714            RegentError::FailedDryRunEvaluation(format!(
1715                "Unable to inspect image {}: {:?}",
1716                image, e
1717            ))
1718        })?;
1719
1720    if result.return_code != 0 {
1721        let output = format!("{}{}", result.stdout, result.stderr);
1722        if output.contains("No such image") || output.contains("No such object") {
1723            return Ok(None);
1724        }
1725        return Err(RegentError::FailedDryRunEvaluation(format!(
1726            "Unable to inspect image {}: {:?}",
1727            image, result
1728        )));
1729    }
1730
1731    // stderr is merged into stdout by the handlers, keep only the ID line
1732    match result
1733        .stdout
1734        .lines()
1735        .map(str::trim)
1736        .find(|line| line.starts_with("sha256:"))
1737    {
1738        Some(image_id) => Ok(Some(image_id.to_string())),
1739        None => Err(RegentError::FailureToParseContent(format!(
1740            "Unexpected output of docker image inspect: {}",
1741            result.stdout
1742        ))),
1743    }
1744}
1745
1746/// GPUs requested by the container (`all` for all of them)
1747fn current_gpus(inspect: &Value) -> BTreeSet<String> {
1748    let mut gpus = BTreeSet::new();
1749    for request in pointer(inspect, "/HostConfig/DeviceRequests")
1750        .as_array()
1751        .into_iter()
1752        .flatten()
1753    {
1754        let is_gpu_request = pointer(request, "/Capabilities")
1755            .as_array()
1756            .into_iter()
1757            .flatten()
1758            .any(|capabilities| string_list(capabilities).iter().any(|cap| cap == "gpu"));
1759        if !is_gpu_request {
1760            continue;
1761        }
1762        if pointer(request, "/Count").as_i64() == Some(-1) {
1763            gpus.insert("all".to_string());
1764        }
1765        gpus.extend(string_list(pointer(request, "/DeviceIDs")));
1766    }
1767    gpus
1768}
1769
1770fn current_port_bindings(inspect: &Value) -> BTreeSet<PortBinding> {
1771    let mut current = BTreeSet::new();
1772    if let Some(bindings) = pointer(inspect, "/HostConfig/PortBindings").as_object() {
1773        for (container_port, host_bindings) in bindings {
1774            for host_binding in host_bindings.as_array().into_iter().flatten() {
1775                current.insert(PortBinding {
1776                    container_port: container_port.clone(),
1777                    host_ip: pointer(host_binding, "/HostIp")
1778                        .as_str()
1779                        .unwrap_or_default()
1780                        .to_string(),
1781                    host_port: pointer(host_binding, "/HostPort")
1782                        .as_str()
1783                        .unwrap_or_default()
1784                        .to_string(),
1785                });
1786            }
1787        }
1788    }
1789    current
1790}
1791
1792#[cfg(test)]
1793mod tests {
1794    use super::*;
1795    use crate::secrets::SecretReference;
1796    use crate::state::attribute::container::container_spec::{GpuList, PrivateIpc};
1797
1798    fn full_yaml() -> &'static str {
1799        "---
1800Name: web
1801State: !Running
1802  Image: nginx:1.27
1803  Pull: Missing
1804  Entrypoint: /docker-entrypoint.sh
1805  Command: [nginx, -g, daemon off;]
1806  Env:
1807    TZ: Europe/Paris
1808    API_TOKEN:
1809      SecRef: web_api_token
1810      Provider: files
1811  Volumes:
1812    - /srv/www:/usr/share/nginx/html:ro
1813    - /var/cache/nginx
1814  Networking: !Attached
1815    Networks: [frontend, backend]
1816    Ports:
1817      - 8080:80
1818      - 127.0.0.1:8443:443/tcp
1819  RestartPolicy: UnlessStopped
1820  Hostname: web01
1821  User: '101:101'
1822  WorkingDir: /usr/share/nginx
1823  Labels:
1824    traefik.enable: 'true'
1825  Privileged: false
1826  ReadOnly: false
1827  Init: true
1828  CapAdd: [net_bind_service]
1829  CapDrop: [ALL]
1830  Memory: 512m
1831  Cpus: 1.5
1832  ExtraHosts: ['db.local:10.0.0.12']
1833  Dns: [1.1.1.1]
1834  Devices: ['/dev/snd']
1835  HealthCheck:
1836    Test: curl -f http://localhost || exit 1
1837    Interval: 30s
1838    Timeout: 5s
1839    StartPeriod: 10s
1840    StartInterval: 1s
1841    Retries: 3
1842  LogDriver: json-file
1843  LogOptions:
1844    max-size: 10m
1845"
1846    }
1847
1848    fn full_config() -> DockerContainerConfig {
1849        let state: DockerExpectedState = yaml_serde::from_str(full_yaml()).unwrap();
1850        match state.state {
1851            DockerContainerState::Running(config) => *config,
1852            other => panic!("unexpected state {:?}", other),
1853        }
1854    }
1855
1856    /// `docker container inspect` output of a container matching `full_yaml()`
1857    fn matching_inspect() -> Value {
1858        serde_json::json!({
1859            "Name": "/web",
1860            "Image": "sha256:abc",
1861            "State": { "Running": true, "Paused": false },
1862            "Config": {
1863                "Image": "nginx:1.27",
1864                "Cmd": ["nginx", "-g", "daemon off;"],
1865                "Entrypoint": ["/docker-entrypoint.sh"],
1866                "Env": ["TZ=Europe/Paris", "API_TOKEN=s3cr3t", "PATH=/usr/bin", "NGINX_VERSION=1.27"],
1867                "Hostname": "web01",
1868                "User": "101:101",
1869                "WorkingDir": "/usr/share/nginx",
1870                "Labels": { "traefik.enable": "true", "maintainer": "NGINX" },
1871                "Volumes": { "/var/cache/nginx": {} },
1872                "Healthcheck": {
1873                    "Test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
1874                    "Interval": 30000000000i64,
1875                    "Timeout": 5000000000i64,
1876                    "StartPeriod": 10000000000i64,
1877                    "StartInterval": 1000000000i64,
1878                    "Retries": 3
1879                }
1880            },
1881            "HostConfig": {
1882                "Binds": ["/srv/www:/usr/share/nginx/html:ro"],
1883                "NetworkMode": "frontend",
1884                "PortBindings": {
1885                    "80/tcp": [{ "HostIp": "", "HostPort": "8080" }],
1886                    "443/tcp": [{ "HostIp": "127.0.0.1", "HostPort": "8443" }]
1887                },
1888                "RestartPolicy": { "Name": "unless-stopped", "MaximumRetryCount": 0 },
1889                "Privileged": false,
1890                "ReadonlyRootfs": false,
1891                "Init": true,
1892                "CapAdd": ["CAP_NET_BIND_SERVICE"],
1893                "CapDrop": ["ALL"],
1894                "Memory": 536870912,
1895                "NanoCpus": 1500000000i64,
1896                "ExtraHosts": ["db.local:10.0.0.12"],
1897                "Dns": ["1.1.1.1"],
1898                "Devices": [{ "PathOnHost": "/dev/snd", "PathInContainer": "/dev/snd", "CgroupPermissions": "rwm" }],
1899                "LogConfig": { "Type": "json-file", "Config": { "max-size": "10m", "max-file": "1" } }
1900            },
1901            "NetworkSettings": { "Networks": { "frontend": {}, "backend": {} } }
1902        })
1903    }
1904
1905    fn resolved_env() -> Vec<(String, String)> {
1906        vec![
1907            ("API_TOKEN".to_string(), "s3cr3t".to_string()),
1908            ("TZ".to_string(), "Europe/Paris".to_string()),
1909        ]
1910    }
1911
1912    #[test]
1913    fn parsing_docker_module_block_from_yaml_str() {
1914        let config = full_config();
1915        assert_eq!(
1916            config
1917                .env
1918                .as_ref()
1919                .unwrap()
1920                .get(&"API_TOKEN".parse().unwrap()),
1921            Some(&Parameter::Secret(SecretReference::from(
1922                "web_api_token",
1923                Some("files".to_string())
1924            )))
1925        );
1926
1927        let raw = "---
1928- Name: legacy
1929  State: Absent
1930- Name: worker
1931  State: !Stopped
1932    Image: busybox
1933    Networking: Host
1934    RestartPolicy: !OnFailure 3
1935";
1936        let states: Vec<DockerExpectedState> = yaml_serde::from_str(raw).unwrap();
1937        assert_eq!(states[0].state, DockerContainerState::Absent);
1938        match &states[1].state {
1939            DockerContainerState::Stopped(config) => {
1940                assert_eq!(config.networking, Some(DockerNetworking::Host));
1941                assert_eq!(config.restart_policy, Some(RestartPolicy::OnFailure(3)));
1942            }
1943            other => panic!("unexpected state {:?}", other),
1944        }
1945    }
1946
1947    #[test]
1948    fn serialization_round_trip_preserves_state() {
1949        // Templating (Attribute::consider_context) relies on a JSON round trip
1950        let state: DockerExpectedState = yaml_serde::from_str(full_yaml()).unwrap();
1951        let json = serde_json::to_string(&state).unwrap();
1952        assert_eq!(
1953            serde_json::from_str::<DockerExpectedState>(&json).unwrap(),
1954            state
1955        );
1956    }
1957
1958    #[test]
1959    fn invalid_states_are_rejected_while_parsing() {
1960        let rejected = [
1961            // Configuration makes no sense for an absent container
1962            "Name: old\nState: !Absent\n  User: root\n",
1963            // Image is mandatory for a container which must exist
1964            "Name: web\nState: !Running\n  User: root\n",
1965            // Unknown field
1966            "Name: web\nState: !Running\n  Image: nginx\n  Port: 80:80\n",
1967            // Ports can't be published with host networking
1968            "Name: web\nState: !Running\n  Image: nginx\n  Networking: !Host\n    Ports: ['80:80']\n",
1969            // host is a networking mode, not a network to attach to
1970            "Name: web\nState: !Running\n  Image: nginx\n  Networking: !Attached\n    Networks: [host, frontend]\n",
1971            "Name: web\nState: !Running\n  Image: nginx\n  Networking: !Attached\n    Networks: []\n",
1972            "Name: /web\nState: Absent\n",
1973            "Name: web\nState: !Running\n  Image: ''\n",
1974            "Name: web\nState: !Running\n  Image: nginx\n  Command: []\n",
1975            "Name: web\nState: !Running\n  Image: nginx\n  Memory: lots\n",
1976            "Name: web\nState: !Running\n  Image: nginx\n  Cpus: 0\n",
1977            "Name: web\nState: !Running\n  Image: nginx\n  Volumes: [relative/path]\n",
1978            "Name: web\nState: !Running\n  Image: nginx\n  Env: {'A=B': c}\n",
1979            "Name: web\nState: !Running\n  Image: nginx\n  ExtraHosts: ['db.local:not-an-ip']\n",
1980            "Name: web\nState: !Running\n  Image: nginx\n  Devices: ['/dev/snd:/dev/snd:x']\n",
1981            "Name: web\nState: !Running\n  Image: nginx\n  Dns: [dns.example.com]\n",
1982            // /dev/shm size can only be set on a private IPC namespace
1983            "Name: web\nState: !Running\n  Image: nginx\n  Ipc: !Host\n    ShmSize: 1g\n",
1984            "Name: web\nState: !Running\n  Image: nginx\n  Gpus: !Devices []\n",
1985            "Name: web\nState: !Running\n  Image: nginx\n  Gpus: !Devices [gpu0]\n",
1986            // The health check test is mandatory and never empty
1987            "Name: web\nState: !Running\n  Image: nginx\n  HealthCheck:\n    Interval: 30s\n",
1988            "Name: web\nState: !Running\n  Image: nginx\n  HealthCheck:\n    Test: ''\n",
1989            // Health check durations follow the Go syntax and are strictly positive
1990            "Name: web\nState: !Running\n  Image: nginx\n  HealthCheck:\n    Test: curl\n    Interval: 30\n",
1991            "Name: web\nState: !Running\n  Image: nginx\n  HealthCheck:\n    Test: curl\n    Interval: lots\n",
1992            "Name: web\nState: !Running\n  Image: nginx\n  HealthCheck:\n    Test: curl\n    StartPeriod: 0s\n",
1993            "Name: web\nState: !Running\n  Image: nginx\n  HealthCheck:\n    Test: curl\n    Retries: -1\n",
1994            "Name: web\nState: !Running\n  Image: nginx\n  HealthCheck:\n    Test: curl\n    Unknown: x\n",
1995        ];
1996        for raw in rejected {
1997            assert!(
1998                yaml_serde::from_str::<DockerExpectedState>(raw).is_err(),
1999                "should be rejected: {}",
2000                raw
2001            );
2002        }
2003    }
2004
2005    #[test]
2006    fn matching_container_needs_no_recreation() {
2007        let config = full_config();
2008        let reasons = config.recreation_reasons(&matching_inspect(), &resolved_env());
2009        assert!(reasons.is_empty(), "{:?}", reasons);
2010        assert!(config.is_restart_policy_compliant(&matching_inspect()));
2011    }
2012
2013    #[test]
2014    fn drifted_container_needs_recreation() {
2015        let config = full_config();
2016        let mut inspect = matching_inspect();
2017        inspect["HostConfig"]["PortBindings"]["80/tcp"][0]["HostPort"] = "9090".into();
2018        inspect["Config"]["Env"][1] = "API_TOKEN=old_secret".into();
2019        inspect["NetworkSettings"]["Networks"] = serde_json::json!({ "bridge": {} });
2020
2021        let reasons = config.recreation_reasons(&inspect, &resolved_env());
2022        assert_eq!(reasons.len(), 3, "{:?}", reasons);
2023        // Secret values must never leak in remediation messages
2024        assert!(reasons.iter().all(|reason| !reason.contains("secret")));
2025    }
2026
2027    #[test]
2028    fn network_mode_drift_is_detected() {
2029        let mut inspect = matching_inspect();
2030        inspect["HostConfig"]["NetworkMode"] = "host".into();
2031
2032        let attached = DockerContainerConfig::new("nginx:1.27".parse().unwrap()).with_networking(
2033            DockerNetworking::attached(None, Some(vec!["8080:80".parse().unwrap()])),
2034        );
2035        let reasons = attached.recreation_reasons(&inspect, &[]);
2036        assert!(
2037            reasons
2038                .iter()
2039                .any(|reason| reason.starts_with("network mode is host")),
2040            "{:?}",
2041            reasons
2042        );
2043
2044        let host = DockerContainerConfig::new("nginx:1.27".parse().unwrap())
2045            .with_networking(DockerNetworking::Host);
2046        assert!(host.recreation_reasons(&inspect, &[]).is_empty());
2047    }
2048
2049    #[test]
2050    fn restart_policy_drift_is_detected() {
2051        let config = DockerContainerConfig::new("nginx".parse().unwrap())
2052            .with_restart_policy(RestartPolicy::OnFailure(5));
2053        assert!(!config.is_restart_policy_compliant(&matching_inspect()));
2054
2055        let config = DockerContainerConfig::new("nginx".parse().unwrap())
2056            .with_restart_policy(RestartPolicy::No);
2057        let inspect = serde_json::json!({ "HostConfig": { "RestartPolicy": { "Name": "", "MaximumRetryCount": 0 } } });
2058        assert!(config.is_restart_policy_compliant(&inspect));
2059    }
2060
2061    #[test]
2062    fn gpu_inference_server() {
2063        let raw = "---
2064Name: vllm
2065State: !Running
2066  Image: vllm/vllm-openai:v0.10.1
2067  Command: [--model, Qwen/Qwen3-8B]
2068  Gpus: !Devices [0, GPU-4b2c7f1e-9a3d-4e6f-8b1a-2c3d4e5f6a7b]
2069  Ipc: !Private
2070    ShmSize: 16g
2071";
2072        let state: DockerExpectedState = yaml_serde::from_str(raw).unwrap();
2073        let json = serde_json::to_string(&state).unwrap();
2074        assert_eq!(
2075            serde_json::from_str::<DockerExpectedState>(&json).unwrap(),
2076            state
2077        );
2078        let DockerContainerState::Running(config) = state.state else {
2079            panic!("unexpected state");
2080        };
2081        let args = config.create_args(&"vllm".parse().unwrap(), &[]);
2082        assert_eq!(
2083            args[4..10],
2084            to_strings(&[
2085                "--gpus",
2086                "\"device=0,GPU-4b2c7f1e-9a3d-4e6f-8b1a-2c3d4e5f6a7b\"",
2087                "--ipc",
2088                "private",
2089                "--shm-size",
2090                "16g",
2091            ])
2092        );
2093
2094        let inspect = serde_json::json!({
2095            "Config": { "Image": "vllm/vllm-openai:v0.10.1", "Cmd": ["--model", "Qwen/Qwen3-8B"] },
2096            "HostConfig": {
2097                "IpcMode": "private",
2098                "ShmSize": 17179869184i64,
2099                "DeviceRequests": [{
2100                    "Driver": "",
2101                    "Count": 0,
2102                    "DeviceIDs": ["0", "GPU-4b2c7f1e-9a3d-4e6f-8b1a-2c3d4e5f6a7b"],
2103                    "Capabilities": [["gpu"]],
2104                }]
2105            }
2106        });
2107        assert!(config.recreation_reasons(&inspect, &[]).is_empty());
2108
2109        let all_gpus = DockerContainerConfig::new("vllm/vllm-openai:v0.10.1".parse().unwrap())
2110            .with_gpus(GpuSelection::All)
2111            .with_ipc(IpcNamespace::Host);
2112        let reasons = all_gpus.recreation_reasons(&inspect, &[]);
2113        assert_eq!(reasons.len(), 2, "{:?}", reasons);
2114
2115        let mut inspect = inspect;
2116        inspect["HostConfig"]["IpcMode"] = "host".into();
2117        inspect["HostConfig"]["DeviceRequests"][0]["Count"] = (-1).into();
2118        inspect["HostConfig"]["DeviceRequests"][0]["DeviceIDs"] = Value::Null;
2119        assert!(all_gpus.recreation_reasons(&inspect, &[]).is_empty());
2120        assert_eq!(
2121            all_gpus.create_args(&"vllm".parse().unwrap(), &[])[4..8],
2122            to_strings(&["--gpus", "all", "--ipc", "host"])
2123        );
2124    }
2125
2126    #[test]
2127    fn create_args_follow_docker_cli() {
2128        let config = DockerContainerConfig::new("nginx:1.27".parse().unwrap())
2129            .with_networking(DockerNetworking::attached(
2130                Some(
2131                    NetworkList::try_from(vec![
2132                        "frontend".parse().unwrap(),
2133                        "backend".parse().unwrap(),
2134                    ])
2135                    .unwrap(),
2136                ),
2137                Some(vec!["8080:80".parse().unwrap()]),
2138            ))
2139            .with_env(
2140                "TZ".parse().unwrap(),
2141                Parameter::Clear("Europe/Paris".to_string()),
2142            )
2143            .with_init(true)
2144            .with_restart_policy(RestartPolicy::OnFailure(3))
2145            .with_health_check(
2146                HealthCheck::new("curl -f http://localhost || exit 1".parse().unwrap())
2147                    .with_interval("30s".parse().unwrap())
2148                    .with_retries(3),
2149            )
2150            .with_command(ContainerCommand::new(&["nginx", "-g", "daemon off;"]).unwrap());
2151        let env = vec![("TZ".to_string(), "Europe/Paris".to_string())];
2152        assert_eq!(
2153            config.create_args(&"web".parse().unwrap(), &env),
2154            to_strings(&[
2155                "docker",
2156                "create",
2157                "--name",
2158                "web",
2159                "--env",
2160                "TZ=Europe/Paris",
2161                "--network",
2162                "frontend",
2163                "--publish",
2164                "8080:80",
2165                "--restart",
2166                "on-failure:3",
2167                "--health-cmd",
2168                "curl -f http://localhost || exit 1",
2169                "--health-interval",
2170                "30s",
2171                "--health-retries",
2172                "3",
2173                "--init",
2174                "nginx:1.27",
2175                "nginx",
2176                "-g",
2177                "daemon off;",
2178            ])
2179        );
2180        assert_eq!(
2181            config
2182                .additional_networks()
2183                .iter()
2184                .map(NetworkName::as_str)
2185                .collect::<Vec<_>>(),
2186            vec!["backend"]
2187        );
2188    }
2189
2190    #[test]
2191    fn health_check_durations_follow_go_syntax() {
2192        let nanos = |spec: &str| {
2193            spec.parse::<HealthDuration>()
2194                .map(|duration| duration.nanos())
2195        };
2196        assert_eq!(nanos("30s").unwrap(), 30_000_000_000);
2197        assert_eq!(nanos("1m30s").unwrap(), 90_000_000_000);
2198        assert_eq!(nanos("500ms").unwrap(), 500_000_000);
2199        assert_eq!(nanos("1.5s").unwrap(), 1_500_000_000);
2200        assert_eq!(nanos("2h").unwrap(), 7_200_000_000_000);
2201        assert_eq!(nanos("100us").unwrap(), 100_000);
2202        assert_eq!("30s".parse::<HealthDuration>().unwrap().to_string(), "30s");
2203        for rejected in ["", "30", "lots", "0s", "-5s", "1x", "s", "1.2.3s"] {
2204            assert!(
2205                nanos(rejected).is_err(),
2206                "should be rejected: {:?}",
2207                rejected
2208            );
2209        }
2210
2211        assert!("".parse::<HealthCheckTest>().is_err());
2212        assert!("  ".parse::<HealthCheckTest>().is_err());
2213        assert_eq!(
2214            "curl -f http://localhost || exit 1"
2215                .parse::<HealthCheckTest>()
2216                .unwrap()
2217                .to_string(),
2218            "curl -f http://localhost || exit 1"
2219        );
2220    }
2221
2222    #[test]
2223    fn health_check_drift_is_detected() {
2224        let config = DockerContainerConfig::new("nginx".parse().unwrap()).with_health_check(
2225            HealthCheck::new("curl -f http://localhost || exit 1".parse().unwrap())
2226                .with_interval("30s".parse().unwrap())
2227                .with_timeout("5s".parse().unwrap())
2228                .with_start_period("10m".parse().unwrap())
2229                .with_retries(5),
2230        );
2231
2232        // No health check on the container: every managed field drifts
2233        let inspect = serde_json::json!({ "Config": { "Image": "nginx" } });
2234        let reasons = config.recreation_reasons(&inspect, &[]);
2235        assert_eq!(reasons.len(), 5, "{:?}", reasons);
2236
2237        // Matching health check
2238        let inspect = serde_json::json!({
2239            "Config": {
2240                "Image": "nginx",
2241                "Healthcheck": {
2242                    "Test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
2243                    "Interval": 30000000000i64,
2244                    "Timeout": 5000000000i64,
2245                    "StartPeriod": 600000000000i64,
2246                    "Retries": 5
2247                }
2248            }
2249        });
2250        assert!(config.recreation_reasons(&inspect, &[]).is_empty());
2251
2252        // Drifted retries
2253        let mut inspect = inspect;
2254        inspect["Config"]["Healthcheck"]["Retries"] = 3.into();
2255        let reasons = config.recreation_reasons(&inspect, &[]);
2256        assert_eq!(reasons.len(), 1, "{:?}", reasons);
2257        assert!(reasons[0].contains("retries"), "{:?}", reasons);
2258
2259        // Only the fields set are compared (the daemon defaults are not managed)
2260        let minimal = DockerContainerConfig::new("nginx".parse().unwrap()).with_health_check(
2261            HealthCheck::new("curl -f http://localhost || exit 1".parse().unwrap()),
2262        );
2263        assert!(minimal.recreation_reasons(&inspect, &[]).is_empty());
2264    }
2265
2266    #[test]
2267    fn choice_methods_match_the_explicit_enums() {
2268        let new = || DockerContainerConfig::new("nginx:1.27".parse().unwrap());
2269
2270        assert_eq!(
2271            new().with_pull_never(),
2272            new().with_pull_policy(PullPolicy::Never)
2273        );
2274        assert_eq!(
2275            new().with_pull_never().with_pull_if_missing(),
2276            new().with_pull_policy(PullPolicy::Missing)
2277        );
2278
2279        for (config, policy) in [
2280            (new().with_restart_never(), RestartPolicy::No),
2281            (new().with_restart_always(), RestartPolicy::Always),
2282            (
2283                new().with_restart_unless_stopped(),
2284                RestartPolicy::UnlessStopped,
2285            ),
2286            (
2287                new().with_restart_on_failure(3),
2288                RestartPolicy::OnFailure(3),
2289            ),
2290        ] {
2291            assert_eq!(config, new().with_restart_policy(policy));
2292        }
2293
2294        assert_eq!(
2295            new().with_host_networking(),
2296            new().with_networking(DockerNetworking::Host)
2297        );
2298        assert_eq!(
2299            new().with_isolated_networking(),
2300            new().with_networking(DockerNetworking::Isolated)
2301        );
2302        let attached = DockerNetworking::attached(
2303            Some(
2304                NetworkList::try_from(vec![
2305                    "frontend".parse().unwrap(),
2306                    "backend".parse().unwrap(),
2307                ])
2308                .unwrap(),
2309            ),
2310            Some(vec![
2311                "8080:80".parse().unwrap(),
2312                "127.0.0.1:8443:443".parse().unwrap(),
2313            ]),
2314        );
2315        assert_eq!(
2316            new()
2317                .with_network("frontend".parse().unwrap())
2318                .with_published_port("8080:80".parse().unwrap())
2319                .with_network("backend".parse().unwrap())
2320                .with_published_port("127.0.0.1:8443:443".parse().unwrap()),
2321            new().with_networking(attached.clone())
2322        );
2323        // Attaching a network or publishing a port replaces Host or Isolated networking...
2324        assert_eq!(
2325            new()
2326                .with_host_networking()
2327                .with_published_port("8080:80".parse().unwrap()),
2328            new().with_networking(DockerNetworking::attached(
2329                None,
2330                Some(vec!["8080:80".parse().unwrap()])
2331            ))
2332        );
2333        assert_eq!(
2334            new()
2335                .with_isolated_networking()
2336                .with_network("frontend".parse().unwrap()),
2337            new().with_networking(DockerNetworking::attached(
2338                Some(NetworkList::try_from(vec!["frontend".parse().unwrap()]).unwrap()),
2339                None
2340            ))
2341        );
2342        // ...and Host or Isolated networking replaces the networks and ports
2343        assert_eq!(
2344            new().with_networking(attached).with_host_networking(),
2345            new().with_networking(DockerNetworking::Host)
2346        );
2347
2348        assert_eq!(new().with_all_gpus(), new().with_gpus(GpuSelection::All));
2349        let devices = GpuSelection::Devices(
2350            GpuList::try_from(vec!["0".parse().unwrap(), "1".parse().unwrap()]).unwrap(),
2351        );
2352        assert_eq!(
2353            new()
2354                .with_gpu_device("0".parse().unwrap())
2355                .with_gpu_device("1".parse().unwrap()),
2356            new().with_gpus(devices.clone())
2357        );
2358        assert_eq!(
2359            new()
2360                .with_all_gpus()
2361                .with_gpu_device("0".parse().unwrap())
2362                .with_gpu_device("1".parse().unwrap()),
2363            new().with_gpus(devices)
2364        );
2365        assert_eq!(
2366            new().with_gpu_device("0".parse().unwrap()).with_all_gpus(),
2367            new().with_gpus(GpuSelection::All)
2368        );
2369
2370        let shm_size = || "16g".parse::<MemorySize>().unwrap();
2371        let own = || PrivateIpc::new().with_shm_size(shm_size());
2372        assert_eq!(new().with_host_ipc(), new().with_ipc(IpcNamespace::Host));
2373        assert_eq!(
2374            new().with_private_ipc(),
2375            new().with_ipc(IpcNamespace::Private(PrivateIpc::new()))
2376        );
2377        assert_eq!(
2378            new().with_shareable_ipc(),
2379            new().with_ipc(IpcNamespace::Shareable(PrivateIpc::new()))
2380        );
2381        assert_eq!(
2382            new().with_shm_size(shm_size()),
2383            new().with_ipc(IpcNamespace::Private(own()))
2384        );
2385        // The /dev/shm size replaces the host namespace, and is kept between own namespaces
2386        assert_eq!(
2387            new().with_host_ipc().with_shm_size(shm_size()),
2388            new().with_ipc(IpcNamespace::Private(own()))
2389        );
2390        assert_eq!(
2391            new().with_shareable_ipc().with_shm_size(shm_size()),
2392            new().with_ipc(IpcNamespace::Shareable(own()))
2393        );
2394        assert_eq!(
2395            new().with_shm_size(shm_size()).with_shareable_ipc(),
2396            new().with_ipc(IpcNamespace::Shareable(own()))
2397        );
2398        assert_eq!(
2399            new().with_shm_size(shm_size()).with_host_ipc(),
2400            new().with_ipc(IpcNamespace::Host)
2401        );
2402    }
2403}