sysknife_daemon/state.rs
1use crate::audit_forward::AuditForwarder;
2use crate::policy::PolicyTable;
3use crate::store::{AuditStore, SqliteStore};
4use crate::transactions::{TransactionStore, TransactionStoreError};
5use crate::transport::listen::{bind_unix_listener, ListenTarget, ListenTargetError};
6use std::os::unix::net::UnixListener;
7use std::path::PathBuf;
8use std::sync::Arc;
9use tokio::sync::Mutex;
10
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct DaemonConfig {
13 pub listen_target: ListenTarget,
14 pub database_path: PathBuf,
15}
16
17impl DaemonConfig {
18 pub fn new(listen_target: ListenTarget, database_path: impl Into<PathBuf>) -> Self {
19 Self {
20 listen_target,
21 database_path: database_path.into(),
22 }
23 }
24}
25
26#[derive(Clone, Debug)]
27pub struct DaemonState {
28 pub config: DaemonConfig,
29 /// Polymorphic audit store. Concrete impls: `SqliteStore` (default) and
30 /// `PostgresStore` (selected via `[storage] backend = "postgres"`).
31 /// Both implement [`AuditStore`] async trait.
32 pub audit: Arc<dyn AuditStore>,
33 pub policy: PolicyTable,
34 /// Strict host identity used to enforce the documented support matrix at
35 /// preview and execution boundaries.
36 pub host_distro: Option<sysknife_core::distro::DistroId>,
37 /// Optional external audit-log forwarder. `None` when no `[audit.forward]`
38 /// sink is configured; events recorded by the dispatcher are then only
39 /// written to the local hash-chained store.
40 pub forwarder: Option<AuditForwarder>,
41 /// Coarse concurrency guard for High-risk reboot-required actions (ME4).
42 ///
43 /// Holds the `request_hash` of any currently executing action whose
44 /// `ActionSpec` has `risk_level == High && reboot_required == true` (e.g.
45 /// `UbuntuReleaseUpgrade`, `AddLayeredPackage`, `RebaseSystem`). `None`
46 /// when no such action is in flight.
47 ///
48 /// The guard is one-directional, not mutual exclusion between all
49 /// mutating actions: every mutating action checks this slot before it is
50 /// allowed to start, but only a High-risk + reboot-required action ever
51 /// *claims* it (sets it to `Some`). So while a High-risk +
52 /// reboot-required action is in flight, every other mutating action —
53 /// including another High-risk + reboot-required one — is rejected with
54 /// a `ConflictResponse` rather than racing the in-flight upgrade and
55 /// causing dpkg/rpm-ostree lock contention. An ordinary mutating action
56 /// (e.g. `StartService`) never claims the slot itself, so it neither
57 /// blocks other ordinary mutating actions nor blocks a High-risk +
58 /// reboot-required action that starts while it is still running.
59 ///
60 /// The slot is `Arc<Mutex<…>>` so cloned `DaemonState` values — one per
61 /// IPC connection — all share the same underlying guard. `Mutex::lock`
62 /// is held for at most a few microseconds; this does not become a
63 /// hot-path bottleneck because read-only actions skip the check entirely.
64 ///
65 /// On daemon crash the in-memory guard is lost. That is correct: the
66 /// daemon's SQLite store will show the orphaned `Running` row; the
67 /// operator can inspect it via `ListJobHistory`.
68 pub running_high_risk_reboot: Arc<Mutex<Option<String>>>,
69}
70
71#[derive(Debug)]
72pub struct DaemonRuntime {
73 pub state: DaemonState,
74 pub listener: UnixListener,
75}
76
77#[derive(Debug, thiserror::Error)]
78pub enum DaemonStateError {
79 #[error(transparent)]
80 Transactions(#[from] TransactionStoreError),
81
82 #[error(transparent)]
83 Listen(#[from] ListenTargetError),
84}
85
86impl DaemonState {
87 /// Open the daemon state with no policy overrides and no forwarding.
88 /// Suitable for tests and dev runs.
89 pub fn open(config: DaemonConfig) -> Result<Self, DaemonStateError> {
90 Self::open_with_policy(config, PolicyTable::empty())
91 }
92
93 /// Open the daemon state with an explicit policy table and no forwarding.
94 pub fn open_with_policy(
95 config: DaemonConfig,
96 policy: PolicyTable,
97 ) -> Result<Self, DaemonStateError> {
98 Self::open_full(config, policy, None)
99 }
100
101 /// Open the daemon state with full configuration. Production callers
102 /// (`main.rs`) build the policy table from `[policy.risk_overrides]` and
103 /// the forwarder from `[audit.forward]`. The audit store defaults to
104 /// SQLite at `config.database_path`; pass an explicit `audit` to use
105 /// Postgres or any other [`AuditStore`] impl.
106 pub fn open_full(
107 config: DaemonConfig,
108 policy: PolicyTable,
109 forwarder: Option<AuditForwarder>,
110 ) -> Result<Self, DaemonStateError> {
111 let store = TransactionStore::open(&config.database_path)?;
112 let audit: Arc<dyn AuditStore> = Arc::new(SqliteStore::new(store));
113 Ok(Self {
114 config,
115 audit,
116 policy,
117 host_distro: sysknife_core::distro::detect().ok(),
118 forwarder,
119 running_high_risk_reboot: Arc::new(Mutex::new(None)),
120 })
121 }
122
123 /// Open the daemon state with an explicit audit store. Used by `main.rs`
124 /// when `[storage] backend = "postgres"` is configured: the Postgres
125 /// `AuditStore` is constructed via `PostgresStore::connect` and passed
126 /// here as the `audit` argument.
127 pub fn open_with_audit(
128 config: DaemonConfig,
129 policy: PolicyTable,
130 forwarder: Option<AuditForwarder>,
131 audit: Arc<dyn AuditStore>,
132 ) -> Self {
133 Self {
134 config,
135 audit,
136 policy,
137 host_distro: sysknife_core::distro::detect().ok(),
138 forwarder,
139 running_high_risk_reboot: Arc::new(Mutex::new(None)),
140 }
141 }
142
143 pub fn bootstrap(config: DaemonConfig) -> Result<DaemonRuntime, DaemonStateError> {
144 let state = Self::open(config)?;
145 let listener = bind_unix_listener(&state.config.listen_target)?;
146 Ok(DaemonRuntime { state, listener })
147 }
148}
149
150// ---------------------------------------------------------------------------
151// T18 — `state.rs` constructor and field-default tests
152//
153// Before this batch the only coverage of `DaemonState` was transitive
154// (every dispatcher integration test happened to construct one). Pin
155// the constructors directly so a regression that swaps `open` to skip
156// the policy default, or that flips the `forwarder: None` short-circuit
157// into a panic, is caught by a unit test rather than a full integration
158// suite that may still pass for unrelated reasons.
159// ---------------------------------------------------------------------------
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use tempfile::tempdir;
165
166 fn unix_target(dir: &tempfile::TempDir) -> ListenTarget {
167 ListenTarget::Unix(dir.path().join("daemon.sock"))
168 }
169
170 #[test]
171 fn open_creates_state_with_empty_policy_and_no_forwarder() {
172 let dir = tempdir().unwrap();
173 let cfg = DaemonConfig::new(unix_target(&dir), dir.path().join("audit.sqlite"));
174 let state = DaemonState::open(cfg.clone()).expect("open should succeed");
175
176 assert_eq!(state.config, cfg);
177 assert!(
178 state.forwarder.is_none(),
179 "open() must not configure a forwarder by default"
180 );
181 assert_eq!(
182 state.policy.override_count(),
183 0,
184 "open() must use an empty policy table — no overrides without explicit opt-in"
185 );
186 }
187
188 #[test]
189 fn open_full_threads_the_provided_forwarder_through() {
190 let dir = tempdir().unwrap();
191 let cfg = DaemonConfig::new(unix_target(&dir), dir.path().join("audit.sqlite"));
192 let state = DaemonState::open_full(cfg, PolicyTable::empty(), None)
193 .expect("open_full should succeed with no forwarder");
194 assert!(state.forwarder.is_none());
195
196 // Smoke-test that `open_full` accepts a non-None forwarder. We
197 // don't have a constructor that builds an AuditForwarder from a
198 // socket-less spec without spawning a tokio task, so we use the
199 // canonical UDP loopback constructor and immediately drop the
200 // returned state — the goal is to verify the field is wired.
201 let dir2 = tempdir().unwrap();
202 let cfg2 = DaemonConfig::new(unix_target(&dir2), dir2.path().join("audit.sqlite"));
203 let runtime = tokio::runtime::Builder::new_current_thread()
204 .enable_all()
205 .build()
206 .unwrap();
207 let forwarder = runtime.block_on(async {
208 crate::audit_forward::spawn(crate::audit_forward::AuditSinkSpec::SyslogUdp {
209 host: "127.0.0.1:65000".parse().unwrap(),
210 facility: 1,
211 })
212 });
213 let state = DaemonState::open_full(cfg2, PolicyTable::empty(), Some(forwarder))
214 .expect("open_full with forwarder should succeed");
215 assert!(state.forwarder.is_some());
216 }
217
218 #[test]
219 fn bootstrap_returns_a_runtime_with_a_bound_listener() {
220 let dir = tempdir().unwrap();
221 let cfg = DaemonConfig::new(unix_target(&dir), dir.path().join("audit.sqlite"));
222 let runtime = DaemonState::bootstrap(cfg.clone()).expect("bootstrap should succeed");
223
224 assert_eq!(runtime.state.config, cfg);
225 // The listener should be a real bound socket — calling local_addr
226 // proves the bind succeeded and the path is what we asked for.
227 let addr = runtime
228 .listener
229 .local_addr()
230 .expect("listener has a local addr");
231 let bound = addr.as_pathname().expect("Unix listener is path-bound");
232 assert_eq!(bound, dir.path().join("daemon.sock"));
233 }
234
235 #[test]
236 fn open_with_audit_uses_the_provided_audit_store_verbatim() {
237 // The contract: open_with_audit doesn't open a SQLite file — it
238 // accepts whatever AuditStore the caller supplies. We supply a
239 // SqliteStore pointing at a path that doesn't exist as a database
240 // and assert that open_with_audit returns Ok regardless (no I/O
241 // happens during construction; the caller is responsible).
242 let dir = tempdir().unwrap();
243 let cfg = DaemonConfig::new(unix_target(&dir), dir.path().join("audit.sqlite"));
244
245 // A real SqliteStore over a fresh temp file — proves the
246 // injected store is used as-is, not replaced by a re-opened one.
247 let store_dir = tempdir().unwrap();
248 let store_path = store_dir.path().join("custom-audit.sqlite");
249 let inner = TransactionStore::open(&store_path).unwrap();
250 let custom_audit: Arc<dyn AuditStore> = Arc::new(SqliteStore::new(inner));
251
252 let state =
253 DaemonState::open_with_audit(cfg.clone(), PolicyTable::empty(), None, custom_audit);
254
255 assert_eq!(state.config, cfg);
256 assert!(state.forwarder.is_none());
257 assert_eq!(state.policy.override_count(), 0);
258 // The SqliteStore is opaque from outside the module, but the
259 // fact that we constructed it pointing at `store_path` (different
260 // from `cfg.database_path`) and the test compiled and ran is the
261 // proof that `open_with_audit` doesn't open `cfg.database_path`
262 // a second time.
263 }
264}