Skip to main content

wowlab_sentinel/http/routes/webhooks/
deploy.rs

1#![expect(
2    clippy::items_after_statements,
3    reason = "webhook payload helper types stay beside the event branch that deserializes them"
4)]
5
6use std::{sync::Arc, time::Duration};
7
8use axum::{
9    body::Bytes,
10    extract::State,
11    http::{HeaderMap, StatusCode},
12    response::{IntoResponse, Response},
13};
14use serde::{Deserialize, Serialize};
15
16use crate::{ExposeSecret, http::api_error::ApiError, state::ServerState};
17
18const DEPLOY_UPDATE_DELAY: Duration = Duration::from_secs(30);
19
20#[derive(Clone, Debug, Deserialize)]
21pub(super) struct DeployPayload {
22    pub project: String,
23    pub status: String,
24    pub branch: String,
25    pub commit_sha: Option<String>,
26}
27
28#[derive(Clone, Debug, Serialize)]
29struct AppVersionEvent {
30    payload: AppVersionPayload,
31    r#type: &'static str,
32}
33
34#[derive(Clone, Debug, Serialize)]
35struct AppVersionPayload {
36    project: String,
37    commit_sha: Option<String>,
38}
39
40// #t(rust_cyclomatic_complexity) webhook handler with signature verification and event dispatch
41pub(super) async fn handle(
42    State(state): State<Arc<ServerState>>,
43    headers: HeaderMap,
44    body: Bytes,
45) -> Response {
46    let Some(ref secret) = state.config.deploy_webhook_secret else {
47        tracing::error!("SENTINEL_DEPLOY_WEBHOOK_SECRET not configured");
48
49        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
50    };
51
52    let signature = headers
53        .get("x-deploy-signature")
54        .and_then(|v| v.to_str().ok())
55        .unwrap_or("");
56
57    if !super::verify_signature(secret.expose_secret().as_bytes(), signature, &body) {
58        tracing::warn!("Invalid deploy webhook signature");
59
60        return StatusCode::UNAUTHORIZED.into_response();
61    }
62
63    let payload: DeployPayload = match serde_json::from_slice(&body) {
64        Ok(p) => p,
65        Err(source) => {
66            tracing::error!(error = %source, "Failed to parse deploy payload");
67
68            return ApiError::invalid_webhook_payload(source).into_response();
69        }
70    };
71
72    use crate::telemetry::{DeployEvent, record_deploy_webhook};
73
74    let branch = payload
75        .branch
76        .strip_prefix("refs/heads/")
77        .unwrap_or(&payload.branch);
78
79    tracing::info!(
80        project = %payload.project,
81        branch = %branch,
82        status = %payload.status,
83        "Received deploy webhook"
84    );
85
86    if !super::is_branch_allowed(&state.config.deploy_branch_filter, branch) {
87        tracing::debug!(branch = %branch, "Skipping deploy webhook for filtered branch");
88
89        return StatusCode::OK.into_response();
90    }
91
92    let event = match payload.status.as_str() {
93        "succeeded" => DeployEvent::Succeeded,
94        "failed" => DeployEvent::Failed,
95        "building" => DeployEvent::Building,
96        _ => return StatusCode::OK.into_response(),
97    };
98
99    record_deploy_webhook(event);
100
101    if payload.status == "succeeded" {
102        schedule_app_update_broadcast(state, payload.project, payload.commit_sha).await;
103    }
104
105    StatusCode::OK.into_response()
106}
107
108#[expect(
109    clippy::unused_async,
110    reason = "callers await webhook scheduling uniformly while this function spawns the delayed work"
111)]
112async fn schedule_app_update_broadcast(
113    state: Arc<ServerState>,
114    project: String,
115    commit_sha: Option<String>,
116) {
117    tokio::spawn(async move {
118        tokio::time::sleep(DEPLOY_UPDATE_DELAY).await;
119
120        let event = AppVersionEvent {
121            payload: AppVersionPayload {
122                project,
123                commit_sha,
124            },
125            r#type: "updated",
126        };
127
128        state.publish("portal:version", &event).await;
129    });
130}