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}