Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
130 changes: 99 additions & 31 deletions src/controller/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(())
}
Expand All @@ -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<PathBuf, Error> {
// 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,
Expand All @@ -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(())
}
Expand All @@ -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<PathBuf, Error> {
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<PathBuf, Error> {
let unit_dir = detect_systemd_unit_dir()?;
Ok(unit_dir.join(format!("{}.d", self.get_service_file_name())))
}
Comment on lines +175 to 178
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detect_systemd_unit_dir() function is called separately in both get_service_unit_path() and get_service_dropin_dir(). When both methods are called (e.g., in write_service_config() and delete()), this results in redundant detection work including potential Command execution and filesystem probing. Consider caching the detected directory in the LinuxController struct or calling the detection once at a higher level.

Copilot uses AI. Check for mistakes.

fn get_service_unit_content(&self) -> Result<String, Error> {
Expand All @@ -128,21 +197,20 @@ 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)
.and_then(|mut file| file.write_all(content.as_bytes()))
.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)))?;
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message references path.display() but the variable being created is dropin_dir. This will show the wrong path (the config file path instead of the directory path) if directory creation fails. Change to dropin_dir.display().

Suggested change
.map_err(|e| Error::new(&format!("Failed to create {}: {}", path.display(), e)))?;
.map_err(|e| Error::new(&format!("Failed to create {}: {}", dropin_dir.display(), e)))?;

Copilot uses AI. Check for mistakes.
}
info!("Writing config file {}", path.display());
File::create(&path)
Expand All @@ -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(())
}
Expand All @@ -187,9 +257,7 @@ impl ControllerInterface for LinuxController {
}

#[cfg(feature = "systemd-rs")]
fn run_monitor<T: Send + 'static>(
tx: mpsc::Sender<ServiceEvent<T>>,
) -> Result<Monitor, std::io::Error> {
fn run_monitor<T: Send + 'static>(tx: mpsc::Sender<ServiceEvent<T>>) -> Result<Monitor, std::io::Error> {
let monitor = Monitor::new()?;

let mut current_session = match login_session::get_active_session() {
Expand Down