airlock/
driver.rs

1use std::{
2    collections::HashMap,
3    fmt,
4    path::{Path, PathBuf},
5    time::{Duration, Instant},
6};
7
8use bollard::{
9    container::{LogOutput, NetworkingConfig as ContainerNetworkingConfig},
10    errors::Error,
11    exec::{CreateExecOptions, StartExecResults},
12    models::{
13        ContainerCreateBody, ContainerStateStatusEnum, EndpointSettings, HealthConfig, HealthStatusEnum, HostConfig,
14        HostConfigCgroupnsModeEnum, Ipam, NetworkConnectRequest, NetworkCreateRequest, VolumeCreateRequest,
15    },
16    query_parameters::{CreateContainerOptionsBuilder, CreateImageOptions, ListContainersOptionsBuilder, LogsOptions},
17    Docker,
18};
19use futures::{StreamExt as _, TryStreamExt as _};
20use saluki_error::{generic_error, ErrorContext as _, GenericError};
21use tokio::{
22    io::{AsyncWriteExt as _, BufWriter},
23    time::sleep,
24};
25use tracing::{debug, error, trace};
26
27use crate::config::{DatadogIntakeConfig, MillstoneConfig, TargetConfig};
28
29const MILLSTONE_CONFIG_PATH_INTERNAL: &str = "/etc/millstone/config.toml";
30
31/// Default image for the shared-volume permission fix-up container.
32pub const DEFAULT_ALPINE_IMAGE: &str = "alpine:latest";
33const DATADOG_INTAKE_HEALTHCHECK_INTERVAL: Duration = Duration::from_secs(1);
34const DATADOG_INTAKE_HEALTHCHECK_TIMEOUT: Duration = Duration::from_secs(1);
35const DATADOG_INTAKE_HEALTHCHECK_RETRIES: i64 = 30;
36const DATADOG_INTAKE_HEALTHCHECK_START_PERIOD: Duration = Duration::from_secs(1);
37const DATADOG_INTAKE_HEALTHCHECK_START_INTERVAL: Duration = Duration::from_secs(1);
38const DATADOG_INTAKE_HEALTHCHECK_COMMAND: &str = concat!(
39    "exec 3<>/dev/tcp/127.0.0.1/2049 && ",
40    "printf 'GET /ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && ",
41    "grep -q '200 OK' <&3"
42);
43
44pub enum ExitStatus {
45    Success,
46    Failed { code: i64, error: String },
47}
48
49impl fmt::Display for ExitStatus {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            ExitStatus::Success => write!(f, "success (0)"),
53            ExitStatus::Failed { code, error } => write!(f, "failed (exit code: {}, error: {})", code, error),
54        }
55    }
56}
57
58/// Container operating system for a driver target.
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum ContainerOs {
61    /// Linux container defaults.
62    Linux,
63    /// Windows container defaults.
64    Windows,
65}
66
67/// Driver configuration.
68///
69/// This is the basic set of configuration options needed to spawn the container for a given driver.
70#[derive(Clone)]
71pub struct DriverConfig {
72    driver_id: &'static str,
73    image: String,
74    entrypoint: Option<Vec<String>>,
75    command: Option<Vec<String>>,
76    env: Vec<String>,
77    binds: Vec<String>,
78    healthcheck: Option<HealthConfig>,
79    exposed_ports: Vec<(&'static str, u16)>,
80    container_os: ContainerOs,
81    host_cgroup_namespace: bool,
82    /// Additional named Docker volume mounts, in `volume_name:/container/path` format.
83    ///
84    /// Unlike bind mounts specified via [`with_bind_mount`][Self::with_bind_mount], these reference
85    /// existing named Docker volumes rather than host filesystem paths. Used to mount volumes that
86    /// belong to other isolation groups (for example, a shared millstone mounting both the baseline
87    /// and comparison agent volumes).
88    additional_volume_mounts: Vec<String>,
89
90    /// DNS aliases for this container on its primary network.
91    ///
92    /// Set via `NetworkingConfig.EndpointsConfig` at container creation time. Other containers on
93    /// the same network can reach this container using any of these aliases in addition to its
94    /// hostname. Used to give agent containers unambiguous names (for example, `"baseline"`, `"comparison"`)
95    /// that the shared millstone can use to address each one independently.
96    network_aliases: Vec<String>,
97
98    /// Additional Docker networks to connect this container to after creation.
99    ///
100    /// The primary network is set via `HostConfig.NetworkMode`. Each network listed here is joined
101    /// via a separate `docker network connect` call after the container is created but before it's
102    /// started. Used to connect the shared millstone container to both agent networks so it can
103    /// reach `baseline` and `comparison` by hostname.
104    additional_networks: Vec<String>,
105
106    /// Image used for the shared-volume permission fix-up container.
107    ///
108    /// Defaults to [`DEFAULT_ALPINE_IMAGE`].
109    alpine_image: String,
110}
111
112impl DriverConfig {
113    pub async fn millstone(config: MillstoneConfig) -> Result<Self, GenericError> {
114        // Ensure the given configuration file path actually exists.
115        match tokio::fs::metadata(&config.config_path).await {
116            Ok(metadata) if metadata.is_file() => {}
117            Ok(_) => {
118                return Err(generic_error!(
119                    "Specified millstone configuration path ({}) does not point to a file.",
120                    config.config_path.display()
121                ))
122            }
123            Err(e) => {
124                return Err(generic_error!(
125                    "Failed to ensure specified millstone configuration ({}) exists locally: {}",
126                    config.config_path.display(),
127                    e
128                ))
129            }
130        }
131
132        let millstone_binary_path = config
133            .binary_path
134            .unwrap_or_else(|| "/usr/local/bin/millstone".to_string());
135        let entrypoint = vec![millstone_binary_path, MILLSTONE_CONFIG_PATH_INTERNAL.to_string()];
136
137        let driver_config = Self::from_image("millstone", config.image)
138            .with_entrypoint(entrypoint)
139            .with_bind_mount(config.config_path, MILLSTONE_CONFIG_PATH_INTERNAL);
140
141        Ok(driver_config)
142    }
143
144    pub async fn datadog_intake(config: DatadogIntakeConfig) -> Result<Self, GenericError> {
145        let datadog_intake_binary_path = config
146            .binary_path
147            .unwrap_or_else(|| "/usr/local/bin/datadog-intake".to_string());
148        let entrypoint = vec![datadog_intake_binary_path];
149
150        let driver_config = DriverConfig::from_image("datadog-intake", config.image)
151            .with_entrypoint(entrypoint)
152            .with_healthcheck(
153                vec![
154                    "/bin/bash".to_string(),
155                    "-c".to_string(),
156                    DATADOG_INTAKE_HEALTHCHECK_COMMAND.to_string(),
157                ],
158                DATADOG_INTAKE_HEALTHCHECK_INTERVAL,
159                DATADOG_INTAKE_HEALTHCHECK_TIMEOUT,
160                DATADOG_INTAKE_HEALTHCHECK_RETRIES,
161                DATADOG_INTAKE_HEALTHCHECK_START_PERIOD,
162                DATADOG_INTAKE_HEALTHCHECK_START_INTERVAL,
163            )
164            // Map our intake port to an ephemeral port on the host side, which we'll query once the container has been
165            // started so that we can connect to it.
166            .with_exposed_port("tcp", 2049);
167
168        Ok(driver_config)
169    }
170
171    pub async fn target(target_id: &'static str, config: TargetConfig) -> Result<Self, GenericError> {
172        let driver_config = DriverConfig::from_image(target_id, config.image)
173            .with_entrypoint(config.entrypoint)
174            .with_command(config.command)
175            .with_env_vars(config.additional_env_vars)
176            .with_container_os(config.container_os)
177            .with_host_cgroup_namespace(config.host_cgroup_namespace);
178
179        Ok(driver_config)
180    }
181
182    /// Creates a new `DriverConfig` from the given driver identifier and container image reference.
183    pub fn from_image(driver_id: &'static str, image: String) -> Self {
184        Self {
185            driver_id,
186            image,
187            entrypoint: None,
188            command: None,
189            env: vec![],
190            binds: vec![],
191            healthcheck: None,
192            exposed_ports: vec![],
193            container_os: ContainerOs::Linux,
194            host_cgroup_namespace: false,
195            additional_volume_mounts: vec![],
196            network_aliases: vec![],
197            additional_networks: vec![],
198            alpine_image: DEFAULT_ALPINE_IMAGE.to_string(),
199        }
200    }
201
202    /// Sets the image used for the shared-volume permission fix-up container.
203    pub fn with_alpine_image(mut self, alpine_image: impl Into<String>) -> Self {
204        self.alpine_image = alpine_image.into();
205        self
206    }
207
208    /// Sets the entrypoint for the container.
209    ///
210    /// If `entrypoint` is empty, the default entrypoint will be used.
211    pub fn with_entrypoint(mut self, entrypoint: Vec<String>) -> Self {
212        if !entrypoint.is_empty() {
213            self.entrypoint = Some(entrypoint);
214        }
215        self
216    }
217
218    /// Sets the command for the container.
219    ///
220    /// If `command` is empty, the default command will be used.
221    pub fn with_command(mut self, command: Vec<String>) -> Self {
222        if !command.is_empty() {
223            self.command = Some(command);
224        }
225        self
226    }
227
228    /// Adds an environment variable to the container.
229    pub fn with_env_var<K, V>(mut self, key: K, value: V) -> Self
230    where
231        K: AsRef<str>,
232        V: AsRef<str>,
233    {
234        self.env.push(format!("{}={}", key.as_ref(), value.as_ref()));
235        self
236    }
237
238    /// Adds environment variables to the container.
239    pub fn with_env_vars(mut self, env: Vec<String>) -> Self {
240        self.env.extend(env);
241        self
242    }
243
244    /// Adds a bind mount to the container.
245    ///
246    /// `host_path` represents the path on the host to mount, while `container_path` represents the path on the
247    /// container side to mount it to. Bind mounts can be either files or directories.
248    pub fn with_bind_mount<HP, CP>(mut self, host_path: HP, container_path: CP) -> Self
249    where
250        HP: AsRef<Path>,
251        CP: AsRef<Path>,
252    {
253        let bind_mount = format!("{}:{}", host_path.as_ref().display(), container_path.as_ref().display());
254        self.binds.push(bind_mount);
255        self
256    }
257
258    /// Adds a read-only bind mount to the container.
259    ///
260    /// Same as [`with_bind_mount`][Self::with_bind_mount] but the container can't modify the mounted path.
261    pub fn with_readonly_bind_mount<HP, CP>(mut self, host_path: HP, container_path: CP) -> Self
262    where
263        HP: AsRef<Path>,
264        CP: AsRef<Path>,
265    {
266        let bind_mount = format!(
267            "{}:{}:ro",
268            host_path.as_ref().display(),
269            container_path.as_ref().display()
270        );
271        self.binds.push(bind_mount);
272        self
273    }
274
275    /// Sets the healthcheck for the container.
276    pub fn with_healthcheck(
277        mut self, mut test_command: Vec<String>, interval: Duration, timeout: Duration, retries: i64,
278        start_period: Duration, start_interval: Duration,
279    ) -> Self {
280        // We manually insert "CMD" as the first value in the command array, so that it doesn't have to be done by the
281        // caller, since it's some goofy ass syntax to have to know about.
282        test_command.insert(0, "CMD".to_string());
283
284        self.healthcheck = Some(HealthConfig {
285            test: Some(test_command),
286            interval: Some(interval.as_nanos() as i64),
287            timeout: Some(timeout.as_nanos() as i64),
288            retries: Some(retries),
289            start_period: Some(start_period.as_nanos() as i64),
290            start_interval: Some(start_interval.as_nanos() as i64),
291        });
292        self
293    }
294
295    /// Adds a DNS alias for this container on its primary network.
296    ///
297    /// Other containers on the same network can resolve this container by `alias` in addition to
298    /// its hostname. Call this before the container is started.
299    pub fn with_network_alias(mut self, alias: impl Into<String>) -> Self {
300        self.network_aliases.push(alias.into());
301        self
302    }
303
304    /// Connects this container to an additional Docker network after creation.
305    ///
306    /// The primary network is always the container's isolation group network. Each network added
307    /// here is joined via `docker network connect` after the container is created but before it
308    /// is started, so the container is reachable on all listed networks from the moment it runs.
309    pub fn with_network(mut self, network: impl Into<String>) -> Self {
310        self.additional_networks.push(network.into());
311        self
312    }
313
314    /// Mounts a named Docker volume into the container at the given path.
315    ///
316    /// Unlike [`with_bind_mount`][Self::with_bind_mount], this references a named Docker volume
317    /// rather than a host filesystem path. The volume must already exist when the container starts.
318    /// This is useful for mounting volumes that belong to other isolation groups: for example,
319    /// a shared millstone container that needs to reach the DogStatsD sockets of both the baseline
320    /// and comparison agent containers.
321    pub fn with_volume_mount(mut self, volume_name: impl Into<String>, container_path: impl AsRef<Path>) -> Self {
322        self.additional_volume_mounts
323            .push(format!("{}:{}", volume_name.into(), container_path.as_ref().display()));
324        self
325    }
326
327    /// Adds an exposed port to the container.
328    ///
329    /// The `protocol` should be either `tcp` or `udp`. Linux containers publish exposed ports to ephemeral host ports,
330    /// which are returned in [`DriverDetails`] after starting the driver. Windows containers keep exposed ports internal
331    /// to the container network because Panoramic probes them from inside the container or via the container IP.
332    pub fn with_exposed_port(mut self, protocol: &'static str, internal_port: u16) -> Self {
333        self.exposed_ports.push((protocol, internal_port));
334        self
335    }
336
337    /// Sets the operating system this container will run as.
338    ///
339    /// The OS choice drives several non-portable defaults (network driver, default binds, host
340    /// resources to share, container path conventions) that the rest of the driver applies
341    /// automatically through the helpers below. Callers should set this before any binds or
342    /// health checks are added so OS-specific defaults are appended consistently.
343    pub fn with_container_os(mut self, container_os: ContainerOs) -> Self {
344        self.container_os = container_os;
345        self
346    }
347
348    /// Configures whether a Linux target joins the Docker host's cgroup namespace.
349    pub fn with_host_cgroup_namespace(mut self, host_cgroup_namespace: bool) -> Self {
350        self.host_cgroup_namespace = host_cgroup_namespace;
351        self
352    }
353
354    /// Whether the shared `/airlock` volume needs a one-shot world-writable chmod fix-up.
355    ///
356    /// Linux Docker volumes default to root-owned with restrictive permissions, so containers
357    /// running as non-root users (the Datadog Agent image, in particular) cannot write to
358    /// `/airlock` without an out-of-band chmod. We do that fix-up by spawning a short-lived
359    /// Alpine container that owns the volume mount and runs `chmod -R 777 /airlock`. Windows
360    /// containers do not have the same UID/permission model and the fix-up is unnecessary
361    /// (and unsupported, since Alpine is a Linux image).
362    fn needs_shared_volume_permission_fixup(&self) -> bool {
363        self.container_os == ContainerOs::Linux
364    }
365
366    /// Docker network driver to use for the isolation group network on this container's OS.
367    ///
368    /// Linux containers use the `bridge` driver; Windows containers use `nat` (the only
369    /// driver that supports container-to-container traffic on a single Windows host).
370    fn network_driver(&self) -> &'static str {
371        match self.container_os {
372            ContainerOs::Linux => "bridge",
373            ContainerOs::Windows => "nat",
374        }
375    }
376
377    fn port_publishing_options(&self) -> (Option<bool>, Option<Vec<String>>) {
378        if self.exposed_ports.is_empty() {
379            return (None, None);
380        }
381
382        let exposed_ports = self
383            .exposed_ports
384            .iter()
385            .map(|(protocol, internal_port)| format!("{}/{}", internal_port, protocol))
386            .collect();
387        let publish_all_ports = match self.container_os {
388            ContainerOs::Linux => Some(true),
389            ContainerOs::Windows => None,
390        };
391
392        (publish_all_ports, Some(exposed_ports))
393    }
394
395    /// Returns the full set of bind mounts to apply to this container, including OS-specific
396    /// defaults and any additional named volume mounts.
397    ///
398    /// Linux containers receive the shared `/airlock` volume plus read-only mounts of host
399    /// paths needed for origin detection (`/proc`, `/sys/fs/cgroup`, the Docker socket).
400    /// Windows containers receive only the shared `C:\airlock` volume; the host-resource
401    /// mounts have no Windows-container equivalent and the `:z` shared-relabel mount option is
402    /// Linux-specific.
403    fn container_binds_from(&self, isolation_group_name: &str, mut binds: Vec<String>) -> Vec<String> {
404        match self.container_os {
405            ContainerOs::Linux => {
406                binds.push(format!("{}:/airlock:z", isolation_group_name));
407                binds.push("/proc:/host/proc:ro".to_string());
408                binds.push("/sys/fs/cgroup:/host/sys/fs/cgroup:ro".to_string());
409                binds.push("/var/run/docker.sock:/var/run/docker.sock:ro".to_string());
410            }
411            ContainerOs::Windows => {
412                binds.push(format!("{}:C:\\airlock", isolation_group_name));
413            }
414        }
415
416        binds.extend(self.additional_volume_mounts.clone());
417        binds
418    }
419}
420
421/// Detailed information about the spawned container.
422#[derive(Debug, Default)]
423pub struct DriverDetails {
424    container_name: String,
425    container_ip: Option<String>,
426    port_mappings: Option<HashMap<String, u16>>,
427}
428
429/// Inserts an `internal_port` -> host port mapping when `host_port` parses as a valid `u16`.
430///
431/// Docker reports each binding's host port as a string, and we treat values that don't parse as
432/// "no mapping available" rather than failing the whole inspect call. `internal_port` is the
433/// existing key (already including the protocol suffix, for example `"58125/udp"`).
434fn insert_port_mapping_if_parseable(
435    port_mappings: &mut HashMap<String, u16>, internal_port: impl Into<String>, host_port: Option<&str>,
436) {
437    if let Some(host_port) = host_port.and_then(|value| value.parse::<u16>().ok()) {
438        port_mappings.insert(internal_port.into(), host_port);
439    }
440}
441
442impl DriverDetails {
443    /// Returns the name of the container.
444    pub fn container_name(&self) -> &str {
445        &self.container_name
446    }
447
448    /// Returns the container IP address on its primary Docker network, if known.
449    pub fn container_ip(&self) -> Option<&str> {
450        self.container_ip.as_deref()
451    }
452
453    /// Attempts to look up a mapped ephemeral port for the given exposed port.
454    ///
455    /// The same `protocol` and internal port values used to expose the port must be used here. If the given
456    /// protocol/port combination wasn't exposed, `None` is returned. Otherwise, the mapped ephemeral port is returned.
457    /// This port is exposed on `0.0.0.0` on the host side.
458    pub fn try_get_exposed_port(&self, protocol: &str, internal_port: u16) -> Option<u16> {
459        self.port_mappings
460            .as_ref()
461            .and_then(|port_mappings| port_mappings.get(&format!("{}/{}", internal_port, protocol)).copied())
462    }
463}
464
465/// Container driver.
466pub struct Driver {
467    isolation_group_id: String,
468    isolation_group_name: String,
469    container_name: String,
470    config: DriverConfig,
471    docker: Docker,
472    log_dir: Option<PathBuf>,
473}
474
475impl Driver {
476    /// Creates a new `Driver` from the given isolation group ID and configuration.
477    ///
478    /// # Isolation group
479    ///
480    /// The isolation group ID serves as a unique identifier to be used for both the name of the container as well as
481    /// the shared resources that are created and attached to the container. If two drivers share the same isolation
482    /// group ID, the containers they spawn will be located in the same network namespace, have access to the same
483    /// shared Airlock volume, etc.
484    ///
485    /// # Shared volume
486    ///
487    /// The container will have a volume bind-mounted at `/airlock` that's shared between all containers in the same
488    /// isolation group. This volume is mounted as world writeable (777) so all containers can freely read and write to
489    /// it. This makes it easier for containers to share data between one another, but also means that care should be
490    /// taken to avoid conflicts between trying to write to the same file, etc.
491    ///
492    /// # Errors
493    ///
494    /// If the Docker client can't be created/configured, an error will be returned.
495    pub fn from_config(isolation_group_id: String, config: DriverConfig) -> Result<Self, GenericError> {
496        let docker = crate::docker::connect()?;
497
498        Ok(Self {
499            isolation_group_name: format!("airlock-{}", isolation_group_id),
500            container_name: format!("airlock-{}-{}", isolation_group_id, config.driver_id),
501            isolation_group_id,
502            config,
503            docker,
504            log_dir: None,
505        })
506    }
507
508    /// Configures the driver to capture container logs.
509    ///
510    /// The logs will be stored in the given directory, under a subdirectory named after the isolation group ID. Each
511    /// container will get a log for standard output and standard error, following the pattern of `<container
512    /// name>.[stdout|stderr].log`.
513    pub fn with_logging(mut self, log_dir: PathBuf) -> Self {
514        self.log_dir = Some(log_dir);
515        self
516    }
517
518    /// Returns the string identifier of the driver.
519    ///
520    /// This is generally a shorthand of the application/service, such as `dogstatsd` or `millstone`.
521    pub fn driver_id(&self) -> &'static str {
522        self.config.driver_id
523    }
524
525    /// Clean up any containers, networks, and volumes related to the given isolation group ID.
526    ///
527    /// This is a free function to facilitate cleaning up resources after a number of drivers are run.
528    ///
529    /// # Errors
530    ///
531    /// If the Docker client can't be created/configured, or there is an error when finding or removing any of the
532    /// related resources, an error will be returned.
533    pub async fn clean_related_resources(isolation_group_id: String) -> Result<(), GenericError> {
534        let docker = crate::docker::connect()?;
535
536        let isolation_group_name = format!("airlock-{}", isolation_group_id);
537        let isolation_group_label = format!("airlock-isolation-group={}", isolation_group_id);
538
539        // Remove any containers related to the isolation group. We do so forcefully.
540        let list_filters: HashMap<&str, Vec<&str>> =
541            [("label", vec!["created_by=airlock", isolation_group_label.as_str()])]
542                .into_iter()
543                .collect();
544        let list_options = Some(
545            ListContainersOptionsBuilder::default()
546                .all(true)
547                .filters(&list_filters)
548                .build(),
549        );
550        let containers = docker.list_containers(list_options).await.with_error_context(|| {
551            format!(
552                "Failed to list containers attached to isolation group '{}'.",
553                isolation_group_id
554            )
555        })?;
556
557        for container in containers {
558            let container_name = match container.id {
559                Some(id) => id,
560                None => {
561                    debug!("Listed container had no ID. Skipping removal.");
562                    continue;
563                }
564            };
565
566            if let Err(e) = docker.stop_container(container_name.as_str(), None).await {
567                error!(error = %e, "Failed to stop container '{}'.", container_name);
568                continue;
569            } else {
570                debug!("Stopped container '{}'.", container_name);
571            }
572
573            if let Err(e) = docker.remove_container(container_name.as_str(), None).await {
574                error!(error = %e, "Failed to remove container '{}'.", container_name);
575                continue;
576            } else {
577                debug!("Removed container '{}'.", container_name);
578            }
579        }
580
581        // Remove the shared volume.
582        if let Err(e) = docker
583            .remove_volume(
584                isolation_group_name.as_str(),
585                None::<bollard::query_parameters::RemoveVolumeOptions>,
586            )
587            .await
588        {
589            error!(error = %e, "Failed to remove shared volume '{}'.", isolation_group_name);
590        } else {
591            debug!("Removed shared volume '{}'.", isolation_group_name);
592        }
593
594        // Remove the network.
595        if let Err(e) = docker.remove_network(isolation_group_name.as_str()).await {
596            error!(error = %e, "Failed to remove shared network '{}'.", isolation_group_name);
597        } else {
598            debug!("Removed shared network '{}'.", isolation_group_name);
599        }
600
601        Ok(())
602    }
603
604    async fn create_network_if_missing(&self) -> Result<(), GenericError> {
605        // See if the network already exists or not.
606        let networks = self.docker.list_networks(None).await?;
607        if networks
608            .iter()
609            .any(|network| network.name.as_deref() == Some(self.isolation_group_name.as_str()))
610        {
611            debug!("Network '{}' already exists.", self.isolation_group_name);
612            return Ok(());
613        }
614
615        debug!(
616            driver_id = self.config.driver_id,
617            isolation_group = self.isolation_group_id,
618            "Network '{}' does not exist. Creating...",
619            self.isolation_group_name
620        );
621
622        // Create the network since it doesn't yet exist.
623        let network_options = NetworkCreateRequest {
624            name: self.isolation_group_name.clone(),
625            driver: Some(self.config.network_driver().to_string()),
626            ipam: Some(Ipam::default()),
627            enable_ipv6: Some(false),
628            labels: Some(get_default_airlock_labels(self.isolation_group_id.as_str())),
629            ..Default::default()
630        };
631        let response = self.docker.create_network(network_options).await?;
632        debug!(
633            driver_id = self.config.driver_id,
634            isolation_group = self.isolation_group_id,
635            "Created network '{}' (ID: {:?}).",
636            self.isolation_group_name,
637            response.id
638        );
639
640        Ok(())
641    }
642
643    async fn create_image_if_missing_inner(&self, image: &str) -> Result<(), GenericError> {
644        let image_options = CreateImageOptions {
645            from_image: Some(image.to_string()),
646            ..Default::default()
647        };
648
649        let mut create_stream = self.docker.create_image(Some(image_options), None, None);
650        while let Some(info) = create_stream.next().await {
651            trace!(
652                driver_id = self.config.driver_id,
653                isolation_group = self.isolation_group_id,
654                image,
655                "Received image pull update: {:?}",
656                info
657            );
658        }
659
660        Ok(())
661    }
662
663    async fn create_image_if_missing(&self) -> Result<(), GenericError> {
664        debug!(
665            driver_id = self.config.driver_id,
666            isolation_group = self.isolation_group_id,
667            "Pulling image '{}'...",
668            self.config.image
669        );
670
671        self.create_image_if_missing_inner(self.config.image.as_str()).await?;
672
673        debug!(
674            driver_id = self.config.driver_id,
675            isolation_group = self.isolation_group_id,
676            "Pulled image '{}'.",
677            self.config.image
678        );
679
680        Ok(())
681    }
682
683    async fn create_volume_if_missing(&self) -> Result<(), GenericError> {
684        // Check to see if the shared volume already exists.
685        let volumes = self
686            .docker
687            .list_volumes(None::<bollard::query_parameters::ListVolumesOptions>)
688            .await?;
689        if volumes
690            .volumes
691            .iter()
692            .flatten()
693            .any(|volume| volume.name == self.isolation_group_name.as_str())
694        {
695            debug!("Shared volume '{}' already exists.", self.isolation_group_name);
696            return Ok(());
697        }
698
699        debug!(
700            driver_id = self.config.driver_id,
701            isolation_group = self.isolation_group_id,
702            "Shared volume '{}' does not exist. Creating...",
703            self.isolation_group_name
704        );
705
706        let volume_options = VolumeCreateRequest {
707            name: Some(self.isolation_group_name.clone()),
708            driver: Some("local".to_string()),
709            labels: Some(get_default_airlock_labels(self.isolation_group_id.as_str())),
710            ..Default::default()
711        };
712        self.docker.create_volume(volume_options).await?;
713
714        debug!(
715            driver_id = self.config.driver_id,
716            isolation_group = self.isolation_group_id,
717            "Created shared volume '{}'.",
718            self.isolation_group_name
719        );
720
721        Ok(())
722    }
723
724    async fn adjust_shared_volume_permissions(&self) -> Result<(), GenericError> {
725        debug!(
726            driver_id = self.config.driver_id,
727            isolation_group = self.isolation_group_id,
728            "Adjusting permissions on shared volume '{}'...",
729            self.container_name
730        );
731
732        // We spin up a minimal Alpine container, chmod the directory bind-mounted to the shared volume, and that's it.
733        let image = self.config.alpine_image.clone();
734        self.create_image_if_missing_inner(&image).await?;
735
736        let container_name = format!("airlock-{}-volume-fix-up", self.isolation_group_id);
737        let entrypoint = vec![
738            "chmod".to_string(),
739            "-R".to_string(),
740            "777".to_string(),
741            "/airlock".to_string(),
742        ];
743        let _ = self
744            .create_container_inner(container_name.clone(), image, Some(entrypoint), None, vec![], None)
745            .await?;
746
747        self.start_container_inner(&container_name).await?;
748        self.wait_for_container_exit_inner(&container_name).await?;
749        self.cleanup_inner(&container_name).await?;
750
751        Ok(())
752    }
753
754    async fn create_container_inner(
755        &self, container_name: String, image: String, entrypoint: Option<Vec<String>>, cmd: Option<Vec<String>>,
756        binds: Vec<String>, env: Option<Vec<String>>,
757    ) -> Result<String, GenericError> {
758        let binds = self.config.container_binds_from(&self.isolation_group_name, binds);
759
760        // Set up NetworkingConfig to apply aliases on the primary network, if any are configured.
761        let networking_config = if !self.config.network_aliases.is_empty() {
762            let mut endpoints = HashMap::new();
763            endpoints.insert(
764                self.isolation_group_name.clone(),
765                EndpointSettings {
766                    aliases: Some(self.config.network_aliases.clone()),
767                    ..Default::default()
768                },
769            );
770            Some(
771                ContainerNetworkingConfig {
772                    endpoints_config: endpoints,
773                }
774                .into(),
775            )
776        } else {
777            None
778        };
779
780        let (publish_all_ports, exposed_ports) = self.config.port_publishing_options();
781
782        // Linux test containers run with `pid_mode=host` so origin-detection logic in ADP and
783        // the Core Agent can see processes on the runner. Windows containers do not support
784        // host PID mode, so we leave it unset and accept that Windows-runtime tests don't
785        // exercise the host-pid origin-detection path.
786        let pid_mode = match self.config.container_os {
787            ContainerOs::Linux => Some("host".to_string()),
788            ContainerOs::Windows => None,
789        };
790        let cgroupns_mode = (self.config.container_os == ContainerOs::Linux && self.config.host_cgroup_namespace)
791            .then_some(HostConfigCgroupnsModeEnum::HOST);
792
793        let container_config = ContainerCreateBody {
794            hostname: Some(self.config.driver_id.to_string()),
795            env,
796            image: Some(image),
797            entrypoint,
798            cmd,
799            host_config: Some(HostConfig {
800                binds: Some(binds),
801                network_mode: Some(self.isolation_group_name.clone()),
802                publish_all_ports,
803                pid_mode,
804                cgroupns_mode,
805                ..Default::default()
806            }),
807            healthcheck: self.config.healthcheck.clone(),
808            exposed_ports,
809            labels: Some(get_default_airlock_labels(self.isolation_group_id.as_str())),
810            networking_config,
811            ..Default::default()
812        };
813
814        let create_options = CreateContainerOptionsBuilder::default().name(&container_name).build();
815
816        let response = self
817            .docker
818            .create_container(Some(create_options), container_config)
819            .await?;
820
821        Ok(response.id)
822    }
823
824    async fn create_container(&self) -> Result<(), GenericError> {
825        debug!(
826            driver_id = self.config.driver_id,
827            isolation_group = self.isolation_group_id,
828            "Creating container '{}'...",
829            self.container_name
830        );
831
832        let container_id = self
833            .create_container_inner(
834                self.container_name.clone(),
835                self.config.image.clone(),
836                self.config.entrypoint.clone(),
837                self.config.command.clone(),
838                self.config.binds.clone(),
839                Some(self.config.env.clone()),
840            )
841            .await?;
842
843        debug!(
844            driver_id = self.config.driver_id,
845            isolation_group = self.isolation_group_id,
846            "Created container '{}' (ID: {}).",
847            self.container_name,
848            container_id
849        );
850
851        Ok(())
852    }
853
854    async fn start_container_inner(&self, container_name: &str) -> Result<DriverDetails, GenericError> {
855        self.docker.start_container(container_name, None).await?;
856
857        let mut details = DriverDetails {
858            container_name: container_name.to_string(),
859            ..Default::default()
860        };
861
862        let response = self.docker.inspect_container(container_name, None).await?;
863        if let Some(network_settings) = response.network_settings {
864            // Look up the IP only on the primary isolation-group network. Falling back to
865            // "any other network's IP" would be non-deterministic and effectively wrong for
866            // assertion targeting.
867            if let Some(networks) = network_settings.networks.as_ref() {
868                details.container_ip = networks
869                    .get(&self.isolation_group_name)
870                    .and_then(|settings| settings.ip_address.clone())
871                    .filter(|address| !address.is_empty());
872            }
873
874            if let Some(ports) = network_settings.ports {
875                let port_mappings = details.port_mappings.get_or_insert_with(HashMap::new);
876                for (internal_port, bindings) in ports {
877                    if let Some(bindings) = bindings {
878                        for binding in bindings {
879                            insert_port_mapping_if_parseable(
880                                port_mappings,
881                                internal_port.clone(),
882                                binding.host_port.as_deref(),
883                            );
884                            if port_mappings.contains_key(&internal_port) {
885                                break;
886                            }
887                        }
888                    }
889                }
890            }
891        }
892
893        Ok(details)
894    }
895
896    async fn start_container(&self) -> Result<DriverDetails, GenericError> {
897        debug!(
898            driver_id = self.config.driver_id,
899            isolation_group = self.isolation_group_id,
900            "Starting container '{}'...",
901            self.container_name
902        );
903
904        let details = self.start_container_inner(&self.container_name).await?;
905
906        if let Some(log_dir) = self.log_dir.clone() {
907            debug!(
908                "Capturing logs for container '{}' to {}...",
909                self.container_name,
910                log_dir.display()
911            );
912
913            self.capture_container_logs(log_dir, self.config.driver_id, &self.container_name)
914                .await?;
915        }
916
917        debug!(
918            driver_id = self.config.driver_id,
919            isolation_group = self.isolation_group_id,
920            "Started container '{}'.",
921            self.container_name
922        );
923
924        Ok(details)
925    }
926
927    /// Connects this container to each network listed in `additional_networks`.
928    ///
929    /// Called after container creation but before start, so the container is already reachable
930    /// on all configured networks from the moment it begins running.
931    async fn connect_to_additional_networks(&self) -> Result<(), GenericError> {
932        for network in &self.config.additional_networks {
933            self.docker
934                .connect_network(
935                    network,
936                    NetworkConnectRequest {
937                        container: self.container_name.clone(),
938                        endpoint_config: None,
939                    },
940                )
941                .await
942                .map_err(|e| {
943                    generic_error!(
944                        "Failed to connect container '{}' to network '{}': {}",
945                        self.container_name,
946                        network,
947                        e
948                    )
949                })?;
950        }
951        Ok(())
952    }
953
954    /// Starts the container, creating any necessary resources.
955    ///
956    /// # Errors
957    ///
958    /// If there is an error while creating the network or shared volume, while pulling the container image, or while
959    /// creating or starting the container, it will be returned.
960    pub async fn start(&mut self) -> Result<DriverDetails, GenericError> {
961        self.create_network_if_missing().await?;
962        self.create_image_if_missing().await?;
963        self.create_volume_if_missing().await?;
964        if self.config.needs_shared_volume_permission_fixup() {
965            self.adjust_shared_volume_permissions().await?;
966        }
967
968        self.create_container().await?;
969        self.connect_to_additional_networks().await?;
970        self.start_container().await
971    }
972
973    /// Waits until the container is marked as healthy.
974    ///
975    /// If the container has no health checks defined, this returns early and does no waiting.
976    ///
977    /// # Errors
978    ///
979    /// If there is an error while inspecting the container, it will be returned.
980    pub async fn wait_for_container_healthy(&mut self) -> Result<(), GenericError> {
981        loop {
982            // Inspect the container, and see if it even has any health checks defined. If not, then we can return early.
983            let response = self.docker.inspect_container(&self.container_name, None).await?;
984            let state = response
985                .state
986                .ok_or_else(|| generic_error!("Container state should be present."))?;
987
988            // Make sure the container is actually running.
989            let status = state
990                .status
991                .ok_or_else(|| generic_error!("Container status should be present."))?;
992            if status != ContainerStateStatusEnum::RUNNING {
993                return Err(generic_error!(
994                    "Container exited unexpectedly (driver_id: {}, container: {}). Check logs in the test run directory.",
995                    self.config.driver_id,
996                    self.container_name
997                ));
998            }
999
1000            if let Some(health_status) = state.health.and_then(|h| h.status) {
1001                match health_status {
1002                    // No healthcheck defined, or healthy, so we're good to go.
1003                    HealthStatusEnum::EMPTY | HealthStatusEnum::NONE | HealthStatusEnum::HEALTHY => {
1004                        debug!(
1005                            driver_id = self.config.driver_id,
1006                            "Container '{}' healthy or no healthcheck defined. Proceeding.", &self.container_name
1007                        );
1008                        return Ok(());
1009                    }
1010
1011                    // Not healthy yet, so we'll keep waiting.
1012                    HealthStatusEnum::STARTING => {
1013                        debug!(
1014                            driver_id = self.config.driver_id,
1015                            "Container '{}' not yet healthy. Waiting...", &self.container_name
1016                        );
1017                    }
1018
1019                    HealthStatusEnum::UNHEALTHY => {
1020                        return Err(generic_error!(
1021                            "Container became unhealthy (driver_id: {}, container: {}). Check logs in the test run directory.",
1022                            self.config.driver_id,
1023                            self.container_name
1024                        ));
1025                    }
1026                }
1027            } else {
1028                debug!(
1029                    driver_id = self.config.driver_id,
1030                    "Container '{}' has no healthcheck defined. Proceeding.", &self.container_name
1031                );
1032                return Ok(());
1033            }
1034
1035            // Wait for a second and then check again.
1036            sleep(Duration::from_secs(1)).await;
1037        }
1038    }
1039
1040    async fn wait_for_container_exit_inner(&self, container_name: &str) -> Result<ExitStatus, GenericError> {
1041        let mut wait_stream = self.docker.wait_container(container_name, None);
1042        match wait_stream.next().await {
1043            Some(result) => match result {
1044                Ok(response) => {
1045                    // When the exit code is non-zero, `bollard` transforms the normal `ContainerWaitResponse` into
1046                    // `Error::DockerContainerWaitError`, which is why we have these asserts here to catch any scenario
1047                    // where there's _somehow_ an error condition being indicated without it having been transformed into
1048                    // `Error::DockerContainerWaitError`.
1049                    //
1050                    // Essentially, getting to this point should imply successfully exiting, but the API isn't very
1051                    // ergonomic in that regard, so we're just making sure.
1052                    assert_eq!(response.error, None);
1053                    assert_eq!(response.status_code, 0);
1054
1055                    Ok(ExitStatus::Success)
1056                }
1057
1058                Err(Error::DockerContainerWaitError { error, code }) => {
1059                    let error = if error.is_empty() {
1060                        String::from("<no error message provided>")
1061                    } else {
1062                        error
1063                    };
1064                    Ok(ExitStatus::Failed { code, error })
1065                }
1066
1067                Err(e) => Err(generic_error!("Failed to wait for container to finish: {:?}", e)),
1068            },
1069            None => unreachable!("Docker wait stream ended unexpectedly."),
1070        }
1071    }
1072
1073    /// Waits for the container to finish successfully.
1074    ///
1075    /// The container's exit status is returned, indicating success (exit code 0) or failure (exit code != 0), including
1076    /// any error message related to the failure.
1077    ///
1078    /// # Errors
1079    ///
1080    /// If an error is encountered while waiting for the container to exit, it will be returned.
1081    pub async fn wait_for_container_exit(&self) -> Result<ExitStatus, GenericError> {
1082        debug!(
1083            driver_id = self.config.driver_id,
1084            isolation_group = self.isolation_group_id,
1085            "Waiting for container '{}' to finish...",
1086            &self.container_name
1087        );
1088
1089        let exit_status = self.wait_for_container_exit_inner(&self.container_name).await?;
1090
1091        debug!(
1092            driver_id = self.config.driver_id,
1093            isolation_group = self.isolation_group_id,
1094            "Container '{}' finished successfully.",
1095            &self.container_name
1096        );
1097
1098        Ok(exit_status)
1099    }
1100
1101    /// Executes a command inside the running container and returns its stdout.
1102    ///
1103    /// The command runs as root with no TTY. Stderr is discarded, and only stdout is returned. If the command exits with a
1104    /// nonzero status, an error is returned.
1105    ///
1106    /// # Errors
1107    ///
1108    /// If the exec creation, start, output collection, or command exit code indicates failure, an error is returned.
1109    pub async fn exec_in_container(&self, cmd: Vec<String>) -> Result<String, GenericError> {
1110        let exec_opts = CreateExecOptions {
1111            attach_stdout: Some(true),
1112            attach_stderr: Some(false),
1113            cmd: Some(cmd.clone()),
1114            ..Default::default()
1115        };
1116
1117        let exec = self
1118            .docker
1119            .create_exec(&self.container_name, exec_opts)
1120            .await
1121            .with_error_context(|| format!("Failed to create exec instance for container {}.", self.container_name))?;
1122
1123        let exec_id = exec.id.clone();
1124
1125        let output = self
1126            .docker
1127            .start_exec(&exec.id, None)
1128            .await
1129            .with_error_context(|| format!("Failed to start exec for container {}.", self.container_name))?;
1130
1131        let mut stdout = String::new();
1132        if let StartExecResults::Attached { mut output, .. } = output {
1133            while let Some(chunk) = output.try_next().await? {
1134                if let LogOutput::StdOut { message } = chunk {
1135                    stdout.push_str(&String::from_utf8_lossy(&message));
1136                }
1137            }
1138        }
1139
1140        // Check the command's exit code.
1141        let inspect = self
1142            .docker
1143            .inspect_exec(&exec_id)
1144            .await
1145            .error_context("Failed to inspect exec result.")?;
1146
1147        if let Some(code) = inspect.exit_code {
1148            if code != 0 {
1149                return Err(generic_error!(
1150                    "Command {:?} exited with code {} in container {}.",
1151                    cmd,
1152                    code,
1153                    self.container_name
1154                ));
1155            }
1156        }
1157
1158        Ok(stdout)
1159    }
1160
1161    async fn cleanup_inner(&self, container_name: &str) -> Result<(), GenericError> {
1162        self.docker.stop_container(container_name, None).await?;
1163        self.docker.remove_container(container_name, None).await?;
1164
1165        Ok(())
1166    }
1167
1168    /// Cleans up the container, stopping and removing it from the system.
1169    ///
1170    /// # Errors
1171    ///
1172    /// If there is an error while stopping or removing the container, it will be returned.
1173    pub async fn cleanup(self) -> Result<(), GenericError> {
1174        debug!(
1175            driver_id = self.config.driver_id,
1176            isolation_group = self.isolation_group_id,
1177            "Cleaning up container '{}'...",
1178            self.container_name
1179        );
1180
1181        let start = Instant::now();
1182
1183        self.cleanup_inner(&self.container_name).await?;
1184
1185        debug!(
1186            driver_id = self.config.driver_id,
1187            isolation_group = self.isolation_group_id,
1188            "Container '{}' removed after {:?}.",
1189            self.container_name,
1190            start.elapsed()
1191        );
1192
1193        Ok(())
1194    }
1195
1196    async fn capture_container_logs(
1197        &self, container_log_dir: PathBuf, log_name: &str, container_name: &str,
1198    ) -> Result<(), GenericError> {
1199        // Make sure the directories exist first and prepare the files, just to get any permissions issues out of the
1200        // way up front before we spawn our background task.
1201        tokio::fs::create_dir_all(&container_log_dir)
1202            .await
1203            .error_context("Failed to create logs directory. Possible permissions issue.")?;
1204
1205        let stdout_log_path = container_log_dir.join(format!("{}.stdout.log", log_name));
1206        let stderr_log_path = container_log_dir.join(format!("{}.stderr.log", log_name));
1207
1208        let mut stdout_file = tokio::fs::File::create(&stdout_log_path)
1209            .await
1210            .map(BufWriter::new)
1211            .error_context("Failed to create standard output log file. Possible permissions issue.")?;
1212        let mut stderr_file = tokio::fs::File::create(&stderr_log_path)
1213            .await
1214            .map(BufWriter::new)
1215            .error_context("Failed to create standard error log file. Possible permissions issue.")?;
1216
1217        // Spawn a background task to capture the logs.
1218        let logs_config = LogsOptions {
1219            follow: true,
1220            stdout: true,
1221            stderr: true,
1222            ..Default::default()
1223        };
1224        let mut log_stream = self.docker.logs(container_name, Some(logs_config));
1225
1226        tokio::spawn(async move {
1227            while let Some(log_result) = log_stream.next().await {
1228                match log_result {
1229                    Ok(log) => match log {
1230                        LogOutput::StdErr { message } => {
1231                            if let Err(e) = stderr_file.write_all(&strip_ansi_codes(&message)).await {
1232                                error!(error = %e, "Failed to write log line to standard error log file.");
1233                                break;
1234                            }
1235                            if let Err(e) = stderr_file.flush().await {
1236                                error!(error = %e, "Failed to flush standard error log file.");
1237                                break;
1238                            }
1239                        }
1240                        LogOutput::StdOut { message } => {
1241                            if let Err(e) = stdout_file.write_all(&strip_ansi_codes(&message)).await {
1242                                error!(error = %e, "Failed to write log line to standard output log file.");
1243                                break;
1244                            }
1245                            if let Err(e) = stdout_file.flush().await {
1246                                error!(error = %e, "Failed to flush standard output log file.");
1247                                break;
1248                            }
1249                        }
1250                        LogOutput::StdIn { .. } | LogOutput::Console { .. } => {}
1251                    },
1252                    Err(e) => {
1253                        error!(error = %e, "Failed to read log line from container.");
1254                        break;
1255                    }
1256                }
1257            }
1258
1259            // One final fsync to ensure the logs are fully written to disk.
1260            if let Err(e) = stdout_file.get_mut().sync_all().await {
1261                error!(error = %e, "Failed to fsync standard output log file.");
1262            }
1263
1264            if let Err(e) = stderr_file.get_mut().sync_all().await {
1265                error!(error = %e, "Failed to fsync standard error log file.");
1266            }
1267        });
1268
1269        Ok(())
1270    }
1271}
1272
1273/// Removes ANSI escape sequences (`ESC[...letter`) from a byte slice.
1274fn strip_ansi_codes(input: &[u8]) -> Vec<u8> {
1275    let mut out = Vec::with_capacity(input.len());
1276    let mut i = 0;
1277    while i < input.len() {
1278        if input[i] == 0x1b && input.get(i + 1) == Some(&b'[') {
1279            i += 2;
1280            while i < input.len() && !input[i].is_ascii_alphabetic() {
1281                i += 1;
1282            }
1283            i += 1;
1284        } else {
1285            out.push(input[i]);
1286            i += 1;
1287        }
1288    }
1289    out
1290}
1291
1292fn get_default_airlock_labels(isolation_group_id: &str) -> HashMap<String, String> {
1293    let mut labels = HashMap::new();
1294    labels.insert("created_by".to_string(), "airlock".to_string());
1295    labels.insert("airlock-isolation-group".to_string(), isolation_group_id.to_string());
1296    labels
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301    use super::*;
1302
1303    #[test]
1304    fn alpine_image_defaults_to_docker_hub_and_can_be_overridden() {
1305        let config = DriverConfig::from_image("target", "example:latest".to_string());
1306        assert_eq!(config.alpine_image, "alpine:latest");
1307
1308        let config = config.with_alpine_image("registry.example/alpine:3.20");
1309        assert_eq!(config.alpine_image, "registry.example/alpine:3.20");
1310    }
1311
1312    #[test]
1313    fn default_linux_container_binds_include_airlock_and_linux_host_resources() {
1314        let config = DriverConfig::from_image("target", "example:latest".to_string());
1315
1316        let binds = config.container_binds_from("airlock-test", config.binds.clone());
1317
1318        assert!(binds.contains(&"airlock-test:/airlock:z".to_string()));
1319        assert!(binds.contains(&"/proc:/host/proc:ro".to_string()));
1320        assert!(binds.contains(&"/sys/fs/cgroup:/host/sys/fs/cgroup:ro".to_string()));
1321        assert!(binds.contains(&"/var/run/docker.sock:/var/run/docker.sock:ro".to_string()));
1322    }
1323
1324    #[test]
1325    fn windows_container_binds_use_windows_airlock_and_skip_linux_host_resources() {
1326        let config =
1327            DriverConfig::from_image("target", "example:latest".to_string()).with_container_os(ContainerOs::Windows);
1328
1329        let binds = config.container_binds_from("airlock-test", config.binds.clone());
1330
1331        assert!(binds.contains(&"airlock-test:C:\\airlock".to_string()));
1332        assert!(!binds.iter().any(|bind| bind.contains("/proc")));
1333        assert!(!binds.iter().any(|bind| bind.contains("/sys/fs/cgroup")));
1334        assert!(!binds.iter().any(|bind| bind.contains("/var/run/docker.sock")));
1335        assert!(!binds.iter().any(|bind| bind.ends_with(":z")));
1336    }
1337
1338    #[tokio::test]
1339    async fn target_config_preserves_windows_container_os() {
1340        let target = TargetConfig {
1341            image: "example:latest".to_string(),
1342            entrypoint: vec![],
1343            command: vec![],
1344            additional_env_vars: vec![],
1345            container_os: ContainerOs::Windows,
1346            host_cgroup_namespace: false,
1347        };
1348
1349        let config = DriverConfig::target("target", target).await.unwrap();
1350
1351        assert_eq!(config.container_os, ContainerOs::Windows);
1352        assert!(!config.host_cgroup_namespace);
1353    }
1354
1355    #[tokio::test]
1356    async fn target_config_preserves_host_cgroup_namespace() {
1357        let target = TargetConfig {
1358            image: "example:latest".to_string(),
1359            entrypoint: vec![],
1360            command: vec![],
1361            additional_env_vars: vec![],
1362            container_os: ContainerOs::Linux,
1363            host_cgroup_namespace: true,
1364        };
1365
1366        let config = DriverConfig::target("target", target).await.unwrap();
1367
1368        assert!(config.host_cgroup_namespace);
1369    }
1370
1371    #[test]
1372    fn port_mapping_inserts_parseable_host_port() {
1373        let mut mappings = HashMap::new();
1374
1375        insert_port_mapping_if_parseable(&mut mappings, "55100/tcp", Some("49152"));
1376
1377        assert_eq!(mappings.get("55100/tcp"), Some(&49152));
1378    }
1379
1380    #[test]
1381    fn port_mapping_ignores_invalid_host_port() {
1382        let mut mappings = HashMap::new();
1383
1384        insert_port_mapping_if_parseable(&mut mappings, "55100/tcp", Some("not-a-port"));
1385
1386        assert!(!mappings.contains_key("55100/tcp"));
1387    }
1388
1389    #[test]
1390    fn windows_container_skips_shared_volume_permission_fixup() {
1391        let config =
1392            DriverConfig::from_image("target", "example:latest".to_string()).with_container_os(ContainerOs::Windows);
1393
1394        assert!(!config.needs_shared_volume_permission_fixup());
1395    }
1396
1397    #[test]
1398    fn windows_container_uses_nat_network_driver() {
1399        let config =
1400            DriverConfig::from_image("target", "example:latest".to_string()).with_container_os(ContainerOs::Windows);
1401
1402        assert_eq!(config.network_driver(), "nat");
1403    }
1404
1405    #[test]
1406    fn windows_container_exposes_ports_without_publishing_to_host() {
1407        let config = DriverConfig::from_image("target", "example:latest".to_string())
1408            .with_container_os(ContainerOs::Windows)
1409            .with_exposed_port("udp", 58125);
1410
1411        let (publish_all_ports, exposed_ports) = config.port_publishing_options();
1412
1413        assert_eq!(publish_all_ports, None);
1414        assert_eq!(exposed_ports, Some(vec!["58125/udp".to_string()]));
1415    }
1416
1417    #[test]
1418    fn linux_container_exposes_ports_and_publishes_to_host() {
1419        let config = DriverConfig::from_image("target", "example:latest".to_string()).with_exposed_port("tcp", 55100);
1420
1421        let (publish_all_ports, exposed_ports) = config.port_publishing_options();
1422
1423        assert_eq!(publish_all_ports, Some(true));
1424        assert_eq!(exposed_ports, Some(vec!["55100/tcp".to_string()]));
1425    }
1426
1427    #[test]
1428    fn linux_container_uses_bridge_network_driver() {
1429        let config = DriverConfig::from_image("target", "example:latest".to_string());
1430
1431        assert_eq!(config.network_driver(), "bridge");
1432    }
1433}