Skip to main content

tatara_github_watcher/
allocation_factory.rs

1//! Translate a GitHub event into an `EphemeralAllocation` spec.
2//!
3//! Pure function: GitHub event in, typed Allocation out. The handler
4//! applies the resulting Allocation via kube-rs.
5
6use kube::Resource;
7
8use tatara_process::allocation::{AllocationSpec, EphemeralAllocation, Requestor};
9use tatara_process::pool::AllocationRef;
10
11use crate::event::{PrAction, PullRequestEvent};
12
13/// Errors building an Allocation from an event.
14#[derive(Debug, thiserror::Error, PartialEq, Eq)]
15pub enum FactoryError {
16    /// The PR action doesn't warrant an allocation (e.g., draft PRs,
17    /// labeling-only edits).
18    #[error("PR action {0:?} does not warrant allocation")]
19    NotAllocatable(PrAction),
20    /// PR is a draft and the watcher's policy excludes drafts.
21    #[error("PR is draft and drafts are excluded")]
22    DraftExcluded,
23}
24
25/// Deterministic Allocation name from a PR event — re-running the same
26/// event yields the same name (idempotent create).
27#[must_use]
28pub fn allocation_name(repo: &str, pr_number: u64) -> String {
29    // Replace `/` so the result is a valid K8s name.
30    let safe_repo = repo.replace('/', "-");
31    let safe_repo: String = safe_repo
32        .chars()
33        .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
34        .collect();
35    let trimmed = if safe_repo.len() > 50 {
36        &safe_repo[..50]
37    } else {
38        &safe_repo
39    };
40    format!("pr-{pr_number}-{trimmed}").to_lowercase()
41}
42
43/// Build a typed EphemeralAllocation from a PR event.
44///
45/// Routing knobs:
46/// * `namespace` — where to create the Allocation. Same namespace as the
47///   pool (typically `ephemeral-pools`).
48/// * `pool_name` — when `Some`, pin the allocation to a named pool (skips
49///   selector routing). When `None`, the reconciler matches via PoolSelector.
50/// * `include_drafts` — when `false`, draft PRs return `DraftExcluded`.
51pub fn build_allocation(
52    evt: &PullRequestEvent,
53    namespace: &str,
54    pool_name: Option<&str>,
55    include_drafts: bool,
56) -> Result<EphemeralAllocation, FactoryError> {
57    // Action filter — only opening states allocate; "closed" is handled
58    // by the deletion path elsewhere.
59    match evt.action {
60        PrAction::Opened | PrAction::Reopened | PrAction::Synchronize => {}
61        other => return Err(FactoryError::NotAllocatable(other)),
62    }
63
64    if !include_drafts && evt.pull_request.draft.unwrap_or(false) {
65        return Err(FactoryError::DraftExcluded);
66    }
67
68    let name = allocation_name(&evt.repository.full_name, evt.number);
69    let labels: Vec<String> = evt
70        .pull_request
71        .labels
72        .iter()
73        .map(|l| l.name.clone())
74        .collect();
75
76    let pool_ref = pool_name.map(|n| AllocationRef {
77        name: n.to_string(),
78        namespace: namespace.to_string(),
79    });
80
81    let spec = AllocationSpec {
82        pool_ref,
83        requestor: Requestor {
84            kind: "github-pr".into(),
85            repo: Some(evt.repository.full_name.clone()),
86            branch: Some(evt.pull_request.head.ref_name.clone()),
87            pr_number: Some(evt.number),
88            sha: Some(evt.pull_request.head.sha.clone()),
89            pr_labels: labels,
90            actor: Some(evt.pull_request.user.login.clone()),
91        },
92        ttl: None,
93        note: Some(format!(
94            "github webhook: PR #{} on {} ({})",
95            evt.number,
96            evt.repository.full_name,
97            format_action(evt.action)
98        )),
99    };
100
101    let mut alloc = EphemeralAllocation::new(&name, spec);
102    alloc.meta_mut().namespace = Some(namespace.to_string());
103    Ok(alloc)
104}
105
106fn format_action(a: PrAction) -> &'static str {
107    match a {
108        PrAction::Opened => "opened",
109        PrAction::Reopened => "reopened",
110        PrAction::Synchronize => "synchronize",
111        PrAction::Closed => "closed",
112        PrAction::Other => "other",
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::event::{Branch, Label, PullRequest, Repository, User};
120
121    fn sample_event(action: PrAction, draft: bool) -> PullRequestEvent {
122        PullRequestEvent {
123            action,
124            number: 42,
125            repository: Repository {
126                full_name: "pleme-io/akeyless-deployment".into(),
127                default_branch: Some("main".into()),
128            },
129            pull_request: PullRequest {
130                head: Branch {
131                    ref_name: "fix-something".into(),
132                    sha: "abc123def".into(),
133                },
134                base: Branch {
135                    ref_name: "main".into(),
136                    sha: "def456abc".into(),
137                },
138                draft: Some(draft),
139                merged: Some(false),
140                labels: vec![
141                    Label {
142                        name: "needs-akeyless".into(),
143                    },
144                    Label {
145                        name: "integration".into(),
146                    },
147                ],
148                user: User {
149                    login: "drzln".into(),
150                },
151            },
152        }
153    }
154
155    #[test]
156    fn opened_pr_builds_typed_allocation() {
157        let evt = sample_event(PrAction::Opened, false);
158        let alloc = build_allocation(&evt, "ephemeral-pools", None, false).unwrap();
159        assert_eq!(
160            alloc.metadata.name.as_deref(),
161            Some("pr-42-pleme-io-akeyless-deployment")
162        );
163        assert_eq!(alloc.metadata.namespace.as_deref(), Some("ephemeral-pools"));
164        assert_eq!(alloc.spec.requestor.kind, "github-pr");
165        assert_eq!(
166            alloc.spec.requestor.repo.as_deref(),
167            Some("pleme-io/akeyless-deployment")
168        );
169        assert_eq!(alloc.spec.requestor.pr_number, Some(42));
170        assert_eq!(alloc.spec.requestor.sha.as_deref(), Some("abc123def"));
171        assert_eq!(alloc.spec.requestor.pr_labels, vec!["needs-akeyless", "integration"]);
172        assert_eq!(alloc.spec.requestor.actor.as_deref(), Some("drzln"));
173        // Selector-routed (no pool pinned).
174        assert!(alloc.spec.pool_ref.is_none());
175    }
176
177    #[test]
178    fn pool_ref_pins_to_named_pool() {
179        let evt = sample_event(PrAction::Opened, false);
180        let alloc = build_allocation(&evt, "pools", Some("akeyless-pool"), false).unwrap();
181        let pr = alloc.spec.pool_ref.unwrap();
182        assert_eq!(pr.name, "akeyless-pool");
183        assert_eq!(pr.namespace, "pools");
184    }
185
186    #[test]
187    fn draft_pr_excluded_by_default() {
188        let evt = sample_event(PrAction::Opened, true);
189        let err = build_allocation(&evt, "pools", None, false).unwrap_err();
190        assert_eq!(err, FactoryError::DraftExcluded);
191    }
192
193    #[test]
194    fn draft_pr_included_when_allowed() {
195        let evt = sample_event(PrAction::Opened, true);
196        let alloc = build_allocation(&evt, "pools", None, true).unwrap();
197        assert_eq!(alloc.spec.requestor.pr_number, Some(42));
198    }
199
200    #[test]
201    fn closed_pr_does_not_allocate() {
202        let evt = sample_event(PrAction::Closed, false);
203        let err = build_allocation(&evt, "pools", None, false).unwrap_err();
204        assert_eq!(err, FactoryError::NotAllocatable(PrAction::Closed));
205    }
206
207    #[test]
208    fn allocation_name_is_deterministic() {
209        let a = allocation_name("pleme-io/akeyless-deployment", 42);
210        let b = allocation_name("pleme-io/akeyless-deployment", 42);
211        assert_eq!(a, b);
212    }
213
214    #[test]
215    fn allocation_name_is_dns_safe() {
216        let n = allocation_name("Some/Org/Weird@Name", 7);
217        assert!(n.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
218        assert!(n.starts_with("pr-7-"));
219    }
220
221    #[test]
222    fn synchronize_action_allocates() {
223        // PR push events come as `synchronize` — same allocation is
224        // refreshed (idempotent name).
225        let evt = sample_event(PrAction::Synchronize, false);
226        let alloc = build_allocation(&evt, "pools", None, false).unwrap();
227        assert_eq!(alloc.spec.requestor.sha.as_deref(), Some("abc123def"));
228    }
229
230    #[test]
231    fn note_records_event_action_for_audit() {
232        let evt = sample_event(PrAction::Reopened, false);
233        let alloc = build_allocation(&evt, "pools", None, false).unwrap();
234        let note = alloc.spec.note.unwrap();
235        assert!(note.contains("PR #42"));
236        assert!(note.contains("reopened"));
237        assert!(note.contains("pleme-io/akeyless-deployment"));
238    }
239}