Skip to main content

devboy_core/
liveness.rs

1//! Liveness probes for provider plugins per [ADR-021] §6 ("the
2//! validation framework") and ADR-021 §9 (`doctor` integration —
3//! "current status: provisioned / expiring / missing /
4//! format-invalid").
5//!
6//! [ADR-020]'s format validator (epic phase P9.1) tells you a
7//! candidate value *looks* right. Liveness asks the upstream the
8//! harder question: does it actually work? Plugins implement
9//! [`LivenessProbe`] against their native auth-introspection
10//! endpoint (GitHub `/user`, GitLab `/personal_access_tokens/self`,
11//! …) and report a typed [`LivenessResult`]. The wider validation
12//! flow (P9.4 `devboy secrets validate`, P9.3 expiry tracking)
13//! consumes the result.
14//!
15//! This module is the *contract*; concrete impls live in
16//! `crates/plugins/api/{github,gitlab,…}`.
17//!
18//! ## Token argument
19//!
20//! `test(&self, token: &SecretString)` deliberately takes the
21//! token as a parameter rather than reading from `self`. The
22//! validate flow may want to test a *candidate* token before
23//! storing it — see also the format validator's same
24//! pattern (it takes the value, not the path).
25//!
26//! ## Variants
27//!
28//! - [`LivenessStatus::Live`]: token authenticated and the
29//!   upstream returned account info.
30//! - [`LivenessStatus::Revoked`]: token was rejected as
31//!   permanently invalid (401 with revoke semantics, or 403 on a
32//!   probe endpoint that the token previously had access to).
33//! - [`LivenessStatus::Expired`]: token was rejected because it
34//!   had a hard expiration (mostly GitLab personal-access-tokens
35//!   with `expires_at` < today).
36//! - [`LivenessStatus::Throttled`]: probe hit a 429. Caller may
37//!   retry later; the result is inconclusive.
38//! - [`LivenessStatus::NotImplemented`]: the provider plugin
39//!   has no native introspection (or hasn't been wired yet).
40//!   The shared default in this module returns this; concrete
41//!   plugins override.
42//! - [`LivenessStatus::Error`]: anything else (network failure,
43//!   non-JSON body, unexpected 5xx). The `detail` field carries
44//!   the message.
45//!
46//! [ADR-020]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-020-secret-manifest-and-alias-resolution.md
47//! [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
48
49use async_trait::async_trait;
50use secrecy::SecretString;
51
52use crate::Result;
53
54/// What the upstream said about a probed token.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum LivenessStatus {
57    /// Token authenticated.
58    Live,
59    /// Token was rejected as permanently invalid.
60    Revoked,
61    /// Token had a hard expiration that has now passed.
62    Expired,
63    /// Probe was rate-limited; result inconclusive.
64    Throttled,
65    /// Provider plugin has no native introspection.
66    NotImplemented,
67    /// Unexpected error (network, non-JSON, 5xx, …). The
68    /// `detail` field carries the message.
69    Error,
70}
71
72/// Outcome of [`LivenessProbe::test`].
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct LivenessResult {
75    /// Classified outcome.
76    pub status: LivenessStatus,
77    /// Human-readable detail — account login, scope summary,
78    /// upstream error message, …. Always optional; doctor / the
79    /// validate CLI render it verbatim.
80    pub detail: Option<String>,
81    /// Upstream-reported expiry, when the API exposes one (GitHub
82    /// `github-authentication-token-expiration` header, GitLab
83    /// `expires_at` JSON field). P9.3 reads this back into the
84    /// global index.
85    pub expires_at: Option<String>,
86}
87
88impl LivenessResult {
89    /// Convenience — `Live` with a detail string.
90    pub fn live(detail: impl Into<String>) -> Self {
91        Self {
92            status: LivenessStatus::Live,
93            detail: Some(detail.into()),
94            expires_at: None,
95        }
96    }
97
98    /// Convenience — `Revoked` with a detail string.
99    pub fn revoked(detail: impl Into<String>) -> Self {
100        Self {
101            status: LivenessStatus::Revoked,
102            detail: Some(detail.into()),
103            expires_at: None,
104        }
105    }
106
107    /// Convenience — `Expired` carrying the upstream-reported
108    /// expiry timestamp.
109    pub fn expired(expires_at: impl Into<String>) -> Self {
110        Self {
111            status: LivenessStatus::Expired,
112            detail: None,
113            expires_at: Some(expires_at.into()),
114        }
115    }
116
117    /// Convenience — `Throttled` with a detail string (e.g. the
118    /// `Retry-After` header value).
119    pub fn throttled(detail: impl Into<String>) -> Self {
120        Self {
121            status: LivenessStatus::Throttled,
122            detail: Some(detail.into()),
123            expires_at: None,
124        }
125    }
126
127    /// Convenience — `NotImplemented` carrying the provider
128    /// name so `doctor` can show "this provider has no native
129    /// introspection".
130    pub fn not_implemented(provider: impl Into<String>) -> Self {
131        Self {
132            status: LivenessStatus::NotImplemented,
133            detail: Some(format!(
134                "{} liveness probe is not implemented; format-only validation only",
135                provider.into()
136            )),
137            expires_at: None,
138        }
139    }
140
141    /// Convenience — `Error` with a detail string.
142    pub fn error(detail: impl Into<String>) -> Self {
143        Self {
144            status: LivenessStatus::Error,
145            detail: Some(detail.into()),
146            expires_at: None,
147        }
148    }
149}
150
151/// Liveness probe a provider plugin implements against its
152/// native introspection endpoint. The `test` method is async
153/// because it does network I/O.
154#[async_trait]
155pub trait LivenessProbe: Send + Sync {
156    /// Probe the upstream with `token`. The default impl returns
157    /// `NotImplemented` so providers without a native
158    /// introspection endpoint can opt-in trivially.
159    async fn test(&self, _token: &SecretString) -> Result<LivenessResult> {
160        Ok(LivenessResult::not_implemented(self.provider_name()))
161    }
162
163    /// Lower-cased provider name surfaced in the `NotImplemented`
164    /// default and in `doctor` output. Concrete impls override
165    /// to return e.g. `"github"`, `"gitlab"`.
166    fn provider_name(&self) -> &str;
167}
168
169// =============================================================================
170// Tests
171// =============================================================================
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    /// Stub provider that uses the trait's default `test` impl.
178    /// Verifies the default returns `NotImplemented` carrying the
179    /// `provider_name`.
180    struct StubProvider;
181
182    #[async_trait]
183    impl LivenessProbe for StubProvider {
184        fn provider_name(&self) -> &str {
185            "stub"
186        }
187    }
188
189    // -- Result helpers -----------------------------------------
190
191    #[test]
192    fn live_helper_sets_status_and_detail() {
193        let r = LivenessResult::live("alice");
194        assert_eq!(r.status, LivenessStatus::Live);
195        assert_eq!(r.detail.as_deref(), Some("alice"));
196        assert!(r.expires_at.is_none());
197    }
198
199    #[test]
200    fn revoked_helper_sets_status_and_detail() {
201        let r = LivenessResult::revoked("token revoked by user");
202        assert_eq!(r.status, LivenessStatus::Revoked);
203        assert_eq!(r.detail.as_deref(), Some("token revoked by user"));
204    }
205
206    #[test]
207    fn expired_helper_carries_expiry_timestamp() {
208        let r = LivenessResult::expired("2025-12-31");
209        assert_eq!(r.status, LivenessStatus::Expired);
210        assert_eq!(r.expires_at.as_deref(), Some("2025-12-31"));
211    }
212
213    #[test]
214    fn throttled_helper_holds_retry_after_detail() {
215        let r = LivenessResult::throttled("retry after 60s");
216        assert_eq!(r.status, LivenessStatus::Throttled);
217        assert!(r.detail.unwrap().contains("retry"));
218    }
219
220    #[test]
221    fn not_implemented_helper_includes_provider_name_in_detail() {
222        let r = LivenessResult::not_implemented("clickup");
223        assert_eq!(r.status, LivenessStatus::NotImplemented);
224        let detail = r.detail.unwrap();
225        assert!(detail.contains("clickup"));
226        assert!(detail.contains("not implemented"));
227    }
228
229    #[test]
230    fn error_helper_sets_status_and_detail() {
231        let r = LivenessResult::error("connection refused");
232        assert_eq!(r.status, LivenessStatus::Error);
233        assert_eq!(r.detail.as_deref(), Some("connection refused"));
234    }
235
236    // -- Trait default ------------------------------------------
237
238    #[tokio::test]
239    async fn default_test_impl_returns_not_implemented_with_provider_name() {
240        let p = StubProvider;
241        let r = p
242            .test(&SecretString::from("any-token".to_owned()))
243            .await
244            .unwrap();
245        assert_eq!(r.status, LivenessStatus::NotImplemented);
246        let detail = r.detail.unwrap();
247        assert!(detail.contains("stub"));
248    }
249
250    #[tokio::test]
251    async fn default_impl_works_through_dyn_trait_object() {
252        // The validate flow (P9.4) will store probes in a
253        // `HashMap<String, Box<dyn LivenessProbe>>`. Confirm dyn
254        // compatibility for the default impl.
255        let p: Box<dyn LivenessProbe> = Box::new(StubProvider);
256        assert_eq!(p.provider_name(), "stub");
257        let r = p.test(&SecretString::from("x".to_owned())).await.unwrap();
258        assert_eq!(r.status, LivenessStatus::NotImplemented);
259    }
260}