Skip to main content

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