feat: post webhook notifications to user
All checks were successful
ci/woodpecker/push/cron-docker-builder Pipeline was successful
ci/woodpecker/push/push-next Pipeline was successful
ci/woodpecker/push/tag-created Pipeline was successful

Closes kemitix/git-next#91
This commit is contained in:
Paul Campbell 2024-07-21 13:44:44 +01:00
parent a317fc6648
commit 7ca886c4bc
16 changed files with 204 additions and 17 deletions

View file

@ -78,6 +78,8 @@ git-conventional = "0.12"
bytes = "1.6" bytes = "1.6"
ulid = "1.1" ulid = "1.1"
warp = "0.3" warp = "0.3"
time = "0.3"
standardwebhooks = "1.0"
# boilerplate # boilerplate
derive_more = { version = "1.0.0-beta.6", features = [ derive_more = { version = "1.0.0-beta.6", features = [

View file

@ -1 +1,3 @@
crate::newtype!(BranchName: String, derive_more::Display, Default: "The name of a Git branch"); use serde::Serialize;
crate::newtype!(BranchName: String, derive_more::Display, Default, Serialize: "The name of a Git branch");

View file

@ -1,4 +1,6 @@
crate::newtype!(ForgeAlias: String, Hash, PartialOrd, Ord, derive_more::Display, Default: "The name of a Forge to connect to"); use serde::Serialize;
crate::newtype!(ForgeAlias: String, Hash, PartialOrd, Ord, derive_more::Display, Default, Serialize: "The name of a Forge to connect to");
impl From<&ForgeAlias> for std::path::PathBuf { impl From<&ForgeAlias> for std::path::PathBuf {
fn from(value: &ForgeAlias) -> Self { fn from(value: &ForgeAlias) -> Self {
Self::from(&value.0) Self::from(&value.0)

View file

@ -1,7 +1,8 @@
use derive_more::Display; use derive_more::Display;
use serde::Serialize;
use crate::newtype; use crate::newtype;
newtype!(RepoAlias: String, Display, Default, PartialOrd, Ord: r#"The alias of a repo. newtype!(RepoAlias: String, Display, Default, PartialOrd, Ord, Serialize: r#"The alias of a repo.
This is the alias for the repo within `git-next-server.toml`."#); This is the alias for the repo within `git-next-server.toml`."#);

View file

@ -199,7 +199,7 @@ impl Notification {
webhook: None, webhook: None,
} }
} }
pub const fn webhook(webhook: OutboundWebhook) -> Self { pub const fn new_webhook(webhook: OutboundWebhook) -> Self {
Self { Self {
r#type: NotificationType::Webhook, r#type: NotificationType::Webhook,
webhook: Some(webhook), webhook: Some(webhook),
@ -210,6 +210,10 @@ impl Notification {
self.r#type self.r#type
} }
pub const fn webhook(&self) -> Option<&OutboundWebhook> {
self.webhook.as_ref()
}
pub fn webhook_url(&self) -> Option<String> { pub fn webhook_url(&self) -> Option<String> {
self.webhook.clone().map(|x| x.url) self.webhook.clone().map(|x| x.url)
} }

View file

@ -753,7 +753,7 @@ mod given {
ServerStorage::new(a_name().into()) ServerStorage::new(a_name().into())
} }
pub fn a_notification_config() -> Notification { pub fn a_notification_config() -> Notification {
Notification::webhook(an_outbound_webhook()) Notification::new_webhook(an_outbound_webhook())
} }
pub fn some_forge_configs() -> BTreeMap<String, ForgeConfig> { pub fn some_forge_configs() -> BTreeMap<String, ForgeConfig> {
[(a_name(), a_forge_config())].into() [(a_name(), a_forge_config())].into()

View file

@ -25,11 +25,13 @@ async-trait = { workspace = true }
# fs/network # fs/network
kxio = { workspace = true } kxio = { workspace = true }
# # TOML parsing # TOML parsing
# serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
# toml = { workspace = true } # toml = { workspace = true }
# webhooks - user notification
serde = { workspace = true }
# Secrets and Password # Secrets and Password
secrecy = { workspace = true } secrecy = { workspace = true }

View file

@ -2,6 +2,7 @@ use config::newtype;
use derive_more::Display; use derive_more::Display;
// //
use git_next_config as config; use git_next_config as config;
use serde::Serialize;
#[derive( #[derive(
Clone, Clone,
@ -13,6 +14,7 @@ use git_next_config as config;
Ord, Ord,
derive_more::Constructor, derive_more::Constructor,
derive_more::Display, derive_more::Display,
Serialize,
)] )]
#[display("{}", sha)] #[display("{}", sha)]
pub struct Commit { pub struct Commit {
@ -37,8 +39,8 @@ impl From<config::webhook::Push> for Commit {
} }
} }
newtype!(Sha: String, Display, Hash,PartialOrd, Ord: "The unique SHA for a git commit."); newtype!(Sha: String, Display, Hash,PartialOrd, Ord, Serialize: "The unique SHA for a git commit.");
newtype!(Message: String, Hash, PartialOrd, Ord: "The commit message for a git commit."); newtype!(Message: String, Display, Hash, PartialOrd, Ord, Serialize: "The commit message for a git commit.");
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Histories { pub struct Histories {

View file

@ -43,6 +43,7 @@ git-conventional = { workspace = true }
# Webhooks # Webhooks
bytes = { workspace = true } bytes = { workspace = true }
ulid = { workspace = true } ulid = { workspace = true }
time = { workspace = true }
# boilerplate # boilerplate
derive_more = { workspace = true } derive_more = { workspace = true }

View file

@ -2,6 +2,7 @@ mod branch;
pub mod handlers; pub mod handlers;
mod load; mod load;
pub mod messages; pub mod messages;
mod notifications;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View file

@ -0,0 +1,72 @@
use derive_more::Deref as _;
use git_next_git::UserNotification;
use serde_json::json;
use crate::messages::NotifyUser;
impl NotifyUser {
pub fn as_json(self, timestamp: time::OffsetDateTime) -> serde_json::Value {
let timestamp = timestamp.unix_timestamp().to_string();
match self.deref() {
UserNotification::CICheckFailed {
forge_alias,
repo_alias,
commit,
} => json!({
"type": "cicheck.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"commit": {
"sha": commit.sha(),
"message": commit.message()
}
}
}),
UserNotification::RepoConfigLoadFailure {
forge_alias,
repo_alias,
reason,
} => json!({
"type": "config.load.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"reason": reason
}
}),
UserNotification::WebhookRegistration {
forge_alias,
repo_alias,
reason,
} => json!({
"type": "webhook.registration.failed",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"reason": reason
}
}),
UserNotification::DevNotBasedOnMain {
forge_alias,
repo_alias,
dev_branch,
main_branch,
} => json!({
"type": "branch.dev.not-on-main",
"timestamp": timestamp,
"data": {
"forge_alias": forge_alias,
"repo_alias": repo_alias,
"branches": {
"dev": dev_branch,
"main": main_branch
}
}
}),
}
}
}

View file

@ -28,6 +28,14 @@ derive-with = { workspace = true }
# Actors # Actors
actix = { workspace = true } actix = { workspace = true }
# Webhooks
serde = { workspace = true }
serde_json = { workspace = true }
ulid = { workspace = true }
time = { workspace = true }
secrecy = { workspace = true }
standardwebhooks = { workspace = true }
[dev-dependencies] [dev-dependencies]
# Testing # Testing
# assert2 = { workspace = true } # assert2 = { workspace = true }

View file

@ -1,14 +1,81 @@
// //
use actix::prelude::*; use actix::prelude::*;
use git_next_config::server::{Notification, NotificationType};
use git_next_repo_actor::messages::NotifyUser; use git_next_repo_actor::messages::NotifyUser;
use secrecy::ExposeSecret;
use standardwebhooks::Webhook;
use tracing::Instrument;
use crate::ServerActor; use crate::ServerActor;
impl Handler<NotifyUser> for ServerActor { impl Handler<NotifyUser> for ServerActor {
type Result = (); type Result = ();
fn handle(&mut self, _msg: NotifyUser, _ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: NotifyUser, ctx: &mut Self::Context) -> Self::Result {
// TODO: (#95) should notify user let Some(server_config) = &self.server_config else {
// send post to notification webhook url return;
};
let notification_config = server_config.notification().clone();
let net = self.net.clone();
async move {
match notification_config.r#type() {
NotificationType::None => { /* do nothing */ }
NotificationType::Webhook => send_webhook(msg, notification_config, net).await,
} }
} }
.in_current_span()
.into_actor(self)
.wait(ctx);
}
}
async fn send_webhook(
msg: NotifyUser,
notification_config: Notification,
net: kxio::network::Network,
) {
let Some(webhook_config) = notification_config.webhook() else {
tracing::warn!("Invalid notification configuration (config) - can't sent notification");
return;
};
let Ok(webhook) = Webhook::new(webhook_config.secret().expose_secret()) else {
tracing::warn!("Invalid notification configuration (signer) - can't sent notification");
return;
};
do_send_webhook(msg, webhook, webhook_config, net).await;
}
async fn do_send_webhook(
msg: NotifyUser,
webhook: Webhook,
webhook_config: &git_next_config::server::OutboundWebhook,
net: kxio::network::Network,
) {
let message_id = format!("msg_{}", ulid::Ulid::new());
let timestamp = time::OffsetDateTime::now_utc();
let payload = msg.as_json(timestamp);
let timestamp = timestamp.unix_timestamp();
let to_sign = format!("{message_id}.{timestamp}.{payload}");
tracing::info!(?to_sign, "");
#[allow(clippy::expect_used)]
let signature = webhook
.sign(&message_id, timestamp, payload.to_string().as_ref())
.expect("signature");
tracing::info!(?signature, "");
let url = webhook_config.url();
use kxio::network::{NetRequest, NetUrl, RequestBody, ResponseType};
let net_url = NetUrl::new(url.to_string());
let request = NetRequest::post(net_url)
.body(RequestBody::Json(payload))
.header("webhook-id", &message_id)
.header("webhook-timestamp", &timestamp.to_string())
.header("webhook-signature", &signature)
.response_type(ResponseType::None)
.build();
net.post_json::<()>(request).await.map_or_else(
|err| {
tracing::warn!(?err, "sending webhook");
},
|_| (),
);
}

