Radish alpha
r
rad:zwTxygwuz5LDGBq255RA2CbNGrz8
Radicle CI broker
Radicle
Git
radicle-ci-broker src subplot.rs
// Implementations of Subplot scenario steps for the CI broker.

// #![allow(clippy::unwrap_used)]
// #![allow(clippy::collapsible_if)]

use std::{
    fs::{metadata, set_permissions},
    io::Write,
    os::unix::fs::PermissionsExt,
    path::{Path, PathBuf},
    process::Command,
    str::FromStr,
};

use git_ref_format_core::RefString;
use radicle::{
    node::{Event, NodeId},
    prelude::RepoId,
    storage::RefUpdate,
};

pub use subplotlib::prelude::*;
pub use subplotlib::steplibrary::datadir::Datadir;
pub use subplotlib::steplibrary::runcmd::Runcmd;

use radicle_ci_broker::ergo::Oid;

#[derive(Debug, Default)]
#[allow(dead_code)]
struct SubplotContext {}

impl ContextElement for SubplotContext {}

#[step]
#[context(SubplotContext)]
#[context(Datadir)]
#[context(Runcmd)]
fn setup_node(context: &ScenarioContext, config: SubplotDataFile, adapter: SubplotDataFile) {
    // Install binaries.
    let target_path = bindir();
    println!("check CI broker binaries are in {}", target_path.display());
    assert!(target_path.join("cib").exists());
    assert!(target_path.join("cibtool").exists());
    assert!(target_path.join("synthetic-events").exists());
    context.with_mut(
        |context: &mut Runcmd| {
            context.prepend_to_path(target_path);
            Ok(())
        },
        false,
    )?;

    // Create configuration file.
    if let Some(parent) = config.name().parent() {
        if parent != Path::new("") {
            println!(
                "create directory for configuration file: {:?}",
                parent.display()
            );
            context.with_mut(
                |context: &mut Datadir| {
                    context.create_dir_all(parent)?;
                    Ok(())
                },
                false,
            )?;
        }
    }

    println!("write configuration file {}", config.name().display());
    context.with_mut(
        |context: &mut Datadir| {
            context
                .open_write(config.name())?
                .write_all(config.data())?;
            Ok(())
        },
        false,
    )?;

    // Create an executable adapter.
    if let Some(parent) = adapter.name().parent() {
        if parent != Path::new("") {
            println!("create directory for adapter: {}", parent.display());
            context.with_mut(
                |context: &mut Datadir| {
                    context.create_dir_all(parent)?;
                    Ok(())
                },
                false,
            )?;
        }
    }

    context.with_mut(
        |context: &mut Datadir| {
            // Unix mode bits for an executable file: read/write/exec for
            // owner, read/exec for group and others
            const EXECUTABLE: u32 = 0o755;

            println!("create env file");
            let home = context.canonicalise_filename(".")?;
            let home_str = home.display().to_string();

            let rad_home = context.canonicalise_filename(".radicle")?;
            let rad_home_str = rad_home.display().to_string();

            let envs = &[
                ("HOME", home_str.as_str()),
                ("RAD_HOME", rad_home_str.as_str()),
                ("RAD_PASSPHRASE", "secret"),
                ("RAD_SOCKET", "synt.sock"),
            ];
            {
                let mut file = context.open_write("env")?;
                for (k, v) in envs.iter() {
                    file.write_all(format!("export {k}='{v}'\n").as_bytes())?;
                }
            }

            println!("create env.sh script");
            {
                const SCRIPT: &str = r#"#!/bin/sh
echo "env.sh starts"
if [ -e env ]; then . ./env; fi
exec "$@"
"#;
                let mut file = context.open_write("env.sh")?;
                file.write_all(SCRIPT.as_bytes())?;
            }

            println!("make env.sh executable");
            let filename = context.canonicalise_filename("env.sh")?;
            let meta = metadata(&filename)?;
            let mut perm = meta.permissions();
            perm.set_mode(EXECUTABLE);
            set_permissions(&filename, perm)?;

            let filename = Path::new("adapter.sh");

            println!(
                "write adapter file {} from {}",
                filename.display(),
                adapter.name().display()
            );
            context.open_write(filename)?.write_all(adapter.data())?;

            println!("make {} executable", filename.display());
            let filename = context.canonicalise_filename("adapter.sh")?;
            let meta = metadata(&filename)?;
            let mut perm = meta.permissions();
            perm.set_mode(EXECUTABLE);
            set_permissions(&filename, perm)?;

            Ok(())
        },
        false,
    )?;

    // Create node by running "rad auth".

    context.with_mut(
        |context: &mut Datadir| {
            let home = context.canonicalise_filename(".")?;
            let rad_home = context.canonicalise_filename(".radicle")?;

            rad_in(
                &["auth", "--alias=brokertest"],
                &[
                    ("RAD_HOME", &rad_home.display().to_string()),
                    ("RAD_PASSPHRASE", "secret"),
                    ("RAD_SOCKET", "synt.sock"),
                ],
                &home,
            )?;

            Ok(())
        },
        false,
    )?;
}

