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