Skip to main content

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}