1use serde::{Deserialize, Serialize};
7
8#[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#[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 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#[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
190pub 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 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 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}