isothrum-runner 0.11.0

The Isothrum runner: executes the run manifest Isoloom writes next to a target's files (lifecycle, state and locks, drivers, checks, events).
Documentation
//! `terraform.copy_module`: the lab's own Terraform module (cloud-services: the project's
//! `terraform/`) copied into `${run.state_dir}/module`, where the run's steps apply it, so its
//! lock file and whatever it writes stay with the run, out of the project.
//!
//! The copy's Terraform files (`*.tf`, `*.tf.json`, `*.tfvars`, `*.tfvars.json`) are removed
//! first, so a file renamed or removed in the module isn't applied any more; everything else the
//! module wrote there is kept. `.terraform` folders and state files (`*.tfstate*`) are never
//! copied. With `when: missing` it copies only when there is no copy yet: a `down` destroys with
//! the module as it was applied. The module must be inside the lab's project.

use std::path::{Path, PathBuf};

use crate::error::Error;
use crate::executor::Cx;

/// The copy, in the run's state folder.
pub(super) const COPY: &str = "module";

/// What a module copy replaces: Terraform's own files.
const TERRAFORM_FILES: [&str; 4] = [".tf", ".tf.json", ".tfvars", ".tfvars.json"];

/// The copy the action of step `step` asks for: module `from` into the run's state folder, only
/// when there is none with `when` `missing`.
pub(super) fn run(cx: &Cx<'_>, step: &str, from: Option<&str>, when: Option<&str>) -> crate::Result<()> {
    let refuse = |what: String| Error::Unsupported {
        what: format!("step `{step}`: {what}"),
    };
    let from = from.ok_or_else(|| refuse("a `terraform.copy_module` without its `from`".into()))?;
    let only_missing = match when {
        None => false,
        Some("missing") => true,
        Some(other) => return Err(refuse(format!("`when: {other}` (only `missing`)"))),
    };
    let source = inside_project(cx.lab.dir(), from).ok_or_else(|| refuse(format!("the module `{from}` isn't a folder inside the lab's project")))?;
    let copy = cx.lab.runner.store.run_dir(cx.lab.name())?.join(COPY);
    if only_missing && copy.is_dir() {
        return Ok(());
    }
    crate::store::private_dir(&copy).map_err(Error::io(&copy, "created"))?;
    forget_terraform_files(&copy)?;
    copy_tree(&source, &copy)
}

/// `from` (relative to the manifest's folder `dir`) as a folder inside the lab's project: the
/// folder above the `.isoloom…` one.
pub(super) fn inside_project(dir: &Path, from: &str) -> Option<PathBuf> {
    let project = std::fs::canonicalize(crate::container::lab_root(dir)).ok()?;
    let source = std::fs::canonicalize(dir.join(from)).ok()?;
    (source.starts_with(&project) && source.is_dir()).then_some(source)
}

/// Whether `dir` holds a Terraform file.
pub(super) fn has_terraform_files(dir: &Path) -> bool {
    std::fs::read_dir(dir).is_ok_and(|d| d.flatten().any(|e| is_terraform_file(&e.path())))
}

fn is_terraform_file(path: &Path) -> bool {
    let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
    path.is_file() && TERRAFORM_FILES.iter().any(|ext| name.ends_with(ext))
}

/// Removes the Terraform files under `dir` (its `.terraform` left alone).
fn forget_terraform_files(dir: &Path) -> crate::Result<()> {
    let entries = std::fs::read_dir(dir).map_err(Error::io(dir, "read"))?;
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() && entry.file_name() != ".terraform" {
            forget_terraform_files(&path)?;
        } else if is_terraform_file(&path) {
            std::fs::remove_file(&path).map_err(Error::io(&path, "removed"))?;
        }
    }
    Ok(())
}

/// Whether `name` stays out of a copy: Terraform's data folder, a state.
fn left_out(name: &str) -> bool {
    name == ".terraform" || name.contains(".tfstate")
}

/// Copies `from` into `to`, recursively, but what [`left_out`] names.
fn copy_tree(from: &Path, to: &Path) -> crate::Result<()> {
    let entries = std::fs::read_dir(from).map_err(Error::io(from, "read"))?;
    for entry in entries.flatten() {
        let name = entry.file_name();
        if left_out(&name.to_string_lossy()) {
            continue;
        }
        let (source, target) = (entry.path(), to.join(&name));
        if source.is_dir() {
            crate::store::private_dir(&target).map_err(Error::io(&target, "created"))?;
            copy_tree(&source, &target)?;
        } else {
            std::fs::copy(&source, &target).map_err(Error::io(&target, "written"))?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests;