Skip to content
151 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Fast pages, required checks on the branch, self-hosted runners, honest incidents1//! The runner's calls to g1t: outbound HTTPS only, JSON in `snake_case`.
2//! See `g1t_contracts::runners` for the shapes.
3
4use std::time::Duration;
5
6use anyhow::{Result, anyhow};
7use serde::Deserialize;
8use serde_json::{Map, Value, json};
9
10use super::VERSION;
11
12#[derive(Clone, Debug, Deserialize)]
13pub struct Assignment {
14 pub kind: String,
15 pub id: String,
16 pub name: String,
17 pub repo: String,
18 pub timeout_minutes: u32,
19 #[serde(default)]
20 pub image: Option<String>,
21 #[serde(default)]
22 pub token: Option<String>,
23 #[serde(default)]
24 pub env: Option<Map<String, Value>>,
The self-hosted runner runs as a container under Sysbox, with a Docker Engine of its own inside, and each job runs in a fresh container on that engine, so neither the runner nor its jobs reach the machine: `docker run --runtime=sysbox-runc -v g1t-runner:/data -e G1T_RUNNER_URL=… -e G1T_RUNNER_TOKEN=… g1t.sh/flagon-io/g1t-runner`, with docker-init as PID 1 supervising g1t-runner and g1t-runner supervising dockerd. The image refuses to start when it can see the machine's Docker socket mounted in, an engine socket it did not make, DOCKER_HOST, or a root that is the machine's (no Sysbox, or --privileged), unless G1T_RUNNER_UNSAFE_ALLOW_HOST_ACCESS=1 is set, and its log says which isolation it has: nested-container, container or host, which it now also tells g1t when it registers (kept on the runner, shown nowhere yet). Every job container drops all capabilities but eight it needs, has no-new-privileges (agent work keeps sudo for its guardrails), a process, memory and CPU limit (--pids-limit, --memory, --cpus, with defaults), a network and a /home/runner volume of its own removed after it, and no mount but the read-only harness; a job that uses Docker gets an engine of its own beside it, started the first time it is used and removed after, unless the runner is registered with --no-job-docker. --no-docker keeps working but is deprecated: the runner says so when it starts, every job's log starts with a warning that it ran on the host unsandboxed, and it refuses a public repository's work now that g1t says a job's repository visibility. Windows and macOS sandboxes (Hyper-V-isolated containers, ephemeral macOS VMs) are designed and not built. The runner is 0.2.0; the self-hosted runners guide, the limitations page and the New runner command say so.25 /// `public` or `private`; none from an older g1t.
26 #[serde(default)]
27 pub visibility: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents28}
29
30#[derive(Debug, Default, Deserialize)]
31pub struct Poll {
32 #[serde(default)]
33 pub assignment: Option<Assignment>,
34 #[serde(default)]
35 pub cancel: Vec<String>,
36 #[serde(default)]
37 pub credential: Option<String>,
38 #[serde(default)]
39 pub removed: bool,
40}
41
42#[derive(Debug, Deserialize)]
43pub struct RegisteredRunner {
44 pub id: String,
45 pub name: String,
46 pub workspace: String,
47 #[serde(default)]
48 pub repo: Option<String>,
49 #[serde(default)]
50 pub group: Option<String>,
51 pub labels: Vec<String>,
52}
53
54#[derive(Debug, Deserialize)]
55pub struct Registered {
56 pub runner: RegisteredRunner,
57 pub credential: String,
58}
59
60/// How a call failed: g1t refused it (with its status and message), or it
61/// did not get through.
62#[derive(Debug)]
63pub enum Failure {
64 Refused(u16, String),
65 Unreachable(String),
66}
67
68impl std::fmt::Display for Failure {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 Failure::Refused(status, message) => write!(f, "{message} ({status})"),
72 Failure::Unreachable(why) => write!(f, "could not reach g1t: {why}"),
73 }
74 }
75}
76
77fn agent() -> ureq::Agent {
78 ureq::AgentBuilder::new()
79 .timeout_connect(Duration::from_secs(15))
80 .timeout_read(Duration::from_secs(60))
81 .user_agent(&format!("g1t-runner/{VERSION} ({}; {})", std::env::consts::OS, std::env::consts::ARCH))
82 .build()
83}
84
85fn post(url: &str, credential: Option<&str>, body: &Value) -> std::result::Result<Value, Failure> {
86 let mut request = agent().post(url);
87 if let Some(credential) = credential {
88 request = request.set("authorization", &format!("Bearer {credential}"));
89 }
90 match request.send_json(body.clone()) {
91 Ok(response) => response.into_json::<Value>().map_err(|e| Failure::Unreachable(e.to_string())),
92 Err(ureq::Error::Status(status, response)) => {
93 let text = response.into_string().unwrap_or_default();
94 let message = serde_json::from_str::<Value>(&text)
95 .ok()
96 .and_then(|v| v["error"]["message"].as_str().map(str::to_owned))
97 .unwrap_or(text);
98 Err(Failure::Refused(status, message))
99 }
100 Err(error) => Err(Failure::Unreachable(error.to_string())),
101 }
102}
103
104pub struct Api {
105 pub base: String,
106 pub runner: String,
107 pub credential: String,
108}
109
110impl Api {
111 pub fn register(base: &str, body: &Value) -> Result<Registered> {
112 let answer = post(&format!("{base}/runners/register"), None, body).map_err(|failure| anyhow!("{failure}"))?;
113 Ok(serde_json::from_value(answer)?)
114 }
115
116 pub fn poll(&self, running: &[String], wait_ms: u64) -> std::result::Result<Poll, Failure> {
117 let body = json!({ "version": VERSION, "running": running, "wait_ms": wait_ms });
118 let answer = post(&format!("{}/runners/{}/poll", self.base, self.runner), Some(&self.credential), &body)?;
119 serde_json::from_value(answer).map_err(|e| Failure::Unreachable(e.to_string()))
120 }
121
122 pub fn finished(&self, id: &str, exit_code: i32, reason: Option<&str>) -> std::result::Result<(), Failure> {
123 let body = json!({ "id": id, "exit_code": exit_code, "reason": reason });
124 // Tried a few times: a job that crashed should not wait for the
125 // silence check to be noticed.
126 let mut last = None;
127 for attempt in 0..3 {
128 match post(&format!("{}/runners/{}/finished", self.base, self.runner), Some(&self.credential), &body) {
129 Ok(_) => return Ok(()),
130 Err(Failure::Refused(status, message)) => return Err(Failure::Refused(status, message)),
131 Err(failure) => {
132 last = Some(failure);
133 std::thread::sleep(Duration::from_secs(2 * (attempt + 1)));
134 }
135 }
136 }
137 Err(last.unwrap_or(Failure::Unreachable("no answer".into())))
138 }
139
140 pub fn remove(&self) -> std::result::Result<(), Failure> {
141 post(&format!("{}/runners/{}/remove", self.base, self.runner), Some(&self.credential), &json!({})).map(|_| ())
142 }
143}
144
145/// Fetches a file's bytes.
146pub fn download(url: &str) -> Result<Vec<u8>> {
147 let response = agent().get(url).timeout(Duration::from_secs(300)).call().map_err(|e| anyhow!("could not download {url}: {e}"))?;
148 let mut bytes = Vec::new();
149 std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)?;
150 Ok(bytes)
151}