1use std::collections::HashMap;
6use std::fmt;
7use std::sync::{Arc, Mutex};
8use std::time::{Duration, SystemTime};
9
10use reqwest::header::{self, HeaderMap, HeaderValue};
11use reqwest::{Method, StatusCode};
12use serde::Deserialize;
13use serde::de::DeserializeOwned;
14
15use crate::docker::JitConfig;
16use crate::sync::lock;
17use crate::time;
18
19pub const API_URL: &str = "https://api.github.com";
21
22const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
25
26#[derive(Clone)]
28pub struct Token(String);
29
30impl fmt::Debug for Token {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 f.write_str("Token(<redacted>)")
33 }
34}
35
36pub type TokenSource = Box<dyn Fn() -> Result<Token, Error> + Send + Sync>;
38
39pub fn gh_token() -> Result<Token, Error> {
41 let output = std::process::Command::new("gh")
42 .args(["auth", "token", "--hostname", "github.com"])
43 .output()
44 .map_err(|error| Error::Token(format!("cannot run gh: {error}")))?;
45 if !output.status.success() {
46 return Err(Error::Token(format!(
47 "gh auth token failed: {}",
48 String::from_utf8_lossy(&output.stderr).trim()
49 )));
50 }
51 let token = String::from_utf8(output.stdout)
52 .map_err(|_| Error::Token("gh auth token printed a token that is not UTF-8".to_owned()))?;
53 Ok(Token(token.trim().to_owned()))
54}
55
56pub fn install_crypto_provider() {
59 let _ = rustls::crypto::ring::default_provider().install_default();
61}
62
63#[derive(Debug, thiserror::Error)]
65pub enum Error {
66 #[error("GitHub's rate limit is used up until {}s after the epoch", secs(*until))]
68 RateLimited {
69 until: SystemTime,
71 },
72 #[error("GitHub answered {status} to {method} {path}: {message}")]
74 Status {
75 method: Method,
77 path: String,
79 status: StatusCode,
81 message: String,
83 },
84 #[error("{method} {path}: {source}")]
86 Http {
87 method: Method,
89 path: String,
91 source: reqwest::Error,
93 },
94 #[error("{path}: unexpected response: {source}")]
96 Decode {
97 path: String,
99 source: serde_json::Error,
101 },
102 #[error("no GitHub token: {0}")]
104 Token(String),
105}
106
107fn secs(time: SystemTime) -> u64 {
108 time.duration_since(SystemTime::UNIX_EPOCH)
109 .unwrap_or_default()
110 .as_secs()
111}
112
113#[derive(Debug, Clone, Default, PartialEq, Eq)]
115pub struct QueuedJob {
116 pub id: u64,
118 pub run_id: u64,
120 pub name: String,
122 pub labels: Vec<String>,
124 pub created_at: u64,
126 pub run_attempt: u32,
128 pub workflow: Option<String>,
130 pub branch: Option<String>,
132 pub url: Option<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
138pub struct JobDetails {
139 pub id: u64,
141 pub run_id: u64,
143 #[serde(default = "first_attempt")]
145 pub run_attempt: u32,
146 pub name: String,
148 pub workflow_name: Option<String>,
150 pub head_branch: Option<String>,
152 pub html_url: Option<String>,
154 #[serde(default)]
156 pub labels: Vec<String>,
157 pub created_at: Option<String>,
159 pub status: String,
161 pub conclusion: Option<String>,
163 pub started_at: Option<String>,
165 pub completed_at: Option<String>,
167 pub runner_name: Option<String>,
169 #[serde(default)]
171 pub steps: Vec<JobStep>,
172}
173
174fn first_attempt() -> u32 {
175 1
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
180pub struct JobStep {
181 pub number: u32,
183 pub name: String,
185 pub status: String,
187 pub conclusion: Option<String>,
189 pub started_at: Option<String>,
191 pub completed_at: Option<String>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
197pub struct Runner {
198 pub id: u64,
200 pub name: String,
202 pub status: String,
204 pub busy: bool,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Deleted {
211 Deleted,
213 NotFound,
215 Busy,
217}
218
219pub trait GithubApi: Send + Sync {
221 fn queued_jobs(
223 &self,
224 repository: &str,
225 ) -> impl Future<Output = Result<Vec<QueuedJob>, Error>> + Send;
226 fn runners(&self, repository: &str) -> impl Future<Output = Result<Vec<Runner>, Error>> + Send;
228 fn jit_config(
230 &self,
231 repository: &str,
232 name: &str,
233 labels: &[String],
234 ) -> impl Future<Output = Result<(Runner, JitConfig), Error>> + Send;
235 fn delete_runner(
237 &self,
238 repository: &str,
239 id: u64,
240 ) -> impl Future<Output = Result<Deleted, Error>> + Send;
241 fn job(
243 &self,
244 repository: &str,
245 id: u64,
246 ) -> impl Future<Output = Result<JobDetails, Error>> + Send;
247 fn cancel_run(
250 &self,
251 repository: &str,
252 run: u64,
253 ) -> impl Future<Output = Result<(), Error>> + Send;
254}
255
256pub struct Github {
259 http: reqwest::Client,
260 base: String,
261 token_source: Arc<dyn Fn() -> Result<Token, Error> + Send + Sync>,
263 token: tokio::sync::Mutex<Option<Token>>,
266 etags: Mutex<HashMap<String, (HeaderValue, Vec<u8>)>>,
268 blocked_until: Mutex<Option<SystemTime>>,
269}
270
271impl fmt::Debug for Github {
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273 f.debug_struct("Github")
274 .field("base", &self.base)
275 .finish_non_exhaustive()
276 }
277}
278
279#[derive(Deserialize)]
280struct Runs {
281 workflow_runs: Vec<Run>,
282}
283
284#[derive(Deserialize)]
285struct Run {
286 id: u64,
287}
288
289#[derive(Deserialize)]
290struct Jobs {
291 jobs: Vec<Job>,
292}
293
294#[derive(Deserialize)]
295struct Job {
296 id: u64,
297 run_id: u64,
298 name: String,
299 status: String,
300 labels: Vec<String>,
301 created_at: Option<String>,
302 run_attempt: Option<u32>,
303 workflow_name: Option<String>,
304 head_branch: Option<String>,
305 html_url: Option<String>,
306}
307
308#[derive(Deserialize)]
309struct Runners {
310 runners: Vec<Runner>,
311}
312
313#[derive(Deserialize)]
314struct JitResponse {
315 runner: Runner,
316 encoded_jit_config: String,
317}
318
319#[derive(Deserialize)]
320struct ErrorBody {
321 message: String,
322}
323
324impl Github {
325 pub fn new(base: &str, token_source: TokenSource) -> Result<Github, Error> {
327 install_crypto_provider();
328 let mut headers = HeaderMap::new();
329 headers.insert(
330 header::ACCEPT,
331 HeaderValue::from_static("application/vnd.github+json"),
332 );
333 headers.insert(
334 "x-github-api-version",
335 HeaderValue::from_static("2022-11-28"),
336 );
337 let http = reqwest::Client::builder()
338 .user_agent(concat!("runnerd/", env!("CARGO_PKG_VERSION")))
339 .default_headers(headers)
340 .timeout(REQUEST_TIMEOUT)
341 .build()
342 .map_err(|source| Error::Http {
343 method: Method::GET,
344 path: base.to_owned(),
345 source,
346 })?;
347 Ok(Github {
348 http,
349 base: base.trim_end_matches('/').to_owned(),
350 token_source: Arc::from(token_source),
351 token: tokio::sync::Mutex::new(None),
352 etags: Mutex::new(HashMap::new()),
353 blocked_until: Mutex::new(None),
354 })
355 }
356
357 pub fn blocked_until(&self) -> Option<SystemTime> {
359 let blocked = *lock(&self.blocked_until);
360 blocked.filter(|until| *until > SystemTime::now())
361 }
362
363 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
364 let response = self.send(Method::GET, path, None).await?;
365 let bytes = if response.status == StatusCode::NOT_MODIFIED {
366 let cached = lock(&self.etags).get(path).map(|(_, body)| body.clone());
367 match cached {
368 Some(body) => body,
369 None => {
370 return Err(Error::Status {
371 method: Method::GET,
372 path: path.to_owned(),
373 status: response.status,
374 message: "not modified, but nothing is cached".to_owned(),
375 });
376 }
377 }
378 } else {
379 let etag = response.etag.clone();
380 let response = expect_success(Method::GET, path, response)?;
381 if let Some(etag) = etag {
382 lock(&self.etags).insert(path.to_owned(), (etag, response.bytes.clone()));
383 }
384 response.bytes
385 };
386 decode(path, &bytes)
387 }
388
389 async fn send(
391 &self,
392 method: Method,
393 path: &str,
394 body: Option<&serde_json::Value>,
395 ) -> Result<Response, Error> {
396 if let Some(until) = self.blocked_until() {
397 return Err(Error::RateLimited { until });
398 }
399 let mut refused: Option<Token> = None;
401 loop {
402 let token = self.token(refused.as_ref()).await?;
403 let mut request = self
404 .http
405 .request(method.clone(), format!("{}{path}", self.base))
406 .bearer_auth(&token.0);
407 if let Some(body) = body {
408 request = request
409 .header(header::CONTENT_TYPE, "application/json")
410 .body(body.to_string());
411 }
412 if method == Method::GET
413 && let Some((etag, _)) = lock(&self.etags).get(path)
414 {
415 request = request.header(header::IF_NONE_MATCH, etag.clone());
416 }
417 let response = request.send().await.map_err(|source| Error::Http {
418 method: method.clone(),
419 path: path.to_owned(),
420 source: source.without_url(),
421 })?;
422 let status = response.status();
423 if status == StatusCode::UNAUTHORIZED && refused.is_none() {
424 refused = Some(token);
425 continue;
426 }
427 if let Some(until) = rate_limited_until(status, response.headers(), SystemTime::now()) {
428 *lock(&self.blocked_until) = Some(until);
429 if matches!(
430 status,
431 StatusCode::FORBIDDEN | StatusCode::TOO_MANY_REQUESTS
432 ) {
433 return Err(Error::RateLimited { until });
434 }
435 }
436 let etag = response.headers().get(header::ETAG).cloned();
437 let bytes = response.bytes().await.map_err(|source| Error::Http {
438 method: method.clone(),
439 path: path.to_owned(),
440 source: source.without_url(),
441 })?;
442 return Ok(Response {
443 status,
444 etag,
445 bytes: bytes.to_vec(),
446 });
447 }
448 }
449
450 async fn token(&self, refused: Option<&Token>) -> Result<Token, Error> {
453 let mut token = self.token.lock().await;
454 let stale = match (&*token, refused) {
455 (None, _) => true,
456 (Some(current), Some(refused)) => current.0 == refused.0,
457 (Some(_), None) => false,
458 };
459 if stale {
460 let source = self.token_source.clone();
461 *token = Some(
462 tokio::task::spawn_blocking(move || source())
463 .await
464 .map_err(|error| {
465 Error::Token(format!("the token source stopped: {error}"))
466 })??,
467 );
468 }
469 token
470 .clone()
471 .ok_or_else(|| Error::Token("the token source returned nothing".to_owned()))
472 }
473}
474
475impl GithubApi for Github {
476 async fn queued_jobs(&self, repository: &str) -> Result<Vec<QueuedJob>, Error> {
477 let mut jobs = Vec::new();
478 let mut listed = Vec::new();
479 for status in ["queued", "in_progress"] {
480 let runs: Runs = self
481 .get(&format!(
482 "/repos/{repository}/actions/runs?status={status}&per_page=100"
483 ))
484 .await?;
485 for run in runs.workflow_runs {
486 let path = jobs_path(repository, run.id);
487 let run_jobs: Jobs = self.get(&path).await?;
488 listed.push(path);
489 jobs.extend(
490 run_jobs
491 .jobs
492 .into_iter()
493 .filter(|job| job.status == "queued")
494 .map(|job| QueuedJob {
495 id: job.id,
496 run_id: job.run_id,
497 name: job.name,
498 labels: job.labels,
499 created_at: job
500 .created_at
501 .as_deref()
502 .and_then(time::parse_ms)
503 .unwrap_or(0),
504 run_attempt: job.run_attempt.unwrap_or(1),
505 workflow: job.workflow_name,
506 branch: job.head_branch,
507 url: job.html_url,
508 }),
509 );
510 }
511 }
512 let runs_prefix = format!("/repos/{repository}/actions/runs/");
514 lock(&self.etags).retain(|path, _| {
515 !(path.starts_with(&runs_prefix) && path.contains("/jobs?")) || listed.contains(path)
516 });
517 jobs.sort_by_key(|job| job.id);
518 jobs.dedup_by_key(|job| job.id);
519 Ok(jobs)
520 }
521
522 async fn runners(&self, repository: &str) -> Result<Vec<Runner>, Error> {
523 let runners: Runners = self
524 .get(&format!("/repos/{repository}/actions/runners?per_page=100"))
525 .await?;
526 Ok(runners.runners)
527 }
528
529 async fn jit_config(
530 &self,
531 repository: &str,
532 name: &str,
533 labels: &[String],
534 ) -> Result<(Runner, JitConfig), Error> {
535 let body = serde_json::json!({
536 "name": name,
537 "runner_group_id": 1,
539 "labels": labels,
540 "work_folder": "_work",
541 });
542 let path = format!("/repos/{repository}/actions/runners/generate-jitconfig");
543 let response = self.send(Method::POST, &path, Some(&body)).await?;
544 let response = expect_success(Method::POST, &path, response)?;
545 let jit: JitResponse = decode(&path, &response.bytes)?;
546 Ok((jit.runner, JitConfig(jit.encoded_jit_config)))
547 }
548
549 async fn delete_runner(&self, repository: &str, id: u64) -> Result<Deleted, Error> {
550 let path = format!("/repos/{repository}/actions/runners/{id}");
551 let response = self.send(Method::DELETE, &path, None).await?;
552 match response.status {
553 StatusCode::NOT_FOUND => Ok(Deleted::NotFound),
554 StatusCode::UNPROCESSABLE_ENTITY => Ok(Deleted::Busy),
555 _ => expect_success(Method::DELETE, &path, response).map(|_| Deleted::Deleted),
556 }
557 }
558
559 async fn job(&self, repository: &str, id: u64) -> Result<JobDetails, Error> {
560 self.get(&format!("/repos/{repository}/actions/jobs/{id}"))
561 .await
562 }
563
564 async fn cancel_run(&self, repository: &str, run: u64) -> Result<(), Error> {
565 let path = format!("/repos/{repository}/actions/runs/{run}/cancel");
566 let response = self.send(Method::POST, &path, None).await?;
567 if response.status == StatusCode::CONFLICT {
569 return Ok(());
570 }
571 expect_success(Method::POST, &path, response).map(|_| ())
572 }
573}
574
575fn jobs_path(repository: &str, run: u64) -> String {
576 format!("/repos/{repository}/actions/runs/{run}/jobs?filter=latest&per_page=100")
577}
578
579struct Response {
580 status: StatusCode,
581 etag: Option<HeaderValue>,
582 bytes: Vec<u8>,
583}
584
585fn expect_success(method: Method, path: &str, response: Response) -> Result<Response, Error> {
586 if response.status.is_success() {
587 return Ok(response);
588 }
589 let message = serde_json::from_slice::<ErrorBody>(&response.bytes).map_or_else(
590 |_| String::from_utf8_lossy(&response.bytes).into_owned(),
591 |body| body.message,
592 );
593 Err(Error::Status {
594 method,
595 path: path.to_owned(),
596 status: response.status,
597 message,
598 })
599}
600
601fn decode<T: DeserializeOwned>(path: &str, bytes: &[u8]) -> Result<T, Error> {
602 serde_json::from_slice(bytes).map_err(|source| Error::Decode {
603 path: path.to_owned(),
604 source,
605 })
606}
607
608fn rate_limited_until(
611 status: StatusCode,
612 headers: &HeaderMap,
613 now: SystemTime,
614) -> Option<SystemTime> {
615 let number = |name: &str| {
616 headers
617 .get(name)
618 .and_then(|value| value.to_str().ok())
619 .and_then(|value| value.trim().parse::<u64>().ok())
620 };
621 if number("x-ratelimit-remaining") == Some(0)
622 && let Some(reset) = number("x-ratelimit-reset")
623 {
624 return Some(SystemTime::UNIX_EPOCH + Duration::from_secs(reset));
625 }
626 if matches!(
627 status,
628 StatusCode::FORBIDDEN | StatusCode::TOO_MANY_REQUESTS
629 ) && let Some(seconds) = number("retry-after")
630 {
631 return Some(now + Duration::from_secs(seconds));
632 }
633 None
634}
635
636#[cfg(test)]
637mod tests;