1use 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
22pub const OVERRIDES_VERSION: u32 = 1;
28
29#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct AppOverrides {
39 pub fields: serde_json::Map<String, serde_json::Value>,
42 pub declared: BTreeSet<String>,
44 pub declared_env: BTreeSet<String>,
46}
47
48impl 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#[derive(Debug, Default, Serialize, Deserialize)]
65struct OverridesFile {
66 version: u32,
67 apps: BTreeMap<String, AppOverrides>,
68}
69
70#[non_exhaustive]
80#[derive(Debug)]
81pub enum OverridesError {
82 Io(std::io::Error),
84 Decode(serde_json::Error),
90 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
132fn 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
152fn 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 tmp.persist(path)
167 .map_err(|err| OverridesError::Io(err.error))?;
168
169 crate::atomic_file::sync_dir(parent)?;
172 Ok(())
173}
174
175pub fn all(path: &Path) -> Result<BTreeMap<String, AppOverrides>, OverridesError> {
186 let _lock = FileLock::acquire(path)?;
189 Ok(read_file(path)?.apps)
190}
191
192pub fn get(path: &Path, name: &str) -> Result<Option<AppOverrides>, OverridesError> {
199 Ok(all(path)?.remove(name))
200}
201
202pub 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
219pub 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
235pub 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 #[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 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 #[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}