diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9258e0f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,33 @@ + + +#[derive(Debug, Default, serde::Deserialize)] +pub struct TciServerConfig { + #[serde(default)] + pub allow_all: bool, + + #[serde(default)] + pub run_setup_even_if_not_allowed: bool, + + #[serde(default)] + pub branch: Option, + + #[serde(default)] + pub setup: Vec, + + #[serde(default)] + pub cleanup: Vec, +} +#[derive(Debug, Default, serde::Deserialize)] +pub struct TciScriptConfig { + #[serde(default)] + pub branch: Option, + + #[serde(default)] + pub filter: Option, + + #[serde(default)] + pub commands: Vec, + + // #[serde(default)] + // concurrent: bool, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..86d9a1a --- /dev/null +++ b/src/error.rs @@ -0,0 +1,24 @@ + +#[derive(Debug, thiserror::Error)] +pub enum TciError { + #[error("could not understand file system structure, bailing out: {0}")] + FsError(&'static str), + + #[error("missing tci script")] + MissingScript, + + #[error("unexpected i/o error: {0}")] + IoError(#[from] std::io::Error), + + #[error("git error: {0}")] + GitError(#[from] git2::Error), + + #[error("subprocess finished with non-zero exit code: {0}")] + SubprocessError(i32), + + #[error("subprocess terminated")] + SubprocessTerminated, + + #[error("invalid toml configuration: {0}")] + ConfigError(#[from] toml::de::Error), +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..f70a561 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,23 @@ +pub struct RefUpdate { + pub from: String, + pub to: String, + pub branch: String, +} + +pub trait RefUpdateVecHelper { + fn contains_branch(&self, branch: &str) -> bool; +} + +impl RefUpdateVecHelper for Vec { + fn contains_branch(&self, branch: &str) -> bool { + self.iter().any(|rf| rf.branch == branch) + } +} + +pub fn shell_out>(cmd: S) -> Result<(), crate::error::TciError> { + match std::process::Command::new(cmd).status()?.code() { + Some(0) => Ok(()), + Some(x) => Err(crate::error::TciError::SubprocessError(x)), + None => Err(crate::error::TciError::SubprocessTerminated), + } +} diff --git a/src/main.rs b/src/main.rs index 278bc4b..ec45922 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,42 +1,7 @@ -#[derive(Debug, thiserror::Error)] -enum TciError { - #[error("could not understand file system structure, bailing out: {0}")] - FsError(&'static str), - - #[error("missing tci script")] - MissingScript, - - #[error("unexpected i/o error: {0}")] - IoError(#[from] std::io::Error), - - #[error("git error: {0}")] - GitError(#[from] git2::Error), - - #[error("subprocess finished with non-zero exit code: {0}")] - SubprocessError(i32), - - #[error("subprocess terminated")] - SubprocessTerminated, -} - -#[derive(Debug, Default, serde::Deserialize)] -struct TciConfig { - #[serde(default)] - allow_all: bool, - - #[serde(default)] - run_setup_even_if_not_allowed: bool, - - #[serde(default)] - setup: Vec, - - #[serde(default)] - cleanup: Vec, -} - -lazy_static::lazy_static!{ - static ref HOME : String = std::env::var("HOME").unwrap_or_default(); -} +mod config; +mod error; +mod git; +mod tci; fn main() { // load tci config @@ -51,118 +16,11 @@ fn main() { let cfg = toml::from_str(&cfg_raw) .unwrap_or_else(|e| { println!("[!] invalid config: {e}"); - TciConfig::default() + crate::config::TciServerConfig::default() }); - if let Err(e) = tci(cfg) { + if let Err(e) = crate::tci::tci(cfg) { println!("[!] error running tci: {e}"); } } -fn shell_out>(cmd: S) -> Result<(), TciError> { - match std::process::Command::new(cmd).status()?.code() { - Some(0) => Ok(()), - Some(x) => Err(TciError::SubprocessError(x)), - None => Err(TciError::SubprocessTerminated), - } -} - -fn tci(cfg: TciConfig) -> Result<(), Box> { - let repo_path = std::env::current_dir()?; - - // check config before even creating temp dir and cloning repo - let git_config = git2::Config::open(&repo_path.join("config"))?; - let tci_script = git_config.get_str("tci.script").unwrap_or(".tci").to_string(); - - // check if CI is allowed - if !cfg.allow_all - && !cfg.run_setup_even_if_not_allowed - && !git_config.get_bool("tci.allow").unwrap_or(false) - { - return Ok(()); // we are in whitelist mode and this repo is not whitelisted - } - - // run hooks - for setup in cfg.setup { - println!("[+] setting up ({setup})"); - shell_out(setup)?; - } - - if !cfg.allow_all && !git_config.get_bool("tci.allow").unwrap_or(false) { - return Ok(()); // ewwww!!! ugly fix to allow running cgit update-agefile on every repo anyway - } - - let res = tci_hook(&repo_path, &tci_script); - - for cleanup in cfg.cleanup { - println!("[-] cleaning up ({cleanup}) "); - shell_out(cleanup)?; - } - - res?; // only check error AFTER running cleanup hooks - - println!("[o] script run successfully"); - - Ok(()) -} - -fn tci_hook(repo_path: &std::path::PathBuf, tci_script: &str) -> Result<(), TciError> { - // TODO kind of ew but ehh should do its job - let mut name = repo_path.to_string_lossy() - .replace(HOME.as_str(), ""); - if name.starts_with('/') { - name.remove(0); - } - if name.ends_with('/') { - name.remove(name.chars().count()); - } - - let tmp = tempdir::TempDir::new(&format!("tci-{}", name.replace('/', "_")))?; - - // TODO allow customizing clone? just clone recursive? just let hook setup submodules? - git2::Repository::clone( - repo_path.to_str() - .ok_or(TciError::FsError("repo path is not a valid string"))?, - tmp.path(), - )?; - - let tci_path = tmp.path().join(tci_script); - - // if .tci is one script file, just run it - if tci_path.is_file() { - - println!("[=] running tci script for repo '{name}'"); - std::env::set_current_dir(tmp.path())?; - let res = shell_out(tci_path); - std::env::set_current_dir(repo_path)?; - res - - // if .tci is a directory of scripts, run all of them sequentially - } else if tci_path.is_dir() { - - let mut scripts : Vec = std::fs::read_dir(&tci_path)? - .filter_map(|x| Some(x.ok()?.file_name().to_string_lossy().to_string())) - .collect(); - scripts.sort(); // so that we get reliable execution order - - std::env::set_current_dir(tmp.path())?; - for script in scripts { - println!("[=] running tci script '{script}' for repo '{name}'"); - let res = shell_out(tci_path.clone().join(&script)); - if let Err(e) = res { - eprintln!("[!] error executing script '{script}': {e}"); - std::env::set_current_dir(repo_path)?; - return Err(e); - } - } - std::env::set_current_dir(repo_path)?; - - Ok(()) - - // return error in any other case - } else { - - return Err(TciError::MissingScript); - - } -} diff --git a/src/tci.rs b/src/tci.rs new file mode 100644 index 0000000..28b8aac --- /dev/null +++ b/src/tci.rs @@ -0,0 +1,111 @@ +use std::io::Read; + +use crate::{config::{TciServerConfig, TciScriptConfig}, error::TciError, git::{shell_out, RefUpdate, RefUpdateVecHelper}}; + +lazy_static::lazy_static!{ + static ref HOME : String = std::env::var("HOME").unwrap_or_default(); +} + +pub fn tci(cfg: TciServerConfig) -> Result<(), Box> { + let repo_path = std::path::PathBuf::from(&std::env::var("GIT_DIR")?); + + // check which branches are being updated + let mut input = String::new(); + std::io::stdin().read_to_string(&mut input)?; + + let updates : Vec = input.split('\n') + .filter_map(|l| { + let split : Vec<&str> = l.split(' ').collect(); + Some(RefUpdate { + from: (*split.get(0)?).to_string(), + to: (*split.get(1)?).to_string(), + branch: (*split.get(2)?).to_string(), + }) + }) + .collect(); + + if let Some(branch) = cfg.branch { + if !updates.contains_branch(&branch) { + return Ok(()); // tci is not allowed to run on changes for this branch + } + } + + // check config before even creating temp dir and cloning repo + let git_config = git2::Config::open(&repo_path.join("config"))?; + let tci_script = git_config.get_str("tci.script").unwrap_or(".tci").to_string(); + + // check if CI is allowed + if !cfg.allow_all + && !cfg.run_setup_even_if_not_allowed + && !git_config.get_bool("tci.allow").unwrap_or(false) + { + return Ok(()); // we are in whitelist mode and this repo is not whitelisted + } + + // run hooks + for setup in cfg.setup { + println!("[+] setting up ({setup})"); + shell_out(setup)?; + } + + if !cfg.allow_all && !git_config.get_bool("tci.allow").unwrap_or(false) { + return Ok(()); // ewwww!!! ugly fix to allow running cgit update-agefile on every repo anyway + } + + let res = tci_hook(&repo_path, &updates, tci_script); + + for cleanup in cfg.cleanup { + println!("[-] cleaning up ({cleanup}) "); + shell_out(cleanup)?; + } + + res?; // only check error AFTER running cleanup hooks + + println!("[o] script run successfully"); + + Ok(()) +} + +pub fn tci_hook(repo_path: &std::path::Path, updates: &Vec, tci_script: String) -> Result<(), TciError> { + // TODO kind of ew but ehh should do its job + let mut name = repo_path.to_string_lossy() + .replace(HOME.as_str(), ""); + if name.starts_with('/') { + name.remove(0); + } + if name.ends_with('/') { + name.remove(name.chars().count()); + } + + let tmp = tempdir::TempDir::new(&format!("tci-{}", name.replace('/', "_")))?; + + // TODO allow customizing clone? just clone recursive? just let hook setup submodules? + git2::Repository::clone( + repo_path.to_str() + .ok_or(TciError::FsError("repo path is not a valid string"))?, + tmp.path(), + )?; + + let tci_path = tmp.path().join(&tci_script); + let tci_config_path = tmp.path().join(tci_script + ".toml"); + + if tci_config_path.is_file() { // if .tci.toml is a config file, parse it for advanced CI options + let tci_config_raw = std::fs::read_to_string(tci_config_path)?; + let tci_config : TciScriptConfig = toml::from_str(&tci_config_raw)?; + if let Some(branch) = tci_config.branch { + if !updates.contains_branch(&branch) { + return Ok(()); // tci script should not be invoked for this branch + } + } + println!("[=] running tci pipeline for repo '{name}'"); + Ok(()) + } else if tci_path.is_file() { // if .tci is just one script, simply run it + println!("[=] running tci script for repo '{name}'"); + std::env::set_current_dir(tmp.path())?; + let res = shell_out(tci_path); + std::env::set_current_dir(repo_path)?; + res + } else { + return Err(TciError::MissingScript); + } +}