tower/storage.rs
1//! Rule storage: per-machine `.yah/cloud/rules/*.yaml` with config-file watcher.
2//!
3//! `RuleStore` loads all rule files from the rules directory at tower boot.
4//! Schema versioning is enforced: future-version files are skipped with a warning;
5//! older files (lower minor than the current `SchemaVersion`) migrate lazily.
6//! Today that migration is a no-op since V1 is the only version.
7//!
8//! `RuleStore::watch` starts a `notify`-backed file watcher that emits
9//! `WatchEvent`s whenever a rule file is created, modified, or removed. The
10//! caller (the tower daemon) maps these events onto `Supervisor::update_rule`
11//! and `Supervisor::disable_rule` calls per the arch doc §Rule storage.
12//!
13//! Architecture: `.yah/docs/architecture/A052-yah-tower.md`
14
15use std::path::{Path, PathBuf};
16use std::sync::mpsc;
17
18use notify::{EventKind, RecursiveMode, Watcher};
19use tower_rules::{RuleId, SchemaVersion, TowerRule};
20
21use crate::rules::{
22 parse::{parse_rule_json, parse_rule_yaml, ParseError},
23 validate::{validate, RuleError, RuleWarning},
24};
25
26// ── Skip reason ───────────────────────────────────────────────────────────────
27
28/// Why a rule file was not loaded.
29#[derive(Debug, Clone, thiserror::Error)]
30pub enum SkipReason {
31 #[error("parse error: {0}")]
32 ParseError(String),
33
34 #[error("validation error: {0}")]
35 ValidationError(String),
36
37 /// The file declares a `schema_version` the running tower doesn't know about.
38 /// Skipped instead of failing so a mixed-version fleet can coexist.
39 #[error("future schema version '{found}' — upgrade tower to load this rule")]
40 FutureVersion { found: String },
41}
42
43/// A rule file that was skipped during `load_all`.
44#[derive(Debug)]
45pub struct SkippedFile {
46 pub path: PathBuf,
47 pub reason: SkipReason,
48}
49
50// ── Load result ───────────────────────────────────────────────────────────────
51
52/// Output of `RuleStore::load_all`.
53pub struct LoadResult {
54 /// Successfully parsed and validated rules.
55 pub loaded: Vec<TowerRule>,
56 /// Per-rule non-fatal warnings from semantic validation (empty IDs, etc.).
57 pub warnings: Vec<(RuleId, Vec<RuleWarning>)>,
58 /// Files that were present but skipped.
59 pub skipped: Vec<SkippedFile>,
60}
61
62// ── Watch event ───────────────────────────────────────────────────────────────
63
64/// Event emitted by the `RuleWatcher` background thread.
65///
66/// Tower maps these onto `Supervisor::update_rule` (Updated) and
67/// `Supervisor::disable_rule` (Removed). On `Error`, log the reason and
68/// keep the previously-loaded rule active.
69#[derive(Debug)]
70pub enum WatchEvent {
71 /// A rule file was created or modified; the new parsed rule is included.
72 Updated { path: PathBuf, rule: TowerRule },
73 /// A rule file was removed.
74 /// `rule_id` is derived from the file-stem (convention: filename == rule ID).
75 Removed { path: PathBuf, rule_id: RuleId },
76 /// A rule file changed but could not be reloaded.
77 Error { path: PathBuf, reason: SkipReason },
78}
79
80// ── RuleWatcher ───────────────────────────────────────────────────────────────
81
82/// Live config-file watcher for the rules directory.
83///
84/// Kept alive for as long as the caller holds this handle. Dropping it stops
85/// the watcher and the background translation thread exits when its channel closes.
86pub struct RuleWatcher {
87 // Held to keep the notify watcher alive. Dropped together with this struct.
88 _watcher: notify::RecommendedWatcher,
89}
90
91// ── RuleStore ─────────────────────────────────────────────────────────────────
92
93/// Loader and watcher for per-machine tower rule files.
94///
95/// Rules live in `<rules_dir>/*.yaml` (or `.yml` / `.json`). The store is
96/// stateless — call `load_all` at boot and `watch` to receive subsequent changes.
97#[derive(Clone)]
98pub struct RuleStore {
99 rules_dir: PathBuf,
100}
101
102impl RuleStore {
103 pub fn new(rules_dir: impl Into<PathBuf>) -> Self {
104 Self { rules_dir: rules_dir.into() }
105 }
106
107 pub fn rules_dir(&self) -> &Path {
108 &self.rules_dir
109 }
110
111 /// Load all rule files from the rules directory.
112 ///
113 /// Non-rule files (wrong extension) are silently ignored. Files that fail
114 /// parsing, validation, or version checks are collected in `skipped`.
115 /// I/O errors reading the directory are logged and an empty result returned.
116 pub fn load_all(&self) -> LoadResult {
117 let mut loaded = Vec::new();
118 let mut warnings = Vec::new();
119 let mut skipped = Vec::new();
120
121 let entries = match std::fs::read_dir(&self.rules_dir) {
122 Ok(it) => it,
123 Err(_) => return LoadResult { loaded, warnings, skipped },
124 };
125
126 for entry in entries.flatten() {
127 let path = entry.path();
128 if !is_rule_file(&path) {
129 continue;
130 }
131 match self.load_file(&path) {
132 Ok((rule, warns)) => {
133 if !warns.is_empty() {
134 warnings.push((rule.id.clone(), warns));
135 }
136 loaded.push(rule);
137 }
138 Err(reason) => skipped.push(SkippedFile { path, reason }),
139 }
140 }
141
142 LoadResult { loaded, warnings, skipped }
143 }
144
145 /// Parse and validate a single rule file.
146 ///
147 /// YAML and JSON are both accepted. The schema version is probed before
148 /// full deserialisation so that future-version files produce a
149 /// `SkipReason::FutureVersion` rather than a serde unknown-variant error.
150 pub fn load_file(&self, path: &Path) -> Result<(TowerRule, Vec<RuleWarning>), SkipReason> {
151 let src = std::fs::read_to_string(path)
152 .map_err(|e| SkipReason::ParseError(e.to_string()))?;
153
154 // Probe the schema version before full parse.
155 let version = probe_schema_version(path, &src)?;
156 let _ = version; // V1 is the only version; migration hook is a no-op.
157
158 let rule = parse_src(path, &src).map_err(|e| SkipReason::ParseError(e.to_string()))?;
159
160 let warns = validate(&rule).map_err(|e: RuleError| SkipReason::ValidationError(e.to_string()))?;
161
162 Ok((rule, warns))
163 }
164
165 /// Start a config-file watcher on the rules directory.
166 ///
167 /// Returns a `RuleWatcher` handle (keep alive for as long as watching is
168 /// desired) and a `mpsc::Receiver<WatchEvent>` for consuming events.
169 ///
170 /// The watcher translates notify events into `WatchEvent`s on a background
171 /// thread. The thread exits when the watcher handle is dropped (channel close).
172 pub fn watch(&self) -> Result<(RuleWatcher, mpsc::Receiver<WatchEvent>), notify::Error> {
173 let (watch_tx, watch_rx) = mpsc::channel::<WatchEvent>();
174 let (notify_tx, notify_rx) = mpsc::channel::<notify::Result<notify::Event>>();
175
176 let mut watcher = notify::recommended_watcher(notify_tx)?;
177 watcher.watch(&self.rules_dir, RecursiveMode::NonRecursive)?;
178
179 let store = self.clone();
180 // Explicit stack size (R330-S26): this thread previously relied on
181 // the platform pthread default — 8 MiB on glibc, but only ~128 KiB
182 // on musl. Pin it so a musl build doesn't silently shrink the stack
183 // this loop runs on.
184 std::thread::Builder::new()
185 .name("tower-rule-watcher".into())
186 .stack_size(8 * 1024 * 1024)
187 .spawn(move || {
188 for result in notify_rx {
189 let event = match result {
190 Ok(e) => e,
191 Err(e) => {
192 eprintln!("tower::storage: notify error: {e}");
193 continue;
194 }
195 };
196 for path in event.paths {
197 if !is_rule_file(&path) {
198 continue;
199 }
200 match event.kind {
201 EventKind::Create(_) | EventKind::Modify(_) => {
202 let ev = match store.load_file(&path) {
203 Ok((rule, _)) => WatchEvent::Updated { path, rule },
204 Err(reason) => WatchEvent::Error { path, reason },
205 };
206 let _ = watch_tx.send(ev);
207 }
208 EventKind::Remove(_) => {
209 let rule_id = rule_id_from_path(&path)
210 .unwrap_or_else(|| RuleId("unknown".into()));
211 let _ = watch_tx.send(WatchEvent::Removed { path, rule_id });
212 }
213 _ => {}
214 }
215 }
216 }
217 })
218 .expect("spawn tower-rule-watcher thread");
219
220 Ok((RuleWatcher { _watcher: watcher }, watch_rx))
221 }
222}
223
224// ── Helpers ───────────────────────────────────────────────────────────────────
225
226/// Returns `true` if the path looks like a rule file (yaml, yml, or json).
227fn is_rule_file(path: &Path) -> bool {
228 matches!(
229 path.extension().and_then(|e| e.to_str()),
230 Some("yaml") | Some("yml") | Some("json")
231 )
232}
233
234/// Derive a `RuleId` from the filename stem.
235///
236/// Convention: the stem is the rule ID (e.g. `yubaba-quorum-loss.yaml` → `yubaba-quorum-loss`).
237/// Used for `WatchEvent::Removed` where the file no longer exists.
238fn rule_id_from_path(path: &Path) -> Option<RuleId> {
239 path.file_stem().and_then(|s| s.to_str()).map(|s| RuleId(s.to_string()))
240}
241
242/// Probe the `schema_version` field from raw YAML/JSON without deserialising
243/// the full rule. Returns the known version on success, or a `SkipReason` if
244/// the version is unrecognised or the file cannot even be parsed as YAML.
245fn probe_schema_version(path: &Path, src: &str) -> Result<SchemaVersion, SkipReason> {
246 // Parse as a generic map to read just the schema_version string.
247 let raw: serde_yaml::Value = if is_json_path(path) {
248 serde_json::from_str::<serde_json::Value>(src)
249 .map_err(|e| SkipReason::ParseError(e.to_string()))
250 .map(|v| serde_yaml::to_value(v).unwrap_or(serde_yaml::Value::Null))?
251 } else {
252 serde_yaml::from_str::<serde_yaml::Value>(src)
253 .map_err(|e| SkipReason::ParseError(e.to_string()))?
254 };
255
256 let version_str = raw
257 .get("schema_version")
258 .and_then(|v| v.as_str())
259 .unwrap_or("V1"); // missing version → treat as V1 for backward compat
260
261 match version_str {
262 "V1" => Ok(SchemaVersion::V1),
263 other => Err(SkipReason::FutureVersion { found: other.to_string() }),
264 }
265}
266
267fn parse_src(path: &Path, src: &str) -> Result<TowerRule, ParseError> {
268 if is_json_path(path) { parse_rule_json(src) } else { parse_rule_yaml(src) }
269}
270
271fn is_json_path(path: &Path) -> bool {
272 matches!(path.extension().and_then(|e| e.to_str()), Some("json"))
273}