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

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

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

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

            
33
  /// Defines the path to a directory to build the container
34
  pub context: String,
35

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

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

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

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

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

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

            
60
  /// Always remove intermediate containers
61
  #[serde(default)]
62
  pub force_rm: bool,
63

            
64
  /// The container runtime to use
65
  #[serde(default)]
66
  pub runtime: Option<ContainerRuntime>,
67
}
68

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

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

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

            
84
    let verbose = self.verbose.or(context.verbose).unwrap_or(default_verbose());
85

            
86
    let stdout = get_output_handler(verbose);
87
    let stderr = get_output_handler(verbose);
88

            
89
    let resolved_context = self.resolved_context(context);
90
    let resolved_containerfile = self.resolved_containerfile(context);
91

            
92
    let container_runtime = ContainerRuntime::resolve(
93
      self
94
        .container_build
95
        .runtime
96
        .as_ref()
97
        .or(context.container_runtime.as_ref()),
98
    )?;
99

            
100
    let mut cmd = ProcessCommand::new(container_runtime);
101
    cmd.arg("build").stdout(stdout).stderr(stderr);
102

            
103
    if self.container_build.sbom {
104
      cmd.arg("--sbom=true");
105
    }
106

            
107
    if self.container_build.no_cache {
108
      cmd.arg("--no-cache=true");
109
    }
110

            
111
    if self.container_build.force_rm {
112
      cmd.arg("--force-rm=true");
113
    }
114

            
115
    if let Some(build_args) = &self.container_build.build_args {
116
      for arg in build_args {
117
        cmd.arg("--build-arg").arg(arg);
118
      }
119
    }
120

            
121
    if let Some(labels) = &self.container_build.labels {
122
      for label in labels {
123
        let label = self.get_label(context, label.trim())?;
124
        cmd.arg("--label").arg(label);
125
      }
126
    }
127

            
128
    if let Some(tags) = &self.container_build.tags {
129
      for tag in tags {
130
        let tag = self.get_tag(context, tag.trim())?;
131
        let tag = format!("{}:{}", &self.container_build.image_name, tag);
132
        cmd.arg("-t").arg(tag);
133
      }
134
    } else {
135
      let tag = format!("{}:latest", &self.container_build.image_name);
136
      cmd.arg("-t").arg(tag);
137
    }
138

            
139
    if let Some(containerfile) = &resolved_containerfile {
140
      cmd.arg("-f").arg(containerfile);
141
    } else {
142
      let dockerfile = resolved_context.join("Dockerfile");
143
      let containerfile = resolved_context.join("Containerfile");
144

            
145
      // Check for Dockerfile and Containerfile
146
      if dockerfile.exists() {
147
        cmd.arg("-f").arg(dockerfile);
148
      } else if containerfile.exists() {
149
        cmd.arg("-f").arg(containerfile);
150
      } else {
151
        anyhow::bail!("Failed to find Dockerfile or Containerfile in context");
152
      }
153
    }
154

            
155
    cmd.arg(&resolved_context);
156

            
157
    let cmd_str = format!("{:?}", cmd);
158
    context.multi.println(cmd_str)?;
159

            
160
    // Inject environment variables in both container and command
161
    for (key, value) in context.env_vars.iter() {
162
      cmd.env(key, value);
163
    }
164

            
165
    log::trace!("Running command: {:?}", cmd);
166

            
167
    let mut cmd = cmd.spawn()?;
168
    if verbose {
169
      handle_output!(cmd.stdout, context);
170
      handle_output!(cmd.stderr, context);
171
    }
172

            
173
    let status = cmd.wait()?;
174
    if !status.success() {
175
      // Note: container build failures are always fatal and do not honor task-level ignore_errors.
176
      anyhow::bail!("Container build failed");
177
    }
178

            
179
    Ok(())
180
  }
181

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

            
185
    if is_shell_command(tag_in)? {
186
      let mut cmd = context.shell().proc();
187
      let output = run_shell_command!(tag_in, cmd, verbose);
188
      Ok(output)
189
    } else if is_template_command(tag_in)? {
190
      let output = get_template_command_value!(tag_in, context);
191
      Ok(output)
192
    } else {
193
      Ok(tag_in.to_string())
194
    }
195
  }
196

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

            
200
    let verbose = self.verbose.or(context.verbose).unwrap_or(default_verbose());
201

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

            
232
          Ok(format!("{}={}", key, value))
233
        },
234
      }
235
    } else {
236
      Ok(label_in.to_string())
237
    }
238
  }
239

            
240
  fn get_git_revision(&self, context: &TaskContext) -> anyhow::Result<String> {
241
    let repo = self.open_git_repository(context)?;
242
    let head = repo.head().context("Failed to get git HEAD reference")?;
243
    let commit = head
244
      .peel_to_commit()
245
      .context("Failed to resolve git HEAD commit")?;
246
    Ok(commit.id().to_string())
247
  }