View file

@ -1,5 +1,7 @@
use actix::prelude::*; use actix::prelude::*;
use git_next_config::server::NotificationType;
use git_next_webhook_actor::{AddWebhookRecipient, ShutdownWebhook, WebhookActor, WebhookRouter}; use git_next_webhook_actor::{AddWebhookRecipient, ShutdownWebhook, WebhookActor, WebhookRouter};
use standardwebhooks::Webhook;
use crate::{ use crate::{
messages::{ReceiveValidServerConfig, ValidServerConfig}, messages::{ReceiveValidServerConfig, ValidServerConfig},
@ -19,6 +21,26 @@ impl Handler<ReceiveValidServerConfig> for ServerActor {
if let Some(webhook_actor_addr) = self.webhook_actor_addr.take() { if let Some(webhook_actor_addr) = self.webhook_actor_addr.take() {
webhook_actor_addr.do_send(ShutdownWebhook); webhook_actor_addr.do_send(ShutdownWebhook);
} }
match server_config.notification().r#type() {
NotificationType::None => { /* do nothing */ }
NotificationType::Webhook => {
// Create webhook signer
use secrecy::ExposeSecret;
let webhook = server_config
.notification()
.webhook_secret()
.map(|secret| Webhook::new(secret.expose_secret()))
.transpose()
.map_err(|e| {
tracing::error!(
"Invalid notification webhook secret (will not send notifications): {e}"
)
})
.ok()
.flatten();
self.webhook = webhook;
}
}
self.generation.inc(); self.generation.inc();
// Webhook Server // Webhook Server
tracing::info!("Starting Webhook Server..."); tracing::info!("Starting Webhook Server...");
@ -52,8 +74,9 @@ impl Handler<ReceiveValidServerConfig> for ServerActor {
.insert((forge_alias.clone(), repo_alias), addr); .insert((forge_alias.clone(), repo_alias), addr);
}); });
} }
let webhook = WebhookActor::new(socket_address, webhook_router.recipient()).start(); let webhook_actor_addr =
WebhookActor::new(socket_address, webhook_router.recipient()).start();
self.webhook_actor_addr.replace(webhook_actor_addr);
self.server_config.replace(server_config); self.server_config.replace(server_config);
self.webhook.replace(webhook);
} }
} }

