feat: Webhook query paths include forge alias

This allows for more than one forge to be configured and for the webhook
to correctly route incoming messages.
This commit is contained in:
Paul Campbell 2024-05-29 19:22:05 +01:00
parent 17148e74b6
commit 206e64cd5b
17 changed files with 119 additions and 80 deletions

View file

@ -1,5 +1,5 @@
use crate::{
ApiToken, BranchName, ForgeDetails, ForgeName, ForgeType, Hostname, RepoAlias, RepoBranches,
ApiToken, BranchName, ForgeAlias, ForgeDetails, ForgeType, Hostname, RepoAlias, RepoBranches,
RepoConfig, RepoConfigSource, RepoPath, User,
};
@ -25,8 +25,8 @@ pub fn hostname(n: u32) -> Hostname {
Hostname::new(format!("hostname-{}", n))
}
pub fn forge_name(n: u32) -> ForgeName {
ForgeName::new(format!("forge-name-{}", n))
pub fn forge_name(n: u32) -> ForgeAlias {
ForgeAlias::new(format!("forge-name-{}", n))
}
pub fn branch_name(n: u32) -> BranchName {

View file

@ -1,9 +1,9 @@
use crate::{ApiToken, ForgeConfig, ForgeName, ForgeType, Hostname, User};
use crate::{ApiToken, ForgeAlias, ForgeConfig, ForgeType, Hostname, User};
/// The derived information about a Forge, used to create interactions with it
#[derive(Clone, Default, Debug, derive_more::Constructor, derive_with::With)]
pub struct ForgeDetails {
forge_name: ForgeName,
forge_alias: ForgeAlias,
forge_type: ForgeType,
hostname: Hostname,
user: User,
@ -12,8 +12,8 @@ pub struct ForgeDetails {
// Private SSH Key Path
}
impl ForgeDetails {
pub const fn forge_name(&self) -> &ForgeName {
&self.forge_name
pub const fn forge_alias(&self) -> &ForgeAlias {
&self.forge_alias
}
pub const fn forge_type(&self) -> ForgeType {
self.forge_type
@ -28,10 +28,10 @@ impl ForgeDetails {
&self.token
}
}
impl From<(&ForgeName, &ForgeConfig)> for ForgeDetails {
fn from(forge: (&ForgeName, &ForgeConfig)) -> Self {
impl From<(&ForgeAlias, &ForgeConfig)> for ForgeDetails {
fn from(forge: (&ForgeAlias, &ForgeConfig)) -> Self {
Self {
forge_name: forge.0.clone(),
forge_alias: forge.0.clone(),
forge_type: forge.1.forge_type(),
hostname: forge.1.hostname(),
user: forge.1.user(),

View file

@ -1,10 +1,12 @@
use std::path::PathBuf;
/// The name of a Forge to connect to
#[derive(Clone, Default, Debug, PartialEq, Eq, derive_more::Constructor, derive_more::Display)]
pub struct ForgeName(String);
impl From<&ForgeName> for PathBuf {
fn from(value: &ForgeName) -> Self {
#[derive(
Clone, Default, Debug, Hash, PartialEq, Eq, derive_more::Constructor, derive_more::Display,
)]
pub struct ForgeAlias(String);
impl From<&ForgeAlias> for PathBuf {
fn from(value: &ForgeAlias) -> Self {
Self::from(&value.0)
}
}

View file

@ -24,7 +24,7 @@ pub use api_token::ApiToken;
pub use branch_name::BranchName;
pub use forge_config::ForgeConfig;
pub use forge_details::ForgeDetails;
pub use forge_name::ForgeName;
pub use forge_name::ForgeAlias;
pub use forge_type::ForgeType;
pub use git_dir::GitDir;
pub use host_name::Hostname;

View file

@ -12,7 +12,7 @@ use std::{
use kxio::fs::FileSystem;
use tracing::info;
use crate::{ForgeConfig, ForgeName};
use crate::{ForgeAlias, ForgeConfig, RepoAlias};
#[derive(Debug, derive_more::From, derive_more::Display)]
pub enum Error {
@ -43,10 +43,10 @@ impl ServerConfig {
toml::from_str(&str).map_err(Into::into)
}
pub fn forges(&self) -> impl Iterator<Item = (ForgeName, &ForgeConfig)> {
pub fn forges(&self) -> impl Iterator<Item = (ForgeAlias, &ForgeConfig)> {
self.forge
.iter()
.map(|(name, forge)| (ForgeName::new(name.clone()), forge))
.map(|(alias, forge)| (ForgeAlias::new(alias.clone()), forge))
}
pub const fn storage(&self) -> &ServerStorage {
@ -84,8 +84,9 @@ pub struct Webhook {
url: String,
}
impl Webhook {
pub fn url(&self) -> WebhookUrl {
WebhookUrl(self.url.clone())
pub fn url(&self, forge_alias: &ForgeAlias, repo_alias: &RepoAlias) -> WebhookUrl {
let base_url = &self.url;
WebhookUrl(format!("{base_url}/{forge_alias}/{repo_alias}"))
}
}

View file

@ -252,7 +252,7 @@ mod forge_details {
use secrecy::ExposeSecret;
use crate::{ApiToken, ForgeConfig, ForgeDetails, ForgeName, ForgeType, Hostname, User};
use crate::{ApiToken, ForgeAlias, ForgeConfig, ForgeDetails, ForgeType, Hostname, User};
#[test]
fn should_return_forge_name() {
@ -260,11 +260,11 @@ mod forge_details {
let hostname = Hostname::new("localhost".to_string());
let user = User::new("bob".to_string());
let token = ApiToken::new("alpha".to_string().into());
let forge_name = ForgeName::new("gamma".to_string());
let forge_name = ForgeAlias::new("gamma".to_string());
let forge_details =
ForgeDetails::new(forge_name.clone(), forge_type, hostname, user, token);
let result = forge_details.forge_name();
let result = forge_details.forge_alias();
assert_eq!(result, &forge_name);
}
@ -274,7 +274,7 @@ mod forge_details {
let hostname = Hostname::new("localhost".to_string());
let user = User::new("bob".to_string());
let token = ApiToken::new("alpha".to_string().into());
let forge_name = ForgeName::new("gamma".to_string());
let forge_name = ForgeAlias::new("gamma".to_string());
let forge_details = ForgeDetails::new(forge_name, forge_type, hostname, user, token);
let result = forge_details.forge_type();
@ -287,7 +287,7 @@ mod forge_details {
let hostname = Hostname::new("localhost".to_string());
let user = User::new("bob".to_string());
let token = ApiToken::new("alpha".to_string().into());
let forge_name = ForgeName::new("gamma".to_string());
let forge_name = ForgeAlias::new("gamma".to_string());
let forge_details =
ForgeDetails::new(forge_name, forge_type, hostname.clone(), user, token);
@ -301,7 +301,7 @@ mod forge_details {
let hostname = Hostname::new("localhost".to_string());
let user = User::new("bob".to_string());
let token = ApiToken::new("alpha".to_string().into());
let forge_name = ForgeName::new("gamma".to_string());
let forge_name = ForgeAlias::new("gamma".to_string());
let forge_details =
ForgeDetails::new(forge_name, forge_type, hostname, user.clone(), token);
@ -315,7 +315,7 @@ mod forge_details {
let hostname = Hostname::new("localhost".to_string());
let user = User::new("bob".to_string());
let token = ApiToken::new("alpha".to_string().into());
let forge_name = ForgeName::new("gamma".to_string());
let forge_name = ForgeAlias::new("gamma".to_string());
let forge_details =
ForgeDetails::new(forge_name, forge_type, hostname, user, token.clone());
@ -329,7 +329,7 @@ mod forge_details {
let hostname = Hostname::new("localhost".to_string());
let user = User::new("bob".to_string());
let token = ApiToken::new("alpha".to_string().into());
let forge_name = ForgeName::new("gamma".to_string());
let forge_name = ForgeAlias::new("gamma".to_string());
let forge_details = ForgeDetails::new(forge_name, forge_type, hostname, user, token);
let result = forge_details.with_hostname(Hostname::new("remotehost".to_string()));
@ -342,7 +342,7 @@ mod forge_details {
let hostname = Hostname::new("localhost".to_string());
let user = User::new("bob".to_string());
let token = ApiToken::new("alpha".to_string().into());
let forge_name = ForgeName::new("gamma".to_string());
let forge_alias = ForgeAlias::new("gamma".to_string());
let forge_config = ForgeConfig::new(
forge_type,
"localhost".to_string(),
@ -351,9 +351,9 @@ mod forge_details {
BTreeMap::new(),
);
let forge_details = ForgeDetails::from((&forge_name, &forge_config));
let forge_details = ForgeDetails::from((&forge_alias, &forge_config));
assert_eq!(forge_details.forge_name(), &forge_name);
assert_eq!(forge_details.forge_alias(), &forge_alias);
assert_eq!(forge_details.hostname(), &hostname);
assert_eq!(forge_details.user(), &user);
assert_eq!(forge_details.token().expose_secret(), token.expose_secret());
@ -362,13 +362,13 @@ mod forge_details {
mod forge_name {
use std::path::PathBuf;
use crate::ForgeName;
use crate::ForgeAlias;
#[test]
fn should_convert_to_pathbuf() {
let forge_name = ForgeName::new("alpha".to_string());
let forge_alias = ForgeAlias::new("alpha".to_string());
let pathbuf: PathBuf = (&forge_name).into();
let pathbuf: PathBuf = (&forge_alias).into();
assert_eq!(pathbuf, PathBuf::new().join("alpha"));
}

View file

@ -15,7 +15,7 @@ impl ForgeJo {
}
#[async_trait::async_trait]
impl git::ForgeLike for ForgeJo {
fn name(&self) -> String {
fn forge_alias(&self) -> String {
"forgejo".to_string()
}

View file

@ -13,7 +13,7 @@ impl MockForgeEnv {
}
#[async_trait::async_trait]
impl git::ForgeLike for MockForgeEnv {
fn name(&self) -> String {
fn forge_alias(&self) -> String {
"mock".to_string()
}

View file

@ -10,7 +10,7 @@ mod github;
#[test]
fn test_mock_name() {
let forge = Forge::new_mock();
assert_eq!(forge.name(), "mock");
assert_eq!(forge.forge_alias(), "mock");
}
#[test]
@ -30,5 +30,5 @@ fn test_forgejo_name() {
config::GitDir::new(fs.base()),
);
let forge = Forge::new_forgejo(repo_details, net);
assert_eq!(forge.name(), "forgejo");
assert_eq!(forge.forge_alias(), "forgejo");
}

View file

@ -2,7 +2,7 @@ use crate as git;
#[async_trait::async_trait]
pub trait ForgeLike {
fn name(&self) -> String;
fn forge_alias(&self) -> String;
/// Checks the results of any (e.g. CI) status checks for the commit.
async fn commit_status(&self, commit: &git::Commit) -> git::commit::Status;

View file

@ -1,5 +1,5 @@
use git_next_config::{
BranchName, ForgeConfig, ForgeDetails, ForgeName, GitDir, RepoAlias, RepoConfig, RepoPath,
BranchName, ForgeAlias, ForgeConfig, ForgeDetails, GitDir, RepoAlias, RepoConfig, RepoPath,
ServerRepoConfig,
};
@ -8,7 +8,7 @@ use super::{Generation, GitRemote};
/// The derived information about a repo, used to interact with it
#[derive(Clone, Default, Debug, derive_more::Display, derive_with::With)]
#[display("gen-{}:{}:{}/{}:{}@{}/{}@{}", generation, forge.forge_type(),
forge.forge_name(), repo_alias, forge.user(), forge.hostname(), repo_path,
forge.forge_alias(), repo_alias, forge.user(), forge.hostname(), repo_path,
branch)]
pub struct RepoDetails {
pub generation: Generation,
@ -24,7 +24,7 @@ impl RepoDetails {
generation: Generation,
repo_alias: &RepoAlias,
server_repo_config: &ServerRepoConfig,
forge_name: &ForgeName,
forge_alias: &ForgeAlias,
forge_config: &ForgeConfig,
gitdir: GitDir,
) -> Self {
@ -36,7 +36,7 @@ impl RepoDetails {
branch: server_repo_config.branch(),
gitdir,
forge: ForgeDetails::new(
forge_name.clone(),
forge_alias.clone(),
forge_config.forge_type(),
forge_config.hostname(),
forge_config.user(),

View file

@ -92,7 +92,7 @@ mod repo_details {
use std::{collections::BTreeMap, path::PathBuf};
use git_next_config::{
ForgeConfig, ForgeName, ForgeType, GitDir, Hostname, RepoAlias, RepoPath, ServerRepoConfig,
ForgeAlias, ForgeConfig, ForgeType, GitDir, Hostname, RepoAlias, RepoPath, ServerRepoConfig,
};
use secrecy::ExposeSecret;
@ -111,7 +111,7 @@ mod repo_details {
None,
None,
),
&ForgeName::new("default".to_string()),
&ForgeAlias::new("default".to_string()),
&ForgeConfig::new(
ForgeType::MockForge,
"host".to_string(),
@ -140,7 +140,7 @@ mod repo_details {
None,
None,
),
&ForgeName::new("default".to_string()),
&ForgeAlias::new("default".to_string()),
&ForgeConfig::new(
ForgeType::MockForge,
"host".to_string(),

View file

@ -20,7 +20,7 @@ use kxio::network::Network;
use tracing::{debug, info, warn, Instrument};
#[derive(Debug, derive_more::Display)]
#[display("{}:{}:{}", generation, repo_details.forge.forge_name(), repo_details.repo_alias)]
#[display("{}:{}:{}", generation, repo_details.forge.forge_alias(), repo_details.repo_alias)]
pub struct RepoActor {
generation: git::Generation,
message_token: MessageToken,

View file

@ -1,7 +1,7 @@
use actix::prelude::*;
use git_next_config::{
server::{Webhook, WebhookUrl},
BranchName, RepoAlias, RepoBranches,
BranchName, ForgeAlias, RepoAlias, RepoBranches,
};
use git_next_git as git;
use kxio::network::{self, json};
@ -75,7 +75,9 @@ pub async fn register(
return;
};
let webhook_url = webhook.url();
let forge_alias = repo_details.forge.forge_alias();
let repo_alias = &repo_details.repo_alias;
let webhook_url = webhook.url(forge_alias, repo_alias);
// remove any lingering webhooks for the same URL
let existing_webhook_ids = find_existing_webhooks(&repo_details, &webhook_url, &net).await;
for webhook_id in existing_webhook_ids {
@ -89,7 +91,6 @@ pub async fn register(
let url = network::NetUrl::new(format!(
"https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}"
));
let repo_alias = &repo_details.repo_alias;
let headers = network::NetRequestHeaders::new().with("Content-Type", "application/json");
let authorisation = WebhookAuth::generate();
let body = json!({
@ -98,7 +99,7 @@ pub async fn register(
"branch_filter": format!("{{{},{},{}}}", repo_config.branches().main(), repo_config.branches().next(), repo_config.branches().dev()),
"config": {
"content_type": "json",
"url": format!("{}/{}", webhook_url.as_ref(), repo_alias),
"url": webhook_url.as_ref(),
},
"events": [ "push" ],
"type": "forgejo"
@ -156,7 +157,7 @@ async fn find_existing_webhooks(
}
for hook in list {
if let Some(existing_url) = hook.config.get("url") {
if existing_url.starts_with(webhook_url.as_ref()) {
if existing_url == webhook_url.as_ref() {
ids.push(hook.id());
}
}
@ -312,12 +313,15 @@ struct HeadCommit {
#[derive(Message, Debug, Clone, derive_more::Constructor)]
#[rtype(result = "()")]
pub struct WebhookMessage {
// forge // TODO: (#58) differentiate between multiple forges
forge_alias: ForgeAlias,
repo_alias: RepoAlias,
authorisation: WebhookAuth,
body: Body,
}
impl WebhookMessage {
pub const fn forge_alias(&self) -> &ForgeAlias {
&self.forge_alias
}
pub const fn repo_alias(&self) -> &RepoAlias {
&self.repo_alias
}

View file

@ -5,7 +5,7 @@ use actix::prelude::*;
use config::server::{ServerConfig, ServerStorage, Webhook};
use git_next_config::{
self as config, ForgeConfig, ForgeName, GitDir, RepoAlias, ServerRepoConfig,
self as config, ForgeAlias, ForgeConfig, GitDir, RepoAlias, ServerRepoConfig,
};
use git_next_git::{Generation, RepoDetails, Repository};
use git_next_repo_actor::{CloneRepo, RepoActor};
@ -99,11 +99,13 @@ impl Handler<ServerConfig> for Server {
let webhook = server_config.webhook();
// Forge Actors
for (forge_name, forge_config) in server_config.forges() {
self.create_forge_repos(forge_config, forge_name.clone(), server_storage, webhook)
for (forge_alias, forge_config) in server_config.forges() {
self.create_forge_repos(forge_config, forge_alias.clone(), server_storage, webhook)
.into_iter()
.map(|a| self.start_actor(a))
.map(|(alias, addr)| AddWebhookRecipient(alias, addr.recipient()))
.map(|(repo_alias, addr)| {
AddWebhookRecipient::new(forge_alias.clone(), repo_alias, addr.recipient())
})
.for_each(|msg| webhook_router.do_send(msg));
}
@ -146,10 +148,10 @@ impl Server {
fn create_forge_repos(
&self,
forge_config: &ForgeConfig,
forge_name: ForgeName,
forge_name: ForgeAlias,
server_storage: &ServerStorage,
webhook: &Webhook,
) -> Vec<(ForgeName, RepoAlias, RepoActor)> {
) -> Vec<(ForgeAlias, RepoAlias, RepoActor)> {
let span =
tracing::info_span!("create_forge_repos", name = %forge_name, config = %forge_config);
@ -170,11 +172,11 @@ impl Server {
fn create_actor(
&self,
forge_name: ForgeName,
forge_name: ForgeAlias,
forge_config: ForgeConfig,
server_storage: &ServerStorage,
webhook: &Webhook,
) -> impl Fn((RepoAlias, &ServerRepoConfig)) -> (ForgeName, RepoAlias, RepoActor) {
) -> impl Fn((RepoAlias, &ServerRepoConfig)) -> (ForgeAlias, RepoAlias, RepoActor) {
let server_storage = server_storage.clone();
let webhook = webhook.clone();
let net = self.net.clone();
@ -219,7 +221,7 @@ impl Server {
fn start_actor(
&self,
actor: (ForgeName, RepoAlias, RepoActor),
actor: (ForgeAlias, RepoAlias, RepoActor),
) -> (RepoAlias, Addr<RepoActor>) {
let (forge_name, repo_alias, actor) = actor;
let span = tracing::info_span!("start_actor", forge = %forge_name, repo = %repo_alias);

View file

@ -2,20 +2,21 @@
use std::collections::HashMap;
use actix::prelude::*;
use git_next_config::RepoAlias;
use derive_more::Constructor;
use git_next_config::{ForgeAlias, RepoAlias};
use git_next_repo_actor::webhook::WebhookMessage;
use tracing::{debug, info};
pub struct WebhookRouter {
span: tracing::Span,
repos: HashMap<RepoAlias, Recipient<WebhookMessage>>,
recipients: HashMap<ForgeAlias, HashMap<RepoAlias, Recipient<WebhookMessage>>>,
}
impl WebhookRouter {
pub fn new() -> Self {
let span = tracing::info_span!("WebhookRouter");
Self {
span,
repos: Default::default(),
recipients: Default::default(),
}
}
}
@ -28,24 +29,39 @@ impl Handler<WebhookMessage> for WebhookRouter {
fn handle(&mut self, msg: WebhookMessage, _ctx: &mut Self::Context) -> Self::Result {
let _gaurd = self.span.enter();
let forge_alias = msg.forge_alias();
let repo_alias = msg.repo_alias();
debug!(repo = %repo_alias, "Router...");
if let Some(recipient) = self.repos.get(repo_alias) {
debug!(forge = %forge_alias, repo = %repo_alias, "Router...");
let Some(forge_repos) = self.recipients.get(forge_alias) else {
return;
};
let Some(recipient) = forge_repos.get(repo_alias) else {
return;
};
info!(repo = %repo_alias, "Sending to Recipient");
recipient.do_send(msg);
}
}
}
#[derive(Message)]
#[derive(Message, Constructor)]
#[rtype(result = "()")]
pub struct AddWebhookRecipient(pub RepoAlias, pub Recipient<WebhookMessage>);
pub struct AddWebhookRecipient {
pub forge_alias: ForgeAlias,
pub repo_alias: RepoAlias,
pub recipient: Recipient<WebhookMessage>,
}
impl Handler<AddWebhookRecipient> for WebhookRouter {
type Result = ();
fn handle(&mut self, msg: AddWebhookRecipient, _ctx: &mut Self::Context) -> Self::Result {
let _gaurd = self.span.enter();
info!(repo = %msg.0, "Register Recipient");
self.repos.insert(msg.0, msg.1);
info!(forge = %msg.forge_alias, repo = %msg.repo_alias, "Register Recipient");
if !self.recipients.contains_key(&msg.forge_alias) {
self.recipients
.insert(msg.forge_alias.clone(), HashMap::new());
}
self.recipients
.get_mut(&msg.forge_alias)
.map(|repos| repos.insert(msg.repo_alias, msg.recipient));
}
}

View file

@ -3,7 +3,7 @@ use std::net::SocketAddr;
use actix::prelude::*;
use git_next_config::RepoAlias;
use git_next_config::{ForgeAlias, RepoAlias};
use git_next_repo_actor::webhook::{self, WebhookAuth, WebhookMessage};
use tracing::{info, warn};
use warp::reject::Rejection;
@ -15,17 +15,19 @@ pub async fn start(socket_addr: SocketAddr, address: actix::prelude::Recipient<W
let route = warp::post()
.map(move || address.clone())
.and(warp::path::param())
// .and(warp::query::raw())
.and(warp::path::param())
.and(warp::header::headers_cloned())
.and(warp::body::bytes())
.and_then(
|recipient: Recipient<WebhookMessage>,
path: String,
forge_alias: String,
repo_alias: String,
// query: String,
headers: warp::http::HeaderMap,
body: bytes::Bytes| async move {
info!("POST received");
let repo_alias = RepoAlias::new(path);
let forge_alias = ForgeAlias::new(forge_alias);
let repo_alias = RepoAlias::new(repo_alias);
let bytes = body.to_vec();
let body = webhook::Body::new(String::from_utf8_lossy(&bytes).to_string());
headers.get("Authorization").map_or_else(
@ -34,10 +36,22 @@ pub async fn start(socket_addr: SocketAddr, address: actix::prelude::Recipient<W
Err(warp::reject())
},
|authorisation_header| {
info!(?repo_alias, ?authorisation_header, "Received webhook",);
info!(
forge = %forge_alias,
repo = %repo_alias,
?authorisation_header,
"Received webhook",
);
// TODO: (#86) Authorization isn't presented consistently, allow each forge
// to parse the authorization from the request
match parse_auth(authorisation_header) {
Ok(authorisation) => {
let message = WebhookMessage::new(repo_alias, authorisation, body);
let message = WebhookMessage::new(
forge_alias,
repo_alias,
authorisation,
body,
);
recipient
.try_send(message)
.map(|_| {