diff --git a/Cargo.toml b/Cargo.toml index c7b2f36..ce2d068 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ceviche" -version = "0.6.1" +version = "0.7.0" edition = "2021" license = "MIT/Apache-2.0" homepage = "https://github.com/devolutions/ceviche-rs" diff --git a/README.md b/README.md index 067fe3a..d5be39b 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,55 @@ ![Build](https://github.com/devolutions/ceviche-rs/actions/workflows/build.yaml/badge.svg) Service/daemon wrapper. Supports Windows, Linux (systemd) and macOS. + +## Where we install systemd units and why + +### The Challenge + +Different Linux distributions place systemd unit files in different locations: +- Debian/Ubuntu and modern RHEL-based systems: `/usr/lib/systemd/system/` +- Older systems and some distributions: `/lib/systemd/system/` +- User-specific overrides: `/etc/systemd/system/` + +### Our Approach + +This library detects the correct systemd unit directory at runtime using the following strategy: + +1. **Environment variable override** (`CEVICHE_SYSTEMD_UNITDIR`): If set, this takes precedence over everything else. Use this when you need explicit control over where units are installed. + +2. **pkg-config detection**: We query `pkg-config --variable=systemdsystemunitdir systemd` to get the distribution's preferred location. This works on most modern systems that have systemd development packages installed. + +3. **Fallback probing**: If pkg-config is unavailable or doesn't return a result, we probe common directories in order: + - `/usr/lib/systemd/system` + - `/lib/systemd/system` + +### Caveats and Best Practices + +⚠️ **This isn't the ideal approach for packaged software.** + +If you're creating distribution packages (`.deb`, `.rpm`, etc.), you should: +- **For Debian/Ubuntu**: Use `dh_installsystemd` or manually install to `${prefix}/lib/systemd/system/` +- **For RPM-based systems**: Use `%{_unitdir}` macro in your spec file +- Let the distribution's packaging tools determine the correct location + +This runtime detection is a pragmatic compromise for applications that need to self-register as services without relying on package manager scripts. It works well for: +- Development and testing +- Self-contained applications +- Situations where you can't use distribution-specific packaging + +### Usage + +By default, ceviche will detect and use the system unit directory. To override: + +```bash +# Specify a custom location +export CEVICHE_SYSTEMD_UNITDIR=/etc/systemd/system +./your-application service register +``` + +### Why Default to System Units? + +We default to **system** units (`/usr/lib/systemd/system/` or `/lib/systemd/system/`) rather than user units (`/usr/lib/systemd/user/`, `~/.config/systemd/user/`, etc) because: +- Most services run system-wide +- System units are more common for daemon applications +- You can always override with `CEVICHE_SYSTEMD_UNITDIR` if you need user units diff --git a/src/controller/linux.rs b/src/controller/linux.rs index e4d911d..c3c4113 100644 --- a/src/controller/linux.rs +++ b/src/controller/linux.rs @@ -6,7 +6,7 @@ use std::process::Command; use std::sync::mpsc; use ctrlc; -use log::{debug, info}; +use log::{debug, info, warn}; use crate::controller::{ControllerInterface, ServiceMainFn}; use crate::session; @@ -53,12 +53,8 @@ fn systemd_install_daemon(name: &str) -> Result<(), Error> { fn systemd_uninstall_daemon(name: &str) -> Result<(), Error> { systemctl_execute(&["disable", name])?; - systemctl_execute(&["daemon-reload"]) - .map_err(|e| debug!("{}", e)) - .ok(); - systemctl_execute(&["reset-failed"]) - .map_err(|e| debug!("{}", e)) - .ok(); + systemctl_execute(&["daemon-reload"]).map_err(|e| debug!("{}", e)).ok(); + systemctl_execute(&["reset-failed"]).map_err(|e| debug!("{}", e)).ok(); Ok(()) } @@ -71,6 +67,80 @@ fn systemd_stop_daemon(name: &str) -> Result<(), Error> { systemctl_execute(&["stop", name]) } +/// Detect the systemd system unit directory at runtime. +/// +/// # Rationale +/// +/// This isn't the best approach for Linux — packagers should normally choose the +/// destination and rely on distro tooling (e.g., Debian's dh_installsystemd or +/// RPM's %{_unitdir} macros). Using pkg-config at build/packaging time is a +/// pragmatic, good-enough approach in many situations to discover the vendor +/// unit dir without hardcoding paths. +/// +/// # Caveat +/// +/// We can't automatically determine whether it should go into user/ or +/// system/, and we default to system/. Use CEVICHE_SYSTEMD_UNITDIR if you need +/// to override this behavior. +/// +/// # Detection order +/// +/// 1. CEVICHE_SYSTEMD_UNITDIR environment variable (takes precedence) +/// 2. pkg-config --variable=systemdsystemunitdir systemd +/// 3. Fallback probing: /usr/lib/systemd/system, then /lib/systemd/system +fn detect_systemd_unit_dir() -> Result { + // 1. Check for environment variable override. + if let Ok(dir) = env::var("CEVICHE_SYSTEMD_UNITDIR") { + if !dir.is_empty() { + info!("Using systemd unit directory from CEVICHE_SYSTEMD_UNITDIR: {dir}"); + return Ok(PathBuf::from(dir)); + } + } + + // 2. Try pkg-config. + match Command::new("pkg-config") + .args(["--variable=systemdsystemunitdir", "systemd"]) + .output() + { + Ok(output) if output.status.success() => { + let dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !dir.is_empty() { + info!("Detected systemd unit directory via pkg-config: {dir}"); + return Ok(PathBuf::from(dir)); + } + } + Ok(_) => { + debug!("pkg-config returned no systemd unit directory"); + } + Err(e) => { + debug!("pkg-config not available or failed: {e}"); + } + } + + // 3. Fallback: probe common directories. + warn!( + "pkg-config unavailable or didn't return a systemd unit directory. \ + Falling back to heuristic probing of common vendor directories. \ + This may be distro-specific. Consider setting CEVICHE_SYSTEMD_UNITDIR \ + environment variable to specify the correct path." + ); + + let candidates = ["/usr/lib/systemd/system", "/lib/systemd/system"]; + + for &candidate in &candidates { + let path = Path::new(candidate); + if path.exists() && path.is_dir() { + info!("Found systemd unit directory via fallback probing: {candidate}"); + return Ok(PathBuf::from(candidate)); + } + } + + Err(Error::new( + "Unable to detect systemd unit directory. \ + Please set CEVICHE_SYSTEMD_UNITDIR environment variable to specify the correct path.", + )) +} + pub struct LinuxController { pub service_name: String, pub display_name: String, @@ -88,10 +158,7 @@ impl LinuxController { } } - pub fn register( - &mut self, - service_main_wrapper: LinuxServiceMainWrapperFn, - ) -> Result<(), Error> { + pub fn register(&mut self, service_main_wrapper: LinuxServiceMainWrapperFn) -> Result<(), Error> { service_main_wrapper(env::args().collect()); Ok(()) } @@ -100,12 +167,14 @@ impl LinuxController { format!("{}.service", &self.service_name) } - fn get_service_unit_path(&self) -> PathBuf { - Path::new("/lib/systemd/system/").join(self.get_service_file_name()) + fn get_service_unit_path(&self) -> Result { + let unit_dir = detect_systemd_unit_dir()?; + Ok(unit_dir.join(self.get_service_file_name())) } - fn get_service_dropin_dir(&self) -> PathBuf { - Path::new("/lib/systemd/system/").join(format!("{}.d", self.get_service_file_name())) + fn get_service_dropin_dir(&self) -> Result { + let unit_dir = detect_systemd_unit_dir()?; + Ok(unit_dir.join(format!("{}.d", self.get_service_file_name()))) } fn get_service_unit_content(&self) -> Result { @@ -128,7 +197,7 @@ WantedBy=multi-user.target"#, } fn write_service_config(&self) -> Result<(), Error> { - let path = self.get_service_unit_path(); + let path = self.get_service_unit_path()?; let content = self.get_service_unit_content()?; info!("Writing service file {}", path.display()); File::create(&path) @@ -136,13 +205,12 @@ WantedBy=multi-user.target"#, .map_err(|e| Error::new(&format!("Failed to write {}: {}", path.display(), e)))?; if let Some(ref config) = self.config { - let dropin_dir = self.get_service_dropin_dir(); + let dropin_dir = self.get_service_dropin_dir()?; let path = dropin_dir.join(format!("{}.conf", self.service_name)); if !Path::exists(&dropin_dir) { - fs::create_dir(dropin_dir).map_err(|e| { - Error::new(&format!("Failed to create {}: {}", path.display(), e)) - })?; + fs::create_dir(&dropin_dir) + .map_err(|e| Error::new(&format!("Failed to create {}: {}", path.display(), e)))?; } info!("Writing config file {}", path.display()); File::create(&path) @@ -164,15 +232,17 @@ impl ControllerInterface for LinuxController { fn delete(&mut self) -> Result<(), Error> { systemd_uninstall_daemon(&self.service_name)?; - let path = self.get_service_unit_path(); - fs::remove_file(&path) - .map_err(|e| debug!("Failed to delete {}: {}", path.display(), e)) - .ok(); + if let Ok(path) = self.get_service_unit_path() { + fs::remove_file(&path) + .map_err(|e| debug!("Failed to delete {}: {}", path.display(), e)) + .ok(); + } - let path = self.get_service_dropin_dir(); - fs::remove_dir_all(self.get_service_dropin_dir()) - .map_err(|e| debug!("Failed to delete {}: {}", path.display(), e)) - .ok(); + if let Ok(dropin_dir) = self.get_service_dropin_dir() { + fs::remove_dir_all(&dropin_dir) + .map_err(|e| debug!("Failed to delete {}: {}", dropin_dir.display(), e)) + .ok(); + } Ok(()) } @@ -187,9 +257,7 @@ impl ControllerInterface for LinuxController { } #[cfg(feature = "systemd-rs")] -fn run_monitor( - tx: mpsc::Sender>, -) -> Result { +fn run_monitor(tx: mpsc::Sender>) -> Result { let monitor = Monitor::new()?; let mut current_session = match login_session::get_active_session() {