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}