wowlab_sentinel/http/routes/webhooks/
deploy.rs1#![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
40pub(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}