| 1 | //! The Sandboxed backend on Linux today: the runner's image under Sysbox, |
| 2 | //! through this computer's Docker, exactly as docs.g1t.sh/guides/ |
| 3 | //! self-hosted-runners/#run-it-as-a-container runs it. Sysbox gives the |
| 4 | //! runner's container a user namespace of its own and a Docker Engine |
| 5 | //! inside it, so each job is a container inside the runner's container and |
| 6 | //! neither reaches this computer (`nested-container` isolation). The |
| 7 | //! machine's Docker socket is never given to it. |
| 8 | //! |
| 9 | //! The registration and credential are kept on a volume of their own, so |
| 10 | //! the container can be replaced, when a newer image is out, without |
| 11 | //! registering again. Keeping it up to date is pulling the image and, |
| 12 | //! while the runner is idle, putting a new container in the old one's place. |
| 13 | |
| 14 | use std::process::{Child, Stdio}; |
| 15 | |
| 16 | use super::backend::quiet; |
| 17 | |
| 18 | /// The runner's image, published with each release. |
| 19 | pub const IMAGE: &str = "g1t.sh/flagon-io/g1t-runner:latest"; |
| 20 | /// The container the app runs, and the volume its registration is kept on. |
| 21 | pub const NAME: &str = "g1t-desktop-runner"; |
| 22 | pub const VOLUME: &str = "g1t-desktop-runner"; |
| 23 | |
| 24 | fn docker_ok(args: &[&str]) -> bool { |
| 25 | quiet("docker").args(args).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|status| status.success()) |
| 26 | } |
| 27 | |
| 28 | fn docker_text(args: &[&str]) -> Option<String> { |
| 29 | let out = quiet("docker").args(args).stderr(Stdio::null()).output().ok()?; |
| 30 | out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()).filter(|text| !text.is_empty()) |
| 31 | } |
| 32 | |
| 33 | /// The arguments of the `docker run` that starts the runner. Each |
| 34 | /// variable's value comes from the environment of the `docker` command, |
| 35 | /// never its command line, so a registration token is not in the |
| 36 | /// computer's list of processes. |
| 37 | pub fn run_args(names: &[&str]) -> Vec<String> { |
| 38 | let mut args: Vec<String> = ["run", "-d", "--name", NAME, "--runtime=sysbox-runc", "--label", "sh.g1t.desktop=runner", "-v"] |
| 39 | .iter() |
| 40 | .map(|s| s.to_string()) |
| 41 | .collect(); |
| 42 | args.push(format!("{VOLUME}:/data")); |
| 43 | for name in names { |
| 44 | args.push("-e".into()); |
| 45 | args.push((*name).to_owned()); |
| 46 | } |
| 47 | args.push(IMAGE.into()); |
| 48 | args.push("register-and-run".into()); |
| 49 | args |
| 50 | } |
| 51 | |
| 52 | /// Fetches the newest image; whether the one here changed. |
| 53 | pub fn pull() -> bool { |
| 54 | let before = docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]); |
| 55 | let pulled = docker_ok(&["pull", "--quiet", IMAGE]); |
| 56 | pulled && docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]) != before |
| 57 | } |
| 58 | |
| 59 | /// Whether the image is here at all. |
| 60 | pub fn have_image() -> bool { |
| 61 | docker_ok(&["image", "inspect", IMAGE]) |
| 62 | } |
| 63 | |
| 64 | /// Whether the running container is from an older image than the one here. |
| 65 | pub fn outdated() -> bool { |
| 66 | let image = docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]); |
| 67 | let running = docker_text(&["inspect", "--format", "{{.Image}}", NAME]); |
| 68 | matches!((image, running), (Some(image), Some(running)) if image != running) |
| 69 | } |
| 70 | |
| 71 | /// Whether a registration is kept here. |
| 72 | pub fn registered() -> bool { |
| 73 | docker_ok(&["volume", "inspect", VOLUME]) |
| 74 | } |
| 75 | |
| 76 | /// Starts the runner's container in place of any earlier one, then |
| 77 | /// follows its log: the child's output is the runner's. |
| 78 | pub fn start(envs: &[(&str, String)]) -> Result<Child, String> { |
| 79 | let _ = docker_ok(&["rm", "-f", NAME]); |
| 80 | let names: Vec<&str> = envs.iter().map(|(name, _)| *name).collect(); |
| 81 | let out = quiet("docker") |
| 82 | .args(run_args(&names)) |
| 83 | .envs(envs.iter().map(|(name, value)| (*name, value.as_str()))) |
| 84 | .stdout(Stdio::null()) |
| 85 | .output() |
| 86 | .map_err(|e| format!("could not run docker: {e}"))?; |
| 87 | if !out.status.success() { |
| 88 | return Err(String::from_utf8_lossy(&out.stderr).trim().to_owned()); |
| 89 | } |
| 90 | quiet("docker") |
| 91 | .args(["logs", "--follow", NAME]) |
| 92 | .stdout(Stdio::piped()) |
| 93 | .stderr(Stdio::piped()) |
| 94 | .spawn() |
| 95 | .map_err(|e| format!("could not follow the runner's log: {e}")) |
| 96 | } |
| 97 | |
| 98 | /// How the container's runner ended, while the container is still there. |
| 99 | pub fn exit_code() -> Option<i32> { |
| 100 | docker_text(&["inspect", "--format", "{{.State.ExitCode}}", NAME]).and_then(|code| code.parse().ok()) |
| 101 | } |
| 102 | |
| 103 | /// Stops the container and removes it; its volume stays. |
| 104 | pub fn stop(logs: Option<&mut Child>) { |
| 105 | let _ = docker_ok(&["stop", "--time", "20", NAME]); |
| 106 | let _ = docker_ok(&["rm", "-f", NAME]); |
| 107 | if let Some(logs) = logs { |
| 108 | let _ = logs.kill(); |
| 109 | let _ = logs.wait(); |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | /// Unregisters the runner kept on the volume, then removes the volume. |
| 114 | pub fn forget() { |
| 115 | if registered() { |
| 116 | let mut args = vec!["run", "--rm", "--runtime=sysbox-runc", "-v"]; |
| 117 | let mount = format!("{VOLUME}:/data"); |
| 118 | args.push(&mount); |
| 119 | args.extend([IMAGE, "remove"]); |
| 120 | let _ = docker_ok(&args); |
| 121 | let _ = docker_ok(&["volume", "rm", "-f", VOLUME]); |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | #[cfg(test)] |
| 126 | mod tests { |
| 127 | use super::*; |
| 128 | |
| 129 | #[test] |
| 130 | fn the_runner_runs_under_sysbox_without_the_machines_docker() { |
| 131 | let args = run_args(&["G1T_RUNNER_URL", "G1T_RUNNER_TOKEN"]); |
| 132 | assert!(args.contains(&"--runtime=sysbox-runc".to_owned())); |
| 133 | assert!(args.contains(&"g1t-desktop-runner:/data".to_owned())); |
| 134 | assert!(!args.iter().any(|a| a.contains("docker.sock") || a == "--privileged"), "{args:?}"); |
| 135 | // Values never on the command line: only the names. |
| 136 | assert!(args.windows(2).any(|w| w[0] == "-e" && w[1] == "G1T_RUNNER_TOKEN")); |
| 137 | assert_eq!(&args[args.len() - 2..], [IMAGE, "register-and-run"]); |
| 138 | } |
| 139 | } |