depl 2.4.3

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

use anyhow::bail;
use colored::Colorize;
use std::collections::BTreeMap;
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::exit;

use crate::ARTIFACTS_DIR;
use crate::entities::ansible_opts::AnsibleOpts;
use crate::entities::custom_command::CustomCommand;
use crate::entities::driver::PipelineDriver;
use crate::entities::environment::RunEnvironment;
use crate::entities::info::ShortName;
use crate::entities::remote_host::RemoteHost;
use crate::entities::requirements::Requirement;
use crate::pipelines::DescribedPipeline;
use crate::project::DeployerProjectOptions;
use crate::rw::log;

const INVENTORY_TEMPLATE: &str = "[targets]\n{remotes}";
const PLAYBOOK_TEMPLATE_DEPL_DRIVER: &str = r#"# Generated by Deployer 2.X
- name: {pipeline_name}
  hosts: {host-group}
  become: {sudo}
  tasks:
    - name: Ensure `rsync` plugin
      ansible.builtin.package:
        name: rsync
        state: present
    - name: Create deployment directory
      ansible.builtin.file:
        path: '{{ ansible_env.HOME }}/.cache/deploy-cache/ansible-remote/{project_name}'
        state: directory
        mode: '0755'
    - name: Synchronize project files
      ansible.builtin.synchronize:
        src: ./
        dest: '{{ ansible_env.HOME }}/.cache/deploy-cache/ansible-remote/{project_name}'
        archive: yes
        rsync_opts:
          - "--delete"{exclude_files}
      delegate_to: "{{ inventory_hostname }}"
    - name: Run Deployer Pipeline
      ansible.builtin.shell: |
        cd {{ ansible_env.HOME }}/.cache/deploy-cache/ansible-remote/{project_name}
        DEPLOYER_ANSIBLE_RUN=1 {{ ansible_env.HOME }}/.cargo/bin/depl run -j -k {pipeline_name}
      register: deployer_result
    - name: Display Deployer output
      ansible.builtin.debug:
        msg: "{{ deployer_result.stdout_lines }}"{possible_artifacts}
"#;
const PLAYBOOK_TEMPLATE_SHELL_DRIVER: &str = r#"- name: {pipeline_name}
  hosts: {host-group}
  become: {sudo}
  tasks:
    - name: Ensure `rsync` plugin
      ansible.builtin.package:
        name: rsync
        state: present
    - name: Create deployment directory
      ansible.builtin.file:
        path: '{{ ansible_env.HOME }}/.cache/deploy-cache/ansible-remote/{project_name}'
        state: directory
        mode: '0755'
    - name: Synchronize project files
      ansible.builtin.synchronize:
        src: ./
        dest: '{{ ansible_env.HOME }}/.cache/deploy-cache/ansible-remote/{project_name}'
        archive: yes
        rsync_opts:
          - "--delete"{exclude_files}
      delegate_to: "{{ inventory_hostname }}"
    - name: Run Deployer Pipeline
      ansible.builtin.shell: |
        cd {{ ansible_env.HOME }}/.cache/deploy-cache/ansible-remote/{project_name}
        ./{run-script}
      register: deployer_result
    - name: Display Deployer output
      ansible.builtin.debug:
        msg: "{{ deployer_result.stdout_lines }}"{possible_artifacts}
"#;
const POSSIBLE_ARTIFACTS_SYNC: &str = r#"- name: Fetch artifact
      ansible.builtin.fetch:
        src: '{{ ansible_env.HOME }}/.cache/deploy-cache/ansible-remote/{project_name}/artifacts/{relative-path}'
        dest: './artifacts/{{ inventory_hostname }}/{relative-path}'
        flat: yes
        fail_on_missing: no"#;

/// Makes Ansible inventory from given options.
pub fn make_inventory(
  env: &RunEnvironment,
  opts: &AnsibleOpts,
  remotes: &BTreeMap<ShortName, RemoteHost>,
) -> anyhow::Result<()> {
  if !opts.create_inventory.is_empty() && opts.use_inventory.is_some() {
    bail!("Use either `create_inventory` or `use_inventory` field in Ansible options!")
  }

  if !opts.create_inventory.is_empty() {
    let mut remote = vec![];
    for short_name in opts.create_inventory.iter() {
      if let Some(host) = remotes.get(short_name) {
        remote.push(host.clone());
      }
    }

    let remotes = remote
      .iter()
      .map(|remote| {
        format!(
          "{} ansible_port={} ansible_user={}",
          remote.ip, remote.port, remote.username
        )
      })
      .collect::<Vec<_>>()
      .join("\n");
    let inventory = INVENTORY_TEMPLATE.replace("{remotes}", &remotes);
    std::fs::write(env.run_dir.join("inventory.ini"), inventory)?;
  }

  if let Some(inventory_path) = &opts.use_inventory {
    std::fs::copy(inventory_path, env.run_dir.join("inventory.ini"))?;
  }

  Ok(())
}

