chore: big refactor

This commit is contained in:
əlemi 2024-02-15 03:04:53 +01:00
parent 80fce1d909
commit 00c4e848e1
Signed by: alemi
GPG key ID: A4895B84D311642C
6 changed files with 205 additions and 131 deletions

View file

@ -12,7 +12,6 @@ repository = "https://git.alemi.dev/tci.git"
[dependencies] [dependencies]
git2 = "0.18.2" git2 = "0.18.2"
lazy_static = "1.4.0"
serde = { version = "1.0.196", features = ["derive"] } serde = { version = "1.0.196", features = ["derive"] }
tempdir = "0.3.7" tempdir = "0.3.7"
thiserror = "1.0.57" thiserror = "1.0.57"

View file

@ -1,15 +1,13 @@
#[derive(Debug, Default, serde::Deserialize)] #[derive(Debug, Default, serde::Deserialize)]
pub struct TciServerConfig { pub struct TciConfig {
#[serde(default)] #[serde(default)]
pub allow_all: bool, pub allow_all: bool,
#[serde(default)] #[serde(default)]
pub run_setup_even_if_not_allowed: bool, pub branch: Option<String>,
#[serde(default)] #[serde(default)]
pub branch: Option<String>, pub hooks: Vec<String>,
#[serde(default)] #[serde(default)]
pub setup: Vec<String>, pub setup: Vec<String>,
@ -17,6 +15,20 @@ pub struct TciServerConfig {
#[serde(default)] #[serde(default)]
pub cleanup: Vec<String>, pub cleanup: Vec<String>,
} }
impl TciConfig {
pub fn load() -> Result<Self, toml::de::Error> {
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)] #[derive(Debug, Default, serde::Deserialize)]
pub struct TciScriptConfig { pub struct TciScriptConfig {
#[serde(default)] #[serde(default)]
@ -26,8 +38,17 @@ pub struct TciScriptConfig {
pub filter: Option<String>, pub filter: Option<String>,
#[serde(default)] #[serde(default)]
pub commands: Vec<String>, pub scripts: Vec<String>,
// #[serde(default)] // #[serde(default)]
// concurrent: bool, // concurrent: bool,
} }
impl TciScriptConfig {
pub fn load(path: &std::path::PathBuf) -> Result<Self, toml::de::Error> {
let cfg_raw = std::fs::read_to_string(path)
.unwrap_or_default();
toml::from_str(&cfg_raw)
}
}

View file

@ -4,8 +4,8 @@ pub enum TciError {
#[error("could not understand file system structure, bailing out: {0}")] #[error("could not understand file system structure, bailing out: {0}")]
FsError(&'static str), FsError(&'static str),
#[error("missing tci script")] #[error("missing tci script or directory")]
MissingScript, Missing,
#[error("unexpected i/o error: {0}")] #[error("unexpected i/o error: {0}")]
IoError(#[from] std::io::Error), IoError(#[from] std::io::Error),
@ -22,3 +22,5 @@ pub enum TciError {
#[error("invalid toml configuration: {0}")] #[error("invalid toml configuration: {0}")]
ConfigError(#[from] toml::de::Error), ConfigError(#[from] toml::de::Error),
} }
pub type TciResult<T> = Result<T, TciError>;

View file

@ -1,3 +1,45 @@
pub struct TciRepo {
pub name: String,
pub path: std::path::PathBuf,
pub cfg: git2::Config,
pub updates: Vec<RefUpdate>,
}
impl TciRepo {
pub fn new(updates_raw: &str, home: &str) -> crate::error::TciResult<Self> {
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<RefUpdate> = 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 struct RefUpdate {
pub from: String, pub from: String,
pub to: String, pub to: String,
@ -13,11 +55,3 @@ impl RefUpdateVecHelper for Vec<RefUpdate> {
self.iter().any(|rf| rf.branch == branch) self.iter().any(|rf| rf.branch == branch)
} }
} }
pub fn shell_out<S : AsRef<std::ffi::OsStr>>(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),
}
}

View file

@ -1,26 +1,14 @@
mod config;
mod error; mod error;
mod git; mod git;
mod config;
mod tci; mod tci;
fn main() { fn main() {
// load tci config match crate::tci::Tci::new() {
// TODO how could we receive this? Err(e) => eprintln!("[!] error setting up tci: {e}"),
// arguments are used by git Ok(tci) => if let Err(e) = tci.run() {
// idk how we could reliably set env vars??? eprintln!("[!] error running tci: {e}");
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}");
} }
} }

View file

