Skip to main content

devboy_gitlab/
liveness.rs

1//! GitLab `LivenessProbe` impl per [ADR-021] ยง6.
2//!
3//! Hits `GET /api/v4/personal_access_tokens/self` with the
4//! supplied token. The endpoint returns the token's own metadata
5//! when authenticated:
6//!
7//! ```json
8//! {
9//!   "id": 1,
10//!   "name": "deploy",
11//!   "active": true,
12//!   "expires_at": "2026-08-01",
13//!   "scopes": ["api", "read_repository"]
14//! }
15//! ```
16//!
17//! `expires_at` (when present) is plumbed straight into
18//! [`LivenessResult::expires_at`] so P9.3 can write it back into
19//! the global index. `active = false` maps to
20//! [`LivenessStatus::Revoked`].
21//!
22//! Error mapping:
23//!
24//! | Status | Mapping                            |
25//! |--------|------------------------------------|
26//! | 200, `active=true` | `Live`                  |
27//! | 200, `active=false` | `Revoked`              |
28//! | 401    | `Revoked`                          |
29//! | 403    | `Revoked`                          |
30//! | 429    | `Throttled` (`Retry-After`)        |
31//! | other  | `Error`                            |
32//!
33//! [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
34
35use async_trait::async_trait;
36use devboy_core::{Error, LivenessProbe, LivenessResult, Result, liveness::LivenessStatus};
37use secrecy::{ExposeSecret, SecretString};
38use serde::Deserialize;
39
40use crate::client::GitLabClient;
41
42#[derive(Deserialize)]
43struct TokenSelf {
44    #[serde(default)]
45    name: Option<String>,
46    #[serde(default = "default_active")]
47    active: bool,
48    #[serde(default)]
49    expires_at: Option<String>,
50    #[serde(default)]
51    scopes: Option<Vec<String>>,
52}
53
54fn default_active() -> bool {
55    // GitLab returns `active` on every token-self payload; default
56    // is defensive in case a fork omits the field.
57    true
58}
59
60#[async_trait]
61impl LivenessProbe for GitLabClient {
62    fn provider_name(&self) -> &str {
63        "gitlab"
64    }
65
66    async fn test(&self, token: &SecretString) -> Result<LivenessResult> {
67        let url = format!("{}/api/v4/personal_access_tokens/self", self.base_url());
68        let resp = self
69            .http_client()
70            .get(&url)
71            .header("PRIVATE-TOKEN", token.expose_secret())
72            .send()
73            .await
74            .map_err(|e| {
75                Error::Http(format!(
76                    "gitlab liveness GET /personal_access_tokens/self: {e}"
77                ))
78            })?;
79
80        let status = resp.status();
81        match status.as_u16() {
82            200 => {
83                let body: TokenSelf = resp.json().await.map_err(|e| {
84                    Error::InvalidData(format!(
85                        "gitlab personal_access_tokens/self returned non-JSON body: {e}"
86                    ))
87                })?;
88                if !body.active {
89                    return Ok(LivenessResult {
90                        status: LivenessStatus::Revoked,
91                        detail: Some(
92                            "gitlab marked the token as inactive (active=false)".to_owned(),
93                        ),
94                        expires_at: body.expires_at,
95                    });
96                }
97                let scope_summary = body
98                    .scopes
99                    .as_ref()
100                    .map(|s| s.join(","))
101                    .unwrap_or_default();
102                let detail = match (body.name.as_deref(), scope_summary.as_str()) {
103                    (Some(n), s) if !s.is_empty() => Some(format!("{n} ({s})")),
104                    (Some(n), _) => Some(n.to_owned()),
105                    (None, s) if !s.is_empty() => Some(s.to_owned()),
106                    _ => Some("gitlab personal access token".to_owned()),
107                };
108                Ok(LivenessResult {
109                    status: LivenessStatus::Live,
110                    detail,
111                    expires_at: body.expires_at,
112                })
113            }
114            401 => Ok(LivenessResult::revoked(
115                "gitlab rejected the token (401 Unauthorized)",
116            )),
117            403 => Ok(LivenessResult::revoked(
118                "gitlab refused the token (403 Forbidden) โ€” revoked or scope-restricted",
119            )),
120            429 => {
121                let retry = resp
122                    .headers()
123                    .get("retry-after")
124                    .and_then(|v| v.to_str().ok())
125                    .map(str::to_owned)
126                    .unwrap_or_else(|| "unknown".to_owned());
127                Ok(LivenessResult::throttled(format!(
128                    "gitlab rate limit exceeded; retry-after: {retry}"
129                )))
130            }
131            other => {
132                let body = resp.text().await.unwrap_or_default();
133                Ok(LivenessResult::error(format!(
134                    "gitlab GET /personal_access_tokens/self returned {other}: {}",
135                    body.trim()
136                )))
137            }
138        }
139    }
140}
141
142// =============================================================================
143// Tests
144// =============================================================================
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use httpmock::prelude::*;
150
151    fn client_against(base_url: &str) -> GitLabClient {
152        GitLabClient::with_base_url(
153            base_url,
154            "1",
155            SecretString::from("ignored-self-token".to_owned()),
156        )
157    }
158
159    #[tokio::test]
160    async fn live_token_returns_name_scopes_and_expiry() {
161        let server = MockServer::start_async().await;
162        let _m = server
163            .mock_async(|when, then| {
164                when.method(GET)
165                    .path("/api/v4/personal_access_tokens/self")
166                    .header("PRIVATE-TOKEN", "glpat-fixture");
167                then.status(200).json_body(serde_json::json!({
168                    "id": 7,
169                    "name": "deploy",
170                    "active": true,
171                    "expires_at": "2026-08-01",
172                    "scopes": ["api", "read_repository"]
173                }));
174            })
175            .await;
176
177        let client = client_against(&server.base_url());
178        let r = client
179            .test(&SecretString::from("glpat-fixture".to_owned()))
180            .await
181            .unwrap();
182        assert_eq!(r.status, LivenessStatus::Live);
183        let detail = r.detail.unwrap();
184        assert!(detail.contains("deploy"));
185        assert!(detail.contains("api"));
186        assert!(detail.contains("read_repository"));
187        assert_eq!(r.expires_at.as_deref(), Some("2026-08-01"));
188    }
189
190    #[tokio::test]
191    async fn inactive_token_returns_revoked_with_expiry() {
192        let server = MockServer::start_async().await;
193        let _m = server
194            .mock_async(|when, then| {
195                when.method(GET).path("/api/v4/personal_access_tokens/self");
196                then.status(200).json_body(serde_json::json!({
197                    "id": 7,
198                    "name": "old",
199                    "active": false,
200                    "expires_at": "2025-12-01",
201                    "scopes": []
202                }));
203            })
204            .await;
205
206        let client = client_against(&server.base_url());
207        let r = client
208            .test(&SecretString::from("revoked".to_owned()))
209            .await
210            .unwrap();
211        assert_eq!(r.status, LivenessStatus::Revoked);
212        // Even on revoked, surface the expiry โ€” useful for
213        // doctor and the migration flow.
214        assert_eq!(r.expires_at.as_deref(), Some("2025-12-01"));
215    }
216
217    #[tokio::test]
218    async fn revoked_on_401() {
219        let server = MockServer::start_async().await;
220        let _m = server
221            .mock_async(|when, then| {
222                when.method(GET).path("/api/v4/personal_access_tokens/self");
223                then.status(401)
224                    .json_body(serde_json::json!({"message": "401 Unauthorized"}));
225            })
226            .await;
227        let client = client_against(&server.base_url());
228        let r = client
229            .test(&SecretString::from("bad".to_owned()))
230            .await
231            .unwrap();
232        assert_eq!(r.status, LivenessStatus::Revoked);
233    }
234
235    #[tokio::test]
236    async fn revoked_on_403() {
237        let server = MockServer::start_async().await;
238        let _m = server
239            .mock_async(|when, then| {
240                when.method(GET).path("/api/v4/personal_access_tokens/self");
241                then.status(403);
242            })
243            .await;
244        let client = client_against(&server.base_url());
245        let r = client
246            .test(&SecretString::from("bad".to_owned()))
247            .await
248            .unwrap();
249        assert_eq!(r.status, LivenessStatus::Revoked);
250    }
251
252    #[tokio::test]
253    async fn throttled_on_429_carries_retry_after() {
254        let server = MockServer::start_async().await;
255        let _m = server
256            .mock_async(|when, then| {
257                when.method(GET).path("/api/v4/personal_access_tokens/self");
258                then.status(429).header("Retry-After", "30");
259            })
260            .await;
261        let client = client_against(&server.base_url());
262        let r = client
263            .test(&SecretString::from("over-limit".to_owned()))
264            .await
265            .unwrap();
266        assert_eq!(r.status, LivenessStatus::Throttled);
267        assert!(r.detail.unwrap().contains("30"));
268    }
269
270    #[tokio::test]
271    async fn unexpected_status_classified_as_error() {
272        let server = MockServer::start_async().await;
273        let _m = server
274            .mock_async(|when, then| {
275                when.method(GET).path("/api/v4/personal_access_tokens/self");
276                then.status(503).body("upstream down");
277            })
278            .await;
279        let client = client_against(&server.base_url());
280        let r = client
281            .test(&SecretString::from("any".to_owned()))
282            .await
283            .unwrap();
284        assert_eq!(r.status, LivenessStatus::Error);
285    }
286
287    #[test]
288    fn provider_name_is_gitlab() {
289        let client = GitLabClient::new("1", SecretString::from("any".to_owned()));
290        assert_eq!(client.provider_name(), "gitlab");
291    }
292}