mk 0.7.12

Yet another simple task runner 🦀
Documentation
use std::io::{
  BufRead as _,
  BufReader,
};
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
use std::thread;

use anyhow::Context as _;
use git2::Repository;
use schemars::JsonSchema;
use serde::Deserialize;

use crate::defaults::default_verbose;
use crate::schema::{
  get_output_handler,
  is_shell_command,
  is_template_command,
  ContainerRuntime,
  TaskContext,
};
use crate::{
  get_template_command_value,
  handle_output,
  run_shell_command,
};

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

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

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

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

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

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

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

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

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

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

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

  /// Show verbose output
  #[serde(default)]
  pub verbose: Option<bool>,
}

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

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

    let stdout = get_output_handler(verbose);
    let stderr = get_output_handler(verbose);

    let resolved_context = self.resolved_context(context);
    let resolved_containerfile = self.resolved_containerfile(context);

    let container_runtime = ContainerRuntime::resolve(
      self
        .container_build
        .runtime
        .as_ref()
        .or(context.container_runtime.as_ref()),
    )?;

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

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

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

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

    if let Some(build_args) = &self.container_build.build_args {
      for arg in build_args {
        cmd.arg("--build-arg").arg(arg);
      }
    }

    if let Some(labels) = &self.container_build.labels {
      for label in labels {
        let label = self.get_label(context, label.trim())?;
        cmd.arg("--label").arg(label);
      }
    }

    if let Some(tags) = &self.container_build.tags {
      for tag in tags {
        let tag = self.get_tag(context, tag.trim())?;
        let tag = format!("{}:{}", &self.container_build.image_name, tag);
        cmd.arg("-t").arg(tag);
      }
    } else {
      let tag = format!("{}:latest", &self.container_build.image_name);
      cmd.arg("-t").arg(tag);
    }

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

      // Check for Dockerfile and Containerfile
      if dockerfile.exists() {
        cmd.arg("-f").arg(dockerfile);
      } else if containerfile.exists() {
        cmd.arg("-f").arg(containerfile);
      } else {
        anyhow::bail!("Failed to find Dockerfile or Containerfile in context");
      }
    }

    cmd.arg(&resolved_context);

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

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

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

    let mut cmd = cmd.spawn()?;
    if verbose {
      handle_output!(cmd.stdout, context);
      handle_output!(cmd.stderr, context);
    }

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

    Ok(())
  }

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

    if is_shell_command(tag_in)? {
      let mut cmd = context.shell().proc();
      let output = run_shell_command!(tag_in, cmd, verbose);
      Ok(output)
    } else if is_template_command(tag_in)? {
      let output = get_template_command_value!(tag_in, context);
      Ok(output)
    } else {
      Ok(tag_in.to_string())
    }
  }

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

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

    if let Some((key, value)) = label_in.split_once('=') {
      match value {
        "MK_NOW" => {
          // Create formatted time in +%Y-%m-%dT%H:%M:%S%z format
          let now: DateTime<Local> = Local::now();
          let now = now.format("%Y-%m-%dT%H:%M:%S%z").to_string();
          Ok(format!("{}={}", key, now))
        },
        "MK_GIT_REVISION" => {
          let revision = self
            .get_git_revision(context)
            .unwrap_or_else(|_| "unknown".to_string());
          Ok(format!("{}={}", key, revision))
        },
        "MK_GIT_REMOTE_ORIGIN" => {
          let remote_url = self
            .get_git_remote_origin(context)
            .unwrap_or_else(|_| "unknown".to_string());
          Ok(format!("{}={}", key, remote_url))
        },
        _ => {
          let value = if is_shell_command(value)? {
            let mut cmd = context.shell().proc();
            run_shell_command!(value, cmd, verbose)
          } else if is_template_command(value)? {
            get_template_command_value!(value, context)
          } else {
            value.to_string()
          };

          Ok(format!("{}={}", key, value))
        },
      }
    } else {
      Ok(label_in.to_string())
    }
  }

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

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

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

  pub fn resolved_containerfile(&self, context: &TaskContext) -> Option<PathBuf> {
    self
      .container_build
      .containerfile
      .as_ref()
      .map(|containerfile| context.resolve_from_config(containerfile))
  }

  fn open_git_repository(&self, context: &TaskContext) -> anyhow::Result<Repository> {
    let resolved_context = self.resolved_context(context);
    Repository::discover(&resolved_context).with_context(|| {
      format!(
        "Failed to open git repository from build context - {}",
        resolved_context.to_string_lossy()
      )
    })
  }
}

#[cfg(test)]
mod test {
  use anyhow::Ok;

  use super::*;

  #[test]
  fn test_container_build_1() -> anyhow::Result<()> {
    let yaml = r#"
      container_build:
        image_name: my-image
        context: .
        tags:
          - latest
        labels:
          - "org.opencontainers.image.created=MK_NOW"
          - "org.opencontainers.image.revision=MK_GIT_REVISION"
          - "org.opencontainers.image.source=MK_GIT_REMOTE_ORIGIN"
        sbom: true
        no_cache: true
        force_rm: true
      verbose: false
    "#;
    let container_build = serde_yaml::from_str::<ContainerBuild>(yaml)?;

    assert_eq!(container_build.verbose, Some(false));
    assert_eq!(container_build.container_build.image_name, "my-image");
    assert_eq!(container_build.container_build.context, ".");
    assert_eq!(
      container_build.container_build.tags,
      Some(vec!["latest".to_string()])
    );
    assert_eq!(
      container_build.container_build.labels,
      Some(vec![
        "org.opencontainers.image.created=MK_NOW".to_string(),
        "org.opencontainers.image.revision=MK_GIT_REVISION".to_string(),
        "org.opencontainers.image.source=MK_GIT_REMOTE_ORIGIN".to_string(),
      ])
    );
    assert!(container_build.container_build.sbom);
    assert!(container_build.container_build.no_cache);
    assert!(container_build.container_build.force_rm);
    Ok(())
  }

  #[test]
  fn test_container_build_2() -> anyhow::Result<()> {
    let yaml = r#"
      container_build:
        image_name: my-image
        context: .
    "#;
    let container_build = serde_yaml::from_str::<ContainerBuild>(yaml)?;

    assert_eq!(container_build.verbose, None);
    assert_eq!(container_build.container_build.image_name, "my-image");
    assert_eq!(container_build.container_build.context, ".");
    assert_eq!(container_build.container_build.tags, None,);
    assert_eq!(container_build.container_build.labels, None,);
    assert!(!container_build.container_build.sbom);
    assert!(!container_build.container_build.no_cache);
    assert!(!container_build.container_build.force_rm);

    Ok(())
  }

  #[test]
  fn test_container_build_3() -> anyhow::Result<()> {
    let yaml = r#"
      container_build:
        image_name: docker.io/my-image/my-image
        context: /hello/world
    "#;
    let container_build = serde_yaml::from_str::<ContainerBuild>(yaml)?;

    assert_eq!(container_build.verbose, None);
    assert_eq!(
      container_build.container_build.image_name,
      "docker.io/my-image/my-image"
    );
    assert_eq!(container_build.container_build.context, "/hello/world");
    assert_eq!(container_build.container_build.tags, None,);
    assert_eq!(container_build.container_build.labels, None,);
    assert!(!container_build.container_build.sbom);
    assert!(!container_build.container_build.no_cache);
    assert!(!container_build.container_build.force_rm);

    Ok(())
  }
}