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 run 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 run, 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 run 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 run 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 run, 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    /// Every [`ErrorCode`], in declaration order. The match in [`all_lists_every_code_once`] fails
366    /// to compile if a variant is added or removed without updating this list, so the exhaustive
367    /// tests below can't silently cover only some codes.
368    const ALL: &[ErrorCode] = &[
369        ErrorCode::ManifestInvalid,
370        ErrorCode::ManifestVersion,
371        ErrorCode::Unsupported,
372        ErrorCode::Io,
373        ErrorCode::Locked,
374        ErrorCode::ToolMissing,
375        ErrorCode::ToolFailed,
376        ErrorCode::ToolTooOld,
377        ErrorCode::Timeout,
378        ErrorCode::AlreadyRunning,
379        ErrorCode::RunningElsewhere,
380        ErrorCode::PortInUse,
381        ErrorCode::NotParkable,
382        ErrorCode::NothingToResume,
383        ErrorCode::NotRunning,
384        ErrorCode::NotReady,
385        ErrorCode::SecretMissing,
386        ErrorCode::ParamInvalid,
387        ErrorCode::RunName,
388        ErrorCode::ProviderRefused,
389        ErrorCode::ElevationDeclined,
390        ErrorCode::MachineUnknown,
391        ErrorCode::SlotsFull,
392        ErrorCode::AdoptRefused,
393        ErrorCode::Unreachable,
394        ErrorCode::NotSealed,
395    ];
396
397    #[test]
398    fn all_lists_every_code_once() {
399        // Exhaustiveness guard: this match fails to compile if a variant is added or removed, a
400        // reminder to update ALL above. It is a no-op at run time.
401        for &code in ALL {
402            match code {
403                ErrorCode::ManifestInvalid
404                | ErrorCode::ManifestVersion
405                | ErrorCode::Unsupported
406                | ErrorCode::Io
407                | ErrorCode::Locked
408                | ErrorCode::ToolMissing
409                | ErrorCode::ToolFailed
410                | ErrorCode::ToolTooOld
411                | ErrorCode::Timeout
412                | ErrorCode::AlreadyRunning
413                | ErrorCode::RunningElsewhere
414                | ErrorCode::PortInUse
415                | ErrorCode::NotParkable
416                | ErrorCode::NothingToResume
417                | ErrorCode::NotRunning
418                | ErrorCode::NotReady
419                | ErrorCode::SecretMissing
420                | ErrorCode::ParamInvalid
421                | ErrorCode::RunName
422                | ErrorCode::ProviderRefused
423                | ErrorCode::ElevationDeclined
424                | ErrorCode::MachineUnknown
425                | ErrorCode::SlotsFull
426                | ErrorCode::AdoptRefused
427                | ErrorCode::Unreachable
428                | ErrorCode::NotSealed => {}
429            }
430        }
431        let codes: Vec<&str> = ALL.iter().map(|c| c.as_str()).collect();
432        let mut unique = codes.clone();
433        unique.sort_unstable();
434        unique.dedup();
435        assert_eq!(unique.len(), codes.len(), "two variants share a code string: {codes:?}");
436    }
437
438    #[test]
439    fn every_code_round_trips_through_json_as_its_written_form() {
440        for &code in ALL {
441            let json = serde_json::to_value(code).unwrap();
442            assert_eq!(json, serde_json::json!(code.as_str()), "as_str() disagrees with serde for {code:?}");
443            let back: ErrorCode = serde_json::from_value(json).unwrap();
444            assert_eq!(back, code, "{code:?} didn't read back as itself");
445        }
446    }
447
448    #[test]
449    fn another_manifest_version_has_its_own_code() {
450        let err = isoloom_manifest::parse(r#"{"manifest_version": 1}"#).unwrap_err();
451        let err = Error::Manifest {
452            path: "run.json".into(),
453            source: err,
454        };
455        assert_eq!(err.code(), ErrorCode::ManifestVersion);
456        assert!(err.to_string().starts_with("run.json: manifest_version 1"), "{err}");
457    }
458}