use std::path::PathBuf;
use super::InstallOptions;
use crate::config::DaemonConfig;
const SQRYD_VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn generate_user_unit(cfg: &DaemonConfig, opts: &InstallOptions) -> String {
let exe = opts.exe_path.clone().unwrap_or_else(resolve_current_exe);
let memory_max = format_memory_max(cfg.memory_limit_mb);
format!(
r#"# sqryd version {version}
# Generated by `sqryd install-systemd-user`. Do not edit manually —
# re-run the subcommand to regenerate after upgrading sqryd.
#
# Install:
# cp this-file ~/.config/systemd/user/sqryd.service
# systemctl --user daemon-reload
# systemctl --user enable --now sqryd
[Unit]
Description=sqry daemon (semantic code-search graph service)
Documentation=https://github.com/verivus-oss/sqry
[Service]
Type=notify
NotifyAccess=main
ExecStart={exe} foreground
Restart=on-failure
RestartSec=3
RuntimeDirectory=sqry
RuntimeDirectoryMode=0700
# sqryd will use %t/sqry/sqryd.sock by default (XDG_RUNTIME_DIR/sqry/sqryd.sock).
Environment=SQRY_DAEMON_SOCKET=%t/sqry/sqryd.sock
Environment=RUST_BACKTRACE=1
# NOTE: Under systemd management, in-process log rotation is disabled.
# sqryd detects NOTIFY_SOCKET and skips RollingSizeAppender; all output
# is written to stdout/stderr and captured by systemd via StandardOutput
# below. Rotation should be managed by systemd + logrotate on the
# system log path.
StandardOutput=append:%t/sqry/sqryd.log
StandardError=inherit
{memory_max}
[Install]
WantedBy=default.target
"#,
version = SQRYD_VERSION,
exe = exe.display(),
memory_max = memory_max,
)
}
pub fn generate_system_unit(cfg: &DaemonConfig, opts: &InstallOptions) -> String {
let exe = opts.exe_path.clone().unwrap_or_else(resolve_current_exe);
let memory_max = format_memory_max(cfg.memory_limit_mb);
format!(
r#"# sqryd version {version}
# Generated by `sqryd install-systemd-system`. Do not edit manually —
# re-run the subcommand to regenerate after upgrading sqryd.
#
# Install:
# cp this-file /etc/systemd/system/sqryd@.service
# systemctl daemon-reload
# systemctl enable --now sqryd@<username>
#
# The %i instance specifier is the POSIX user account to run the daemon as.
# Validate the account name with `id <username>` before enabling.
[Unit]
Description=sqry daemon (semantic code-search graph service) for %i
Documentation=https://github.com/verivus-oss/sqry
After=network.target
[Service]
Type=notify
NotifyAccess=main
User=%i
Group=%i
ExecStart={exe} foreground
Restart=on-failure
RestartSec=3
StateDirectory=sqry/%i
RuntimeDirectory=sqry/%i
RuntimeDirectoryMode=0700
LogsDirectory=sqry/%i
# Each sqryd@<user> instance binds its own socket under the per-user runtime
# directory (/run/sqry/<user>/sqryd.sock). The %i specifier is included so
# that multiple instances cannot collide on the same socket path.
Environment=SQRY_DAEMON_SOCKET=%t/sqry/%i/sqryd.sock
Environment=RUST_BACKTRACE=1
# NOTE: Under systemd management, in-process log rotation is disabled.
# sqryd detects NOTIFY_SOCKET and skips RollingSizeAppender; all output
# is written to stdout/stderr and captured by systemd via StandardOutput
# below. Rotation should be managed by systemd + logrotate.
# LogsDirectory= above provisions /var/log/sqry/<user>/ with correct ownership.
StandardOutput=append:/var/log/sqry/%i/sqryd.log
StandardError=inherit
{memory_max}
[Install]
WantedBy=multi-user.target
"#,
version = SQRYD_VERSION,
exe = exe.display(),
memory_max = memory_max,
)
}
pub fn resolve_system_unit_user(opts: &InstallOptions) -> Result<String, String> {
let candidate = match &opts.user {
Some(u) => u.clone(),
None => std::env::var("USER").map_err(|_| {
"cannot resolve system-unit user: neither --user nor $USER is set; \
use `sqryd install-systemd-system --user <username>`"
.to_owned()
})?,
};
if users::get_user_by_name(candidate.as_str()).is_some() {
Ok(candidate)
} else {
Err(format!(
"system-unit user {candidate:?} is not a valid POSIX account on this system; \
use `sqryd install-systemd-system --user <username>` with a known account name"
))
}
}
fn resolve_current_exe() -> PathBuf {
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("/usr/local/bin/sqryd"))
}
fn format_memory_max(memory_limit_mb: u64) -> String {
format!("MemoryMax={memory_limit_mb}M")
}
#[cfg(test)]
mod tests {
use std::sync::{LazyLock, Mutex};
use super::*;
static ENV_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
fn fixture_config() -> DaemonConfig {
DaemonConfig {
memory_limit_mb: 2_048,
..DaemonConfig::default()
}
}
fn default_opts() -> InstallOptions {
InstallOptions::default()
}
#[test]
fn systemd_user_unit_contains_type_notify() {
let cfg = fixture_config();
let unit = generate_user_unit(&cfg, &default_opts());
assert!(
unit.contains("Type=notify"),
"user unit must contain 'Type=notify'; got:\n{unit}"
);
assert!(
unit.contains("NotifyAccess=main"),
"user unit must contain 'NotifyAccess=main'; got:\n{unit}"
);
}
#[test]
fn systemd_user_unit_contains_memory_max_matching_config() {
let cfg = DaemonConfig {
memory_limit_mb: 4_096,
..DaemonConfig::default()
};
let unit = generate_user_unit(&cfg, &default_opts());
assert!(
unit.contains("MemoryMax=4096M"),
"user unit must contain 'MemoryMax=4096M'; got:\n{unit}"
);
}
#[test]
fn systemd_user_unit_wanted_by_default_target() {
let cfg = fixture_config();
let unit = generate_user_unit(&cfg, &default_opts());
assert!(
unit.contains("WantedBy=default.target"),
"user unit must target default.target; got:\n{unit}"
);
}
#[test]
fn systemd_user_unit_exec_start_uses_foreground_subcommand() {
let cfg = fixture_config();
let unit = generate_user_unit(&cfg, &default_opts());
assert!(
unit.contains("foreground"),
"ExecStart must include the 'foreground' subcommand; got:\n{unit}"
);
}
#[test]
fn systemd_user_unit_runtime_directory_is_0700() {
let cfg = fixture_config();
let unit = generate_user_unit(&cfg, &default_opts());
assert!(
unit.contains("RuntimeDirectory=sqry"),
"user unit must set RuntimeDirectory; got:\n{unit}"
);
assert!(
unit.contains("RuntimeDirectoryMode=0700"),
"user unit must set RuntimeDirectoryMode=0700; got:\n{unit}"
);
}
#[test]
fn systemd_user_unit_contains_version_stamp() {
let cfg = fixture_config();
let unit = generate_user_unit(&cfg, &default_opts());
let expected = format!("# sqryd version {SQRYD_VERSION}");
assert!(
unit.contains(&expected),
"user unit must contain version stamp '{expected}'; got:\n{unit}"
);
}
#[test]
fn systemd_user_unit_contains_log_rotation_disabled_comment() {
let cfg = fixture_config();
let unit = generate_user_unit(&cfg, &default_opts());
assert!(
unit.contains("in-process log rotation is disabled"),
"user unit must document that in-process log rotation is disabled; got:\n{unit}"
);
}
#[test]
fn systemd_user_unit_sets_socket_env_via_runtime_dir() {
let cfg = fixture_config();
let unit = generate_user_unit(&cfg, &default_opts());
assert!(
unit.contains("SQRY_DAEMON_SOCKET=%t/sqry/sqryd.sock"),
"user unit must set SQRY_DAEMON_SOCKET using %t specifier; got:\n{unit}"
);
}
#[test]
fn systemd_user_unit_snapshot_mandatory_sections() {
let cfg = DaemonConfig {
memory_limit_mb: 2_048,
..DaemonConfig::default()
};
let unit = generate_user_unit(&cfg, &default_opts());
assert!(unit.contains("[Unit]"), "missing [Unit]");
assert!(unit.contains("[Service]"), "missing [Service]");
assert!(unit.contains("[Install]"), "missing [Install]");
assert!(unit.contains("Restart=on-failure"), "missing Restart");
assert!(unit.contains("RestartSec=3"), "missing RestartSec");
assert!(
unit.contains("StandardOutput=append:"),
"missing StandardOutput"
);
assert!(
unit.contains("StandardError=inherit"),
"missing StandardError"
);
}
#[test]
fn systemd_system_unit_is_templated_with_percent_i() {
let cfg = fixture_config();
let unit = generate_system_unit(&cfg, &default_opts());
assert!(
unit.contains("User=%i"),
"system unit must use User=%i; got:\n{unit}"
);
assert!(
unit.contains("Group=%i"),
"system unit must use Group=%i; got:\n{unit}"
);
assert!(
unit.contains("StateDirectory=sqry/%i"),
"system unit must use StateDirectory=sqry/%i; got:\n{unit}"
);
assert!(
unit.contains("RuntimeDirectory=sqry/%i"),
"system unit must use RuntimeDirectory=sqry/%i; got:\n{unit}"
);
}
#[test]
fn systemd_system_unit_wanted_by_multi_user_target() {
let cfg = fixture_config();
let unit = generate_system_unit(&cfg, &default_opts());
assert!(
unit.contains("WantedBy=multi-user.target"),
"system unit must target multi-user.target; got:\n{unit}"
);
assert!(
!unit.contains("WantedBy=default.target"),
"system unit must NOT target default.target; got:\n{unit}"
);
}
#[test]
fn systemd_system_unit_socket_path_is_per_user_isolated() {
let cfg = fixture_config();
let unit = generate_system_unit(&cfg, &default_opts());
assert!(
unit.contains("SQRY_DAEMON_SOCKET=%t/sqry/%i/sqryd.sock"),
"system unit socket path must include %i for per-user isolation; got:\n{unit}"
);
assert!(
unit.contains("LogsDirectory=sqry/%i"),
"system unit must declare LogsDirectory=sqry/%i to provision /var/log/sqry/<user>/; \
got:\n{unit}"
);
}
#[test]
fn systemd_system_unit_after_network_target() {
let cfg = fixture_config();
let unit = generate_system_unit(&cfg, &default_opts());
assert!(
unit.contains("After=network.target"),
"system unit must declare After=network.target; got:\n{unit}"
);
}
#[test]
fn systemd_system_unit_memory_max_matches_config() {
let cfg = DaemonConfig {
memory_limit_mb: 1_024,
..DaemonConfig::default()
};
let unit = generate_system_unit(&cfg, &default_opts());
assert!(
unit.contains("MemoryMax=1024M"),
"system unit must contain MemoryMax=1024M; got:\n{unit}"
);
}
#[test]
fn systemd_system_unit_snapshot_mandatory_sections() {
let cfg = fixture_config();
let unit = generate_system_unit(&cfg, &default_opts());
assert!(unit.contains("[Unit]"), "missing [Unit]");
assert!(unit.contains("[Service]"), "missing [Service]");
assert!(unit.contains("[Install]"), "missing [Install]");
assert!(unit.contains("Type=notify"), "missing Type=notify");
assert!(unit.contains("NotifyAccess=main"), "missing NotifyAccess");
assert!(unit.contains("ExecStart="), "missing ExecStart");
assert!(unit.contains("Restart=on-failure"), "missing Restart");
let expected_version = format!("# sqryd version {SQRYD_VERSION}");
assert!(
unit.contains(&expected_version),
"missing version stamp: {expected_version}"
);
}
#[test]
fn systemd_system_unit_resolves_current_user_as_valid() {
let Some(current_user) = users::get_current_username() else {
eprintln!("skip: could not determine current username via users crate");
return;
};
let Some(username_str) = current_user.to_str() else {
eprintln!("skip: current username is not valid UTF-8");
return;
};
let opts = InstallOptions {
user: Some(username_str.to_owned()),
..InstallOptions::default()
};
let result = resolve_system_unit_user(&opts);
assert!(
result.is_ok(),
"resolve_system_unit_user must succeed for the current user {username_str:?}, \
got: {result:?}"
);
assert_eq!(result.unwrap(), username_str);
}
#[test]
fn systemd_system_unit_rejects_invalid_posix_user() {
let opts = InstallOptions {
user: Some("sqryd_nonexistent_test_account_xyzzy_12345".to_owned()),
..InstallOptions::default()
};
let result = resolve_system_unit_user(&opts);
assert!(
result.is_err(),
"resolve_system_unit_user must fail for a non-existent account; got: {result:?}"
);
let err_msg = result.unwrap_err();
assert!(
err_msg.contains("not a valid POSIX account"),
"error message must explain why validation failed; got: {err_msg}"
);
}
#[test]
fn systemd_system_unit_falls_back_to_user_env_var() {
let Some(current_user) = users::get_current_username() else {
eprintln!("skip: could not determine current username");
return;
};
let Some(username_str) = current_user.to_str() else {
eprintln!("skip: current username is not valid UTF-8");
return;
};
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var_os("USER");
unsafe {
std::env::set_var("USER", username_str);
}
let opts = InstallOptions::default();
let result = resolve_system_unit_user(&opts);
unsafe {
match prior {
Some(v) => std::env::set_var("USER", v),
None => std::env::remove_var("USER"),
}
}
assert!(
result.is_ok(),
"resolve_system_unit_user must succeed when $USER is set to a valid account; \
got: {result:?}"
);
}
#[test]
fn systemd_system_unit_errors_when_no_user_and_no_env() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var_os("USER");
unsafe {
std::env::remove_var("USER");
}
let opts = InstallOptions::default();
let result = resolve_system_unit_user(&opts);
unsafe {
match prior {
Some(v) => std::env::set_var("USER", v),
None => std::env::remove_var("USER"),
}
}
assert!(
result.is_err(),
"resolve_system_unit_user must fail when neither --user nor $USER is available; \
got: {result:?}"
);
}
}