g1t/crates/runner/src/selfhosted/service.rs
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 `g1t-runner` as a service that starts with the machine and |
| 2 | //! restarts if it stops: systemd on Linux, launchd on macOS, the Service | |
| 3 | //! Control Manager on Windows. | |
| 4 | //! | |
| 5 | //! g1t-runner service install # then it is running | |
| 6 | //! g1t-runner service stop|start|status | |
| 7 | //! g1t-runner service uninstall | |
| 8 | ||
| 9 | use std::path::{Path, PathBuf}; | |
| 10 | use std::process::Command; | |
| 11 | ||
| 12 | use anyhow::{Context, Result, bail}; | |
| 13 | ||
| 14 | use super::config::Config; | |
| 15 | use super::log; | |
| 16 | ||
| 17 | /// What the service is called: one per runner, so a machine can run several. | |
| 18 | pub fn service_name(config: &Config) -> String { | |
| 19 | let clean: String = config | |
| 20 | .name | |
| 21 | .chars() | |
| 22 | .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c.to_ascii_lowercase() } else { '-' }) | |
| 23 | .collect(); | |
| 24 | format!("g1t-runner-{clean}") | |
| 25 | } | |
| 26 | ||
| 27 | fn run(program: &str, args: &[&str]) -> Result<()> { | |
| 28 | let status = Command::new(program).args(args).status().with_context(|| format!("could not run {program}"))?; | |
| 29 | if !status.success() { | |
| 30 | bail!("{program} {} failed ({status})", args.join(" ")); | |
| 31 | } | |
| 32 | Ok(()) | |
| 33 | } | |
| 34 | ||
| 35 | /// The systemd unit for a runner. | |
| 36 | pub fn systemd_unit(exe: &Path, folder: &Path, user: Option<&str>) -> String { | |
| 37 | let user = user.map(|u| format!("User={u}\n")).unwrap_or_default(); | |
| 38 | format!( | |
| 39 | "[Unit]\nDescription=g1t self-hosted runner\nAfter=network-online.target docker.service\nWants=network-online.target\n\n\ | |
| 40 | [Service]\nExecStart={} run --dir {}\n{user}Restart=always\nRestartSec=5\nKillSignal=SIGINT\nTimeoutStopSec=60\n\n\ | |
| 41 | [Install]\nWantedBy=multi-user.target\n", | |
| 42 | exe.display(), | |
| 43 | folder.display() | |
| 44 | ) | |
| 45 | } | |
| 46 | ||
| 47 | /// The launchd job for a runner. | |
| 48 | pub fn launchd_plist(label: &str, exe: &Path, folder: &Path) -> String { | |
| 49 | let log = folder.join("runner.log"); | |
| 50 | format!( | |
| 51 | "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\ | |
| 52 | <plist version=\"1.0\">\n<dict>\n <key>Label</key><string>{label}</string>\n <key>ProgramArguments</key>\n <array>\n <string>{}</string>\n <string>run</string>\n <string>--dir</string>\n <string>{}</string>\n </array>\n\ | |
| 53 | \x20 <key>RunAtLoad</key><true/>\n <key>KeepAlive</key><true/>\n <key>StandardOutPath</key><string>{}</string>\n <key>StandardErrorPath</key><string>{}</string>\n</dict>\n</plist>\n", | |
| 54 | exe.display(), | |
| 55 | folder.display(), | |
| 56 | log.display(), | |
| 57 | log.display() | |
| 58 | ) | |
| 59 | } | |
| 60 | ||
| 61 | fn launchd_path(label: &str) -> PathBuf { | |
| 62 | let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")); | |
| 63 | home.join("Library/LaunchAgents").join(format!("{label}.plist")) | |
| 64 | } | |
| 65 | ||
| 66 | pub fn main(action: &str, config: &Config, folder: &Path) -> Result<()> { | |
| 67 | let exe = std::env::current_exe()?; | |
| 68 | let name = service_name(config); | |
| 69 | if cfg!(target_os = "linux") { | |
| 70 | let unit = PathBuf::from(format!("/etc/systemd/system/{name}.service")); | |
| 71 | match action { | |
| 72 | "install" => { | |
| 73 | let user = std::env::var("SUDO_USER").ok().or_else(|| std::env::var("USER").ok()).filter(|u| u != "root"); | |
| 74 | std::fs::write(&unit, systemd_unit(&exe, folder, user.as_deref())) | |
| 75 | .with_context(|| format!("could not write {} (run with sudo)", unit.display()))?; | |
| 76 | run("systemctl", &["daemon-reload"])?; | |
| 77 | run("systemctl", &["enable", "--now", &name])?; | |
| 78 | log(&format!("Installed and started {name}. Its log: journalctl -u {name} -f")); | |
| 79 | } | |
| 80 | "uninstall" => { | |
| 81 | let _ = run("systemctl", &["disable", "--now", &name]); | |
| 82 | std::fs::remove_file(&unit).with_context(|| format!("could not remove {} (run with sudo)", unit.display()))?; | |
| 83 | run("systemctl", &["daemon-reload"])?; | |
| 84 | log(&format!("Removed {name}.")); | |
| 85 | } | |
| 86 | "start" | "stop" | "status" => run("systemctl", &[action, &name])?, | |
| 87 | _ => bail!("service takes install, uninstall, start, stop or status"), | |
| 88 | } | |
| 89 | return Ok(()); | |
| 90 | } | |
| 91 | if cfg!(target_os = "macos") { | |
| 92 | let label = format!("sh.g1t.{name}"); | |
| 93 | let plist = launchd_path(&label); | |
| 94 | match action { | |
| 95 | "install" => { | |
| 96 | std::fs::create_dir_all(plist.parent().unwrap_or(folder))?; | |
| 97 | std::fs::write(&plist, launchd_plist(&label, &exe, folder))?; | |
| 98 | run("launchctl", &["load", "-w", &plist.display().to_string()])?; | |
| 99 | log(&format!("Installed and started {label}. Its log: {}", folder.join("runner.log").display())); | |
| 100 | } | |
| 101 | "uninstall" => { | |
| 102 | let _ = run("launchctl", &["unload", "-w", &plist.display().to_string()]); | |
| 103 | let _ = std::fs::remove_file(&plist); | |
| 104 | log(&format!("Removed {label}.")); | |
| 105 | } | |
| 106 | "start" => run("launchctl", &["load", "-w", &plist.display().to_string()])?, | |
| 107 | "stop" => run("launchctl", &["unload", &plist.display().to_string()])?, | |
| 108 | "status" => run("launchctl", &["list", &label])?, | |
| 109 | _ => bail!("service takes install, uninstall, start, stop or status"), | |
| 110 | } | |
| 111 | return Ok(()); | |
| 112 | } | |
| 113 | if cfg!(windows) { | |
| 114 | match action { | |
| 115 | "install" => { | |
| 116 | let bin = format!("\"{}\" service run --dir \"{}\"", exe.display(), folder.display()); | |
| 117 | run("sc.exe", &["create", &name, "binPath=", &bin, "start=", "auto", "DisplayName=", &format!("g1t runner {}", config.name)]) | |
| 118 | .context("could not create the service (run from an Administrator prompt)")?; | |
| 119 | let _ = run("sc.exe", &["description", &name, "g1t self-hosted runner: runs this workspace's workflow jobs."]); | |
| 120 | let _ = run("sc.exe", &["failure", &name, "reset=", "86400", "actions=", "restart/5000/restart/5000/restart/30000"]); | |
| 121 | run("sc.exe", &["start", &name])?; | |
| 122 | log(&format!("Installed and started the {name} service.")); | |
| 123 | } | |
| 124 | "uninstall" => { | |
| 125 | let _ = run("sc.exe", &["stop", &name]); | |
| 126 | run("sc.exe", &["delete", &name])?; | |
| 127 | log(&format!("Removed the {name} service.")); | |
| 128 | } | |
| 129 | "start" => run("sc.exe", &["start", &name])?, | |
| 130 | "stop" => run("sc.exe", &["stop", &name])?, | |
| 131 | "status" => run("sc.exe", &["query", &name])?, | |
| 132 | "run" => windows::serve(&name)?, | |
| 133 | _ => bail!("service takes install, uninstall, start, stop or status"), | |
| 134 | } | |
| 135 | return Ok(()); | |
| 136 | } | |
| 137 | bail!("services are not supported on {}; run `g1t-runner run` under your own supervisor", std::env::consts::OS) | |
| 138 | } | |
| 139 | ||
| 140 | /// The Service Control Manager's side: when Windows starts the service, | |
| 141 | /// the runner's loop runs on another thread while this one answers the | |
| 142 | /// manager, and a stop request ends the loop between polls. | |
| 143 | #[cfg(windows)] | |
| 144 | mod windows { | |
| 145 | use std::ffi::c_void; | |
| 146 | use std::sync::atomic::Ordering; | |
| 147 | ||
| 148 | use anyhow::{Result, bail}; | |
| 149 | ||
| 150 | #[repr(C)] | |
| 151 | struct ServiceTableEntry { | |
| 152 | name: *const u16, | |
| 153 | main: Option<unsafe extern "system" fn(u32, *mut *mut u16)>, | |
| 154 | } | |
| 155 | ||
| 156 | #[repr(C)] | |
| 157 | struct ServiceStatus { | |
| 158 | service_type: u32, | |
| 159 | current_state: u32, | |
| 160 | controls_accepted: u32, | |
| 161 | win32_exit_code: u32, | |
| 162 | service_exit_code: u32, | |
| 163 | check_point: u32, | |
| 164 | wait_hint: u32, | |
| 165 | } | |
| 166 | ||
| 167 | #[link(name = "advapi32")] | |
| 168 | unsafe extern "system" { | |
| 169 | fn StartServiceCtrlDispatcherW(table: *const ServiceTableEntry) -> i32; | |
| 170 | fn RegisterServiceCtrlHandlerExW( | |
| 171 | name: *const u16, | |
| 172 | handler: Option<unsafe extern "system" fn(u32, u32, *mut c_void, *mut c_void) -> u32>, | |
| 173 | context: *mut c_void, | |
| 174 | ) -> *mut c_void; | |
| 175 | fn SetServiceStatus(handle: *mut c_void, status: *const ServiceStatus) -> i32; | |
| 176 | } | |
| 177 | ||
| 178 | const OWN_PROCESS: u32 = 0x10; | |
| 179 | const STOPPED: u32 = 1; | |
| 180 | const STOP_PENDING: u32 = 3; | |
| 181 | const RUNNING: u32 = 4; | |
| 182 | const ACCEPT_STOP: u32 = 1 | 4; // stop, shutdown | |
| 183 | const CONTROL_STOP: u32 = 1; | |
| 184 | const CONTROL_SHUTDOWN: u32 = 5; | |
| 185 | ||
| 186 | static HANDLE: std::sync::atomic::AtomicPtr<c_void> = std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()); | |
| 187 | static NAME: std::sync::OnceLock<Vec<u16>> = std::sync::OnceLock::new(); | |
| 188 | ||
| 189 | fn status(state: u32, exit: u32) { | |
| 190 | let status = ServiceStatus { | |
| 191 | service_type: OWN_PROCESS, | |
| 192 | current_state: state, | |
| 193 | controls_accepted: if state == RUNNING { ACCEPT_STOP } else { 0 }, | |
| 194 | win32_exit_code: exit, | |
| 195 | service_exit_code: 0, | |
| 196 | check_point: 0, | |
| 197 | wait_hint: if state == STOP_PENDING { 60_000 } else { 0 }, | |
| 198 | }; | |
| 199 | let handle = HANDLE.load(Ordering::SeqCst); | |
| 200 | if !handle.is_null() { | |
| 201 | // SAFETY: the handle came from RegisterServiceCtrlHandlerExW, and | |
| 202 | // the manager allows SetServiceStatus from any thread. | |
| 203 | unsafe { | |
| 204 | SetServiceStatus(handle, &status); | |
| 205 | } | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | unsafe extern "system" fn handler(control: u32, _: u32, _: *mut c_void, _: *mut c_void) -> u32 { | |
| 210 | if control == CONTROL_STOP || control == CONTROL_SHUTDOWN { | |
| 211 | super::super::STOP.store(true, Ordering::SeqCst); | |
| 212 | status(STOP_PENDING, 0); | |
| 213 | } | |
| 214 | 0 | |
| 215 | } | |
| 216 | ||
| 217 | unsafe extern "system" fn service_main(_: u32, _: *mut *mut u16) { | |
| 218 | let name = NAME.get().cloned().unwrap_or_default(); | |
| 219 | // SAFETY: the name is a NUL-terminated UTF-16 string that lives as | |
| 220 | // long as the process; the handler is a plain function. | |
| 221 | let handle = unsafe { RegisterServiceCtrlHandlerExW(name.as_ptr(), Some(handler), std::ptr::null_mut()) }; | |
| 222 | HANDLE.store(handle, Ordering::SeqCst); | |
| 223 | status(RUNNING, 0); | |
| 224 | let code = super::super::serve_from_service(); | |
| 225 | status(STOPPED, if code == 0 { 0 } else { 1066 }); | |
| 226 | } | |
| 227 | ||
| 228 | pub fn serve(name: &str) -> Result<()> { | |
| 229 | let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect(); | |
| 230 | let _ = NAME.set(wide.clone()); | |
| 231 | let table = [ | |
| 232 | ServiceTableEntry { name: wide.as_ptr(), main: Some(service_main) }, | |
| 233 | ServiceTableEntry { name: std::ptr::null(), main: None }, | |
| 234 | ]; | |
| 235 | // SAFETY: the table is terminated by a null entry and outlives the | |
| 236 | // call, which returns when the service stops. | |
| 237 | if unsafe { StartServiceCtrlDispatcherW(table.as_ptr()) } == 0 { | |
| 238 | bail!("`service run` is for Windows to start; use `g1t-runner run` from a prompt"); | |
| 239 | } | |
| 240 | Ok(()) | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | #[cfg(not(windows))] | |
| 245 | mod windows { | |
| 246 | pub fn serve(_: &str) -> anyhow::Result<()> { | |
| 247 | anyhow::bail!("`service run` is for Windows") | |
| 248 | } | |
| 249 | } | |
| 250 | ||
| 251 | #[cfg(test)] | |
| 252 | mod tests { | |
| 253 | use super::*; | |
| 254 | ||
| 255 | #[test] | |
| 256 | fn units_run_this_runner_from_its_folder() { | |
| 257 | let unit = systemd_unit(Path::new("/usr/local/bin/g1t-runner"), Path::new("/home/ci/.g1t-runner"), Some("ci")); | |
| 258 | assert!(unit.contains("ExecStart=/usr/local/bin/g1t-runner run --dir /home/ci/.g1t-runner")); | |
| 259 | assert!(unit.contains("User=ci")); | |
| 260 | assert!(unit.contains("Restart=always")); | |
| 261 | let plist = launchd_plist("sh.g1t.g1t-runner-mac", Path::new("/opt/g1t-runner"), Path::new("/Users/ci/.g1t-runner")); | |
| 262 | assert!(plist.contains("<string>run</string>")); | |
| 263 | assert!(plist.contains("<key>KeepAlive</key><true/>")); | |
| 264 | } | |
| 265 | } |