Skip to main content

maincopy_server/
error.rs

1use std::{fmt, process::Termination};
2
3use markdown_compiler::ContentValidationErrors;
4use thiserror::Error;
5
6use crate::config::ConfigurationErrors;
7
8macro_rules! impl_display {
9    ($name:ty { $($pattern:pat => $value:literal),+ $(,)? }) => {
10        impl fmt::Display for $name {
11            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
12                formatter.write_str(match self {
13                    $($pattern => $value),+
14                })
15            }
16        }
17    };
18}
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21#[repr(u8)]
22pub enum ProcessExit {
23    Success = 0,
24    Usage = 2,
25    Validation = 65,
26    Internal = 70,
27    Conflict = 75,
28    Configuration = 78,
29}
30
31impl ProcessExit {
32    pub const fn code(self) -> u8 {
33        self as u8
34    }
35}
36
37impl Termination for ProcessExit {
38    fn report(self) -> std::process::ExitCode {
39        std::process::ExitCode::from(self.code())
40    }
41}
42
43#[derive(Debug, Error)]
44pub enum ProcessError {
45    #[error(transparent)]
46    Configuration(#[from] ConfigurationErrors),
47
48    #[error(transparent)]
49    Validation(#[from] ContentValidationErrors),
50
51    #[error("another process already owns a required Maincopy resource")]
52    AlreadyRunning,
53
54    #[error("identity bootstrap is already complete")]
55    IdentityAlreadyBootstrapped,
56
57    #[error("owner credential input is invalid")]
58    IdentityCredentialInvalid,
59
60    #[error("managed Git source mode requires durable source settings")]
61    SourceConfigurationRequired,
62
63    #[error("this source operation requires source.mode = \"managed_git\"")]
64    ManagedSourceDisabled,
65
66    #[error("the selected source credential is not registered by host configuration")]
67    SourceCredentialUnknown,
68
69    #[error("source configuration conflicts with durable state")]
70    SourceConfigurationConflict,
71
72    #[error("source setup requires an existing enabled owner")]
73    SourceOwnerRequired,
74
75    #[error(transparent)]
76    Application(#[from] ApplicationError),
77}
78
79impl ProcessError {
80    pub const fn exit(&self) -> ProcessExit {
81        match self {
82            Self::Configuration(_)
83            | Self::SourceConfigurationRequired
84            | Self::ManagedSourceDisabled => ProcessExit::Configuration,
85            Self::Validation(_)
86            | Self::IdentityCredentialInvalid
87            | Self::SourceCredentialUnknown => ProcessExit::Validation,
88            Self::AlreadyRunning
89            | Self::IdentityAlreadyBootstrapped
90            | Self::SourceConfigurationConflict
91            | Self::SourceOwnerRequired => ProcessExit::Conflict,
92            Self::Application(_) => ProcessExit::Internal,
93        }
94    }
95
96    pub const fn category(&self) -> &'static str {
97        match self {
98            Self::Configuration(_)
99            | Self::SourceConfigurationRequired
100            | Self::ManagedSourceDisabled => "configuration",
101            Self::Validation(_)
102            | Self::IdentityCredentialInvalid
103            | Self::SourceCredentialUnknown => "validation",
104            Self::AlreadyRunning
105            | Self::IdentityAlreadyBootstrapped
106            | Self::SourceConfigurationConflict
107            | Self::SourceOwnerRequired => "conflict",
108            Self::Application(_) => "internal",
109        }
110    }
111}
112
113#[derive(Debug, Error)]
114pub enum ApplicationError {
115    #[error("failed to register the {signal} shutdown signal: {source}")]
116    SignalRegistration {
117        signal: ShutdownSignal,
118        #[source]
119        source: std::io::Error,
120    },
121
122    #[error("the {signal} shutdown signal stream closed")]
123    SignalStreamClosed { signal: ShutdownSignal },
124
125    #[error("this platform does not provide a supported shutdown signal stream")]
126    SignalPlatformUnsupported,
127
128    #[error("critical task {task} exited unexpectedly")]
129    CriticalTaskExited { task: CriticalTaskName },
130
131    #[error("critical task {task} failed: {source}")]
132    CriticalTaskFailed {
133        task: CriticalTaskName,
134        #[source]
135        source: Box<dyn std::error::Error + Send + Sync>,
136    },
137
138    #[error("critical task {task} panicked: {message}")]
139    CriticalTaskPanicked {
140        task: CriticalTaskName,
141        message: Box<str>,
142    },
143
144    #[error("the critical task supervisor failed")]
145    TaskSupervisor {
146        #[source]
147        source: tokio::task::JoinError,
148    },
149
150    #[error("the critical task supervisor became empty unexpectedly")]
151    TaskSupervisorEmpty,
152
153    #[error(
154        "application construction failed during {stage} while attempting to {operation}: {source}"
155    )]
156    Startup {
157        stage: StartupStage,
158        operation: &'static str,
159        #[source]
160        source: Box<dyn std::error::Error + Send + Sync>,
161    },
162}
163
164#[derive(Clone, Copy, Debug, Eq, PartialEq)]
165pub enum ShutdownSignal {
166    Interrupt,
167    Terminate,
168}
169
170impl_display!(ShutdownSignal {
171    Self::Interrupt => "interrupt",
172    Self::Terminate => "terminate",
173});
174
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176pub enum CriticalTaskName {
177    PublicServer,
178    AdminServer,
179    MetricsServer,
180    MetricsCollector,
181    ContentSync,
182    PublicationCoordinator,
183    DatabaseWriter,
184    Scheduler,
185    SourceSync,
186    MailDispatch,
187    MailFeedback,
188    MailRetention,
189    Worker,
190}
191
192impl_display!(CriticalTaskName {
193    Self::PublicServer => "public server",
194    Self::AdminServer => "admin server",
195    Self::MetricsServer => "metrics server",
196    Self::MetricsCollector => "metrics collector",
197    Self::ContentSync => "content sync",
198    Self::PublicationCoordinator => "publication coordinator",
199    Self::DatabaseWriter => "database writer",
200    Self::Scheduler => "scheduler",
201    Self::SourceSync => "source sync",
202    Self::MailDispatch => "mail dispatch",
203    Self::MailFeedback => "mail feedback",
204    Self::MailRetention => "mail retention",
205    Self::Worker => "worker",
206});
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum StartupStage {
210    Configuration,
211    ProcessLock,
212    Database,
213    Identity,
214    Content,
215    Source,
216    FrontendAssets,
217    Listeners,
218}
219
220impl_display!(StartupStage {
221    Self::Configuration => "configuration",
222    Self::ProcessLock => "process lock",
223    Self::Database => "database startup",
224    Self::Identity => "identity bootstrap",
225    Self::Content => "content compilation",
226    Self::Source => "managed source synchronization",
227    Self::FrontendAssets => "frontend asset validation",
228    Self::Listeners => "listener binding",
229});
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::config::{ConfigurationDiagnostic, ConfigurationValidationCode};
235
236    fn configuration_errors() -> ConfigurationErrors {
237        ConfigurationErrors::from_diagnostics(vec![ConfigurationDiagnostic::new(
238            "$document",
239            ConfigurationValidationCode::HostTomlInvalid,
240            "host TOML does not match the schema",
241        )])
242    }
243
244    fn validation_errors() -> ContentValidationErrors {
245        let root = tempfile::tempdir().unwrap();
246        markdown_compiler::discover_content_tree(
247            &root.path().join("missing"),
248            markdown_compiler::ContentTreeLimits::default(),
249        )
250        .unwrap_err()
251    }
252
253    #[test]
254    fn server_errors_have_stable_exit_codes() {
255        let cases = [
256            (
257                ProcessError::Configuration(configuration_errors()),
258                ProcessExit::Configuration,
259            ),
260            (
261                ProcessError::Validation(validation_errors()),
262                ProcessExit::Validation,
263            ),
264            (ProcessError::AlreadyRunning, ProcessExit::Conflict),
265            (
266                ProcessError::IdentityAlreadyBootstrapped,
267                ProcessExit::Conflict,
268            ),
269            (
270                ProcessError::IdentityCredentialInvalid,
271                ProcessExit::Validation,
272            ),
273            (
274                ProcessError::SourceConfigurationRequired,
275                ProcessExit::Configuration,
276            ),
277            (
278                ProcessError::ManagedSourceDisabled,
279                ProcessExit::Configuration,
280            ),
281            (
282                ProcessError::SourceCredentialUnknown,
283                ProcessExit::Validation,
284            ),
285            (
286                ProcessError::SourceConfigurationConflict,
287                ProcessExit::Conflict,
288            ),
289            (ProcessError::SourceOwnerRequired, ProcessExit::Conflict),
290            (
291                ProcessError::Application(ApplicationError::Startup {
292                    stage: StartupStage::Configuration,
293                    operation: "load test configuration",
294                    source: Box::new(std::io::Error::other("test startup failure")),
295                }),
296                ProcessExit::Internal,
297            ),
298        ];
299
300        for (error, expected_exit) in cases {
301            assert_eq!(error.exit(), expected_exit);
302        }
303    }
304
305    #[test]
306    fn server_exit_values_follow_the_documented_contract() {
307        assert_eq!(ProcessExit::Success.code(), 0);
308        assert_eq!(ProcessExit::Usage.code(), 2);
309        assert_eq!(ProcessExit::Validation.code(), 65);
310        assert_eq!(ProcessExit::Internal.code(), 70);
311        assert_eq!(ProcessExit::Conflict.code(), 75);
312        assert_eq!(ProcessExit::Configuration.code(), 78);
313    }
314
315    #[test]
316    fn server_errors_have_stable_log_categories() {
317        let cases = [
318            (
319                ProcessError::Configuration(configuration_errors()),
320                "configuration",
321            ),
322            (ProcessError::Validation(validation_errors()), "validation"),
323            (ProcessError::AlreadyRunning, "conflict"),
324            (ProcessError::IdentityAlreadyBootstrapped, "conflict"),
325            (ProcessError::IdentityCredentialInvalid, "validation"),
326            (ProcessError::SourceConfigurationRequired, "configuration"),
327            (ProcessError::ManagedSourceDisabled, "configuration"),
328            (ProcessError::SourceCredentialUnknown, "validation"),
329            (ProcessError::SourceConfigurationConflict, "conflict"),
330            (ProcessError::SourceOwnerRequired, "conflict"),
331            (
332                ProcessError::Application(ApplicationError::Startup {
333                    stage: StartupStage::Configuration,
334                    operation: "load test configuration",
335                    source: Box::new(std::io::Error::other("test startup failure")),
336                }),
337                "internal",
338            ),
339        ];
340
341        for (error, category) in cases {
342            assert_eq!(error.category(), category);
343        }
344    }
345
346    #[test]
347    fn error_component_names_have_stable_text() {
348        macro_rules! assert_display {
349            ($($value:expr => $expected:literal),+ $(,)?) => {
350                $(assert_eq!($value.to_string(), $expected);)+
351            };
352        }
353
354        assert_display! {
355            ProcessError::AlreadyRunning =>
356                "another process already owns a required Maincopy resource",
357            ProcessError::IdentityAlreadyBootstrapped =>
358                "identity bootstrap is already complete",
359            ProcessError::IdentityCredentialInvalid =>
360                "owner credential input is invalid",
361            ProcessError::SourceConfigurationRequired =>
362                "managed Git source mode requires durable source settings",
363            ProcessError::ManagedSourceDisabled =>
364                "this source operation requires source.mode = \"managed_git\"",
365            ProcessError::SourceCredentialUnknown =>
366                "the selected source credential is not registered by host configuration",
367            ProcessError::SourceConfigurationConflict =>
368                "source configuration conflicts with durable state",
369            ProcessError::SourceOwnerRequired =>
370                "source setup requires an existing enabled owner",
371            ShutdownSignal::Interrupt => "interrupt",
372            ShutdownSignal::Terminate => "terminate",
373            CriticalTaskName::PublicServer => "public server",
374            CriticalTaskName::AdminServer => "admin server",
375            CriticalTaskName::MetricsServer => "metrics server",
376            CriticalTaskName::MetricsCollector => "metrics collector",
377            CriticalTaskName::ContentSync => "content sync",
378            CriticalTaskName::PublicationCoordinator => "publication coordinator",
379            CriticalTaskName::DatabaseWriter => "database writer",
380            CriticalTaskName::Scheduler => "scheduler",
381            CriticalTaskName::SourceSync => "source sync",
382            CriticalTaskName::MailDispatch => "mail dispatch",
383            CriticalTaskName::MailFeedback => "mail feedback",
384            CriticalTaskName::MailRetention => "mail retention",
385            CriticalTaskName::Worker => "worker",
386            StartupStage::Configuration => "configuration",
387            StartupStage::ProcessLock => "process lock",
388            StartupStage::Database => "database startup",
389            StartupStage::Identity => "identity bootstrap",
390            StartupStage::Content => "content compilation",
391            StartupStage::Source => "managed source synchronization",
392            StartupStage::FrontendAssets => "frontend asset validation",
393            StartupStage::Listeners => "listener binding",
394        }
395    }
396}