greentic_start/
runtime_state.rs1#![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}