g1t/crates/runner/src/selfhosted/service.rs
| 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 | } |