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 | //! Running what g1t hands the runner: a workflow job or agent work, with |
| 2 | //! the same harness g1t's sandboxes run (this program, in another mode). | |
| 3 | //! | |
| 4 | //! - **In Docker** (the default): one container per job, removed when it | |
| 5 | //! ends, from the job's `container:` image or the runner's `--image`. A | |
| 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. | 6 | //! Linux build of the harness is mounted into it read-only; that and the |
| 7 | //! job's own work volume, removed after, are its only mounts. It runs | |
| 8 | //! with every capability dropped but the few a build needs, no new | |
| 9 | //! privileges, a process, memory and CPU limit, and a network of its | |
| 10 | //! own. With nested-container isolation (sandbox.rs), all of this is on | |
| 11 | //! the runner's own Docker Engine, inside the runner's container, and a | |
| 12 | //! job that uses Docker gets an engine of its own (engine.rs). | |
| 13 | //! - **Directly** (`--no-docker`, deprecated): the harness runs as a child | |
| 14 | //! process, in a fresh folder under the runner's work folder that is | |
| 15 | //! removed after. Unsandboxed: the job's log says so, and a public | |
| 16 | //! repository's work is refused. | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 17 | //! |
| 18 | //! The runner's own credential never reaches either: a job only ever has | |
| 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. | 19 | //! its own short-lived token, as it would in g1t's sandbox. Credentials are |
| 20 | //! passed by name (`-e NAME`), never on the command line. | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 21 | |
| 22 | use std::collections::BTreeMap; | |
| 23 | use std::path::{Path, PathBuf}; | |
| 24 | use std::process::{Child, Command, Stdio}; | |
| 25 | use std::time::{Duration, Instant}; | |
| 26 | ||
| 27 | use anyhow::{Context, Result, bail}; | |
| 28 | ||
| 29 | use super::api::Assignment; | |
| 30 | use super::config::Config; | |
| 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. | 31 | use super::sandbox::{self, Isolation}; |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 32 | use super::{log, update}; |
| 33 | ||
| 34 | /// The image workflow jobs run in when they name none: Debian with Node, | |
| 35 | /// git and Python, as g1t's sandbox has. | |
| 36 | pub const DEFAULT_IMAGE: &str = "node:24-bookworm"; | |
| 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. | 37 | /// The most processes in a job's container, unless the runner says. |
| 38 | pub const DEFAULT_PIDS_LIMIT: u32 = 4096; | |
| 39 | ||
| 40 | /// The capabilities a job's container keeps; Docker's others, and | |
| 41 | /// everything else, are dropped. A workflow job runs as its image's user, | |
| 42 | /// root for most CI images, and these are what installing packages and | |
| 43 | /// building need: | |
| 44 | /// | |
| 45 | /// - `CHOWN`, `FOWNER`, `FSETID`, `DAC_OVERRIDE`: a package manager (apt, | |
| 46 | /// npm as root) writing files it then hands to another owner, keeping | |
| 47 | /// setgid bits, and root writing files it does not own. | |
| 48 | /// - `SETUID`, `SETGID`: apt and others drop to their own user (`_apt`) to | |
| 49 | /// download; with no new privileges, nothing can gain a user back. | |
| 50 | /// - `KILL`: a step stopping processes another user started. | |
| 51 | /// - `NET_BIND_SERVICE`: a test server on port 80 or 443. | |
| 52 | /// | |
| 53 | /// Not kept, among others: `NET_RAW` (raw sockets, `ping`), `MKNOD`, | |
| 54 | /// `SYS_CHROOT`, `SETFCAP`, `AUDIT_WRITE`, and every `SYS_*` and `NET_ADMIN`. | |
| 55 | pub const JOB_CAPS: &[&str] = &["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "SETUID", "SETGID", "KILL", "NET_BIND_SERVICE"]; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 56 | |
| 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. | 57 | /// Agent work's image runs as `node` and uses `sudo` once, to put its |
| 58 | /// guardrails' policy where the agent cannot change it, and then removes | |
| 59 | /// its own sudo (guard.rs). sudo needs a setuid program to work and | |
| 60 | /// `AUDIT_WRITE` to log, so agent work keeps those and has no | |
| 61 | /// `no-new-privileges`; it still has every other limit. | |
| 62 | pub const AGENT_EXTRA_CAPS: &[&str] = &["AUDIT_WRITE"]; | |
| 63 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 64 | /// Work in progress. |
| 65 | pub struct Running { | |
| 66 | pub id: String, | |
| 67 | pub kind: String, | |
| 68 | pub name: String, | |
| 69 | child: Child, | |
| 70 | container: Option<String>, | |
| 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. | 71 | /// The job's network and work volume, removed after. |
| 72 | network: Option<String>, | |
| 73 | volume: Option<String>, | |
| 74 | /// The job's own Docker Engine, when it may have one. | |
| 75 | engine: Option<JobEngine>, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 76 | folder: Option<PathBuf>, |
| 77 | /// Past its time limit and a grace for g1t to say so: stopped here. | |
| 78 | deadline: Instant, | |
| 79 | } | |
| 80 | ||
| 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. | 81 | #[cfg(unix)] |
| 82 | use super::engine::JobEngine; | |
| 83 | ||
| 84 | /// No job has an engine of its own off Unix. | |
| 85 | #[cfg(not(unix))] | |
| 86 | pub struct JobEngine; | |
| 87 | ||
| 88 | #[cfg(not(unix))] | |
| 89 | impl JobEngine { | |
| 90 | fn remove(&self) {} | |
| 91 | fn job_args(&self) -> Vec<String> { | |
| 92 | Vec::new() | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | /// `docker`, at the runner's own engine when it has one. | |
| 97 | fn docker() -> Command { | |
| 98 | #[cfg(unix)] | |
| 99 | return super::engine::docker(); | |
| 100 | #[cfg(not(unix))] | |
| 101 | Command::new("docker") | |
| 102 | } | |
| 103 | ||
| 104 | fn quietly(command: &mut Command) { | |
| 105 | let _ = command.stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 106 | } | |
| 107 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 108 | impl Running { |
| 109 | pub fn overdue(&self) -> bool { | |
| 110 | Instant::now() > self.deadline | |
| 111 | } | |
| 112 | ||
| 113 | /// Its exit code once it has ended. | |
| 114 | pub fn ended(&mut self) -> Option<i32> { | |
| 115 | match self.child.try_wait() { | |
| 116 | Ok(Some(status)) => Some(status.code().unwrap_or(1)), | |
| 117 | Ok(None) => None, | |
| 118 | Err(_) => Some(1), | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | /// Stops it: the container, then the process and everything it started. | |
| 123 | pub fn stop(&mut self) { | |
| 124 | if let Some(container) = &self.container { | |
| 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. | 125 | quietly(docker().args(["kill", container])); |
| 126 | } | |
| 127 | if let Some(engine) = &self.engine { | |
| 128 | quietly(docker().args(["kill", &engine.container])); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 129 | } |
| 130 | let pid = self.child.id().to_string(); | |
| 131 | if cfg!(windows) { | |
| 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. | 132 | quietly(Command::new("taskkill").args(["/T", "/F", "/PID", &pid])); |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 133 | } else { |
| 134 | // Its own process group (see `directly`): the steps' processes too. | |
| 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. | 135 | quietly(Command::new("kill").args(["-TERM", &format!("-{pid}")])); |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 136 | } |
| 137 | let _ = self.child.kill(); | |
| 138 | let _ = self.child.wait(); | |
| 139 | } | |
| 140 | ||
| 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. | 141 | /// Removes what it left: its container, its engine and that engine's |
| 142 | /// data, its network, its work volume, its folder. | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 143 | pub fn clean(&mut self) { |
| 144 | if let Some(container) = &self.container { | |
| 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. | 145 | quietly(docker().args(["rm", "-f", "-v", container])); |
| 146 | } | |
| 147 | if let Some(engine) = self.engine.take() { | |
| 148 | engine.remove(); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 149 | } |
| 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. | 150 | if let Some(network) = &self.network { |
| 151 | quietly(docker().args(["network", "rm", network])); | |
| 152 | } | |
| 153 | if let Some(volume) = &self.volume { | |
| 154 | quietly(docker().args(["volume", "rm", "-f", volume])); | |
| 155 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 156 | if let Some(folder) = &self.folder { |
| 157 | let _ = std::fs::remove_dir_all(folder); | |
| 158 | } | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | /// A name Docker takes, from a job's id. | |
| 163 | fn container_name(id: &str) -> String { | |
| 164 | let clean: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '-' }).collect(); | |
| 165 | format!("g1t-{clean}") | |
| 166 | } | |
| 167 | ||
| 168 | /// The environment the harness gets for this work. | |
| 169 | fn harness_env(config: &Config, work: &Assignment) -> Result<BTreeMap<String, String>> { | |
| 170 | let mut env = BTreeMap::new(); | |
| 171 | match work.kind.as_str() { | |
| 172 | "workflow" => { | |
| 173 | let Some(token) = &work.token else { bail!("the job came without its token") }; | |
| 174 | env.insert("MODE".into(), "actions".into()); | |
| 175 | env.insert("G1T_API".into(), config.api.clone()); | |
| 176 | env.insert("ACTIONS_JOB".into(), work.id.clone()); | |
| 177 | env.insert("ACTIONS_TOKEN".into(), token.clone()); | |
| 178 | } | |
| 179 | "agent" => { | |
| 180 | for (name, value) in work.env.clone().unwrap_or_default() { | |
| 181 | let value = match value { | |
| 182 | serde_json::Value::String(text) => text, | |
| 183 | other => other.to_string(), | |
| 184 | }; | |
| 185 | env.insert(name, value); | |
| 186 | } | |
| 187 | if !env.contains_key("MODE") && !env.contains_key("PROMPT") { | |
| 188 | bail!("the agent work came without its environment"); | |
| 189 | } | |
| 190 | // Where its API calls go: this installation's. | |
| 191 | env.insert("G1T_API".into(), config.api.clone()); | |
| 192 | } | |
| 193 | other => bail!("this runner does not know work of kind {other}; update it"), | |
| 194 | } | |
| 195 | // The machine is the workspace's own: no mining watch. | |
| 196 | env.insert("G1T_ABUSE".into(), "off".into()); | |
| 197 | Ok(env) | |
| 198 | } | |
| 199 | ||
| 200 | /// Starts work. The harness reports a workflow job's steps and log itself; | |
| 201 | /// the runner tells g1t how the process ended. | |
| 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. | 202 | pub fn start(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment) -> Result<Running> { |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 203 | let env = harness_env(config, work)?; |
| 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. | 204 | if isolation == Isolation::Host { |
| 205 | refuse_public_on_host(work)?; | |
| 206 | return directly(config, work, env); | |
| 207 | } | |
| 208 | in_docker(config, isolation, folder, work, env) | |
| 209 | } | |
| 210 | ||
| 211 | /// Host mode does not take a public repository's work: anyone can propose | |
| 212 | /// code to one, and here it would run on the machine itself. | |
| 213 | fn refuse_public_on_host(work: &Assignment) -> Result<()> { | |
| 214 | match work.visibility.as_deref() { | |
| 215 | Some("public") => bail!("{}", sandbox::host_mode_refuses(&work.repo)), | |
| 216 | Some(_) => Ok(()), | |
| 217 | None => { | |
| 218 | log(&format!("g1t did not say whether {} is public (an older g1t), so it runs; host mode refuses public repositories' work once it does.", work.repo)); | |
| 219 | Ok(()) | |
| 220 | } | |
| 221 | } | |
| 222 | } | |
| 223 | ||
| 224 | /// Each job container's limits. | |
| 225 | #[derive(Debug, Clone, PartialEq)] | |
| 226 | pub struct Limits { | |
| 227 | pub memory: String, | |
| 228 | pub cpus: String, | |
| 229 | pub pids: u32, | |
| 230 | } | |
| 231 | ||
| 232 | /// Three quarters of `MemTotal` in `/proc/meminfo`, in MiB, which under | |
| 233 | /// Sysbox is the runner container's own memory. | |
| 234 | fn default_memory(meminfo: &str) -> Option<String> { | |
| 235 | let kb: u64 = meminfo.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; | |
| 236 | let mib = kb / 1024 * 3 / 4; | |
| 237 | (mib >= 256).then(|| format!("{mib}m")) | |
| 238 | } | |
| 239 | ||
| 240 | pub fn limits(config: &Config) -> Limits { | |
| 241 | let memory = config | |
| 242 | .memory | |
| 243 | .clone() | |
| 244 | .or_else(|| default_memory(&std::fs::read_to_string("/proc/meminfo").unwrap_or_default())) | |
| 245 | .unwrap_or_else(|| "4g".into()); | |
| 246 | let cpus = config.cpus.clone().unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2).to_string()); | |
| 247 | Limits { memory, cpus, pids: config.pids_limit.unwrap_or(DEFAULT_PIDS_LIMIT) } | |
| 248 | } | |
| 249 | ||
| 250 | /// Everything a job's `docker run` is made from. | |
| 251 | pub struct Plan<'a> { | |
| 252 | pub kind: &'a str, | |
| 253 | pub name: &'a str, | |
| 254 | pub runner: &'a str, | |
| 255 | pub image: &'a str, | |
| 256 | pub harness: &'a Path, | |
| 257 | pub network: &'a str, | |
| 258 | /// The job's work volume, for a workflow job's `/home/runner`. | |
| 259 | pub volume: Option<&'a str>, | |
| 260 | pub limits: &'a Limits, | |
| 261 | /// The names of the harness's variables, whose values come from the | |
| 262 | /// runner's process, never its command line. | |
| 263 | pub env_names: Vec<&'a str>, | |
| 264 | /// What the job's own engine adds (`JobEngine::job_args`). | |
| 265 | pub engine_args: Vec<String>, | |
| 266 | } | |
| 267 | ||
| 268 | /// The arguments of `docker` that start a job's container. | |
| 269 | pub fn run_args(plan: &Plan) -> Vec<String> { | |
| 270 | let mut args: Vec<String> = Vec::new(); | |
| 271 | fn add(args: &mut Vec<String>, items: &[&str]) { | |
| 272 | args.extend(items.iter().map(|s| s.to_string())); | |
| 273 | } | |
| 274 | add(&mut args, &["run", "--rm", "--name", plan.name, "--label", &format!("sh.g1t.runner={}", plan.runner)]); | |
| 275 | add(&mut args, &["--pull", "missing", "--init"]); | |
| 276 | // The walls. | |
| 277 | add(&mut args, &["--cap-drop", "ALL"]); | |
| 278 | let mut caps: Vec<&str> = JOB_CAPS.to_vec(); | |
| 279 | if plan.kind == "agent" { | |
| 280 | caps.extend(AGENT_EXTRA_CAPS); | |
| 281 | } else { | |
| 282 | add(&mut args, &["--security-opt", "no-new-privileges"]); | |
| 283 | } | |
| 284 | for cap in caps { | |
| 285 | add(&mut args, &["--cap-add", cap]); | |
| 286 | } | |
| 287 | let pids = plan.limits.pids.to_string(); | |
| 288 | add(&mut args, &["--pids-limit", &pids, "--memory", &plan.limits.memory, "--memory-swap", &plan.limits.memory, "--cpus", &plan.limits.cpus]); | |
| 289 | add(&mut args, &["--network", plan.network, "--ipc", "private"]); | |
| 290 | // The job is in its `container:` image already: the harness inside | |
| 291 | // does not start it again (actions/containers.rs). | |
| 292 | add(&mut args, &["-e", "G1T_JOB_CONTAINER=1"]); | |
| 293 | for name in &plan.env_names { | |
| 294 | add(&mut args, &["-e", name]); | |
| 295 | } | |
| 296 | // Its only mounts: the harness, read-only, and its own volume. | |
| 297 | add(&mut args, &["-v", &format!("{}:/opt/g1t/g1t-runner:ro", plan.harness.display())]); | |
| 298 | if let Some(volume) = plan.volume { | |
| 299 | add(&mut args, &["-v", &format!("{volume}:/home/runner")]); | |
| 300 | } | |
| 301 | args.extend(plan.engine_args.iter().cloned()); | |
| 302 | add(&mut args, &["--entrypoint", "/opt/g1t/g1t-runner", plan.image]); | |
| 303 | args | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 304 | } |
| 305 | ||
| 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. | 306 | fn in_docker(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 307 | let image = match work.kind.as_str() { |
| 308 | "agent" => match config.agent_image.clone().or_else(|| update::agent_image(config)) { | |
| 309 | Some(image) => image, | |
| 310 | None => bail!( | |
| 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. | 311 | "agent work needs an image with git, Node and the agent's CLI: register this runner with --agent-image IMAGE" |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 312 | ), |
| 313 | }, | |
| 314 | _ => work.image.clone().or_else(|| config.image.clone()).unwrap_or_else(|| DEFAULT_IMAGE.to_owned()), | |
| 315 | }; | |
| 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. | 316 | #[cfg(unix)] |
| 317 | if super::engine::in_use() { | |
| 318 | super::engine::ensure_running()?; | |
| 319 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 320 | let harness = update::linux_harness(config, folder)?; |
| 321 | let name = container_name(&work.id); | |
| 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. | 322 | // What an earlier run of this job may have left. |
| 323 | quietly(docker().args(["rm", "-f", "-v", &name])); | |
| 324 | quietly(docker().args(["network", "rm", &name])); | |
| 325 | let output = docker() | |
| 326 | .args(["network", "create", "--label", &format!("sh.g1t.runner={}", config.runner), &name]) | |
| 327 | .output() | |
| 328 | .context("could not run docker: is Docker installed and running?")?; | |
| 329 | if !output.status.success() { | |
| 330 | bail!("could not make the job's network: {}", String::from_utf8_lossy(&output.stderr).trim()); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 331 | } |
| 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. | 332 | let volume = (work.kind == "workflow").then(|| format!("{name}-work")); |
| 333 | if let Some(volume) = &volume { | |
| 334 | quietly(docker().args(["volume", "rm", "-f", volume])); | |
| 335 | quietly(docker().args(["volume", "create", "--label", &format!("sh.g1t.runner={}", config.runner), volume])); | |
| 336 | } | |
| 337 | // A job's own Docker, only inside the runner's own sandbox: on a | |
| 338 | // shared engine it would be the machine's. | |
| 339 | let engine: Option<JobEngine> = job_engine(config, isolation, &name); | |
| 340 | let limits = limits(config); | |
| 341 | let plan = Plan { | |
| 342 | kind: &work.kind, | |
| 343 | name: &name, | |
| 344 | runner: &config.runner, | |
| 345 | image: &image, | |
| 346 | harness: &harness, | |
| 347 | network: &name, | |
| 348 | volume: volume.as_deref(), | |
| 349 | limits: &limits, | |
| 350 | env_names: env.keys().map(String::as_str).collect(), | |
| 351 | engine_args: engine.as_ref().map(JobEngine::job_args).unwrap_or_default(), | |
| 352 | }; | |
| 353 | let mut command = docker(); | |
| 354 | command.args(run_args(&plan)); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 355 | command.envs(&env); |
| 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. | 356 | log(&format!( |
| 357 | "Running {} {} in {image}: its own container and network, capabilities dropped, {} memory, {} CPUs, {} processes{}", | |
| 358 | work.kind, | |
| 359 | work.name, | |
| 360 | limits.memory, | |
| 361 | limits.cpus, | |
| 362 | limits.pids, | |
| 363 | if engine.is_some() { ", Docker of its own when it uses it" } else { "" } | |
| 364 | )); | |
| 365 | let child = match command.spawn() { | |
| 366 | Ok(child) => child, | |
| 367 | Err(error) => { | |
| 368 | if let Some(engine) = &engine { | |
| 369 | engine.remove(); | |
| 370 | } | |
| 371 | quietly(docker().args(["network", "rm", &name])); | |
| 372 | return Err(error).context("could not run docker: is Docker installed and running?"); | |
| 373 | } | |
| 374 | }; | |
| 375 | Ok(Running { | |
| 376 | id: work.id.clone(), | |
| 377 | kind: work.kind.clone(), | |
| 378 | name: work.name.clone(), | |
| 379 | child, | |
| 380 | container: Some(name.clone()), | |
| 381 | network: Some(name), | |
| 382 | volume, | |
| 383 | engine, | |
| 384 | folder: None, | |
| 385 | deadline: deadline(work), | |
| 386 | }) | |
| 387 | } | |
| 388 | ||
| 389 | #[cfg(unix)] | |
| 390 | fn job_engine(config: &Config, isolation: Isolation, name: &str) -> Option<JobEngine> { | |
| 391 | if isolation != Isolation::NestedContainer || !config.job_docker { | |
| 392 | return None; | |
| 393 | } | |
| 394 | let image = config.job_docker_image.as_deref().unwrap_or(super::engine::DEFAULT_JOB_ENGINE_IMAGE); | |
| 395 | match JobEngine::prepare(name, &config.runner, image) { | |
| 396 | Ok(engine) => Some(engine), | |
| 397 | Err(error) => { | |
| 398 | log(&format!("This job has no Docker of its own: {error:#}")); | |
| 399 | None | |
| 400 | } | |
| 401 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 402 | } |
| 403 | ||
| 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. | 404 | #[cfg(not(unix))] |
| 405 | fn job_engine(_config: &Config, _isolation: Isolation, _name: &str) -> Option<JobEngine> { | |
| 406 | None | |
| 407 | } | |
| 408 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 409 | fn directly(config: &Config, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| 410 | let me = std::env::current_exe().context("could not find this program to run the job")?; | |
| 411 | let mut command = Command::new(me); | |
| 412 | let mut folder = None; | |
| 413 | if work.kind == "workflow" { | |
| 414 | // GitHub's layout, in a folder of its own. | |
| 415 | let root = config.work_dir.join(container_name(&work.id)); | |
| 416 | let _ = std::fs::remove_dir_all(&root); | |
| 417 | std::fs::create_dir_all(&root).with_context(|| format!("could not make {}", root.display()))?; | |
| 418 | command.env("G1T_RUNNER_ROOT", &root); | |
| 419 | folder = Some(root); | |
| 420 | } else if !cfg!(target_os = "linux") || std::fs::create_dir_all("/work").is_err() { | |
| 421 | // Agent work expects g1t's sandbox layout (/work), which only a | |
| 422 | // Linux machine (or a container) set aside for it has. | |
| 423 | bail!("agent work runs in Docker, or directly on Linux where /work can be written; this runner can do neither"); | |
| 424 | } | |
| 425 | // Its own variables, less anything of the runner's. | |
| 426 | for (name, _) in std::env::vars_os() { | |
| 427 | if name.to_string_lossy().starts_with("G1T_RUNNER_") && name != "G1T_RUNNER_ROOT" { | |
| 428 | command.env_remove(&name); | |
| 429 | } | |
| 430 | } | |
| 431 | command.envs(&env); | |
| 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. | 432 | // The job's log says it ran unsandboxed (actions/mod.rs). |
| 433 | command.env(JOB_NOTICE, sandbox::HOST_MODE_IN_JOB); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 434 | // A group of its own, so stopping it stops what its steps started. |
| 435 | #[cfg(unix)] | |
| 436 | std::os::unix::process::CommandExt::process_group(&mut command, 0); | |
| 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. | 437 | log(&format!("Running {} {} on this machine, unsandboxed (host mode, deprecated)", work.kind, work.name)); |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 438 | let child = command.spawn().context("could not start the job's process")?; |
| 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. | 439 | Ok(Running { |
| 440 | id: work.id.clone(), | |
| 441 | kind: work.kind.clone(), | |
| 442 | name: work.name.clone(), | |
| 443 | child, | |
| 444 | container: None, | |
| 445 | network: None, | |
| 446 | volume: None, | |
| 447 | engine: None, | |
| 448 | folder, | |
| 449 | deadline: deadline(work), | |
| 450 | }) | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 451 | } |
| 452 | ||
| 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. | 453 | /// A warning the harness puts at the top of the job's log. |
| 454 | pub const JOB_NOTICE: &str = "G1T_JOB_NOTICE"; | |
| 455 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 456 | fn deadline(work: &Assignment) -> Instant { |
| 457 | Instant::now() + Duration::from_secs(u64::from(work.timeout_minutes.max(1)) * 60 + 10 * 60) | |
| 458 | } | |
| 459 | ||
| 460 | /// Whether Docker answers. | |
| 461 | pub fn docker_ready() -> bool { | |
| 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. | 462 | docker() |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 463 | .args(["version", "--format", "{{.Server.Version}}"]) |
| 464 | .stdout(Stdio::null()) | |
| 465 | .stderr(Stdio::null()) | |
| 466 | .status() | |
| 467 | .is_ok_and(|status| status.success()) | |
| 468 | } | |
| 469 | ||
| 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. | 470 | /// Removes what jobs of this runner's left behind, if it stopped while one ran. |
| 471 | pub fn sweep(config: &Config) { | |
| 472 | let label = format!("label=sh.g1t.runner={}", config.runner); | |
| 473 | for (list, remove) in [ | |
| 474 | (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]), | |
| 475 | (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]), | |
| 476 | (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]), | |
| 477 | ] { | |
| 478 | let Ok(output) = docker().args(&list).output() else { return }; | |
| 479 | let ids: Vec<String> = String::from_utf8_lossy(&output.stdout).split_whitespace().map(str::to_owned).collect(); | |
| 480 | if !ids.is_empty() { | |
| 481 | quietly(docker().args(&remove).args(&ids)); | |
| 482 | } | |
| 483 | } | |
| 484 | } | |
| 485 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 486 | #[cfg(test)] |
| 487 | mod tests { | |
| 488 | use super::*; | |
| 489 | use serde_json::json; | |
| 490 | ||
| 491 | fn config() -> Config { | |
| 492 | Config { | |
| 493 | url: "https://g1t.sh".into(), | |
| 494 | api: "https://api.g1t.sh".into(), | |
| 495 | runner: "rnr_1".into(), | |
| 496 | name: "a".into(), | |
| 497 | credential: "g1tr_secret".into(), | |
| 498 | workspace: "acme".into(), | |
| 499 | repo: None, | |
| 500 | group: None, | |
| 501 | labels: vec![], | |
| 502 | ephemeral: false, | |
| 503 | work_dir: PathBuf::from("/tmp/w"), | |
| 504 | docker: true, | |
| 505 | image: None, | |
| 506 | agent_image: None, | |
| 507 | harness: None, | |
| 508 | auto_update: false, | |
| 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. | 509 | memory: None, |
| 510 | cpus: None, | |
| 511 | pids_limit: None, | |
| 512 | job_docker: true, | |
| 513 | job_docker_image: None, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 514 | } |
| 515 | } | |
| 516 | ||
| 517 | fn assignment(kind: &str) -> Assignment { | |
| 518 | serde_json::from_value(json!({ | |
| 519 | "kind": kind, "id": "job_1", "name": "build", "repo": "acme/web", "timeout_minutes": 60, | |
| 520 | "token": "jobtoken", "env": { "MODE": "checks", "G1T_TOKEN": "g1t_run", "G1T_API": "https://elsewhere" } | |
| 521 | })) | |
| 522 | .unwrap() | |
| 523 | } | |
| 524 | ||
| 525 | #[test] | |
| 526 | fn a_workflow_job_gets_its_own_token_and_never_the_runners() { | |
| 527 | let env = harness_env(&config(), &assignment("workflow")).unwrap(); | |
| 528 | assert_eq!(env["MODE"], "actions"); | |
| 529 | assert_eq!(env["ACTIONS_JOB"], "job_1"); | |
| 530 | assert_eq!(env["ACTIONS_TOKEN"], "jobtoken"); | |
| 531 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); | |
| 532 | assert!(env.values().all(|value| !value.contains("g1tr_"))); | |
| 533 | } | |
| 534 | ||
| 535 | #[test] | |
| 536 | fn agent_work_gets_the_sandboxs_environment_and_this_api() { | |
| 537 | let env = harness_env(&config(), &assignment("agent")).unwrap(); | |
| 538 | assert_eq!(env["MODE"], "checks"); | |
| 539 | assert_eq!(env["G1T_TOKEN"], "g1t_run"); | |
| 540 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); | |
| 541 | assert_eq!(env["G1T_ABUSE"], "off"); | |
| 542 | } | |
| 543 | ||
| 544 | #[test] | |
| 545 | fn unknown_work_is_refused() { | |
| 546 | assert!(harness_env(&config(), &assignment("deploy")).is_err()); | |
| 547 | } | |
| 548 | ||
| 549 | #[test] | |
| 550 | fn container_names_are_dockers() { | |
| 551 | assert_eq!(container_name("job_01ab"), "g1t-job_01ab"); | |
| 552 | assert_eq!(container_name("a/b c"), "g1t-a-b-c"); | |
| 553 | } | |
| 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. | 554 | |
| 555 | fn args_for(kind: &str, engine_args: Vec<String>) -> (Vec<String>, BTreeMap<String, String>) { | |
| 556 | let config = config(); | |
| 557 | let env = harness_env(&config, &assignment(kind)).unwrap(); | |
| 558 | let limits = Limits { memory: "6144m".into(), cpus: "4".into(), pids: 4096 }; | |
| 559 | let harness = PathBuf::from("/usr/local/bin/g1t-runner"); | |
| 560 | let plan = Plan { | |
| 561 | kind, | |
| 562 | name: "g1t-job_1", | |
| 563 | runner: "rnr_1", | |
| 564 | image: DEFAULT_IMAGE, | |
| 565 | harness: &harness, | |
| 566 | network: "g1t-job_1", | |
| 567 | volume: (kind == "workflow").then_some("g1t-job_1-work"), | |
| 568 | limits: &limits, | |
| 569 | env_names: env.keys().map(String::as_str).collect(), | |
| 570 | engine_args, | |
| 571 | }; | |
| 572 | (run_args(&plan), env.clone()) | |
| 573 | } | |
| 574 | ||
| 575 | fn value_of<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> { | |
| 576 | args.windows(2).filter(|w| w[0] == flag).map(|w| w[1].as_str()).collect() | |
| 577 | } | |
| 578 | ||
| 579 | #[test] | |
| 580 | fn a_jobs_container_is_walled_in() { | |
| 581 | let (args, _) = args_for("workflow", Vec::new()); | |
| 582 | assert_eq!(args[0], "run"); | |
| 583 | assert!(args.contains(&"--rm".to_owned())); | |
| 584 | assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]); | |
| 585 | assert_eq!(value_of(&args, "--cap-add"), JOB_CAPS.to_vec()); | |
| 586 | assert_eq!(value_of(&args, "--security-opt"), vec!["no-new-privileges"]); | |
| 587 | assert_eq!(value_of(&args, "--pids-limit"), vec!["4096"]); | |
| 588 | assert_eq!(value_of(&args, "--memory"), vec!["6144m"]); | |
| 589 | assert_eq!(value_of(&args, "--memory-swap"), vec!["6144m"]); | |
| 590 | assert_eq!(value_of(&args, "--cpus"), vec!["4"]); | |
| 591 | assert_eq!(value_of(&args, "--network"), vec!["g1t-job_1"]); | |
| 592 | for forbidden in ["--privileged", "--pid", "--userns", "--device", "host"] { | |
| 593 | assert!(!args.iter().any(|a| a == forbidden), "{forbidden} in {args:?}"); | |
| 594 | } | |
| 595 | assert!(!JOB_CAPS.iter().any(|c| c.starts_with("SYS_") || *c == "NET_ADMIN" || *c == "NET_RAW")); | |
| 596 | } | |
| 597 | ||
| 598 | #[test] | |
| 599 | fn a_jobs_only_mounts_are_the_harness_and_its_own_volume() { | |
| 600 | let (args, _) = args_for("workflow", Vec::new()); | |
| 601 | let mounts = value_of(&args, "-v"); | |
| 602 | assert_eq!(mounts, vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro", "g1t-job_1-work:/home/runner"]); | |
| 603 | assert!(!args.iter().any(|a| a.contains("docker.sock")), "no engine socket: {args:?}"); | |
| 604 | assert!(!args.iter().any(|a| a == "--mount")); | |
| 605 | } | |
| 606 | ||
| 607 | #[test] | |
| 608 | fn credentials_go_by_name_and_the_runners_never() { | |
| 609 | let (args, env) = args_for("workflow", Vec::new()); | |
| 610 | let passed = value_of(&args, "-e"); | |
| 611 | assert!(passed.contains(&"ACTIONS_TOKEN")); | |
| 612 | // Never a value on the command line, and never the runner's credential. | |
| 613 | assert!(!args.iter().any(|a| a.contains("jobtoken"))); | |
| 614 | assert!(!args.iter().any(|a| a.contains("g1tr_"))); | |
| 615 | assert!(env.values().all(|v| !v.contains("g1tr_secret"))); | |
| 616 | assert!(!env.contains_key("G1T_RUNNER_TOKEN")); | |
| 617 | } | |
| 618 | ||
| 619 | #[test] | |
| 620 | fn agent_work_keeps_sudo_for_its_guardrails_and_every_other_wall() { | |
| 621 | let (args, _) = args_for("agent", Vec::new()); | |
| 622 | assert!(value_of(&args, "--security-opt").is_empty()); | |
| 623 | assert!(value_of(&args, "--cap-add").contains(&"AUDIT_WRITE")); | |
| 624 | assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]); | |
| 625 | assert_eq!(value_of(&args, "-v"), vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro"]); | |
| 626 | } | |
| 627 | ||
| 628 | #[test] | |
| 629 | fn a_job_with_docker_gets_a_socket_of_its_own_not_the_runners() { | |
| 630 | let engine_args = vec!["-v".into(), "/run/g1t-runner/jobs/g1t-job_1/proxy:/run/g1t-docker:ro".into(), "-e".into(), "DOCKER_HOST=unix:///run/g1t-docker/docker.sock".into()]; | |
| 631 | let (args, _) = args_for("workflow", engine_args); | |
| 632 | let mounts = value_of(&args, "-v"); | |
| 633 | assert!(mounts.contains(&"/run/g1t-runner/jobs/g1t-job_1/proxy:/run/g1t-docker:ro")); | |
| 634 | assert!(!mounts.iter().any(|m| m.starts_with("/var/run/docker.sock") || m.starts_with("/run/g1t-runner/docker.sock"))); | |
| 635 | // The entrypoint and image stay last. | |
| 636 | assert_eq!(&args[args.len() - 3..], ["--entrypoint", "/opt/g1t/g1t-runner", DEFAULT_IMAGE]); | |
| 637 | } | |
| 638 | ||
| 639 | #[test] | |
| 640 | fn limits_default_sensibly() { | |
| 641 | assert_eq!(default_memory("MemTotal: 16384000 kB\nMemFree: 1 kB\n"), Some("12000m".into())); | |
| 642 | assert_eq!(default_memory(""), None); | |
| 643 | let mut config = config(); | |
| 644 | config.memory = Some("2g".into()); | |
| 645 | config.pids_limit = Some(100); | |
| 646 | let limits = limits(&config); | |
| 647 | assert_eq!(limits.memory, "2g"); | |
| 648 | assert_eq!(limits.pids, 100); | |
| 649 | assert!(limits.cpus.parse::<f64>().unwrap() >= 1.0); | |
| 650 | } | |
| 651 | ||
| 652 | #[test] | |
| 653 | fn host_mode_refuses_a_public_repositorys_work() { | |
| 654 | let mut work = assignment("workflow"); | |
| 655 | work.visibility = Some("public".into()); | |
| 656 | let refused = refuse_public_on_host(&work).unwrap_err().to_string(); | |
| 657 | assert!(refused.contains("acme/web is public"), "{refused}"); | |
| 658 | work.visibility = Some("private".into()); | |
| 659 | assert!(refuse_public_on_host(&work).is_ok()); | |
| 660 | work.visibility = None; | |
| 661 | assert!(refuse_public_on_host(&work).is_ok()); | |
| 662 | // And refuses it before anything runs. | |
| 663 | work.visibility = Some("public".into()); | |
| 664 | assert!(start(&config(), Isolation::Host, Path::new("/tmp"), &work).is_err()); | |
| 665 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 666 | } |