Skip to main content

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;