1
use std::io::{
2
  BufRead as _,
3
  BufReader,
4
};
5
use std::process::Command as ProcessCommand;
6
use std::thread;
7

            
8
use anyhow::Context;
9
use schemars::JsonSchema;
10
use serde::Deserialize;
11

            
12
use crate::defaults::{
13
  default_ignore_errors,
14
  default_verbose,
15
};
16
use crate::file::ToUtf8 as _;
17
use crate::handle_output;
18
use crate::schema::{
19
  get_output_handler,
20
  ContainerRuntime,
21
  TaskContext,
22
};
23

            
24
#[derive(Debug, Deserialize, Clone, JsonSchema)]
25
pub struct ContainerRun {
26
  /// The command to run in the container
27
  pub container_command: Vec<String>,
28

            
29
  /// The container image to use
30
  pub image: String,
31

            
32
  /// The mounted paths to bind mount into the container
33
  #[serde(default)]
34
  pub mounted_paths: Vec<String>,
35

            
36
  /// The container runtime to use
37
  #[serde(default)]
38
  pub runtime: Option<ContainerRuntime>,
39

            
40
  /// Ignore errors if the command fails
41
  #[serde(default)]
42
  pub ignore_errors: Option<bool>,
43

            
44
  /// Show verbose output
45
  #[serde(default)]
46
  pub verbose: Option<bool>,
47
}
48

            
49
impl ContainerRun {
50
  pub fn execute(&self, context: &TaskContext) -> anyhow::Result<()> {
51
    assert!(!self.image.is_empty());
52
    assert!(!self.container_command.is_empty());
53

            
54
    let ignore_errors = self.ignore_errors(context);
55
    let verbose = self.verbose(context);
56

            
57
    let stdout = get_output_handler(verbose);
58
    let stderr = get_output_handler(verbose);
59

            
60
    let container_runtime =
61
      ContainerRuntime::resolve(self.runtime.as_ref().or(context.container_runtime.as_ref()))?;
62

            
63
    let mut cmd = ProcessCommand::new(container_runtime);
64
    cmd.arg("run").arg("--rm").arg("-i").stdout(stdout).stderr(stderr);
65

            
66
    let workdir = context.task_root.config_base_dir();
67
    cmd.arg("-v").arg(format!("{}:/workdir:z", workdir.to_utf8()?));
68
    cmd.arg("-w").arg("/workdir");
69

            
70
    for mounted_path in self.resolved_mounted_paths(context) {
71
      cmd.arg("-v").arg(mounted_path);
72
    }
73

            
74
    // Inject environment variables in both container and command
75
    for (key, value) in context.env_vars.iter() {
76
      cmd.env(key, value);
77
      cmd.arg("-e").arg(format!("{}={}", key, value));
78
    }
79

            
80
    cmd.arg(&self.image).args(&self.container_command);
81

            
82
    log::trace!("Running command: {:?}", cmd);
83

            
84
    let mut cmd = cmd.spawn()?;
85
    if verbose {
86
      handle_output!(cmd.stdout, context);
87
      handle_output!(cmd.stderr, context);
88
    }
89

            
90
    let status = cmd.wait()?;
91
    if !status.success() && !ignore_errors {
92
      anyhow::bail!("Command failed - {}", self.container_command.join(" "));
93
    }
94

            
95
    Ok(())
96
  }
97

            
98
  fn ignore_errors(&self, context: &TaskContext) -> bool {
99
    self
100
      .ignore_errors
101
      .or(context.ignore_errors)
102
      .unwrap_or(default_ignore_errors())
103
  }
104

            
105
  fn verbose(&self, context: &TaskContext) -> bool {
106
    self.verbose.or(context.verbose).unwrap_or(default_verbose())
107
  }
108

            
109
  pub fn resolved_mounted_paths(&self, context: &TaskContext) -> Vec<String> {
110
    self
111
      .mounted_paths
112
      .iter()
113
      .map(|mounted_path| resolve_mount_spec(context, mounted_path))
114
      .collect()
115
  }
116
}
117

            
118
fn resolve_mount_spec(context: &TaskContext, mounted_path: &str) -> String {
119
  let mut parts = mounted_path.splitn(3, ':');
120
  let host = parts.next().unwrap_or_default();
121
  let second = parts.next();
122
  let third = parts.next();
123

            
124
  if let Some(container_path) = second {
125
    if !should_resolve_bind_host(host, container_path) {
126
      return mounted_path.to_string();
127
    }
128

            
129
    let resolved_host = context.resolve_from_config(host);
130
    match third {
131
      Some(options) => format!(
132
        "{}:{}:{}",
133
        resolved_host.to_string_lossy(),
134
        container_path,
135
        options
136
      ),
137
      None => format!("{}:{}", resolved_host.to_string_lossy(), container_path),
138
    }
139
  } else {
140
    mounted_path.to_string()
141
  }
142
}
143

            
144
fn should_resolve_bind_host(host: &str, container_path: &str) -> bool {
145
  if host.is_empty() || container_path.is_empty() {
146
    return false;
147
  }
148

            
149
  host.starts_with('.')
150
    || host.starts_with('/')
151
    || host.contains('/')
152
    || host == "~"
153
    || host.starts_with("~/")
154
}