Skip to main content

coop/
config.rs

1//! Host configuration: a hand-edited TOML file, not a state database.
2//!
3//! This reverses an early instinct to copy murmur's `peers` table. murmur's
4//! peers carry *discovered* state (snapshots, `fetched_at`, `last_error`),
5//! which is why they need a store. coop's hosts are pure user intent, so a
6//! file is editable, diffable, and needs no migration story.
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result, anyhow, bail};
12use serde::Deserialize;
13
14/// Warn past this many running jobs on one host. Backpressure, not a queue.
15const DEFAULT_MAX_RUNNING: u32 = 4;
16
17/// Prune `done` jobs older than this. Generous on purpose: deleting a log
18/// someone still wants costs more than the disk it saves.
19const DEFAULT_KEEP_DAYS: u32 = 14;
20
21/// Cap on a single job's log.
22///
23/// Nothing else bounds it: a verbose build was measured writing 35MB in 5s
24/// (~400MB/min), and a runaway `while :; do echo; done` has no ceiling but the
25/// disk. Filling the disk is worse than losing output, because the failing
26/// `rc` write then leaves an `orphan` holding a large log. Orphans are kept
27/// longer than finished jobs (4× `keep_days`), not forever.
28const DEFAULT_MAX_LOG_BYTES: u64 = 100 * 1024 * 1024;
29
30/// Jobs are unbounded unless the host or caller opts into a limit. A six-hour
31/// build is legitimate work, and an arbitrary default would make coop the
32/// process that unexpectedly kills it.
33const DEFAULT_MAX_JOB_SECS: u64 = 0;
34
35/// The private tmux server name. Jobs run under `tmux -L coop`, which does not
36/// appear in the user's `tmux ls`.
37const DEFAULT_TMUX_SOCKET: &str = "coop";
38
39/// A configured host, after name-derived defaults have been applied.
40///
41/// Every field is resolved here so no downstream code has to know a default:
42/// `socket` is `~`-expanded because it is handed to `ssh -S`, which does not
43/// expand it.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Host {
46    /// The config section name, and the handle the user passes to `--host`.
47    pub name: String,
48    /// The ssh target. Defaults to `name`.
49    pub target: String,
50    /// coop's *private* `ControlPath`. Everything else on the machine uses the
51    /// default `~/.ssh/control/...` and so cannot contend with it.
52    pub socket: PathBuf,
53    /// `tmux -L <this>`: a private server, invisible to the user's `tmux ls`.
54    pub tmux_socket: String,
55    /// Warn past this count; never block.
56    pub max_running: u32,
57    /// Where `run` starts, unless `--cwd`. `None` means the remote `$HOME`.
58    pub default_cwd: Option<String>,
59    /// Prune horizon for `done` jobs.
60    pub keep_days: u32,
61    /// Bytes of log kept per job; the rest is discarded and flagged.
62    pub max_log_bytes: u64,
63    /// Maximum remote runtime in seconds; zero means unbounded.
64    pub max_job_secs: u64,
65}
66
67/// The raw `[hosts.<name>]` table. Everything is optional; `Host` fills in the
68/// defaults that serde cannot, because serde cannot see the section name.
69#[derive(Debug, Default, Deserialize)]
70#[serde(deny_unknown_fields)]
71struct RawHost {
72    target: Option<String>,
73    socket: Option<String>,
74    tmux_socket: Option<String>,
75    max_running: Option<u32>,
76    default_cwd: Option<String>,
77    keep_days: Option<u32>,
78    max_log_bytes: Option<u64>,
79    max_job_secs: Option<u64>,
80}
81
82#[derive(Debug, Default, Deserialize)]
83#[serde(deny_unknown_fields)]
84struct RawConfig {
85    /// `BTreeMap` rather than `HashMap` so `coop host list` does not reshuffle
86    /// between invocations.
87    #[serde(default)]
88    hosts: BTreeMap<String, RawHost>,
89}
90
91#[derive(Debug, Clone)]
92pub struct Config {
93    hosts: Vec<Host>,
94}
95
96/// Is `value` safe to use as one component of a filesystem path?
97///
98/// Conservative on purpose. Both a host name and a `tmux_socket` end up in a
99/// path *and* unquoted in a remote shell script, so the grammar has to exclude
100/// path separators, shell metacharacters and whitespace at once. `.` and `..`
101/// are excluded separately: they satisfy the character rule while still
102/// meaning "this directory" and "the parent".
103fn is_filename_component(value: &str) -> bool {
104    if value.is_empty() || value == "." || value == ".." {
105        return false;
106    }
107    value
108        .bytes()
109        .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
110}
111
112/// Expand a leading `~/` against the real home directory.
113///
114/// Only a leading `~/` (or a bare `~`): `~user` is deliberately unsupported,
115/// since resolving another user's home is a different problem and silently
116/// treating it as a literal path would be worse than refusing.
117fn expand_tilde(raw: &str) -> Result<PathBuf> {
118    let Some(rest) = raw.strip_prefix('~') else {
119        return Ok(PathBuf::from(raw));
120    };
121    if !(rest.is_empty() || rest.starts_with('/')) {
122        bail!("cannot expand {raw:?}: only a leading `~/` is supported, not `~user`");
123    }
124    let home = directories::BaseDirs::new()
125        .ok_or_else(|| anyhow!("cannot locate the home directory to expand {raw:?}"))?
126        .home_dir()
127        .to_path_buf();
128    Ok(home.join(rest.trim_start_matches('/')))
129}
130
131/// Where the config lives: `~/.config/coop/config.toml`.
132/// Written to the config path the first time coop runs without one.
133///
134/// Every host is commented out, so the file is a prompt rather than a guess:
135/// coop cannot know a host name, and inventing one would produce confusing
136/// failures against a target that does not exist.
137pub const TEMPLATE: &str = "\
138# coop hosts. Uncomment and edit -- the section name is what you pass to --host.
139#
140# One block per host. `target` is the only key worth setting by hand; every
141# other line below shows its default and can stay commented out.
142#
143# [hosts.build]
144# target      = \"build\"                    # ssh target (default: section name)
145# socket      = \"~/.ssh/coop/build.sock\"   # coop's own ControlPath
146# tmux_socket = \"coop\"                     # private tmux server
147# max_running = 4                          # warn past this; not a queue
148# default_cwd = \"~/work\"                   # where `run` starts, unless --cwd
149# keep_days   = 14                         # prune finished jobs older than this
150# max_log_bytes = 104857600                # 100MB; longer logs are truncated
151# max_job_secs = 0                         # remote runtime cap; 0 is unbounded
152#
153# Then open the control master, once per ControlPersist window. This may ask
154# you to touch a hardware key; coop cannot do it for you:
155#
156#   ssh -MNf -S ~/.ssh/coop/build.sock -o ControlPersist=8h build
157";
158
159/// Write [`TEMPLATE`] to `path` unless something is already there.
160///
161/// Returns whether it created the file. Uses `create_new`, so a race with
162/// another coop process cannot clobber a real config.
163pub fn seed(path: &Path) -> Result<bool> {
164    if let Some(parent) = path.parent() {
165        std::fs::create_dir_all(parent)
166            .with_context(|| format!("creating {}", parent.display()))?;
167    }
168    match std::fs::OpenOptions::new()
169        .write(true)
170        .create_new(true)
171        .open(path)
172    {
173        Ok(mut file) => {
174            use std::io::Write;
175            file.write_all(TEMPLATE.as_bytes())
176                .with_context(|| format!("writing {}", path.display()))?;
177            Ok(true)
178        }
179        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
180        Err(e) => Err(e).with_context(|| format!("creating {}", path.display())),
181    }
182}
183
184pub fn default_path() -> Result<PathBuf> {
185    let dirs =
186        directories::BaseDirs::new().ok_or_else(|| anyhow!("cannot locate a home directory"))?;
187    // `config_dir()` is `~/Library/Application Support` on macOS, which is not
188    // where a hand-edited dotfile belongs. coop is a terminal tool, so it uses
189    // the XDG layout on every platform and stays greppable.
190    let base = std::env::var_os("XDG_CONFIG_HOME")
191        .map(PathBuf::from)
192        .unwrap_or_else(|| dirs.home_dir().join(".config"));
193    Ok(base.join("coop").join("config.toml"))
194}
195
196impl Config {
197    pub fn load(path: &Path) -> Result<Self> {
198        let text = std::fs::read_to_string(path)
199            .with_context(|| format!("reading config {}", path.display()))?;
200        Self::parse(&text).with_context(|| format!("in config {}", path.display()))
201    }
202
203    pub fn parse(text: &str) -> Result<Self> {
204        let raw: RawConfig = toml::from_str(text)?;
205        if raw.hosts.is_empty() {
206            // Reached via the template, whose hosts are all commented out, so
207            // point at the two lines that actually turn it into a config.
208            bail!(
209                "no hosts configured\n  \
210                 uncomment a block, or add:\n\n    \
211                 [hosts.dev]\n    target = \"dev\""
212            );
213        }
214        let hosts = raw
215            .hosts
216            .into_iter()
217            .map(|(name, h)| {
218                // The section name is not just a label: it is appended to
219                // `{host}.lock` under the state dir and to the default
220                // `~/.ssh/coop/{host}.sock`. TOML allows a quoted key, so
221                // without this a name is arbitrary text reaching two paths.
222                // Measured: `[hosts."../../../../tmp/coop-escape"]` parsed and
223                // produced a socket outside the directory coop owns, and a `/`
224                // nests the lock somewhere `create_dir_all` may not reach --
225                // which lets two hosts share one lock and silently breaks the
226                // per-host serialisation the fairness gate depends on.
227                //
228                // Same grammar as `tmux_socket` below, for the same reason: a
229                // host name is a filename component, so this loses nothing
230                // real.
231                if !is_filename_component(&name) {
232                    bail!(
233                        "invalid host name {name:?}; use letters, digits, dot, \
234                         dash or underscore, and not `.` or `..`"
235                    );
236                }
237                let tmux_socket = h
238                    .tmux_socket
239                    .unwrap_or_else(|| DEFAULT_TMUX_SOCKET.to_string());
240                // Interpolated unquoted into every remote script, so a socket
241                // name containing shell syntax would be command injection from
242                // a config file. tmux socket names are a filename component,
243                // so this grammar loses nothing real.
244                if !is_filename_component(&tmux_socket) {
245                    bail!(
246                        "host {name:?}: invalid tmux_socket {tmux_socket:?}; \
247                         use letters, digits, dot, dash or underscore"
248                    );
249                }
250                let socket = match h.socket {
251                    Some(s) => expand_tilde(&s)?,
252                    None => expand_tilde(&format!("~/.ssh/coop/{name}.sock"))?,
253                };
254                Ok(Host {
255                    target: h.target.unwrap_or_else(|| name.clone()),
256                    socket,
257                    tmux_socket,
258                    max_running: h.max_running.unwrap_or(DEFAULT_MAX_RUNNING),
259                    default_cwd: h.default_cwd,
260                    keep_days: h.keep_days.unwrap_or(DEFAULT_KEEP_DAYS),
261                    max_log_bytes: h.max_log_bytes.unwrap_or(DEFAULT_MAX_LOG_BYTES),
262                    max_job_secs: h.max_job_secs.unwrap_or(DEFAULT_MAX_JOB_SECS),
263                    name,
264                })
265            })
266            .collect::<Result<Vec<_>>>()?;
267        Ok(Self { hosts })
268    }
269
270    pub fn hosts(&self) -> &[Host] {
271        &self.hosts
272    }
273
274    /// Resolve `--host`. `None` is the single configured host, or an error that
275    /// names the choices — the fix is one flag away, so the user should never
276    /// have to open the config to learn the names.
277    pub fn host(&self, name: Option<&str>) -> Result<&Host> {
278        let names = || {
279            self.hosts
280                .iter()
281                .map(|h| h.name.as_str())
282                .collect::<Vec<_>>()
283                .join(", ")
284        };
285        match name {
286            Some(n) => self
287                .hosts
288                .iter()
289                .find(|h| h.name == n)
290                .ok_or_else(|| anyhow!("unknown host {n:?}; configured: {}", names())),
291            None if self.hosts.len() == 1 => Ok(&self.hosts[0]),
292            None => Err(anyhow!(
293                "several hosts configured; pass --host <name>: {}",
294                names()
295            )),
296        }
297    }
298}