#[step]
#[context(SubplotContext)]
#[context(Datadir)]
#[context(Runcmd)]
fn create_repo(context: &ScenarioContext, name: &str) {
    // Create a Git repository and add it to the Radicle node.
    context.with_mut(
        |context: &mut Datadir| {
            let home = context.canonicalise_filename(".")?;
            let home_str = home.display().to_string();

            let rad_home = context.canonicalise_filename(".radicle")?;
            let rad_home_str = rad_home.display().to_string();

            let envs = &[
                ("HOME", home_str.as_str()),
                ("RAD_HOME", rad_home_str.as_str()),
                ("RAD_PASSPHRASE", "secret"),
                ("RAD_SOCKET", "synt.sock"),
            ];

            git_in(
                &["config", "--global", "user.email", "radicle@example.com"],
                envs,
                &home,
            )?;

            git_in(
                &["config", "--global", "user.name", "TestyMcTestFace"],
                envs,
                &home,
            )?;

            git_in(&["init", "-b", "main", name], envs, &home)?;

            {
                let filename = Path::new(name).join("file.dat");
                let mut file = context.open_write(filename)?;
                file.write_all(b"hello, world")?;
            }

            let repodir = context.canonicalise_filename(name)?;
            git_in(&["add", "."], envs, &repodir)?;
            git_in(&["commit", "-am", "test"], envs, &repodir)?;

            rad_in(
                &[
                    "init",
                    "--name",
                    name,
                    "--description=test",
                    "--default-branch=main",
                    "--private",
                    "--no-confirm",
                    "--no-seed",
                ],
                envs,
                &repodir,
            )?;

            // rad init --name testy --description test --default-branch main --private --no-confirm --no-seed
            // rad inspect --identity
            // rad id list

            Ok(())
        },
        false,
    )?;
}

fn rad_in(args: &[&str], envs: &[(&str, &str)], cwd: &Path) -> Result<(), std::io::Error> {
    run_in("rad", args, envs, cwd)
}

fn git_in(args: &[&str], envs: &[(&str, &str)], cwd: &Path) -> Result<(), std::io::Error> {
    run_in("git", args, envs, cwd)
}

fn run_in(
    argv0: &str,
    args: &[&str],
    envs: &[(&str, &str)],
    cwd: &Path,
) -> Result<(), std::io::Error> {
    println!("running command {argv0} {args:?}");
    println!("envs: {envs:?}");
    println!("cwd: {cwd:?}; exists? {}", cwd.exists());

    let output = Command::new(argv0)
        .args(args)
        .envs(envs.iter().copied())
        .current_dir(cwd)
        .output()?;
    println!("{argv0} exit code: {:?}", output.status.code());
    println!(
        "{argv0}: stdout:\n{}\n====================",
        String::from_utf8_lossy(&output.stdout)
    );
    println!(
        "{argv0}: stderr:\n{}\n=====================",
        String::from_utf8_lossy(&output.stderr)
    );
    if !output.status.success() {
        panic!("command failed");
    }
    Ok(())
}

