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 incidents | 1 | //! The runner's calls to g1t: outbound HTTPS only, JSON in `snake_case`. |
| 2 | //! See `g1t_contracts::runners` for the shapes. | |
| 3 | ||
| 4 | use std::time::Duration; | |
| 5 | ||
| 6 | use anyhow::{Result, anyhow}; | |
| 7 | use serde::Deserialize; | |
| 8 | use serde_json::{Map, Value, json}; | |
| 9 | ||
| 10 | use super::VERSION; | |
| 11 | ||
| 12 | #[derive(Clone, Debug, Deserialize)] | |
| 13 | pub 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 incidents | 28 | } |
| 29 | ||
| 30 | #[derive(Debug, Default, Deserialize)] | |
| 31 | pub 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)] | |
| 43 | pub 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)] | |
| 55 | pub 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)] | |
| 63 | pub enum Failure { | |
| 64 | Refused(u16, String), | |
| 65 | Unreachable(String), | |
| 66 | } | |
| 67 | ||
| 68 | impl 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 | ||
| 77 | fn 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 | ||
| 85 | fn 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 | ||
| 104 | pub struct Api { | |
| 105 | pub base: String, | |
| 106 | pub runner: String, | |
| 107 | pub credential: String, | |
| 108 | } | |
| 109 | ||
| 110 | impl 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. | |
| 146 | pub 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 | } |