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