@ -1,111 +1,141 @@
use std::io::Read; 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!{ pub struct Tci {
static ref HOME : String = std::env::var("HOME").unwrap_or_default(); cfg: TciConfig,
repo: TciRepo,
home: String,
stdin: String,
} }
pub fn tci(cfg: TciServerConfig) -> Result<(), Box<dyn std::error::Error>> { impl Tci {
let repo_path = std::path::PathBuf::from(&std::env::var("GIT_DIR")?); pub fn new() -> TciResult<Self> {
let home = std::env::var("HOME")
.unwrap_or_default();
let cfg = TciConfig::load()
.unwrap_or_else(|e| {
eprintln!("[!] invalid config: {e}");
TciConfig::default()
});
// check which branches are being updated // check which branches are being updated
let mut input = String::new(); let mut stdin = String::new();
std::io::stdin().read_to_string(&mut input)?; std::io::stdin().read_to_string(&mut stdin)
.unwrap_or_else(|e| {
eprintln!("[!] could not read refs from stdin: {e}");
0
});
let updates : Vec<RefUpdate> = input.split('\n') let repo = TciRepo::new(&stdin, &home)?;
.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 { Ok(Tci{cfg, repo, home, stdin})
if !updates.contains_branch(&branch) { }
return Ok(()); // tci is not allowed to run on changes for this branch
/// 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
}
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
} }
} }
// check config before even creating temp dir and cloning repo true
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
} }
pub fn run(&self) -> TciResult<()> {
// run hooks // run hooks
for setup in cfg.setup { 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})"); println!("[+] setting up ({setup})");
shell_out(setup)?; Tci::exec(&self.repo.path, setup)?;
} }
if !cfg.allow_all && !git_config.get_bool("tci.allow").unwrap_or(false) { let res = if tci_path.is_file() {
return Ok(()); // ewwww!!! ugly fix to allow running cgit update-agefile on every repo anyway 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
let res = tci_hook(&repo_path, &updates, tci_script); for cleanup in &self.cfg.cleanup {
for cleanup in cfg.cleanup {
println!("[-] cleaning up ({cleanup}) "); println!("[-] cleaning up ({cleanup}) ");
shell_out(cleanup)?; Tci::exec(&self.repo.path, cleanup)?;
} }
res?; // only check error AFTER running cleanup hooks res?; // if there was an error, return it now
println!("[o] script run successfully"); println!("[o] tci complete");
Ok(()) Ok(())
}
pub fn tci_hook(repo_path: &std::path::Path, updates: &Vec<RefUpdate>, 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('/', "_")))?; fn prepare_env(&self) -> TciResult<tempdir::TempDir> {
let tmp = tempdir::TempDir::new(
&format!("tci-{}", self.repo.name.replace('/', "_"))
)?;
// TODO allow customizing clone? just clone recursive? just let hook setup submodules? // TODO allow customizing clone? just clone recursive? just let hook setup submodules?
git2::Repository::clone( git2::Repository::clone(
repo_path.to_str() self.repo.path.to_str()
.ok_or(TciError::FsError("repo path is not a valid string"))?, .ok_or(TciError::FsError("repo path is not a valid string"))?,
tmp.path(), tmp.path(),
)?; )?;
let tci_path = tmp.path().join(&tci_script); Ok(tmp)
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 fn run_tci_hook(&self, cwd: &std::path::Path, dir: std::path::PathBuf) -> TciResult<()> {
let tci_config_raw = std::fs::read_to_string(tci_config_path)?; let cfg = TciScriptConfig::load(&dir.join("config.toml"))?;
let tci_config : TciScriptConfig = toml::from_str(&tci_config_raw)?;
if let Some(branch) = tci_config.branch { if let Some(branch) = cfg.branch {
if !updates.contains_branch(&branch) { if !self.repo.updates.contains_branch(&branch) {
return Ok(()); // tci script should not be invoked for this branch return Ok(()); // tci is not configured to run on this branch
} }
} }
println!("[=] running tci pipeline for repo '{name}'");
// 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(()) 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())?; fn exec<S : AsRef<std::ffi::OsStr>>(cwd: &std::path::Path, s: S) -> TciResult<()> {
let res = shell_out(tci_path); match std::process::Command::new(s)
std::env::set_current_dir(repo_path)?; .current_dir(cwd)
res // .stdin(self.stdin.clone()) // TODO not this easy
} else { .status()?
return Err(TciError::MissingScript); .code()
{
Some(0) => Ok(()),
Some(x) => Err(crate::error::TciError::SubprocessError(x)),
None => Err(crate::error::TciError::SubprocessTerminated),
}
} }
} }