tatara_github_watcher/handler.rs
1//! axum HTTP handler — verify signature, dispatch on event kind, apply
2//! resulting Allocation via kube-rs.
3
4use 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/// Handler state shared across requests.
22#[derive(Clone)]
23pub struct HandlerState {
24 pub config: Arc<WatcherConfig>,
25 pub kube: Client,
26}
27
28impl HandlerState {
29 /// Namespaced `Api<EphemeralAllocation>` bound to this handler's
30 /// client + configured watcher namespace — the ONE substrate
31 /// primitive that owns the `Api::namespaced(self.kube.clone(),
32 /// &self.config.namespace)` shape for the github-watcher.
33 ///
34 /// Pre-lift the two-slot `(self.kube.clone(), &self.config.
35 /// namespace)` incantation was hand-authored at TWO sites in
36 /// `handler::handle_pr_event`, past the ★★ PRIME-DIRECTIVE ≥ 2
37 /// duplication threshold — the `PrAction::Closed` delete-branch
38 /// slot (`api.delete(&name, …)`) plus the `PrAction::{Opened,
39 /// Reopened, Synchronize}` create-branch slot (`api.create(&pp,
40 /// &alloc)`) each restated the SAME `Api::namespaced(state.kube.
41 /// clone(), &state.config.namespace)` chain verbatim. Post-lift
42 /// the two consumers share ONE substrate owner; a future emitter
43 /// of `Api<EphemeralAllocation>` on the handler reaches for
44 /// `state.allocation_api()` rather than re-authoring the two-slot
45 /// chain a third time — matching the composition discipline the
46 /// peer [`tatara_pool_reconciler::context::PoolContext::
47 /// allocation_api`] substrate primitive already establishes on
48 /// the pool-reconciler side of the same CRD.
49 ///
50 /// The typed `Api<EphemeralAllocation>` return pins the resource
51 /// kind at rustc time — a future consumer that reaches for a
52 /// different CRD via this primitive's client-slot fails to
53 /// compile rather than silently issuing a REST request under the
54 /// wrong resource plural.
55 pub fn allocation_api(&self) -> Api<EphemeralAllocation> {
56 Api::namespaced(self.kube.clone(), &self.config.namespace)
57 }
58}
59
60/// POST handler for GitHub webhooks.
61pub async fn webhook(
62 State(state): State<HandlerState>,
63 headers: HeaderMap,
64 body: Bytes,
65) -> impl IntoResponse {
66 // 1. Verify HMAC.
67 let sig_header = headers
68 .get("X-Hub-Signature-256")
69 .and_then(|v| v.to_str().ok())
70 .unwrap_or("");
71 if let Err(e) = verify_signature(sig_header, &body, state.config.secret.as_bytes()) {
72 warn!(error = %e, "webhook signature verification failed");
73 return (StatusCode::UNAUTHORIZED, format!("signature: {e}")).into_response();
74 }
75
76 // 2. Dispatch on event kind.
77 let event_header = headers
78 .get("X-GitHub-Event")
79 .and_then(|v| v.to_str().ok())
80 .unwrap_or("");
81 let kind = EventKind::from_header(event_header);
82
83 match kind {
84 EventKind::PullRequest => handle_pr_event(&state, &body).await,
85 EventKind::Push => {
86 // Push events handled by a separate path (e.g., main-branch
87 // attestation runs). v0 just acknowledges.
88 (StatusCode::OK, "push event acknowledged (not allocated)").into_response()
89 }
90 EventKind::Other => (StatusCode::OK, "event ignored").into_response(),
91 }
92}
93
94async fn handle_pr_event(state: &HandlerState, body: &[u8]) -> axum::response::Response {
95 let evt: PullRequestEvent = match serde_json::from_slice(body) {
96 Ok(e) => e,
97 Err(e) => {
98 warn!(error = %e, "failed to parse PR event");
99 return (StatusCode::BAD_REQUEST, format!("parse: {e}")).into_response();
100 }
101 };
102
103 // Repo allowlist.
104 if !state.config.allow_repos.is_empty()
105 && !repo_allowed(&evt.repository.full_name, &state.config.allow_repos)
106 {
107 info!(repo = %evt.repository.full_name, "repo not in allowlist; skipping");
108 return (StatusCode::OK, "repo not in allowlist").into_response();
109 }
110
111 use crate::event::PrAction;
112 match evt.action {
113 PrAction::Closed => {
114 // Delete the allocation; pool reconciler returns the member.
115 let name = allocation_name(&evt.repository.full_name, evt.number);
116 // `Api<EphemeralAllocation>` binds via the ONE substrate
117 // primitive `HandlerState::allocation_api` — pre-lift this
118 // was a hand-authored `Api::namespaced(state.kube.clone(),
119 // &state.config.namespace)` chain, one of TWO workspace-
120 // wide restatements past the ★★ PRIME-DIRECTIVE ≥ 2
121 // duplication threshold (peer at the `PrAction::{Opened,
122 // Reopened, Synchronize}` create-branch slot below). Post-
123 // lift the two consumers share ONE substrate owner.
124 let api = state.allocation_api();
125 match api.delete(&name, &DeleteParams::default()).await {
126 Ok(_) => {
127 info!(
128 namespace = %state.config.namespace,
129 allocation = %name,
130 "closed PR → deleted Allocation"
131 );
132 (StatusCode::OK, "allocation deleted").into_response()
133 }
134 // 404 detection rides the substrate primitive
135 // `tatara_process::kube_error::is_not_found` — pre-lift
136 // this was a hand-authored `Err(kube::Error::Api(e)) if
137 // e.code == 404` match-arm guard, one of FIVE workspace-
138 // wide restatements past the ★★ PRIME-DIRECTIVE ≥ 2
139 // duplication threshold (the OTHER four sites all key
140 // off 409, routed through the peer `is_conflict`).
141 Err(ref e) if tatara_process::kube_error::is_not_found(e) => {
142 (StatusCode::OK, "allocation already gone").into_response()
143 }
144 Err(e) => {
145 warn!(error = %e, "delete failed");
146 (StatusCode::INTERNAL_SERVER_ERROR, format!("delete: {e}")).into_response()
147 }
148 }
149 }
150 PrAction::Opened | PrAction::Reopened | PrAction::Synchronize => {
151 // Build + create-or-replace the allocation.
152 let alloc = match build_allocation(
153 &evt,
154 &state.config.namespace,
155 state.config.pin_pool.as_deref(),
156 state.config.include_drafts,
157 ) {
158 Ok(a) => a,
159 Err(FactoryError::DraftExcluded) => {
160 info!("draft PR — skipping allocation");
161 return (StatusCode::OK, "draft excluded").into_response();
162 }
163 Err(FactoryError::NotAllocatable(_)) => {
164 return (StatusCode::OK, "action not allocatable").into_response();
165 }
166 };
167 // `Api<EphemeralAllocation>` binds via the ONE substrate
168 // primitive `HandlerState::allocation_api` — pre-lift this
169 // was a hand-authored `Api::namespaced(state.kube.clone(),
170 // &state.config.namespace)` chain, peer to the
171 // `PrAction::Closed` delete-branch slot already routed
172 // through the primitive above. Post-lift both consumers
173 // share ONE substrate owner.
174 let api = state.allocation_api();
175 match api.create(&PostParams::default(), &alloc).await {
176 Ok(_) => {
177 info!(
178 namespace = %state.config.namespace,
179 allocation = alloc.metadata.name.as_deref().unwrap_or("?"),
180 pr_number = evt.number,
181 repo = %evt.repository.full_name,
182 "PR event → created Allocation"
183 );
184 (StatusCode::CREATED, "allocation created").into_response()
185 }
186 // 409 detection rides the substrate primitive
187 // `tatara_process::kube_error::is_conflict` — pre-lift
188 // this was a hand-authored `Err(kube::Error::Api(e)) if
189 // e.code == 409` match-arm guard, sibling to the 404
190 // arm above (both routed through the same substrate
191 // module's paired predicates).
192 Err(ref e) if tatara_process::kube_error::is_conflict(e) => {
193 // Already exists — refresh via PATCH (synchronize event).
194 (StatusCode::OK, "allocation already exists (synchronize)").into_response()
195 }
196 Err(e) => {
197 warn!(error = %e, "create allocation failed");
198 (StatusCode::INTERNAL_SERVER_ERROR, format!("create: {e}")).into_response()
199 }
200 }
201 }
202 PrAction::Other => (StatusCode::OK, "action ignored").into_response(),
203 }
204}
205
206fn repo_allowed(repo: &str, allowlist: &[String]) -> bool {
207 allowlist.iter().any(|p| repo_matches(p, repo))
208}
209
210fn repo_matches(pattern: &str, repo: &str) -> bool {
211 if let Some(prefix) = pattern.strip_suffix("/*") {
212 repo.starts_with(&format!("{prefix}/"))
213 } else {
214 pattern == repo
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 // ─── HandlerState api-primitive substrate pins ─────────────────
223 //
224 // The two-slot `(state.kube.clone(), &state.config.namespace)`
225 // incantation was hand-authored at TWO sites in
226 // `handle_pr_event` before `HandlerState::allocation_api` closed
227 // it. These pins bind the primitive at fail-before-pass-after
228 // granularity so a regression that drifts the configured
229 // namespace, the resource kind, or the reused client-slot
230 // surfaces here rather than as silent operator-facing drift at
231 // every downstream webhook path.
232 //
233 // `Client::try_from(Config::new(url))` needs a live tokio
234 // reactor (`tower::buffer::Buffer::new` spawns a background task
235 // on construction), so every pin runs under `#[tokio::test]`.
236 #[cfg(test)]
237 mod api_primitive_pins {
238 use super::*;
239 use kube::Config;
240
241 fn state_with_namespace(namespace: &str) -> HandlerState {
242 let url = "http://localhost:9999".parse().expect("valid probe url");
243 let client = Client::try_from(Config::new(url)).expect("build kube client");
244 let config = WatcherConfig {
245 listen: "0.0.0.0:8080".into(),
246 secret: "test-secret".into(),
247 namespace: namespace.into(),
248 pin_pool: None,
249 include_drafts: false,
250 allow_repos: Vec::new(),
251 };
252 HandlerState {
253 config: Arc::new(config),
254 kube: client,
255 }
256 }
257
258 #[tokio::test]
259 async fn allocation_api_binds_configured_namespace_into_resource_url() {
260 // The webhook handler reads the target namespace from
261 // `state.config.namespace` (operator-configured via
262 // `TATARA_WATCHER_NAMESPACE`) and expects
263 // `state.allocation_api()` to bind that namespace onto
264 // the returned Api's REST path — the primitive routes
265 // the configured slot through to the `Api::namespaced`
266 // dispatcher's `ns` argument unchanged.
267 let state = state_with_namespace("watcher-test-ns");
268 let api = state.allocation_api();
269 let url = api.resource_url();
270 assert!(
271 url.contains("/namespaces/watcher-test-ns/"),
272 "allocation_api resource url must carry the configured namespace verbatim; got {url}"
273 );
274 }
275
276 #[tokio::test]
277 async fn allocation_api_binds_the_ephemeral_allocation_kind() {
278 // The typed `Api<EphemeralAllocation>` return pins the
279 // resource kind at rustc time; this pin adds the runtime
280 // witness — the emitted REST path targets the
281 // `tatara.pleme.io/v1alpha1/ephemeralallocations`
282 // collection matching the `#[kube(group =
283 // "tatara.pleme.io", version = "v1alpha1", plural =
284 // "ephemeralallocations")]` attribute on
285 // `AllocationSpec`.
286 let state = state_with_namespace("default");
287 let api = state.allocation_api();
288 let url = api.resource_url();
289 assert!(
290 url.starts_with("/apis/tatara.pleme.io/v1alpha1/"),
291 "Api resource url must be scoped to the tatara.pleme.io/v1alpha1 group; got {url}"
292 );
293 assert!(
294 url.ends_with("/ephemeralallocations"),
295 "Api resource url must terminate at the `ephemeralallocations` collection; got {url}"
296 );
297 }
298
299 #[tokio::test]
300 async fn allocation_api_matches_hand_authored_pre_lift_bytewise() {
301 // Bytewise equivalence with the pre-lift `Api::namespaced
302 // (state.kube.clone(), &state.config.namespace)` chain —
303 // the primitive changes the authoring surface, not the
304 // observable REST path, so a regression that drifts the
305 // routing on this primitive surfaces here rather than as
306 // silent operator-facing drift at every handler branch.
307 for ns in ["default", "ephemeral-pools", "watcher-alt"] {
308 let state = state_with_namespace(ns);
309 let via_primitive = state.allocation_api();
310 let via_pre_lift: Api<EphemeralAllocation> =
311 Api::namespaced(state.kube.clone(), &state.config.namespace);
312 assert_eq!(
313 via_primitive.resource_url(),
314 via_pre_lift.resource_url(),
315 "allocation_api must be byte-identical to the pre-lift chain for ns={ns:?}"
316 );
317 }
318 }
319 }
320
321 #[test]
322 fn repo_matches_exact() {
323 assert!(repo_matches("pleme-io/demo-app", "pleme-io/demo-app"));
324 assert!(!repo_matches("pleme-io/demo-app", "pleme-io/other"));
325 }
326
327 #[test]
328 fn repo_matches_org_wildcard() {
329 assert!(repo_matches("pleme-io/*", "pleme-io/demo-app"));
330 assert!(repo_matches("pleme-io/*", "pleme-io/tatara"));
331 assert!(!repo_matches("pleme-io/*", "drzln/dotfiles"));
332 }
333
334 #[test]
335 fn empty_allowlist_skipped_at_caller() {
336 // The caller's check `!allowlist.is_empty()` gates this function;
337 // sanity test that an empty allowlist would reject everything if
338 // called directly.
339 assert!(!repo_allowed("anything", &[]));
340 }
341
342 #[test]
343 fn allowlist_with_one_pattern_filters() {
344 let allow = vec!["pleme-io/*".to_string()];
345 assert!(repo_allowed("pleme-io/demo-app", &allow));
346 assert!(!repo_allowed("drzln/dotfiles", &allow));
347 }
348}