diff --git a/.env b/.env index 8b13789..aaeb092 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ - +RUST_LOG=notmad=debug,info +FE_CONFIG_DIR=./templates/fe/configs/ diff --git a/Cargo.lock b/Cargo.lock index 213e0d3..a6d7e47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.3.2" @@ -204,6 +213,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.8" @@ -225,12 +240,16 @@ name = "forge-enforce" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "axum", "clap", "dotenv", "notmad", + "regex", "serde", "tokio", + "tokio-util", + "toml", "tower-http", "tracing", "tracing-subscriber", @@ -358,6 +377,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.4.1" @@ -452,6 +477,16 @@ dependencies = [ "tower-service", ] +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "io-lifetimes" version = "1.0.11" @@ -523,9 +558,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.5.0" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "mime" @@ -712,6 +747,35 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + [[package]] name = "rustix" version = "0.37.20" @@ -788,6 +852,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -947,6 +1020,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "tower" version = "0.4.13" @@ -1351,6 +1463,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/crates/forge-enforce/Cargo.toml b/crates/forge-enforce/Cargo.toml index f175a37..94c868c 100644 --- a/crates/forge-enforce/Cargo.toml +++ b/crates/forge-enforce/Cargo.toml @@ -15,3 +15,7 @@ serde.workspace = true uuid.workspace = true tower-http.workspace = true notmad.workspace = true +tokio-util = "0.7.18" +async-trait = "0.1.89" +regex = "1.12.3" +toml = "0.9.11" diff --git a/crates/forge-enforce/src/cli/serve.rs b/crates/forge-enforce/src/cli/serve.rs index abf762e..e50c6d6 100644 --- a/crates/forge-enforce/src/cli/serve.rs +++ b/crates/forge-enforce/src/cli/serve.rs @@ -1,9 +1,6 @@ use std::net::SocketAddr; -use axum::{Router, extract::MatchedPath, http::Request, routing::get}; -use tower_http::trace::TraceLayer; - -use crate::State; +use crate::{State, forge_services::load, servehttp::ServeHttp}; #[derive(clap::Parser)] pub struct ServeCommand { @@ -13,37 +10,20 @@ pub struct ServeCommand { impl ServeCommand { pub async fn execute(&self, state: &State) -> anyhow::Result<()> { - let app = Router::new() - .route("/", get(root)) - .with_state(state.clone()) - .layer( - TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { - // Log the matched route's path (with placeholders not filled in). - // Use request.uri() or OriginalUri if you want the real path. - let matched_path = request - .extensions() - .get::() - .map(MatchedPath::as_str); + let svcs = load(&state.config.config_dir, state).await?; - tracing::info_span!( - "http_request", - method = ?request.method(), - matched_path, - some_other_field = tracing::field::Empty, - ) - }), // ... - ); + let mut mad = notmad::Mad::builder(); + mad.add(ServeHttp { + host: self.host, + state: state.clone(), + }); - tracing::info!("listening on {}", self.host); - let listener = tokio::net::TcpListener::bind(self.host).await.unwrap(); - axum::serve(listener, app.into_make_service()) - .await - .unwrap(); + for svc in svcs { + mad.add(svc); + } + + mad.run().await?; Ok(()) } } - -async fn root() -> &'static str { - "Hello, nostore!" -} diff --git a/crates/forge-enforce/src/forge_config.rs b/crates/forge-enforce/src/forge_config.rs new file mode 100644 index 0000000..9a339c4 --- /dev/null +++ b/crates/forge-enforce/src/forge_config.rs @@ -0,0 +1,90 @@ +use regex::Regex; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize)] +pub struct ForgeConfig { + #[serde(flatten)] + pub forge_type: ForgeConfigType, + + #[serde(default = "ForgeSchedule::default")] + pub schedule: ForgeSchedule, + + #[serde(default = "allow_all")] + pub allow: Vec, + #[serde(default = "Vec::new")] + pub deny: Vec, + + #[serde(default)] + pub policies: Policies, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Policies { + #[serde(default)] + pub squash_merge_only: PolicyOption, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct PolicyOption { + #[serde(default)] + pub enabled: bool, +} + +fn allow_all() -> Vec { + vec![ForgeRegex { + regex: Regex::new(".*").unwrap(), + }] +} + +#[derive(Clone, Debug, Deserialize)] +pub enum ForgeSchedule { + Cron {}, + Interval {}, + Once {}, +} + +impl Default for ForgeSchedule { + fn default() -> Self { + Self::Interval {} + } +} + +#[derive(Clone)] +pub struct ForgeRegex { + regex: Regex, +} + +impl std::ops::Deref for ForgeRegex { + type Target = Regex; + + fn deref(&self) -> &Self::Target { + &self.regex + } +} + +impl std::fmt::Debug for ForgeRegex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ForgeRegex") + .field("regex", &self.regex) + .finish() + } +} + +impl<'de> Deserialize<'de> for ForgeRegex { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Regex::new(&s) + .map(|regex| ForgeRegex { regex }) + .map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ForgeConfigType { + #[serde(rename = "github")] + GitHub {}, +} diff --git a/crates/forge-enforce/src/forge_services.rs b/crates/forge-enforce/src/forge_services.rs new file mode 100644 index 0000000..cbde673 --- /dev/null +++ b/crates/forge-enforce/src/forge_services.rs @@ -0,0 +1,58 @@ +use std::path::Path; + +use async_trait::async_trait; +use notmad::{Component, MadError}; +use tokio_util::sync::CancellationToken; + +use crate::{State, forge_config::ForgeConfig}; + +pub struct ForgeService { + name: String, + config: ForgeConfig, +} + +#[async_trait] +impl Component for ForgeService { + fn name(&self) -> Option { + Some(format!("forge-enforce/{}", self.name)) + } + + async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> { + cancellation_token.cancelled().await; + + Ok(()) + } +} + +pub async fn load(path: &Path, state: &State) -> anyhow::Result> { + if !path.exists() { + anyhow::bail!("config path does not exist: {}", path.display()); + } + + let mut res = tokio::fs::read_dir(&path).await?; + let mut files = Vec::new(); + while let Ok(Some(entry)) = res.next_entry().await { + if !entry.metadata().await?.is_file() { + continue; + } + + let content = tokio::fs::read(entry.path()).await?; + + let config: ForgeConfig = toml::from_slice(&content)?; + + files.push(( + entry + .file_name() + .into_string() + .expect("to have strings that are utf"), + config, + )); + } + + let services = files + .into_iter() + .map(|(name, config)| ForgeService { config, name }) + .collect::>(); + + Ok(services) +} diff --git a/crates/forge-enforce/src/main.rs b/crates/forge-enforce/src/main.rs index d24879e..6ddb49c 100644 --- a/crates/forge-enforce/src/main.rs +++ b/crates/forge-enforce/src/main.rs @@ -3,6 +3,11 @@ pub use state::*; pub mod cli; +pub mod servehttp; + +mod forge_config; +mod forge_services; + #[tokio::main] async fn main() -> anyhow::Result<()> { dotenv::dotenv().ok(); diff --git a/crates/forge-enforce/src/servehttp.rs b/crates/forge-enforce/src/servehttp.rs new file mode 100644 index 0000000..df96f15 --- /dev/null +++ b/crates/forge-enforce/src/servehttp.rs @@ -0,0 +1,57 @@ +use std::net::SocketAddr; + +use async_trait::async_trait; +use axum::{Router, extract::MatchedPath, http::Request, routing::get}; +use notmad::{Component, MadError}; +use tokio_util::sync::CancellationToken; +use tower_http::trace::TraceLayer; + +use crate::State; + +pub struct ServeHttp { + pub host: SocketAddr, + pub state: State, +} + +#[async_trait] +impl Component for ServeHttp { + fn name(&self) -> Option { + Some("forge-enforce/http".into()) + } + + async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> { + let app = Router::new() + .route("/", get(root)) + .with_state(self.state.clone()) + .layer( + TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { + // Log the matched route's path (with placeholders not filled in). + // Use request.uri() or OriginalUri if you want the real path. + let matched_path = request + .extensions() + .get::() + .map(MatchedPath::as_str); + + tracing::info_span!( + "http_request", + method = ?request.method(), + matched_path, + some_other_field = tracing::field::Empty, + ) + }), // ... + ); + + tracing::info!("listening on {}", self.host); + let listener = tokio::net::TcpListener::bind(self.host).await.unwrap(); + axum::serve(listener, app.into_make_service()) + .with_graceful_shutdown(async move { cancellation_token.cancelled().await }) + .await + .unwrap(); + + Ok(()) + } +} + +async fn root() -> &'static str { + "Hello, forge-enforce!" +} diff --git a/crates/forge-enforce/src/state.rs b/crates/forge-enforce/src/state.rs index 5a29c77..4662761 100644 --- a/crates/forge-enforce/src/state.rs +++ b/crates/forge-enforce/src/state.rs @@ -1,5 +1,10 @@ +use std::path::PathBuf; + #[derive(clap::Parser, Clone)] -pub struct Config {} +pub struct Config { + #[arg(long, env = "FE_CONFIG_DIR")] + pub config_dir: PathBuf, +} #[derive(Clone)] pub struct State { diff --git a/templates/fe/configs/github.com.toml b/templates/fe/configs/github.com.toml new file mode 100644 index 0000000..bcbe865 --- /dev/null +++ b/templates/fe/configs/github.com.toml @@ -0,0 +1,5 @@ +type = "github" +deny = [".*"] + +[policies] +squash_merge_only.enabled = true