g1t/crates/runner/src/selfhosted/service.rs

265 lines11,539 bytesCodeBlame
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
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use anyhow::{Context, Result, bail};
13
14use super::config::Config;
15use super::log;
16
17/// What the service is called: one per runner, so a machine can run several.
18pub 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
27fn 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.
36pub 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.
48pub 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
61fn 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
66pub 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)]
144mod 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))]
245mod windows {
246 pub fn serve(_: &str) -> anyhow::Result<()> {
247 anyhow::bail!("`service run` is for Windows")
248 }
249}
250
251#[cfg(test)]
252mod 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}