Skip to main content

kopiur_kopia/
error.rs

1//! Error types for kopia subprocess invocation and JSON parsing.
2//!
3//! A terminal [`KopiaError`] must carry enough structured detail for the mover
4//! to build a `status.failure` block (ADR §4.10): exit code, the last lines of
5//! stderr, and a best-effort *error class* derived from kopia's stderr so the
6//! controller can decide whether a retry is worthwhile.
7
8use std::fmt;
9
10/// How many trailing lines of stderr we retain on a failed invocation. Kopia
11/// can print a lot of progress to stderr; the tail is where the actual error
12/// message lands.
13pub const STDERR_TAIL_LINES: usize = 20;
14
15/// A best-effort classification of a kopia failure, derived by inspecting the
16/// captured stderr. This is intentionally coarse — it exists to drive the
17/// "should we retry?" decision in the mover, not to be exhaustive. Unknown
18/// failures map to [`KopiaErrorClass::Unknown`] and are treated as
19/// non-retryable by default.
20///
21/// Classification reads kopia's stderr; the class then drives the retry hint and
22/// round-trips through its stable label:
23///
24/// ```
25/// use kopiur_kopia::KopiaErrorClass;
26///
27/// // A backend down / unreachable error is transient → worth a retry.
28/// let class = KopiaErrorClass::classify("ERROR error connecting to repository: dial tcp");
29/// assert_eq!(class, KopiaErrorClass::RepositoryUnavailable);
30/// assert!(class.is_retryable());
31///
32/// // A wrong repository password is not retryable without a config change.
33/// let auth = KopiaErrorClass::classify("invalid repository password");
34/// assert_eq!(auth, KopiaErrorClass::AuthFailure);
35/// assert!(!auth.is_retryable());
36///
37/// // The stable label round-trips through from_label/as_str.
38/// assert_eq!(class.as_str(), "RepositoryUnavailable");
39/// assert_eq!(KopiaErrorClass::from_label("RepositoryUnavailable"), class);
40/// // An unrecognized label degrades to Unknown.
41/// assert_eq!(KopiaErrorClass::from_label("bogus"), KopiaErrorClass::Unknown);
42/// ```
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum KopiaErrorClass {
45    /// Repository could not be reached / opened (network, backend down,
46    /// bad endpoint). Typically transient → retry.
47    RepositoryUnavailable,
48    /// Authentication / password / credential failure (wrong repository
49    /// password). Not retryable without a config change.
50    AuthFailure,
51    /// The storage backend **denied access** to the bucket/container/object
52    /// (e.g. S3/B2/GCS "Access Denied", HTTP 403). The credentials usually
53    /// authenticate fine but lack permission — or the bucket/path doesn't exist
54    /// and the backend masks that as access-denied (RustFS/S3 do this). Not
55    /// retryable without a credentials/permission/bucket fix.
56    AccessDenied,
57    /// The repository **path is not writable by this process** — e.g. a
58    /// filesystem repo whose PVC/NFS export is not writable by the operator's
59    /// UID ("permission denied" / EACCES when connecting or creating). Not
60    /// retryable without fixing ownership/mode.
61    PermissionDenied,
62    /// The requested source path / snapshot / target was not found.
63    NotFound,
64    /// A repository lock is held by another writer. Often transient → retry.
65    Locked,
66    /// Source filesystem error during upload (I/O, prepare failure).
67    SourceError,
68    /// Anything we could not classify.
69    Unknown,
70}
71
72impl KopiaErrorClass {
73    /// Stable string form for status fields / metrics labels.
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            KopiaErrorClass::RepositoryUnavailable => "RepositoryUnavailable",
77            KopiaErrorClass::AuthFailure => "AuthFailure",
78            KopiaErrorClass::AccessDenied => "AccessDenied",
79            KopiaErrorClass::PermissionDenied => "PermissionDenied",
80            KopiaErrorClass::NotFound => "NotFound",
81            KopiaErrorClass::Locked => "Locked",
82            KopiaErrorClass::SourceError => "SourceError",
83            KopiaErrorClass::Unknown => "Unknown",
84        }
85    }
86
87    /// Inverse of [`as_str`](Self::as_str): reconstruct the class from its stable
88    /// label. Used when only the persisted string is available (the controller
89    /// reads `result.failure.kopiaErrorClass` from a bootstrap Job's ConfigMap).
90    /// An unrecognized label maps to [`KopiaErrorClass::Unknown`].
91    pub fn from_label(s: &str) -> KopiaErrorClass {
92        match s {
93            "RepositoryUnavailable" => KopiaErrorClass::RepositoryUnavailable,
94            "AuthFailure" => KopiaErrorClass::AuthFailure,
95            "AccessDenied" => KopiaErrorClass::AccessDenied,
96            "PermissionDenied" => KopiaErrorClass::PermissionDenied,
97            "NotFound" => KopiaErrorClass::NotFound,
98            "Locked" => KopiaErrorClass::Locked,
99            "SourceError" => KopiaErrorClass::SourceError,
100            _ => KopiaErrorClass::Unknown,
101        }
102    }
103
104    /// A **stable**, volatile-free one-line summary of what this class means and
105    /// how to fix it, suitable for a status *condition message*.
106    ///
107    /// Unlike `KopiaError::to_string` (which embeds the kopia stderr tail — and
108    /// thus a per-attempt-random temp filename like `.shards.tmp.<hex>`), this is
109    /// byte-identical across repeated failures of the same class. The controller
110    /// uses it for the persisted condition so that re-writing an unchanged Failed
111    /// status is a true no-op (no resourceVersion bump → no self-triggered
112    /// reconcile). The full, volatile detail still goes to the Warning Event.
113    pub fn summary(&self) -> &'static str {
114        match self {
115            KopiaErrorClass::RepositoryUnavailable => {
116                "repository backend is unreachable; check the endpoint/network and retry"
117            }
118            KopiaErrorClass::AuthFailure => {
119                "repository password was rejected; check the encryption password Secret \
120                 (the KOPIA_PASSWORD key)"
121            }
122            KopiaErrorClass::AccessDenied => {
123                "the storage backend denied access; check the credentials Secret and that the \
124                 bucket/container/path exists and is reachable"
125            }
126            KopiaErrorClass::PermissionDenied => {
127                "repository path is not writable by the operator's UID; fix ownership/mode on the \
128                 backing PVC/NFS export"
129            }
130            KopiaErrorClass::NotFound => {
131                "the requested repository path, snapshot, or target was not found"
132            }
133            KopiaErrorClass::Locked => {
134                "a repository lock is held by another writer; it usually clears on retry"
135            }
136            KopiaErrorClass::SourceError => "a source filesystem error occurred during upload",
137            KopiaErrorClass::Unknown => "an unclassified repository backend error occurred",
138        }
139    }
140
141    /// Whether re-running the same operation later might succeed without any
142    /// configuration change. This is the operator's default retry hint; the
143    /// caller may override it with policy.
144    pub fn is_retryable(&self) -> bool {
145        matches!(
146            self,
147            KopiaErrorClass::RepositoryUnavailable
148                | KopiaErrorClass::Locked
149                | KopiaErrorClass::SourceError
150        )
151    }
152
153    /// Best-effort classification from captured stderr text. Matches against
154    /// substrings kopia is observed to emit (kopia 0.23). Order matters: more
155    /// specific checks come first.
156    pub fn classify(stderr: &str) -> KopiaErrorClass {
157        let s = stderr.to_ascii_lowercase();
158        if s.contains("invalid repository password")
159            || s.contains("incorrect password")
160            || s.contains("unable to derive")
161        {
162            KopiaErrorClass::AuthFailure
163        } else if s.contains("access denied")
164            || s.contains("accessdenied")
165            || s.contains("forbidden")
166            || s.contains("not authorized")
167        {
168            // Backend authorization (e.g. S3 "Access Denied"). Checked before the
169            // generic permission/not-found arms because the backend phrasing is
170            // specific and the fix (creds/bucket) is distinct.
171            KopiaErrorClass::AccessDenied
172        } else if s.contains("permission denied")
173            || s.contains("operation not permitted")
174            || s.contains("eacces")
175        {
176            // Local repo path not writable by our UID. Checked before SourceError
177            // (which used to absorb "permission denied" and wrongly mark it
178            // retryable) and before NotFound.
179            KopiaErrorClass::PermissionDenied
180        } else if s.contains("repository is locked")
181            || s.contains("another process")
182            || s.contains("lock")
183        {
184            KopiaErrorClass::Locked
185        } else if s.contains("no such file or directory")
186            || s.contains("not found")
187            || s.contains("does not exist")
188            || s.contains("unable to find snapshot")
189            // kopia's `repo.ErrRepositoryNotInitialized` ("repository not initialized
190            // in the provided storage") — an *empty* backend with no kopia repo at the
191            // prefix. The CLI wraps it as `error connecting to repository: repository
192            // not initialized ...`, so it MUST be matched here, ahead of the
193            // RepositoryUnavailable arm, or an uninitialized repo is misread as an
194            // unreachable backend. Classifying it `NotFound` is what lets the mover
195            // surface the actionable `RepositoryNotInitialized` outcome (set
196            // `spec.create.enabled: true`) instead of a misleading "backend
197            // unreachable, retry".
198            || s.contains("not initialized")
199        {
200            KopiaErrorClass::NotFound
201        } else if s.contains("error connecting to repository")
202            || s.contains("unable to open repository")
203            || s.contains("connection refused")
204            || s.contains("dial tcp")
205            || s.contains("no route to host")
206            || s.contains("timeout")
207        {
208            KopiaErrorClass::RepositoryUnavailable
209        } else if s.contains("upload error") || s.contains("failed to prepare source") {
210            KopiaErrorClass::SourceError
211        } else {
212            KopiaErrorClass::Unknown
213        }
214    }
215}
216
217/// Whether a [`KopiaErrorClass::NotFound`] connect failure is the backend
218/// reporting a *genuinely uninitialized* repository (kopia's
219/// `ErrRepositoryNotInitialized`: "repository not initialized in the provided
220/// storage") rather than a *missing path / mount* ("no such file or directory",
221/// "does not exist").
222///
223/// Both phrasings classify as [`KopiaErrorClass::NotFound`] (so first-bootstrap
224/// `create` still fires for either), but they mean very different things for an
225/// already-`Ready` repository under the health probe:
226///
227/// * genuine "not initialized" ⇒ the backend answered and the kopia format blob
228///   is gone — a candidate *vanished repository* (`RepositoryVanished`).
229/// * a missing path / mount ⇒ the PVC isn't bound or the export moved — a
230///   *backend/mount fault* (`BackendReachable=False`), NOT a wipe. Recreating
231///   here would be catastrophic, so the two must never be conflated.
232///
233/// Returns `false` for any non-`NotFound` stderr; callers gate on the class first.
234///
235/// ```
236/// use kopiur_kopia::notfound_is_uninitialized;
237/// assert!(notfound_is_uninitialized("repository not initialized in the provided storage"));
238/// assert!(!notfound_is_uninitialized("open /repo/kopia.repository: no such file or directory"));
239/// ```
240pub fn notfound_is_uninitialized(stderr: &str) -> bool {
241    stderr.to_ascii_lowercase().contains("not initialized")
242}
243
244impl fmt::Display for KopiaErrorClass {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        f.write_str(self.as_str())
247    }
248}
249
250/// Errors produced while invoking kopia or parsing its `--json` output.
251///
252/// Each variant's `Display` is actionable — it names the operation, the failure,
253/// and (for non-zero exits) the error class plus the stderr tail — so it can be
254/// dropped straight into a `status.failure` block (ADR §4.10):
255///
256/// ```
257/// use kopiur_kopia::{KopiaError, KopiaErrorClass};
258///
259/// let err = KopiaError::NonZeroExit {
260///     args: "snapshot create".into(),
261///     code: Some(1),
262///     class: KopiaErrorClass::Locked,
263///     stderr_tail: "repository is locked by another process".into(),
264/// };
265/// assert_eq!(
266///     err.to_string(),
267///     "kopia `snapshot create` exited with code Some(1) (class Locked): \
268///      repository is locked by another process",
269/// );
270/// // The class drives the retry decision; the stderr tail is recoverable.
271/// assert_eq!(err.class(), KopiaErrorClass::Locked);
272/// assert!(err.class().is_retryable());
273/// assert_eq!(err.stderr_tail(), Some("repository is locked by another process"));
274///
275/// // A timeout names the args and elapsed seconds, and maps to a retryable class.
276/// let to = KopiaError::Timeout { args: "maintenance run --full".into(), seconds: 3600 };
277/// assert_eq!(to.to_string(), "kopia `maintenance run --full` timed out after 3600s");
278/// assert_eq!(to.class(), KopiaErrorClass::RepositoryUnavailable);
279/// ```
280#[derive(thiserror::Error, Debug)]
281pub enum KopiaError {
282    /// The kopia binary could not be spawned at all (missing binary, not
283    /// executable, fork failure). Carries the OS error.
284    #[error("failed to spawn kopia binary `{binary}`: {source}")]
285    Spawn {
286        /// Path we attempted to execute.
287        binary: String,
288        /// Underlying I/O error.
289        #[source]
290        source: std::io::Error,
291    },
292
293    /// kopia ran but exited with a non-zero status. Carries everything needed
294    /// to build a `status.failure` block.
295    #[error("kopia `{args}` exited with code {code:?} (class {class}): {stderr_tail}")]
296    NonZeroExit {
297        /// The subcommand + args that were run (for diagnostics; secrets are
298        /// passed via env, never argv).
299        args: String,
300        /// Process exit code, if one was reported (None if killed by signal).
301        code: Option<i32>,
302        /// Best-effort error classification from stderr.
303        class: KopiaErrorClass,
304        /// The last [`STDERR_TAIL_LINES`] lines of stderr, joined by newlines.
305        stderr_tail: String,
306    },
307
308    /// kopia exited 0 (or produced output) but the JSON could not be parsed
309    /// into the expected type — usually a kopia version skew.
310    #[error("failed to parse kopia JSON output for `{context}`: {source}")]
311    Json {
312        /// What we were trying to parse (e.g. "snapshot create result").
313        context: String,
314        /// The serde error.
315        #[source]
316        source: serde_json::Error,
317    },
318
319    /// We expected a JSON object/array on stdout but found none (kopia printed
320    /// only progress / nothing).
321    #[error("no JSON output found on stdout for `{context}`")]
322    EmptyOutput {
323        /// What we were trying to parse.
324        context: String,
325    },
326
327    /// The operation exceeded its configured timeout and was killed.
328    #[error("kopia `{args}` timed out after {seconds}s")]
329    Timeout {
330        /// The subcommand + args that were run.
331        args: String,
332        /// The timeout that elapsed, in seconds.
333        seconds: u64,
334    },
335}
336
337impl KopiaError {
338    /// The error class for this error, for retry decisions and metrics. Spawn,
339    /// JSON-parse, empty-output, and timeout errors map to a fixed class;
340    /// non-zero exits carry their own classification.
341    pub fn class(&self) -> KopiaErrorClass {
342        match self {
343            KopiaError::NonZeroExit { class, .. } => *class,
344            // A spawn failure is environmental (bad image / missing binary) —
345            // retrying the same pod won't help, treat as Unknown/non-retryable.
346            KopiaError::Spawn { .. } => KopiaErrorClass::Unknown,
347            KopiaError::Json { .. } | KopiaError::EmptyOutput { .. } => KopiaErrorClass::Unknown,
348            // Timeouts are usually a slow backend → worth a retry.
349            KopiaError::Timeout { .. } => KopiaErrorClass::RepositoryUnavailable,
350        }
351    }
352
353    /// The trailing stderr lines, if this error captured any.
354    pub fn stderr_tail(&self) -> Option<&str> {
355        match self {
356            KopiaError::NonZeroExit { stderr_tail, .. } => Some(stderr_tail.as_str()),
357            _ => None,
358        }
359    }
360}
361
362/// Keep only the last `STDERR_TAIL_LINES` non-empty-trimmed lines of a stderr
363/// blob, joined by newlines. Used when building a [`KopiaError::NonZeroExit`].
364pub(crate) fn tail_lines(stderr: &str) -> String {
365    let lines: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
366    let start = lines.len().saturating_sub(STDERR_TAIL_LINES);
367    lines[start..].join("\n")
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn classify_known_patterns() {
376        assert_eq!(
377            KopiaErrorClass::classify("ERROR error connecting to repository: dial tcp ..."),
378            KopiaErrorClass::RepositoryUnavailable
379        );
380        assert_eq!(
381            KopiaErrorClass::classify("invalid repository password"),
382            KopiaErrorClass::AuthFailure
383        );
384        assert_eq!(
385            KopiaErrorClass::classify("lstat /nope: no such file or directory"),
386            KopiaErrorClass::NotFound
387        );
388        assert_eq!(
389            KopiaErrorClass::classify("repository is locked by another process"),
390            KopiaErrorClass::Locked
391        );
392        assert_eq!(
393            KopiaErrorClass::classify("upload error: unsupported source"),
394            KopiaErrorClass::SourceError
395        );
396        assert_eq!(
397            KopiaErrorClass::classify("something totally unexpected"),
398            KopiaErrorClass::Unknown
399        );
400    }
401
402    #[test]
403    fn classify_uninitialized_repository_as_not_found() {
404        // Regression: connecting to an empty backend (no kopia repo at the prefix)
405        // makes kopia emit `repo.ErrRepositoryNotInitialized`, which the CLI wraps
406        // with its generic connect prefix. That prefix matches the
407        // RepositoryUnavailable arm, so without an explicit "not initialized" check
408        // the empty-bucket case was misclassified as a transient unreachable backend
409        // — and the mover's `not_initialized()` path (keyed on NotFound) never fired,
410        // so the operator saw "backend unreachable; retry" instead of the actionable
411        // "set spec.create.enabled: true". It must classify as NotFound.
412        assert_eq!(
413            KopiaErrorClass::classify(
414                "ERROR error connecting to repository: repository not initialized in the \
415                 provided storage"
416            ),
417            KopiaErrorClass::NotFound
418        );
419        // Bare form (no connect prefix) classifies the same way.
420        assert_eq!(
421            KopiaErrorClass::classify("repository not initialized in the provided storage"),
422            KopiaErrorClass::NotFound
423        );
424        // NotFound is non-retryable: the fix is a spec change, not a blind retry.
425        assert!(!KopiaErrorClass::NotFound.is_retryable());
426    }
427
428    #[test]
429    fn notfound_distinguishes_uninitialized_from_missing_path() {
430        // A genuinely empty backend (format blob absent) → uninitialized: the health
431        // probe may treat this as a candidate "vanished" repository.
432        assert!(notfound_is_uninitialized(
433            "ERROR error connecting to repository: repository not initialized in the \
434             provided storage"
435        ));
436        // A missing path / unbound mount also classifies NotFound, but is a backend/
437        // mount fault — NOT an empty repository. Must NOT read as uninitialized, so the
438        // probe never misreads a mis-mounted volume as a wipe (and never nudges a recreate).
439        assert!(!notfound_is_uninitialized(
440            "open /repo/kopia.repository: no such file or directory"
441        ));
442        assert!(!notfound_is_uninitialized("stat /mnt/nas: does not exist"));
443        // Both still classify as NotFound (so first-bootstrap `create` fires for either).
444        assert_eq!(
445            KopiaErrorClass::classify("open /repo/kopia.repository: no such file or directory"),
446            KopiaErrorClass::NotFound
447        );
448    }
449
450    #[test]
451    fn classify_access_denied_and_permission_denied() {
452        // The exact RustFS/S3 message we observed live (bucket missing, masked as
453        // Access Denied) must classify as AccessDenied, not Unknown.
454        assert_eq!(
455            KopiaErrorClass::classify(
456                "can't connect to storage: error retrieving storage config from bucket \
457                 \"kopiur\": Access Denied"
458            ),
459            KopiaErrorClass::AccessDenied
460        );
461        assert_eq!(
462            KopiaErrorClass::classify("403 Forbidden"),
463            KopiaErrorClass::AccessDenied
464        );
465        // Filesystem repo path not writable by our UID → PermissionDenied, NOT
466        // the old SourceError (which marked it retryable).
467        assert_eq!(
468            KopiaErrorClass::classify("unable to create directory /repo: permission denied"),
469            KopiaErrorClass::PermissionDenied
470        );
471        assert_eq!(
472            KopiaErrorClass::classify("open /repo/kopia.repository: operation not permitted"),
473            KopiaErrorClass::PermissionDenied
474        );
475    }
476
477    #[test]
478    fn from_label_roundtrips_every_variant() {
479        for c in [
480            KopiaErrorClass::RepositoryUnavailable,
481            KopiaErrorClass::AuthFailure,
482            KopiaErrorClass::AccessDenied,
483            KopiaErrorClass::PermissionDenied,
484            KopiaErrorClass::NotFound,
485            KopiaErrorClass::Locked,
486            KopiaErrorClass::SourceError,
487            KopiaErrorClass::Unknown,
488        ] {
489            assert_eq!(KopiaErrorClass::from_label(c.as_str()), c);
490        }
491        assert_eq!(
492            KopiaErrorClass::from_label("not-a-real-class"),
493            KopiaErrorClass::Unknown
494        );
495    }
496
497    #[test]
498    fn summary_is_stable_and_volatile_free() {
499        // Every class yields a non-empty, stable summary with no per-attempt
500        // volatile content (the temp-filename suffix kopia emits in stderr must
501        // never leak into the condition message — that volatility is what caused
502        // the reconcile hot-loop).
503        for c in [
504            KopiaErrorClass::RepositoryUnavailable,
505            KopiaErrorClass::AuthFailure,
506            KopiaErrorClass::AccessDenied,
507            KopiaErrorClass::PermissionDenied,
508            KopiaErrorClass::NotFound,
509            KopiaErrorClass::Locked,
510            KopiaErrorClass::SourceError,
511            KopiaErrorClass::Unknown,
512        ] {
513            let s = c.summary();
514            assert!(!s.is_empty());
515            assert!(
516                !s.contains(".shards"),
517                "summary leaks a volatile temp path: {s}"
518            );
519            assert!(
520                !s.contains(".tmp"),
521                "summary leaks a volatile temp path: {s}"
522            );
523            // Stable across calls (it returns a 'static str, but assert intent).
524            assert_eq!(s, c.summary());
525        }
526        // The PermissionDenied summary is the actionable one for the reported bug.
527        assert!(
528            KopiaErrorClass::PermissionDenied
529                .summary()
530                .contains("not writable")
531        );
532    }
533
534    #[test]
535    fn retryable_classification() {
536        assert!(KopiaErrorClass::RepositoryUnavailable.is_retryable());
537        assert!(KopiaErrorClass::Locked.is_retryable());
538        assert!(!KopiaErrorClass::AuthFailure.is_retryable());
539        assert!(!KopiaErrorClass::AccessDenied.is_retryable());
540        assert!(!KopiaErrorClass::PermissionDenied.is_retryable());
541        assert!(!KopiaErrorClass::NotFound.is_retryable());
542        assert!(!KopiaErrorClass::Unknown.is_retryable());
543    }
544
545    #[test]
546    fn tail_keeps_last_lines() {
547        let blob: String = (0..50)
548            .map(|i| format!("line {i}\n"))
549            .collect::<Vec<_>>()
550            .join("");
551        let tail = tail_lines(&blob);
552        let kept: Vec<&str> = tail.lines().collect();
553        assert_eq!(kept.len(), STDERR_TAIL_LINES);
554        assert_eq!(*kept.last().unwrap(), "line 49");
555    }
556
557    #[test]
558    fn error_class_propagation() {
559        let e = KopiaError::NonZeroExit {
560            args: "snapshot create".into(),
561            code: Some(1),
562            class: KopiaErrorClass::Locked,
563            stderr_tail: "repository is locked".into(),
564        };
565        assert_eq!(e.class(), KopiaErrorClass::Locked);
566        assert_eq!(e.stderr_tail(), Some("repository is locked"));
567    }
568}