Skip to main content

git_policy_cli/
service_provision.rs

1//! What:
2//!  The wrapper makes socket activation for the commit service exist the first time a commit needs it: it writes the two
3//!  unit files into the user's runtime unit directory, asks the user manager (through the session bus) to reload and to
4//!  start the socket, and returns. The manager, not the wrapper, starts the service when the first connection arrives.
5//! Why:
6//!  There is no install step to remember (`doc/planning/cli-git-resident-service.md`, "Provisioning"): like Bazel's
7//!  client, the wrapper sets up what it needs and tells nobody. The units live under `$XDG_RUNTIME_DIR/systemd/user`, which
8//!  disappears at logout, so nothing is left behind in the home directory and the next first use provisions again. Every
9//!  step is idempotent (a file is written, by temporary file and rename, only when its content differs), so several commits
10//!  racing to provision agree without any lock. Units this wrapper's earlier releases wrote into `~/.config/systemd/user`
11//!  outrank the runtime ones in the unit search path, so they are recognized by their content and removed; a unit of the
12//!  same name that something else wrote is never touched, and the commit stops with a message naming the file.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! await provision({ runtimeDir, configHome, busPath, serviceBinary, git }) // writes units, reloads, starts the socket
17//! ```
18
19use super::service_messages::{ClientFailure, Platform};
20use super::systemd_dbus::{Bus, CallError, EXCHANGE_WAIT};
21use std::path::{Path, PathBuf};
22
23/// The socket unit's text, with the marker on its first line.
24pub const SOCKET_UNIT: &str = include_str!("service_units/cli-git.socket");
25
26/// The service unit's text, with `@SERVICE_BINARY@` and `@GIT@` still to fill in.
27pub const SERVICE_UNIT: &str = include_str!("service_units/cli-git.service");
28
29/// File name of the socket unit.
30pub const SOCKET_UNIT_NAME: &str = "cli-git.socket";
31
32/// File name of the service unit.
33pub const SERVICE_UNIT_NAME: &str = "cli-git.service";
34
35/// Name of the service program, next to the wrapper.
36pub const SERVICE_PROGRAM: &str = "cli-git-service";
37
38/// The words that mark a unit file as written by this wrapper: the marker its units carry now, and the descriptions the
39///  units of earlier releases carried.
40pub const OWN_MARKERS: [&str; 3] =
41    ["(cli-git-unit)", "Description=Commit service socket for the git wrapper", "Description=Commit service for the git wrapper"];
42
43const BINARY_PLACEHOLDER: &str = "@SERVICE_BINARY@";
44const GIT_PLACEHOLDER: &str = "@GIT@";
45
46/// What provisioning needs to know about the machine.
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct ProvisionHost {
49    /// The user's runtime directory, which holds the units and the service's socket.
50    pub runtime_dir: PathBuf,
51    /// The directory a user's own unit files live in (`~/.config/systemd/user`), where earlier releases wrote theirs.
52    pub user_unit_dir: Option<PathBuf>,
53    /// The session bus socket.
54    pub bus: PathBuf,
55    /// The service program.
56    pub service_binary: PathBuf,
57    /// The real Git the service runs.
58    pub git: PathBuf,
59}
60
61/// What the manager is asked to do, so a control can record it instead of reaching a manager.
62pub trait Manager {
63    /// Reloads the unit files.
64    fn reload(&mut self) -> Result<(), CallError>;
65    /// Starts a unit.
66    fn start(&mut self, unit: &str) -> Result<(), CallError>;
67}
68
69impl Manager for Bus {
70    fn reload(&mut self) -> Result<(), CallError> {
71        return Bus::reload(self);
72    }
73
74    fn start(&mut self, unit: &str) -> Result<(), CallError> {
75        return Bus::start_unit(self, unit);
76    }
77}
78
79/// The platform whose remedy applies when no user service manager answers.
80pub fn platform_without_manager() -> Platform {
81    if cfg!(windows) {
82        return Platform::Windows;
83    }
84    if cfg!(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")) {
85        return Platform::Bsd;
86    }
87    if cfg!(target_os = "linux") {
88        return Platform::LinuxWithoutUserServices;
89    }
90    return Platform::Other;
91}
92
93/// The two unit files for a host, as written.
94pub fn unit_texts(host: &ProvisionHost) -> [(&'static str, String); 2] {
95    let service: String = SERVICE_UNIT
96        .replace(BINARY_PLACEHOLDER, host.service_binary.to_string_lossy().as_ref())
97        .replace(GIT_PLACEHOLDER, host.git.to_string_lossy().as_ref());
98    return [(SOCKET_UNIT_NAME, SOCKET_UNIT.to_string()), (SERVICE_UNIT_NAME, service)];
99}
100
101/// The directory the runtime units are written to.
102pub fn runtime_unit_dir(runtime_dir: &Path) -> PathBuf {
103    return runtime_dir.join("systemd").join("user");
104}
105
106static TEMPORARY_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
107
108/// Writes `text` to `path` through a temporary file and a rename, so a reader sees the old file or the new one whole.
109fn write_atomically(path: &Path, text: &str) -> std::io::Result<()> {
110    let number: u64 = TEMPORARY_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
111    let temporary: PathBuf = path.with_extension(format!("tmp-{}-{number}", std::process::id()));
112    std::fs::write(&temporary, text.as_bytes())?;
113    let renamed: std::io::Result<()> = std::fs::rename(&temporary, path);
114    if renamed.is_err() {
115        let _removed = std::fs::remove_file(&temporary);
116    }
117    return renamed;
118}
119
120/// What the user's own unit directory holds under the names the wrapper uses.
121fn clear_earlier_units(host: &ProvisionHost) -> Result<bool, ClientFailure> {
122    let Some(directory) = host.user_unit_dir.as_deref() else { return Ok(false) };
123    let mut removed: bool = false;
124    for name in [SOCKET_UNIT_NAME, SERVICE_UNIT_NAME] {
125        let path: PathBuf = directory.join(name);
126        let Ok(text) = std::fs::read_to_string(&path) else {
127            if path.exists() {
128                return Err(ClientFailure::UnitInTheWay(path.display().to_string()));
129            }
130            continue;
131        };
132        if !OWN_MARKERS.iter().any(|marker| { return text.contains(marker); }) {
133            return Err(ClientFailure::UnitInTheWay(path.display().to_string()));
134        }
135        std::fs::remove_file(&path).map_err(|error| { return ClientFailure::CouldNotStart(format!("{} cannot be removed: {error}", path.display())); })?;
136        removed = true;
137    }
138    // The enabling link an earlier release made points at the file just removed.
139    let link: PathBuf = directory.join("sockets.target.wants").join(SOCKET_UNIT_NAME);
140    if std::fs::symlink_metadata(&link).is_ok_and(|meta| { return meta.file_type().is_symlink(); }) {
141        let _removed = std::fs::remove_file(&link);
142        removed = true;
143    }
144    return Ok(removed);
145}
146
147/// What:
148///  Makes socket activation exist: earlier units removed, current units written where they differ, the manager reloaded when
149///  a unit it may hold changed or went away, and the socket started.
150/// Why:
151///   Idempotent by construction: a second call with nothing to change only starts the socket, which is a no-op when it is
152///  already listening; two calls at once write identical content through renames and both succeed.
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// await provisionWith(host, manager)
157/// ```
158pub fn provision_with(host: &ProvisionHost, manager: &mut dyn Manager) -> Result<(), ClientFailure> {
159    // A reload makes the manager re-read every unit and interrupts its other clients for a while, so it is asked for only
160    //  when a unit it may already hold changed or went away. A file that is new is found by the manager when the unit is
161    //  first started.
162    let mut reload: bool = clear_earlier_units(host)?;
163    let directory: PathBuf = runtime_unit_dir(host.runtime_dir.as_path());
164    std::fs::create_dir_all(&directory).map_err(|error| { return ClientFailure::CouldNotStart(format!("{} cannot be created: {error}", directory.display())); })?;
165    for (name, text) in unit_texts(host) {
166        let path: PathBuf = directory.join(name);
167        let existed: bool = path.exists();
168        if std::fs::read_to_string(&path).is_ok_and(|current| { return current == text; }) {
169            continue;
170        }
171        write_atomically(&path, text.as_str())
172            .map_err(|error| { return ClientFailure::CouldNotStart(format!("{} cannot be written: {error}", path.display())); })?;
173        reload |= existed;
174    }
175    let refused = |error: CallError| -> ClientFailure {
176        return match error {
177            // The error's name is the manager's internal vocabulary; the person gets its words, or a plain statement.
178            CallError::Manager { text, .. } if text.is_empty() => ClientFailure::CouldNotStart(String::from("the service manager refused the request")),
179            CallError::Manager { text, .. } => ClientFailure::CouldNotStart(format!("the service manager refused the request ({text})")),
180            CallError::Connection(_) | CallError::Authentication(_) | CallError::Malformed(_) => ClientFailure::NoActivation(platform_without_manager()),
181        };
182    };
183    if reload {
184        manager.reload().map_err(refused)?;
185    }
186    return manager.start(SOCKET_UNIT_NAME).map_err(refused);
187}
188
189/// Provisions through the session bus.
190pub fn provision(host: &ProvisionHost) -> Result<(), ClientFailure> {
191    let mut bus: Bus = Bus::connect(host.bus.as_path(), EXCHANGE_WAIT).map_err(|_| { return ClientFailure::NoActivation(platform_without_manager()); })?;
192    return provision_with(host, &mut bus);
193}
194
195/// Provisioning controls stay out of the release executable.
196#[cfg(test)]
197#[path = "service_provision_tests.rs"]
198mod tests;