248

            
249
  fn get_git_remote_origin(&self, context: &TaskContext) -> anyhow::Result<String> {
250
    let repo = self.open_git_repository(context)?;
251
    let remote = repo
252
      .find_remote("origin")
253
      .context("Failed to find git remote origin")?;
254
    let url = remote.url().context("Failed to get git remote URL")?;
255
    Ok(url.to_string())
256
  }
257

            
258
  pub fn resolved_context(&self, context: &TaskContext) -> PathBuf {
259
    context.resolve_from_config(&self.container_build.context)
260
  }
261

            
262
  pub fn resolved_containerfile(&self, context: &TaskContext) -> Option<PathBuf> {
263
    self
264
      .container_build
265
      .containerfile
266
      .as_ref()
267
      .map(|containerfile| context.resolve_from_config(containerfile))
268
  }
269

            
270
  fn open_git_repository(&self, context: &TaskContext) -> anyhow::Result<Repository> {
271
    let resolved_context = self.resolved_context(context);
272
    Repository::discover(&resolved_context).with_context(|| {
273
      format!(
274
        "Failed to open git repository from build context - {}",
275
        resolved_context.to_string_lossy()
276
      )
277
    })
278
  }
279
}
280

            
281
#[cfg(test)]
282
mod test {
283
  use anyhow::Ok;
284

            
285
  use super::*;
286

            
287
  #[test]
288
2
  fn test_container_build_1() -> anyhow::Result<()> {
289
2
    let yaml = r#"
290
2
      container_build:
291
2
        image_name: my-image
292
2
        context: .
293
2
        tags:
294
2
          - latest
295
2
        labels:
296
2
          - "org.opencontainers.image.created=MK_NOW"
297
2
          - "org.opencontainers.image.revision=MK_GIT_REVISION"
298
2
          - "org.opencontainers.image.source=MK_GIT_REMOTE_ORIGIN"
299
2
        sbom: true
300
2
        no_cache: true
301
2
        force_rm: true
302
2
      verbose: false
303
2
    "#;
304
2
    let container_build = serde_yaml::from_str::<ContainerBuild>(yaml)?;
305

            
306
2
    assert_eq!(container_build.verbose, Some(false));
307
2
    assert_eq!(container_build.container_build.image_name, "my-image");
308
2
    assert_eq!(container_build.container_build.context, ".");
309
2
    assert_eq!(
310
      container_build.container_build.tags,
311
2
      Some(vec!["latest".to_string()])
312
    );
313
2
    assert_eq!(
314
      container_build.container_build.labels,
315
2
      Some(vec![
316
2
        "org.opencontainers.image.created=MK_NOW".to_string(),
317
2
        "org.opencontainers.image.revision=MK_GIT_REVISION".to_string(),
318
2
        "org.opencontainers.image.source=MK_GIT_REMOTE_ORIGIN".to_string(),
319
2
      ])
320
    );
321
2
    assert!(container_build.container_build.sbom);
322
2
    assert!(container_build.container_build.no_cache);
323
2
    assert!(container_build.container_build.force_rm);
324
2
    Ok(())
325
2
  }
326

            
327
  #[test]
328
2
  fn test_container_build_2() -> anyhow::Result<()> {
329
2
    let yaml = r#"
330
2
      container_build:
331
2
        image_name: my-image
332
2
        context: .
333
2
    "#;
334
2
    let container_build = serde_yaml::from_str::<ContainerBuild>(yaml)?;
335

            
336
2
    assert_eq!(container_build.verbose, None);
337
2
    assert_eq!(container_build.container_build.image_name, "my-image");
338
2
    assert_eq!(container_build.container_build.context, ".");
339
2
    assert_eq!(container_build.container_build.tags, None,);
340
2
    assert_eq!(container_build.container_build.labels, None,);
341
2
    assert!(!container_build.container_build.sbom);
342
2
    assert!(!container_build.container_build.no_cache);
343
2
    assert!(!container_build.container_build.force_rm);
344

            
345
2
    Ok(())
346
2
  }
347

            
348
  #[test]
349
2
  fn test_container_build_3() -> anyhow::Result<()> {
350
2
    let yaml = r#"
351
2
      container_build:
352
2
        image_name: docker.io/my-image/my-image
353
2
        context: /hello/world
354
2
    "#;
355
2
    let container_build = serde_yaml::from_str::<ContainerBuild>(yaml)?;
356

            
357
2
    assert_eq!(container_build.verbose, None);
358
2
    assert_eq!(
359
      container_build.container_build.image_name,
360
      "docker.io/my-image/my-image"
361
    );
362
2
    assert_eq!(container_build.container_build.context, "/hello/world");
363
2
    assert_eq!(container_build.container_build.tags, None,);
364
2
    assert_eq!(container_build.container_build.labels, None,);
365
2
    assert!(!container_build.container_build.sbom);
366
2
    assert!(!container_build.container_build.no_cache);
367
2
    assert!(!container_build.container_build.force_rm);
368

            
369
2
    Ok(())
370
2
  }
371
}