Skip to main content

greentic_start/
runtime_state.rs

1#![allow(dead_code)]
2
3use std::path::{Path, PathBuf};
4
5use serde::de::DeserializeOwned;
6use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug)]
9pub struct RuntimePaths {
10    state_dir: PathBuf,
11    log_root: PathBuf,
12    tenant: String,
13    team: String,
14}
15
16impl RuntimePaths {
17    pub fn new(
18        state_dir: impl Into<PathBuf>,
19        tenant: impl Into<String>,
20        team: impl Into<String>,
21    ) -> Self {
22        let state_dir = state_dir.into();
23        let log_root = state_dir
24            .parent()
25            .map(|parent| parent.to_path_buf())
26            .unwrap_or_else(|| PathBuf::from("."))
27            .join("logs");
28        Self {
29            state_dir,
30            log_root,
31            tenant: tenant.into(),
32            team: team.into(),
33        }
34    }
35
36    pub fn key(&self) -> String {
37        format!("{}.{}", self.tenant, self.team)
38    }
39
40    pub fn runtime_root(&self) -> PathBuf {
41        self.state_dir.join("runtime").join(self.key())
42    }
43
44    pub fn pids_dir(&self) -> PathBuf {
45        self.state_dir.join("pids").join(self.key())
46    }
47
48    pub fn logs_dir(&self) -> PathBuf {
49        self.log_root.join(self.key())
50    }
51
52    pub fn dlq_log_path(&self) -> PathBuf {
53        self.logs_dir().join("dlq.log")
54    }
55
56    pub fn resolved_dir(&self) -> PathBuf {
57        self.runtime_root().join("resolved")
58    }
59
60    pub fn pid_path(&self, service_id: &str) -> PathBuf {
61        self.pids_dir().join(format!("{service_id}.pid"))
62    }
63
64    pub fn log_path(&self, service_id: &str) -> PathBuf {
65        self.logs_dir().join(format!("{service_id}.log"))
66    }
67
68    pub fn resolved_path(&self, service_id: &str) -> PathBuf {
69        self.resolved_dir().join(format!("{service_id}.json"))
70    }
71
72    pub fn logs_root(&self) -> PathBuf {
73        self.log_root.clone()
74    }
75
76    pub fn service_manifest_path(&self) -> PathBuf {
77        self.runtime_root().join("services.json")
78    }
79
80    pub fn control_dir(&self) -> PathBuf {
81        self.runtime_root().join("control")
82    }
83
84    pub fn stop_request_path(&self) -> PathBuf {
85        self.control_dir().join("stop.json")
86    }
87}
88
89pub fn write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
90    let bytes = serde_json::to_vec_pretty(value)?;
91    atomic_write(path, &bytes)
92}
93
94pub fn read_json<T: DeserializeOwned>(path: &Path) -> anyhow::Result<Option<T>> {
95    if !path.exists() {
96        return Ok(None);
97    }
98    let data = std::fs::read(path)?;
99    let value = serde_json::from_slice(&data)?;
100    Ok(Some(value))
101}
102
103pub fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
104    use std::io::Write;
105
106    if let Some(parent) = path.parent() {
107        std::fs::create_dir_all(parent)?;
108    }
109    let mut tmp = path.to_path_buf();
110    tmp.set_extension("tmp");
111    let mut file = std::fs::File::create(&tmp)?;
112    file.write_all(bytes)?;
113    file.sync_all()?;
114    std::fs::rename(&tmp, path)?;
115    if let Some(parent) = path.parent()
116        && let Ok(dir) = std::fs::File::open(parent)
117    {
118        let _ = dir.sync_all();
119    }
120    Ok(())
121}
122
123#[derive(Debug, Serialize, Deserialize, Default)]
124pub struct ServiceManifest {
125    #[serde(default)]
126    pub log_dir: Option<String>,
127    #[serde(default)]
128    pub services: Vec<ServiceEntry>,
129}
130
131#[derive(Debug, Serialize, Deserialize)]
132pub struct ServiceEntry {
133    pub id: String,
134    pub kind: String,
135    pub log_path: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct StopRequest {
140    pub requested_by: String,
141    #[serde(default)]
142    pub reason: Option<String>,
143}
144
145impl ServiceEntry {
146    pub fn new(id: impl Into<String>, kind: impl Into<String>, log_path: Option<&Path>) -> Self {
147        Self {
148            id: id.into(),
149            kind: kind.into(),
150            log_path: log_path.map(|path| path.display().to_string()),
151        }
152    }
153}
154
155pub fn persist_service_manifest(
156    paths: &RuntimePaths,
157    manifest: &ServiceManifest,
158) -> anyhow::Result<()> {
159    write_json(&paths.service_manifest_path(), manifest)
160}
161
162pub fn read_service_manifest(paths: &RuntimePaths) -> anyhow::Result<Option<ServiceManifest>> {
163    read_json(&paths.service_manifest_path())
164}
165
166pub fn remove_service_manifest(paths: &RuntimePaths) -> anyhow::Result<()> {
167    let path = paths.service_manifest_path();
168    if path.exists() {
169        std::fs::remove_file(path)?;
170    }
171    Ok(())
172}
173
174pub fn write_stop_request(paths: &RuntimePaths, request: &StopRequest) -> anyhow::Result<()> {
175    write_json(&paths.stop_request_path(), request)
176}
177
178pub fn read_stop_request(paths: &RuntimePaths) -> anyhow::Result<Option<StopRequest>> {
179    read_json(&paths.stop_request_path())
180}
181
182pub fn clear_stop_request(paths: &RuntimePaths) -> anyhow::Result<()> {
183    let path = paths.stop_request_path();
184    if path.exists() {
185        std::fs::remove_file(path)?;
186    }
187    Ok(())
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn logs_dir_uses_bundle_logs() {
196        let paths = RuntimePaths::new("/tmp/bundle/state", "demo", "default");
197        assert_eq!(
198            paths.logs_dir(),
199            PathBuf::from("/tmp/bundle/logs").join("demo.default")
200        );
201    }
202
203    #[test]
204    fn stop_request_roundtrip() {
205        let root = tempfile::tempdir().unwrap();
206        let paths = RuntimePaths::new(root.path().join("state"), "demo", "default");
207        let req = StopRequest {
208            requested_by: "admin-api".into(),
209            reason: Some("remote stop".into()),
210        };
211
212        write_stop_request(&paths, &req).unwrap();
213        let loaded = read_stop_request(&paths).unwrap().unwrap();
214        assert_eq!(loaded.requested_by, "admin-api");
215        assert_eq!(loaded.reason.as_deref(), Some("remote stop"));
216
217        clear_stop_request(&paths).unwrap();
218        assert!(read_stop_request(&paths).unwrap().is_none());
219    }
220}