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
//! Delivering params and secrets: each slot's value reaches exactly the steps its `env` delivery
//! names (`*`: every step), as an environment variable, never on a command line. A slot this
//! operation has no value for gets its manifest `default` when it has one (Isoloom gives every
//! docker input slot `default: ""`: Compose refuses an unset variable for a secret it reads from
//! the environment, on every start, and keeps what a container was created with when it is
//! empty), and isn't delivered otherwise.

use std::collections::BTreeMap;

use isoloom_manifest::{Delivery, NonEmpty, StepSelection, TfVarShape};

use super::Cx;
use crate::secrets::SecretValue;

/// The variables step `id` gets from the manifest's params and secret slots: those that concern
/// this host and provider (an ESXi password never reaches a VirtualBox run). A slot without a
/// value gets its `default`, else isn't delivered. Terraform variables reach every step.
pub(crate) fn step_env(cx: &Cx<'_>, id: &str) -> Vec<(String, String)> {
    let valued = valued(cx);
    let mut env = delivered(&valued, id);
    env.extend(tf_vars(&valued));
    env
}

/// The variables the manifest delivers to step `id` by name (`env` deliveries), without the
/// Terraform variables every step gets: what an `ssh.run` step carries into its guest.
pub(crate) fn env_deliveries(cx: &Cx<'_>, id: &str) -> Vec<(String, String)> {
    delivered(&valued(cx), id)
}

/// The `env` deliveries of `valued` that name step `id`.
fn delivered(valued: &[(&str, String, &NonEmpty<Delivery>)], id: &str) -> Vec<(String, String)> {
    valued
        .iter()
        .flat_map(|(_, value, deliveries)| env_vars(deliveries.iter(), id).map(move |var| (var, value.clone())))
        .collect()
}

/// Every delivery of `manifest`'s params, then of its slots.
pub(crate) fn every_delivery(manifest: &isoloom_manifest::Manifest) -> impl Iterator<Item = &Delivery> {
    let params = manifest.params.iter().flat_map(|p| p.deliver.iter());
    params.chain(manifest.secrets.iter().flat_map(|s| s.deliver.iter()))
}

/// The params and slots this operation has a value for (a slot's `default` when none was given),
/// with where each goes.
pub(super) fn valued<'a>(cx: &'a Cx<'_>) -> Vec<(&'a str, String, &'a NonEmpty<Delivery>)> {
    let manifest = cx.lab.manifest();
    let params = manifest
        .params
        .iter()
        .filter(|p| cx.scope.admits(&p.hosts, &p.providers))
        .filter_map(|p| Some((p.name.as_str(), cx.vars.params().get(p.name.as_str())?.clone(), &p.deliver)));
    let every_host = std::collections::BTreeSet::new();
    let slots = manifest
        .secrets
        .iter()
        .filter(move |s| cx.scope.admits(&every_host, &s.providers))
        .filter_map(|s| {
            let given = cx.secrets.get(s.name.as_str()).map(SecretValue::expose);
            Some((s.name.as_str(), given.or(s.default.as_deref())?.to_owned(), &s.deliver))
        });
    params.chain(slots).collect()
}

/// The Terraform variables of `valued` (`TF_VAR_<var>`): a `string` one is the value; the
/// `map_entry` ones of a variable make one JSON object, each value under its slot's name (the
/// module's `inputs`).
fn tf_vars(valued: &[(&str, String, &NonEmpty<Delivery>)]) -> Vec<(String, String)> {
    let mut plain: Vec<(String, String)> = Vec::new();
    let mut maps: BTreeMap<String, serde_json::Map<String, serde_json::Value>> = BTreeMap::new();
    for (name, value, deliveries) in valued {
        for d in *deliveries {
            match d {
                Delivery::TfVar {
                    var,
                    shape: TfVarShape::String,
                } => plain.push((format!("TF_VAR_{var}"), value.clone())),
                Delivery::TfVar {
                    var,
                    shape: TfVarShape::MapEntry,
                } => {
                    maps.entry(var.to_string())
                        .or_default()
                        .insert((*name).to_owned(), serde_json::Value::String(value.clone()));
                }
                _ => {}
            }
        }
    }
    let objects = maps
        .into_iter()
        .map(|(var, o)| (format!("TF_VAR_{var}"), serde_json::Value::Object(o).to_string()));
    plain.into_iter().chain(objects).collect()
}

/// The variables of `deliveries` that reach step `id`.
fn env_vars<'a>(deliveries: impl Iterator<Item = &'a Delivery> + 'a, id: &'a str) -> impl Iterator<Item = String> + 'a {
    deliveries.filter_map(move |d| match d {
        Delivery::Env { var, steps } if reaches(steps, id) => Some(var.to_string()),
        _ => None,
    })
}

/// Whether `steps` names step `id`.
pub(crate) fn reaches(steps: &StepSelection, id: &str) -> bool {
    match steps {
        StepSelection::Every => true,
        StepSelection::Only(ids) => ids.iter().any(|s| s.as_str() == id),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn deliveries(json: &str) -> NonEmpty<Delivery> {
        serde_json::from_str(json).unwrap()
    }

    #[test]
    fn a_string_variable_is_its_value_and_the_map_entries_of_one_make_one_json_object() {
        let token = deliveries(r#"[{"via": "tf_var", "var": "api_token"}]"#);
        let flag = deliveries(r#"[{"via": "tf_var", "var": "inputs", "shape": "map_entry"}]"#);
        let user = deliveries(r#"[{"via": "tf_var", "var": "inputs", "shape": "map_entry"}, {"via": "env", "var": "USER_NAME", "steps": ["*"]}]"#);
        let valued = [
            ("token", "t0k".to_owned(), &token),
            ("CTF_FLAG", "flag{x}".to_owned(), &flag),
            ("user", "ada".to_owned(), &user),
        ];
        assert_eq!(
            tf_vars(&valued),
            [
                ("TF_VAR_api_token".to_owned(), "t0k".to_owned()),
                ("TF_VAR_inputs".to_owned(), r#"{"CTF_FLAG":"flag{x}","user":"ada"}"#.to_owned())
            ]
        );
    }

    #[test]
    fn a_variable_reaches_the_steps_it_names_or_every_one() {
        let every: StepSelection = serde_json::from_str(r#"["*"]"#).unwrap();
        assert!(reaches(&every, "up"));
        let only: StepSelection = serde_json::from_str(r#"["up"]"#).unwrap();
        assert!(reaches(&only, "up") && !reaches(&only, "down"));
    }
}