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