206 lines
6.0 KiB
Rust
206 lines
6.0 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::process::Stdio;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
|
|
const UNIT_NAME: &str = "picobot.service";
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum ServiceCommand {
|
|
Install,
|
|
Start,
|
|
Stop,
|
|
Status,
|
|
Restart,
|
|
Uninstall,
|
|
}
|
|
|
|
pub async fn execute(command: ServiceCommand) -> Result<()> {
|
|
ensure_systemd_supported()?;
|
|
|
|
match command {
|
|
ServiceCommand::Install => install().await,
|
|
ServiceCommand::Start => run_systemctl(&["start", UNIT_NAME]).await,
|
|
ServiceCommand::Stop => run_systemctl(&["stop", UNIT_NAME]).await,
|
|
ServiceCommand::Status => run_systemctl(&["status", "--no-pager", UNIT_NAME]).await,
|
|
ServiceCommand::Restart => run_systemctl(&["restart", UNIT_NAME]).await,
|
|
ServiceCommand::Uninstall => uninstall().await,
|
|
}
|
|
}
|
|
|
|
fn ensure_systemd_supported() -> Result<()> {
|
|
if cfg!(target_os = "linux") {
|
|
Ok(())
|
|
} else {
|
|
bail!("service management currently requires Linux with a systemd user session")
|
|
}
|
|
}
|
|
|
|
async fn install() -> Result<()> {
|
|
let executable = std::env::current_exe()
|
|
.context("failed to determine the PicoBot executable path")?
|
|
.canonicalize()
|
|
.context("failed to canonicalize the PicoBot executable path")?;
|
|
let working_directory = std::env::current_dir()
|
|
.context("failed to determine the current working directory")?
|
|
.canonicalize()
|
|
.context("failed to canonicalize the current working directory")?;
|
|
let service_path = service_path()?;
|
|
|
|
let parent = service_path
|
|
.parent()
|
|
.context("systemd user service path has no parent directory")?;
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
|
std::fs::write(
|
|
&service_path,
|
|
render_systemd_unit(&executable, &working_directory),
|
|
)
|
|
.with_context(|| format!("failed to write {}", service_path.display()))?;
|
|
|
|
if let Err(error) = async {
|
|
run_systemctl(&["daemon-reload"]).await?;
|
|
run_systemctl(&["enable", UNIT_NAME]).await
|
|
}
|
|
.await
|
|
{
|
|
return Err(error).with_context(|| {
|
|
format!(
|
|
"service file was written to {}, but systemd registration failed",
|
|
service_path.display()
|
|
)
|
|
});
|
|
}
|
|
|
|
println!(
|
|
"Installed and enabled {UNIT_NAME} at {}",
|
|
service_path.display()
|
|
);
|
|
println!("Start it with: picobot service start");
|
|
Ok(())
|
|
}
|
|
|
|
async fn uninstall() -> Result<()> {
|
|
let service_path = service_path()?;
|
|
if !service_path.exists() {
|
|
println!("{UNIT_NAME} is not installed at {}", service_path.display());
|
|
return Ok(());
|
|
}
|
|
|
|
run_systemctl(&["disable", "--now", UNIT_NAME]).await?;
|
|
std::fs::remove_file(&service_path)
|
|
.with_context(|| format!("failed to remove {}", service_path.display()))?;
|
|
run_systemctl(&["daemon-reload"]).await?;
|
|
let _ = run_systemctl_quiet(&["reset-failed", UNIT_NAME]).await;
|
|
|
|
println!("Uninstalled {UNIT_NAME}");
|
|
Ok(())
|
|
}
|
|
|
|
fn service_path() -> Result<PathBuf> {
|
|
let home = dirs::home_dir().context("failed to determine the home directory")?;
|
|
Ok(home
|
|
.join(".config")
|
|
.join("systemd")
|
|
.join("user")
|
|
.join(UNIT_NAME))
|
|
}
|
|
|
|
async fn run_systemctl(args: &[&str]) -> Result<()> {
|
|
let status = tokio::process::Command::new("systemctl")
|
|
.arg("--user")
|
|
.args(args)
|
|
.status()
|
|
.await
|
|
.with_context(|| format!("failed to execute systemctl --user {}", args.join(" ")))?;
|
|
|
|
if !status.success() {
|
|
bail!(
|
|
"systemctl --user {} failed with status {}",
|
|
args.join(" "),
|
|
status
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_systemctl_quiet(args: &[&str]) -> Result<()> {
|
|
let status = tokio::process::Command::new("systemctl")
|
|
.arg("--user")
|
|
.args(args)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.await
|
|
.with_context(|| format!("failed to execute systemctl --user {}", args.join(" ")))?;
|
|
|
|
if !status.success() {
|
|
bail!(
|
|
"systemctl --user {} failed with status {}",
|
|
args.join(" "),
|
|
status
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn render_systemd_unit(executable: &Path, working_directory: &Path) -> String {
|
|
let executable = quote_systemd_value(executable.to_string_lossy().as_ref());
|
|
let working_directory = quote_systemd_value(working_directory.to_string_lossy().as_ref());
|
|
|
|
format!(
|
|
"[Unit]\n\
|
|
Description=PicoBot Gateway\n\
|
|
After=network-online.target\n\
|
|
Wants=network-online.target\n\
|
|
\n\
|
|
[Service]\n\
|
|
Type=simple\n\
|
|
ExecStart={executable} gateway\n\
|
|
WorkingDirectory={working_directory}\n\
|
|
Restart=on-failure\n\
|
|
RestartSec=5\n\
|
|
Environment=RUST_LOG=info\n\
|
|
\n\
|
|
[Install]\n\
|
|
WantedBy=default.target\n"
|
|
)
|
|
}
|
|
|
|
fn quote_systemd_value(value: &str) -> String {
|
|
let escaped = value
|
|
.replace('%', "%%")
|
|
.replace('\\', "\\\\")
|
|
.replace('"', "\\\"");
|
|
format!("\"{escaped}\"")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn unit_starts_current_binary_as_gateway_and_restarts_on_failure() {
|
|
let unit = render_systemd_unit(
|
|
Path::new("/home/alice/bin/picobot"),
|
|
Path::new("/home/alice/picobot"),
|
|
);
|
|
|
|
assert!(unit.contains("ExecStart=\"/home/alice/bin/picobot\" gateway"));
|
|
assert!(unit.contains("WorkingDirectory=\"/home/alice/picobot\""));
|
|
assert!(unit.contains("Restart=on-failure"));
|
|
assert!(unit.contains("WantedBy=default.target"));
|
|
}
|
|
|
|
#[test]
|
|
fn unit_quotes_spaces_quotes_backslashes_and_specifiers() {
|
|
let unit = render_systemd_unit(
|
|
Path::new("/home/alice/Pico Bot/%i/\"picobot\""),
|
|
Path::new("/home/alice/work\\space"),
|
|
);
|
|
|
|
assert!(unit.contains("ExecStart=\"/home/alice/Pico Bot/%%i/\\\"picobot\\\"\" gateway"));
|
|
assert!(unit.contains("WorkingDirectory=\"/home/alice/work\\\\space\""));
|
|
}
|
|
}
|