Skip to main content

rns_cli/
readiness.rs

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}