1use std::path::{Path, PathBuf};
2
3use rusqlite::Connection;
4
5use crate::errors::{Result, StateError};
6
7mod applies;
8mod backups;
9mod compliance;
10mod decisions;
11mod drift;
12mod journal;
13mod managed;
14mod modules;
15mod pending_config;
16mod sources;
17mod types;
18
19pub use pending_config::{
20 clear_pending_server_config, load_pending_server_config, save_pending_server_config,
21};
22pub use types::{
23 ApplyRecord, ApplyStatus, ComplianceHistoryRow, ConfigSourceRecord, DriftEvent,
24 FileBackupRecord, JournalEntry, ManagedResource, ModuleFileRecord, ModuleStateRecord,
25 PendingDecision, SourceConfigHash, SourceConflictRecord,
26};
27
28const MIGRATIONS: &[&str] = &[
29 "CREATE TABLE IF NOT EXISTS applies (
30 id INTEGER PRIMARY KEY AUTOINCREMENT,
31 timestamp TEXT NOT NULL,
32 profile TEXT NOT NULL,
33 plan_hash TEXT NOT NULL,
34 status TEXT NOT NULL,
35 summary TEXT
36 );
37
38 CREATE TABLE IF NOT EXISTS drift_events (
39 id INTEGER PRIMARY KEY AUTOINCREMENT,
40 timestamp TEXT NOT NULL,
41 resource_type TEXT NOT NULL,
42 resource_id TEXT NOT NULL,
43 expected TEXT,
44 actual TEXT,
45 source TEXT NOT NULL DEFAULT 'local',
46 resolved_by INTEGER,
47 FOREIGN KEY (resolved_by) REFERENCES applies(id)
48 );
49
50 CREATE TABLE IF NOT EXISTS managed_resources (
51 id INTEGER PRIMARY KEY AUTOINCREMENT,
52 resource_type TEXT NOT NULL,
53 resource_id TEXT NOT NULL,
54 source TEXT NOT NULL DEFAULT 'local',
55 last_hash TEXT,
56 last_applied INTEGER,
57 UNIQUE(resource_type, resource_id),
58 FOREIGN KEY (last_applied) REFERENCES applies(id)
59 );
60
61 CREATE TABLE IF NOT EXISTS config_sources (
62 id INTEGER PRIMARY KEY AUTOINCREMENT,
63 name TEXT NOT NULL UNIQUE,
64 origin_url TEXT NOT NULL,
65 origin_branch TEXT NOT NULL DEFAULT 'main',
66 last_fetched TEXT,
67 last_commit TEXT,
68 source_version TEXT,
69 pinned_version TEXT,
70 status TEXT NOT NULL DEFAULT 'active'
71 );
72
73 CREATE TABLE IF NOT EXISTS source_applies (
74 id INTEGER PRIMARY KEY AUTOINCREMENT,
75 source_id INTEGER NOT NULL,
76 apply_id INTEGER NOT NULL,
77 source_commit TEXT NOT NULL,
78 FOREIGN KEY (source_id) REFERENCES config_sources(id),
79 FOREIGN KEY (apply_id) REFERENCES applies(id)
80 );
81
82 CREATE TABLE IF NOT EXISTS source_conflicts (
83 id INTEGER PRIMARY KEY AUTOINCREMENT,
84 timestamp TEXT NOT NULL,
85 source_name TEXT NOT NULL,
86 resource_type TEXT NOT NULL,
87 resource_id TEXT NOT NULL,
88 resolution TEXT NOT NULL,
89 detail TEXT
90 );
91
92 CREATE TABLE IF NOT EXISTS pending_decisions (
93 id INTEGER PRIMARY KEY AUTOINCREMENT,
94 source TEXT NOT NULL,
95 resource TEXT NOT NULL,
96 tier TEXT NOT NULL,
97 action TEXT NOT NULL,
98 summary TEXT NOT NULL,
99 created_at TEXT NOT NULL,
100 resolved_at TEXT,
101 resolution TEXT
102 );
103
104 CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_decisions_source_resource
105 ON pending_decisions (source, resource)
106 WHERE resolved_at IS NULL;
107
108 CREATE TABLE IF NOT EXISTS source_config_hashes (
109 source TEXT PRIMARY KEY,
110 config_hash TEXT NOT NULL,
111 merged_at TEXT NOT NULL
112 );
113
114 CREATE TABLE IF NOT EXISTS module_state (
115 id INTEGER PRIMARY KEY AUTOINCREMENT,
116 module_name TEXT NOT NULL UNIQUE,
117 installed_at TEXT NOT NULL,
118 last_applied INTEGER,
119 packages_hash TEXT NOT NULL,
120 files_hash TEXT NOT NULL,
121 git_sources TEXT,
122 status TEXT NOT NULL DEFAULT 'installed',
123 FOREIGN KEY (last_applied) REFERENCES applies(id)
124 );
125
126 CREATE TABLE IF NOT EXISTS schema_version (
127 version INTEGER NOT NULL
128 );
129
130 INSERT INTO schema_version (version) VALUES (0);",
131 "CREATE TABLE IF NOT EXISTS file_backups (
133 id INTEGER PRIMARY KEY AUTOINCREMENT,
134 apply_id INTEGER NOT NULL,
135 file_path TEXT NOT NULL,
136 content_hash TEXT NOT NULL,
137 content BLOB NOT NULL,
138 permissions INTEGER,
139 was_symlink INTEGER NOT NULL DEFAULT 0,
140 symlink_target TEXT,
141 oversized INTEGER NOT NULL DEFAULT 0,
142 backed_up_at TEXT NOT NULL,
143 FOREIGN KEY (apply_id) REFERENCES applies(id)
144 );
145
146 CREATE INDEX IF NOT EXISTS idx_file_backups_apply ON file_backups (apply_id);
147 CREATE INDEX IF NOT EXISTS idx_file_backups_path ON file_backups (file_path);
148
149 CREATE TABLE IF NOT EXISTS apply_journal (
150 id INTEGER PRIMARY KEY AUTOINCREMENT,
151 apply_id INTEGER NOT NULL,
152 action_index INTEGER NOT NULL,
153 phase TEXT NOT NULL,
154 action_type TEXT NOT NULL,
155 resource_id TEXT NOT NULL,
156 pre_state TEXT,
157 post_state TEXT,
158 status TEXT NOT NULL DEFAULT 'pending',
159 error TEXT,
160 started_at TEXT NOT NULL,
161 completed_at TEXT,
162 FOREIGN KEY (apply_id) REFERENCES applies(id)
163 );
164
165 CREATE INDEX IF NOT EXISTS idx_apply_journal_apply ON apply_journal (apply_id);
166
167 CREATE TABLE IF NOT EXISTS module_file_manifest (
168 id INTEGER PRIMARY KEY AUTOINCREMENT,
169 module_name TEXT NOT NULL,
170 file_path TEXT NOT NULL,
171 content_hash TEXT NOT NULL,
172 strategy TEXT NOT NULL,
173 last_applied INTEGER,
174 UNIQUE(module_name, file_path),
175 FOREIGN KEY (last_applied) REFERENCES applies(id)
176 );
177
178 CREATE INDEX IF NOT EXISTS idx_module_file_manifest_module ON module_file_manifest (module_name);",
179 "ALTER TABLE apply_journal ADD COLUMN script_output TEXT;",
181 "CREATE TABLE IF NOT EXISTS compliance_snapshots (
183 id INTEGER PRIMARY KEY AUTOINCREMENT,
184 timestamp TEXT NOT NULL,
185 content_hash TEXT NOT NULL,
186 snapshot_json TEXT NOT NULL,
187 summary_compliant INTEGER NOT NULL,
188 summary_warning INTEGER NOT NULL,
189 summary_violation INTEGER NOT NULL
190 );",
191];
192
193pub struct StateStore {
195 pub(in crate::state) conn: Connection,
196}
197
198impl StateStore {
199 pub fn open_default() -> Result<Self> {
202 let data_dir = default_state_dir()?;
203 std::fs::create_dir_all(&data_dir).map_err(|_| StateError::DirectoryNotWritable {
204 path: data_dir.clone(),
205 })?;
206 let db_path = data_dir.join("state.db");
207 Self::open(&db_path)
208 }
209
210 pub fn open(path: &Path) -> Result<Self> {
212 let conn = Connection::open(path)?;
213 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
214 conn.busy_timeout(std::time::Duration::from_secs(5))?;
215
216 let mut store = Self { conn };
217 store.run_migrations()?;
218 Ok(store)
219 }
220
221 pub fn open_in_memory() -> Result<Self> {
223 let conn = Connection::open_in_memory()?;
224 conn.execute_batch("PRAGMA foreign_keys=ON;")?;
225
226 let mut store = Self { conn };
227 store.run_migrations()?;
228 Ok(store)
229 }
230
231 fn run_migrations(&mut self) -> Result<()> {
232 self.conn
235 .execute_batch("BEGIN EXCLUSIVE")
236 .map_err(|e| StateError::MigrationFailed {
237 message: format!("failed to acquire migration lock: {e}"),
238 })?;
239
240 let current_version = self.schema_version();
241
242 for (i, migration) in MIGRATIONS.iter().enumerate() {
243 if i >= current_version {
244 self.conn.execute_batch(migration).map_err(|e| {
245 if let Err(rb) = self.conn.execute_batch("ROLLBACK") {
246 tracing::error!("rollback after migration {i} failure also failed: {rb}");
247 }
248 StateError::MigrationFailed {
249 message: format!("migration {}: {}", i, e),
250 }
251 })?;
252 let new_version = (i + 1) as i64;
254 self.conn
255 .execute(
256 "UPDATE schema_version SET version = ?1",
257 rusqlite::params![new_version],
258 )
259 .map_err(|e| {
260 if let Err(rb) = self.conn.execute_batch("ROLLBACK") {
261 tracing::error!(
262 "rollback after schema_version update failure also failed: {rb}"
263 );
264 }
265 StateError::MigrationFailed {
266 message: format!("migration {}: failed to update version: {}", i, e),
267 }
268 })?;
269 }
270 }
271
272 self.conn
273 .execute_batch("COMMIT")
274 .map_err(|e| StateError::MigrationFailed {
275 message: format!("failed to commit migrations: {e}"),
276 })?;
277
278 Ok(())
279 }
280
281 fn schema_version(&self) -> usize {
282 self.conn
283 .query_row("SELECT version FROM schema_version", [], |row| {
284 row.get::<_, i64>(0)
285 })
286 .map(|v| v as usize)
287 .unwrap_or(0)
288 }
289}
290
291pub fn plan_hash(data: &str) -> String {
293 crate::sha256_hex(data.as_bytes())
294}
295
296pub fn default_state_dir() -> Result<PathBuf> {
297 if let Ok(dir) = std::env::var("CFGD_STATE_DIR") {
298 return Ok(PathBuf::from(dir));
299 }
300 let base = directories::BaseDirs::new().ok_or_else(|| StateError::DirectoryNotWritable {
301 path: PathBuf::from("~/.local/share/cfgd"),
302 })?;
303 Ok(base.data_local_dir().join("cfgd"))
304}
305
306#[cfg(test)]
307mod tests;