torii_lib/platforms/gitlab/
runner.rs1use crate::error::{Result, ToriiError};
4use crate::platforms::runner::*;
5use reqwest::blocking::Client;
6
7pub struct GitLabRunnerClient {
8 token: String,
9 pub(crate) base_url: String,
10}
11
12impl GitLabRunnerClient {
13 pub fn new() -> Result<Self> {
14 let token = crate::auth::resolve_token("gitlab", ".")
15 .value
16 .ok_or_else(|| ToriiError::Auth {
17 provider: "gitlab".into(),
18 message: "GitLab token not found. Run: torii auth oauth gitlab".to_string(),
19 })?;
20 Ok(Self {
21 token,
22 base_url: "https://gitlab.com/api/v4".to_string(),
23 })
24 }
25
26 pub(crate) fn client(&self) -> Client {
27 crate::http::make_client()
28 }
29 pub(crate) fn auth(&self) -> String {
30 format!("Bearer {}", self.token)
31 }
32
33 fn project_path(owner: &str, repo: &str) -> String {
34 crate::url::encode(&format!("{}/{}", owner, repo))
35 }
36}
37
38impl RunnerClient for GitLabRunnerClient {
39 fn list(&self, owner: &str, repo: &str) -> Result<Vec<Runner>> {
40 let url = format!(
41 "{}/projects/{}/runners?per_page=100",
42 self.base_url,
43 Self::project_path(owner, repo)
44 );
45 let req = self.client().get(&url).header("Authorization", self.auth());
46 let json = crate::http::send_json(req, &format!("GitLab (url: {})", url))?;
47 crate::http::extract_array(&json, &url)?
48 .iter()
49 .map(parse_gitlab_runner)
50 .collect()
51 }
52
53 fn show(&self, _owner: &str, _repo: &str, id: &str) -> Result<Runner> {
54 let url = format!("{}/runners/{}", self.base_url, id);
55 let req = self.client().get(&url).header("Authorization", self.auth());
56 let json = crate::http::send_json(req, &format!("GitLab (url: {})", url))?;
57 parse_gitlab_runner(&json)
58 }
59
60 fn remove(&self, _owner: &str, _repo: &str, id: &str) -> Result<()> {
61 let url = format!("{}/runners/{}", self.base_url, id);
62 let req = self
63 .client()
64 .delete(&url)
65 .header("Authorization", self.auth());
66 crate::http::send_empty(req, "GitLab delete runner")
67 }
68
69 fn reset_token(&self, _owner: &str, _repo: &str, id: &str) -> Result<String> {
70 let url = format!(
71 "{}/runners/{}/reset_authentication_token",
72 self.base_url, id
73 );
74 let req = self
75 .client()
76 .post(&url)
77 .header("Authorization", self.auth());
78 let json = crate::http::send_json(req, "GitLab reset runner token")?;
79 Ok(json["token"]
80 .as_str()
81 .ok_or_else(|| ToriiError::Auth {
82 provider: "gitlab".into(),
83 message: format!(
84 "GitLab returned no `token` field in reset response: {}",
85 json
86 ),
87 })?
88 .to_string())
89 }
90
91 fn pause(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
92 set_paused(self, owner, repo, id, true)
93 }
94 fn resume(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
95 set_paused(self, owner, repo, id, false)
96 }
97
98 fn registration_token(&self, owner: &str, repo: &str) -> Result<RegistrationToken> {
99 let url = format!(
104 "{}/projects/{}",
105 self.base_url,
106 Self::project_path(owner, repo)
107 );
108 let req = self.client().get(&url).header("Authorization", self.auth());
109 let json = crate::http::send_json(req, &format!("GitLab (url: {})", url))?;
110 let token = json["runners_token"]
111 .as_str()
112 .ok_or_else(|| ToriiError::MalformedResponse {
113 provider: "gitlab".into(),
114 message: format!(
115 "GitLab project response missing `runners_token`. \
116 The token API needs Maintainer+ on the project. Body: {}",
117 json
118 ),
119 })?
120 .to_string();
121 Ok(RegistrationToken {
122 token,
123 register_url: "https://gitlab.com".to_string(),
124 expires_in_seconds: None,
125 })
126 }
127}
128
129fn parse_gitlab_runner(v: &serde_json::Value) -> Result<Runner> {
130 let id = v["id"]
131 .as_u64()
132 .ok_or_else(|| ToriiError::MalformedResponse {
133 provider: "gitlab".into(),
134 message: format!("GitLab runner has no `id`: {}", v),
135 })?
136 .to_string();
137 let raw_status = v["status"].as_str().unwrap_or("").to_string();
138 let paused = v["paused"].as_bool().unwrap_or(false);
139 let status = if paused {
140 "paused".to_string()
141 } else {
142 raw_status
143 };
144
145 let tags = v["tag_list"]
146 .as_array()
147 .map(|a| {
148 a.iter()
149 .filter_map(|t| t.as_str().map(String::from))
150 .collect()
151 })
152 .unwrap_or_default();
153
154 Ok(Runner {
155 id,
156 description: v["description"].as_str().unwrap_or("").to_string(),
157 status,
158 paused,
159 ip_address: v["ip_address"].as_str().unwrap_or("").to_string(),
160 os: v["platform"].as_str().unwrap_or("").to_string(),
161 tags,
162 version: v["version"].as_str().unwrap_or("").to_string(),
163 runner_type: v["runner_type"].as_str().unwrap_or("").to_string(),
164 web_url: v["web_url"].as_str().unwrap_or("").to_string(),
165 })
166}
167
168#[cfg(test)]
173mod tests {
174 use super::*;
175 use httpmock::prelude::*;
176
177 #[test]
180 fn parse_gitlab_runner_full() {
181 let json = serde_json::json!({
182 "id": 77u64,
183 "description": "shared-runner-1",
184 "status": "online",
185 "paused": false,
186 "ip_address": "10.0.0.5",
187 "platform": "linux",
188 "tag_list": ["docker", "amd64"],
189 "version": "17.1.0",
190 "runner_type": "project_type",
191 "web_url": "https://gitlab.com/acme/widget/-/runners/77"
192 });
193 let r = parse_gitlab_runner(&json).unwrap();
194 assert_eq!(r.id, "77");
195 assert_eq!(r.description, "shared-runner-1");
196 assert_eq!(r.status, "online");
197 assert!(!r.paused);
198 assert_eq!(r.ip_address, "10.0.0.5");
199 assert_eq!(r.os, "linux");
200 assert_eq!(r.tags, vec!["docker", "amd64"]);
201 assert_eq!(r.version, "17.1.0");
202 assert_eq!(r.runner_type, "project_type");
203 assert_eq!(r.web_url, "https://gitlab.com/acme/widget/-/runners/77");
204 }
205
206 #[test]
207 fn parse_gitlab_runner_paused_overrides_status() {
208 let json = serde_json::json!({ "id": 1u64, "status": "online", "paused": true });
209 let r = parse_gitlab_runner(&json).unwrap();
210 assert_eq!(r.status, "paused");
211 assert!(r.paused);
212 assert!(r.tags.is_empty());
213 }
214
215 #[test]
216 fn parse_gitlab_runner_missing_id_is_malformed_response() {
217 let err = parse_gitlab_runner(&serde_json::json!({ "status": "online" })).unwrap_err();
218 assert!(
219 matches!(err, ToriiError::MalformedResponse { .. }),
220 "expected MalformedResponse, got: {err:?}"
221 );
222 }
223
224 fn client(server: &MockServer) -> GitLabRunnerClient {
227 GitLabRunnerClient {
228 token: "test-token".into(),
229 base_url: server.base_url(),
230 }
231 }
232
233 #[test]
234 fn list_parses_runners() {
235 let server = MockServer::start();
236 let m = server.mock(|when, then| {
237 when.method(GET)
238 .path("/projects/acme%2Fwidget/runners")
239 .header("Authorization", "Bearer test-token");
240 then.status(200).json_body(serde_json::json!([
241 { "id": 77u64, "description": "r1", "status": "online", "paused": false },
242 { "id": 78u64, "description": "r2", "status": "offline", "paused": true }
243 ]));
244 });
245 let runners = client(&server).list("acme", "widget").unwrap();
246 m.assert();
247 assert_eq!(runners.len(), 2);
248 assert_eq!(runners[0].id, "77");
249 assert_eq!(runners[1].status, "paused");
250 }
251
252 #[test]
253 fn pause_puts_paused_true_with_bearer_auth() {
254 let server = MockServer::start();
255 let m = server.mock(|when, then| {
256 when.method(PUT)
257 .path("/runners/77")
258 .header("Authorization", "Bearer test-token")
259 .json_body(serde_json::json!({ "paused": true }));
260 then.status(200);
261 });
262 client(&server).pause("acme", "widget", "77").unwrap();
263 m.assert();
264 }
265
266 #[test]
267 fn reset_token_returns_new_token() {
268 let server = MockServer::start();
269 server.mock(|when, then| {
270 when.method(POST)
271 .path("/runners/77/reset_authentication_token")
272 .header("Authorization", "Bearer test-token");
273 then.status(201)
274 .json_body(serde_json::json!({ "token": "glrt-new-token" }));
275 });
276 let token = client(&server).reset_token("acme", "widget", "77").unwrap();
277 assert_eq!(token, "glrt-new-token");
278 }
279
280 #[test]
281 fn registration_token_missing_field_is_malformed_response() {
282 let server = MockServer::start();
283 server.mock(|when, then| {
284 when.method(GET).path("/projects/acme%2Fwidget");
285 then.status(200)
286 .json_body(serde_json::json!({ "id": 1u64 }));
287 });
288 let err = client(&server)
289 .registration_token("acme", "widget")
290 .unwrap_err();
291 assert!(
292 matches!(err, ToriiError::MalformedResponse { .. }),
293 "expected MalformedResponse, got: {err:?}"
294 );
295 }
296
297 #[test]
298 fn show_non_2xx_maps_to_platform_api_error() {
299 let server = MockServer::start();
300 server.mock(|when, then| {
301 when.method(GET).path("/runners/404");
302 then.status(404)
303 .json_body(serde_json::json!({ "message": "404 Not Found" }));
304 });
305 let err = client(&server).show("acme", "widget", "404").unwrap_err();
306 assert!(
307 matches!(err, ToriiError::PlatformApi { .. }),
308 "expected PlatformApi, got: {err:?}"
309 );
310 }
311}