tatara_github_watcher/
handler.rs1use std::sync::Arc;
5
6use axum::body::Bytes;
7use axum::extract::State;
8use axum::http::{HeaderMap, StatusCode};
9use axum::response::IntoResponse;
10use kube::api::{Api, DeleteParams, PostParams};
11use kube::Client;
12use tracing::{info, warn};
13
14use tatara_process::allocation::EphemeralAllocation;
15
16use crate::allocation_factory::{allocation_name, build_allocation, FactoryError};
17use crate::config::WatcherConfig;
18use crate::event::{EventKind, PullRequestEvent};
19use crate::verify::verify_signature;
20
21#[derive(Clone)]
23pub struct HandlerState {
24 pub config: Arc<WatcherConfig>,
25 pub kube: Client,
26}
27
28pub async fn webhook(
30 State(state): State<HandlerState>,
31 headers: HeaderMap,
32 body: Bytes,
33) -> impl IntoResponse {
34 let sig_header = headers
36 .get("X-Hub-Signature-256")
37 .and_then(|v| v.to_str().ok())
38 .unwrap_or("");
39 if let Err(e) = verify_signature(sig_header, &body, state.config.secret.as_bytes()) {
40 warn!(error = %e, "webhook signature verification failed");
41 return (StatusCode::UNAUTHORIZED, format!("signature: {e}")).into_response();
42 }
43
44 let event_header = headers
46 .get("X-GitHub-Event")
47 .and_then(|v| v.to_str().ok())
48 .unwrap_or("");
49 let kind = EventKind::from_header(event_header);
50
51 match kind {
52 EventKind::PullRequest => handle_pr_event(&state, &body).await,
53 EventKind::Push => {
54 (StatusCode::OK, "push event acknowledged (not allocated)").into_response()
57 }
58 EventKind::Other => (StatusCode::OK, "event ignored").into_response(),
59 }
60}
61
62async fn handle_pr_event(state: &HandlerState, body: &[u8]) -> axum::response::Response {
63 let evt: PullRequestEvent = match serde_json::from_slice(body) {
64 Ok(e) => e,
65 Err(e) => {
66 warn!(error = %e, "failed to parse PR event");
67 return (StatusCode::BAD_REQUEST, format!("parse: {e}")).into_response();
68 }
69 };
70
71 if !state.config.allow_repos.is_empty() && !repo_allowed(&evt.repository.full_name, &state.config.allow_repos) {
73 info!(repo = %evt.repository.full_name, "repo not in allowlist; skipping");
74 return (StatusCode::OK, "repo not in allowlist").into_response();
75 }
76
77 use crate::event::PrAction;
78 match evt.action {
79 PrAction::Closed => {
80 let name = allocation_name(&evt.repository.full_name, evt.number);
82 let api: Api<EphemeralAllocation> =
83 Api::namespaced(state.kube.clone(), &state.config.namespace);
84 match api.delete(&name, &DeleteParams::default()).await {
85 Ok(_) => {
86 info!(
87 namespace = %state.config.namespace,
88 allocation = %name,
89 "closed PR → deleted Allocation"
90 );
91 (StatusCode::OK, "allocation deleted").into_response()
92 }
93 Err(kube::Error::Api(e)) if e.code == 404 => {
94 (StatusCode::OK, "allocation already gone").into_response()
95 }
96 Err(e) => {
97 warn!(error = %e, "delete failed");
98 (StatusCode::INTERNAL_SERVER_ERROR, format!("delete: {e}"))
99 .into_response()
100 }
101 }
102 }
103 PrAction::Opened | PrAction::Reopened | PrAction::Synchronize => {
104 let alloc = match build_allocation(
106 &evt,
107 &state.config.namespace,
108 state.config.pin_pool.as_deref(),
109 state.config.include_drafts,
110 ) {
111 Ok(a) => a,
112 Err(FactoryError::DraftExcluded) => {
113 info!("draft PR — skipping allocation");
114 return (StatusCode::OK, "draft excluded").into_response();
115 }
116 Err(FactoryError::NotAllocatable(_)) => {
117 return (StatusCode::OK, "action not allocatable").into_response();
118 }
119 };
120 let api: Api<EphemeralAllocation> =
121 Api::namespaced(state.kube.clone(), &state.config.namespace);
122 match api.create(&PostParams::default(), &alloc).await {
123 Ok(_) => {
124 info!(
125 namespace = %state.config.namespace,
126 allocation = alloc.metadata.name.as_deref().unwrap_or("?"),
127 pr_number = evt.number,
128 repo = %evt.repository.full_name,
129 "PR event → created Allocation"
130 );
131 (StatusCode::CREATED, "allocation created").into_response()
132 }
133 Err(kube::Error::Api(e)) if e.code == 409 => {
134 (StatusCode::OK, "allocation already exists (synchronize)").into_response()
136 }
137 Err(e) => {
138 warn!(error = %e, "create allocation failed");
139 (StatusCode::INTERNAL_SERVER_ERROR, format!("create: {e}"))
140 .into_response()
141 }
142 }
143 }
144 PrAction::Other => (StatusCode::OK, "action ignored").into_response(),
145 }
146}
147
148fn repo_allowed(repo: &str, allowlist: &[String]) -> bool {
149 allowlist.iter().any(|p| repo_matches(p, repo))
150}
151
152fn repo_matches(pattern: &str, repo: &str) -> bool {
153 if let Some(prefix) = pattern.strip_suffix("/*") {
154 repo.starts_with(&format!("{prefix}/"))
155 } else {
156 pattern == repo
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn repo_matches_exact() {
166 assert!(repo_matches("pleme-io/akeyless", "pleme-io/akeyless"));
167 assert!(!repo_matches("pleme-io/akeyless", "pleme-io/other"));
168 }
169
170 #[test]
171 fn repo_matches_org_wildcard() {
172 assert!(repo_matches("pleme-io/*", "pleme-io/akeyless"));
173 assert!(repo_matches("pleme-io/*", "pleme-io/tatara"));
174 assert!(!repo_matches("pleme-io/*", "drzln/dotfiles"));
175 }
176
177 #[test]
178 fn empty_allowlist_skipped_at_caller() {
179 assert!(!repo_allowed("anything", &[]));
183 }
184
185 #[test]
186 fn allowlist_with_one_pattern_filters() {
187 let allow = vec!["pleme-io/*".to_string()];
188 assert!(repo_allowed("pleme-io/akeyless", &allow));
189 assert!(!repo_allowed("drzln/dotfiles", &allow));
190 }
191}