decdn_common/config/errors.rs
1//! Structured config-resolution diagnostics: fatal problems and non-fatal
2//! notices.
3//!
4//! [`ConfigDiagnostics`] is threaded through every section resolver during a
5//! `resolve_config` pass — and through every reloadable section during a
6//! SIGHUP reload (`runtime::reload`) — so every problem an operator made
7//! surfaces in one bulleted `anyhow::Error`, not one per re-run.
8//!
9//! Each problem is recorded under a dotted field label (`blockchain.rpc_url`,
10//! `cache.origins[2]`, ...) with the resolver's message text. Single-line
11//! messages are reproduced verbatim; multi-line messages have their
12//! continuation lines indented to stay under their bullet (see
13//! [`ConfigDiagnostics::into_result`]).
14//!
15//! [`ConfigNotice`] carries the other half: a value that resolves fine but
16//! that the operator should know about (a disabled cap, an unbounded map, a
17//! privileged port). A notice never fails resolution. It rides the same bag
18//! because the bag already reaches every resolver on both the startup and the
19//! SIGHUP path, so the caller — which is the only layer that knows whether a
20//! `tracing` subscriber exists yet — decides how to render it.
21
22/// Field labels that participate in a `has_field` cascade-suppression guard.
23///
24/// These are the only labels whose exact string is load-bearing for
25/// *correctness*: a producing-site `push`/`try_with` and a guarding-site
26/// `has_field` (e.g. `resolve_identity_into` and
27/// `ensure_region_when_publishing_global_into`) must agree byte-for-byte, or
28/// the cascade-suppression guard silently degrades and a misleading second
29/// error bubbles up. Naming them as constants makes that coupling
30/// refactor-safe.
31///
32/// Add a new constant here only when introducing a new `has_field` guard.
33/// The other ~60 non-guarded labels stay inline literals — they appear only
34/// in operator output, never in a guard, so promoting them all would defeat
35/// the point of singling these two out.
36pub(crate) const IDENTITY_REGION: &str = "identity.region";
37pub(crate) const IDENTITY_DATA_DIR: &str = "identity.data_dir";
38
39/// How loud a [`ConfigNotice`] is.
40///
41/// The split is about what a consumer does with it, not about how the resolver
42/// feels: `Warn` is what an operator alerts on and what moves
43/// `decdn node doctor --strict` off zero, `Info` is visible but never changes
44/// an exit status. A consumer keys off this rather than parsing the message.
45///
46/// As a guide, `Warn` fits a value that weakens a safety property (an
47/// unbounded bookkeeping map, a collapsed failover list) or a setting that is
48/// likely a mistake (a privileged port). `Info` fits a
49/// deliberate opt-out working exactly as configured, where paging someone
50/// would only teach them to ignore the channel.
51///
52/// Variants are declared in ascending severity. Deliberately no `Ord`: a
53/// consumer matches exhaustively, so a future variant inserted by topic cannot
54/// silently flip a comparison.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ConfigNoticeLevel {
57 /// A deliberate, documented opt-out. Nothing is wrong.
58 Info,
59 /// The value warrants attention: it weakens a safety property, or it is
60 /// likely a mistake. Resolution still succeeds — the operator asked for
61 /// it.
62 Warn,
63}
64
65/// One non-fatal resolve-time notice: a severity, the label of whatever the
66/// operator wrote, and the operator-facing message text.
67///
68/// `field` is the dotted config label (`security.max_tracked_sources`),
69/// matching the problem-label convention so a notice and a problem about the
70/// same field read the same way.
71///
72/// `field` is kept out of the message text, so a consumer can
73/// render or filter on it independently of what it says.
74#[derive(Debug, Clone)]
75pub struct ConfigNotice {
76 /// How loud this notice is.
77 pub level: ConfigNoticeLevel,
78 /// What the operator wrote: a dotted config label.
79 pub field: String,
80 /// Operator-facing message text, without a severity prefix.
81 pub message: String,
82}
83
84/// One resolved-config problem: a dotted field label (`blockchain.rpc_url`)
85/// plus the resolver's message text.
86#[derive(Debug)]
87struct ConfigProblem {
88 field: String,
89 message: String,
90}
91
92/// Accumulates every problem and every non-fatal notice found during a single
93/// `resolve_config` pass.
94///
95/// Threaded by `&mut` through the `*_into` section workers. Callers record
96/// problems via `check` (the `anyhow::ensure!` replacement) and
97/// [`try_with`](Self::try_with) (the `?`/`.context()` replacement),
98/// substituting a placeholder for any value they could not resolve so
99/// later independent checks still run. They record notices via `warn` / `note`.
100///
101/// Insertion order is preserved end-to-end: [`into_result`](Self::into_result)
102/// renders bullets in the same order they were `push`ed, so
103/// resolver authors can rely on operator-facing problem order matching the
104/// order of validation logic, and tests that pin specific output order keep
105/// working through future refactors. [`take_notices`](Self::take_notices)
106/// preserves notice order for the same reason.
107///
108/// Notices and problems are independent: a bag carrying only notices still
109/// collapses to `Ok(())`, and a bag that fails resolution may still hold
110/// notices its caller chooses not to render.
111#[derive(Debug)]
112pub struct ConfigDiagnostics {
113 problems: Vec<ConfigProblem>,
114 notices: Vec<ConfigNotice>,
115}
116
117impl Default for ConfigDiagnostics {
118 fn default() -> Self {
119 Self::new()
120 }
121}
122
123impl ConfigDiagnostics {
124 /// An empty bag. Resolution fills it and reports every problem at once,
125 /// rather than failing on the first.
126 pub const fn new() -> Self {
127 Self {
128 problems: Vec::new(),
129 notices: Vec::new(),
130 }
131 }
132
133 /// Record a problem under `field` with `message`.
134 pub(crate) fn push(&mut self, field: impl Into<String>, message: impl Into<String>) {
135 self.problems.push(ConfigProblem {
136 field: field.into(),
137 message: message.into(),
138 });
139 }
140
141 /// Record a [`ConfigNoticeLevel::Warn`] notice under `field`.
142 ///
143 /// Non-fatal by construction — the bag still collapses to `Ok(())`. Use it
144 /// where the operator asked for something legal that weakens a safety
145 /// property, and refusing to boot over it would be the worse failure.
146 pub(crate) fn warn(&mut self, field: impl Into<String>, message: impl Into<String>) {
147 self.notices.push(ConfigNotice {
148 level: ConfigNoticeLevel::Warn,
149 field: field.into(),
150 message: message.into(),
151 });
152 }
153
154 /// Record a [`ConfigNoticeLevel::Info`] notice under `field`. The `Info`
155 /// twin of [`warn`](Self::warn), for a deliberate opt-out that is working
156 /// exactly as configured.
157 pub(crate) fn note(&mut self, field: impl Into<String>, message: impl Into<String>) {
158 self.notices.push(ConfigNotice {
159 level: ConfigNoticeLevel::Info,
160 field: field.into(),
161 message: message.into(),
162 });
163 }
164
165 /// Take every notice recorded so far, leaving the bag's notice list empty.
166 ///
167 /// Separate from [`into_result`](Self::into_result) because that method
168 /// consumes the bag: notices have to come out before the problems are
169 /// collapsed. Whether a caller then renders them is its own policy — both
170 /// callers today discard them on their failure path, since notices about a
171 /// config that was never applied describe nothing the node is running.
172 pub fn take_notices(&mut self) -> Vec<ConfigNotice> {
173 std::mem::take(&mut self.notices)
174 }
175
176 /// `anyhow::ensure!` replacement for a static message: record `message`
177 /// under `field` when `cond` is false. Returns `cond` so callers can
178 /// branch and skip a dependent check or substitute a placeholder.
179 pub(crate) fn check(
180 &mut self,
181 cond: bool,
182 field: impl Into<String>,
183 message: impl Into<String>,
184 ) -> bool {
185 if !cond {
186 self.push(field, message);
187 }
188 cond
189 }
190
191 /// Lazy variant of [`check`](Self::check): the message closure runs only
192 /// when `cond` is false, matching `anyhow::ensure!`'s format-on-failure
193 /// behaviour so the happy path allocates nothing for `format!(...)`
194 /// messages.
195 pub(crate) fn check_with(
196 &mut self,
197 cond: bool,
198 field: impl Into<String>,
199 message: impl FnOnce() -> String,
200 ) -> bool {
201 if !cond {
202 self.push(field, message());
203 }
204 cond
205 }
206
207 /// `?` / `.context()` replacement: on `Err`, record the full `{e:#}`
208 /// alternate-form context chain under `field` and return `None`; on
209 /// `Ok`, return `Some(value)`.
210 pub fn try_with<T>(
211 &mut self,
212 field: impl Into<String>,
213 result: anyhow::Result<T>,
214 ) -> Option<T> {
215 match result {
216 Ok(v) => Some(v),
217 Err(e) => {
218 self.push(field, format!("{e:#}"));
219 None
220 }
221 }
222 }
223
224 /// Whether any problem has already been recorded for `field`.
225 ///
226 /// Match is exact-string, not prefix: a problem recorded under
227 /// `cache.origins[0]` does *not* make `has_field("cache.origins")`
228 /// true. Cascade guards must use the same label the producing site
229 /// used (see [`IDENTITY_REGION`] / [`IDENTITY_DATA_DIR`] for the
230 /// labels currently participating in guards).
231 pub(crate) fn has_field(&self, field: &str) -> bool {
232 self.problems.iter().any(|p| p.field == field)
233 }
234
235 /// Number of problems recorded. Read before
236 /// [`into_result`](Self::into_result) when the count is needed
237 /// alongside the consumed error (e.g. as a structured
238 /// `problem_count` log field).
239 pub const fn problem_count(&self) -> usize {
240 self.problems.len()
241 }
242
243 /// Collapse the bag into a single `anyhow::Error` listing every problem
244 /// as a ` - <field>: <message>` bullet, or `Ok(())` when empty.
245 /// Single-line messages are reproduced verbatim; continuation lines of
246 /// a multi-line message are indented to stay under their bullet.
247 ///
248 /// Only `\n` is replaced — `\r\n` continuations have their `\n` re-indented
249 /// but the carriage return is left in place. An empty `message` renders as
250 /// ` - <field>: `, and a trailing `\n` becomes a dangling-indent blank
251 /// line; neither shape is produced by the current resolvers but a future
252 /// `{e:#}` source whose `Display` ends in a newline would surface it.
253 pub fn into_result(self) -> anyhow::Result<()> {
254 if self.problems.is_empty() {
255 return Ok(());
256 }
257 let n = self.problems.len();
258 let bullets = self
259 .problems
260 .iter()
261 .map(|p| {
262 // Indent any continuation lines so a multi-line message
263 // (e.g. a future `{e:#}` chain that wraps) still reads as
264 // one bullet rather than dedenting to the margin.
265 let message = p.message.replace('\n', "\n ");
266 format!(" - {}: {}", p.field, message)
267 })
268 .collect::<Vec<_>>()
269 .join("\n");
270 Err(anyhow::anyhow!(
271 "configuration has {n} problem(s):\n{bullets}"
272 ))
273 }
274}
275
276/// Wraps a section `*_into` worker so a single-section caller gets back an
277/// `anyhow::Result<T>`. Cross-section aggregation lives in `resolve_config`,
278/// which runs the workers against one shared bag instead.
279///
280/// **Notices recorded by the wrapped worker are discarded**, so do not route
281/// an operator-facing resolve through this. The two paths that render notices
282/// — `resolve_config` and `RuntimeReloadState::reload` — drive the workers
283/// against their own bag and drain it with
284/// [`ConfigDiagnostics::take_notices`]. Every wrapper built on this is
285/// therefore `#[cfg(test)]`, except `resolve_discovery`, whose worker records
286/// no notices; a worker that grows one needs its caller moved off this
287/// wrapper, not a second sink here.
288pub(crate) fn one_section<T>(f: impl FnOnce(&mut ConfigDiagnostics) -> T) -> anyhow::Result<T> {
289 let mut bag = ConfigDiagnostics::new();
290 let value = f(&mut bag);
291 bag.into_result()?;
292 Ok(value)
293}
294
295#[cfg(test)]
296#[allow(
297 clippy::unwrap_used,
298 clippy::expect_used,
299 clippy::panic,
300 clippy::indexing_slicing
301)]
302mod tests;