1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5pub struct ReadyFile {
6 path: PathBuf,
7}
8
9impl ReadyFile {
10 pub fn new(path: Option<&str>) -> Result<Option<Self>, String> {
11 let Some(path) = path else {
12 return Ok(None);
13 };
14 let ready_file = Self {
15 path: PathBuf::from(path),
16 };
17 ready_file.clear()?;
18 Ok(Some(ready_file))
19 }
20
21 pub fn mark_ready(&self, process: &str, detail: &str) -> Result<(), String> {
22 self.write_status(process, "ready", detail)
23 }
24
25 pub fn mark_draining(&self, process: &str, detail: &str) -> Result<(), String> {
26 self.write_status(process, "draining", detail)
27 }
28
29 fn write_status(&self, process: &str, status: &str, detail: &str) -> Result<(), String> {
30 if let Some(parent) = self.path.parent() {
31 fs::create_dir_all(parent).map_err(|err| {
32 format!(
33 "failed to create readiness dir {}: {}",
34 parent.display(),
35 err
36 )
37 })?;
38 }
39
40 let body = format!(
41 "version=1\nstatus={}\nprocess={}\npid={}\ntimestamp_ms={}\ndetail={}\n",
42 status,
43 process,
44 std::process::id(),
45 now_unix_ms(),
46 escape_value(detail),
47 );
48 fs::write(&self.path, body).map_err(|err| {
49 format!(
50 "failed to write readiness file {}: {}",
51 self.path.display(),
52 err
53 )
54 })
55 }
56
57 pub fn clear(&self) -> Result<(), String> {
58 match fs::remove_file(&self.path) {
59 Ok(()) => Ok(()),
60 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
61 Err(err) => Err(format!(
62 "failed to remove readiness file {}: {}",
63 self.path.display(),
64 err
65 )),
66 }
67 }
68
69 pub fn path(&self) -> &Path {
70 &self.path
71 }
72}
73
74impl Drop for ReadyFile {
75 fn drop(&mut self) {
76 let _ = self.clear();
77 }
78}
79
80fn now_unix_ms() -> u128 {
81 SystemTime::now()
82 .duration_since(UNIX_EPOCH)
83 .unwrap_or_default()
84 .as_millis()
85}
86
87fn escape_value(value: &str) -> String {
88 value.replace('\\', "\\\\").replace('\n', "\\n")
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn ready_file_lifecycle_writes_and_clears_contract_file() {
97 let tempdir = tempfile::tempdir().unwrap();
98 let path = tempdir.path().join("sentineld.ready");
99 let path_string = path.display().to_string();
100 let ready_file = ReadyFile::new(Some(&path_string))
101 .unwrap()
102 .expect("ready file should be configured");
103
104 ready_file
105 .mark_ready(
106 "rns-sentineld",
107 "hooks loaded and provider bridge connected",
108 )
109 .unwrap();
110
111 let body = fs::read_to_string(ready_file.path()).unwrap();
112 assert!(body.contains("version=1"));
113 assert!(body.contains("status=ready"));
114 assert!(body.contains("process=rns-sentineld"));
115 assert!(body.contains("detail=hooks loaded and provider bridge connected"));
116
117 ready_file.clear().unwrap();
118 assert!(!ready_file.path().exists());
119 }
120
121 #[test]
122 fn ready_file_can_report_draining_state() {
123 let tempdir = tempfile::tempdir().unwrap();
124 let path = tempdir.path().join("statsd.ready");
125 let path_string = path.display().to_string();
126 let ready_file = ReadyFile::new(Some(&path_string))
127 .unwrap()
128 .expect("ready file should be configured");
129
130 ready_file
131 .mark_draining("rns-statsd", "stopping ingest and flushing stats database")
132 .unwrap();
133
134 let body = fs::read_to_string(ready_file.path()).unwrap();
135 assert!(body.contains("status=draining"));
136 assert!(body.contains("process=rns-statsd"));
137 assert!(body.contains("detail=stopping ingest and flushing stats database"));
138 }
139
140 #[test]
141 fn ready_file_new_clears_stale_file() {
142 let tempdir = tempfile::tempdir().unwrap();
143 let path = tempdir.path().join("statsd.ready");
144 fs::write(&path, "stale").unwrap();
145
146 let path_string = path.display().to_string();
147 let ready_file = ReadyFile::new(Some(&path_string))
148 .unwrap()
149 .expect("ready file should be configured");
150
151 assert!(!ready_file.path().exists());
152 }
153}