depl 2.4.3

Toolkit for a bunch of local and remote CI/CD actions
Documentation
//! Containered options module.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

use crate::entities::driver::PipelineDriver;
use crate::entities::environment::RunEnvironment;
use crate::entities::info::{ContentInfo, ShortName, info2str_opt, str2info_wl_opt};
use crate::entities::variables::Variable;
use crate::storage::use_from_storage;

/// Port binding.
#[derive(Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
pub struct PortBinding {
  /// Host port.
  pub from: u16,

  /// Container port.
  pub to: u16,
}

/// Container executor.
#[derive(Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Clone, Default)]
#[serde(rename_all = "snake_case")]
#[allow(missing_docs)]
pub enum ContainerExecutor {
  Docker,
  Podman,
  #[default]
  Auto,
}

impl ContainerExecutor {
  /// Is container executor Docker?
  pub fn is_docker(&self) -> bool {
    match self {
      Self::Docker => true,
      Self::Podman => false,
      Self::Auto => !matches!(
        std::env::var("DEPLOYER_CONTAINER_DEFAULT_EXECUTOR").as_deref(),
        Ok("podman")
      ),
    }
  }

  /// Is container executor default?
  pub fn is_default(&self) -> bool {
    *self == Self::Auto
  }
}

/// Options for containered runs.
#[derive(Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Clone, Default)]
pub struct ContaineredOpts {
  /// Base image tag.
  #[serde(skip_serializing_if = "Option::is_none")]
  pub base_image: Option<String>,

  /// Variables that used in `preflight_cmds`, `deployer_build_cmds` and `cache_strategies`.
  #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
  pub with: BTreeMap<String, ShortName>,

  /// Set of commands to install on a given base image.
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
  pub preflight_cmds: Vec<String>,

  /// Base Deployer build image tag.
  #[serde(skip_serializing_if = "Option::is_none")]
  pub build_deployer_base_image: Option<String>,

  /// [Dockerfile commands](https://docs.docker.com/reference/dockerfile/) to build Deployer itself.
  ///
  /// Requirements:
  ///
  /// 1. Install `git` and clone `deployer` from any repository.
  /// 2. Install nightly Rust toolchain via [`rustup`](https://rustup.rs/).
  /// 3. Build `deployer` by running `cargo build --release`.
  ///
  /// Default commands for `ubuntu:latest` image:
  ///
  /// ```dockerfile
  /// RUN apt-get update && apt-get install -y build-essential curl git && rm -rf /var/lib/apt/lists/*
  /// RUN curl https://sh.rustup.rs -sSf | bash -s -- -y --profile minimal --default-toolchain nightly
  /// ENV PATH="/root/.cargo/bin:${PATH}"
  /// RUN git clone --single-branch --branch unstable https://github.com/impulse-sw/deployer.git && cd deployer && cargo build --release
  /// ```
  ///
  /// Note that you should especially build in release mode, not install or build in debug mode.
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
  pub deployer_build_cmds: Vec<String>,

  /// User to own copied files inside container.
  #[serde(default, skip_serializing_if = "String::is_empty")]
  pub user: String,

  /// Cache strategy to build & save stage cache.
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
  pub cache_strategies: Vec<ContainerizedBuildStrategyStep>,

  /// Run container detached after build.
  #[serde(default, skip_serializing_if = "crate::utils::is_false")]
  pub run_detached: bool,

  /// Port bindings.
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
  pub port_bindings: Vec<PortBinding>,

  /// Allow internal host bind.
  #[serde(default, skip_serializing_if = "crate::utils::is_false")]
  pub allow_internal_host_bind: bool,

  /// Use `containerd` image store for local cache.
  ///
  /// This allows you to cleanup build cache via `depl clean`.
  /// Make sure that `docker` containerd image store is enabled (`/etc/docker/daemon.json`):
  ///
  /// ```json
  /// {
  ///   "features": {
  ///     "containerd-snapshotter": true
  ///   }
  /// }
  /// ```
  #[serde(default, skip_serializing_if = "crate::utils::is_false")]
  pub use_containerd_local_storage_cache: bool,

  /// Prevent metadata loading on every startup.
  #[serde(default, skip_serializing_if = "crate::utils::is_false")]
  pub prevent_metadata_loading: bool,

  /// Container executor (runner).
  ///
  /// Tells to Deployer which commands should it use:
  ///
  /// 1. Docker (`docker`).
  /// 2. Podman (`podman`).
  ///
  /// Default is Docker (`docker`).
  #[serde(default, skip_serializing_if = "ContainerExecutor::is_default")]
  pub executor: ContainerExecutor,
}

/// Replaces placeholders with variables' values.
pub async fn try_enplace(
  lines: &[String],
  env: &RunEnvironment<'_>,
  vars: &BTreeMap<String, Variable>,
) -> anyhow::Result<Vec<String>> {
  let mut lines = lines.to_vec();
  for line in &mut lines {
    for (placeholder, variable) in vars.iter() {
      if line.contains(placeholder) {
        *line = line.replace(placeholder, &variable.get_value(env).await?);
      }
    }
  }
  Ok(lines)
}

