Skip to main content

shep_core/
overrides.rs

1//! `overrides.json`: what an operator has changed since a Flockfile was
2//! loaded.
3//!
4//! A Flockfile arrives from an app's own repository, so a merged pull request
5//! must not be able to silently change a running flock's config out from
6//! under an operator who edited it live. This store is where that edit lives:
7//! one entry per sheep name, holding the fields an operator set that the
8//! Flockfile does not currently declare. A later file load merges the two:
9//! the Flockfile's declared keys win, everything else falls back to the
10//! override, then to the built-in default. The store's own shape carries
11//! no merge logic itself; it is the ledger the merge reads and writes.
12//!
13//! # Writing
14//!
15//! Same shape as [`crate::kv`]: a read-modify-rename under an exclusive
16//! advisory lock on a sibling `overrides.json.lock`, staged through a
17//! uniquely-named `0600` temp file, `fsync`ed and `rename`d over the
18//! original. Copied rather than shared because `KvLock` is private to its
19//! module: see that module's own doc for why the lock exists at all and why
20//! `snapshot::write_atomic`'s lock-free shape does not apply here: this store
21//! is written by the daemon today and will be written by CLI verbs later, so
22//! two independent OS processes can race on it exactly as `kv.json` can.
23
24use core::fmt;
25use std::collections::{BTreeMap, BTreeSet};
26use std::io::Write as _;
27use std::path::Path;
28// `PathBuf` backs `lock_path` below, which both platform arms of
29// `OverridesLock` need (the unix one for `nix::fcntl::Flock`'s target, the
30// windows one for the `share_mode(0)` handle), so it is gated the same way
31// `lock_path` is, rather than to `cfg(unix)` alone.
32#[cfg(any(unix, windows))]
33use std::path::PathBuf;
34
35use serde::{Deserialize, Serialize};
36
37/// The on-disk format's version.
38///
39/// A store carrying a HIGHER version is refused rather than read or replaced
40/// ([`OverridesError::FutureVersion`]): the file holds an operator's live
41/// edits with no Flockfile copy to fall back to, and there is no undo for a
42/// downgrade that overwrites it. `kv.rs`'s `KV_VERSION` is the precedent.
43pub const OVERRIDES_VERSION: u32 = 1;
44
45/// One sheep's overrides: the fields an operator has set that its current
46/// Flockfile does not declare.
47///
48/// `fields` is a flat JSON object rather than a typed `AppConfig` because a
49/// later shep version may accept fields this one does not know, and reading
50/// this store must not silently drop them (the same reasoning
51/// [`OverridesError::FutureVersion`] applies to the whole file, applied per
52/// field instead). `declared` and `declared_env` are not overrides
53/// themselves: they are the set of keys the *Flockfile* has established, kept
54/// here so a later merge can tell "the file used to declare this and no
55/// longer does" apart from "the file never mentioned it".
56#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct AppOverrides {
58    /// Operator-set field values, keyed by the same names `AppConfig`'s
59    /// fields use. May include an `env` object.
60    pub fields: serde_json::Map<String, serde_json::Value>,
61    /// Names of fields the current Flockfile declares.
62    pub declared: BTreeSet<String>,
63    /// Names of `env` keys the current Flockfile declares.
64    pub declared_env: BTreeSet<String>,
65}
66
67/// Redacted: `fields` can hold an `env` map, and this store is the primary
68/// place an operator's secrets live (IR-41).
69impl fmt::Debug for AppOverrides {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.debug_struct("AppOverrides")
72            .field("fields", &format_args!("<{} fields>", self.fields.len()))
73            .field("declared", &self.declared)
74            .field("declared_env", &self.declared_env)
75            .finish()
76    }
77}
78
79/// The file's shape: a version and a flat map of sheep name to overrides.
80///
81/// `BTreeMap`, not `HashMap`, so the file is written in key order and two
82/// writes of the same content produce byte-identical files, which makes the
83/// store diffable, greppable, and safe to keep in a dotfiles repository.
84/// Same argument `kv::KvFile` records.
85#[derive(Debug, Default, Serialize, Deserialize)]
86struct OverridesFile {
87    version: u32,
88    apps: BTreeMap<String, AppOverrides>,
89}
90
91/// Error type returned by this module.
92///
93/// `#[non_exhaustive]`: shep-core is a published library and this enum is
94/// reachable from it, so a further failure shape must not break an
95/// out-of-tree consumer's `match` (IR-20).
96///
97/// Wraps `io::Error`/`serde_json::Error` directly rather than stringifying
98/// them, matching [`crate::kv::KvError`], so callers keep the underlying
99/// diagnostic through [`core::error::Error::source`], at the cost,
100/// documented there too, of not deriving `Clone`/`PartialEq`/`Eq` (IR-19's
101/// exception for variants wrapping `io::Error`).
102#[non_exhaustive]
103#[derive(Debug)]
104pub enum OverridesError {
105    /// The store could not be read, written, or replaced.
106    Io(std::io::Error),
107    /// The store's JSON could not be parsed.
108    ///
109    /// Refused rather than repaired: this file is an operator's live config
110    /// and a partial read of it would silently drop overrides that are still
111    /// on disk.
112    Decode(serde_json::Error),
113    /// The store on disk is a version this build does not understand; carries
114    /// that version. Nothing was written.
115    FutureVersion(u32),
116}
117
118impl fmt::Display for OverridesError {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        match self {
121            Self::Io(err) => write!(f, "overrides store I/O failed: {err}"),
122            Self::Decode(err) => write!(f, "overrides store failed to parse: {err}"),
123            Self::FutureVersion(version) => {
124                write!(
125                    f,
126                    "overrides store is version {version}, newer than this build understands"
127                )
128            }
129        }
130    }
131}
132
133impl core::error::Error for OverridesError {
134    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
135        match self {
136            Self::Io(err) => Some(err),
137            Self::Decode(err) => Some(err),
138            Self::FutureVersion(_) => None,
139        }
140    }
141}
142
143impl From<std::io::Error> for OverridesError {
144    fn from(source: std::io::Error) -> Self {
145        Self::Io(source)
146    }
147}
148
149impl From<serde_json::Error> for OverridesError {
150    fn from(source: serde_json::Error) -> Self {
151        Self::Decode(source)
152    }
153}
154
155/// The lock file that guards `path`: its own name with `.lock` appended, so
156/// it sits in `$SHEP_HOME` next to the store and inherits that directory's
157/// `0700`.
158///
159/// Copied from `kv::lock_path`: see that module's doc for why a sibling
160/// file rather than a lock on the store itself.
161#[cfg(any(unix, windows))]
162fn lock_path(path: &Path) -> PathBuf {
163    let mut name = path
164        .file_name()
165        .map(std::ffi::OsStr::to_os_string)
166        .unwrap_or_default();
167    name.push(".lock");
168    path.parent().unwrap_or_else(|| Path::new(".")).join(name)
169}
170
171/// An exclusive advisory lock over one overrides store, held for as long as
172/// the value lives and released when it drops (including on an early `?`,
173/// and by the kernel if the process dies holding it).
174///
175/// Copied from `kv::KvLock`, which is private to its module: see that
176/// module's doc for the two-platform dance this mirrors, and for why the
177/// lock is on a **sibling** `overrides.json.lock`, never on the store
178/// itself.
179struct OverridesLock {
180    /// `flock(2)` is released by this handle's `Drop`. Named with a leading
181    /// underscore because it is held, never read.
182    #[cfg(unix)]
183    _flock: nix::fcntl::Flock<std::fs::File>,
184    /// The lock file, opened with `share_mode(0)` so no other handle,
185    /// same-process or not, read or write, can open it while this one is
186    /// live. Released by this handle's `Drop`, the same role `_flock` plays
187    /// on unix. Named with a leading underscore because it is held, never
188    /// read.
189    #[cfg(windows)]
190    _handle: std::fs::File,
191}
192
193impl OverridesLock {
194    /// Blocks until this process holds the store's lock exclusively.
195    ///
196    /// # Errors
197    /// The lock file could not be created beside `path`, or `flock` failed
198    /// for a reason other than contention (contention blocks rather than
199    /// failing).
200    #[cfg(unix)]
201    fn acquire(path: &Path) -> std::io::Result<Self> {
202        use nix::fcntl::{Flock, FlockArg};
203        use std::os::unix::fs::OpenOptionsExt as _;
204
205        let file = std::fs::OpenOptions::new()
206            .write(true)
207            .create(true)
208            .truncate(false)
209            .mode(crate::atomic_file::OWNER_ONLY_FILE_MODE)
210            .open(lock_path(path))?;
211
212        Flock::lock(file, FlockArg::LockExclusive)
213            .map(|flock| Self { _flock: flock })
214            .map_err(|(_file, errno)| std::io::Error::from(errno))
215    }
216
217    /// Blocks until this process holds the store's lock exclusively.
218    ///
219    /// `flock(2)` has no Windows equivalent, but `share_mode(0)` gives the
220    /// same exclusivity through a different door: see `kv::KvLock::acquire`
221    /// (windows) for the full reasoning this mirrors, including why it polls
222    /// on a short sleep rather than blocking.
223    ///
224    /// # Errors
225    /// The lock file could not be created beside `path`, or the open failed
226    /// for a reason other than sharing contention (contention retries rather
227    /// than failing).
228    #[cfg(windows)]
229    fn acquire(path: &Path) -> std::io::Result<Self> {
230        use std::os::windows::fs::OpenOptionsExt as _;
231
232        /// Windows' `ERROR_SHARING_VIOLATION`: another handle already holds
233        /// share access this open's `share_mode(0)` denies. Hardcoded rather
234        /// than pulled from `windows-sys`, matching `kv::KvLock::acquire`.
235        const ERROR_SHARING_VIOLATION: i32 = 32;
236
237        /// How long a contended retry sleeps before trying again. Short
238        /// enough that a lock held for a normal `put`/`get`'s duration (a
239        /// handful of small file operations) costs this loop only a few
240        /// iterations, long enough not to spin the CPU while it waits.
241        const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2);
242
243        let lock_path = lock_path(path);
244        loop {
245            match std::fs::OpenOptions::new()
246                .write(true)
247                .create(true)
248                .truncate(false)
249                .share_mode(0)
250                .open(&lock_path)
251            {
252                Ok(handle) => return Ok(Self { _handle: handle }),
253                Err(error) if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION) => {
254                    std::thread::sleep(RETRY_INTERVAL);
255                }
256                Err(error) => return Err(error),
257            }
258        }
259    }
260}
261
262/// Reads `path` under the lock the caller already holds.
263///
264/// A missing file reads as an empty, current-version store: a fresh
265/// `$SHEP_HOME` has no overrides, and that is the normal state, not a fault.
266/// Any other `io::Error` propagates.
267fn read_file(path: &Path) -> Result<OverridesFile, OverridesError> {
268    let raw = match std::fs::read_to_string(path) {
269        Ok(raw) => raw,
270        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
271            return Ok(OverridesFile::default());
272        }
273        Err(err) => return Err(OverridesError::Io(err)),
274    };
275    let file: OverridesFile = serde_json::from_str(&raw)?;
276    if file.version > OVERRIDES_VERSION {
277        return Err(OverridesError::FutureVersion(file.version));
278    }
279    Ok(file)
280}
281
282/// Rewrites `path` to hold exactly `file`, atomically: see this module's
283/// own doc for the staged-temp-file-then-rename shape.
284fn write_file(path: &Path, file: &OverridesFile) -> Result<(), OverridesError> {
285    let parent = path.parent().unwrap_or_else(|| Path::new("."));
286    let mut tmp = crate::atomic_file::create_staging_file(parent, "overrides", ".tmp")?;
287
288    let json = serde_json::to_string_pretty(file)?;
289    tmp.write_all(json.as_bytes())?;
290    tmp.write_all(b"\n")?;
291    tmp.as_file().sync_all()?;
292
293    // `persist` is `rename(2)`. On failure the `NamedTempFile` comes back
294    // inside the error and its `Drop` removes the staging file, so a failed
295    // replace does not leave one behind.
296    tmp.persist(path)
297        .map_err(|err| OverridesError::Io(err.error))?;
298
299    // The `sync_all` above made the CONTENTS durable; this makes the rename
300    // that published them durable. See `shep_core::atomic_file`.
301    crate::atomic_file::sync_dir(parent)?;
302    Ok(())
303}
304
305/// Every sheep's overrides, in name order.
306///
307/// # Errors
308///
309/// - [`OverridesError::Io`]: the store could not be opened or read. A store
310///   that is simply absent is not an error: it reads as empty.
311/// - [`OverridesError::Decode`]: the file is not the JSON this module
312///   writes.
313/// - [`OverridesError::FutureVersion`]: the file's `version` is newer than
314///   [`OVERRIDES_VERSION`]. Nothing is read and nothing is written.
315pub fn all(path: &Path) -> Result<BTreeMap<String, AppOverrides>, OverridesError> {
316    // Taking the lock here too costs one extra `open` and removes the
317    // question of whether a lock-free reader could observe a half-`rename`d
318    // file entirely: harmless in practice, since the rename is atomic and
319    // the worst case is a whole old file, but not worth reasoning about
320    // twice. Do not "optimize" this away without re-deriving that. Same
321    // argument `kv::all` records.
322    let _lock = OverridesLock::acquire(path)?;
323    Ok(read_file(path)?.apps)
324}
325
326/// One sheep's overrides, or `None` if it has none.
327///
328/// # Errors
329///
330/// [`OverridesError::Io`], [`OverridesError::Decode`] and
331/// [`OverridesError::FutureVersion`], exactly as [`all`] returns them.
332pub fn get(path: &Path, name: &str) -> Result<Option<AppOverrides>, OverridesError> {
333    Ok(all(path)?.remove(name))
334}
335
336/// Stores `value` under `name`, replacing any previous overrides.
337///
338/// # Errors
339///
340/// - [`OverridesError::FutureVersion`]: the store on disk is newer than this
341///   build understands. **Nothing is written**; a downgrade that overwrote
342///   an operator's overrides has no undo.
343/// - [`OverridesError::Decode`]: the existing file could not be parsed.
344///   Refused rather than replaced, for the same reason.
345/// - [`OverridesError::Io`]: the lock, the temp file, the `fsync` or the
346///   `rename` failed. Either the whole write landed or none of it did.
347pub fn put(path: &Path, name: &str, value: &AppOverrides) -> Result<(), OverridesError> {
348    let _lock = OverridesLock::acquire(path)?;
349    let mut file = read_file(path)?;
350    file.version = OVERRIDES_VERSION;
351    file.apps.insert(name.to_string(), value.clone());
352    write_file(path, &file)
353}
354
355/// Removes `name`'s overrides, returning whether it was there.
356///
357/// # Errors
358///
359/// The same set [`put`] returns: `FutureVersion`, `Decode`, `Io`.
360pub fn remove(path: &Path, name: &str) -> Result<bool, OverridesError> {
361    let _lock = OverridesLock::acquire(path)?;
362    let mut file = read_file(path)?;
363    let was_present = file.apps.remove(name).is_some();
364    if was_present {
365        file.version = OVERRIDES_VERSION;
366        write_file(path, &file)?;
367    }
368    Ok(was_present)
369}
370
371/// Applies several changes at once: `Some` stores, `None` removes.
372///
373/// One lock acquisition and one rewrite for the whole batch, where the
374/// per-name [`put`] and [`remove`] take one each. The daemon merges a whole
375/// Flockfile in one pass, and doing that through the single-name calls made
376/// an eleven-app file 11 full rewrites of this store on the thread
377/// supervising the flock. It also makes the record of one load atomic: either
378/// every app the load established is written, or none is.
379///
380/// Names this batch does not mention are left exactly as they are, which is
381/// what makes this safe against a concurrent writer touching a different app:
382/// the read and the write both happen under the one lock, so this is a
383/// read-modify-write of the whole file rather than a blind overwrite of it.
384///
385/// An empty batch takes no lock and writes nothing.
386///
387/// # Errors
388///
389/// The same set [`put`] returns: `FutureVersion`, `Decode`, `Io`. Nothing is
390/// written on any of them.
391pub fn update(
392    path: &Path,
393    changes: &BTreeMap<String, Option<AppOverrides>>,
394) -> Result<(), OverridesError> {
395    if changes.is_empty() {
396        return Ok(());
397    }
398    let _lock = OverridesLock::acquire(path)?;
399    let mut file = read_file(path)?;
400    for (name, change) in changes {
401        match change {
402            Some(value) => {
403                file.apps.insert(name.clone(), value.clone());
404            }
405            None => {
406                file.apps.remove(name);
407            }
408        }
409    }
410    file.version = OVERRIDES_VERSION;
411    write_file(path, &file)
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    /// fails if a batch does not store and remove in one pass, or if it
419    /// touches a name it was not given. The daemon writes a whole Flockfile
420    /// through this, so a batch that clobbered an app the file never
421    /// mentioned would delete an operator's overrides for it.
422    #[test]
423    fn update_stores_removes_and_leaves_the_rest_alone() {
424        let dir = tempfile::TempDir::new().unwrap();
425        let path = dir.path().join("overrides.json");
426        let record = |value: u64| AppOverrides {
427            fields: [("max_restarts".to_string(), serde_json::json!(value))]
428                .into_iter()
429                .collect(),
430            ..AppOverrides::default()
431        };
432        put(&path, "web", &record(1)).unwrap();
433        put(&path, "worker", &record(2)).unwrap();
434        put(&path, "bystander", &record(3)).unwrap();
435
436        let changes = BTreeMap::from([
437            ("web".to_string(), Some(record(9))),
438            ("worker".to_string(), None),
439        ]);
440        update(&path, &changes).unwrap();
441
442        let all = all(&path).unwrap();
443        assert_eq!(all.get("web"), Some(&record(9)));
444        assert_eq!(all.get("worker"), None);
445        assert_eq!(all.get("bystander"), Some(&record(3)));
446    }
447
448    /// fails if an empty batch writes anything. A load whose every app
449    /// refused must not rewrite the store at all.
450    #[test]
451    fn an_empty_update_writes_nothing() {
452        let dir = tempfile::TempDir::new().unwrap();
453        let path = dir.path().join("overrides.json");
454        update(&path, &BTreeMap::new()).unwrap();
455        assert!(!path.exists(), "an empty batch created a store");
456    }
457
458    /// fails if a written override does not come back.
459    #[test]
460    fn put_then_get_round_trips() {
461        let dir = tempfile::TempDir::new().unwrap();
462        let path = dir.path().join("overrides.json");
463        let mut fields = serde_json::Map::new();
464        fields.insert("max_memory".to_string(), serde_json::json!("512M"));
465        let value = AppOverrides {
466            fields,
467            declared: ["name", "script"].iter().map(|s| s.to_string()).collect(),
468            declared_env: BTreeSet::new(),
469        };
470        put(&path, "web", &value).unwrap();
471        assert_eq!(get(&path, "web").unwrap().as_ref(), Some(&value));
472    }
473
474    /// fails if a missing store is an error. A fresh $SHEP_HOME has no
475    /// overrides and that is the normal state, not a fault.
476    #[test]
477    fn a_missing_store_reads_as_empty() {
478        let dir = tempfile::TempDir::new().unwrap();
479        assert!(all(&dir.path().join("overrides.json")).unwrap().is_empty());
480    }
481
482    /// fails if the store is readable by anyone but its owner. It holds env
483    /// values, which is what flock.json's own owner-only test exists for.
484    #[cfg(unix)]
485    #[test]
486    fn the_store_is_owner_only() {
487        use std::os::unix::fs::PermissionsExt as _;
488        let dir = tempfile::TempDir::new().unwrap();
489        let path = dir.path().join("overrides.json");
490        put(&path, "web", &AppOverrides::default()).unwrap();
491        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
492        assert_eq!(mode & 0o777, 0o600, "mode was {:o}", mode & 0o777);
493    }
494
495    /// fails if Debug prints an env value. This store is where an operator's
496    /// secrets will live (IR-41).
497    #[test]
498    fn debug_redacts_override_values() {
499        let mut fields = serde_json::Map::new();
500        fields.insert(
501            "env".to_string(),
502            serde_json::json!({"DATABASE_URL": "postgres://hunter2"}),
503        );
504        let value = AppOverrides {
505            fields,
506            ..AppOverrides::default()
507        };
508        let rendered = format!("{value:?}");
509        assert!(!rendered.contains("hunter2"), "leaked: {rendered}");
510        // Exact string pinned so a lazy derive(Debug) refactor fails here,
511        // matching `config::app`'s own `debug_redacts_env_values`.
512        assert_eq!(
513            rendered,
514            "AppOverrides { fields: <1 fields>, declared: {}, declared_env: {} }"
515        );
516    }
517
518    /// fails if a store written by a NEWER shep is silently rewritten by an
519    /// older one, which would drop every field this binary does not know.
520    #[test]
521    fn a_future_version_refuses_without_clobbering() {
522        let dir = tempfile::TempDir::new().unwrap();
523        let path = dir.path().join("overrides.json");
524        std::fs::write(&path, r#"{"version":99,"apps":{}}"#).unwrap();
525        assert!(matches!(
526            get(&path, "web"),
527            Err(OverridesError::FutureVersion(99))
528        ));
529        assert_eq!(
530            std::fs::read_to_string(&path).unwrap(),
531            r#"{"version":99,"apps":{}}"#
532        );
533    }
534
535    /// fails if two concurrent writers lose each other's work. This is what
536    /// the lock is for; `kv.rs`'s own version of this test is the model.
537    ///
538    /// Bounded (IR-46): each join is under a timeout, so a lock that
539    /// deadlocks fails this test instead of hanging the suite.
540    #[test]
541    fn two_concurrent_writers_lose_nothing() {
542        let dir = tempfile::TempDir::new().unwrap();
543        let path = dir.path().join("overrides.json");
544        const PER_WRITER: usize = 50;
545
546        let (done_tx, done_rx) = std::sync::mpsc::channel();
547        for writer in 0..2 {
548            let path = path.clone();
549            let done_tx = done_tx.clone();
550            std::thread::spawn(move || {
551                for n in 0..PER_WRITER {
552                    put(&path, &format!("w{writer}-{n}"), &AppOverrides::default()).unwrap();
553                }
554                done_tx.send(()).unwrap();
555            });
556        }
557        drop(done_tx);
558        for _ in 0..2 {
559            done_rx
560                .recv_timeout(std::time::Duration::from_secs(60))
561                .expect("a writer did not finish within 60s");
562        }
563
564        assert_eq!(all(&path).unwrap().len(), PER_WRITER * 2);
565    }
566}