Skip to main content

torii_lib/platforms/gitlab/
runner.rs

1//! GitLab — runner client.
2
3use 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        // GitLab returns the project's `runners_token` as part of the
100        // project payload. Requires Maintainer+ on the project. The
101        // token doesn't expire on its own (only when explicitly reset
102        // from the project settings).
103        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// ============================================================================
169// GitHub Actions (self-hosted)
170// ============================================================================
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use httpmock::prelude::*;
176
177    // ── parser ───────────────────────────────────────────────────────────
178
179    #[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    // ── client (httpmock) ────────────────────────────────────────────────
225
226    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}