devboy_gitlab/
liveness.rs1use 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 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#[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 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}