/// Makes Ansible playbook from given pipeline.
pub async fn make_playbook(
  config: &DeployerProjectOptions,
  env: &RunEnvironment<'_>,
  pipeline: &DescribedPipeline,
) -> anyhow::Result<String> {
  let opts = pipeline.ansible_opts.as_ref().unwrap();
  let playbook_name = format!(
    "playbook.{}.{}.ansible.yml",
    pipeline.title,
    config.project_name.as_str()
  );

  let s = "sudo".to_string();
  let needs_sudo = if pipeline
    .return_all_cmds(&config.actions)
    .iter()
    .any(|cmd| cmd.contains(&s))
  {
    "true"
  } else {
    "false"
  };

  let exclude: std::collections::BTreeSet<_> = env.ignore.iter().collect();
  let mut exclude = exclude
    .iter()
    .map(|i: &&PathBuf| {
      format!(
        "\n          - \"--exclude={}\"",
        i.to_str().expect("`exclude` file contains non-UTF8 symbols!")
      )
    })
    .collect::<Vec<_>>();
  exclude.extend_from_slice(&[
    "\n          - \"--exclude=artifacts\"".to_string(),
    "\n          - \"--exclude=inventory.ini\"".to_string(),
    format!("\n          - \"--exclude={}\"", playbook_name.as_str()),
  ]);
  let exclude = exclude.join("");

  let afs_sync = if !pipeline.artifacts.is_empty() {
    pipeline
      .artifacts
      .iter()
      .map(|pl| {
        format!(
          "\n    {}",
          POSSIBLE_ARTIFACTS_SYNC
            .replace("{project_name}", config.project_name.as_str())
            .replace("{relative-path}", pl.to.as_str())
        )
      })
      .collect::<Vec<_>>()
      .join("")
  } else {
    String::new()
  };
  let playbook_content = match env.driver {
    PipelineDriver::Deployer => PLAYBOOK_TEMPLATE_DEPL_DRIVER
      .replace("{pipeline_name}", &pipeline.title)
      .replace("{host-group}", opts.host_group.as_deref().unwrap_or("all"))
      .replace("{sudo}", needs_sudo)
      .replace("{project_name}", config.project_name.as_str())
      .replace("{exclude_files}", exclude.as_str())
      .replace(
        "{possible_artifacts}",
        if !pipeline.artifacts.is_empty() {
          afs_sync.as_str()
        } else {
          ""
        },
      ),
    PipelineDriver::Shell => {
      let run_env = RunEnvironment {
        ansible_run: true,
        daemons: Default::default(),
        skipper: Default::default(),
        restart_requested: env.restart_requested.clone(),
        ..*env
      };
      let run_script = pipeline.to_shell_script(config, &run_env).await?;
      let run_script_filename = format!(".pipe.{}.ansible.sh", env.master_pipeline);
      let run_script_filepath = env.run_dir.join(run_script_filename.as_str());
      std::fs::write(&run_script_filepath, run_script)?;
      std::fs::set_permissions(run_script_filepath, Permissions::from_mode(0o700))?;
      PLAYBOOK_TEMPLATE_SHELL_DRIVER
        .replace("{pipeline_name}", &pipeline.title)
        .replace("{host-group}", opts.host_group.as_deref().unwrap_or("all"))
        .replace("{sudo}", needs_sudo)
        .replace("{project_name}", config.project_name.as_str())
        .replace(
          "{exclude_files}",
          if !env.ignore.is_empty() { exclude.as_str() } else { "" },
        )
        .replace("{run-script}", run_script_filename.as_str())
        .replace(
          "{possible_artifacts}",
          if !pipeline.artifacts.is_empty() {
            afs_sync.as_str()
          } else {
            ""
          },
        )
    }
  };

  std::fs::write(env.run_dir.join(&playbook_name), playbook_content)?;

  Ok(playbook_name)
}

/// Runs given pipeline with Ansible.
pub async fn execute_pipeline_with_ansible(
  config: &DeployerProjectOptions,
  env: &RunEnvironment<'_>,
  pipeline: &DescribedPipeline,
) -> anyhow::Result<bool> {
  if pipeline.ansible_opts.is_none() {
    bail!("There is no Ansible options provided!");
  }
  let opts = pipeline.ansible_opts.as_ref().unwrap();

  let canonicalized = env.run_dir.canonicalize()?;
  let canonicalized = canonicalized.to_str().expect("Can't convert `Path` to string!");
  println!("Run path: {}", canonicalized);

  make_inventory(env, opts, env.remotes)?;
  let playbook_filename = make_playbook(config, env, pipeline).await?;
  if !pipeline.artifacts.is_empty() {
    let _ = std::fs::create_dir(env.run_dir.join(ARTIFACTS_DIR));
  }

  println!("{}", "Inventory & playbook created. Starting...".green());

  if Requirement::in_path("ansible-playbook").satisfy(env).await.is_err() {
    println!("{}", "Ansible is not installed!".red());
    exit(1);
  }

  CustomCommand::run_simple_observer(env, format!("ansible-playbook -i inventory.ini {playbook_filename}")).await?;

  if !pipeline.artifacts.is_empty() {
    log("Trying to copy artifacts from Ansible local run dir to project dir...");
    crate::rw::copy_all(
      env.run_dir.join(ARTIFACTS_DIR),
      env.run_dir.join(ARTIFACTS_DIR),
      env.artifacts_dir,
      &[] as &[&std::path::Path],
    )?;
  }

  println!("{}", "Ansible run done.".green());

  Ok(true)
}