use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeployPhase {
Plan,
Preflight,
OciBuild,
OciPush,
K8sApply,
CfDoctor,
CfBuild,
CfDeploy,
OpenNext,
Verify,
Unknown,
}
impl DeployPhase {
pub fn as_str(self) -> &'static str {
match self {
Self::Plan => "plan",
Self::Preflight => "preflight",
Self::OciBuild => "oci_build",
Self::OciPush => "oci_push",
Self::K8sApply => "k8s_apply",
Self::CfDoctor => "cf_doctor",
Self::CfBuild => "cf_build",
Self::CfDeploy => "cf_deploy",
Self::OpenNext => "opennext",
Self::Verify => "verify",
Self::Unknown => "unknown",
}
}
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"plan" => Self::Plan,
"preflight" => Self::Preflight,
"oci_build" | "build" => Self::OciBuild,
"oci_push" | "push" => Self::OciPush,
"k8s_apply" | "kubernetes" | "apply" => Self::K8sApply,
"cf_doctor" | "doctor" => Self::CfDoctor,
"cf_build" => Self::CfBuild,
"cf_deploy" | "cloudflare" => Self::CfDeploy,
"opennext" | "open_next" | "wsl" => Self::OpenNext,
"verify" | "health" => Self::Verify,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeployFailureClass {
Success,
OciBuildBasePull,
OciMissingArtifact,
OciBuildFailed,
OciRegistryAuth,
OciPushDenied,
K8sApplyFailed,
CfWorkerNotFound,
CfContractIncomplete,
CfAuthToken,
CfUnchangedContainerImage,
CfMissingEntryPoint,
OpenNextWslFailed,
OpenNextBuildFailed,
ConfigParse,
PreflightFailed,
SparseFailure,
Unknown,
}
impl DeployFailureClass {
pub fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::OciBuildBasePull => "oci_build_base_pull",
Self::OciMissingArtifact => "oci_missing_artifact",
Self::OciBuildFailed => "oci_build_failed",
Self::OciRegistryAuth => "oci_registry_auth",
Self::OciPushDenied => "oci_push_denied",
Self::K8sApplyFailed => "k8s_apply_failed",
Self::CfWorkerNotFound => "cf_worker_not_found",
Self::CfContractIncomplete => "cf_contract_incomplete",
Self::CfAuthToken => "cf_auth_token",
Self::CfUnchangedContainerImage => "cf_unchanged_container_image",
Self::CfMissingEntryPoint => "cf_missing_entry_point",
Self::OpenNextWslFailed => "opennext_wsl_failed",
Self::OpenNextBuildFailed => "opennext_build_failed",
Self::ConfigParse => "config_parse",
Self::PreflightFailed => "preflight_failed",
Self::SparseFailure => "sparse_failure",
Self::Unknown => "unknown",
}
}
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"success" => Self::Success,
"oci_build_base_pull" => Self::OciBuildBasePull,
"oci_missing_artifact" => Self::OciMissingArtifact,
"oci_build_failed" => Self::OciBuildFailed,
"oci_registry_auth" => Self::OciRegistryAuth,
"oci_push_denied" => Self::OciPushDenied,
"k8s_apply_failed" => Self::K8sApplyFailed,
"cf_worker_not_found" => Self::CfWorkerNotFound,
"cf_contract_incomplete" => Self::CfContractIncomplete,
"cf_auth_token" => Self::CfAuthToken,
"cf_unchanged_container_image" => Self::CfUnchangedContainerImage,
"cf_missing_entry_point" => Self::CfMissingEntryPoint,
"opennext_wsl_failed" => Self::OpenNextWslFailed,
"opennext_build_failed" => Self::OpenNextBuildFailed,
"config_parse" => Self::ConfigParse,
"preflight_failed" => Self::PreflightFailed,
"sparse_failure" => Self::SparseFailure,
_ => Self::Unknown,
}
}
pub fn remediation(self) -> &'static str {
match self {
Self::Success => "none",
Self::OciBuildBasePull => {
"retry; `docker pull` base images; check Docker Hub/network access"
}
Self::OciMissingArtifact => {
"ensure multi-stage Dockerfile COPY targets exist in the builder stage"
}
Self::OciBuildFailed => "inspect docker buildx log; fix Dockerfile or context",
Self::OciRegistryAuth | Self::OciPushDenied => {
"run `docker login ghcr.io` (or target registry) with a token that can push"
}
Self::K8sApplyFailed => "check kube context, namespace, and `kubectl get events`",
Self::CfWorkerNotFound => {
"pass workers[].name / service deploy.worker; run `xbp cloudflare doctor --app <name>`"
}
Self::CfContractIncomplete => {
"run `xbp cloudflare doctor --app <name>` and fix listed contract issues"
}
Self::CfAuthToken => {
"set CLOUDFLARE_API_TOKEN or `xbp config cloudflare set-key`; `wrangler logout` if OAuth is stale"
}
Self::CfUnchangedContainerImage => {
"pass --allow-unchanged-container-image for config-only, or rebuild the container image"
}
Self::CfMissingEntryPoint => {
"run OpenNext build first; ensure .open-next/worker.js (or wrangler main) exists"
}
Self::OpenNextWslFailed => {
"inspect WSL OpenNext log tail; ensure WSL, Node/pnpm, and worker root are healthy"
}
Self::OpenNextBuildFailed => "fix OpenNext build errors; re-run with verbose stage logs",
Self::ConfigParse => "run `xbp config heal` or fix duplicate keys in .xbp/xbp.toml",
Self::PreflightFailed => "resolve preflight diagnostics before retrying deploy",
Self::SparseFailure => "re-run with a current xbp; failure detail was not recorded",
Self::Unknown => "see full error text and deploy history record",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClassifiedDeployOutcome {
pub error_code: DeployFailureClass,
pub phase: DeployPhase,
pub diagnostics: Vec<String>,
}
impl ClassifiedDeployOutcome {
pub fn success() -> Self {
Self {
error_code: DeployFailureClass::Success,
phase: DeployPhase::Unknown,
diagnostics: Vec::new(),
}
}
}
pub fn classify_deploy_error(error: Option<&str>, summary: &str, ok: bool) -> ClassifiedDeployOutcome {
if ok {
return ClassifiedDeployOutcome::success();
}
let text = error.unwrap_or(summary);
let lower = text.to_ascii_lowercase();
let mut diagnostics = Vec::new();
let (code, phase) = if lower.contains("container image did not change")
|| lower.contains("allow-unchanged-container-image")
{
(
DeployFailureClass::CfUnchangedContainerImage,
DeployPhase::CfDeploy,
)
} else if lower.contains("worker contract is incomplete")
|| lower.contains("cloudflare worker contract")
{
(
DeployFailureClass::CfContractIncomplete,
DeployPhase::CfDoctor,
)
} else if lower.contains("was not found")
&& (lower.contains("worker app") || lower.contains("known workers"))
{
(DeployFailureClass::CfWorkerNotFound, DeployPhase::CfDoctor)
} else if lower.contains("entry-point")
|| lower.contains("entry point")
|| lower.contains(".open-next") && lower.contains("not found")
|| lower.contains("worker.js") && lower.contains("not found")
{
(
DeployFailureClass::CfMissingEntryPoint,
DeployPhase::CfDeploy,
)
} else if lower.contains("cf_missing_entry_point")
|| lower.contains("stage=entry_assert")
|| (lower.contains("open-next")
&& lower.contains("entry")
&& (lower.contains("not found") || lower.contains("missing")))
{
(
DeployFailureClass::CfMissingEntryPoint,
DeployPhase::OpenNext,
)
} else if lower.contains("opennext_build_failed")
|| lower.contains("stage=build")
&& (lower.contains("opennext") || lower.contains("open next"))
{
(
DeployFailureClass::OpenNextBuildFailed,
DeployPhase::OpenNext,
)
} else if lower.contains("opennext wsl")
|| lower.contains("opennext_wsl_failed")
|| lower.contains("open next wsl")
|| (lower.contains("opennext") && lower.contains("wsl"))
{
(
DeployFailureClass::OpenNextWslFailed,
DeployPhase::OpenNext,
)
} else if lower.contains("grant_type=refresh_token")
|| lower.contains("failed to fetch auth token")
|| lower.contains("authentication error (10000)")
|| lower.contains("cloudflare_api_token")
&& (lower.contains("failed") || lower.contains("wrangler"))
|| lower.contains("stale oauth")
{
(DeployFailureClass::CfAuthToken, DeployPhase::CfDeploy)
} else if lower.contains("duplicate key")
|| lower.contains("toml parse error")
|| lower.contains("failed to parse toml")
|| lower.contains("config heal")
{
(DeployFailureClass::ConfigParse, DeployPhase::Preflight)
} else if lower.contains("registry auth")
|| lower.contains("docker login")
|| (lower.contains("denied") && lower.contains("ghcr"))
|| (lower.contains("push") && lower.contains("denied"))
{
if lower.contains("push") {
(DeployFailureClass::OciPushDenied, DeployPhase::OciPush)
} else {
(DeployFailureClass::OciRegistryAuth, DeployPhase::OciPush)
}
} else if lower.contains("not found")
&& (lower.contains("target/release")
|| lower.contains("failed to calculate checksum")
|| lower.contains("copy --from=builder"))
{
(
DeployFailureClass::OciMissingArtifact,
DeployPhase::OciBuild,
)
} else if lower.contains("docker buildx")
|| lower.contains("docker build")
|| lower.contains("dockerfile")
{
let code = if lower.contains("pull")
|| lower.contains("base image")
|| lower.contains("docker hub")
|| lower.contains("bookworm")
{
DeployFailureClass::OciBuildBasePull
} else {
DeployFailureClass::OciBuildFailed
};
(code, DeployPhase::OciBuild)
} else if lower.contains("kubectl")
|| lower.contains("rollout")
|| lower.contains("namespace") && lower.contains("not found")
{
(DeployFailureClass::K8sApplyFailed, DeployPhase::K8sApply)
} else if is_sparse_failure(text) {
(DeployFailureClass::SparseFailure, DeployPhase::Unknown)
} else if lower.contains("wrangler") {
(DeployFailureClass::Unknown, DeployPhase::CfDeploy)
} else {
(DeployFailureClass::Unknown, DeployPhase::Unknown)
};
diagnostics.push(format!(
"{} — {}",
code.as_str(),
code.remediation()
));
if let Some(first) = text.lines().next() {
let first = first.trim();
if !first.is_empty() && first.len() < 240 {
diagnostics.push(first.to_string());
}
}
ClassifiedDeployOutcome {
error_code: code,
phase,
diagnostics,
}
}
fn is_sparse_failure(text: &str) -> bool {
let t = text.trim();
if t.len() < 40 && t.to_ascii_lowercase().starts_with("failed:") {
return true;
}
if matches!(
t.to_ascii_lowercase().as_str(),
"failed" | "deploy failed" | "error"
) {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_opennext_wsl() {
let c = classify_deploy_error(
Some("failed: next-heroui-example — OpenNext WSL deploy failed with status exit code: 1."),
"failed",
false,
);
assert_eq!(c.error_code, DeployFailureClass::OpenNextWslFailed);
assert_eq!(c.phase, DeployPhase::OpenNext);
}
#[test]
fn classifies_missing_binary() {
let err = "COPY --from=builder /app/target/release/athena_client_pressure_worker: not found";
let c = classify_deploy_error(Some(err), "", false);
assert_eq!(c.error_code, DeployFailureClass::OciMissingArtifact);
}
#[test]
fn classifies_unchanged_container() {
let err = "Container image did not change after deploy (`registry.cloudflare.com/...`). Pass --allow-unchanged-container-image";
let c = classify_deploy_error(Some(err), "", false);
assert_eq!(
c.error_code,
DeployFailureClass::CfUnchangedContainerImage
);
}
#[test]
fn classifies_worker_not_found() {
let err = "Worker app `athena-auth` was not found. Known workers: worker (athena-auth)";
let c = classify_deploy_error(Some(err), "", false);
assert_eq!(c.error_code, DeployFailureClass::CfWorkerNotFound);
}
#[test]
fn classifies_ghcr_denied() {
let err = "docker push ghcr.io/xylex-group/athena-auth:latest failed: denied Hint: registry auth failed";
let c = classify_deploy_error(Some(err), "", false);
assert_eq!(c.error_code, DeployFailureClass::OciPushDenied);
}
#[test]
fn classifies_sparse() {
let c = classify_deploy_error(Some("failed: athena"), "failed: athena", false);
assert_eq!(c.error_code, DeployFailureClass::SparseFailure);
}
#[test]
fn success_is_success() {
let c = classify_deploy_error(None, "deploy succeeded", true);
assert_eq!(c.error_code, DeployFailureClass::Success);
}
}