1use core::fmt;
14use std::collections::{BTreeMap, BTreeSet};
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18
19use crate::file_lock::FileLock;
20
21pub const OVERRIDES_VERSION: u32 = 1;
27
28#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct AppOverrides {
38 pub fields: serde_json::Map<String, serde_json::Value>,
41 pub declared: BTreeSet<String>,
43 pub declared_env: BTreeSet<String>,
45}
46
47impl 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#[derive(Debug, Default, Serialize, Deserialize)]
64struct OverridesFile {
65 version: u32,
66 apps: BTreeMap<String, AppOverrides>,
67}
68
69#[non_exhaustive]
79#[derive(Debug)]
80pub enum OverridesError {
81 Io(std::io::Error),
83 Decode(serde_json::Error),
89 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
131fn 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
151fn write_file(path: &Path, file: &OverridesFile) -> Result<(), OverridesError> {
153 crate::atomic_file::write_json(path, "overrides", file).map_err(OverridesError::Io)
154}
155
156pub fn all(path: &Path) -> Result<BTreeMap<String, AppOverrides>, OverridesError> {
167 let _lock = FileLock::acquire(path)?;
170 Ok(read_file(path)?.apps)
171}
172
173pub fn get(path: &Path, name: &str) -> Result<Option<AppOverrides>, OverridesError> {
180 Ok(all(path)?.remove(name))
181}
182
183pub 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
200pub 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
216pub 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 #[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 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 #[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}