diff --git a/Cargo.toml b/Cargo.toml index 0f148fe..bdd7578 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,6 @@ repository = "https://git.alemi.dev/tci.git" [dependencies] git2 = "0.18.2" -lazy_static = "1.4.0" serde = { version = "1.0.196", features = ["derive"] } tempdir = "0.3.7" thiserror = "1.0.57" diff --git a/src/config.rs b/src/config.rs index 9258e0f..7cb6fb9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,15 +1,13 @@ - - #[derive(Debug, Default, serde::Deserialize)] -pub struct TciServerConfig { +pub struct TciConfig { #[serde(default)] pub allow_all: bool, #[serde(default)] - pub run_setup_even_if_not_allowed: bool, + pub branch: Option, #[serde(default)] - pub branch: Option, + pub hooks: Vec, #[serde(default)] pub setup: Vec, @@ -17,6 +15,20 @@ pub struct TciServerConfig { #[serde(default)] pub cleanup: Vec, } + +impl TciConfig { + pub fn load() -> Result { + let config_path = std::env::var("TCI_CONFIG") + .as_deref() + .unwrap_or("/etc/tci/config.toml"); + + let cfg_raw = std::fs::read_to_string(config_path) + .unwrap_or_default(); + + toml::from_str(&cfg_raw) + } +} + #[derive(Debug, Default, serde::Deserialize)] pub struct TciScriptConfig { #[serde(default)] @@ -26,8 +38,17 @@ pub struct TciScriptConfig { pub filter: Option, #[serde(default)] - pub commands: Vec, + pub scripts: Vec, // #[serde(default)] // concurrent: bool, } + + +impl TciScriptConfig { + pub fn load(path: &std::path::PathBuf) -> Result { + let cfg_raw = std::fs::read_to_string(path) + .unwrap_or_default(); + toml::from_str(&cfg_raw) + } +} diff --git a/src/error.rs b/src/error.rs index 86d9a1a..eec0c3c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,8 +4,8 @@ pub enum TciError { #[error("could not understand file system structure, bailing out: {0}")] FsError(&'static str), - #[error("missing tci script")] - MissingScript, + #[error("missing tci script or directory")] + Missing, #[error("unexpected i/o error: {0}")] IoError(#[from] std::io::Error), @@ -22,3 +22,5 @@ pub enum TciError { #[error("invalid toml configuration: {0}")] ConfigError(#[from] toml::de::Error), } + +pub type TciResult = Result; diff --git a/src/git.rs b/src/git.rs index f70a561..d092347 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,3 +1,45 @@ + +pub struct TciRepo { + pub name: String, + pub path: std::path::PathBuf, + pub cfg: git2::Config, + pub updates: Vec, +} + +impl TciRepo { + pub fn new(updates_raw: &str, home: &str) -> crate::error::TciResult { + let path = match std::env::var("GIT_DIR") { + Ok(p) => std::path::PathBuf::from(&p), + Err(e) => std::env::current_dir()?, + }; + + // TODO kind of ew but ehh should do its job + let mut name = path.to_string_lossy() + .replace(home, ""); + if name.starts_with('/') { + name.remove(0); + } + if name.ends_with('/') { + name.remove(name.chars().count()); + } + + let cfg = git2::Config::open(&path.join("config"))?; + + let updates : Vec = updates_raw.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(); + + Ok(TciRepo { name, path, cfg, updates }) + } +} + pub struct RefUpdate { pub from: String, pub to: String, @@ -13,11 +55,3 @@ impl RefUpdateVecHelper for Vec { 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 ec45922..0a3f56e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,26 +1,14 @@ -mod config; mod error; mod git; +mod config; mod tci; fn main() { - // load tci config - // TODO how could we receive this? - // arguments are used by git - // idk how we could reliably set env vars??? - let config_path = "/etc/tci/config.toml"; - - let cfg_raw = std::fs::read_to_string(config_path) - .unwrap_or_default(); - - let cfg = toml::from_str(&cfg_raw) - .unwrap_or_else(|e| { - println!("[!] invalid config: {e}"); - crate::config::TciServerConfig::default() - }); - - if let Err(e) = crate::tci::tci(cfg) { - println!("[!] error running tci: {e}"); + match crate::tci::Tci::new() { + Err(e) => eprintln!("[!] error setting up tci: {e}"), + Ok(tci) => if let Err(e) = tci.run() { + eprintln!("[!] error running tci: {e}"); + }, } } diff --git a/src/tci.rs b/src/tci.rs index 28b8aac..0dad902 100644 --- a/src/tci.rs +++ b/src/tci.rs @@ -1,111 +1,141 @@ use std::io::Read; -use crate::{config::{TciServerConfig, TciScriptConfig}, error::TciError, git::{shell_out, RefUpdate, RefUpdateVecHelper}}; +use crate::{config::{TciConfig, TciScriptConfig}, error::{TciError, TciResult}, git::{RefUpdate, RefUpdateVecHelper, TciRepo}}; -lazy_static::lazy_static!{ - static ref HOME : String = std::env::var("HOME").unwrap_or_default(); +pub struct Tci { + cfg: TciConfig, + repo: TciRepo, + home: String, + stdin: String, } -pub fn tci(cfg: TciServerConfig) -> Result<(), Box> { - let repo_path = std::path::PathBuf::from(&std::env::var("GIT_DIR")?); +impl Tci { + pub fn new() -> TciResult { + let home = std::env::var("HOME") + .unwrap_or_default(); - // check which branches are being updated - let mut input = String::new(); - std::io::stdin().read_to_string(&mut input)?; + let cfg = TciConfig::load() + .unwrap_or_else(|e| { + eprintln!("[!] invalid config: {e}"); + TciConfig::default() + }); - 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(); + // check which branches are being updated + let mut stdin = String::new(); + std::io::stdin().read_to_string(&mut stdin) + .unwrap_or_else(|e| { + eprintln!("[!] could not read refs from stdin: {e}"); + 0 + }); - if let Some(branch) = cfg.branch { - if !updates.contains_branch(&branch) { - return Ok(()); // tci is not allowed to run on changes for this branch + let repo = TciRepo::new(&stdin, &home)?; + + Ok(Tci{cfg, repo, home, stdin}) + } + + /// check if tci is allowed to run for this repository + pub fn allowed(&self) -> bool { + if !self.cfg.allow_all && !self.repo.cfg.get_bool("tci.allow").unwrap_or(false) { + return false; // we are in whitelist mode and this repo is not whitelisted } - } - // 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 + if let Some(ref branch) = self.cfg.branch { + if !self.repo.updates.contains_branch(branch) { + return false; // tci is not allowed to run on changes for this branch } } - println!("[=] running tci pipeline for repo '{name}'"); + + true + } + + pub fn run(&self) -> TciResult<()> { + // run hooks + for hook in &self.cfg.hooks { + println!("[*] running hook ({hook})"); + Tci::exec(&self.repo.path, hook)?; + } + + if !self.allowed() { return Ok(()) } + + let env = self.prepare_env()?; + + let tci_script = self.repo.cfg.get_string("tci.path").unwrap_or(".tci".into()); + let tci_path = env.path().join(&tci_script); + + if !tci_path.is_file() && !tci_path.is_dir() { + return Err(TciError::Missing); + } + + for setup in &self.cfg.setup { + println!("[+] setting up ({setup})"); + Tci::exec(&self.repo.path, setup)?; + } + + let res = if tci_path.is_file() { + println!("[=] running tci script for {}", self.repo.name); + Tci::exec(env.path(), tci_path) + } else if tci_path.is_dir() { + self.run_tci_hook(env.path(), tci_path) + } else { Ok(()) }; // will never happen but rustc doesn't know + + for cleanup in &self.cfg.cleanup { + println!("[-] cleaning up ({cleanup}) "); + Tci::exec(&self.repo.path, cleanup)?; + } + + res?; // if there was an error, return it now + + println!("[o] tci complete"); + 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); + } + + fn prepare_env(&self) -> TciResult { + let tmp = tempdir::TempDir::new( + &format!("tci-{}", self.repo.name.replace('/', "_")) + )?; + + // TODO allow customizing clone? just clone recursive? just let hook setup submodules? + git2::Repository::clone( + self.repo.path.to_str() + .ok_or(TciError::FsError("repo path is not a valid string"))?, + tmp.path(), + )?; + + Ok(tmp) + } + + fn run_tci_hook(&self, cwd: &std::path::Path, dir: std::path::PathBuf) -> TciResult<()> { + let cfg = TciScriptConfig::load(&dir.join("config.toml"))?; + + if let Some(branch) = cfg.branch { + if !self.repo.updates.contains_branch(&branch) { + return Ok(()); // tci is not configured to run on this branch + } + } + + // TODO calculate which files changed and decide if we should + // run depending on cfg.filter (regex match idk?) + + for script in cfg.scripts { + println!("[=] running tci script '{script}' for {}", self.repo.name); + Tci::exec(cwd, dir.join(script))?; + } + + Ok(()) + } + + fn exec>(cwd: &std::path::Path, s: S) -> TciResult<()> { + match std::process::Command::new(s) + .current_dir(cwd) + // .stdin(self.stdin.clone()) // TODO not this easy + .status()? + .code() + { + Some(0) => Ok(()), + Some(x) => Err(crate::error::TciError::SubprocessError(x)), + None => Err(crate::error::TciError::SubprocessTerminated), + } } } +