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
31pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum ContainerOs {
61 Linux,
63 Windows,
65}
66
67#[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_volume_mounts: Vec<String>,
89
90 network_aliases: Vec<String>,
97
98 additional_networks: Vec<String>,
105
106 alpine_image: String,
110}
111
112impl DriverConfig {
113 pub async fn millstone(config: MillstoneConfig) -> Result<Self, GenericError> {
114 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 .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 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 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 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 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 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 pub fn with_env_vars(mut self, env: Vec<String>) -> Self {
240 self.env.extend(env);
241 self
242 }
243
244 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 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 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 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 pub fn with_network_alias(mut self, alias: impl Into<String>) -> Self {
300 self.network_aliases.push(alias.into());
301 self
302 }
303
304 pub fn with_network(mut self, network: impl Into<String>) -> Self {
310 self.additional_networks.push(network.into());
311 self
312 }
313
314 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 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 pub fn with_container_os(mut self, container_os: ContainerOs) -> Self {
344 self.container_os = container_os;
345 self
346 }
347
348 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 fn needs_shared_volume_permission_fixup(&self) -> bool {
363 self.container_os == ContainerOs::Linux
364 }
365
366 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 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#[derive(Debug, Default)]
423pub struct DriverDetails {
424 container_name: String,
425 container_ip: Option<String>,
426 port_mappings: Option<HashMap<String, u16>>,
427}
428
429fn 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 pub fn container_name(&self) -> &str {
445 &self.container_name
446 }
447
448 pub fn container_ip(&self) -> Option<&str> {
450 self.container_ip.as_deref()
451 }
452
453 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
465pub 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 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 pub fn with_logging(mut self, log_dir: PathBuf) -> Self {
514 self.log_dir = Some(log_dir);
515 self
516 }
517
518 pub fn driver_id(&self) -> &'static str {
522 self.config.driver_id
523 }
524
525 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn wait_for_container_healthy(&mut self) -> Result<(), GenericError> {
981 loop {
982 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 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 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 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 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 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 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 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 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 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 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 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 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
1273fn 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}