Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
87 linesCodeBlameRaw
1//! What a self-hosted runner calls: registering with a registration token,
2//! then polling for work, saying how work ended and removing itself, with
3//! its own credential. Neither is a g1t access token, so these are served
4//! before anything reads one, and no other endpoint accepts either.
5//!
6//! | Route | Body |
7//! | --- | --- |
8//! | `POST /runners/register` | `token`, `name`, `labels`, `os`, `arch`, `version`, `ephemeral`, `group`, `replace`, `isolation`, `machine` |
9//! | `POST /runners/{id}/poll` | `version`, `running`, `wait_ms` |
10//! | `POST /runners/{id}/finished` | `id`, `exit_code`, `reason` |
11//! | `POST /runners/{id}/remove` | nothing |
12//!
13//! The last three send `Authorization: Bearer g1tr_…`. See
14//! `g1t_contracts::runners` for what each answers.
15
16use g1t_contracts::Outcome;
17use g1t_kit::wire;
18use serde_json::{Value, json};
19use worker::{Request, Response, Result};
20
21use crate::operations::Services;
22use crate::{fail, failure, json_body};
23
24/// What is passed through as given: an agent task's environment.
25const AS_GIVEN: &[&str] = &["env"];
26
27fn credential(request: &Request) -> Result<String> {
28 let header = request.headers().get("authorization")?.unwrap_or_default();
29 Ok(match header.split_once(' ') {
30 Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") => token.trim().to_owned(),
31 _ => String::new(),
32 })
33}
34
35async fn answer(services: &Services, method: &str, args: &Value) -> Result<Response> {
36 let answered: Outcome<Value> = g1t_kit::call(&services.actions, method, args).await?;
37 match answered {
38 Outcome::Ok(value) => Response::from_json(&wire::snake_case_keeping(value, AS_GIVEN)),
39 Outcome::Fail(refused) => failure(&refused),
40 }
41}
42
43/// Serves `path` if it is one of the runner's routes.
44pub async fn handle(request: &mut Request, services: &Services, path: &str) -> Result<Option<Response>> {
45 let Some(rest) = path.strip_prefix("/runners/") else { return Ok(None) };
46 let body = json_body(request).await;
47 let body = if body.is_object() { body } else { json!({}) };
48 if rest == "register" {
49 let args = json!({
50 "token": body["token"].as_str().unwrap_or_default(),
51 "name": body["name"].as_str().unwrap_or_default(),
52 "labels": body["labels"].as_array().cloned().unwrap_or_default(),
53 "os": body["os"].as_str().unwrap_or_default(),
54 "arch": body["arch"].as_str().unwrap_or_default(),
55 "version": body["version"].as_str().unwrap_or_default(),
56 "ephemeral": body["ephemeral"].as_bool().unwrap_or(false),
57 "group": body["group"].as_str(),
58 "replace": body["replace"].as_bool().unwrap_or(false),
59 "isolation": body["isolation"].as_str(),
60 "machine": body["machine"].as_str(),
61 });
62 return Ok(Some(answer(services, "runner_register", &args).await?));
63 }
64 let Some((runner, action)) = rest.split_once('/') else {
65 return Ok(Some(fail(g1t_contracts::FailureCode::NotFound, "No such endpoint.")?));
66 };
67 let auth = json!({ "runner": runner, "credential": credential(request)? });
68 let mut args = auth;
69 let method = match action {
70 "poll" => {
71 args["version"] = json!(body["version"].as_str().unwrap_or_default());
72 args["running"] = json!(body["running"].as_array().cloned().unwrap_or_default());
73 args["wait_ms"] = json!(body["wait_ms"].as_u64().unwrap_or(0));
74 args["isolation"] = json!(body["isolation"].as_str());
75 "runner_poll"
76 }
77 "finished" => {
78 args["id"] = json!(body["id"].as_str().unwrap_or_default());
79 args["exit_code"] = json!(body["exit_code"].as_i64().unwrap_or(1));
80 args["reason"] = body["reason"].clone();
81 "runner_finished"
82 }
83 "remove" => "runner_remove_self",
84 _ => return Ok(Some(fail(g1t_contracts::FailureCode::NotFound, "No such endpoint.")?)),
85 };
86 Ok(Some(answer(services, method, &args).await?))
87}