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}