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