diff --git a/charts/clowarden/templates/server_secret.yaml b/charts/clowarden/templates/server_secret.yaml index 7892d05..b4d780f 100644 --- a/charts/clowarden/templates/server_secret.yaml +++ b/charts/clowarden/templates/server_secret.yaml @@ -22,21 +22,10 @@ stringData: password: {{ .Values.server.basicAuth.password }} githubApp: appId: {{ .Values.server.githubApp.appId }} - installationId: {{ .Values.server.githubApp.installationId }} privateKey: {{ .Values.server.githubApp.privateKey | quote }} webhookSecret: {{ .Values.server.githubApp.webhookSecret | quote }} - config: - organization: {{ .Values.server.config.organization }} - repository: {{ .Values.server.config.repository }} - branch: {{ .Values.server.config.branch }} - legacy: - enabled: {{ .Values.server.config.legacy.enabled }} - sheriff: - permissionsPath: {{ .Values.server.config.legacy.sheriff.permissionsPath }} - {{- with .Values.server.config.legacy.cncf.peoplePath }} - cncf: - peoplePath: {{ . }} - {{- end }} - services: - github: - enabled: {{ .Values.server.config.services.github.enabled }} + services: + github: + enabled: {{ .Values.services.github.enabled }} + organizations: {{ toYaml .Values.organizations | nindent 6 }} + diff --git a/charts/clowarden/values.yaml b/charts/clowarden/values.yaml index 25fd794..fe2fb72 100644 --- a/charts/clowarden/values.yaml +++ b/charts/clowarden/values.yaml @@ -59,28 +59,11 @@ server: githubApp: # GitHub application ID appId: null - # GitHub application installation ID - installationId: null # GitHub application private key path privateKey: null # GitHub application webhook secret webhookSecret: null - # CLOWarden service configuration - config: - organization: null - repository: null - branch: main - legacy: - enabled: true - sheriff: - permissionsPath: config.yaml - cncf: - peoplePath: null - services: - github: - enabled: true - # Ingress configuration ingress: enabled: true @@ -101,6 +84,29 @@ server: repository: clowarden-server resources: {} +# Services CLOWarden will manage +services: + github: + enabled: true + +# Organizations managed by this CLOWarden instance +organizations: + [] + # - # Name of the GitHub organization + # name: org-name + # # ID of the CLOWarden's GitHub app installlation + # installationId: 35329291 + # # Repository where the configuration files are located + # repository: .clowarden + # # Branch to use in the configuration repository + # branch: main + # # Legacy mode configuration + # legacy: + # # Whether legacy mode is enabled or not + # enabled: true + # # Path of the Sheriff's permissions file + # sheriffPermissionsPath: config.yaml + # PostgreSQL configuration postgresql: enabled: true diff --git a/clowarden-cli/src/main.rs b/clowarden-cli/src/main.rs index 5533727..0b04563 100644 --- a/clowarden-cli/src/main.rs +++ b/clowarden-cli/src/main.rs @@ -1,15 +1,22 @@ +#![warn(clippy::all, clippy::pedantic)] +#![allow(clippy::doc_markdown, clippy::similar_names)] + use anyhow::{format_err, Result}; use clap::{Args, Parser, Subcommand}; use clowarden_core::{ - github::GHApi, + cfg::Legacy, + github::{GHApi, Source}, multierror, services::{ self, - github::{self, service::SvcApi, State}, + github::{ + self, + service::{Ctx, SvcApi}, + State, + }, Change, }, }; -use config::Config; use std::{env, sync::Arc}; #[derive(Parser)] @@ -68,7 +75,7 @@ async fn main() -> Result<()> { // Setup logging if std::env::var_os("RUST_LOG").is_none() { - std::env::set_var("RUST_LOG", "clowarden_cli=debug") + std::env::set_var("RUST_LOG", "clowarden_cli=debug"); } tracing_subscriber::fmt::init(); @@ -91,10 +98,15 @@ async fn main() -> Result<()> { async fn validate(args: BaseArgs, github_token: String) -> Result<()> { // GitHub + // Setup services + let (gh, svc) = setup_services(github_token); + let legacy = setup_legacy(&args); + let ctx = setup_context(&args); + let src = setup_source(&args); + // Validate configuration and display results println!("Validating configuration..."); - let (cfg, gh, svc) = setup_services(&args, github_token)?; - match github::State::new_from_config(cfg, gh, svc, None, None, None).await { + match github::State::new_from_config(gh, svc, &legacy, &ctx, &src).await { Ok(_) => println!("Configuration is valid!"), Err(err) => { println!("{}\n", multierror::format_error(&err)?); @@ -109,21 +121,26 @@ async fn validate(args: BaseArgs, github_token: String) -> Result<()> { async fn diff(args: BaseArgs, github_token: String) -> Result<()> { // GitHub + // Setup services + let (gh, svc) = setup_services(github_token); + let legacy = setup_legacy(&args); + let ctx = setup_context(&args); + let src = setup_source(&args); + // Get changes from the actual state to the desired state println!("Calculating diff between the actual state and the desired state..."); - let (cfg, gh, svc) = setup_services(&args, github_token)?; - let actual_state = State::new_from_service(svc.clone()).await?; - let desired_state = State::new_from_config(cfg, gh, svc, None, None, None).await?; + let actual_state = State::new_from_service(svc.clone(), &ctx).await?; + let desired_state = State::new_from_config(gh, svc, &legacy, &ctx, &src).await?; let changes = actual_state.diff(&desired_state); // Display changes println!("\n# GitHub"); println!("\n## Directory changes\n"); - for change in changes.directory.into_iter() { + for change in changes.directory { println!("{}", change.template_format()?); } println!("\n## Repositories changes\n"); - for change in changes.repositories.into_iter() { + for change in changes.repositories { println!("{}", change.template_format()?); } println!(); @@ -132,22 +149,36 @@ async fn diff(args: BaseArgs, github_token: String) -> Result<()> { } /// Helper function to setup some services from the arguments provided. -fn setup_services(args: &BaseArgs, github_token: String) -> Result<(Arc, Arc, Arc)> { - let cfg = Config::builder() - .set_override("server.config.legacy.enabled", true)? - .set_override( - "server.config.legacy.sheriff.permissionsPath", - args.permissions_file.clone(), - )? - .set_override_option("server.config.legacy.cncf.peoplePath", args.people_file.clone())? - .build()?; - let gh = GHApi::new( - args.org.clone(), - args.repo.clone(), - args.branch.clone(), - github_token.clone(), - )?; - let svc = services::github::service::SvcApi::new(args.org.clone(), github_token)?; - - Ok((Arc::new(cfg), Arc::new(gh), Arc::new(svc))) +fn setup_services(github_token: String) -> (Arc, Arc) { + let gh = GHApi::new_with_token(github_token.clone()); + let svc = services::github::service::SvcApi::new_with_token(github_token); + + (Arc::new(gh), Arc::new(svc)) +} + +/// Helper function to create a legacy config instance from the arguments. +fn setup_legacy(args: &BaseArgs) -> Legacy { + Legacy { + enabled: true, + sheriff_permissions_path: args.permissions_file.clone(), + cncf_people_path: args.people_file.clone(), + } +} + +/// Helper function to create a context instance from the arguments. +fn setup_context(args: &BaseArgs) -> Ctx { + Ctx { + inst_id: None, + org: args.org.clone(), + } +} + +/// Helper function to create a source instance from the arguments. +fn setup_source(args: &BaseArgs) -> Source { + Source { + inst_id: None, + owner: args.org.clone(), + repo: args.repo.clone(), + ref_: args.branch.clone(), + } } diff --git a/clowarden-core/src/cfg.rs b/clowarden-core/src/cfg.rs new file mode 100644 index 0000000..55449f5 --- /dev/null +++ b/clowarden-core/src/cfg.rs @@ -0,0 +1,32 @@ +//! This module defines some types that represent parts of the configuration. + +use serde::{Deserialize, Serialize}; + +/// Organization configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct Organization { + pub name: String, + pub installation_id: i64, + pub repository: String, + pub branch: String, + pub legacy: Legacy, +} + +/// Organization legacy configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct Legacy { + pub enabled: bool, + pub sheriff_permissions_path: String, + pub cncf_people_path: Option, +} + +/// GitHub application configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct GitHubApp { + pub app_id: i64, + pub private_key: String, + pub webhook_secret: String, +} diff --git a/clowarden-core/src/directory/legacy.rs b/clowarden-core/src/directory/legacy.rs index b79f322..660cc5a 100644 --- a/clowarden-core/src/directory/legacy.rs +++ b/clowarden-core/src/directory/legacy.rs @@ -1,8 +1,14 @@ -use crate::{github::DynGH, multierror::MultiError}; +//! This module defines the types used to represent the legacy configuration +//! format (Sheriff's and CNCF's users). The directory module relies on this +//! module to create new directory instances from the legacy configuration. + +use crate::{ + cfg::Legacy, + github::{DynGH, Source}, + multierror::MultiError, +}; use anyhow::Result; -use config::Config; use serde::{Deserialize, Serialize}; -use std::sync::Arc; /// Legacy configuration. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] @@ -13,17 +19,11 @@ pub(crate) struct Cfg { impl Cfg { /// Get legacy configuration. - pub(crate) async fn get( - cfg: Arc, - gh: DynGH, - owner: Option<&str>, - repo: Option<&str>, - ref_: Option<&str>, - ) -> Result { + pub(crate) async fn get(gh: DynGH, legacy: &Legacy, src: &Source) -> Result { let mut merr = MultiError::new(Some("invalid directory configuration".to_string())); // Get sheriff configuration - let sheriff = match sheriff::Cfg::get(cfg.clone(), gh.clone(), owner, repo, ref_).await { + let sheriff = match sheriff::Cfg::get(gh.clone(), src, &legacy.sheriff_permissions_path).await { Ok(cfg) => Some(cfg), Err(err) => { merr.push(err); @@ -32,7 +32,7 @@ impl Cfg { }; // Get CNCF people configuration - let cncf = match cncf::Cfg::get(cfg, gh, owner, repo, ref_).await { + let cncf = match cncf::Cfg::get(gh, src, legacy.cncf_people_path.as_deref()).await { Ok(cfg) => cfg, Err(err) => { merr.push(err); @@ -53,13 +53,11 @@ impl Cfg { pub(crate) mod sheriff { use crate::{ directory::{TeamName, UserName}, - github::DynGH, + github::{DynGH, Source}, multierror::MultiError, }; use anyhow::{format_err, Context, Error, Result}; - use config::Config; use serde::{Deserialize, Serialize}; - use std::sync::Arc; /// Sheriff configuration. /// https://github.com/electron/sheriff#permissions-file @@ -70,19 +68,9 @@ pub(crate) mod sheriff { impl Cfg { /// Get sheriff configuration. - pub(crate) async fn get( - cfg: Arc, - gh: DynGH, - owner: Option<&str>, - repo: Option<&str>, - ref_: Option<&str>, - ) -> Result { + pub(crate) async fn get(gh: DynGH, src: &Source, path: &str) -> Result { // Fetch configuration file and parse it - let path = &cfg.get_string("server.config.legacy.sheriff.permissionsPath").unwrap(); - let content = gh - .get_file_content(path, owner, repo, ref_) - .await - .context("error getting permissions file")?; + let content = gh.get_file_content(src, path).await.context("error getting permissions file")?; let mut cfg: Cfg = serde_yaml::from_str(&content) .map_err(Error::new) .context("error parsing permissions file")?; @@ -100,7 +88,7 @@ pub(crate) mod sheriff { fn process_composite_teams(&mut self) { let teams_copy = self.teams.clone(); - for team in self.teams.iter_mut() { + for team in &mut self.teams { if let Some(formation) = &team.formation { for team_name in formation { if let Some(source_team) = teams_copy.iter().find(|t| &t.name == team_name) { @@ -126,7 +114,7 @@ pub(crate) mod sheriff { /// Remove duplicates in teams' maintainers and members. fn remove_duplicates(&mut self) { - for team in self.teams.iter_mut() { + for team in &mut self.teams { // Maintainers if let Some(maintainers) = team.maintainers.as_mut() { maintainers.sort(); @@ -151,7 +139,7 @@ pub(crate) mod sheriff { // available, it'll be the team name. Otherwise we'll use its // index on the list. let id = if team.name.is_empty() { - format!("{}", i) + format!("{i}") } else { team.name.clone() }; @@ -206,11 +194,12 @@ pub(crate) mod sheriff { } pub(crate) mod cncf { - use crate::{github::DynGH, multierror::MultiError}; + use crate::{ + github::{DynGH, Source}, + multierror::MultiError, + }; use anyhow::{format_err, Context, Error, Result}; - use config::Config; use serde::{Deserialize, Serialize}; - use std::sync::Arc; /// CNCF people configuration. /// https://github.com/cncf/people/tree/main#listing-format @@ -222,26 +211,18 @@ pub(crate) mod cncf { impl Cfg { /// Get CNCF people configuration. - pub(crate) async fn get( - cfg: Arc, - gh: DynGH, - owner: Option<&str>, - repo: Option<&str>, - ref_: Option<&str>, - ) -> Result> { - match &cfg.get_string("server.config.legacy.cncf.peoplePath") { - Ok(path) => { - let content = gh - .get_file_content(path, owner, repo, ref_) - .await - .context("error getting cncf people file")?; + pub(crate) async fn get(gh: DynGH, src: &Source, path: Option<&str>) -> Result> { + match path { + Some(path) => { + let content = + gh.get_file_content(src, path).await.context("error getting cncf people file")?; let cfg: Cfg = serde_json::from_str(&content) .map_err(Error::new) .context("error parsing cncf people file")?; cfg.validate()?; Ok(Some(cfg)) } - Err(_) => Ok(None), + None => Ok(None), } } diff --git a/clowarden-core/src/directory/mod.rs b/clowarden-core/src/directory/mod.rs index 699cd40..5000fc1 100644 --- a/clowarden-core/src/directory/mod.rs +++ b/clowarden-core/src/directory/mod.rs @@ -1,9 +1,12 @@ +//! This module defines the types used to represent a directory as well as some +//! functionality to create new instances or comparing them. + use crate::{ - github::DynGH, + cfg::{Legacy, Organization}, + github::{DynGH, Source}, services::{BaseRefConfigStatus, Change, ChangeDetails, ChangesSummary, DynChange}, }; use anyhow::{format_err, Context, Result}; -use config::Config; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -11,7 +14,6 @@ use serde_json::json; use std::{ collections::{HashMap, HashSet}, fmt::Write, - sync::Arc, }; mod legacy; @@ -38,20 +40,12 @@ pub struct Directory { } impl Directory { - /// Create a new directory instance from the configuration reference - /// provided (or from the base reference when none is provided). - pub async fn new_from_config( - cfg: Arc, - gh: DynGH, - owner: Option<&str>, - repo: Option<&str>, - ref_: Option<&str>, - ) -> Result { - if let Ok(true) = cfg.get_bool("server.config.legacy.enabled") { - let legacy_cfg = legacy::Cfg::get(cfg, gh, owner, repo, ref_) - .await - .context("invalid directory configuration")?; - return Ok(Self::from(legacy_cfg)); + /// Create a new directory instance from the configuration source provided. + pub async fn new_from_config(gh: DynGH, legacy: &Legacy, src: &Source) -> Result { + if legacy.enabled { + return Ok(Self::from( + legacy::Cfg::get(gh, legacy, src).await.context("invalid directory configuration")?, + )); } Err(format_err!( "only configuration in legacy format supported at the moment" @@ -60,6 +54,7 @@ impl Directory { /// Returns the changes detected between this directory instance and the /// new one provided. + #[must_use] pub fn diff(&self, new: &Directory) -> Vec { let mut changes = vec![]; @@ -71,7 +66,7 @@ impl Directory { let teams_names_old: HashSet<&TeamName> = teams_old.keys().copied().collect(); let teams_names_new: HashSet<&TeamName> = teams_new.keys().copied().collect(); for team_name in teams_names_old.difference(&teams_names_new) { - changes.push(DirectoryChange::TeamRemoved(team_name.to_string())); + changes.push(DirectoryChange::TeamRemoved((*team_name).to_string())); } for team_name in teams_names_new.difference(&teams_names_old) { changes.push(DirectoryChange::TeamAdded(teams_new[*team_name].clone())); @@ -90,27 +85,27 @@ impl Directory { let members_new: HashSet<&UserName> = teams_new[team_name].members.iter().collect(); for user_name in maintainers_old.difference(&maintainers_new) { changes.push(DirectoryChange::TeamMaintainerRemoved( - team_name.to_string(), - user_name.to_string(), - )) + (*team_name).to_string(), + (*user_name).to_string(), + )); } for user_name in members_old.difference(&members_new) { changes.push(DirectoryChange::TeamMemberRemoved( - team_name.to_string(), - user_name.to_string(), - )) + (*team_name).to_string(), + (*user_name).to_string(), + )); } for user_name in maintainers_new.difference(&maintainers_old) { changes.push(DirectoryChange::TeamMaintainerAdded( - team_name.to_string(), - user_name.to_string(), - )) + (*team_name).to_string(), + (*user_name).to_string(), + )); } for user_name in members_new.difference(&members_old) { changes.push(DirectoryChange::TeamMemberAdded( - team_name.to_string(), - user_name.to_string(), - )) + (*team_name).to_string(), + (*user_name).to_string(), + )); } } @@ -123,10 +118,10 @@ impl Directory { let users_fullnames_new: HashSet<&UserFullName> = users_new.keys().copied().collect(); let mut users_added: Vec<&UserFullName> = vec![]; for full_name in users_fullnames_old.difference(&users_fullnames_new) { - changes.push(DirectoryChange::UserRemoved(full_name.to_string())); + changes.push(DirectoryChange::UserRemoved((*full_name).to_string())); } for full_name in users_fullnames_new.difference(&users_fullnames_old) { - changes.push(DirectoryChange::UserAdded(full_name.to_string())); + changes.push(DirectoryChange::UserAdded((*full_name).to_string())); users_added.push(full_name); } @@ -140,7 +135,7 @@ impl Directory { let user_old = &users_old[full_name]; if user_new != user_old { - changes.push(DirectoryChange::UserUpdated(full_name.to_string())); + changes.push(DirectoryChange::UserUpdated((*full_name).to_string())); } } @@ -150,17 +145,14 @@ impl Directory { /// Return a summary of the changes detected in the directory from the base /// to the head reference. pub async fn get_changes_summary( - cfg: Arc, gh: DynGH, - head_owner: Option<&str>, - head_repo: Option<&str>, - head_ref: &str, + org: &Organization, + head_src: &Source, ) -> Result { - let directory_head = - Directory::new_from_config(cfg.clone(), gh.clone(), head_owner, head_repo, Some(head_ref)) - .await?; + let base_src = Source::from(org); + let directory_head = Directory::new_from_config(gh.clone(), &org.legacy, head_src).await?; let (changes, base_ref_config_status) = - match Directory::new_from_config(cfg, gh, None, None, None).await { + match Directory::new_from_config(gh, &org.legacy, &base_src).await { Ok(directory_base) => { let changes = directory_base .diff(&directory_head) @@ -179,11 +171,13 @@ impl Directory { } /// Get team identified by the team name provided. + #[must_use] pub fn get_team(&self, team_name: &str) -> Option<&Team> { self.teams.iter().find(|t| t.name == team_name) } /// Get user identified by the user name provided. + #[must_use] pub fn get_user(&self, user_name: &str) -> Option<&User> { self.users.iter().find(|u| { if let Some(entry_user_name) = &u.user_name { @@ -296,7 +290,7 @@ pub struct User { /// Represents a change in the directory. #[derive(Debug, Clone, PartialEq)] -#[allow(clippy::large_enum_variant)] +#[allow(clippy::large_enum_variant, clippy::module_name_repetitions)] pub enum DirectoryChange { TeamAdded(Team), TeamRemoved(TeamName), diff --git a/clowarden-core/src/github.rs b/clowarden-core/src/github.rs index 0ad3e01..54becf9 100644 --- a/clowarden-core/src/github.rs +++ b/clowarden-core/src/github.rs @@ -1,7 +1,9 @@ -use anyhow::{Context, Result}; +//! This module defines an abstraction layer over the GitHub API. + +use crate::cfg::{GitHubApp, Organization}; +use anyhow::{format_err, Context, Result}; use async_trait::async_trait; use base64::{engine::general_purpose::STANDARD as b64, Engine as _}; -use config::Config; #[cfg(test)] use mockall::automock; use octorust::{ @@ -12,89 +14,75 @@ use std::sync::Arc; /// Trait that defines some operations a GH implementation must support. #[async_trait] +#[allow(clippy::ref_option_ref)] #[cfg_attr(test, automock)] pub trait GH { /// Get file content. - async fn get_file_content( - &self, - path: &str, - owner: Option<&str>, - repo: Option<&str>, - ref_: Option<&str>, - ) -> Result; + async fn get_file_content(&self, src: &Source, path: &str) -> Result; } /// Type alias to represent a GH trait object. pub type DynGH = Arc; /// GH implementation backed by the GitHub API. +#[derive(Default)] pub struct GHApi { - client: Client, - org: String, - repo: String, - branch: String, + app_credentials: Option, + token: Option, } impl GHApi { - /// Create a new GHApi instance. - pub fn new(org: String, repo: String, branch: String, token: String) -> Result { - // Setup GitHub API client - let client = Client::new( - format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")), - Credentials::Token(token), - )?; + /// Create a new GHApi instance using the token provided. + #[must_use] + pub fn new_with_token(token: String) -> Self { + Self { + token: Some(token), + ..Default::default() + } + } + + /// Create a new GHApi instance using the app credentials provided in the + /// configuration. + pub fn new_with_app_creds(gh_app: &GitHubApp) -> Result { + // Setup GitHub app credentials + let private_key = pem::parse(&gh_app.private_key)?.contents().to_owned(); + let jwt_credentials = + JWTCredentials::new(gh_app.app_id, private_key).context("error setting up credentials")?; Ok(Self { - client, - org, - repo, - branch, + app_credentials: Some(jwt_credentials), + ..Default::default() }) } - /// Create a new GHApi instance from the configuration instance provided. - pub fn new_from_config(cfg: Arc) -> Result { - // Setup GitHub app credentials - let app_id = cfg.get_int("server.githubApp.appId").unwrap(); - let app_private_key = - pem::parse(cfg.get_string("server.githubApp.privateKey").unwrap())?.contents().to_owned(); - let credentials = - JWTCredentials::new(app_id, app_private_key).context("error setting up credentials")?; + /// Setup GitHub API client for the installation id provided (if any). + fn setup_client(&self, inst_id: Option) -> Result { + let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); - // Setup GitHub API client - let inst_id = cfg.get_int("server.githubApp.installationId").unwrap(); - let tg = InstallationTokenGenerator::new(inst_id, credentials); - let client = Client::new( - format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")), - Credentials::InstallationToken(tg), - )?; + let credentials = if let Some(inst_id) = inst_id { + let Some(app_creds) = self.app_credentials.clone() else { + return Err(format_err!("error setting up github client: app credentials not provided")); + }; + Credentials::InstallationToken(InstallationTokenGenerator::new(inst_id, app_creds)) + } else { + let Some(token) = self.token.clone() else { + return Err(format_err!("error setting up github client: token not provided")); + }; + Credentials::Token(token) + }; - Ok(Self { - client, - org: cfg.get_string("server.config.organization").unwrap(), - repo: cfg.get_string("server.config.repository").unwrap(), - branch: cfg.get_string("server.config.branch").unwrap(), - }) + Ok(Client::new(user_agent, credentials)?) } } #[async_trait] impl GH for GHApi { /// [GH::get_file_content] - async fn get_file_content( - &self, - path: &str, - owner: Option<&str>, - repo: Option<&str>, - ref_: Option<&str>, - ) -> Result { - let ref_ = ref_.unwrap_or(&self.branch); - let owner = owner.unwrap_or(&self.org); - let repo = repo.unwrap_or(&self.repo); - let mut content = self - .client + async fn get_file_content(&self, src: &Source, path: &str) -> Result { + let client = self.setup_client(src.inst_id)?; + let mut content = client .repos() - .get_content_file(owner, repo, path, ref_) + .get_content_file(&src.owner, &src.repo, path, &src.ref_) .await? .content .as_bytes() @@ -104,3 +92,22 @@ impl GH for GHApi { Ok(decoded_content) } } + +/// Information about the origin of a file located in a GitHub repository. +pub struct Source { + pub inst_id: Option, + pub owner: String, + pub repo: String, + pub ref_: String, +} + +impl From<&Organization> for Source { + fn from(org: &Organization) -> Self { + Source { + inst_id: Some(org.installation_id), + owner: org.name.clone(), + repo: org.repository.clone(), + ref_: org.branch.clone(), + } + } +} diff --git a/clowarden-core/src/lib.rs b/clowarden-core/src/lib.rs index 37fd94e..db305e6 100644 --- a/clowarden-core/src/lib.rs +++ b/clowarden-core/src/lib.rs @@ -1,3 +1,12 @@ +#![warn(clippy::all, clippy::pedantic)] +#![allow( + clippy::doc_markdown, + clippy::missing_errors_doc, + clippy::missing_panics_doc, + clippy::similar_names +)] + +pub mod cfg; pub mod directory; pub mod github; pub mod multierror; diff --git a/clowarden-core/src/multierror.rs b/clowarden-core/src/multierror.rs index 70e7ec9..08994b1 100644 --- a/clowarden-core/src/multierror.rs +++ b/clowarden-core/src/multierror.rs @@ -1,3 +1,5 @@ +//! This module defines an error type that can aggregate multiple errors. + use anyhow::{Error, Result}; use std::fmt; use std::fmt::Write; @@ -11,6 +13,7 @@ pub struct MultiError { impl MultiError { /// Create a new MultiError instance. + #[must_use] pub fn new(context: Option) -> Self { Self { context, @@ -19,18 +22,20 @@ impl MultiError { } /// Check if there is at least one error. + #[must_use] pub fn contains_errors(&self) -> bool { !self.errors.is_empty() } /// Return all errors. + #[must_use] pub fn errors(&self) -> Vec<&Error> { self.errors.iter().collect() } // Append error provided to the internal list of errors. pub fn push(&mut self, err: Error) { - self.errors.push(err) + self.errors.push(err); } } @@ -46,7 +51,7 @@ impl From for MultiError { impl fmt::Display for MultiError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { for err in &self.errors { - write!(f, "{:#} ", err)?; + write!(f, "{err:#} ")?; } Ok(()) } @@ -54,28 +59,26 @@ impl fmt::Display for MultiError { impl std::error::Error for MultiError {} -// Format the error provided recursively. +/// Format the error provided recursively. +#[allow(clippy::missing_errors_doc)] pub fn format_error(err: &Error) -> Result { fn format_error(err: &Error, depth: usize, s: &mut String) -> Result<()> { - match err.downcast_ref::() { - Some(merr) => { - let mut next_depth = depth; - if let Some(context) = &merr.context { - write!(s, "\n{}- {context}", "\t".repeat(depth))?; - next_depth += 1; - } - for err in &merr.errors() { - format_error(err, next_depth, s)?; - } + if let Some(merr) = err.downcast_ref::() { + let mut next_depth = depth; + if let Some(context) = &merr.context { + write!(s, "\n{}- {context}", "\t".repeat(depth))?; + next_depth += 1; + } + for err in &merr.errors() { + format_error(err, next_depth, s)?; } - None => { - write!(s, "\n{}- {err}", "\t".repeat(depth))?; - if err.chain().skip(1).count() > 0 { - let mut depth = depth; - for cause in err.chain().skip(1) { - depth += 1; - write!(s, "\n{}- {cause}", "\t".repeat(depth))?; - } + } else { + write!(s, "\n{}- {err}", "\t".repeat(depth))?; + if err.chain().skip(1).count() > 0 { + let mut depth = depth; + for cause in err.chain().skip(1) { + depth += 1; + write!(s, "\n{}- {cause}", "\t".repeat(depth))?; } } }; diff --git a/clowarden-core/src/services/github/legacy.rs b/clowarden-core/src/services/github/legacy.rs index 6db1255..fe3518b 100644 --- a/clowarden-core/src/services/github/legacy.rs +++ b/clowarden-core/src/services/github/legacy.rs @@ -1,9 +1,15 @@ +//! This module defines the types used to represent the legacy configuration +//! format (Sheriff's). The state module relies on this module to create new +//! state instances from the legacy configuration. + pub(crate) mod sheriff { - use crate::{github::DynGH, multierror::MultiError, services::github::state::Repository}; + use crate::{ + github::{DynGH, Source}, + multierror::MultiError, + services::github::state::Repository, + }; use anyhow::{format_err, Context, Error, Result}; - use config::Config; use serde::{Deserialize, Serialize}; - use std::sync::Arc; /// Sheriff configuration. /// https://github.com/electron/sheriff#permissions-file @@ -14,18 +20,9 @@ pub(crate) mod sheriff { impl Cfg { /// Get sheriff configuration. - pub(crate) async fn get( - cfg: Arc, - gh: DynGH, - owner: Option<&str>, - repo: Option<&str>, - ref_: Option<&str>, - ) -> Result { - let path = &cfg.get_string("server.config.legacy.sheriff.permissionsPath").unwrap(); - let content = gh - .get_file_content(path, owner, repo, ref_) - .await - .context("error getting sheriff permissions file")?; + pub(crate) async fn get(gh: DynGH, src: &Source, path: &str) -> Result { + let content = + gh.get_file_content(src, path).await.context("error getting sheriff permissions file")?; let cfg: Cfg = serde_yaml::from_str(&content) .map_err(Error::new) .context("error parsing permissions file")?; @@ -43,7 +40,7 @@ pub(crate) mod sheriff { // available, it'll be the repo name. Otherwise we'll use its // index on the list. let id = if repo.name.is_empty() { - format!("{}", i) + format!("{i}") } else { repo.name.clone() }; diff --git a/clowarden-core/src/services/github/mod.rs b/clowarden-core/src/services/github/mod.rs index 981dc5f..16d88c9 100644 --- a/clowarden-core/src/services/github/mod.rs +++ b/clowarden-core/src/services/github/mod.rs @@ -1,17 +1,18 @@ +//! This module contains the implementation of the GitHub service handler. + use self::{ - service::DynSvc, + service::{Ctx, DynSvc}, state::{RepositoryChange, RepositoryInvitationId, RepositoryName}, }; use super::{BaseRefConfigStatus, ChangesApplied, ChangesSummary, DynChange, ServiceHandler}; use crate::{ + cfg::Organization, directory::{DirectoryChange, UserName}, - github::DynGH, + github::{DynGH, Source}, services::ChangeApplied, }; use anyhow::{Context, Result}; use async_trait::async_trait; -use config::Config; -use std::sync::Arc; use tracing::debug; mod legacy; @@ -24,30 +25,31 @@ pub const SERVICE_NAME: &str = "github"; /// GitHub's service handler. pub struct Handler { - cfg: Arc, gh: DynGH, svc: DynSvc, } impl Handler { /// Create a new Handler instance. - pub fn new(cfg: Arc, gh: DynGH, svc: DynSvc) -> Self { - Self { cfg, gh, svc } + pub fn new(gh: DynGH, svc: DynSvc) -> Self { + Self { gh, svc } } /// Helper function to get the invitation id for a given user in a /// repository (when available). async fn get_repository_invitation( &self, + ctx: &Ctx, repo_name: &RepositoryName, user_name: &UserName, ) -> Result> { - let invitation_id = self.svc.list_repository_invitations(repo_name).await?.iter().find_map(|i| { - if i.invitee.is_some() && &i.invitee.as_ref().unwrap().login == user_name { - return Some(i.id); - } - None - }); + let invitation_id = + self.svc.list_repository_invitations(ctx, repo_name).await?.iter().find_map(|i| { + if i.invitee.is_some() && &i.invitee.as_ref().unwrap().login == user_name { + return Some(i.id); + } + None + }); Ok(invitation_id) } } @@ -55,42 +57,26 @@ impl Handler { #[async_trait] impl ServiceHandler for Handler { /// [ServiceHandler::get_changes_summary] - async fn get_changes_summary( - &self, - head_owner: Option<&str>, - head_repo: Option<&str>, - head_ref: &str, - ) -> Result { - let head_state = State::new_from_config( - self.cfg.clone(), - self.gh.clone(), - self.svc.clone(), - head_owner, - head_repo, - Some(head_ref), - ) - .await?; - let (changes, base_ref_config_status) = match State::new_from_config( - self.cfg.clone(), - self.gh.clone(), - self.svc.clone(), - None, - None, - None, - ) - .await - { - Ok(base_state) => { - let changes = base_state - .diff(&head_state) - .repositories - .into_iter() - .map(|change| Box::new(change) as DynChange) - .collect(); - (changes, BaseRefConfigStatus::Valid) - } - Err(_) => (vec![], BaseRefConfigStatus::Invalid), - }; + async fn get_changes_summary(&self, org: &Organization, head_src: &Source) -> Result { + let ctx = Ctx::from(org); + let base_src = Source::from(org); + let head_state = + State::new_from_config(self.gh.clone(), self.svc.clone(), &org.legacy, &ctx, head_src).await?; + let (changes, base_ref_config_status) = + match State::new_from_config(self.gh.clone(), self.svc.clone(), &org.legacy, &ctx, &base_src) + .await + { + Ok(base_state) => { + let changes = base_state + .diff(&head_state) + .repositories + .into_iter() + .map(|change| Box::new(change) as DynChange) + .collect(); + (changes, BaseRefConfigStatus::Valid) + } + Err(_) => (vec![], BaseRefConfigStatus::Invalid), + }; Ok(ChangesSummary { changes, @@ -99,21 +85,17 @@ impl ServiceHandler for Handler { } /// [ServiceHandler::reconcile] - async fn reconcile(&self) -> Result { + async fn reconcile(&self, org: &Organization) -> Result { // Get changes between the actual and the desired state - let actual_state = State::new_from_service(self.svc.clone()) + let ctx = Ctx::from(org); + let src = Source::from(org); + let actual_state = State::new_from_service(self.svc.clone(), &ctx) .await .context("error getting actual state from service")?; - let desired_state = State::new_from_config( - self.cfg.clone(), - self.gh.clone(), - self.svc.clone(), - None, - None, - None, - ) - .await - .context("error getting desired state from configuration")?; + let desired_state = + State::new_from_config(self.gh.clone(), self.svc.clone(), &org.legacy, &ctx, &src) + .await + .context("error getting desired state from configuration")?; let changes = actual_state.diff(&desired_state); debug!(?changes, "changes between the actual and the desired state"); @@ -121,72 +103,83 @@ impl ServiceHandler for Handler { let mut changes_applied = vec![]; // Apply directory changes - for change in changes.directory.into_iter() { + let ctx = Ctx::from(org); + for change in changes.directory { let err = match &change { - DirectoryChange::TeamAdded(team) => self.svc.add_team(team).await.err(), - DirectoryChange::TeamRemoved(team_name) => self.svc.remove_team(team_name).await.err(), + DirectoryChange::TeamAdded(team) => self.svc.add_team(&ctx, team).await.err(), + DirectoryChange::TeamRemoved(team_name) => self.svc.remove_team(&ctx, team_name).await.err(), DirectoryChange::TeamMaintainerAdded(team_name, user_name) => { - self.svc.add_team_maintainer(team_name, user_name).await.err() + self.svc.add_team_maintainer(&ctx, team_name, user_name).await.err() } DirectoryChange::TeamMaintainerRemoved(team_name, user_name) => { - self.svc.remove_team_maintainer(team_name, user_name).await.err() + self.svc.remove_team_maintainer(&ctx, team_name, user_name).await.err() } DirectoryChange::TeamMemberAdded(team_name, user_name) => { - self.svc.add_team_member(team_name, user_name).await.err() + self.svc.add_team_member(&ctx, team_name, user_name).await.err() } DirectoryChange::TeamMemberRemoved(team_name, user_name) => { - self.svc.remove_team_member(team_name, user_name).await.err() + self.svc.remove_team_member(&ctx, team_name, user_name).await.err() } - DirectoryChange::UserAdded(_) => continue, - DirectoryChange::UserRemoved(_) => continue, - DirectoryChange::UserUpdated(_) => continue, + DirectoryChange::UserAdded(_) + | DirectoryChange::UserRemoved(_) + | DirectoryChange::UserUpdated(_) => continue, }; changes_applied.push(ChangeApplied { change: Box::new(change), error: err.map(|e| e.to_string()), applied_at: time::OffsetDateTime::now_utc(), - }) + }); } // Apply repositories changes - for change in changes.repositories.into_iter() { + for change in changes.repositories { let err = match &change { - RepositoryChange::RepositoryAdded(repo) => self.svc.add_repository(repo).await.err(), + RepositoryChange::RepositoryAdded(repo) => self.svc.add_repository(&ctx, repo).await.err(), RepositoryChange::TeamAdded(repo_name, team_name, role) => { - self.svc.add_repository_team(repo_name, team_name, role).await.err() + self.svc.add_repository_team(&ctx, repo_name, team_name, role).await.err() } RepositoryChange::TeamRemoved(repo_name, team_name) => { - self.svc.remove_repository_team(repo_name, team_name).await.err() + self.svc.remove_repository_team(&ctx, repo_name, team_name).await.err() } RepositoryChange::TeamRoleUpdated(repo_name, team_name, role) => { - self.svc.update_repository_team_role(repo_name, team_name, role).await.err() + self.svc.update_repository_team_role(&ctx, repo_name, team_name, role).await.err() } RepositoryChange::CollaboratorAdded(repo_name, user_name, role) => { - self.svc.add_repository_collaborator(repo_name, user_name, role).await.err() + self.svc.add_repository_collaborator(&ctx, repo_name, user_name, role).await.err() } RepositoryChange::CollaboratorRemoved(repo_name, user_name) => { - if let Some(invitation_id) = self.get_repository_invitation(repo_name, user_name).await? { - self.svc.remove_repository_invitation(repo_name, invitation_id).await.err() + if let Some(invitation_id) = + self.get_repository_invitation(&ctx, repo_name, user_name).await? + { + self.svc.remove_repository_invitation(&ctx, repo_name, invitation_id).await.err() } else { - self.svc.remove_repository_collaborator(repo_name, user_name).await.err() + self.svc.remove_repository_collaborator(&ctx, repo_name, user_name).await.err() } } RepositoryChange::CollaboratorRoleUpdated(repo_name, user_name, role) => { - if let Some(invitation_id) = self.get_repository_invitation(repo_name, user_name).await? { - self.svc.update_repository_invitation(repo_name, invitation_id, role).await.err() + if let Some(invitation_id) = + self.get_repository_invitation(&ctx, repo_name, user_name).await? + { + self.svc + .update_repository_invitation(&ctx, repo_name, invitation_id, role) + .await + .err() } else { - self.svc.update_repository_collaborator_role(repo_name, user_name, role).await.err() + self.svc + .update_repository_collaborator_role(&ctx, repo_name, user_name, role) + .await + .err() } } RepositoryChange::VisibilityUpdated(repo_name, visibility) => { - self.svc.update_repository_visibility(repo_name, visibility).await.err() + self.svc.update_repository_visibility(&ctx, repo_name, visibility).await.err() } }; changes_applied.push(ChangeApplied { change: Box::new(change), error: err.map(|e| e.to_string()), applied_at: time::OffsetDateTime::now_utc(), - }) + }); } Ok(changes_applied) diff --git a/clowarden-core/src/services/github/service.rs b/clowarden-core/src/services/github/service.rs index f62d7e1..59257a4 100644 --- a/clowarden-core/src/services/github/service.rs +++ b/clowarden-core/src/services/github/service.rs @@ -1,9 +1,13 @@ +//! This module defines an abstraction layer over the service's (GitHub) API. + use super::state::{Repository, RepositoryName, Role, Visibility}; -use crate::directory::{self, TeamName, UserName}; -use anyhow::{Context, Error}; +use crate::{ + cfg::{GitHubApp, Organization}, + directory::{self, TeamName, UserName}, +}; +use anyhow::{format_err, Context, Result}; use async_trait::async_trait; use cached::proc_macro::cached; -use config::Config; #[cfg(test)] use mockall::automock; use octorust::{ @@ -16,7 +20,7 @@ use octorust::{ TeamsAddUpdateMembershipUserInOrgRequest, TeamsAddUpdateRepoPermissionsInOrgRequest, TeamsCreateRequest, TeamsListMembersInOrgRole, }, - Client, ClientError, + Client, }; use std::sync::Arc; use tokio::time::{sleep, Duration}; @@ -26,199 +30,219 @@ use tokio::time::{sleep, Duration}; #[cfg_attr(test, automock)] pub trait Svc { /// Add repository to organization. - async fn add_repository(&self, repo: &Repository) -> Result<(), ClientError>; + async fn add_repository(&self, ctx: &Ctx, repo: &Repository) -> Result<()>; /// Add collaborator to repository. async fn add_repository_collaborator( &self, + ctx: &Ctx, repo_name: &RepositoryName, user_name: &UserName, role: &Role, - ) -> Result<(), ClientError>; + ) -> Result<()>; /// Add team to repository. async fn add_repository_team( &self, + ctx: &Ctx, repo_name: &RepositoryName, team_name: &TeamName, role: &Role, - ) -> Result<(), ClientError>; + ) -> Result<()>; /// Add team to organization. - async fn add_team(&self, team: &directory::Team) -> Result<(), ClientError>; + async fn add_team(&self, ctx: &Ctx, team: &directory::Team) -> Result<()>; /// Add maintainer to the team. - async fn add_team_maintainer( - &self, - team_name: &TeamName, - user_name: &UserName, - ) -> Result<(), ClientError>; + async fn add_team_maintainer(&self, ctx: &Ctx, team_name: &TeamName, user_name: &UserName) -> Result<()>; /// Add member to the team. - async fn add_team_member(&self, team_name: &TeamName, user_name: &UserName) -> Result<(), ClientError>; + async fn add_team_member(&self, ctx: &Ctx, team_name: &TeamName, user_name: &UserName) -> Result<()>; /// Get user's membership in team provided. async fn get_team_membership( &self, + ctx: &Ctx, team_name: &TeamName, user_name: &UserName, - ) -> Result; + ) -> Result; /// List organization admins. - async fn list_org_admins(&self) -> Result, ClientError>; + async fn list_org_admins(&self, ctx: &Ctx) -> Result>; /// List organization members. - async fn list_org_members(&self) -> Result, ClientError>; + async fn list_org_members(&self, ctx: &Ctx) -> Result>; /// List repositories in the organization. - async fn list_repositories(&self) -> Result, ClientError>; + async fn list_repositories(&self, ctx: &Ctx) -> Result>; /// List repository's collaborators. async fn list_repository_collaborators( &self, + ctx: &Ctx, repo_name: &RepositoryName, - ) -> Result, ClientError>; + ) -> Result>; /// List repository's invitations. async fn list_repository_invitations( &self, + ctx: &Ctx, repo_name: &RepositoryName, - ) -> Result, ClientError>; + ) -> Result>; /// List repository's teams. - async fn list_repository_teams(&self, repo_name: &RepositoryName) -> Result, ClientError>; + async fn list_repository_teams(&self, ctx: &Ctx, repo_name: &RepositoryName) -> Result>; /// List team's invitations. async fn list_team_invitations( &self, + ctx: &Ctx, team_name: &TeamName, - ) -> Result, ClientError>; + ) -> Result>; /// List team's maintainers. - async fn list_team_maintainers(&self, team_name: &TeamName) -> Result, ClientError>; + async fn list_team_maintainers(&self, ctx: &Ctx, team_name: &TeamName) -> Result>; /// List team's members. - async fn list_team_members(&self, team_name: &TeamName) -> Result, ClientError>; + async fn list_team_members(&self, ctx: &Ctx, team_name: &TeamName) -> Result>; /// List teams in the organization. - async fn list_teams(&self) -> Result, ClientError>; + async fn list_teams(&self, ctx: &Ctx) -> Result>; /// Remove collaborator from repository. async fn remove_repository_collaborator( &self, + ctx: &Ctx, repo_name: &RepositoryName, user_name: &UserName, - ) -> Result<(), ClientError>; + ) -> Result<()>; /// Remove repository invitation. async fn remove_repository_invitation( &self, + ctx: &Ctx, repo_name: &RepositoryName, invitation_id: i64, - ) -> Result<(), ClientError>; + ) -> Result<()>; /// Remove team from repository. async fn remove_repository_team( &self, + ctx: &Ctx, repo_name: &RepositoryName, team_name: &TeamName, - ) -> Result<(), ClientError>; + ) -> Result<()>; /// Remove team from organization. - async fn remove_team(&self, team_name: &TeamName) -> Result<(), ClientError>; + async fn remove_team(&self, ctx: &Ctx, team_name: &TeamName) -> Result<()>; /// Remove maintainer from the team. async fn remove_team_maintainer( &self, + ctx: &Ctx, team_name: &TeamName, user_name: &UserName, - ) -> Result<(), ClientError>; + ) -> Result<()>; /// Remove member from the team. - async fn remove_team_member(&self, team_name: &TeamName, user_name: &UserName) - -> Result<(), ClientError>; + async fn remove_team_member(&self, ctx: &Ctx, team_name: &TeamName, user_name: &UserName) -> Result<()>; /// Update collaborator role in repository. async fn update_repository_collaborator_role( &self, + ctx: &Ctx, repo_name: &RepositoryName, user_name: &UserName, role: &Role, - ) -> Result<(), ClientError>; + ) -> Result<()>; /// Update repository invitation. async fn update_repository_invitation( &self, + ctx: &Ctx, repo_name: &RepositoryName, invitation_id: i64, role: &Role, - ) -> Result<(), ClientError>; + ) -> Result<()>; /// Update team role in repository. async fn update_repository_team_role( &self, + ctx: &Ctx, repo_name: &RepositoryName, team_name: &TeamName, role: &Role, - ) -> Result<(), ClientError>; + ) -> Result<()>; /// Update repository visibility. async fn update_repository_visibility( &self, + ctx: &Ctx, repo_name: &RepositoryName, visibility: &Visibility, - ) -> Result<(), ClientError>; + ) -> Result<()>; } /// Type alias to represent a Svc trait object. pub type DynSvc = Arc; /// Svc implementation backed by the GitHub API. +#[derive(Default)] pub struct SvcApi { - client: Client, - org: String, + app_credentials: Option, + token: Option, } impl SvcApi { - /// Create a new SvcApi instance. - pub fn new(org: String, token: String) -> Result { - // Setup GitHub API client - let client = Client::new( - format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")), - Credentials::Token(token), - )?; - - Ok(Self { client, org }) + /// Create a new SvcApi instance using the token provided. + #[must_use] + pub fn new_with_token(token: String) -> Self { + Self { + token: Some(token), + ..Default::default() + } } - /// Create a new SvcApi instance from the configuration instance provided. - pub fn new_from_config(cfg: Arc) -> Result { + /// Create a new SvcApi instance using the app credentials provided in the + /// configuration. + pub fn new_with_app_creds(gh_app: &GitHubApp) -> Result { // Setup GitHub app credentials - let app_id = cfg.get_int("server.githubApp.appId").unwrap(); - let app_private_key = - pem::parse(cfg.get_string("server.githubApp.privateKey").unwrap())?.contents().to_owned(); - let credentials = - JWTCredentials::new(app_id, app_private_key).context("error setting up credentials")?; - - // Setup GitHub API client - let inst_id = cfg.get_int("server.githubApp.installationId").unwrap(); - let tg = InstallationTokenGenerator::new(inst_id, credentials); - let client = Client::new( - format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")), - Credentials::InstallationToken(tg), - )?; + let private_key = pem::parse(&gh_app.private_key)?.contents().to_owned(); + let jwt_credentials = + JWTCredentials::new(gh_app.app_id, private_key).context("error setting up credentials")?; Ok(Self { - client, - org: cfg.get_string("server.config.organization").unwrap(), + app_credentials: Some(jwt_credentials), + ..Default::default() }) } + + /// Setup GitHub API client for the installation id provided (if any). + fn setup_client(&self, inst_id: Option) -> Result { + let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + + let credentials = if let Some(inst_id) = inst_id { + let Some(app_creds) = self.app_credentials.clone() else { + return Err(format_err!("error setting up github client: app credentials not provided")); + }; + Credentials::InstallationToken(InstallationTokenGenerator::new(inst_id, app_creds)) + } else { + let Some(token) = self.token.clone() else { + return Err(format_err!("error setting up github client: token not provided")); + }; + Credentials::Token(token) + }; + + Ok(Client::new(user_agent, credentials)?) + } } #[async_trait] impl Svc for SvcApi { /// [Svc::add_repository] - async fn add_repository(&self, repo: &Repository) -> Result<(), ClientError> { + async fn add_repository(&self, ctx: &Ctx, repo: &Repository) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; + // Create repository let visibility = match repo.visibility { Some(Visibility::Internal) => Some(ReposCreateInOrgRequestVisibility::Internal), @@ -233,33 +257,33 @@ impl Svc for SvcApi { allow_squash_merge: None, auto_init: None, delete_branch_on_merge: None, - description: "".to_string(), - gitignore_template: "".to_string(), + description: String::new(), + gitignore_template: String::new(), has_issues: None, has_projects: None, has_wiki: None, - homepage: "".to_string(), + homepage: String::new(), is_template: None, - license_template: "".to_string(), + license_template: String::new(), name: repo.name.clone(), private: None, team_id: 0, visibility, }; - self.client.repos().create_in_org(&self.org, &body).await?; + client.repos().create_in_org(&ctx.org, &body).await?; sleep(Duration::from_secs(1)).await; // Add repository teams if let Some(teams) = &repo.teams { for (team_name, role) in teams { - self.add_repository_team(&repo.name, team_name, role).await?; + self.add_repository_team(ctx, &repo.name, team_name, role).await?; } } // Add repository collaborators if let Some(collaborators) = &repo.collaborators { for (user_name, role) in collaborators { - self.add_repository_collaborator(&repo.name, user_name, role).await?; + self.add_repository_collaborator(ctx, &repo.name, user_name, role).await?; } } @@ -269,81 +293,85 @@ impl Svc for SvcApi { /// [Svc::add_repository_collaborator] async fn add_repository_collaborator( &self, + ctx: &Ctx, repo_name: &RepositoryName, user_name: &UserName, role: &Role, - ) -> Result<(), ClientError> { + ) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; let body = ReposAddCollaboratorRequest { permission: Some(role.into()), - permissions: "".to_string(), + permissions: String::new(), }; - self.client.repos().add_collaborator(&self.org, repo_name, user_name, &body).await?; + client.repos().add_collaborator(&ctx.org, repo_name, user_name, &body).await?; Ok(()) } /// [Svc::add_repository_team] async fn add_repository_team( &self, + ctx: &Ctx, repo_name: &RepositoryName, team_name: &TeamName, role: &Role, - ) -> Result<(), ClientError> { + ) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; let body = TeamsAddUpdateRepoPermissionsInOrgRequest { permission: Some(role.into()), }; - self.client + client .teams() - .add_or_update_repo_permissions_in_org(&self.org, team_name, &self.org, repo_name, &body) - .await + .add_or_update_repo_permissions_in_org(&ctx.org, team_name, &ctx.org, repo_name, &body) + .await?; + Ok(()) } /// [Svc::add_team] - async fn add_team(&self, team: &directory::Team) -> Result<(), ClientError> { + async fn add_team(&self, ctx: &Ctx, team: &directory::Team) -> Result<()> { // Create team + let client = self.setup_client(ctx.inst_id)?; let body = TeamsCreateRequest { name: team.name.clone(), - description: "".to_string(), + description: String::new(), maintainers: team.maintainers.clone(), parent_team_id: 0, permission: None, privacy: Some(Privacy::Closed), repo_names: vec![], }; - self.client.teams().create(&self.org, &body).await?; + client.teams().create(&ctx.org, &body).await?; sleep(Duration::from_secs(1)).await; // Add team members for user_name in &team.members { - self.add_team_member(&team.name, user_name).await?; + self.add_team_member(ctx, &team.name, user_name).await?; } Ok(()) } /// [Svc::add_team_maintainer] - async fn add_team_maintainer( - &self, - team_name: &TeamName, - user_name: &UserName, - ) -> Result<(), ClientError> { + async fn add_team_maintainer(&self, ctx: &Ctx, team_name: &TeamName, user_name: &UserName) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; let body = TeamsAddUpdateMembershipUserInOrgRequest { role: Some(TeamMembershipRole::Maintainer), }; - self.client + client .teams() - .add_or_update_membership_for_user_in_org(&self.org, team_name, user_name, &body) + .add_or_update_membership_for_user_in_org(&ctx.org, team_name, user_name, &body) .await?; Ok(()) } /// [Svc::add_team_member] - async fn add_team_member(&self, team_name: &TeamName, user_name: &UserName) -> Result<(), ClientError> { + async fn add_team_member(&self, ctx: &Ctx, team_name: &TeamName, user_name: &UserName) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; let body = TeamsAddUpdateMembershipUserInOrgRequest { role: Some(TeamMembershipRole::Member), }; - self.client + client .teams() - .add_or_update_membership_for_user_in_org(&self.org, team_name, user_name, &body) + .add_or_update_membership_for_user_in_org(&ctx.org, team_name, user_name, &body) .await?; Ok(()) } @@ -351,14 +379,16 @@ impl Svc for SvcApi { /// [Svc::get_team_membership] async fn get_team_membership( &self, + ctx: &Ctx, team_name: &TeamName, user_name: &UserName, - ) -> Result { - self.client.teams().get_membership_for_user_in_org(&self.org, team_name, user_name).await + ) -> Result { + let client = self.setup_client(ctx.inst_id)?; + Ok(client.teams().get_membership_for_user_in_org(&ctx.org, team_name, user_name).await?) } /// [Svc::list_org_admins] - async fn list_org_admins(&self) -> Result, ClientError> { + async fn list_org_admins(&self, ctx: &Ctx) -> Result> { #[cached( time = 60, sync_writes = true, @@ -366,17 +396,19 @@ impl Svc for SvcApi { key = "String", convert = r#"{ format!("") }"# )] - async fn inner(client: &Client, org: &str) -> Result, ClientError> { - client + async fn inner(client: &Client, org: &str) -> Result> { + let members = client .orgs() .list_all_members(org, OrgsListMembersFilter::All, OrgsListMembersRole::Admin) - .await + .await?; + Ok(members) } - inner(&self.client, &self.org).await + let client = self.setup_client(ctx.inst_id)?; + inner(&client, &ctx.org).await } /// [Svc::list_org_members] - async fn list_org_members(&self) -> Result, ClientError> { + async fn list_org_members(&self, ctx: &Ctx) -> Result> { #[cached( time = 60, sync_writes = true, @@ -384,44 +416,50 @@ impl Svc for SvcApi { key = "String", convert = r#"{ format!("") }"# )] - async fn inner(client: &Client, org: &str) -> Result, ClientError> { - client + async fn inner(client: &Client, org: &str) -> Result> { + let members = client .orgs() .list_all_members(org, OrgsListMembersFilter::All, OrgsListMembersRole::All) - .await + .await?; + Ok(members) } - inner(&self.client, &self.org).await + let client = self.setup_client(ctx.inst_id)?; + inner(&client, &ctx.org).await } /// [Svc::list_repositories] - async fn list_repositories(&self) -> Result, ClientError> { - self.client + async fn list_repositories(&self, ctx: &Ctx) -> Result> { + let client = self.setup_client(ctx.inst_id)?; + let repos = client .repos() .list_all_for_org( - &self.org, + &ctx.org, ReposListOrgType::All, ReposListOrgSort::FullName, Order::Asc, ) - .await + .await?; + Ok(repos) } /// [Svc::list_repository_collaborators] async fn list_repository_collaborators( &self, + ctx: &Ctx, repo_name: &RepositoryName, - ) -> Result, ClientError> { - self.client - .repos() - .list_all_collaborators(&self.org, repo_name, Affiliation::Direct) - .await + ) -> Result> { + let client = self.setup_client(ctx.inst_id)?; + let collaborators = + client.repos().list_all_collaborators(&ctx.org, repo_name, Affiliation::Direct).await?; + Ok(collaborators) } /// [Svc::list_repository_invitations] async fn list_repository_invitations( &self, + ctx: &Ctx, repo_name: &RepositoryName, - ) -> Result, ClientError> { + ) -> Result> { #[cached( time = 60, sync_writes = true, @@ -429,157 +467,181 @@ impl Svc for SvcApi { key = "String", convert = r#"{ format!("{}", repo_name) }"# )] - async fn inner( - client: &Client, - org: &str, - repo_name: &str, - ) -> Result, ClientError> { - client.repos().list_all_invitations(org, repo_name).await + async fn inner(client: &Client, org: &str, repo_name: &str) -> Result> { + let invitations = client.repos().list_all_invitations(org, repo_name).await?; + Ok(invitations) } - inner(&self.client, &self.org, repo_name).await + let client = self.setup_client(ctx.inst_id)?; + inner(&client, &ctx.org, repo_name).await } /// [Svc::list_repository_teams] - async fn list_repository_teams(&self, repo_name: &RepositoryName) -> Result, ClientError> { - self.client.repos().list_all_teams(&self.org, repo_name).await + async fn list_repository_teams(&self, ctx: &Ctx, repo_name: &RepositoryName) -> Result> { + let client = self.setup_client(ctx.inst_id)?; + let teams = client.repos().list_all_teams(&ctx.org, repo_name).await?; + Ok(teams) } /// [Svc::list_team_invitations] async fn list_team_invitations( &self, + ctx: &Ctx, team_name: &TeamName, - ) -> Result, ClientError> { - self.client.teams().list_all_pending_invitations_in_org(&self.org, team_name).await + ) -> Result> { + let client = self.setup_client(ctx.inst_id)?; + let invitations = client.teams().list_all_pending_invitations_in_org(&ctx.org, team_name).await?; + Ok(invitations) } /// [Svc::list_team_maintainers] - async fn list_team_maintainers(&self, team_name: &TeamName) -> Result, ClientError> { - self.client + async fn list_team_maintainers(&self, ctx: &Ctx, team_name: &TeamName) -> Result> { + let client = self.setup_client(ctx.inst_id)?; + let maintainers = client .teams() - .list_all_members_in_org(&self.org, team_name, TeamsListMembersInOrgRole::Maintainer) - .await + .list_all_members_in_org(&ctx.org, team_name, TeamsListMembersInOrgRole::Maintainer) + .await?; + Ok(maintainers) } /// [Svc::list_team_members] - async fn list_team_members(&self, team_name: &TeamName) -> Result, ClientError> { - self.client + async fn list_team_members(&self, ctx: &Ctx, team_name: &TeamName) -> Result> { + let client = self.setup_client(ctx.inst_id)?; + let members = client .teams() - .list_all_members_in_org(&self.org, team_name, TeamsListMembersInOrgRole::Member) - .await + .list_all_members_in_org(&ctx.org, team_name, TeamsListMembersInOrgRole::Member) + .await?; + Ok(members) } /// [Svc::list_teams] - async fn list_teams(&self) -> Result, ClientError> { - self.client.teams().list_all(&self.org).await + async fn list_teams(&self, ctx: &Ctx) -> Result> { + let client = self.setup_client(ctx.inst_id)?; + let teams = client.teams().list_all(&ctx.org).await?; + Ok(teams) } /// [Svc::remove_repository_collaborator] async fn remove_repository_collaborator( &self, + ctx: &Ctx, repo_name: &RepositoryName, user_name: &UserName, - ) -> Result<(), ClientError> { - self.client.repos().remove_collaborator(&self.org, repo_name, user_name).await + ) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; + client.repos().remove_collaborator(&ctx.org, repo_name, user_name).await?; + Ok(()) } /// [Svc::remove_repository_invitation] async fn remove_repository_invitation( &self, + ctx: &Ctx, repo_name: &RepositoryName, invitation_id: i64, - ) -> Result<(), ClientError> { - self.client.repos().delete_invitation(&self.org, repo_name, invitation_id).await + ) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; + client.repos().delete_invitation(&ctx.org, repo_name, invitation_id).await?; + Ok(()) } /// [Svc::remove_repository_team] async fn remove_repository_team( &self, + ctx: &Ctx, repo_name: &RepositoryName, team_name: &TeamName, - ) -> Result<(), ClientError> { - self.client.teams().remove_repo_in_org(&self.org, team_name, &self.org, repo_name).await + ) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; + client.teams().remove_repo_in_org(&ctx.org, team_name, &ctx.org, repo_name).await?; + Ok(()) } /// [Svc::remove_team] - async fn remove_team(&self, team_name: &TeamName) -> Result<(), ClientError> { - self.client.teams().delete_in_org(&self.org, team_name).await + async fn remove_team(&self, ctx: &Ctx, team_name: &TeamName) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; + client.teams().delete_in_org(&ctx.org, team_name).await?; + Ok(()) } /// [Svc::remove_team_maintainer] async fn remove_team_maintainer( &self, + ctx: &Ctx, team_name: &TeamName, user_name: &UserName, - ) -> Result<(), ClientError> { - self.client - .teams() - .remove_membership_for_user_in_org(&self.org, team_name, user_name) - .await + ) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; + client.teams().remove_membership_for_user_in_org(&ctx.org, team_name, user_name).await?; + Ok(()) } /// [Svc::remove_team_member] - async fn remove_team_member( - &self, - team_name: &TeamName, - user_name: &UserName, - ) -> Result<(), ClientError> { - self.client - .teams() - .remove_membership_for_user_in_org(&self.org, team_name, user_name) - .await + async fn remove_team_member(&self, ctx: &Ctx, team_name: &TeamName, user_name: &UserName) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; + client.teams().remove_membership_for_user_in_org(&ctx.org, team_name, user_name).await?; + Ok(()) } /// [Svc::update_repository_collaborator_role] async fn update_repository_collaborator_role( &self, + ctx: &Ctx, repo_name: &RepositoryName, user_name: &UserName, role: &Role, - ) -> Result<(), ClientError> { + ) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; let body = ReposAddCollaboratorRequest { permission: Some(role.into()), - permissions: "".to_string(), + permissions: String::new(), }; - self.client.repos().add_collaborator(&self.org, repo_name, user_name, &body).await?; + client.repos().add_collaborator(&ctx.org, repo_name, user_name, &body).await?; Ok(()) } /// [Svc::update_repository_invitation] async fn update_repository_invitation( &self, + ctx: &Ctx, repo_name: &RepositoryName, invitation_id: i64, role: &Role, - ) -> Result<(), ClientError> { + ) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; let body = ReposUpdateInvitationRequest { permissions: Some(role.into()), }; - self.client.repos().update_invitation(&self.org, repo_name, invitation_id, &body).await?; + client.repos().update_invitation(&ctx.org, repo_name, invitation_id, &body).await?; Ok(()) } /// [Svc::update_repository_team_role] async fn update_repository_team_role( &self, + ctx: &Ctx, repo_name: &RepositoryName, team_name: &TeamName, role: &Role, - ) -> Result<(), ClientError> { + ) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; let body = TeamsAddUpdateRepoPermissionsInOrgRequest { permission: Some(role.into()), }; - self.client + client .teams() - .add_or_update_repo_permissions_in_org(&self.org, team_name, &self.org, repo_name, &body) - .await + .add_or_update_repo_permissions_in_org(&ctx.org, team_name, &ctx.org, repo_name, &body) + .await?; + Ok(()) } /// [Svc::update_repository_visibility] async fn update_repository_visibility( &self, + ctx: &Ctx, repo_name: &RepositoryName, visibility: &Visibility, - ) -> Result<(), ClientError> { + ) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; let visibility = match visibility { Visibility::Internal => Some(ReposCreateInOrgRequestVisibility::Internal), Visibility::Private => Some(ReposCreateInOrgRequestVisibility::Private), @@ -591,20 +653,35 @@ impl Svc for SvcApi { allow_rebase_merge: None, allow_squash_merge: None, archived: None, - default_branch: "".to_string(), + default_branch: String::new(), delete_branch_on_merge: None, - description: "".to_string(), + description: String::new(), has_issues: None, has_projects: None, has_wiki: None, - homepage: "".to_string(), + homepage: String::new(), is_template: None, name: repo_name.clone(), private: None, security_and_analysis: None, visibility, }; - self.client.repos().update(&self.org, repo_name, &body).await?; + client.repos().update(&ctx.org, repo_name, &body).await?; Ok(()) } } + +/// Information about the target of a GitHub API request. +pub struct Ctx { + pub inst_id: Option, + pub org: String, +} + +impl From<&Organization> for Ctx { + fn from(org: &Organization) -> Self { + Ctx { + inst_id: Some(org.installation_id), + org: org.name.clone(), + } + } +} diff --git a/clowarden-core/src/services/github/state.rs b/clowarden-core/src/services/github/state.rs index 2674c73..ad038b1 100644 --- a/clowarden-core/src/services/github/state.rs +++ b/clowarden-core/src/services/github/state.rs @@ -1,12 +1,19 @@ -use super::{legacy, service::DynSvc}; +//! This module defines the types used to represent the state of the GitHub +//! service, as well as the functionality to create new instances from the +//! configuration or the service, and validating and comparing them. + +use super::{ + legacy, + service::{Ctx, DynSvc}, +}; use crate::{ + cfg::Legacy, directory::{Directory, DirectoryChange, Team, TeamName, UserName}, - github::DynGH, + github::{DynGH, Source}, multierror::MultiError, services::{Change, ChangeDetails}, }; use anyhow::{format_err, Context, Result}; -use config::Config; use futures::{ future, stream::{self, StreamExt}, @@ -20,7 +27,6 @@ use serde_json::json; use std::{ collections::{HashMap, HashSet}, fmt::{self, Write}, - sync::Arc, }; /// Type alias to represent a repository name. @@ -37,29 +43,27 @@ pub struct State { } impl State { - /// Create a new State instance from the configuration reference provided - /// (or from the base reference when none is provided). + /// Create a new State instance from the configuration reference provided. pub async fn new_from_config( - cfg: Arc, gh: DynGH, svc: DynSvc, - owner: Option<&str>, - repo: Option<&str>, - ref_: Option<&str>, + legacy: &Legacy, + ctx: &Ctx, + src: &Source, ) -> Result { - if let Ok(true) = cfg.get_bool("server.config.legacy.enabled") { + if legacy.enabled { // We need to get some information from the service's actual state // to deal with some service's particularities. let org_admins: Vec = - svc.list_org_admins().await?.into_iter().map(|a| a.login).collect(); - let repositories_in_service = svc.list_repositories().await?; + svc.list_org_admins(ctx).await?.into_iter().map(|a| a.login).collect(); + let repositories_in_service = svc.list_repositories(ctx).await?; // Helper function to check if a repository has been archived. We // cannot add or remove collaborators or teams to an archived repo, // so we will just ignore them and no changes will be applied to // them while they stay archived. let is_repository_archived = |repo_name: &RepositoryName| { - for repo in repositories_in_service.iter() { + for repo in &repositories_in_service { if &repo.name == repo_name { return repo.archived; } @@ -68,24 +72,23 @@ impl State { }; // Prepare directory - let mut directory = - Directory::new_from_config(cfg.clone(), gh.clone(), owner, repo, ref_).await?; + let mut directory = Directory::new_from_config(gh.clone(), legacy, src).await?; // Team's members that are org admins are considered maintainers by // GitHub, so we do the same with the members defined in the config - for team in directory.teams.iter_mut() { + for team in &mut directory.teams { let mut org_admins_members = vec![]; - for user_name in team.members.clone().iter() { + for user_name in &team.members.clone() { if org_admins.contains(user_name) { - org_admins_members.push(user_name.to_owned()); - team.maintainers.push(user_name.to_owned()); + org_admins_members.push(user_name.clone()); + team.maintainers.push(user_name.clone()); } } team.members.retain(|user_name| !org_admins_members.contains(user_name)); } // Prepare repositories - let repositories = legacy::sheriff::Cfg::get(cfg, gh, owner, repo, ref_) + let repositories = legacy::sheriff::Cfg::get(gh, src, &legacy.sheriff_permissions_path) .await .context("invalid github service configuration")? .repositories @@ -115,7 +118,7 @@ impl State { directory, repositories, }; - state.validate(svc).await?; + state.validate(svc, ctx).await?; return Ok(state); } @@ -125,24 +128,24 @@ impl State { } /// Create a new State instance from the service's actual state. - pub async fn new_from_service(svc: DynSvc) -> Result { + pub async fn new_from_service(svc: DynSvc, ctx: &Ctx) -> Result { let mut state = State::default(); // Teams - for team in stream::iter(svc.list_teams().await?) + for team in stream::iter(svc.list_teams(ctx).await?) .map(|team| async { // Get maintainers and members (including pending invitations) let mut maintainers: Vec = - svc.list_team_maintainers(&team.slug).await?.into_iter().map(|u| u.login).collect(); + svc.list_team_maintainers(ctx, &team.slug).await?.into_iter().map(|u| u.login).collect(); let mut members: Vec = - svc.list_team_members(&team.slug).await?.into_iter().map(|u| u.login).collect(); - for invitation in svc.list_team_invitations(&team.slug).await?.into_iter() { - let membership = svc.get_team_membership(&team.slug, &invitation.login).await?; + svc.list_team_members(ctx, &team.slug).await?.into_iter().map(|u| u.login).collect(); + for invitation in svc.list_team_invitations(ctx, &team.slug).await? { + let membership = svc.get_team_membership(ctx, &team.slug, &invitation.login).await?; if membership.state == OrgMembershipState::Pending { match membership.role { TeamMembershipRole::Maintainer => maintainers.push(invitation.login), TeamMembershipRole::Member => members.push(invitation.login), - _ => {} + TeamMembershipRole::FallthroughString => {} } } } @@ -167,19 +170,20 @@ impl State { } // Repositories - let org_admins: Vec = svc.list_org_admins().await?.into_iter().map(|a| a.login).collect(); - for repo in stream::iter(svc.list_repositories().await?) + let org_admins: Vec = + svc.list_org_admins(ctx).await?.into_iter().map(|a| a.login).collect(); + for repo in stream::iter(svc.list_repositories(ctx).await?) .filter(|repo| future::ready(!repo.archived)) .map(|repo| async { // Get collaborators (including pending invitations and excluding org admins) let mut collaborators: HashMap = svc - .list_repository_collaborators(&repo.name) + .list_repository_collaborators(ctx, &repo.name) .await? .into_iter() .filter(|c| !org_admins.contains(&c.login)) .map(|c| (c.login, c.permissions.into())) .collect(); - for invitation in svc.list_repository_invitations(&repo.name).await?.into_iter() { + for invitation in svc.list_repository_invitations(ctx, &repo.name).await? { if let Some(invitee) = invitation.invitee { collaborators.insert(invitee.login, invitation.permissions.into()); } @@ -192,7 +196,7 @@ impl State { // Get teams let teams: HashMap = svc - .list_repository_teams(&repo.name) + .list_repository_teams(ctx, &repo.name) .await? .into_iter() .map(|t| (t.name, t.permissions.into())) @@ -222,6 +226,7 @@ impl State { /// Returns the changes detected between this state instance and the new /// one provided. + #[must_use] pub fn diff(&self, new: &State) -> Changes { Changes { directory: self @@ -243,7 +248,7 @@ impl State { } /// Validate state. - async fn validate(&self, svc: DynSvc) -> Result<()> { + async fn validate(&self, svc: DynSvc, ctx: &Ctx) -> Result<()> { let mut merr = MultiError::new(Some("invalid github service configuration".to_string())); // Helper closure to get the highest role from a team membership for a @@ -270,7 +275,8 @@ impl State { }; // Check teams' maintainers are members of the organization - let org_members: Vec = svc.list_org_members().await?.into_iter().map(|m| m.login).collect(); + let org_members: Vec = + svc.list_org_members(ctx).await?.into_iter().map(|m| m.login).collect(); for team in &self.directory.teams { for user_name in &team.maintainers { if !org_members.contains(user_name) { @@ -287,7 +293,7 @@ impl State { // available, it'll be the repo name. Otherwise we'll use its // index on the list. let id = if repo.name.is_empty() { - format!("{}", i) + format!("{i}") } else { repo.name.clone() }; @@ -329,6 +335,7 @@ impl State { } /// Returns the changes detected between two lists of repositories. + #[allow(clippy::too_many_lines)] fn repositories_diff(old: &[Repository], new: &[Repository]) -> Vec { let mut changes = vec![]; @@ -341,7 +348,7 @@ impl State { repo_name: &RepositoryName, team_name: &TeamName| { if let Some(teams) = collection[repo_name].teams.as_ref() { - return teams.get(&team_name.to_string()).map(|r| r.to_owned()).unwrap_or_default(); + return teams.get(&team_name.to_string()).cloned().unwrap_or_default(); } Role::default() }; @@ -349,7 +356,7 @@ impl State { repo_name: &RepositoryName, user_name: &UserName| { if let Some(collaborators) = collection[repo_name].collaborators.as_ref() { - return collaborators.get(&user_name.to_string()).map(|r| r.to_owned()).unwrap_or_default(); + return collaborators.get(&user_name.to_string()).cloned().unwrap_or_default(); } Role::default() }; @@ -379,16 +386,16 @@ impl State { } for team_name in teams_old.difference(&teams_new) { changes.push(RepositoryChange::TeamRemoved( - repo_name.to_string(), - team_name.to_string(), - )) + (*repo_name).to_string(), + (*team_name).to_string(), + )); } for team_name in teams_new.difference(&teams_old) { changes.push(RepositoryChange::TeamAdded( - repo_name.to_string(), - team_name.to_string(), + (*repo_name).to_string(), + (*team_name).to_string(), team_role(&repos_new, repo_name, team_name), - )) + )); } for team_name in &teams_new { if !teams_old.contains(team_name) { @@ -399,10 +406,10 @@ impl State { let role_old = team_role(&repos_old, repo_name, team_name); if role_new != role_old { changes.push(RepositoryChange::TeamRoleUpdated( - repo_name.to_string(), - team_name.to_string(), + (*repo_name).to_string(), + (*team_name).to_string(), role_new, - )) + )); } } @@ -417,16 +424,16 @@ impl State { } for user_name in collaborators_old.difference(&collaborators_new) { changes.push(RepositoryChange::CollaboratorRemoved( - repo_name.to_string(), - user_name.to_string(), - )) + (*repo_name).to_string(), + (*user_name).to_string(), + )); } for user_name in collaborators_new.difference(&collaborators_old) { changes.push(RepositoryChange::CollaboratorAdded( - repo_name.to_string(), - user_name.to_string(), + (*repo_name).to_string(), + (*user_name).to_string(), user_role(&repos_new, repo_name, user_name), - )) + )); } for user_name in &collaborators_new { if !collaborators_old.contains(user_name) { @@ -437,10 +444,10 @@ impl State { let role_old = user_role(&repos_old, repo_name, user_name); if role_new != role_old { changes.push(RepositoryChange::CollaboratorRoleUpdated( - repo_name.to_string(), - user_name.to_string(), + (*repo_name).to_string(), + (*user_name).to_string(), role_new, - )) + )); } } @@ -450,9 +457,9 @@ impl State { if visibility_new != visibility_old { let visibility_new = visibility_new.clone().unwrap_or_default(); changes.push(RepositoryChange::VisibilityUpdated( - repo_name.to_string(), + (*repo_name).to_string(), visibility_new, - )) + )); } } @@ -507,8 +514,7 @@ impl From> for Role { Some(p) if p.push => Role::Write, Some(p) if p.triage => Role::Triage, Some(p) if p.pull => Role::Read, - Some(_) => Role::default(), - None => Role::default(), + Some(_) | None => Role::default(), } } } @@ -558,8 +564,7 @@ impl From> for Role { Some(p) if p.push => Role::Write, Some(p) if p.triage => Role::Triage, Some(p) if p.pull => Role::Read, - Some(_) => Role::default(), - None => Role::default(), + Some(_) | None => Role::default(), } } } @@ -734,50 +739,43 @@ impl Change for RepositoryChange { RepositoryChange::TeamAdded(repo_name, team_name, role) => { write!( s, - "- team **{}** has been *added* to repository **{}** (role: **{}**)", - team_name, repo_name, role + "- team **{team_name}** has been *added* to repository **{repo_name}** (role: **{role}**)" )?; } RepositoryChange::TeamRemoved(repo_name, team_name) => { write!( s, - "- team **{}** has been *removed* from repository **{}**", - team_name, repo_name + "- team **{team_name}** has been *removed* from repository **{repo_name}**" )?; } RepositoryChange::TeamRoleUpdated(repo_name, team_name, role) => { write!( s, - "- team **{}** role in repository **{}** has been *updated* to **{}**", - team_name, repo_name, role + "- team **{team_name}** role in repository **{repo_name}** has been *updated* to **{role}**" )?; } RepositoryChange::CollaboratorAdded(repo_name, user_name, role) => { write!( s, - "- user **{}** is now a collaborator (role: **{}**) of repository **{}**", - user_name, role, repo_name + "- user **{user_name}** is now a collaborator (role: **{role}**) of repository **{repo_name}**" )?; } RepositoryChange::CollaboratorRemoved(repo_name, user_name) => { write!( s, - "- user **{}** is no longer a collaborator of repository **{}**", - user_name, repo_name + "- user **{user_name}** is no longer a collaborator of repository **{repo_name}**" )?; } RepositoryChange::CollaboratorRoleUpdated(repo_name, user_name, role) => { write!( s, - "- user **{}** role in repository **{}** has been updated to **{}**", - user_name, repo_name, role + "- user **{user_name}** role in repository **{repo_name}** has been updated to **{role}**" )?; } RepositoryChange::VisibilityUpdated(repo_name, visibility) => { write!( s, - "- repository **{}** visibility has been updated to **{}**", - repo_name, visibility + "- repository **{repo_name}** visibility has been updated to **{visibility}**" )?; } } diff --git a/clowarden-core/src/services/mod.rs b/clowarden-core/src/services/mod.rs index 19ca691..b189fa0 100644 --- a/clowarden-core/src/services/mod.rs +++ b/clowarden-core/src/services/mod.rs @@ -1,3 +1,7 @@ +//! This module defines some types and traits that service handlers +//! implementations will rely upon. + +use crate::{cfg::Organization, github::Source}; use anyhow::Result; use async_trait::async_trait; use std::fmt::Debug; @@ -12,16 +16,11 @@ pub type ServiceName = &'static str; pub trait ServiceHandler { /// Return a summary of the changes detected in the service's state as /// defined in the configuration from the base to the head reference. - async fn get_changes_summary( - &self, - head_owner: Option<&str>, - head_repo: Option<&str>, - head_ref: &str, - ) -> Result; + async fn get_changes_summary(&self, org: &Organization, head_src: &Source) -> Result; /// Apply the changes needed so that the actual state (as defined in the /// service) matches the desired state (as defined in the configuration). - async fn reconcile(&self) -> Result; + async fn reconcile(&self, org: &Organization) -> Result; } /// Type alias to represent a service handler trait object. @@ -71,6 +70,7 @@ pub enum BaseRefConfigStatus { impl BaseRefConfigStatus { /// Check if the configuration is invalid. + #[must_use] pub fn is_invalid(&self) -> bool { *self == BaseRefConfigStatus::Invalid } diff --git a/clowarden-server/src/db.rs b/clowarden-server/src/db.rs index 02cc836..7f734d0 100644 --- a/clowarden-server/src/db.rs +++ b/clowarden-server/src/db.rs @@ -1,3 +1,5 @@ +//! This module defines an abstraction layer over the database. + use crate::jobs::ReconcileInput; use anyhow::{Error, Result}; use async_trait::async_trait; @@ -60,17 +62,17 @@ impl DB for PgDB { let tx = db.transaction().await?; // Prepare reconciliation errors summary - let errors_summary = if !errors.is_empty() { + let errors_summary = if errors.is_empty() { + None + } else { let mut summary = String::new(); for (i, (service_name, error)) in errors.iter().enumerate() { - summary.push_str(&format!("{}: {:?}", service_name, error)); + summary.push_str(&format!("{service_name}: {error:?}")); if errors.len() > i + 1 { summary.push('\n'); } } Some(summary) - } else { - None }; // Register reconciliation entry diff --git a/clowarden-server/src/github.rs b/clowarden-server/src/github.rs index f7e55d9..08b8d98 100644 --- a/clowarden-server/src/github.rs +++ b/clowarden-server/src/github.rs @@ -1,7 +1,9 @@ +//! This module defines an abstraction layer over the GitHub API. + use anyhow::{Context, Result}; use async_trait::async_trait; use axum::http::HeaderValue; -use config::Config; +use clowarden_core::cfg::{GitHubApp, Organization}; #[cfg(test)] use mockall::automock; use octorust::{ @@ -21,13 +23,13 @@ use thiserror::Error; #[cfg_attr(test, automock)] pub(crate) trait GH { /// Create a check run. - async fn create_check_run(&self, body: &ChecksCreateRequest) -> Result<()>; + async fn create_check_run(&self, ctx: &Ctx, body: &ChecksCreateRequest) -> Result<()>; /// List pull request files. - async fn list_pr_files(&self, pr_number: i64) -> Result>; + async fn list_pr_files(&self, ctx: &Ctx, pr_number: i64) -> Result>; /// Post the comment provided in the repository's pull request given. - async fn post_comment(&self, pr_number: i64, body: &str) -> Result; + async fn post_comment(&self, ctx: &Ctx, pr_number: i64, body: &str) -> Result; } /// Type alias to represent a GH trait object. @@ -41,51 +43,45 @@ type FileName = String; /// GH implementation backed by the GitHub API. pub(crate) struct GHApi { - client: Client, - org: String, - repo: String, + app_credentials: JWTCredentials, } impl GHApi { /// Create a new GHApi instance. - pub(crate) fn new(cfg: Arc) -> Result { + pub(crate) fn new(gh_app: &GitHubApp) -> Result { // Setup GitHub app credentials - let app_id = cfg.get_int("server.githubApp.appId").unwrap(); - let app_private_key = - pem::parse(cfg.get_string("server.githubApp.privateKey").unwrap())?.contents().to_owned(); - let credentials = - JWTCredentials::new(app_id, app_private_key).context("error setting up credentials")?; - - // Setup GitHub API client - let inst_id = cfg.get_int("server.githubApp.installationId").unwrap(); - let tg = InstallationTokenGenerator::new(inst_id, credentials); - let client = Client::new( - format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")), - Credentials::InstallationToken(tg), - )?; - - Ok(Self { - client, - org: cfg.get_string("server.config.organization").unwrap(), - repo: cfg.get_string("server.config.repository").unwrap(), - }) + let private_key = pem::parse(&gh_app.private_key)?.contents().to_owned(); + let app_credentials = + JWTCredentials::new(gh_app.app_id, private_key).context("error setting up credentials")?; + + Ok(Self { app_credentials }) + } + + /// Setup GitHub API client for the installation id provided. + fn setup_client(&self, inst_id: i64) -> Result { + let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + let tg = InstallationTokenGenerator::new(inst_id, self.app_credentials.clone()); + let credentials = Credentials::InstallationToken(tg); + + Ok(Client::new(user_agent, credentials)?) } } #[async_trait] impl GH for GHApi { /// [GH::create_check_run] - async fn create_check_run(&self, body: &ChecksCreateRequest) -> Result<()> { - _ = self.client.checks().create(&self.org, &self.repo, body).await?; + async fn create_check_run(&self, ctx: &Ctx, body: &ChecksCreateRequest) -> Result<()> { + let client = self.setup_client(ctx.inst_id)?; + _ = client.checks().create(&ctx.owner, &ctx.repo, body).await?; Ok(()) } /// [GH::list_pr_files] - async fn list_pr_files(&self, pr_number: i64) -> Result> { - let files = self - .client + async fn list_pr_files(&self, ctx: &Ctx, pr_number: i64) -> Result> { + let client = self.setup_client(ctx.inst_id)?; + let files = client .pulls() - .list_all_files(&self.org, &self.repo, pr_number) + .list_all_files(&ctx.owner, &ctx.repo, pr_number) .await? .iter() .map(|e| e.filename.clone()) @@ -94,11 +90,12 @@ impl GH for GHApi { } /// [GH::post_comment] - async fn post_comment(&self, pr_number: i64, body: &str) -> Result { + async fn post_comment(&self, ctx: &Ctx, pr_number: i64, body: &str) -> Result { let body = &PullsUpdateReviewRequest { body: body.to_string(), }; - let comment = self.client.issues().create_comment(&self.org, &self.repo, pr_number, body).await?; + let client = self.setup_client(ctx.inst_id)?; + let comment = client.issues().create_comment(&ctx.owner, &ctx.repo, pr_number, body).await?; Ok(comment.id) } } @@ -180,18 +177,35 @@ pub(crate) fn new_checks_create_request( actions: vec![], completed_at: None, conclusion, - details_url: "".to_string(), - external_id: "".to_string(), + details_url: String::new(), + external_id: String::new(), head_sha, name: CHECK_RUN_NAME.to_string(), output: Some(ChecksCreateRequestOutput { annotations: vec![], images: vec![], summary: msg.to_string(), - text: "".to_string(), + text: String::new(), title: msg.to_string(), }), started_at: None, status, } } + +/// Information about the target of a GitHub API request. +pub struct Ctx { + pub inst_id: i64, + pub owner: String, + pub repo: String, +} + +impl From<&Organization> for Ctx { + fn from(org: &Organization) -> Self { + Ctx { + inst_id: org.installation_id, + owner: org.name.clone(), + repo: org.repository.clone(), + } + } +} diff --git a/clowarden-server/src/handlers.rs b/clowarden-server/src/handlers.rs index 7d401e2..1d1d8c5 100644 --- a/clowarden-server/src/handlers.rs +++ b/clowarden-server/src/handlers.rs @@ -1,7 +1,10 @@ +//! This module defines the handlers used to process HTTP requests to the +//! supported endpoints. + use crate::{ db::{DynDB, SearchChangesInput}, - github::{self, DynGH, Event, EventError, PullRequestEvent, PullRequestEventAction}, - jobs::Job, + github::{self, Ctx, DynGH, Event, EventError, PullRequestEvent, PullRequestEventAction}, + jobs::{Job, ReconcileInput, ValidateInput}, }; use anyhow::{format_err, Error, Result}; use axum::{ @@ -15,6 +18,7 @@ use axum::{ routing::{get, get_service, post}, Router, }; +use clowarden_core::cfg::Organization; use config::Config; use hmac::{Hmac, Mac}; use mime::APPLICATION_JSON; @@ -49,16 +53,16 @@ const PAGINATION_TOTAL_COUNT: &str = "pagination-total-count"; /// Router's state. #[derive(Clone, FromRef)] struct RouterState { - cfg: Arc, db: DynDB, gh: DynGH, webhook_secret: String, jobs_tx: mpsc::UnboundedSender, + orgs: Vec, } /// Setup HTTP server router. pub(crate) fn setup_router( - cfg: Arc, + cfg: &Arc, db: DynDB, gh: DynGH, jobs_tx: mpsc::UnboundedSender, @@ -94,6 +98,7 @@ pub(crate) fn setup_router( } // Setup main router + let orgs = cfg.get("organizations")?; let router = Router::new() .route("/webhook/github", post(event)) .route("/health-check", get(health_check)) @@ -111,17 +116,18 @@ pub(crate) fn setup_router( .fallback_service(get_service(ServeFile::new(&root_index_path))) .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http())) .with_state(RouterState { - cfg, db, gh, webhook_secret, jobs_tx, + orgs, }); Ok(router) } /// Handler that takes care of health check requests. +#[allow(clippy::unused_async)] async fn health_check() -> impl IntoResponse { "" } @@ -130,10 +136,10 @@ async fn health_check() -> impl IntoResponse { #[allow(clippy::let_with_type_underscore)] #[instrument(skip_all, err(Debug))] async fn event( - State(cfg): State>, State(gh): State, State(webhook_secret): State, State(jobs_tx): State>, + State(orgs): State>, headers: HeaderMap, body: Bytes, ) -> impl IntoResponse { @@ -166,6 +172,14 @@ async fn event( // Take action on event when needed match event { Event::PullRequest(event) => { + // Check event comes from a registered organization + let Some(gh_org) = &event.organization else { + return Ok(()); + }; + let Some(org) = orgs.iter().find(|o| o.name == gh_org.login).cloned() else { + return Ok(()); + }; + // Check if we are interested on the event's action if ![ PullRequestEventAction::Closed, @@ -178,7 +192,7 @@ async fn event( } // Check if the PR updates the configuration files - match pr_updates_config(cfg.clone(), gh.clone(), &event).await { + match pr_updates_config(gh.clone(), &org, &event).await { Ok(true) => { // It does, go ahead processing event } @@ -196,23 +210,24 @@ async fn event( match event.action { PullRequestEventAction::Opened | PullRequestEventAction::Synchronize => { // Create validation in-progress check run + let ctx = Ctx::from(&org); let check_body = github::new_checks_create_request( event.pull_request.head.sha.clone(), Some(JobStatus::InProgress), None, "Validating configuration changes", ); - if let Err(err) = gh.create_check_run(&check_body).await { + if let Err(err) = gh.create_check_run(&ctx, &check_body).await { error!(?err, "error creating validation in-progress check run"); } // Enqueue validation job - let input = event.pull_request.into(); + let input = ValidateInput::new(org, event.pull_request); _ = jobs_tx.send(Job::Validate(input)); } PullRequestEventAction::Closed if event.pull_request.merged => { // Enqueue reconcile job - let input = event.pull_request.into(); + let input = ReconcileInput::new(org, event.pull_request); _ = jobs_tx.send(Job::Reconcile(input)); } _ => {} @@ -255,29 +270,27 @@ fn verify_signature(signature: Option<&HeaderValue>, secret: &[u8], body: &[u8]) } /// Check if the pull request in the event provided updates any of the -/// configuration files. -async fn pr_updates_config(cfg: Arc, gh: DynGH, event: &PullRequestEvent) -> Result { +/// organization configuration files. +async fn pr_updates_config(gh: DynGH, org: &Organization, event: &PullRequestEvent) -> Result { // Check if repository in PR matches with config - let cfg_repo = &cfg.get_string("server.config.repository").unwrap(); - if cfg_repo != &event.repository.name { + if org.repository != event.repository.name { return Ok(false); } // Check if base branch in PR matches with config - let cfg_branch = &cfg.get_string("server.config.branch").unwrap(); - if cfg_branch != &event.pull_request.base.ref_ { + if org.branch != event.pull_request.base.ref_ { return Ok(false); } // Check if any of the configuration files is on the pr - if let Ok(true) = cfg.get_bool("server.config.legacy.enabled") { - let mut legacy_cfg_files = - vec![cfg.get_string("server.config.legacy.sheriff.permissionsPath").unwrap()]; - if let Ok(people_path) = cfg.get_string("server.config.legacy.cncf.peoplePath") { - legacy_cfg_files.push(people_path); + if org.legacy.enabled { + let mut legacy_cfg_files = vec![&org.legacy.sheriff_permissions_path]; + if let Some(cncf_people_path) = &org.legacy.cncf_people_path { + legacy_cfg_files.push(cncf_people_path); }; - for filename in gh.list_pr_files(event.pull_request.number).await? { - if legacy_cfg_files.contains(&filename) { + let ctx = Ctx::from(org); + for filename in gh.list_pr_files(&ctx, event.pull_request.number).await? { + if legacy_cfg_files.contains(&&filename) { return Ok(true); } } @@ -287,6 +300,7 @@ async fn pr_updates_config(cfg: Arc, gh: DynGH, event: &PullRequestEvent } /// Helper for mapping any error into a `500 Internal Server Error` response. +#[allow(clippy::needless_pass_by_value)] fn internal_error(err: E) -> StatusCode where E: Into + Display, diff --git a/clowarden-server/src/jobs.rs b/clowarden-server/src/jobs.rs index c2e886a..cdc00fb 100644 --- a/clowarden-server/src/jobs.rs +++ b/clowarden-server/src/jobs.rs @@ -1,6 +1,10 @@ +//! This module defines the types and functionality needed to schedule and +//! process jobs. + +use self::core::github::Source; use crate::{ db::DynDB, - github::{self, DynGH}, + github::{self, Ctx, DynGH}, tmpl, }; use ::time::OffsetDateTime; @@ -8,14 +12,14 @@ use anyhow::{Error, Result}; use askama::Template; use clowarden_core::{ self as core, + cfg::Organization, directory::Directory, multierror::MultiError, services::{BaseRefConfigStatus, ChangesApplied, ChangesSummary, DynServiceHandler, ServiceName}, }; -use config::Config; use octorust::types::{ChecksCreateRequestConclusion, JobStatus, PullRequestData}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; use tokio::{ sync::{broadcast, mpsc}, task::JoinHandle, @@ -46,24 +50,28 @@ pub(crate) enum Job { /// Information required to process a reconcile job. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub(crate) struct ReconcileInput { + pub org: Organization, pub pr_number: Option, pub pr_created_by: Option, pub pr_merged_by: Option, pub pr_merged_at: Option, } -impl From for ReconcileInput { - fn from(pr: PullRequestData) -> Self { +impl ReconcileInput { + /// Create a new ReconcileInput instance. + pub(crate) fn new(org: Organization, pr: PullRequestData) -> Self { let mut input = ReconcileInput { + org, pr_number: Some(pr.number), pr_created_by: pr.user.map(|u| u.login), pr_merged_by: pr.merged_by.map(|u| u.login), - ..Default::default() + pr_merged_at: None, }; if let Some(pr_merged_at) = pr.merged_at { - match OffsetDateTime::from_unix_timestamp(pr_merged_at.timestamp()) { - Ok(pr_merged_at) => input.pr_merged_at = Some(pr_merged_at), - Err(_) => error!(pr.number, ?pr_merged_at, "invalid merged_at value"), + if let Ok(pr_merged_at) = OffsetDateTime::from_unix_timestamp(pr_merged_at.timestamp()) { + input.pr_merged_at = Some(pr_merged_at); + } else { + error!(pr.number, ?pr_merged_at, "invalid merged_at value"); } } input @@ -73,6 +81,7 @@ impl From for ReconcileInput { /// Information required to process a validate job. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub(crate) struct ValidateInput { + pub org: Organization, pub pr_number: i64, pub pr_head_owner: Option, pub pr_head_repo: Option, @@ -80,9 +89,11 @@ pub(crate) struct ValidateInput { pub pr_head_sha: String, } -impl From for ValidateInput { - fn from(pr: PullRequestData) -> Self { +impl ValidateInput { + /// Create a new ValidateInput instance. + pub(crate) fn new(org: Organization, pr: PullRequestData) -> Self { ValidateInput { + org, pr_number: pr.number, pr_head_owner: pr.head.repo.as_ref().map(|r| r.owner.clone().login), pr_head_repo: pr.head.repo.map(|r| r.name), @@ -94,7 +105,6 @@ impl From for ValidateInput { /// A jobs handler is in charge of executing the received jobs. pub(crate) struct Handler { - cfg: Arc, db: DynDB, gh: DynGH, ghc: core::github::DynGH, @@ -104,14 +114,12 @@ pub(crate) struct Handler { impl Handler { /// Create a new handler instance. pub(crate) fn new( - cfg: Arc, db: DynDB, gh: DynGH, ghc: core::github::DynGH, services: HashMap, ) -> Self { Self { - cfg, db, gh, ghc, @@ -157,7 +165,7 @@ impl Handler { // Reconcile services state for (service_name, service_handler) in &self.services { debug!(service_name, "reconciling state"); - match service_handler.reconcile().await { + match service_handler.reconcile(&input.org).await { Ok(service_changes_applied) => { changes_applied.insert(service_name, service_changes_applied); } @@ -174,8 +182,9 @@ impl Handler { // Post reconciliation completed comment if the job was created from a PR if let Some(pr_number) = input.pr_number { + let ctx = Ctx::from(&input.org); let comment_body = tmpl::ReconciliationCompleted::new(&changes_applied, &errors).render()?; - if let Err(err) = self.gh.post_comment(pr_number, &comment_body).await { + if let Err(err) = self.gh.post_comment(&ctx, pr_number, &comment_body).await { error!(?err, "error posting reconciliation comment"); } } @@ -206,42 +215,36 @@ impl Handler { } /// Validate job handler. - #[instrument(fields(pr_number = input.pr_number), skip_all, err(Debug))] + #[instrument(fields(org = input.org.name, pr_number = input.pr_number), skip_all, err(Debug))] async fn handle_validate_job(&self, input: ValidateInput) -> Result<()> { let mut merr = MultiError::new(None); + // Prepare head configuration source + let head_src = Source { + inst_id: Some(input.org.installation_id), + owner: input.pr_head_owner.unwrap_or(input.org.name.clone()), + repo: input.pr_head_repo.unwrap_or(input.org.repository.clone()), + ref_: input.pr_head_ref, + }; + // Directory configuration validation - let directory_changes = match Directory::get_changes_summary( - self.cfg.clone(), - self.ghc.clone(), - input.pr_head_owner.as_deref(), - input.pr_head_repo.as_deref(), - &input.pr_head_ref, - ) - .await - { - Ok(changes) => changes, - Err(err) => { - merr.push(err); - ChangesSummary { - changes: vec![], - base_ref_config_status: BaseRefConfigStatus::Unknown, + let directory_changes = + match Directory::get_changes_summary(self.ghc.clone(), &input.org, &head_src).await { + Ok(changes) => changes, + Err(err) => { + merr.push(err); + ChangesSummary { + changes: vec![], + base_ref_config_status: BaseRefConfigStatus::Unknown, + } } - } - }; + }; // Services configuration validation let mut services_changes: HashMap = HashMap::new(); if !merr.contains_errors() { for (service_name, service_handler) in &self.services { - match service_handler - .get_changes_summary( - input.pr_head_owner.as_deref(), - input.pr_head_repo.as_deref(), - &input.pr_head_ref, - ) - .await - { + match service_handler.get_changes_summary(&input.org, &head_src).await { Ok(changes) => { services_changes.insert(service_name, changes); } @@ -253,31 +256,29 @@ impl Handler { // Post validation completed comment and create check run let errors_found = merr.contains_errors(); let err = Error::from(merr); - let (comment_body, check_body) = match errors_found { - true => { - let comment_body = tmpl::ValidationFailed::new(&err).render()?; - let check_body = github::new_checks_create_request( - input.pr_head_sha, - Some(JobStatus::Completed), - Some(ChecksCreateRequestConclusion::Failure), - "The configuration changes proposed are not valid", - ); - (comment_body, check_body) - } - false => { - let comment_body = - tmpl::ValidationSucceeded::new(&directory_changes, &services_changes).render()?; - let check_body = github::new_checks_create_request( - input.pr_head_sha, - Some(JobStatus::Completed), - Some(ChecksCreateRequestConclusion::Success), - "The configuration changes proposed are valid", - ); - (comment_body, check_body) - } + let ctx = Ctx::from(&input.org); + let (comment_body, check_body) = if errors_found { + let comment_body = tmpl::ValidationFailed::new(&err).render()?; + let check_body = github::new_checks_create_request( + input.pr_head_sha, + Some(JobStatus::Completed), + Some(ChecksCreateRequestConclusion::Failure), + "The configuration changes proposed are not valid", + ); + (comment_body, check_body) + } else { + let comment_body = + tmpl::ValidationSucceeded::new(&directory_changes, &services_changes).render()?; + let check_body = github::new_checks_create_request( + input.pr_head_sha, + Some(JobStatus::Completed), + Some(ChecksCreateRequestConclusion::Success), + "The configuration changes proposed are valid", + ); + (comment_body, check_body) }; - self.gh.post_comment(input.pr_number, &comment_body).await?; - self.gh.create_check_run(&check_body).await?; + self.gh.post_comment(&ctx, input.pr_number, &comment_body).await?; + self.gh.create_check_run(&ctx, &check_body).await?; if errors_found { return Err(err); @@ -291,40 +292,35 @@ const RECONCILE_FREQUENCY: u64 = 60 * 60; /// A jobs scheduler is in charge of scheduling the execution of some jobs /// periodically. -pub(crate) struct Scheduler; +pub(crate) fn scheduler( + jobs_tx: mpsc::UnboundedSender, + mut stop_rx: broadcast::Receiver<()>, + orgs: Vec, +) -> JoinHandle<()> { + tokio::spawn(async move { + let reconcile_frequency = time::Duration::from_secs(RECONCILE_FREQUENCY); + let mut reconcile = time::interval(reconcile_frequency); + reconcile.set_missed_tick_behavior(MissedTickBehavior::Skip); -impl Scheduler { - /// Create a new scheduler instance. - pub(crate) fn new() -> Self { - Self {} - } - - /// Spawn a new task to schedule jobs periodically. - pub(crate) fn start( - &self, - jobs_tx: mpsc::UnboundedSender, - mut stop_rx: broadcast::Receiver<()>, - ) -> JoinHandle<()> { - tokio::spawn(async move { - let reconcile_frequency = time::Duration::from_secs(RECONCILE_FREQUENCY); - let mut reconcile = time::interval(reconcile_frequency); - reconcile.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; - loop { - tokio::select! { - biased; + // Exit if the scheduler has been asked to stop + _ = stop_rx.recv() => { + break + } - // Exit if the scheduler has been asked to stop - _ = stop_rx.recv() => { - break + // Schedule reconcile job for each of the registered organizations + _ = reconcile.tick() => { + for org in &orgs { + _ = jobs_tx.send(Job::Reconcile(ReconcileInput{ + org: org.clone(), + ..Default::default() + })); } - - // Schedule reconcile job - _ = reconcile.tick() => { - _ = jobs_tx.send(Job::Reconcile(ReconcileInput::default())); - }, - } + }, } - }) - } + } + }) } diff --git a/clowarden-server/src/main.rs b/clowarden-server/src/main.rs index 6495ee0..54a8d4a 100644 --- a/clowarden-server/src/main.rs +++ b/clowarden-server/src/main.rs @@ -1,3 +1,6 @@ +#![warn(clippy::all, clippy::pedantic)] +#![allow(clippy::doc_markdown, clippy::similar_names)] + use crate::db::PgDB; use anyhow::{Context, Result}; use clap::Parser; @@ -48,7 +51,7 @@ async fn main() -> Result<()> { // Setup logging if std::env::var_os("RUST_LOG").is_none() { - std::env::set_var("RUST_LOG", "clowarden=debug") + std::env::set_var("RUST_LOG", "clowarden=debug"); } let s = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()); match cfg.get_string("log.format").as_deref() { @@ -65,33 +68,33 @@ async fn main() -> Result<()> { let db = Arc::new(PgDB::new(pool)); // Setup GitHub clients - let gh = Arc::new(github::GHApi::new(cfg.clone()).context("error setting up github client")?); + let gh_app: core::cfg::GitHubApp = cfg.get("server.githubApp")?; + let gh = Arc::new(github::GHApi::new(&gh_app).context("error setting up github client")?); let ghc = Arc::new( - core::github::GHApi::new_from_config(cfg.clone()).context("error setting up core github client")?, + core::github::GHApi::new_with_app_creds(&gh_app).context("error setting up core github client")?, ); // Setup services handlers let mut services: HashMap = HashMap::new(); - if cfg.get_bool("server.config.services.github.enabled").unwrap_or_default() { - let svc = Arc::new(services::github::service::SvcApi::new_from_config(cfg.clone())?); + if cfg.get_bool("services.github.enabled").unwrap_or_default() { + let svc = Arc::new(services::github::service::SvcApi::new_with_app_creds(&gh_app)?); services.insert( services::github::SERVICE_NAME, - Box::new(services::github::Handler::new(cfg.clone(), ghc.clone(), svc)), + Box::new(services::github::Handler::new(ghc.clone(), svc)), ); } // Setup and launch jobs workers let (stop_tx, _): (broadcast::Sender<()>, _) = broadcast::channel(1); let (jobs_tx, jobs_rx) = mpsc::unbounded_channel(); - let jobs_handler = jobs::Handler::new(cfg.clone(), db.clone(), gh.clone(), ghc.clone(), services); - let jobs_scheduler = jobs::Scheduler::new(); + let jobs_handler = jobs::Handler::new(db.clone(), gh.clone(), ghc.clone(), services); let jobs_workers_done = future::join_all([ jobs_handler.start(jobs_rx, stop_tx.subscribe()), - jobs_scheduler.start(jobs_tx.clone(), stop_tx.subscribe()), + jobs::scheduler(jobs_tx.clone(), stop_tx.subscribe(), cfg.get("organizations")?), ]); // Setup and launch HTTP server - let router = handlers::setup_router(cfg.clone(), db.clone(), gh.clone(), jobs_tx) + let router = handlers::setup_router(&cfg, db.clone(), gh.clone(), jobs_tx) .context("error setting up http server router")?; let addr: SocketAddr = cfg.get_string("server.addr").unwrap().parse()?; info!("server started"); @@ -118,18 +121,8 @@ fn validate_config(cfg: &Config) -> Result<()> { // Required fields cfg.get_string("server.addr")?; cfg.get_string("server.staticPath")?; - cfg.get_int("server.githubApp.appId")?; - cfg.get_int("server.githubApp.installationId")?; - cfg.get_string("server.githubApp.privateKey")?; - cfg.get_string("server.githubApp.webhookSecret")?; - cfg.get_string("server.config.organization")?; - cfg.get_string("server.config.repository")?; - cfg.get_string("server.config.branch")?; - - // Required fields when legacy config is used - if let Ok(true) = cfg.get_bool("server.config.legacy.enabled") { - cfg.get_string("server.config.legacy.sheriff.permissionsPath")?; - } + let _: core::cfg::GitHubApp = cfg.get("server.githubApp")?; + let _: Vec = cfg.get("organizations")?; Ok(()) } diff --git a/clowarden-server/src/tmpl.rs b/clowarden-server/src/tmpl.rs index 8f1bf8f..6869dfa 100644 --- a/clowarden-server/src/tmpl.rs +++ b/clowarden-server/src/tmpl.rs @@ -1,3 +1,6 @@ +//! This module defines the templates used to render the comments that +//! CLOWarden will post to GitHub. + use anyhow::Error; use askama::Template; use clowarden_core::services::{ChangesApplied, ChangesSummary, ServiceName}; @@ -19,7 +22,7 @@ impl<'a> ReconciliationCompleted<'a> { changes_applied: &'a HashMap, errors: &'a HashMap, ) -> Self { - let services = changes_applied.keys().chain(errors.keys()).map(|s| s.to_owned()).collect(); + let services = changes_applied.keys().chain(errors.keys()).copied().collect(); let some_changes_applied = (|| { for service_changes_applied in changes_applied.values() { if !service_changes_applied.is_empty() {