2
.env
2
.env
@@ -1,2 +1,4 @@
|
||||
RUST_LOG=notmad=debug,nodrift=debug,forge=trace,info
|
||||
|
||||
FE_CONFIG_DIR=./templates/fe/configs/
|
||||
FE_SCHEMA_FILE=./templates/fe/schema.json
|
||||
|
||||
64
Cargo.lock
generated
64
Cargo.lock
generated
@@ -450,6 +450,12 @@ version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
@@ -572,7 +578,9 @@ dependencies = [
|
||||
"notmad",
|
||||
"octocrab",
|
||||
"regex",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
@@ -1473,6 +1481,26 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -1637,6 +1665,31 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.1.0"
|
||||
@@ -1729,6 +1782,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
|
||||
46
README.md
46
README.md
@@ -1 +1,47 @@
|
||||
# forge-enforce
|
||||
|
||||
Enforce repository policies across forge providers (GitHub, etc.). Runs on a schedule, discovers repositories matching allow/deny filters, and applies configured policies.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
forge-enforce serve --host 127.0.0.1:3000
|
||||
```
|
||||
|
||||
Requires `FE_CONFIG_DIR` (or `--config-dir`) pointing to a directory of TOML config files, one per forge.
|
||||
|
||||
## Configuration
|
||||
|
||||
Each file in the config directory defines a forge connection. Example (`github.com.toml`):
|
||||
|
||||
```toml
|
||||
# Schedule: pick one
|
||||
# schedule.cron = "0 * * * * *"
|
||||
# schedule.interval = "60" # minutes (default)
|
||||
schedule.once = true
|
||||
|
||||
# Repository filters (regex patterns)
|
||||
allow = ["^canopy-.*$"]
|
||||
deny = ["^infrastructure-.*$", "^canopy-data-gateway$"]
|
||||
|
||||
# Forge connection
|
||||
[github]
|
||||
credentials.token_env = "GITHUB_ACCESS_TOKEN"
|
||||
organisation = "understory-io"
|
||||
|
||||
# Policies to enforce
|
||||
[policies]
|
||||
squash_merge_only.enabled = true
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `schedule` | `cron`, `interval` (minutes), or `once` |
|
||||
| `allow` | Regex list of repository names to include (default: `.*`) |
|
||||
| `deny` | Regex list of repository names to exclude (default: none) |
|
||||
| `[github]` | GitHub forge config: `credentials` and `organisation` |
|
||||
| `credentials` | `token = "..."` or `token_env = "ENV_VAR"` |
|
||||
| `[policies]` | Policy rules to enforce on matched repositories |
|
||||
| `squash_merge_only.enabled` | Require squash merges only |
|
||||
|
||||
@@ -21,3 +21,5 @@ regex = "1.12.3"
|
||||
toml = "0.9.11"
|
||||
nodrift = "0.3.5"
|
||||
octocrab = "0.49.5"
|
||||
schemars = "1.2.1"
|
||||
serde_json = "1.0.149"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{Config, State, cli::serve::ServeCommand};
|
||||
use clap::{Parser, Subcommand};
|
||||
use schemars::schema_for;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::{Config, State, cli::serve::ServeCommand, forge_config::ForgeConfig};
|
||||
|
||||
mod serve;
|
||||
|
||||
@@ -17,6 +21,10 @@ struct Command {
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
Serve(ServeCommand),
|
||||
Schema {
|
||||
#[arg(long, env = "FE_SCHEMA_FILE")]
|
||||
schema_file: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn execute() -> anyhow::Result<()> {
|
||||
@@ -26,5 +34,17 @@ pub async fn execute() -> anyhow::Result<()> {
|
||||
|
||||
match cli.command.expect("a subcommand") {
|
||||
Commands::Serve(cmd) => cmd.execute(&state).await,
|
||||
Commands::Schema { schema_file } => {
|
||||
let schema = schema_for!(ForgeConfig);
|
||||
|
||||
let output = serde_json::to_string_pretty(&schema)?;
|
||||
|
||||
let mut file = tokio::fs::File::create(&schema_file).await?;
|
||||
|
||||
file.write_all(output.as_bytes()).await?;
|
||||
file.flush().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,67 @@
|
||||
use regex::Regex;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ForgeConfig {
|
||||
#[serde(flatten)]
|
||||
pub forge_type: ForgeConfigType,
|
||||
|
||||
#[serde(default = "Filter::default")]
|
||||
pub filter: Filter,
|
||||
|
||||
#[serde(default = "ForgeSchedule::default")]
|
||||
pub schedule: ForgeSchedule,
|
||||
|
||||
#[serde(default = "allow_all")]
|
||||
pub allow: Vec<ForgeRegex>,
|
||||
#[serde(default = "Vec::new")]
|
||||
pub deny: Vec<ForgeRegex>,
|
||||
|
||||
#[serde(default)]
|
||||
pub policies: Policies,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
/// # Filter
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Filter {
|
||||
/// # Allow
|
||||
/// Allowed repositories, uses regex patterns
|
||||
///
|
||||
/// ```toml
|
||||
/// allow = [".*", "^something-.*$"]
|
||||
/// ````
|
||||
#[serde(default = "allow_all")]
|
||||
pub allow: Vec<ForgeRegex>,
|
||||
|
||||
/// # Deny
|
||||
/// Denied repositories, uses regex patterns
|
||||
///
|
||||
/// ```toml
|
||||
/// deny = [".*", "^something-.*$"]
|
||||
/// ````
|
||||
#[serde(default = "deny_default")]
|
||||
pub deny: Vec<ForgeRegex>,
|
||||
}
|
||||
|
||||
impl Default for Filter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow: allow_all(),
|
||||
deny: deny_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deny_default() -> Vec<ForgeRegex> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Policies {
|
||||
#[serde(default)]
|
||||
pub squash_merge_only: PolicyOption,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PolicyOption {
|
||||
#[serde(default)]
|
||||
@@ -39,7 +74,7 @@ fn allow_all() -> Vec<ForgeRegex> {
|
||||
}]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
pub enum ForgeSchedule {
|
||||
#[serde(rename = "cron")]
|
||||
Cron(String),
|
||||
@@ -60,6 +95,16 @@ pub struct ForgeRegex {
|
||||
regex: Regex,
|
||||
}
|
||||
|
||||
impl JsonSchema for ForgeRegex {
|
||||
fn schema_name() -> std::borrow::Cow<'static, str> {
|
||||
"regex".into()
|
||||
}
|
||||
|
||||
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
schemars::schema_for!(String)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ForgeRegex> for Regex {
|
||||
fn from(value: ForgeRegex) -> Self {
|
||||
value.regex
|
||||
@@ -99,20 +144,28 @@ impl<'de> Deserialize<'de> for ForgeRegex {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
// #[serde(tag = "type")]
|
||||
pub enum ForgeConfigType {
|
||||
/// GitHub forge
|
||||
#[serde(rename = "github")]
|
||||
GitHub {
|
||||
/// Credentials
|
||||
credentials: GitHubCredentials,
|
||||
|
||||
/// Organisation
|
||||
organisation: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
/// GitHubCredentials
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum GitHubCredentials {
|
||||
/// Token
|
||||
#[serde(rename = "token")]
|
||||
Token(String),
|
||||
|
||||
/// Get token from env key
|
||||
#[serde(rename = "token_env")]
|
||||
TokenEnv(String),
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ impl nodrift::Drifter for ForgeService {
|
||||
client
|
||||
.get_repositories(
|
||||
organisation,
|
||||
self.config.allow.iter().map(|a| a.into()).collect(),
|
||||
self.config.deny.iter().map(|a| a.into()).collect(),
|
||||
self.config.filter.allow.iter().map(|a| a.into()).collect(),
|
||||
self.config.filter.deny.iter().map(|a| a.into()).collect(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -95,7 +95,7 @@ impl nodrift::Drifter for ForgeService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load(path: &Path, state: &State) -> anyhow::Result<Vec<ForgeService>> {
|
||||
pub async fn load(path: &Path, _state: &State) -> anyhow::Result<Vec<ForgeService>> {
|
||||
if !path.exists() {
|
||||
anyhow::bail!("config path does not exist: {}", path.display());
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
# schedule.cron = "0 * * * * *"
|
||||
schedule.once = true
|
||||
#:schema ../schema.json
|
||||
|
||||
[github]
|
||||
organisation = "understory-io"
|
||||
credentials.token_env = "GITHUB_ACCESS_TOKEN"
|
||||
|
||||
|
||||
[filter]
|
||||
allow = ["^canopy-.*$"]
|
||||
deny = ["^infrastructure-.*$", "^canopy-data-gateway$"]
|
||||
|
||||
[github]
|
||||
credentials.token_env = "GITHUB_ACCESS_TOKEN"
|
||||
organisation = "understory-io"
|
||||
[schedule]
|
||||
once = true
|
||||
|
||||
|
||||
[policies]
|
||||
squash_merge_only.enabled = true
|
||||
|
||||
165
templates/fe/schema.json
Normal file
165
templates/fe/schema.json
Normal file
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "ForgeConfig",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"$ref": "#/$defs/Filter"
|
||||
},
|
||||
"policies": {
|
||||
"$ref": "#/$defs/Policies"
|
||||
},
|
||||
"schedule": {
|
||||
"$ref": "#/$defs/ForgeSchedule"
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "GitHub forge",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"github": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credentials": {
|
||||
"description": "Credentials",
|
||||
"$ref": "#/$defs/GitHubCredentials"
|
||||
},
|
||||
"organisation": {
|
||||
"description": "Organisation",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"credentials",
|
||||
"organisation"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"github"
|
||||
]
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false,
|
||||
"$defs": {
|
||||
"Filter": {
|
||||
"title": "Filter",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"title": "Allow",
|
||||
"description": "Allowed repositories, uses regex patterns\n\n```toml\nallow = [\".*\", \"^something-.*$\"]\n````",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/regex"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"title": "Deny",
|
||||
"description": "Denied repositories, uses regex patterns\n\n```toml\ndeny = [\".*\", \"^something-.*$\"]\n````",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/regex"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ForgeSchedule": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cron": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"cron"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"interval"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"once": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"once"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"GitHubCredentials": {
|
||||
"description": "GitHubCredentials",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Token",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"token"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Get token from env key",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token_env": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"token_env"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Policies": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"squash_merge_only": {
|
||||
"$ref": "#/$defs/PolicyOption"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PolicyOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"regex": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "string",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user