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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum ContainerOs {
58 Linux,
60 Windows,
62}
63
64#[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_volume_mounts: Vec<String>,
86
87 network_aliases: Vec<String>,
94
95 additional_networks: Vec<String>,
102}
103
104impl DriverConfig {
105 pub async fn millstone(config: MillstoneConfig) -> Result<Self, GenericError> {
106 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 .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 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 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 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 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 pub fn with_env_vars(mut self, env: Vec<String>) -> Self {
225 self.env.extend(env);
226 self
227 }
228
229 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 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 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 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 pub fn with_network_alias(mut self, alias: impl Into<String>) -> Self {
285 self.network_aliases.push(alias.into());
286 self
287 }
288
289 pub fn with_network(mut self, network: impl Into<String>) -> Self {
295 self.additional_networks.push(network.into());
296 self
297 }
298
299 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 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 pub fn with_container_os(mut self, container_os: ContainerOs) -> Self {
329 self.container_os = container_os;
330 self
331 }
332
333 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 fn needs_shared_volume_permission_fixup(&self) -> bool {
348 self.container_os == ContainerOs::Linux
349 }
350
351 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 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#[derive(Debug, Default)]
408pub struct DriverDetails {
409 container_name: String,
410 container_ip: Option<String>,
411 port_mappings: Option<HashMap<String, u16>>,
412}
413
414fn 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 pub fn container_name(&self) -> &str {
430 &self.container_name
431 }
432
433 pub fn container_ip(&self) -> Option<&str> {
435 self.container_ip.as_deref()
436 }
437
438 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
450pub 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 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 pub fn with_logging(mut self, log_dir: PathBuf) -> Self {
499 self.log_dir = Some(log_dir);
500 self
501 }
502
503 pub fn driver_id(&self) -> &'static str {
507 self.config.driver_id
508 }
509
510 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn wait_for_container_healthy(&mut self) -> Result<(), GenericError> {
966 loop {
967 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 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 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 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 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 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 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 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 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 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 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 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 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
1258fn 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 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}