use anyhow::Result;
use std::path::PathBuf;
use super::{ServiceConfig, UpdateConfig, UpdateOutcome};
#[derive(Debug, Clone)]
pub enum DeployCommand {
Install {
dry_run: bool,
workspace: Option<String>,
},
Update { force: bool },
Version,
Help,
}
#[derive(Debug)]
pub enum CliAction {
Handled,
DryRun(String),
Version(String),
Help(String),
Run { workspace: std::path::PathBuf },
}
#[derive(Debug, Clone)]
pub struct LinuxService {
app: String,
repo_owner: String,
repo_name: String,
version: String,
bin_name: Option<String>,
extra_bins: Vec<String>,
user: Option<String>,
user_home: Option<PathBuf>,
arg_workspace: Option<String>,
description: Option<String>,
exec_args: String,
restart_sec: u32,
watchdog_sec: Option<u32>,
}
impl LinuxService {
pub fn new(
app: impl Into<String>,
repo_owner: impl Into<String>,
repo_name: impl Into<String>,
version: impl Into<String>,
) -> Self {
Self {
app: app.into(),
repo_owner: repo_owner.into(),
repo_name: repo_name.into(),
version: version.into(),
bin_name: None,
extra_bins: Vec::new(),
user: None,
user_home: None,
arg_workspace: None,
description: None,
exec_args: "-w {workspace}".to_string(),
restart_sec: 5,
watchdog_sec: None,
}
}
pub fn bin_name(mut self, name: impl Into<String>) -> Self {
self.bin_name = Some(name.into());
self
}
pub fn extra_bins<I, S>(mut self, bins: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extra_bins = bins.into_iter().map(Into::into).collect();
self
}
pub fn user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
pub fn user_home(mut self, home: impl Into<PathBuf>) -> Self {
self.user_home = Some(home.into());
self
}
pub fn workspace_arg(mut self, path: impl Into<String>) -> Self {
self.arg_workspace = Some(path.into());
self
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn exec_args(mut self, args: impl Into<String>) -> Self {
self.exec_args = args.into();
self
}
pub fn restart_sec(mut self, secs: u32) -> Self {
self.restart_sec = secs;
self
}
pub fn watchdog_sec(mut self, secs: u32) -> Self {
self.watchdog_sec = Some(secs);
self
}
pub fn service_config(&self) -> ServiceConfig {
let mut sc = ServiceConfig::new(&self.app)
.binaries(self.all_bins())
.exec_args(&self.exec_args)
.restart_sec(self.restart_sec);
if let Some(d) = &self.description {
sc = sc.description(d);
}
if let Some(u) = &self.user {
sc = sc.user(u);
}
if let Some(h) = &self.user_home {
sc = sc.user_home(h.clone());
}
if let Some(secs) = self.watchdog_sec {
sc = sc.watchdog_sec(secs);
}
if let Some(ws) = &self.arg_workspace {
let expanded = crate::util_args::expand_path(ws).unwrap_or_else(|_| PathBuf::from(ws));
sc = sc.workspace(expanded);
}
sc
}
pub fn update_config(&self) -> Result<UpdateConfig> {
let bin_dir = self.service_config().bin_dir_path()?;
let cfg = UpdateConfig::new(&self.repo_owner, &self.repo_name, &self.version)
.bin_name(self.primary_bin())
.extra_bins(self.extra_bins.clone())
.install_dir(bin_dir);
Ok(cfg)
}
pub fn workspace(&self) -> Result<PathBuf> {
self.service_config().workspace_path()
}
pub fn bin_dir(&self) -> Result<PathBuf> {
self.service_config().bin_dir_path()
}
pub async fn self_update(&self, force: bool) -> Result<UpdateOutcome> {
self.update_config()?.force(force).execute().await
}
pub fn install(&self) -> Result<()> {
self.service_config().install()
}
pub fn parse_deploy(&self) -> Option<DeployCommand> {
if crate::util_args::exist_arg("--version", "-V") {
return Some(DeployCommand::Version);
}
if crate::util_args::exist_arg("--help", "-h") {
return Some(DeployCommand::Help);
}
match crate::util_args::command().as_deref() {
Some("install") => Some(DeployCommand::Install {
dry_run: crate::util_args::exist_arg("--dry-run", "-n"),
workspace: crate::util_args::arg_value("--workspace", "-w"),
}),
Some("update") => Some(DeployCommand::Update {
force: crate::util_args::exist_arg("--force", "-f"),
}),
_ => None,
}
}
pub async fn dispatch(&self, cmd: DeployCommand) -> Result<CliAction> {
match cmd {
DeployCommand::Install { dry_run, workspace } => {
let mut svc = self.clone();
if let Some(w) = workspace {
svc.arg_workspace = Some(w);
}
if dry_run {
Ok(CliAction::DryRun(svc.service_config().generate_unit()?))
} else {
svc.install()?;
Ok(CliAction::Handled)
}
}
DeployCommand::Update { force } => {
let outcome = self.self_update(force).await?;
log::info!("update: {outcome:?}");
Ok(CliAction::Handled)
}
DeployCommand::Version => Ok(CliAction::Version(self.version.clone())),
DeployCommand::Help => Ok(CliAction::Help(self.deploy_usage())),
}
}
pub fn deploy_usage(&self) -> String {
let bin = self.primary_bin();
format!(
"Deploy subcommands:\n \
{bin} install [--dry-run|-n] [--workspace|-w <path>] install user systemd service (rootless)\n \
{bin} update [--force|-f] self-update from GitHub release\n \
{bin} --version | -V print version\n \
{bin} --help | -h print this help"
)
}
pub async fn handle_cli(&self) -> Result<CliAction> {
match self.parse_deploy() {
Some(cmd) => self.dispatch(cmd).await,
None => {
let mut svc = self.clone();
if let Some(w) = crate::util_args::arg_value("--workspace", "-w") {
svc.arg_workspace = Some(w);
}
Ok(CliAction::Run {
workspace: svc.workspace()?,
})
}
}
}
pub fn spawn_watchdog(&self) -> tokio::task::JoinHandle<()> {
crate::util_daemon::daemon()
}
fn primary_bin(&self) -> String {
self.bin_name.clone().unwrap_or_else(|| self.app.clone())
}
fn all_bins(&self) -> Vec<String> {
let mut bins = Vec::with_capacity(1 + self.extra_bins.len());
bins.push(self.primary_bin());
for b in &self.extra_bins {
if !bins.contains(b) {
bins.push(b.clone());
}
}
bins
}
}
#[cfg(test)]
mod tests {
use super::*;
fn svc() -> LinuxService {
LinuxService::new("alarm-server", "jm-observer", "alarm", "0.1.0")
.extra_bins(["alarm-cli"])
.user("alarm")
.user_home("/home/alarm")
}
#[test]
fn service_config_uses_local_bin_and_config_workspace() {
let unit = svc().service_config().generate_unit().unwrap();
assert!(unit.contains("ExecStart=/home/alarm/.local/bin/alarm-server -w /home/alarm/.config/alarm-server"));
assert!(unit.contains("WorkingDirectory=/home/alarm/.config/alarm-server"));
assert!(unit.contains("WantedBy=default.target"));
assert!(!unit.contains("User="));
}
#[test]
fn watchdog_propagates_to_unit() {
let unit = svc().watchdog_sec(30).service_config().generate_unit().unwrap();
assert!(unit.contains("Type=notify"));
assert!(unit.contains("WatchdogSec=30"));
}
#[test]
fn update_targets_local_bin() {
let bin_dir = svc().bin_dir().unwrap();
assert_eq!(bin_dir, PathBuf::from("/home/alarm/.local/bin"));
assert_eq!(
svc().all_bins(),
vec!["alarm-server".to_string(), "alarm-cli".to_string()]
);
}
#[test]
fn explicit_workspace_arg_overrides_default() {
let ws = svc().workspace_arg("/var/lib/alarm").workspace().unwrap();
assert_eq!(ws, PathBuf::from("/var/lib/alarm"));
}
}