View file

@ -16,7 +16,7 @@ impl Handler<Shutdown> for ServerActor {
tracing::debug!(%forge_alias, %repo_alias, "removed webhook"); tracing::debug!(%forge_alias, %repo_alias, "removed webhook");
}); });
tracing::debug!("server shutdown"); tracing::debug!("server shutdown");
if let Some(webhook) = self.webhook.take() { if let Some(webhook) = self.webhook_actor_addr.take() {
tracing::debug!("shuting down webhook"); tracing::debug!("shuting down webhook");
webhook.do_send(ShutdownWebhook); webhook.do_send(ShutdownWebhook);
tracing::debug!("webhook shutdown"); tracing::debug!("webhook shutdown");

View file

@ -47,7 +47,7 @@ type Result<T> = core::result::Result<T, Error>;
pub struct ServerActor { pub struct ServerActor {
server_config: Option<ServerConfig>, server_config: Option<ServerConfig>,
generation: Generation, generation: Generation,
webhook: Option<Addr<WebhookActor>>, webhook_actor_addr: Option<Addr<WebhookActor>>,
fs: FileSystem, fs: FileSystem,
net: Network, net: Network,
repository_factory: Box<dyn RepositoryFactory>, repository_factory: Box<dyn RepositoryFactory>,
@ -72,7 +72,7 @@ impl ServerActor {
Self { Self {
server_config: None, server_config: None,
generation, generation,
webhook: None, webhook_actor_addr: None,
fs, fs,
net, net,
repository_factory: repo, repository_factory: repo,