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 a
11//! [`crate::file_lock`] on a sibling `overrides.json.lock`.
12
13use core::fmt;
14use std::collections::{BTreeMap, BTreeSet};
15use std::io::Write as _;
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19
20use crate::file_lock::FileLock;
21
22/// The on-disk format's version.
23///
24/// A store carrying a higher version is refused rather than read or
25/// replaced ([`OverridesError::FutureVersion`]): there is no undo for a
26/// downgrade that overwrites an operator's live edits.
27pub const OVERRIDES_VERSION: u32 = 1;
28
29/// One sheep's overrides: the fields an operator has set that its current
30/// Flockfile does not declare.
31///
32/// `fields` is a flat JSON object rather than a typed `AppConfig`, since a
33/// newer shep may accept fields this one does not know, and reading must
34/// not silently drop them. `declared` and `declared_env` are not overrides
35/// themselves: they are the Flockfile's declared keys, kept so a merge can
36/// tell a key the Flockfile dropped apart from one it never mentioned.
37#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct AppOverrides {
39    /// Operator-set field values, keyed by the same names `AppConfig`'s
40    /// fields use. May include an `env` object.
41    pub fields: serde_json::Map<String, serde_json::Value>,
42    /// Names of fields the current Flockfile declares.
43    pub declared: BTreeSet<String>,
44    /// Names of `env` keys the current Flockfile declares.
45    pub declared_env: BTreeSet<String>,
46}
47
48/// Redacted: `fields` can hold an `env` map, and this store is where an
49/// operator's secrets live.
50impl fmt::Debug for AppOverrides {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.debug_struct("AppOverrides")
53            .field("fields", &format_args!("<{} fields>", self.fields.len()))
54            .field("declared", &self.declared)
55            .field("declared_env", &self.declared_env)
56            .finish()
57    }
58}
59
60/// The file's shape: a version and a flat map of sheep name to overrides.
61///
62/// `BTreeMap`, not `HashMap`, so the file writes in key order: two writes
63/// of the same content produce byte-identical files.
64#[derive(Debug, Default, Serialize, Deserialize)]
65struct OverridesFile {
66    version: u32,
67    apps: BTreeMap<String, AppOverrides>,
68}
69
70/// Error type returned by this module.
71///
72/// `#[non_exhaustive]`: shep-core is published, so a new failure variant
73/// must not break an out-of-tree `match`.
74///
75/// Wraps `io::Error`/`serde_json::Error` directly rather than stringifying
76/// them, matching [`crate::kv::KvError`], so callers keep the underlying
77/// diagnostic through [`core::error::Error::source`]; this type does not
78/// derive `Clone`/`PartialEq`/`Eq` as a result.
79#[non_exhaustive]
80#[derive(Debug)]
81pub enum OverridesError {
82    /// The store could not be read, written, or replaced.
83    Io(std::io::Error),
84    /// The store's JSON could not be parsed.
85    ///
86    /// Refused rather than repaired: this file is an operator's live config
87    /// and a partial read of it would silently drop overrides that are still
88    /// on disk.
89    Decode(serde_json::Error),
90    /// The store on disk is a version this build does not understand; carries
91    /// that version. Nothing was written.
92    FutureVersion(u32),
93}
94
95impl fmt::Display for OverridesError {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self {
98            Self::Io(err) => write!(f, "overrides store I/O failed: {err}"),
99            Self::Decode(err) => write!(f, "overrides store failed to parse: {err}"),
100            Self::FutureVersion(version) => {
101                write!(
102                    f,
103                    "overrides store is version {version}, newer than this build understands"
104                )
105            }
106        }
107    }
108}
109
110impl core::error::Error for OverridesError {
111    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
112        match self {
113            Self::Io(err) => Some(err),
114            Self::Decode(err) => Some(err),
115            Self::FutureVersion(_) => None,
116        }
117    }
118}
119
120impl From<std::io::Error> for OverridesError {
121    fn from(source: std::io::Error) -> Self {
122        Self::Io(source)
123    }
124}
125
126impl From<serde_json::Error> for OverridesError {
127    fn from(source: serde_json::Error) -> Self {
128        Self::Decode(source)
129    }
130}
131
132/// Reads `path` under the lock the caller already holds.
133///
134/// A missing file reads as an empty, current-version store: a fresh
135/// `$SHEP_HOME` has no overrides, and that is the normal state, not a fault.
136/// Any other `io::Error` propagates.
137fn read_file(path: &Path) -> Result<OverridesFile, OverridesError> {
138    let raw = match std::fs::read_to_string(path) {
139        Ok(raw) => raw,
140        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
141            return Ok(OverridesFile::default());
142        }
143        Err(err) => return Err(OverridesError::Io(err)),
144    };
145    let file: OverridesFile = serde_json::from_str(&raw)?;
146    if file.version > OVERRIDES_VERSION {
147        return Err(OverridesError::FutureVersion(file.version));
148    }
149    Ok(file)
150}
151
152/// Rewrites `path` to hold exactly `file`, atomically: staged through a
153/// temp file, then renamed over the original.
154fn write_file(path: &Path, file: &OverridesFile) -> Result<(), OverridesError> {
155    let parent = path.parent().unwrap_or_else(|| Path::new("."));
156    let mut tmp = crate::atomic_file::create_staging_file(parent, "overrides", ".tmp")?;
157
158    let json = serde_json::to_string_pretty(file)?;
159    tmp.write_all(json.as_bytes())?;
160    tmp.write_all(b"\n")?;
161    tmp.as_file().sync_all()?;
162
163    // `persist` is `rename(2)`. On failure the `NamedTempFile` comes back
164    // inside the error and its `Drop` removes the staging file, so a failed
165    // replace does not leave one behind.
166    tmp.persist(path)
167        .map_err(|err| OverridesError::Io(err.error))?;
168
169    // `sync_all` above made the contents durable; this makes the rename
170    // that published them durable too.
171    crate::atomic_file::sync_dir(parent)?;
172    Ok(())
173}
174
175/// Every sheep's overrides, in name order.
176///
177/// # Errors
178///
179/// - [`OverridesError::Io`]: the store could not be opened or read. A store
180///   that is simply absent is not an error: it reads as empty.
181/// - [`OverridesError::Decode`]: the file is not the JSON this module
182///   writes.
183/// - [`OverridesError::FutureVersion`]: the file's `version` is newer than
184///   [`OVERRIDES_VERSION`]. Nothing is read and nothing is written.
185pub fn all(path: &Path) -> Result<BTreeMap<String, AppOverrides>, OverridesError> {
186    // Taking the lock here too costs one extra `open`, but it orders this
187    // read against a writer's read-modify-rename instead of racing it.
188    let _lock = FileLock::acquire(path)?;
189    Ok(read_file(path)?.apps)
190}
191
192/// One sheep's overrides, or `None` if it has none.
193///
194/// # Errors
195///
196/// [`OverridesError::Io`], [`OverridesError::Decode`] and
197/// [`OverridesError::FutureVersion`], exactly as [`all`] returns them.
198pub fn get(path: &Path, name: &str) -> Result<Option<AppOverrides>, OverridesError> {
199    Ok(all(path)?.remove(name))
200}
201
202/// Stores `value` under `name`, replacing any previous overrides.
203///
204/// # Errors
205///
206/// - [`OverridesError::FutureVersion`]: the store on disk is newer than
207///   this build understands. Nothing is written.
208/// - [`OverridesError::Decode`]: the existing file could not be parsed.
209/// - [`OverridesError::Io`]: the lock, the temp file, the `fsync` or the
210///   `rename` failed.
211pub fn put(path: &Path, name: &str, value: &AppOverrides) -> Result<(), OverridesError> {
212    let _lock = FileLock::acquire(path)?;
213    let mut file = read_file(path)?;
214    file.version = OVERRIDES_VERSION;
215    file.apps.insert(name.to_string(), value.clone());
216    write_file(path, &file)
217}
218
219/// Removes `name`'s overrides, returning whether it was there.
220///
221/// # Errors
222///
223/// The same set [`put`] returns: `FutureVersion`, `Decode`, `Io`.
224pub fn remove(path: &Path, name: &str) -> Result<bool, OverridesError> {
225    let _lock = FileLock::acquire(path)?;
226    let mut file = read_file(path)?;
227    let was_present = file.apps.remove(name).is_some();
228    if was_present {
229        file.version = OVERRIDES_VERSION;
230        write_file(path, &file)?;
231    }
232    Ok(was_present)
233}
234
235/// Applies several changes at once: `Some` stores, `None` removes.
236///
237/// One lock and one rewrite for the whole batch, atomic: either every
238/// change lands or none does. Names the batch does not mention are left
239/// untouched, and the read and write happen under the same lock, so this
240/// is safe against a concurrent writer touching a different app. An empty
241/// batch takes no lock and writes nothing.
242///
243/// # Errors
244///
245/// The same set [`put`] returns: `FutureVersion`, `Decode`, `Io`. Nothing
246/// is written on any of them.
247pub fn update(
248    path: &Path,
249    changes: &BTreeMap<String, Option<AppOverrides>>,
250) -> Result<(), OverridesError> {
251    if changes.is_empty() {
252        return Ok(());
253    }
254    let _lock = FileLock::acquire(path)?;
255    let mut file = read_file(path)?;
256    for (name, change) in changes {
257        match change {
258            Some(value) => {
259                file.apps.insert(name.clone(), value.clone());
260            }
261            None => {
262                file.apps.remove(name);
263            }
264        }
265    }
266    file.version = OVERRIDES_VERSION;
267    write_file(path, &file)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn update_stores_removes_and_leaves_the_rest_alone() {
276        let dir = tempfile::TempDir::new().unwrap();
277        let path = dir.path().join("overrides.json");
278        let record = |value: u64| AppOverrides {
279            fields: [("max_restarts".to_string(), serde_json::json!(value))]
280                .into_iter()
281                .collect(),
282            ..AppOverrides::default()
283        };
284        put(&path, "web", &record(1)).unwrap();
285        put(&path, "worker", &record(2)).unwrap();
286        put(&path, "bystander", &record(3)).unwrap();
287
288        let changes = BTreeMap::from([
289            ("web".to_string(), Some(record(9))),
290            ("worker".to_string(), None),
291        ]);
292        update(&path, &changes).unwrap();
293
294        let all = all(&path).unwrap();
295        assert_eq!(all.get("web"), Some(&record(9)));
296        assert_eq!(all.get("worker"), None);
297        assert_eq!(all.get("bystander"), Some(&record(3)));
298    }
299
300    #[test]
301    fn an_empty_update_writes_nothing() {
302        let dir = tempfile::TempDir::new().unwrap();
303        let path = dir.path().join("overrides.json");
304        update(&path, &BTreeMap::new()).unwrap();
305        assert!(!path.exists(), "an empty batch created a store");
306    }
307
308    #[test]
309    fn put_then_get_round_trips() {
310        let dir = tempfile::TempDir::new().unwrap();
311        let path = dir.path().join("overrides.json");
312        let mut fields = serde_json::Map::new();
313        fields.insert("max_memory".to_string(), serde_json::json!("512M"));
314        let value = AppOverrides {
315            fields,
316            declared: ["name", "script"].iter().map(|s| s.to_string()).collect(),
317            declared_env: BTreeSet::new(),
318        };
319        put(&path, "web", &value).unwrap();
320        assert_eq!(get(&path, "web").unwrap().as_ref(), Some(&value));
321    }
322
323    #[test]
324    fn a_missing_store_reads_as_empty() {
325        let dir = tempfile::TempDir::new().unwrap();
326        assert!(all(&dir.path().join("overrides.json")).unwrap().is_empty());
327    }
328
329    /// Holds env values, same reason `flock.json` has its own owner-only test.
330    #[cfg(unix)]
331    #[test]
332    fn the_store_is_owner_only() {
333        use std::os::unix::fs::PermissionsExt as _;
334        let dir = tempfile::TempDir::new().unwrap();
335        let path = dir.path().join("overrides.json");
336        put(&path, "web", &AppOverrides::default()).unwrap();
337        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
338        assert_eq!(mode & 0o777, 0o600, "mode was {:o}", mode & 0o777);
339    }
340
341    #[test]
342    fn debug_redacts_override_values() {
343        let mut fields = serde_json::Map::new();
344        fields.insert(
345            "env".to_string(),
346            serde_json::json!({"DATABASE_URL": "postgres://hunter2"}),
347        );
348        let value = AppOverrides {
349            fields,
350            ..AppOverrides::default()
351        };
352        let rendered = format!("{value:?}");
353        assert!(!rendered.contains("hunter2"), "leaked: {rendered}");
354        // Exact string pinned so a lazy derive(Debug) refactor fails here,
355        // matching `config::app`'s own `debug_redacts_env_values`.
356        assert_eq!(
357            rendered,
358            "AppOverrides { fields: <1 fields>, declared: {}, declared_env: {} }"
359        );
360    }
361
362    #[test]
363    fn a_future_version_refuses_without_clobbering() {
364        let dir = tempfile::TempDir::new().unwrap();
365        let path = dir.path().join("overrides.json");
366        std::fs::write(&path, r#"{"version":99,"apps":{}}"#).unwrap();
367        assert!(matches!(
368            get(&path, "web"),
369            Err(OverridesError::FutureVersion(99))
370        ));
371        assert_eq!(
372            std::fs::read_to_string(&path).unwrap(),
373            r#"{"version":99,"apps":{}}"#
374        );
375    }
376
377    /// Bounded: each join is under a timeout, so a lock that deadlocks fails
378    /// this test instead of hanging the suite.
379    #[test]
380    fn two_concurrent_writers_lose_nothing() {
381        let dir = tempfile::TempDir::new().unwrap();
382        let path = dir.path().join("overrides.json");
383        const PER_WRITER: usize = 50;
384
385        let (done_tx, done_rx) = std::sync::mpsc::channel();
386        for writer in 0..2 {
387            let path = path.clone();
388            let done_tx = done_tx.clone();
389            std::thread::spawn(move || {
390                for n in 0..PER_WRITER {
391                    put(&path, &format!("w{writer}-{n}"), &AppOverrides::default()).unwrap();
392                }
393                done_tx.send(()).unwrap();
394            });
395        }
396        drop(done_tx);
397        for _ in 0..2 {
398            done_rx
399                .recv_timeout(std::time::Duration::from_secs(60))
400                .expect("a writer did not finish within 60s");
401        }
402
403        assert_eq!(all(&path).unwrap().len(), PER_WRITER * 2);
404    }
405}