cindy 0.2.0

Managing infrastructure at breakneck speed.
Documentation
//! Run an arbitrary process (`command`) or a script through an
//! explicit interpreter (`shell`) on the remote machine.
//!
//! Unlike the other builtins, these execute opaque user-supplied
//! programs, so the framework cannot introspect what a change *is* —
//! there is no on-disk state to read back, and therefore no honest
//! "changed / unchanged" to report. These modules deliberately make no
//! idempotency claim: they always run, and they hand you back the raw
//! [`Output`] (exit code, stdout, stderr). Deciding whether the
//! intended effect happened is *your* job — inspect the code/output, or
//! check filesystem state with a read-only builtin (e.g. `stat`) before
//! and after.

use std::process::Command;

use crate as cindy;
use crate::Context;

/// Captured result of the executed process.
///
/// Returned for both [`command`] and [`shell`]. The program always ran
/// (these modules never skip), so there is no "changed" flag — just the
/// observable result for you to act on.
#[derive(Clone, PartialEq, Eq)]
#[crate::wire]
pub struct Output {
    /// Process exit code, or `None` if the program was terminated by a
    /// signal (no code available).
    pub code: Option<i32>,
    /// Captured standard output.
    pub stdout: String,
    /// Captured standard error.
    pub stderr: String,
}

impl Output {
    /// `true` if the program exited successfully (`code == 0`).
    pub fn success(&self) -> bool {
        self.code == Some(0)
    }
}

/// Execution environment shared by [`command`] and [`shell`].
#[derive(Clone, Default, PartialEq, Eq)]
#[crate::wire]
pub struct State {
    /// Working directory to run the program in. `None` ⇒ inherit the
    /// remote worker's current directory.
    pub chdir: Option<std::path::PathBuf>,
    /// Extra environment variables to set for the child process. These
    /// are layered on top of the worker's inherited environment.
    pub env: std::collections::BTreeMap<String, String>,
    /// Treat a non-zero exit status as success rather than failing the
    /// task. The exit code is still reported in [`Output::code`].
    pub ignore_errors: bool,
}

/// Run an already-configured [`Command`], capture its output, and map
/// the result onto an [`Output`], honoring `ignore_errors`.
fn run(mut cmd: Command, state: &State, label: &str) -> crate::Result<Output> {
    if let Some(dir) = state.chdir.as_ref() {
        cmd.current_dir(dir);
    }
    for (k, v) in &state.env {
        cmd.env(k, v);
    }

    let out = cmd.output().context(format!("Failed to spawn {label}"))?;

    let code = out.status.code();
    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();

    if !out.status.success() && !state.ignore_errors {
        crate::bail!(
            "{label} failed with {:?}:\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}",
            out.status,
        );
    }

    Ok(Output {
        code,
        stdout,
        stderr,
    })
}

/// Run a program directly, without a shell.
///
/// `argv[0]` is the program to execute and the remaining elements are
/// its arguments, passed verbatim — there is **no** shell, so globbing,
/// pipes, redirection, `&&`, environment-variable expansion, etc. are
/// *not* interpreted. If you need any of that, reach for [`shell`].
///
/// `argv` must be non-empty. This module makes no idempotency claim: it
/// runs every time and reports the raw [`Output`].
#[crate::remote]
pub fn command(argv: Vec<String>, state: State) -> crate::Result<Output> {
    let Some((program, args)) = argv.split_first() else {
        crate::bail!("`command` requires a non-empty argv (argv[0] is the program)");
    };

    let mut cmd = Command::new(program);
    cmd.args(args);
    run(cmd, &state, &format!("`command` {program:?}"))
}

/// Run a script through an explicit interpreter.
///
/// `shell` deliberately refuses to assume an interpreter: `interpreter`
/// is the **full path** to the shell binary (`/bin/sh`, `/bin/bash`,
/// `/usr/bin/zsh`, …) and `script` is handed to it via `-c`. There is
/// no implicit `bash`/`sh`/`zsh` — the caller must spell out exactly
/// which interpreter runs, so plays don't silently depend on whatever
/// `/bin/sh` happens to point at on a given host.
///
/// `interpreter` must be an absolute path that exists on the remote;
/// the module fails fast otherwise rather than letting the OS produce a
/// confusing `ENOENT`.
#[crate::remote]
pub fn shell(
    interpreter: std::path::PathBuf,
    script: String,
    state: State,
) -> crate::Result<Output> {
    if !interpreter.is_absolute() {
        crate::bail!(
            "`shell` interpreter must be an absolute path, got {}; \
             there is no implicit shell — spell out e.g. /bin/bash",
            interpreter.display()
        );
    }
    if !interpreter.exists() {
        crate::bail!(
            "`shell` interpreter {} does not exist on the remote",
            interpreter.display()
        );
    }

    let mut cmd = Command::new(&interpreter);
    cmd.arg("-c").arg(&script);
    run(
        cmd,
        &state,
        &format!("`shell` via {}", interpreter.display()),
    )
}