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.
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 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 | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 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. | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 17 | //! |
| 18 | //! The runner's own credential never reaches either: a job only ever has | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 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. | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 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; | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 31 | use super::sandbox::{self, Isolation}; |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 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"; | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 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"]; | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 56 | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 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 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 64 | /// Work in progress. |
| 65 | pub struct Running { | |
| 66 | pub id: String, | |
| 67 | pub kind: String, | |
| 68 | pub name: String, | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 69 | pub repo: String, |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 70 | child: Child, |
| 71 | container: Option<String>, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 72 | /// The job's network and work volume, removed after. |
| 73 | network: Option<String>, | |
| 74 | volume: Option<String>, | |
| 75 | /// The job's own Docker Engine, when it may have one. | |
| 76 | engine: Option<JobEngine>, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 77 | folder: Option<PathBuf>, |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 78 | /// The job's own VM, for a job native to this OS (native.rs); `child` |
| 79 | /// is then `wsb exec`, whose output carries the job's exit code. | |
| 80 | native: Option<WsbJob>, | |
| 81 | /// How it ended, once read: a native job's code is read once from | |
| 82 | /// `wsb exec`'s output. | |
| 83 | exit: Option<i32>, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 84 | /// Past its time limit and a grace for g1t to say so: stopped here. |
| 85 | deadline: Instant, | |
| 86 | } | |
| 87 | ||
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 88 | use super::native::{self, WsbJob}; |
| 89 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 90 | #[cfg(unix)] |
| 91 | use super::engine::JobEngine; | |
| 92 | ||
| 93 | /// No job has an engine of its own off Unix. | |
| 94 | #[cfg(not(unix))] | |
| 95 | pub struct JobEngine; | |
| 96 | ||
| 97 | #[cfg(not(unix))] | |
| 98 | impl JobEngine { | |
| 99 | fn remove(&self) {} | |
| 100 | fn job_args(&self) -> Vec<String> { | |
| 101 | Vec::new() | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | /// `docker`, at the runner's own engine when it has one. | |
| 106 | fn docker() -> Command { | |
| 107 | #[cfg(unix)] | |
| 108 | return super::engine::docker(); | |
| 109 | #[cfg(not(unix))] | |
| 110 | Command::new("docker") | |
| 111 | } | |
| 112 | ||
| 113 | fn quietly(command: &mut Command) { | |
| 114 | let _ = command.stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 115 | } | |
| 116 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 117 | impl Running { |
| 118 | pub fn overdue(&self) -> bool { | |
| 119 | Instant::now() > self.deadline | |
| 120 | } | |
| 121 | ||
| 122 | /// Its exit code once it has ended. | |
| 123 | pub fn ended(&mut self) -> Option<i32> { | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 124 | if self.exit.is_some() { |
| 125 | return self.exit; | |
| 126 | } | |
| 127 | self.exit = match self.child.try_wait() { | |
| 128 | Ok(Some(_)) if self.native.is_some() => { | |
| 129 | use std::io::Read; | |
| 130 | let mut text = String::new(); | |
| 131 | if let Some(out) = self.child.stdout.as_mut() { | |
| 132 | let _ = out.read_to_string(&mut text); | |
| 133 | } | |
| 134 | Some(native::parse_exit_code(&text).unwrap_or(1)) | |
| 135 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 136 | Ok(Some(status)) => Some(status.code().unwrap_or(1)), |
| 137 | Ok(None) => None, | |
| 138 | Err(_) => Some(1), | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 139 | }; |
| 140 | self.exit | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 141 | } |
| 142 | ||
| 143 | /// Stops it: the container, then the process and everything it started. | |
| 144 | pub fn stop(&mut self) { | |
| 145 | if let Some(container) = &self.container { | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 146 | quietly(docker().args(["kill", container])); |
| 147 | } | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 148 | #[cfg(unix)] |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 149 | if let Some(engine) = &self.engine { |
| 150 | quietly(docker().args(["kill", &engine.container])); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 151 | } |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 152 | if let Some(job) = &self.native { |
| 153 | job.stop(); | |
| 154 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 155 | let pid = self.child.id().to_string(); |
| 156 | if cfg!(windows) { | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 157 | quietly(Command::new("taskkill").args(["/T", "/F", "/PID", &pid])); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 158 | } else { |
| 159 | // Its own process group (see `directly`): the steps' processes too. | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 160 | quietly(Command::new("kill").args(["-TERM", &format!("-{pid}")])); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 161 | } |
| 162 | let _ = self.child.kill(); | |
| 163 | let _ = self.child.wait(); | |
| 164 | } | |
| 165 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 166 | /// Removes what it left: its container, its engine and that engine's |
| 167 | /// data, its network, its work volume, its folder. | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 168 | pub fn clean(&mut self) { |
| 169 | if let Some(container) = &self.container { | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 170 | quietly(docker().args(["rm", "-f", "-v", container])); |
| 171 | } | |
| 172 | if let Some(engine) = self.engine.take() { | |
| 173 | engine.remove(); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 174 | } |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 175 | if let Some(network) = &self.network { |
| 176 | quietly(docker().args(["network", "rm", network])); | |
| 177 | } | |
| 178 | if let Some(volume) = &self.volume { | |
| 179 | quietly(docker().args(["volume", "rm", "-f", volume])); | |
| 180 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 181 | if let Some(folder) = &self.folder { |
| 182 | let _ = std::fs::remove_dir_all(folder); | |
| 183 | } | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 184 | if let Some(job) = self.native.take() { |
| 185 | job.clean(); | |
| 186 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 187 | } |
| 188 | } | |
| 189 | ||
| 190 | /// A name Docker takes, from a job's id. | |
| 191 | fn container_name(id: &str) -> String { | |
| 192 | let clean: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '-' }).collect(); | |
| 193 | format!("g1t-{clean}") | |
| 194 | } | |
| 195 | ||
| 196 | /// The environment the harness gets for this work. | |
| 197 | fn harness_env(config: &Config, work: &Assignment) -> Result<BTreeMap<String, String>> { | |
| 198 | let mut env = BTreeMap::new(); | |
| 199 | match work.kind.as_str() { | |
| 200 | "workflow" => { | |
| 201 | let Some(token) = &work.token else { bail!("the job came without its token") }; | |
| 202 | env.insert("MODE".into(), "actions".into()); | |
| 203 | env.insert("G1T_API".into(), config.api.clone()); | |
| 204 | env.insert("ACTIONS_JOB".into(), work.id.clone()); | |
| 205 | env.insert("ACTIONS_TOKEN".into(), token.clone()); | |
| 206 | } | |
| 207 | "agent" => { | |
| 208 | for (name, value) in work.env.clone().unwrap_or_default() { | |
| 209 | let value = match value { | |
| 210 | serde_json::Value::String(text) => text, | |
| 211 | other => other.to_string(), | |
| 212 | }; | |
| 213 | env.insert(name, value); | |
| 214 | } | |
| 215 | if !env.contains_key("MODE") && !env.contains_key("PROMPT") { | |
| 216 | bail!("the agent work came without its environment"); | |
| 217 | } | |
| 218 | // Where its API calls go: this installation's. | |
| 219 | env.insert("G1T_API".into(), config.api.clone()); | |
| 220 | } | |
| 221 | other => bail!("this runner does not know work of kind {other}; update it"), | |
| 222 | } | |
| 223 | // The machine is the workspace's own: no mining watch. | |
| 224 | env.insert("G1T_ABUSE".into(), "off".into()); | |
| 225 | Ok(env) | |
| 226 | } | |
| 227 | ||
| 228 | /// Starts work. The harness reports a workflow job's steps and log itself; | |
| 229 | /// the runner tells g1t how the process ended. | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 230 | pub fn start(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment) -> Result<Running> { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 231 | let env = harness_env(config, work)?; |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 232 | match isolation { |
| 233 | Isolation::Host => { | |
| 234 | refuse_public_on_host(work)?; | |
| 235 | directly(config, work, env) | |
| 236 | } | |
| 237 | Isolation::Vm => in_native_vm(config, folder, work, env), | |
| 238 | _ => in_docker(config, isolation, folder, work, env), | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 239 | } |
| 240 | } | |
| 241 | ||
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 242 | /// A memory size as `--memory` takes it (`8g`, `4096m`), in MiB. |
| 243 | pub fn mib(text: &str) -> Option<u64> { | |
| 244 | let text = text.trim().to_ascii_lowercase(); | |
| 245 | let (number, unit) = text.split_at(text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len())); | |
| 246 | let number: u64 = number.parse().ok()?; | |
| 247 | match unit.trim_end_matches('b').trim_end_matches('i') { | |
| 248 | "" | "m" => Some(number), | |
| 249 | "g" => Some(number * 1024), | |
| 250 | "k" => Some(number / 1024), | |
| 251 | _ => None, | |
| 252 | } | |
| 253 | } | |
| 254 | ||
| 255 | /// A job native to this OS, in a throwaway VM of its own (native.rs): | |
| 256 | /// Windows Sandbox on Windows. Agent work needs g1t's Linux sandbox | |
| 257 | /// layout and runs on a Linux runner. | |
| 258 | fn in_native_vm(config: &Config, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { | |
| 259 | if work.kind != "workflow" { | |
| 260 | bail!("agent work runs on Linux runners: this one runs jobs native to {}", std::env::consts::OS); | |
| 261 | } | |
| 262 | if !cfg!(windows) { | |
| 263 | bail!("this runner has no sandbox for jobs native to {}", std::env::consts::OS); | |
| 264 | } | |
| 265 | let memory = config.memory.as_deref().and_then(mib).unwrap_or(4096); | |
| 266 | let drop = folder.join("native").join(container_name(&work.id)); | |
| 267 | let job = WsbJob::start(drop, &env, memory)?; | |
| 268 | let child = match job.exec().spawn() { | |
| 269 | Ok(child) => child, | |
| 270 | Err(error) => { | |
| 271 | job.clean(); | |
| 272 | return Err(error).context("could not run wsb exec"); | |
| 273 | } | |
| 274 | }; | |
| 275 | log(&format!( | |
| 276 | "Running {} {} in a Windows Sandbox of its own ({memory} MiB): no folder of this machine's but its read-only harness, discarded after", | |
| 277 | work.kind, work.name | |
| 278 | )); | |
| 279 | Ok(Running { | |
| 280 | id: work.id.clone(), | |
| 281 | repo: work.repo.clone(), | |
| 282 | kind: work.kind.clone(), | |
| 283 | name: work.name.clone(), | |
| 284 | child, | |
| 285 | container: None, | |
| 286 | network: None, | |
| 287 | volume: None, | |
| 288 | engine: None, | |
| 289 | folder: None, | |
| 290 | native: Some(job), | |
| 291 | exit: None, | |
| 292 | deadline: deadline(work), | |
| 293 | }) | |
| 294 | } | |
| 295 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 296 | /// Host mode does not take a public repository's work: anyone can propose |
| 297 | /// code to one, and here it would run on the machine itself. | |
| 298 | fn refuse_public_on_host(work: &Assignment) -> Result<()> { | |
| 299 | match work.visibility.as_deref() { | |
| 300 | Some("public") => bail!("{}", sandbox::host_mode_refuses(&work.repo)), | |
| 301 | Some(_) => Ok(()), | |
| 302 | None => { | |
| 303 | 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)); | |
| 304 | Ok(()) | |
| 305 | } | |
| 306 | } | |
| 307 | } | |
| 308 | ||
| 309 | /// Each job container's limits. | |
| 310 | #[derive(Debug, Clone, PartialEq)] | |
| 311 | pub struct Limits { | |
| 312 | pub memory: String, | |
| 313 | pub cpus: String, | |
| 314 | pub pids: u32, | |
| 315 | } | |
| 316 | ||
| 317 | /// Three quarters of `MemTotal` in `/proc/meminfo`, in MiB, which under | |
| 318 | /// Sysbox is the runner container's own memory. | |
| 319 | fn default_memory(meminfo: &str) -> Option<String> { | |
| 320 | let kb: u64 = meminfo.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; | |
| 321 | let mib = kb / 1024 * 3 / 4; | |
| 322 | (mib >= 256).then(|| format!("{mib}m")) | |
| 323 | } | |
| 324 | ||
| 325 | pub fn limits(config: &Config) -> Limits { | |
| 326 | let memory = config | |
| 327 | .memory | |
| 328 | .clone() | |
| 329 | .or_else(|| default_memory(&std::fs::read_to_string("/proc/meminfo").unwrap_or_default())) | |
| 330 | .unwrap_or_else(|| "4g".into()); | |
| 331 | let cpus = config.cpus.clone().unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2).to_string()); | |
| 332 | Limits { memory, cpus, pids: config.pids_limit.unwrap_or(DEFAULT_PIDS_LIMIT) } | |
| 333 | } | |
| 334 | ||
| 335 | /// Everything a job's `docker run` is made from. | |
| 336 | pub struct Plan<'a> { | |
| 337 | pub kind: &'a str, | |
| 338 | pub name: &'a str, | |
| 339 | pub runner: &'a str, | |
| 340 | pub image: &'a str, | |
| 341 | pub harness: &'a Path, | |
| 342 | pub network: &'a str, | |
| 343 | /// The job's work volume, for a workflow job's `/home/runner`. | |
| 344 | pub volume: Option<&'a str>, | |
| 345 | pub limits: &'a Limits, | |
| 346 | /// The names of the harness's variables, whose values come from the | |
| 347 | /// runner's process, never its command line. | |
| 348 | pub env_names: Vec<&'a str>, | |
| 349 | /// What the job's own engine adds (`JobEngine::job_args`). | |
| 350 | pub engine_args: Vec<String>, | |
| 351 | } | |
| 352 | ||
| 353 | /// The arguments of `docker` that start a job's container. | |
| 354 | pub fn run_args(plan: &Plan) -> Vec<String> { | |
| 355 | let mut args: Vec<String> = Vec::new(); | |
| 356 | fn add(args: &mut Vec<String>, items: &[&str]) { | |
| 357 | args.extend(items.iter().map(|s| s.to_string())); | |
| 358 | } | |
| 359 | add(&mut args, &["run", "--rm", "--name", plan.name, "--label", &format!("sh.g1t.runner={}", plan.runner)]); | |
| 360 | add(&mut args, &["--pull", "missing", "--init"]); | |
| 361 | // The walls. | |
| 362 | add(&mut args, &["--cap-drop", "ALL"]); | |
| 363 | let mut caps: Vec<&str> = JOB_CAPS.to_vec(); | |
| 364 | if plan.kind == "agent" { | |
| 365 | caps.extend(AGENT_EXTRA_CAPS); | |
| 366 | } else { | |
| 367 | add(&mut args, &["--security-opt", "no-new-privileges"]); | |
| 368 | } | |
| 369 | for cap in caps { | |
| 370 | add(&mut args, &["--cap-add", cap]); | |
| 371 | } | |
| 372 | let pids = plan.limits.pids.to_string(); | |
| 373 | add(&mut args, &["--pids-limit", &pids, "--memory", &plan.limits.memory, "--memory-swap", &plan.limits.memory, "--cpus", &plan.limits.cpus]); | |
| 374 | add(&mut args, &["--network", plan.network, "--ipc", "private"]); | |
| 375 | // The job is in its `container:` image already: the harness inside | |
| 376 | // does not start it again (actions/containers.rs). | |
| 377 | add(&mut args, &["-e", "G1T_JOB_CONTAINER=1"]); | |
| 378 | for name in &plan.env_names { | |
| 379 | add(&mut args, &["-e", name]); | |
| 380 | } | |
| 381 | // Its only mounts: the harness, read-only, and its own volume. | |
| 382 | add(&mut args, &["-v", &format!("{}:/opt/g1t/g1t-runner:ro", plan.harness.display())]); | |
| 383 | if let Some(volume) = plan.volume { | |
| 384 | add(&mut args, &["-v", &format!("{volume}:/home/runner")]); | |
| 385 | } | |
| 386 | args.extend(plan.engine_args.iter().cloned()); | |
| 387 | add(&mut args, &["--entrypoint", "/opt/g1t/g1t-runner", plan.image]); | |
| 388 | args | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 389 | } |
| 390 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 391 | fn in_docker(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 392 | let image = match work.kind.as_str() { |
| 393 | "agent" => match config.agent_image.clone().or_else(|| update::agent_image(config)) { | |
| 394 | Some(image) => image, | |
| 395 | None => bail!( | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 396 | "agent work needs an image with git, Node and the agent's CLI: register this runner with --agent-image IMAGE" |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 397 | ), |
| 398 | }, | |
| 399 | _ => work.image.clone().or_else(|| config.image.clone()).unwrap_or_else(|| DEFAULT_IMAGE.to_owned()), | |
| 400 | }; | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 401 | #[cfg(unix)] |
| 402 | if super::engine::in_use() { | |
| 403 | super::engine::ensure_running()?; | |
| 404 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 405 | let harness = update::linux_harness(config, folder)?; |
| 406 | let name = container_name(&work.id); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 407 | // What an earlier run of this job may have left. |
| 408 | quietly(docker().args(["rm", "-f", "-v", &name])); | |
| 409 | quietly(docker().args(["network", "rm", &name])); | |
| 410 | let output = docker() | |
| 411 | .args(["network", "create", "--label", &format!("sh.g1t.runner={}", config.runner), &name]) | |
| 412 | .output() | |
| 413 | .context("could not run docker: is Docker installed and running?")?; | |
| 414 | if !output.status.success() { | |
| 415 | bail!("could not make the job's network: {}", String::from_utf8_lossy(&output.stderr).trim()); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 416 | } |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 417 | let volume = (work.kind == "workflow").then(|| format!("{name}-work")); |
| 418 | if let Some(volume) = &volume { | |
| 419 | quietly(docker().args(["volume", "rm", "-f", volume])); | |
| 420 | quietly(docker().args(["volume", "create", "--label", &format!("sh.g1t.runner={}", config.runner), volume])); | |
| 421 | } | |
| 422 | // A job's own Docker, only inside the runner's own sandbox: on a | |
| 423 | // shared engine it would be the machine's. | |
| 424 | let engine: Option<JobEngine> = job_engine(config, isolation, &name); | |
| 425 | let limits = limits(config); | |
| 426 | let plan = Plan { | |
| 427 | kind: &work.kind, | |
| 428 | name: &name, | |
| 429 | runner: &config.runner, | |
| 430 | image: &image, | |
| 431 | harness: &harness, | |
| 432 | network: &name, | |
| 433 | volume: volume.as_deref(), | |
| 434 | limits: &limits, | |
| 435 | env_names: env.keys().map(String::as_str).collect(), | |
| 436 | engine_args: engine.as_ref().map(JobEngine::job_args).unwrap_or_default(), | |
| 437 | }; | |
| 438 | let mut command = docker(); | |
| 439 | command.args(run_args(&plan)); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 440 | command.envs(&env); |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 441 | log(&format!( |
| 442 | "Running {} {} in {image}: its own container and network, capabilities dropped, {} memory, {} CPUs, {} processes{}", | |
| 443 | work.kind, | |
| 444 | work.name, | |
| 445 | limits.memory, | |
| 446 | limits.cpus, | |
| 447 | limits.pids, | |
| 448 | if engine.is_some() { ", Docker of its own when it uses it" } else { "" } | |
| 449 | )); | |
| 450 | let child = match command.spawn() { | |
| 451 | Ok(child) => child, | |
| 452 | Err(error) => { | |
| 453 | if let Some(engine) = &engine { | |
| 454 | engine.remove(); | |
| 455 | } | |
| 456 | quietly(docker().args(["network", "rm", &name])); | |
| 457 | return Err(error).context("could not run docker: is Docker installed and running?"); | |
| 458 | } | |
| 459 | }; | |
| 460 | Ok(Running { | |
| 461 | id: work.id.clone(), | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 462 | repo: work.repo.clone(), |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 463 | kind: work.kind.clone(), |
| 464 | name: work.name.clone(), | |
| 465 | child, | |
| 466 | container: Some(name.clone()), | |
| 467 | network: Some(name), | |
| 468 | volume, | |
| 469 | engine, | |
| 470 | folder: None, | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 471 | native: None, |
| 472 | exit: None, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 473 | deadline: deadline(work), |
| 474 | }) | |
| 475 | } | |
| 476 | ||
| 477 | #[cfg(unix)] | |
| 478 | fn job_engine(config: &Config, isolation: Isolation, name: &str) -> Option<JobEngine> { | |
| 479 | if isolation != Isolation::NestedContainer || !config.job_docker { | |
| 480 | return None; | |
| 481 | } | |
| 482 | let image = config.job_docker_image.as_deref().unwrap_or(super::engine::DEFAULT_JOB_ENGINE_IMAGE); | |
| 483 | match JobEngine::prepare(name, &config.runner, image) { | |
| 484 | Ok(engine) => Some(engine), | |
| 485 | Err(error) => { | |
| 486 | log(&format!("This job has no Docker of its own: {error:#}")); | |
| 487 | None | |
| 488 | } | |
| 489 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 490 | } |
| 491 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 492 | #[cfg(not(unix))] |
| 493 | fn job_engine(_config: &Config, _isolation: Isolation, _name: &str) -> Option<JobEngine> { | |
| 494 | None | |
| 495 | } | |
| 496 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 497 | fn directly(config: &Config, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| 498 | let me = std::env::current_exe().context("could not find this program to run the job")?; | |
| 499 | let mut command = Command::new(me); | |
| 500 | let mut folder = None; | |
| 501 | if work.kind == "workflow" { | |
| 502 | // GitHub's layout, in a folder of its own. | |
| 503 | let root = config.work_dir.join(container_name(&work.id)); | |
| 504 | let _ = std::fs::remove_dir_all(&root); | |
| 505 | std::fs::create_dir_all(&root).with_context(|| format!("could not make {}", root.display()))?; | |
| 506 | command.env("G1T_RUNNER_ROOT", &root); | |
| 507 | folder = Some(root); | |
| 508 | } else if !cfg!(target_os = "linux") || std::fs::create_dir_all("/work").is_err() { | |
| 509 | // Agent work expects g1t's sandbox layout (/work), which only a | |
| 510 | // Linux machine (or a container) set aside for it has. | |
| 511 | bail!("agent work runs in Docker, or directly on Linux where /work can be written; this runner can do neither"); | |
| 512 | } | |
| 513 | // Its own variables, less anything of the runner's. | |
| 514 | for (name, _) in std::env::vars_os() { | |
| 515 | if name.to_string_lossy().starts_with("G1T_RUNNER_") && name != "G1T_RUNNER_ROOT" { | |
| 516 | command.env_remove(&name); | |
| 517 | } | |
| 518 | } | |
| 519 | command.envs(&env); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 520 | // The job's log says it ran unsandboxed (actions/mod.rs). |
| 521 | command.env(JOB_NOTICE, sandbox::HOST_MODE_IN_JOB); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 522 | // A group of its own, so stopping it stops what its steps started. |
| 523 | #[cfg(unix)] | |
| 524 | std::os::unix::process::CommandExt::process_group(&mut command, 0); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 525 | log(&format!("Running {} {} on this machine, unsandboxed (host mode, deprecated)", work.kind, work.name)); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 526 | let child = command.spawn().context("could not start the job's process")?; |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 527 | Ok(Running { |
| 528 | id: work.id.clone(), | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 529 | repo: work.repo.clone(), |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 530 | kind: work.kind.clone(), |
| 531 | name: work.name.clone(), | |
| 532 | child, | |
| 533 | container: None, | |
| 534 | network: None, | |
| 535 | volume: None, | |
| 536 | engine: None, | |
| 537 | folder, | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 538 | native: None, |
| 539 | exit: None, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 540 | deadline: deadline(work), |
| 541 | }) | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 542 | } |
| 543 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 544 | /// A warning the harness puts at the top of the job's log. |
| 545 | pub const JOB_NOTICE: &str = "G1T_JOB_NOTICE"; | |
| 546 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 547 | fn deadline(work: &Assignment) -> Instant { |
| 548 | Instant::now() + Duration::from_secs(u64::from(work.timeout_minutes.max(1)) * 60 + 10 * 60) | |
| 549 | } | |
| 550 | ||
| 551 | /// Whether Docker answers. | |
| 552 | pub fn docker_ready() -> bool { | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 553 | docker() |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 554 | .args(["version", "--format", "{{.Server.Version}}"]) |
| 555 | .stdout(Stdio::null()) | |
| 556 | .stderr(Stdio::null()) | |
| 557 | .status() | |
| 558 | .is_ok_and(|status| status.success()) | |
| 559 | } | |
| 560 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 561 | /// Removes what jobs of this runner's left behind, if it stopped while one ran. |
| 562 | pub fn sweep(config: &Config) { | |
| 563 | let label = format!("label=sh.g1t.runner={}", config.runner); | |
| 564 | for (list, remove) in [ | |
| 565 | (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]), | |
| 566 | (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]), | |
| 567 | (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]), | |
| 568 | ] { | |
| 569 | let Ok(output) = docker().args(&list).output() else { return }; | |
| 570 | let ids: Vec<String> = String::from_utf8_lossy(&output.stdout).split_whitespace().map(str::to_owned).collect(); | |
| 571 | if !ids.is_empty() { | |
| 572 | quietly(docker().args(&remove).args(&ids)); | |
| 573 | } | |
| 574 | } | |
| 575 | } | |
| 576 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 577 | #[cfg(test)] |
| 578 | mod tests { | |
| 579 | use super::*; | |
| 580 | use serde_json::json; | |
| 581 | ||
| 582 | fn config() -> Config { | |
| 583 | Config { | |
| 584 | url: "https://g1t.sh".into(), | |
| 585 | api: "https://api.g1t.sh".into(), | |
| 586 | runner: "rnr_1".into(), | |
| 587 | name: "a".into(), | |
| 588 | credential: "g1tr_secret".into(), | |
| 589 | workspace: "acme".into(), | |
| 590 | repo: None, | |
| 591 | group: None, | |
| 592 | labels: vec![], | |
| 593 | ephemeral: false, | |
| 594 | work_dir: PathBuf::from("/tmp/w"), | |
| 595 | docker: true, | |
| 596 | image: None, | |
| 597 | agent_image: None, | |
| 598 | harness: None, | |
| 599 | auto_update: false, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 600 | memory: None, |
| 601 | cpus: None, | |
| 602 | pids_limit: None, | |
| 603 | job_docker: true, | |
| 604 | job_docker_image: None, | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 605 | native: false, |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 606 | } |
| 607 | } | |
| 608 | ||
| 609 | fn assignment(kind: &str) -> Assignment { | |
| 610 | serde_json::from_value(json!({ | |
| 611 | "kind": kind, "id": "job_1", "name": "build", "repo": "acme/web", "timeout_minutes": 60, | |
| 612 | "token": "jobtoken", "env": { "MODE": "checks", "G1T_TOKEN": "g1t_run", "G1T_API": "https://elsewhere" } | |
| 613 | })) | |
| 614 | .unwrap() | |
| 615 | } | |
| 616 | ||
| 617 | #[test] | |
| 618 | fn a_workflow_job_gets_its_own_token_and_never_the_runners() { | |
| 619 | let env = harness_env(&config(), &assignment("workflow")).unwrap(); | |
| 620 | assert_eq!(env["MODE"], "actions"); | |
| 621 | assert_eq!(env["ACTIONS_JOB"], "job_1"); | |
| 622 | assert_eq!(env["ACTIONS_TOKEN"], "jobtoken"); | |
| 623 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); | |
| 624 | assert!(env.values().all(|value| !value.contains("g1tr_"))); | |
| 625 | } | |
| 626 | ||
| 627 | #[test] | |
| 628 | fn agent_work_gets_the_sandboxs_environment_and_this_api() { | |
| 629 | let env = harness_env(&config(), &assignment("agent")).unwrap(); | |
| 630 | assert_eq!(env["MODE"], "checks"); | |
| 631 | assert_eq!(env["G1T_TOKEN"], "g1t_run"); | |
| 632 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); | |
| 633 | assert_eq!(env["G1T_ABUSE"], "off"); | |
| 634 | } | |
| 635 | ||
| 636 | #[test] | |
| 637 | fn unknown_work_is_refused() { | |
| 638 | assert!(harness_env(&config(), &assignment("deploy")).is_err()); | |
| 639 | } | |
| 640 | ||
| 641 | #[test] | |
| 642 | fn container_names_are_dockers() { | |
| 643 | assert_eq!(container_name("job_01ab"), "g1t-job_01ab"); | |
| 644 | assert_eq!(container_name("a/b c"), "g1t-a-b-c"); | |
| 645 | } | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 646 | |
| 647 | fn args_for(kind: &str, engine_args: Vec<String>) -> (Vec<String>, BTreeMap<String, String>) { | |
| 648 | let config = config(); | |
| 649 | let env = harness_env(&config, &assignment(kind)).unwrap(); | |
| 650 | let limits = Limits { memory: "6144m".into(), cpus: "4".into(), pids: 4096 }; | |
| 651 | let harness = PathBuf::from("/usr/local/bin/g1t-runner"); | |
| 652 | let plan = Plan { | |
| 653 | kind, | |
| 654 | name: "g1t-job_1", | |
| 655 | runner: "rnr_1", | |
| 656 | image: DEFAULT_IMAGE, | |
| 657 | harness: &harness, | |
| 658 | network: "g1t-job_1", | |
| 659 | volume: (kind == "workflow").then_some("g1t-job_1-work"), | |
| 660 | limits: &limits, | |
| 661 | env_names: env.keys().map(String::as_str).collect(), | |
| 662 | engine_args, | |
| 663 | }; | |
| 664 | (run_args(&plan), env.clone()) | |
| 665 | } | |
| 666 | ||
| 667 | fn value_of<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> { | |
| 668 | args.windows(2).filter(|w| w[0] == flag).map(|w| w[1].as_str()).collect() | |
| 669 | } | |
| 670 | ||
| 671 | #[test] | |
| 672 | fn a_jobs_container_is_walled_in() { | |
| 673 | let (args, _) = args_for("workflow", Vec::new()); | |
| 674 | assert_eq!(args[0], "run"); | |
| 675 | assert!(args.contains(&"--rm".to_owned())); | |
| 676 | assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]); | |
| 677 | assert_eq!(value_of(&args, "--cap-add"), JOB_CAPS.to_vec()); | |
| 678 | assert_eq!(value_of(&args, "--security-opt"), vec!["no-new-privileges"]); | |
| 679 | assert_eq!(value_of(&args, "--pids-limit"), vec!["4096"]); | |
| 680 | assert_eq!(value_of(&args, "--memory"), vec!["6144m"]); | |
| 681 | assert_eq!(value_of(&args, "--memory-swap"), vec!["6144m"]); | |
| 682 | assert_eq!(value_of(&args, "--cpus"), vec!["4"]); | |
| 683 | assert_eq!(value_of(&args, "--network"), vec!["g1t-job_1"]); | |
| 684 | for forbidden in ["--privileged", "--pid", "--userns", "--device", "host"] { | |
| 685 | assert!(!args.iter().any(|a| a == forbidden), "{forbidden} in {args:?}"); | |
| 686 | } | |
| 687 | assert!(!JOB_CAPS.iter().any(|c| c.starts_with("SYS_") || *c == "NET_ADMIN" || *c == "NET_RAW")); | |
| 688 | } | |
| 689 | ||
| 690 | #[test] | |
| 691 | fn a_jobs_only_mounts_are_the_harness_and_its_own_volume() { | |
| 692 | let (args, _) = args_for("workflow", Vec::new()); | |
| 693 | let mounts = value_of(&args, "-v"); | |
| 694 | assert_eq!(mounts, vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro", "g1t-job_1-work:/home/runner"]); | |
| 695 | assert!(!args.iter().any(|a| a.contains("docker.sock")), "no engine socket: {args:?}"); | |
| 696 | assert!(!args.iter().any(|a| a == "--mount")); | |
| 697 | } | |
| 698 | ||
| 699 | #[test] | |
| 700 | fn credentials_go_by_name_and_the_runners_never() { | |
| 701 | let (args, env) = args_for("workflow", Vec::new()); | |
| 702 | let passed = value_of(&args, "-e"); | |
| 703 | assert!(passed.contains(&"ACTIONS_TOKEN")); | |
| 704 | // Never a value on the command line, and never the runner's credential. | |
| 705 | assert!(!args.iter().any(|a| a.contains("jobtoken"))); | |
| 706 | assert!(!args.iter().any(|a| a.contains("g1tr_"))); | |
| 707 | assert!(env.values().all(|v| !v.contains("g1tr_secret"))); | |
| 708 | assert!(!env.contains_key("G1T_RUNNER_TOKEN")); | |
| 709 | } | |
| 710 | ||
| 711 | #[test] | |
| 712 | fn agent_work_keeps_sudo_for_its_guardrails_and_every_other_wall() { | |
| 713 | let (args, _) = args_for("agent", Vec::new()); | |
| 714 | assert!(value_of(&args, "--security-opt").is_empty()); | |
| 715 | assert!(value_of(&args, "--cap-add").contains(&"AUDIT_WRITE")); | |
| 716 | assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]); | |
| 717 | assert_eq!(value_of(&args, "-v"), vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro"]); | |
| 718 | } | |
| 719 | ||
| 720 | #[test] | |
| 721 | fn a_job_with_docker_gets_a_socket_of_its_own_not_the_runners() { | |
| 722 | 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()]; | |
| 723 | let (args, _) = args_for("workflow", engine_args); | |
| 724 | let mounts = value_of(&args, "-v"); | |
| 725 | assert!(mounts.contains(&"/run/g1t-runner/jobs/g1t-job_1/proxy:/run/g1t-docker:ro")); | |
| 726 | assert!(!mounts.iter().any(|m| m.starts_with("/var/run/docker.sock") || m.starts_with("/run/g1t-runner/docker.sock"))); | |
| 727 | // The entrypoint and image stay last. | |
| 728 | assert_eq!(&args[args.len() - 3..], ["--entrypoint", "/opt/g1t/g1t-runner", DEFAULT_IMAGE]); | |
| 729 | } | |
| 730 | ||
| 731 | #[test] | |
| 732 | fn limits_default_sensibly() { | |
| 733 | assert_eq!(default_memory("MemTotal: 16384000 kB\nMemFree: 1 kB\n"), Some("12000m".into())); | |
| 734 | assert_eq!(default_memory(""), None); | |
| 735 | let mut config = config(); | |
| 736 | config.memory = Some("2g".into()); | |
| 737 | config.pids_limit = Some(100); | |
| 738 | let limits = limits(&config); | |
| 739 | assert_eq!(limits.memory, "2g"); | |
| 740 | assert_eq!(limits.pids, 100); | |
| 741 | assert!(limits.cpus.parse::<f64>().unwrap() >= 1.0); | |
| 742 | } | |
| 743 | ||
| 744 | #[test] | |
| 745 | fn host_mode_refuses_a_public_repositorys_work() { | |
| 746 | let mut work = assignment("workflow"); | |
| 747 | work.visibility = Some("public".into()); | |
| 748 | let refused = refuse_public_on_host(&work).unwrap_err().to_string(); | |
| 749 | assert!(refused.contains("acme/web is public"), "{refused}"); | |
| 750 | work.visibility = Some("private".into()); | |
| 751 | assert!(refuse_public_on_host(&work).is_ok()); | |
| 752 | work.visibility = None; | |
| 753 | assert!(refuse_public_on_host(&work).is_ok()); | |
| 754 | // And refuses it before anything runs. | |
| 755 | work.visibility = Some("public".into()); | |
| 756 | assert!(start(&config(), Isolation::Host, Path::new("/tmp"), &work).is_err()); | |
| 757 | } | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 758 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.