Skip to main content

xbp_deploy/
failure.rs

1//! Stable deploy failure classification for history + operator UX.
2//!
3//! Codes are stringly stable (`as_str`) so older CLIs can still read records.
4//! Classification is best-effort from free-text errors produced by adapters.
5
6use serde::{Deserialize, Serialize};
7
8/// Coarse phase of a deploy attempt (for history + metrics).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum DeployPhase {
12    Plan,
13    Preflight,
14    OciBuild,
15    OciPush,
16    K8sApply,
17    CfDoctor,
18    CfBuild,
19    CfDeploy,
20    OpenNext,
21    Verify,
22    Unknown,
23}
24
25impl DeployPhase {
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Self::Plan => "plan",
29            Self::Preflight => "preflight",
30            Self::OciBuild => "oci_build",
31            Self::OciPush => "oci_push",
32            Self::K8sApply => "k8s_apply",
33            Self::CfDoctor => "cf_doctor",
34            Self::CfBuild => "cf_build",
35            Self::CfDeploy => "cf_deploy",
36            Self::OpenNext => "opennext",
37            Self::Verify => "verify",
38            Self::Unknown => "unknown",
39        }
40    }
41
42    pub fn parse(s: &str) -> Self {
43        match s.trim().to_ascii_lowercase().as_str() {
44            "plan" => Self::Plan,
45            "preflight" => Self::Preflight,
46            "oci_build" | "build" => Self::OciBuild,
47            "oci_push" | "push" => Self::OciPush,
48            "k8s_apply" | "kubernetes" | "apply" => Self::K8sApply,
49            "cf_doctor" | "doctor" => Self::CfDoctor,
50            "cf_build" => Self::CfBuild,
51            "cf_deploy" | "cloudflare" => Self::CfDeploy,
52            "opennext" | "open_next" | "wsl" => Self::OpenNext,
53            "verify" | "health" => Self::Verify,
54            _ => Self::Unknown,
55        }
56    }
57}
58
59/// Stable failure class for deploy attempts.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum DeployFailureClass {
63    Success,
64    OciBuildBasePull,
65    OciMissingArtifact,
66    OciBuildFailed,
67    OciRegistryAuth,
68    OciPushDenied,
69    K8sApplyFailed,
70    CfWorkerNotFound,
71    CfContractIncomplete,
72    CfAuthToken,
73    CfUnchangedContainerImage,
74    CfMissingEntryPoint,
75    OpenNextWslFailed,
76    OpenNextBuildFailed,
77    ConfigParse,
78    PreflightFailed,
79    SparseFailure,
80    Unknown,
81}
82
83impl DeployFailureClass {
84    pub fn as_str(self) -> &'static str {
85        match self {
86            Self::Success => "success",
87            Self::OciBuildBasePull => "oci_build_base_pull",
88            Self::OciMissingArtifact => "oci_missing_artifact",
89            Self::OciBuildFailed => "oci_build_failed",
90            Self::OciRegistryAuth => "oci_registry_auth",
91            Self::OciPushDenied => "oci_push_denied",
92            Self::K8sApplyFailed => "k8s_apply_failed",
93            Self::CfWorkerNotFound => "cf_worker_not_found",
94            Self::CfContractIncomplete => "cf_contract_incomplete",
95            Self::CfAuthToken => "cf_auth_token",
96            Self::CfUnchangedContainerImage => "cf_unchanged_container_image",
97            Self::CfMissingEntryPoint => "cf_missing_entry_point",
98            Self::OpenNextWslFailed => "opennext_wsl_failed",
99            Self::OpenNextBuildFailed => "opennext_build_failed",
100            Self::ConfigParse => "config_parse",
101            Self::PreflightFailed => "preflight_failed",
102            Self::SparseFailure => "sparse_failure",
103            Self::Unknown => "unknown",
104        }
105    }
106
107    pub fn parse(s: &str) -> Self {
108        match s.trim().to_ascii_lowercase().as_str() {
109            "success" => Self::Success,
110            "oci_build_base_pull" => Self::OciBuildBasePull,
111            "oci_missing_artifact" => Self::OciMissingArtifact,
112            "oci_build_failed" => Self::OciBuildFailed,
113            "oci_registry_auth" => Self::OciRegistryAuth,
114            "oci_push_denied" => Self::OciPushDenied,
115            "k8s_apply_failed" => Self::K8sApplyFailed,
116            "cf_worker_not_found" => Self::CfWorkerNotFound,
117            "cf_contract_incomplete" => Self::CfContractIncomplete,
118            "cf_auth_token" => Self::CfAuthToken,
119            "cf_unchanged_container_image" => Self::CfUnchangedContainerImage,
120            "cf_missing_entry_point" => Self::CfMissingEntryPoint,
121            "opennext_wsl_failed" => Self::OpenNextWslFailed,
122            "opennext_build_failed" => Self::OpenNextBuildFailed,
123            "config_parse" => Self::ConfigParse,
124            "preflight_failed" => Self::PreflightFailed,
125            "sparse_failure" => Self::SparseFailure,
126            _ => Self::Unknown,
127        }
128    }
129
130    /// Short remediation hint for operators.
131    pub fn remediation(self) -> &'static str {
132        match self {
133            Self::Success => "none",
134            Self::OciBuildBasePull => {
135                "retry; `docker pull` base images; check Docker Hub/network access"
136            }
137            Self::OciMissingArtifact => {
138                "ensure multi-stage Dockerfile COPY targets exist in the builder stage"
139            }
140            Self::OciBuildFailed => "inspect docker buildx log; fix Dockerfile or context",
141            Self::OciRegistryAuth | Self::OciPushDenied => {
142                "run `docker login ghcr.io` (or target registry) with a token that can push"
143            }
144            Self::K8sApplyFailed => "check kube context, namespace, and `kubectl get events`",
145            Self::CfWorkerNotFound => {
146                "pass workers[].name / service deploy.worker; run `xbp cloudflare doctor --app <name>`"
147            }
148            Self::CfContractIncomplete => {
149                "run `xbp cloudflare doctor --app <name>` and fix listed contract issues"
150            }
151            Self::CfAuthToken => {
152                "set CLOUDFLARE_API_TOKEN or `xbp config cloudflare set-key`; `wrangler logout` if OAuth is stale"
153            }
154            Self::CfUnchangedContainerImage => {
155                "pass --allow-unchanged-container-image for config-only, or rebuild the container image"
156            }
157            Self::CfMissingEntryPoint => {
158                "run OpenNext build first; ensure .open-next/worker.js (or wrangler main) exists"
159            }
160            Self::OpenNextWslFailed => {
161                "inspect WSL OpenNext log tail; ensure WSL, Node/pnpm, and worker root are healthy"
162            }
163            Self::OpenNextBuildFailed => "fix OpenNext build errors; re-run with verbose stage logs",
164            Self::ConfigParse => "run `xbp config heal` or fix duplicate keys in .xbp/xbp.toml",
165            Self::PreflightFailed => "resolve preflight diagnostics before retrying deploy",
166            Self::SparseFailure => "re-run with a current xbp; failure detail was not recorded",
167            Self::Unknown => "see full error text and deploy history record",
168        }
169    }
170}
171
172/// Classified outcome used when writing history.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct ClassifiedDeployOutcome {
175    pub error_code: DeployFailureClass,
176    pub phase: DeployPhase,
177    pub diagnostics: Vec<String>,
178}
179
180impl ClassifiedDeployOutcome {
181    pub fn success() -> Self {
182        Self {
183            error_code: DeployFailureClass::Success,
184            phase: DeployPhase::Unknown,
185            diagnostics: Vec::new(),
186        }
187    }
188}
189
190/// Best-effort classification from free-text deploy error/summary lines.
191pub fn classify_deploy_error(error: Option<&str>, summary: &str, ok: bool) -> ClassifiedDeployOutcome {
192    if ok {
193        return ClassifiedDeployOutcome::success();
194    }
195
196    let text = error.unwrap_or(summary);
197    let lower = text.to_ascii_lowercase();
198    let mut diagnostics = Vec::new();
199
200    let (code, phase) = if lower.contains("container image did not change")
201        || lower.contains("allow-unchanged-container-image")
202    {
203        (
204            DeployFailureClass::CfUnchangedContainerImage,
205            DeployPhase::CfDeploy,
206        )
207    } else if lower.contains("worker contract is incomplete")
208        || lower.contains("cloudflare worker contract")
209    {
210        (
211            DeployFailureClass::CfContractIncomplete,
212            DeployPhase::CfDoctor,
213        )
214    } else if lower.contains("was not found")
215        && (lower.contains("worker app") || lower.contains("known workers"))
216    {
217        (DeployFailureClass::CfWorkerNotFound, DeployPhase::CfDoctor)
218    } else if lower.contains("entry-point")
219        || lower.contains("entry point")
220        || lower.contains(".open-next") && lower.contains("not found")
221        || lower.contains("worker.js") && lower.contains("not found")
222    {
223        (
224            DeployFailureClass::CfMissingEntryPoint,
225            DeployPhase::CfDeploy,
226        )
227    } else if lower.contains("cf_missing_entry_point")
228        || lower.contains("stage=entry_assert")
229        || (lower.contains("open-next")
230            && lower.contains("entry")
231            && (lower.contains("not found") || lower.contains("missing")))
232    {
233        (
234            DeployFailureClass::CfMissingEntryPoint,
235            DeployPhase::OpenNext,
236        )
237    } else if lower.contains("opennext_build_failed")
238        || lower.contains("stage=build")
239            && (lower.contains("opennext") || lower.contains("open next"))
240    {
241        (
242            DeployFailureClass::OpenNextBuildFailed,
243            DeployPhase::OpenNext,
244        )
245    } else if lower.contains("opennext wsl")
246        || lower.contains("opennext_wsl_failed")
247        || lower.contains("open next wsl")
248        || (lower.contains("opennext") && lower.contains("wsl"))
249    {
250        (
251            DeployFailureClass::OpenNextWslFailed,
252            DeployPhase::OpenNext,
253        )
254    } else if lower.contains("grant_type=refresh_token")
255        || lower.contains("failed to fetch auth token")
256        || lower.contains("authentication error (10000)")
257        || lower.contains("cloudflare_api_token")
258            && (lower.contains("failed") || lower.contains("wrangler"))
259        || lower.contains("stale oauth")
260    {
261        (DeployFailureClass::CfAuthToken, DeployPhase::CfDeploy)
262    } else if lower.contains("duplicate key")
263        || lower.contains("toml parse error")
264        || lower.contains("failed to parse toml")
265        || lower.contains("config heal")
266    {
267        (DeployFailureClass::ConfigParse, DeployPhase::Preflight)
268    } else if lower.contains("registry auth")
269        || lower.contains("docker login")
270        || (lower.contains("denied") && lower.contains("ghcr"))
271        || (lower.contains("push") && lower.contains("denied"))
272    {
273        if lower.contains("push") {
274            (DeployFailureClass::OciPushDenied, DeployPhase::OciPush)
275        } else {
276            (DeployFailureClass::OciRegistryAuth, DeployPhase::OciPush)
277        }
278    } else if lower.contains("not found")
279        && (lower.contains("target/release")
280            || lower.contains("failed to calculate checksum")
281            || lower.contains("copy --from=builder"))
282    {
283        (
284            DeployFailureClass::OciMissingArtifact,
285            DeployPhase::OciBuild,
286        )
287    } else if lower.contains("docker buildx")
288        || lower.contains("docker build")
289        || lower.contains("dockerfile")
290    {
291        let code = if lower.contains("pull")
292            || lower.contains("base image")
293            || lower.contains("docker hub")
294            || lower.contains("bookworm")
295        {
296            DeployFailureClass::OciBuildBasePull
297        } else {
298            DeployFailureClass::OciBuildFailed
299        };
300        (code, DeployPhase::OciBuild)
301    } else if lower.contains("kubectl")
302        || lower.contains("rollout")
303        || lower.contains("namespace") && lower.contains("not found")
304    {
305        (DeployFailureClass::K8sApplyFailed, DeployPhase::K8sApply)
306    } else if is_sparse_failure(text) {
307        (DeployFailureClass::SparseFailure, DeployPhase::Unknown)
308    } else if lower.contains("wrangler") {
309        (DeployFailureClass::Unknown, DeployPhase::CfDeploy)
310    } else {
311        (DeployFailureClass::Unknown, DeployPhase::Unknown)
312    };
313
314    diagnostics.push(format!(
315        "{} — {}",
316        code.as_str(),
317        code.remediation()
318    ));
319
320    // Extract a short first line for diagnostics.
321    if let Some(first) = text.lines().next() {
322        let first = first.trim();
323        if !first.is_empty() && first.len() < 240 {
324            diagnostics.push(first.to_string());
325        }
326    }
327
328    ClassifiedDeployOutcome {
329        error_code: code,
330        phase,
331        diagnostics,
332    }
333}
334
335fn is_sparse_failure(text: &str) -> bool {
336    let t = text.trim();
337    // e.g. "failed: athena" with almost no detail
338    if t.len() < 40 && t.to_ascii_lowercase().starts_with("failed:") {
339        return true;
340    }
341    if matches!(
342        t.to_ascii_lowercase().as_str(),
343        "failed" | "deploy failed" | "error"
344    ) {
345        return true;
346    }
347    false
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn classifies_opennext_wsl() {
356        let c = classify_deploy_error(
357            Some("failed: next-heroui-example — OpenNext WSL deploy failed with status exit code: 1."),
358            "failed",
359            false,
360        );
361        assert_eq!(c.error_code, DeployFailureClass::OpenNextWslFailed);
362        assert_eq!(c.phase, DeployPhase::OpenNext);
363    }
364
365    #[test]
366    fn classifies_missing_binary() {
367        let err = "COPY --from=builder /app/target/release/athena_client_pressure_worker: not found";
368        let c = classify_deploy_error(Some(err), "", false);
369        assert_eq!(c.error_code, DeployFailureClass::OciMissingArtifact);
370    }
371
372    #[test]
373    fn classifies_unchanged_container() {
374        let err = "Container image did not change after deploy (`registry.cloudflare.com/...`). Pass --allow-unchanged-container-image";
375        let c = classify_deploy_error(Some(err), "", false);
376        assert_eq!(
377            c.error_code,
378            DeployFailureClass::CfUnchangedContainerImage
379        );
380    }
381
382    #[test]
383    fn classifies_worker_not_found() {
384        let err = "Worker app `athena-auth` was not found. Known workers: worker (athena-auth)";
385        let c = classify_deploy_error(Some(err), "", false);
386        assert_eq!(c.error_code, DeployFailureClass::CfWorkerNotFound);
387    }
388
389    #[test]
390    fn classifies_ghcr_denied() {
391        let err = "docker push ghcr.io/xylex-group/athena-auth:latest failed: denied Hint: registry auth failed";
392        let c = classify_deploy_error(Some(err), "", false);
393        assert_eq!(c.error_code, DeployFailureClass::OciPushDenied);
394    }
395
396    #[test]
397    fn classifies_sparse() {
398        let c = classify_deploy_error(Some("failed: athena"), "failed: athena", false);
399        assert_eq!(c.error_code, DeployFailureClass::SparseFailure);
400    }
401
402    #[test]
403    fn success_is_success() {
404        let c = classify_deploy_error(None, "deploy succeeded", true);
405        assert_eq!(c.error_code, DeployFailureClass::Success);
406    }
407}