/// Returns `COPY` command for given user.
pub fn copy_cmd(user: impl AsRef<str>, from: impl AsRef<str>, to: impl AsRef<str>) -> String {
  format!(
    "COPY {}{} {}",
    if !user.as_ref().is_empty() {
      format!("--chown={} ", user.as_ref())
    } else {
      "".to_string()
    },
    from.as_ref(),
    to.as_ref()
  )
}

impl ContaineredOpts {
  /// Synchronizes fake content to make cache strategy works.
  pub fn sync_fake_content(&self, env: &RunEnvironment) -> anyhow::Result<()> {
    for strategy in self.cache_strategies.iter() {
      strategy.sync_fake_content(env)?;
    }
    Ok(())
  }

  /// Unite strategy actions.
  pub async fn concat_strategies(
    &self,
    env: &RunEnvironment<'_>,
    user: &str,
    config_filepath: &str,
    vars: &BTreeMap<String, Variable>,
  ) -> anyhow::Result<Option<String>> {
    if !self.cache_strategies.is_empty() {
      let mut strs = vec![];
      for strategy in self.cache_strategies.iter() {
        strs.push(strategy.concat(env, user, config_filepath, vars).await?);
      }
      Ok(Some(strs.join("\n")))
    } else {
      Ok(None)
    }
  }
}

/// Build strategy.
///
/// Docker (and other container build environments) supports build cache, which allows you to
/// specify your build strategy.
///
/// For example, you can use two-stage builds for Rust projects.
#[derive(Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Clone, Default)]
pub struct ContainerizedBuildStrategyStep {
  /// Fake content's short name and version.
  #[serde(default, skip_serializing_if = "Option::is_none")]
  #[serde(serialize_with = "info2str_opt", deserialize_with = "str2info_wl_opt")]
  pub fake_content: Option<ContentInfo>,
  /// [Dockerfile commands](https://docs.docker.com/reference/dockerfile/) to copy files from project folder to the image.
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
  pub copy_cmds: Vec<String>,
  /// [Dockerfile commands](https://docs.docker.com/reference/dockerfile/) to create cache (perform actions that create this cache).
  ///
  /// You can also request Deployer's build for pipeline by `DEPL` command.
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
  pub pre_cache_cmds: Vec<String>,
}

impl ContainerizedBuildStrategyStep {
  fn sync_fake_content(&self, env: &RunEnvironment) -> anyhow::Result<()> {
    if let Some(content_info) = &self.fake_content {
      use_from_storage(env.storage_dir, env.run_dir, content_info)?;
    }
    Ok(())
  }

  async fn concat(
    &self,
    env: &RunEnvironment<'_>,
    user: &str,
    config_filepath: &str,
    vars: &BTreeMap<String, Variable>,
  ) -> anyhow::Result<String> {
    let copy_cmds = try_enplace(&self.copy_cmds, env, vars).await?;

    let mut pre_cache_cmds = try_enplace(&self.pre_cache_cmds, env, vars).await?;
    for cmd in &mut pre_cache_cmds {
      if cmd.as_str().eq("DEPL") {
        *cmd = match env.driver {
          PipelineDriver::Deployer => {
            format!(
              "ENV DEPLOYER_CONTAINERED_BUILD=1\nRUN /depl --config deploy-config.yaml run {} --current\nENV DEPLOYER_CONTAINERED_BUILD=0",
              env.master_pipeline,
            )
          }
          PipelineDriver::Shell => {
            format!("{}\nRUN /app/{config_filepath}", copy_cmd(user, config_filepath, "."))
          }
        };
      }
    }

    let divider = match env.driver {
      PipelineDriver::Deployer => format!("\n{}\n", copy_cmd(user, config_filepath, "deploy-config.yaml")),
      PipelineDriver::Shell => "\n".to_string(),
    };

    Ok(copy_cmds.join("\n") + divider.as_str() + pre_cache_cmds.join("\n").as_str())
  }
}

impl std::fmt::Display for ContainerizedBuildStrategyStep {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    let parts = vec![
      if let Some(fake) = self.fake_content.as_ref() {
        format!("fake content: {}", fake.to_str())
      } else {
        String::new()
      },
      if !self.copy_cmds.is_empty() {
        "some files to copy".to_string()
      } else {
        String::new()
      },
      if !self.pre_cache_cmds.is_empty() {
        "some commands to cache".to_string()
      } else {
        String::new()
      },
    ];
    let mut strat_desc = parts
      .into_iter()
      .filter(|p| !p.is_empty())
      .collect::<Vec<_>>()
      .join(", ");
    if strat_desc.is_empty() {
      strat_desc = "empty step".to_string();
    }
    write!(f, "{strat_desc}")
  }
}