#[step]
#[context(SubplotContext)]
#[context(Datadir)]
#[context(Runcmd)]
fn add_event_file(context: &ScenarioContext, repodir: &Path) {
    // Write embedded file to "event.json". We only need one, at least for now.

    context.with_mut(
        |datadir: &mut Datadir| {
            let rad_home = datadir.canonicalise_filename(".radicle")?;
            println!("rad_home: {rad_home:#?}");

            let nid = nid(&rad_home)?;
            println!("nid: {nid:#?}");

            let repodir = datadir.canonicalise_filename(repodir)?;
            let rid = rid(&rad_home, &repodir)?;
            println!("rid: {rid:#?}");

            let head = head(&rad_home, &repodir)?;

            let node_event = Event::RefsFetched {
                remote: nid,
                rid,
                updated: vec![RefUpdate::Updated {
                    name: RefString::try_from(
                        format!("refs/namespaces/{nid}/refs/heads/main").as_str(),
                    )?,
                    old: head,
                    new: head,
                }],
            };

            println!("node_event: {node_event:#?}");
            let node_event = serde_json::to_string(&node_event)?;

            let event_json = Path::new("event.json");
            let filename = datadir.canonicalise_filename(event_json)?;
            assert!(!filename.exists());

            let mut file = datadir.open_write(event_json)?;
            file.write_all(node_event.as_bytes())?;
            Ok(())
        },
        false,
    )?;
}

fn nid(rad_home: &Path) -> Result<NodeId, Box<dyn std::error::Error>> {
    let output = Command::new("rad")
        .arg("self")
        .arg("--nid")
        .env("RAD_HOME", rad_home.display().to_string().as_str())
        .output()?;
    if !output.status.success() {
        panic!("rad self --nid failed");
    }

    Ok(NodeId::from_str(
        String::from_utf8_lossy(&output.stdout).to_string().trim(),
    )?)
}

fn rid(rad_home: &Path, repo: &Path) -> Result<RepoId, Box<dyn std::error::Error>> {
    let output = Command::new("rad")
        .arg(".")
        .env("RAD_HOME", rad_home.display().to_string().as_str())
        .current_dir(repo)
        .output()?;
    if !output.status.success() {
        panic!("rad . failed");
    }

    Ok(RepoId::from_str(
        String::from_utf8_lossy(&output.stdout).to_string().trim(),
    )?)
}

fn head(rad_home: &Path, repo: &Path) -> Result<Oid, Box<dyn std::error::Error>> {
    let output = Command::new("git")
        .arg("rev-parse")
        .arg("HEAD")
        .env("RAD_HOME", rad_home.display().to_string().as_str())
        .current_dir(repo)
        .output()?;
    if !output.status.success() {
        panic!("git rev-parse HEAD failed");
    }

    Ok(Oid::from_str(
        String::from_utf8_lossy(&output.stdout).to_string().trim(),
    )?)
}

fn bindir() -> PathBuf {
    let path = if let Ok(target) = std::env::var("CARGO_TARGET_DIR") {
        Path::new(&target).join("debug")
    } else {
        PathBuf::from("target/debug")
    };
    path.canonicalize().unwrap()
}

#[step]
#[context(SubplotContext)]
#[context(Runcmd)]
fn stdout_has_one_line(runcmd: &Runcmd) {
    let linecount = runcmd.stdout_as_string().lines().count();
    if linecount != 1 {
        throw!(format!("stdout had {linecount} lines, expected 1"));
    }
}

#[step]
#[context(SubplotContext)]
#[context(Runcmd)]
fn stdout_has_n_lines_containing(runcmd: &Runcmd, n: usize, text: &str) {
    let linecount = runcmd
        .stdout_as_string()
        .lines()
        .filter(|line| line.contains(text))
        .count();
    if linecount != n {
        throw!(format!("stdout had {linecount} lines, expected {n}"));
    }
}

#[step]
#[context(SubplotContext)]
#[context(Runcmd)]
fn stdout_is_empty(runcmd: &Runcmd) {
    let stdout = runcmd.stdout_as_string();
    if !stdout.is_empty() {
        throw!(format!(
            "expected stdout to be empty, is actually {stdout:?}"
        ));
    }
}