Skip to main content

isothrum_runner/
error.rs

1//! Why an operation failed: one enum for the crate, each variant with a stable [`ErrorCode`].
2//! Messages are plain and technical; the embedder maps codes to its own wording.
3
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8/// The stable code of a failure, the same in the Rust API and the event stream (`error.code`).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
10#[serde(rename_all = "snake_case")]
11#[non_exhaustive]
12pub enum ErrorCode {
13    /// The manifest isn't valid JSON or breaks the contract.
14    ManifestInvalid,
15    /// The manifest is another version of the contract.
16    ManifestVersion,
17    /// The manifest needs something this runner doesn't do yet (a driver, a delivery, a condition).
18    Unsupported,
19    /// A file or folder couldn't be read or written.
20    Io,
21    /// Another process runs an operation on this run.
22    Locked,
23    /// A tool a step runs isn't installed.
24    ToolMissing,
25    /// A step's tool ran and failed.
26    ToolFailed,
27    /// A tool answers at a version older than the lab needs.
28    ToolTooOld,
29    /// A step ran out of time and was stopped, with what it started.
30    Timeout,
31    /// The run is already up.
32    AlreadyRunning,
33    /// The run is recorded on another place (server or account) than the one asked for.
34    RunningElsewhere,
35    /// A host port the run publishes is taken.
36    PortInUse,
37    /// The manifest has no park steps for that mode.
38    NotParkable,
39    /// Nothing of the run is left to resume.
40    NothingToResume,
41    /// The run isn't up.
42    NotRunning,
43    /// A ready condition didn't hold.
44    NotReady,
45    /// A required secret or input has no value.
46    SecretMissing,
47    /// A param is unknown, missing or holds a value it doesn't allow.
48    ParamInvalid,
49    /// A run name that can't name a run.
50    RunName,
51    /// The run's Vagrant provider is missing, unknown, not on this host, refused by the lab, or
52    /// lacks its plugin.
53    ProviderRefused,
54    /// The elevation Hyper-V's commands need couldn't be had (the permission prompt declined).
55    ElevationDeclined,
56    /// A machine the run doesn't have.
57    MachineUnknown,
58    /// Every slot of the place is held by another run.
59    SlotsFull,
60    /// A deployment can't be taken over as a run.
61    AdoptRefused,
62    /// A machine a step goes to over SSH never answered.
63    Unreachable,
64    /// The run's seal failed: it is up, but not sealed, so not ready.
65    NotSealed,
66}
67
68impl ErrorCode {
69    /// The code as written in JSON (`port_in_use`).
70    #[must_use]
71    pub fn as_str(self) -> &'static str {
72        match self {
73            ErrorCode::ManifestInvalid => "manifest_invalid",
74            ErrorCode::ManifestVersion => "manifest_version",
75            ErrorCode::Unsupported => "unsupported",
76            ErrorCode::Io => "io",
77            ErrorCode::Locked => "locked",
78            ErrorCode::ToolMissing => "tool_missing",
79            ErrorCode::ToolFailed => "tool_failed",
80            ErrorCode::ToolTooOld => "tool_too_old",
81            ErrorCode::Timeout => "timeout",
82            ErrorCode::AlreadyRunning => "already_running",
83            ErrorCode::RunningElsewhere => "running_elsewhere",
84            ErrorCode::PortInUse => "port_in_use",
85            ErrorCode::NotParkable => "not_parkable",
86            ErrorCode::NothingToResume => "nothing_to_resume",
87            ErrorCode::NotRunning => "not_running",
88            ErrorCode::NotReady => "not_ready",
89            ErrorCode::SecretMissing => "secret_missing",
90            ErrorCode::ParamInvalid => "param_invalid",
91            ErrorCode::RunName => "run_name",
92            ErrorCode::ProviderRefused => "provider_refused",
93            ErrorCode::ElevationDeclined => "elevation_declined",
94            ErrorCode::MachineUnknown => "machine_unknown",
95            ErrorCode::SlotsFull => "slots_full",
96            ErrorCode::AdoptRefused => "adopt_refused",
97            ErrorCode::Unreachable => "unreachable",
98            ErrorCode::NotSealed => "not_sealed",
99        }
100    }
101}
102
103/// The runner's `Result`: its error is a [`Error`] unless said otherwise.
104pub type Result<T, E = Error> = std::result::Result<T, E>;
105
106/// Why an operation of the runner failed.
107#[derive(Debug, thiserror::Error)]
108#[non_exhaustive]
109pub enum Error {
110    /// The manifest was refused (its own error names the field and the fix).
111    #[error("{}: {source}", path.display())]
112    Manifest {
113        /// The manifest's file.
114        path: PathBuf,
115        /// Why, with Isoloom's stable code (`source.code()`).
116        source: isoloom_manifest::Error,
117    },
118    /// The manifest needs something this runner doesn't do yet.
119    #[error("{what} isn't supported by this runner yet")]
120    Unsupported {
121        /// What, as the manifest names it (`driver vagrant`, `delivery tf_var`).
122        what: String,
123    },
124    /// A file or folder couldn't be read or written.
125    #[error("{} couldn't be {action}: {source}", path.display())]
126    Io {
127        /// The file or folder.
128        path: PathBuf,
129        /// What was done to it (`read`, `written`, `created`, `locked`).
130        action: &'static str,
131        /// The OS's reason.
132        source: std::io::Error,
133    },
134    /// Another process holds the run's lock.
135    #[error("run `{run}` is busy: another process runs an operation on it")]
136    Locked {
137        /// The run.
138        run: String,
139    },
140    /// A step's tool isn't on PATH.
141    #[error("step `{step}`: `{tool}` isn't installed or isn't on PATH")]
142    ToolMissing {
143        /// The step.
144        step: String,
145        /// The tool.
146        tool: String,
147    },
148    /// A tool answers at a version older than the manifest's `min_version` for it.
149    #[error("`{tool}` is version {found}; this lab needs {required} or later")]
150    ToolTooOld {
151        /// The tool.
152        tool: String,
153        /// The version it answered with.
154        found: String,
155        /// The oldest version the lab takes.
156        required: String,
157    },
158    /// A step's tool ran and failed.
159    #[error("step `{step}` failed: {reason}")]
160    ToolFailed {
161        /// The step.
162        step: String,
163        /// Its last error lines, or its exit status (secrets masked).
164        reason: String,
165    },
166    /// A step ran out of time.
167    #[error("step `{step}` timed out after {after_s}s and was stopped")]
168    Timeout {
169        /// The step.
170        step: String,
171        /// Its limit, in seconds.
172        after_s: u64,
173    },
174    /// The run is already up.
175    #[error("run `{run}` is already running")]
176    AlreadyRunning {
177        /// The run.
178        run: String,
179    },
180    /// The run is recorded somewhere else.
181    #[error("run `{run}` is recorded on `{recorded}`, not `{asked}`")]
182    RunningElsewhere {
183        /// The run.
184        run: String,
185        /// Where its record says it is.
186        recorded: String,
187        /// Where this operation was asked to run.
188        asked: String,
189    },
190    /// A host port the run publishes is taken.
191    #[error("host port {port}/{protocol} is in use")]
192    PortInUse {
193        /// The port.
194        port: u16,
195        /// `tcp` or `udp`.
196        protocol: &'static str,
197    },
198    /// No park steps for that mode.
199    #[error("run `{run}` can't be parked ({mode}): its manifest has no steps for it")]
200    NotParkable {
201        /// The run.
202        run: String,
203        /// The mode asked for.
204        mode: &'static str,
205    },
206    /// Nothing left to resume.
207    #[error("run `{run}` has nothing left to resume: start it again")]
208    NothingToResume {
209        /// The run.
210        run: String,
211    },
212    /// The run isn't up.
213    #[error("run `{run}` isn't running")]
214    NotRunning {
215        /// The run.
216        run: String,
217    },
218    /// A ready condition didn't hold.
219    #[error("not ready: {condition}: {detail}")]
220    NotReady {
221        /// The condition (`step_ok up`, `tcp 127.0.0.1:8080`).
222        condition: String,
223        /// Why.
224        detail: String,
225    },
226    /// A required secret or input has no value.
227    #[error("secret `{name}` is required and has no value")]
228    SecretMissing {
229        /// The slot.
230        name: String,
231    },
232    /// A param that can't be used as given.
233    #[error("param `{name}`: {reason}")]
234    Param {
235        /// The param.
236        name: String,
237        /// Why.
238        reason: String,
239    },
240    /// A placeholder with no value at run time.
241    #[error("{at}: `${{{placeholder}}}` has no value in this operation")]
242    Unbound {
243        /// Where (`lifecycle.up.up`).
244        at: String,
245        /// The placeholder.
246        placeholder: String,
247    },
248    /// A machine the run doesn't have (to provision).
249    #[error("run `{run}` has no machine `{machine}`")]
250    MachineUnknown {
251        /// The run.
252        run: String,
253        /// The machine asked for.
254        machine: String,
255    },
256    /// The elevation the run's commands need couldn't be had.
257    #[error("elevation: {reason}")]
258    Elevation {
259        /// Why (the permission prompt declined, the elevated session ended).
260        reason: String,
261    },
262    /// The run's Vagrant provider can't run it.
263    #[error("provider `{provider}`: {reason}")]
264    Provider {
265        /// The provider asked for (empty when none was).
266        provider: String,
267        /// Why it can't run the lab, and what to do.
268        reason: String,
269    },
270    /// Every slot of the run's place is held by another run.
271    #[error("no slot is free on `{place}`: {held} runs hold one; stop one first")]
272    SlotsFull {
273        /// The place (the embedder's label for it, or `this host`).
274        place: String,
275        /// How many runs hold a slot there.
276        held: usize,
277    },
278    /// A machine a step goes to over SSH never answered within the step's `wait_s`.
279    #[error("step `{step}`: {login} didn't answer over SSH within {after_s}s: {reason}")]
280    Unreachable {
281        /// The step.
282        step: String,
283        /// The login it tried (`user@host`).
284        login: String,
285        /// How long it tried, in seconds.
286        after_s: u64,
287        /// ssh's last word.
288        reason: String,
289    },
290    /// The run's seal failed: it is up, unsealed, and never reported ready.
291    #[error("run `{run}` couldn't be sealed, so it isn't ready (stop it to remove it): {reason}")]
292    Unsealed {
293        /// The run.
294        run: String,
295        /// Why the seal failed.
296        reason: String,
297    },
298    /// A deployment that can't be taken over as a run.
299    #[error("run `{run}` can't take the deployment over: {reason}")]
300    Adopt {
301        /// The run it would have become.
302        run: String,
303        /// Why.
304        reason: String,
305    },
306    /// A run name that can't name a run.
307    #[error("`{value}` can't name a run: use 1 to 63 lowercase letters, digits, `-` or `_`, starting with a letter or digit")]
308    RunName {
309        /// The name as given.
310        value: String,
311    },
312}
313
314impl Error {
315    /// The failure's stable code.
316    #[must_use]
317    pub fn code(&self) -> ErrorCode {
318        match self {
319            Error::Manifest { source, .. } if source.code() == "version" => ErrorCode::ManifestVersion,
320            Error::Manifest { .. } => ErrorCode::ManifestInvalid,
321            Error::Unsupported { .. } | Error::Unbound { .. } => ErrorCode::Unsupported,
322            Error::Io { .. } => ErrorCode::Io,
323            Error::Locked { .. } => ErrorCode::Locked,
324            Error::ToolMissing { .. } => ErrorCode::ToolMissing,
325            Error::ToolTooOld { .. } => ErrorCode::ToolTooOld,
326            Error::ToolFailed { .. } => ErrorCode::ToolFailed,
327            Error::Timeout { .. } => ErrorCode::Timeout,
328            Error::AlreadyRunning { .. } => ErrorCode::AlreadyRunning,
329            Error::RunningElsewhere { .. } => ErrorCode::RunningElsewhere,
330            Error::PortInUse { .. } => ErrorCode::PortInUse,
331            Error::NotParkable { .. } => ErrorCode::NotParkable,
332            Error::NothingToResume { .. } => ErrorCode::NothingToResume,
333            Error::NotRunning { .. } => ErrorCode::NotRunning,
334            Error::NotReady { .. } => ErrorCode::NotReady,
335            Error::SecretMissing { .. } => ErrorCode::SecretMissing,
336            Error::Param { .. } => ErrorCode::ParamInvalid,
337            Error::RunName { .. } => ErrorCode::RunName,
338            Error::Provider { .. } => ErrorCode::ProviderRefused,
339            Error::Elevation { .. } => ErrorCode::ElevationDeclined,
340            Error::MachineUnknown { .. } => ErrorCode::MachineUnknown,
341            Error::SlotsFull { .. } => ErrorCode::SlotsFull,
342            Error::Adopt { .. } => ErrorCode::AdoptRefused,
343            Error::Unreachable { .. } => ErrorCode::Unreachable,
344            Error::Unsealed { .. } => ErrorCode::NotSealed,
345        }
346    }
347
348    /// An I/O failure on `path`.
349    pub(crate) fn io(path: impl Into<PathBuf>, action: &'static str) -> impl FnOnce(std::io::Error) -> Error {
350        let path = path.into();
351        move |source| Error::Io { path, action, source }
352    }
353}
354
355impl std::fmt::Display for ErrorCode {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        f.write_str(self.as_str())
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn every_code_reads_as_written_in_json() {
367        for code in [
368            ErrorCode::PortInUse,
369            ErrorCode::ManifestVersion,
370            ErrorCode::NothingToResume,
371            ErrorCode::RunName,
372            ErrorCode::ProviderRefused,
373        ] {
374            assert_eq!(serde_json::to_value(code).unwrap(), serde_json::json!(code.as_str()));
375        }
376    }
377
378    #[test]
379    fn another_manifest_version_has_its_own_code() {
380        let err = isoloom_manifest::parse(r#"{"manifest_version": 1}"#).unwrap_err();
381        let err = Error::Manifest {
382            path: "run.json".into(),
383            source: err,
384        };
385        assert_eq!(err.code(), ErrorCode::ManifestVersion);
386        assert!(err.to_string().starts_with("run.json: manifest_version 1"), "{err}");
387    }
388}