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

            
9
use anyhow::Context as _;
10
use git2::Repository;
11
use serde::Deserialize;
12
use which::which;
13

            
14
use crate::defaults::{
15
  default_shell,
16
  default_verbose,
17
};
18
use crate::schema::{
19
  get_output_handler,
20
  is_shell_command,
21
  is_template_command,
22
  TaskContext,
23
};
24
use crate::{
25
  get_template_command_value,
26
  handle_output,
27
  run_shell_command,
28
};
29

            
30
#[derive(Debug, Deserialize)]
31
pub struct ContainerBuildArgs {
32
  /// The image name to build
33
  pub image_name: String,
34

            
35
  /// Defines the path to a directory to build the container
36
  pub context: String,
37

            
38
  /// The containerfile or dockerfile to use
39
  #[serde(default)]
40
  pub containerfile: Option<String>,
41

            
42
  /// The tags to apply to the container image
43
  #[serde(default)]
44
  pub tags: Option<Vec<String>>,
45

            
46
  /// Build arguments to pass to the container
47
  #[serde(default)]
48
  pub build_args: Option<Vec<String>>,
49

            
50
  /// Labels to apply to the container image
51
  #[serde(default)]
52
  pub labels: Option<Vec<String>>,
53

            
54
  /// Generate a Software Bill of Materials (SBOM) for the container image
55
  #[serde(default)]
56
  pub sbom: bool,
57

            
58
  /// Do not use cache when building the container
59
  #[serde(default)]
60
  pub no_cache: bool,
61

            
62
  /// Always remove intermediate containers
63
  #[serde(default)]
64
  pub force_rm: bool,
65
}
66

            
67
#[derive(Debug, Deserialize)]
68
pub struct ContainerBuild {
69
  /// The command to run in the container
70
  pub container_build: ContainerBuildArgs,
71

            
72
  /// Show verbose output
73
  #[serde(default)]
74
  pub verbose: Option<bool>,
75
}
76

            
77
#[allow(dead_code)]
78
impl ContainerBuild {
79
  pub fn execute(&self, context: &TaskContext) -> anyhow::Result<()> {
80
    assert!(!self.container_build.context.is_empty());
81

            
82
    let verbose = self.verbose.or(context.verbose).unwrap_or(default_verbose());
83

            
84
    let stdout = get_output_handler(verbose);
85
    let stderr = get_output_handler(verbose);
86

            
87
    let container_runtime = which("docker")
88
      .or_else(|_| which("podman"))
89
      .with_context(|| "Failed to find docker or podman")?;
90

            
91
    let mut cmd = ProcessCommand::new(container_runtime);
92
    cmd.arg("build").stdout(stdout).stderr(stderr);
93

            
94
    if self.container_build.sbom {
95
      cmd.arg("--sbom=true");
96
    }
97

            
98
    if self.container_build.no_cache {
99
      cmd.arg("--no-cache=true");
100
    }
101

            
102
    if self.container_build.force_rm {
103
      cmd.arg("--force-rm=true");
104
    }
105

            
106
    if let Some(build_args) = &self.container_build.build_args {
107
      for arg in build_args {
108
        cmd.arg("--build-arg").arg(arg);
109
      }
110
    }
111

            
112
    if let Some(labels) = &self.container_build.labels {
113
      for label in labels {
114
        let label = self.get_label(context, label.trim())?;
115
        cmd.arg("--label").arg(label);
116
      }
117
    }
118

            
119
    if let Some(tags) = &self.container_build.tags {
120
      for tag in tags {
121
        let tag = self.get_tag(context, tag.trim())?;
122
        let tag = format!("{}:{}", &self.container_build.image_name, tag);
123
        cmd.arg("-t").arg(tag);
124
      }
125
    } else {
126
      let tag = format!("{}:latest", &self.container_build.image_name);
127
      cmd.arg("-t").arg(tag);
128
    }
129

            
130
    if let Some(containerfile) = &self.container_build.containerfile {
131
      cmd.arg("-f").arg(containerfile);
132
    } else {
133
      let dockerfile = format!("{}/Dockerfile", &self.container_build.context);
134
      let containerfile = format!("{}/Containerfile", &self.container_build.context);
135

            
136
      // Check for Dockerfile and Containerfile
137
      if Path::new(&dockerfile).exists() {
138
        cmd.arg("-f").arg(dockerfile);
139
      } else if Path::new(&containerfile).exists() {
140
        cmd.arg("-f").arg(containerfile);
141
      } else {
142
        anyhow::bail!("Failed to find Dockerfile or Containerfile in context");
143
      }
144
    }
145

            
146
    let build_path: &str = &self.container_build.context;
147
    cmd.arg(build_path);
148

            
149
    let cmd_str = format!("{:?}", cmd);
150
    context.multi.println(cmd_str)?;
151

            
152
    // Inject environment variables in both container and command
153
    for (key, value) in context.env_vars.iter() {
154
      cmd.env(key, value);
155
    }
156

            
157
    log::trace!("Running command: {:?}", cmd);
158

            
159
    let mut cmd = cmd.spawn()?;
160
    if verbose {
161
      handle_output!(cmd.stdout, context);
162
      handle_output!(cmd.stderr, context);
163
    }
164

            
165
    let status = cmd.wait()?;
166
    if !status.success() {
167
      anyhow::bail!("Container build failed");
168
    }
169

            
170
    Ok(())
171
  }
172

            
173
  fn shell(&self, context: &TaskContext) -> String {
174
    context.shell.clone().unwrap_or(default_shell())
175
  }
176

            
177
  fn get_tag(&self, context: &TaskContext, tag_in: &str) -> anyhow::Result<String> {
178
    let verbose = self.verbose.or(context.verbose).unwrap_or(default_verbose());
179

            
180
    if is_shell_command(tag_in)? {
181
      let shell: &str = &self.shell(context);
182
      let output = run_shell_command!(tag_in, shell, verbose);
183
      Ok(output)
184
    } else if is_template_command(tag_in)? {
185
      let output = get_template_command_value!(tag_in, context);
186
      Ok(output)
187
    } else {
188
      Ok(tag_in.to_string())
189
    }
190
  }
191

            
192
  fn get_label(&self, context: &TaskContext, label_in: &str) -> anyhow::Result<String> {
193
    use chrono::prelude::*;
194

            
195
    let verbose = self.verbose.or(context.verbose).unwrap_or(default_verbose());
196

            
197
    if let Some((key, value)) = label_in.split_once('=') {
198
      match value {
199
        "MK_NOW" => {
200
          // Create formatted time in +%Y-%m-%dT%H:%M:%S%z format
201
          let now: DateTime<Local> = Local::now();
202
          let now = now.format("%Y-%m-%dT%H:%M:%S%z").to_string();
203
          Ok(format!("{}={}", key, now))
204
        },
205
        "MK_GIT_REVISION" => {
206
          let revision = self.get_git_revision().unwrap_or_else(|_| "unknown".to_string());
207
          Ok(format!("{}={}", key, revision))
208
        },
209
        "MK_GIT_REMOTE_ORIGIN" => {
210
          let remote_url = self
211
            .get_git_remote_origin()
212
            .unwrap_or_else(|_| "unknown".to_string());
213
          Ok(format!("{}={}", key, remote_url))
214
        },
215
        _ => {
216
          let value = if is_shell_command(value)? {
217
            let shell: &str = &context.shell.clone().unwrap_or(default_shell());
218
            run_shell_command!(value, shell, verbose)
219
          } else if is_template_command(value)? {
220
            get_template_command_value!(value, context)
221
          } else {
222
            value.to_string()
223
          };
224

            
225
          Ok(format!("{}={}", key, value))
226
        },
227
      }
228
    } else {
229
      Ok(label_in.to_string())
230
    }
231
  }
232

            
233
  fn get_git_revision(&self) -> anyhow::Result<String> {
234
    let repo = Repository::open(".").context("Failed to open git repository")?;
235
    let head = repo.head().context("Failed to get git HEAD reference")?;
236
    let commit = head
237
      .peel_to_commit()
238
      .context("Failed to resolve git HEAD commit")?;
239
    Ok(commit.id().to_string())
240
  }
241

            
242
  fn get_git_remote_origin(&self) -> anyhow::Result<String> {
243
    let repo = Repository::open(".").context("Failed to open git repository")?;
244
    let remote = repo
245
      .find_remote("origin")
246
      .context("Failed to find git remote origin")?;
247
    let url = remote.url().context("Failed to get git remote URL")?;
248
    Ok(url.to_string())
249
  }
250
}
251

            
252
#[cfg(test)]
253
mod test {
254
  use anyhow::Ok;
255

            
256
  use super::*;
257
18

            
258
18
  #[test]
259
20
  fn test_container_build_1() -> anyhow::Result<()> {
260
20
    let yaml = r#"
261
20
      container_build:
262
20
        image_name: my-image
263
20
        context: .
264
20
        tags:
265
20
          - latest
266
20
        labels:
267
20
          - "org.opencontainers.image.created=MK_NOW"
268
20
          - "org.opencontainers.image.revision=MK_GIT_REVISION"
269
20
          - "org.opencontainers.image.source=MK_GIT_REMOTE_ORIGIN"
270
20
        sbom: true
271
20
        no_cache: true
272
20
        force_rm: true
273
20
      verbose: false
274
20
    "#;
275
20
    let container_build = serde_yaml::from_str::<ContainerBuild>(yaml)?;
276
18

            
277
20
    assert_eq!(container_build.verbose, Some(false));
278
20
    assert_eq!(container_build.container_build.image_name, "my-image");
279
20
    assert_eq!(container_build.container_build.context, ".");
280
20
    assert_eq!(
281
20
      container_build.container_build.tags,
282
20
      Some(vec!["latest".to_string()])
283
20
    );
284
20
    assert_eq!(
285
20
      container_build.container_build.labels,
286
20
      Some(vec![
287
20
        "org.opencontainers.image.created=MK_NOW".to_string(),
288
20
        "org.opencontainers.image.revision=MK_GIT_REVISION".to_string(),
289
20
        "org.opencontainers.image.source=MK_GIT_REMOTE_ORIGIN".to_string(),
290
20
      ])
291
20
    );
292
20
    assert!(container_build.container_build.sbom);
293
20
    assert!(container_build.container_build.no_cache);
294
2
    assert!(container_build.container_build.force_rm);
295
2
    Ok(())
296
20
  }
297
18

            
298
18
  #[test]
299
20
  fn test_container_build_2() -> anyhow::Result<()> {
300
20
    let yaml = r#"
301
20
      container_build:
302
20
        image_name: my-image
303
20
        context: .
304
20
    "#;
305
20
    let container_build = serde_yaml::from_str::<ContainerBuild>(yaml)?;
306
18

            
307
20
    assert_eq!(container_build.verbose, None);
308
20
    assert_eq!(container_build.container_build.image_name, "my-image");
309
20
    assert_eq!(container_build.container_build.context, ".");
310
20
    assert_eq!(container_build.container_build.tags, None,);
311
20
    assert_eq!(container_build.container_build.labels, None,);
312
20
    assert!(!container_build.container_build.sbom);
313
2
    assert!(!container_build.container_build.no_cache);
314
2
    assert!(!container_build.container_build.force_rm);
315
18

            
316
20
    Ok(())
317
20
  }
318
18

            
319
18
  #[test]
320
20
  fn test_container_build_3() -> anyhow::Result<()> {
321
20
    let yaml = r#"
322
20
      container_build:
323
20
        image_name: docker.io/my-image/my-image
324
20
        context: /hello/world
325
20
    "#;
326
20
    let container_build = serde_yaml::from_str::<ContainerBuild>(yaml)?;
327
18

            
328
20
    assert_eq!(container_build.verbose, None);
329
20
    assert_eq!(
330
20
      container_build.container_build.image_name,
331
20
      "docker.io/my-image/my-image"
332
20
    );
333
20
    assert_eq!(container_build.container_build.context, "/hello/world");
334
20
    assert_eq!(container_build.container_build.tags, None,);
335
2
    assert_eq!(container_build.container_build.labels, None,);
336
2
    assert!(!container_build.container_build.sbom);
337
2
    assert!(!container_build.container_build.no_cache);
338
2
    assert!(!container_build.container_build.force_rm);
339

            
340
2
    Ok(())
341
2
  }
342
}