pub use crate::service_provision_unit_file_ownership_and_writes::OWN_MARKERS;
use crate::service_provision_unit_file_ownership_and_writes::{any_named_anywhere, carries_own_marker, is_own_unit_file, own_unit_file_in_runtime_or_user_directory, remove_settled, write_atomically};
use crate::service_messages::{ClientFailure, Platform};
use crate::service_client_listen_and_ask_to_retire::ask_to_retire;
use crate::earlier_unix_sockets_that_listen::earlier_socket_of_instance_listens;
use crate::service_instance::{LEGACY_SOCKET_RELATIVE, earlier_service_unit_instance, earlier_socket_path, earlier_socket_unit_instance, service_unit_instance, socket_path, socket_unit_instance};
use crate::systemd_dbus::{Bus, CallError, EXCHANGE_WAIT, NO_SUCH_UNIT};
use std::path::{Path, PathBuf};
pub const SOCKET_UNIT: &str = include_str!("service_units/monochromatic-git-policy-cli@.socket");
pub const SERVICE_UNIT: &str = include_str!("service_units/monochromatic-git-policy-cli@.service");
pub const SOCKET_UNIT_NAME: &str = "monochromatic-git-policy-cli@.socket";
pub const SERVICE_UNIT_NAME: &str = "monochromatic-git-policy-cli@.service";
pub const LEGACY_SOCKET_UNIT_NAME: &str = "cli-git.socket";
pub const LEGACY_SERVICE_UNIT_NAME: &str = "cli-git.service";
const BINARY_PLACEHOLDER: &str = "@SERVICE_BINARY@";
const GIT_PLACEHOLDER: &str = "@GIT@";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProvisionHost {
pub runtime_dir: PathBuf,
pub user_unit_dir: Option<PathBuf>,
pub bus: PathBuf,
pub service_binary: PathBuf,
pub git: PathBuf,
pub instance: String,
pub retirement_wait: std::time::Duration,
}
pub const RETIREMENT_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
pub trait Manager {
fn within(&mut self, wait: std::time::Duration);
fn reload(&mut self) -> Result<(), CallError>;
fn reset_failed(&mut self, unit: &str) -> Result<(), CallError>;
fn start(&mut self, unit: &str) -> Result<(), CallError>;
fn stop(&mut self, unit: &str) -> Result<(), CallError>;
fn stop_alone(&mut self, unit: &str) -> Result<(), CallError>;
fn active_state(&mut self, unit: &str) -> Result<Option<String>, CallError>;
}
impl Manager for Bus {
fn within(&mut self, wait: std::time::Duration) {
Bus::within(self, wait);
}
fn reload(&mut self) -> Result<(), CallError> {
return Bus::reload(self);
}
fn reset_failed(&mut self, unit: &str) -> Result<(), CallError> {
return Bus::reset_failed(self, unit);
}
fn start(&mut self, unit: &str) -> Result<(), CallError> {
return Bus::start_unit(self, unit);
}
fn stop(&mut self, unit: &str) -> Result<(), CallError> {
return Bus::stop_unit(self, unit);
}
fn stop_alone(&mut self, unit: &str) -> Result<(), CallError> {
return Bus::stop_unit_alone(self, unit);
}
fn active_state(&mut self, unit: &str) -> Result<Option<String>, CallError> {
return Bus::active_state(self, unit);
}
}
pub fn platform_without_manager() -> Platform {
if cfg!(windows) {
return Platform::Windows;
}
if cfg!(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")) {
return Platform::Bsd;
}
if cfg!(target_os = "linux") {
return Platform::LinuxWithoutUserServices;
}
return Platform::Other;
}
pub fn unit_texts(host: &ProvisionHost) -> [(&'static str, String); 2] {
let service: String = SERVICE_UNIT
.replace(BINARY_PLACEHOLDER, host.service_binary.to_string_lossy().as_ref())
.replace(GIT_PLACEHOLDER, host.git.to_string_lossy().as_ref());
return [(SOCKET_UNIT_NAME, SOCKET_UNIT.to_string()), (SERVICE_UNIT_NAME, service)];
}
pub fn runtime_unit_dir(runtime_dir: &Path) -> PathBuf {
return runtime_dir.join("systemd").join("user");
}
pub fn in_place(host: &ProvisionHost) -> bool {
return in_place_with(host, Path::new("/proc"));
}
pub fn in_place_with(host: &ProvisionHost, proc_root: &Path) -> bool {
let directory: PathBuf = runtime_unit_dir(host.runtime_dir.as_path());
let mut all_current: bool = true;
for (name, text) in unit_texts(host) {
if !matches!(std::fs::read_to_string(directory.join(name)), Ok(installed) if installed == text) {
all_current = false;
break;
}
}
let mut earlier_left: bool = false;
for name in [LEGACY_SOCKET_UNIT_NAME, LEGACY_SERVICE_UNIT_NAME] {
if own_unit_file_in_runtime_or_user_directory(directory.as_path(), host.user_unit_dir.as_deref(), name) {
earlier_left = true;
break;
}
}
earlier_left = earlier_left || host.runtime_dir.join(LEGACY_SOCKET_RELATIVE).exists();
if !all_current || earlier_left || !socket_path(host.runtime_dir.as_path(), host.instance.as_str()).exists() {
return false;
}
let earlier_file: PathBuf = earlier_socket_path(host.runtime_dir.as_path(), host.instance.as_str());
return !(earlier_file.exists() && earlier_socket_of_instance_listens(proc_root, host.instance.as_str()));
}
fn clear_earlier_units(host: &ProvisionHost) -> Result<bool, ClientFailure> {
let Some(directory) = host.user_unit_dir.as_deref() else { return Ok(false) };
let mut removed: bool = false;
for name in [LEGACY_SOCKET_UNIT_NAME, LEGACY_SERVICE_UNIT_NAME] {
let path: PathBuf = directory.join(name);
let Ok(text) = std::fs::read_to_string(&path) else {
if path.exists() {
return Err(ClientFailure::UnitInTheWay(path.display().to_string()));
}
continue;
};
if !carries_own_marker(text.as_str()) {
return Err(ClientFailure::UnitInTheWay(path.display().to_string()));
}
remove_settled(path.as_path())?;
removed = true;
}
let link: PathBuf = directory.join("sockets.target.wants").join(LEGACY_SOCKET_UNIT_NAME);
if matches!(std::fs::symlink_metadata(&link), Ok(meta) if meta.file_type().is_symlink()) {
let _removed = std::fs::remove_file(&link);
removed = true;
}
return Ok(removed);
}
fn retire_earlier_service(host: &ProvisionHost, manager: &mut dyn Manager) -> Result<bool, ClientFailure> {
let directory: PathBuf = runtime_unit_dir(host.runtime_dir.as_path());
let directories: Vec<PathBuf> = std::iter::once(directory.clone()).chain(host.user_unit_dir.clone()).collect();
let socket_file: PathBuf = host.runtime_dir.join(LEGACY_SOCKET_RELATIVE);
let both: [&str; 2] = [LEGACY_SOCKET_UNIT_NAME, LEGACY_SERVICE_UNIT_NAME];
let mut units: Vec<&str> = Vec::new();
for name in both {
let ours: bool = match effective_unit_file(host, name) {
Some(file) => is_own_unit_file(file.as_path()),
None => false,
};
if ours {
units.push(name);
}
}
let mut owned_files: Vec<PathBuf> = Vec::new();
for name in both {
let file: PathBuf = directory.join(name);
if is_own_unit_file(file.as_path()) {
owned_files.push(file);
}
}
if units.is_empty() && owned_files.is_empty() && socket_file.exists() && !any_named_anywhere(directories.as_slice(), &both) {
units = both.to_vec();
}
if units.is_empty() && owned_files.is_empty() {
return Ok(false);
}
if !units.is_empty() {
ask_to_retire(socket_file.as_path());
manager.within(EXCHANGE_WAIT);
let mut sockets: Vec<&str> = Vec::new();
let mut services: Vec<&str> = Vec::new();
for name in &units {
if *name == LEGACY_SOCKET_UNIT_NAME {
sockets.push(name);
} else {
services.push(name);
}
}
stop_units(manager, sockets.as_slice())?;
wait_until_stopped(host, manager, services.as_slice())?;
stop_units(manager, services.as_slice())?;
wait_until_stopped(host, manager, units.as_slice())?;
}
for file in &owned_files {
remove_settled(file.as_path())?;
}
let _socket_removed = std::fs::remove_file(&socket_file);
return Ok(!owned_files.is_empty());
}
fn stop_units(manager: &mut dyn Manager, units: &[&str]) -> Result<(), ClientFailure> {
for name in units {
match manager.stop(name) {
Ok(()) => {}
Err(CallError::Manager { name: error, .. }) if error == NO_SUCH_UNIT => {}
Err(error) => return Err(refusal(error)),
}
}
return Ok(());
}
fn effective_unit_file(host: &ProvisionHost, name: &str) -> Option<PathBuf> {
if let Some(directory) = host.user_unit_dir.as_ref() {
let file: PathBuf = directory.join(name);
if file.exists() {
return Some(file);
}
}
let runtime_file: PathBuf = runtime_unit_dir(host.runtime_dir.as_path()).join(name);
if runtime_file.exists() {
return Some(runtime_file);
}
return None;
}
fn still_stopping(host: &ProvisionHost, unit: &str) -> ClientFailure {
return ClientFailure::CouldNotStart(format!(
"the earlier commit service ({unit}) is still running after {:.1} seconds, and it may be in the middle of a landing that stopping it would cut; run the commit again in a moment, or, when no commit is running, stop it with `systemctl --user stop {LEGACY_SERVICE_UNIT_NAME} {LEGACY_SOCKET_UNIT_NAME}`",
host.retirement_wait.as_secs_f64()
));
}
fn wait_until_stopped(host: &ProvisionHost, manager: &mut dyn Manager, units: &[&str]) -> Result<(), ClientFailure> {
let deadline: std::time::Instant = std::time::Instant::now() + host.retirement_wait;
loop {
let mut running: Option<&str> = None;
for name in units {
manager.within(deadline.saturating_duration_since(std::time::Instant::now()));
let state: Option<String> = match manager.active_state(name) {
Ok(found) => found,
Err(CallError::TimedOut) => return Err(still_stopping(host, name)),
Err(error) => return Err(refusal(error)),
};
if matches!(&state, Some(text) if !matches!(text.as_str(), "inactive" | "failed")) {
running = Some(name);
}
}
let Some(unit) = running else { return Ok(()) };
if std::time::Instant::now() >= deadline {
return Err(still_stopping(host, unit));
}
std::thread::sleep(RETIREMENT_POLL);
}
}
const RETIREMENT_POLL: std::time::Duration = std::time::Duration::from_millis(50);
fn refusal(error: CallError) -> ClientFailure {
return match error {
CallError::Manager { text, .. } if text.is_empty() => ClientFailure::CouldNotStart(String::from("the service manager refused the request")),
CallError::Manager { text, .. } => ClientFailure::CouldNotStart(format!("the service manager refused the request ({text})")),
CallError::Connection(_) | CallError::Authentication(_) | CallError::Malformed(_) | CallError::TimedOut => {
ClientFailure::NoActivation(platform_without_manager())
}
};
}
pub fn provision_with(host: &ProvisionHost, manager: &mut dyn Manager) -> Result<(), ClientFailure> {
let mut reload: bool = retire_earlier_service(host, manager)?;
reload |= clear_earlier_units(host)?;
let directory: PathBuf = runtime_unit_dir(host.runtime_dir.as_path());
if let Err(error) = std::fs::create_dir_all(&directory) {
return Err(ClientFailure::CouldNotStart(format!("{} cannot be created: {error}", directory.display())));
}
let mut known: bool = false;
let mut written: Vec<PathBuf> = Vec::new();
for (name, text) in unit_texts(host) {
let path: PathBuf = directory.join(name);
let existed: bool = path.exists();
known |= existed;
if matches!(std::fs::read_to_string(&path), Ok(current) if current == text) {
continue;
}
if let Err(error) = write_atomically(&path, text.as_str()) {
return Err(ClientFailure::CouldNotStart(format!("{} cannot be written: {error}", path.display())));
}
written.push(path);
reload |= existed;
}
manager.within(EXCHANGE_WAIT);
let asked: Result<(), CallError> = ask_manager(manager, reload, known, host.instance.as_str());
if let Err(error) = asked {
for path in &written {
let _removed = std::fs::remove_file(path);
}
return Err(refusal(error));
}
stop_earlier_instance_socket(host, manager);
return Ok(());
}
fn ask_manager(manager: &mut dyn Manager, reload: bool, known: bool, instance: &str) -> Result<(), CallError> {
if reload {
manager.reload()?;
}
if known {
for unit in [service_unit_instance(instance), socket_unit_instance(instance)] {
let _reset = manager.reset_failed(unit.as_str());
}
}
return manager.start(socket_unit_instance(instance).as_str());
}
fn is_at_rest(state: &str) -> bool {
return matches!(state, "inactive" | "failed");
}
fn describe(error: &CallError) -> String {
return match error {
CallError::Connection(reason) => format!("the session bus could not be reached ({reason})"),
CallError::Authentication(reason) => format!("the session bus refused the connection ({reason})"),
CallError::TimedOut => String::from("the session bus did not answer in time"),
CallError::Manager { text, name } if text.is_empty() => format!("the service manager refused the request ({name})"),
CallError::Manager { text, .. } => format!("the service manager refused the request ({text})"),
CallError::Malformed(reason) => format!("the service manager's answer could not be read ({reason})"),
};
}
fn stop_earlier_instance_socket(host: &ProvisionHost, manager: &mut dyn Manager) {
let socket: String = earlier_socket_unit_instance(host.instance.as_str());
let service: String = earlier_service_unit_instance(host.instance.as_str());
manager.within(EXCHANGE_WAIT);
let unreadable = |unit: &str, error: &CallError| {
crate::service_warn!(
"the commit service of the earlier release for this worktree ({unit}) was not stopped: {}; the batches it left open stay open, and the next commit tries again",
describe(error)
);
};
match manager.active_state(service.as_str()) {
Ok(Some(state)) if !is_at_rest(state.as_str()) => return,
Ok(_) => {}
Err(error) => return unreadable(service.as_str(), &error),
}
let held: Option<String> = match manager.active_state(socket.as_str()) {
Ok(state) => state,
Err(error) => return unreadable(socket.as_str(), &error),
};
if !matches!(&held, Some(state) if !is_at_rest(state.as_str())) {
return;
}
match manager.stop_alone(socket.as_str()) {
Ok(()) => {}
Err(CallError::Manager { name, .. }) if name == NO_SUCH_UNIT => {}
Err(error) => unreadable(socket.as_str(), &error),
}
}
pub fn provision(host: &ProvisionHost) -> Result<(), ClientFailure> {
if in_place(host) {
return Ok(());
}
let mut bus: Bus = match Bus::connect(host.bus.as_path(), EXCHANGE_WAIT) {
Ok(connected) => connected,
Err(_refused) => return Err(ClientFailure::NoActivation(platform_without_manager())),
};
return provision_with(host, &mut bus);
}
#[cfg(test)]
#[path = "service_provision_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "service_provision_never_starts_at_login_tests.rs"]
mod never_starts_at_login_tests;