feat: add crewai deploy validate pre-deploy validation

Adds a new `crewai deploy validate` command that checks a project
locally against the most common categories of deploy-time failures,
so users don't burn attempts on fixable project-structure problems.
`crewai deploy create` and `crewai deploy push` now run the same
checks automatically and abort on errors; `--skip-validate` opts out.

Checks (errors block, warnings print only):
  1. pyproject.toml present with `[project].name`
  2. lockfile (uv.lock or poetry.lock) present and not stale
  3. src/<package>/ resolves, rejecting empty names and .egg-info dirs
  4. crew.py, config/agents.yaml, config/tasks.yaml for standard crews
  5. main.py for flow projects
  6. hatchling wheel target resolves
  7. crew/flow module imports cleanly in a `uv run` subprocess, with
     classification of common failures (missing provider extras,
     missing API keys at import, stale crewai pins, pydantic errors)
  8. env vars referenced in source vs .env (warning only)
  9. crewai lockfile pin vs a known-bad cutoff (warning only)

Each finding has a stable code and a structured title/detail/hint so
downstream tooling and tests can pin behavior. 33 tests cover the
checks 1:1 against the failure patterns observed in practice.
This commit is contained in:
Greyson LaLonde
2026-04-11 05:50:15 +08:00
parent 851df79a82
commit 9f61deb072
5 changed files with 1306 additions and 9 deletions

View File

@@ -392,10 +392,15 @@ def deploy() -> None:
@deploy.command(name="create")
@click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt")
def deploy_create(yes: bool) -> None:
@click.option(
"--skip-validate",
is_flag=True,
help="Skip the pre-deploy validation checks.",
)
def deploy_create(yes: bool, skip_validate: bool) -> None:
"""Create a Crew deployment."""
deploy_cmd = DeployCommand()
deploy_cmd.create_crew(yes)
deploy_cmd.create_crew(yes, skip_validate=skip_validate)
@deploy.command(name="list")
@@ -407,10 +412,28 @@ def deploy_list() -> None:
@deploy.command(name="push")
@click.option("-u", "--uuid", type=str, help="Crew UUID parameter")
def deploy_push(uuid: str | None) -> None:
@click.option(
"--skip-validate",
is_flag=True,
help="Skip the pre-deploy validation checks.",
)
def deploy_push(uuid: str | None, skip_validate: bool) -> None:
"""Deploy the Crew."""
deploy_cmd = DeployCommand()
deploy_cmd.deploy(uuid=uuid)
deploy_cmd.deploy(uuid=uuid, skip_validate=skip_validate)
@deploy.command(name="validate")
def deploy_validate() -> None:
"""Validate the current project against common deployment failures.
Runs the same pre-deploy checks that `crewai deploy create` and
`crewai deploy push` run automatically, without contacting the platform.
Exits non-zero if any blocking issues are found.
"""
from crewai.cli.deploy.validate import run_validate_command
run_validate_command()
@deploy.command(name="status")

View File

@@ -4,12 +4,35 @@ from rich.console import Console
from crewai.cli import git
from crewai.cli.command import BaseCommand, PlusAPIMixin
from crewai.cli.deploy.validate import validate_project
from crewai.cli.utils import fetch_and_json_env_file, get_project_name
console = Console()
def _run_predeploy_validation(skip_validate: bool) -> bool:
"""Run pre-deploy validation unless skipped.
Returns True if deployment should proceed, False if it should abort.
"""
if skip_validate:
console.print(
"[yellow]Skipping pre-deploy validation (--skip-validate).[/yellow]"
)
return True
console.print("Running pre-deploy validation...", style="bold blue")
validator = validate_project()
if not validator.ok:
console.print(
"\n[bold red]Pre-deploy validation failed. "
"Fix the issues above or re-run with --skip-validate.[/bold red]"
)
return False
return True
class DeployCommand(BaseCommand, PlusAPIMixin):
"""
A class to handle deployment-related operations for CrewAI projects.
@@ -60,13 +83,16 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
f"{log_message['timestamp']} - {log_message['level']}: {log_message['message']}"
)
def deploy(self, uuid: str | None = None) -> None:
def deploy(self, uuid: str | None = None, skip_validate: bool = False) -> None:
"""
Deploy a crew using either UUID or project name.
Args:
uuid (Optional[str]): The UUID of the crew to deploy.
skip_validate (bool): Skip pre-deploy validation checks.
"""
if not _run_predeploy_validation(skip_validate):
return
self._telemetry.start_deployment_span(uuid)
console.print("Starting deployment...", style="bold blue")
if uuid:
@@ -80,10 +106,16 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
self._validate_response(response)
self._display_deployment_info(response.json())
def create_crew(self, confirm: bool = False) -> None:
def create_crew(self, confirm: bool = False, skip_validate: bool = False) -> None:
"""
Create a new crew deployment.
Args:
confirm (bool): Whether to skip the interactive confirmation prompt.
skip_validate (bool): Skip pre-deploy validation checks.
"""
if not _run_predeploy_validation(skip_validate):
return
self._telemetry.create_crew_deployment_span()
console.print("Creating deployment...", style="bold blue")
env_vars = fetch_and_json_env_file()

View File

@@ -0,0 +1,842 @@
"""Pre-deploy validation for CrewAI projects.
Catches locally what a deploy would reject at build or runtime so users
don't burn deployment attempts on fixable project-structure problems.
Each check is grouped into one of:
- ERROR: will block a deployment; validator exits non-zero.
- WARNING: may still deploy but is almost always a deployment bug; printed
but does not block.
The individual checks mirror the categories observed in production
deployment-failure logs:
1. pyproject.toml present with ``[project].name``
2. lockfile (``uv.lock`` or ``poetry.lock``) present and not stale
3. package directory at ``src/<package>/`` exists (no empty name, no egg-info)
4. standard crew files: ``crew.py``, ``config/agents.yaml``, ``config/tasks.yaml``
5. flow entrypoint: ``main.py`` with a Flow subclass
6. hatch wheel target resolves (packages = [...] or default dir matches name)
7. crew/flow module imports cleanly (catches ``@CrewBase not found``,
``No Flow subclass found``, provider import errors)
8. environment variables referenced in code vs ``.env`` / deployment env
9. installed crewai vs lockfile pin (catches missing-attribute failures from
stale pins)
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import json
import logging
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import Any
from rich.console import Console
from crewai.cli.utils import parse_toml
console = Console()
logger = logging.getLogger(__name__)
class Severity(str, Enum):
"""Severity of a validation finding."""
ERROR = "error"
WARNING = "warning"
@dataclass
class ValidationResult:
"""A single finding from a validation check.
Attributes:
severity: whether this blocks deploy or is advisory.
code: stable short identifier, used in tests and docs
(e.g. ``missing_pyproject``, ``stale_lockfile``).
title: one-line summary shown to the user.
detail: optional multi-line explanation.
hint: optional remediation suggestion.
"""
severity: Severity
code: str
title: str
detail: str = ""
hint: str = ""
# Maps known provider env var names → label used in hint messages.
_KNOWN_API_KEY_HINTS: dict[str, str] = {
"OPENAI_API_KEY": "OpenAI",
"ANTHROPIC_API_KEY": "Anthropic",
"GOOGLE_API_KEY": "Google",
"GEMINI_API_KEY": "Gemini",
"AZURE_OPENAI_API_KEY": "Azure OpenAI",
"AZURE_API_KEY": "Azure",
"AWS_ACCESS_KEY_ID": "AWS",
"AWS_SECRET_ACCESS_KEY": "AWS",
"COHERE_API_KEY": "Cohere",
"GROQ_API_KEY": "Groq",
"MISTRAL_API_KEY": "Mistral",
"TAVILY_API_KEY": "Tavily",
"SERPER_API_KEY": "Serper",
"SERPLY_API_KEY": "Serply",
"PERPLEXITY_API_KEY": "Perplexity",
"DEEPSEEK_API_KEY": "DeepSeek",
"OPENROUTER_API_KEY": "OpenRouter",
"FIRECRAWL_API_KEY": "Firecrawl",
"EXA_API_KEY": "Exa",
"BROWSERBASE_API_KEY": "Browserbase",
}
def normalize_package_name(project_name: str) -> str:
"""Normalize a pyproject project.name into a Python package directory name.
Mirrors the rules in ``crewai.cli.create_crew.create_crew`` so the
validator agrees with the scaffolder about where ``src/<pkg>/`` should
live.
"""
folder = project_name.replace(" ", "_").replace("-", "_").lower()
return re.sub(r"[^a-zA-Z0-9_]", "", folder)
class DeployValidator:
"""Runs the full pre-deploy validation suite against a project directory."""
def __init__(self, project_root: Path | None = None) -> None:
self.project_root: Path = (project_root or Path.cwd()).resolve()
self.results: list[ValidationResult] = []
self._pyproject: dict[str, Any] | None = None
self._project_name: str | None = None
self._package_name: str | None = None
self._package_dir: Path | None = None
self._is_flow: bool = False
def _add(
self,
severity: Severity,
code: str,
title: str,
detail: str = "",
hint: str = "",
) -> None:
self.results.append(
ValidationResult(
severity=severity,
code=code,
title=title,
detail=detail,
hint=hint,
)
)
@property
def errors(self) -> list[ValidationResult]:
return [r for r in self.results if r.severity is Severity.ERROR]
@property
def warnings(self) -> list[ValidationResult]:
return [r for r in self.results if r.severity is Severity.WARNING]
@property
def ok(self) -> bool:
return not self.errors
def run(self) -> list[ValidationResult]:
"""Run all checks. Later checks are skipped when earlier ones make
them impossible (e.g. no pyproject.toml → no lockfile check)."""
if not self._check_pyproject():
return self.results
self._check_lockfile()
if not self._check_package_dir():
self._check_hatch_wheel_target()
return self.results
if self._is_flow:
self._check_flow_entrypoint()
else:
self._check_crew_entrypoint()
self._check_config_yamls()
self._check_hatch_wheel_target()
self._check_module_imports()
self._check_env_vars()
self._check_version_vs_lockfile()
return self.results
def _check_pyproject(self) -> bool:
pyproject_path = self.project_root / "pyproject.toml"
if not pyproject_path.exists():
self._add(
Severity.ERROR,
"missing_pyproject",
"Cannot find pyproject.toml",
detail=(
f"Expected pyproject.toml at {pyproject_path}. "
"CrewAI projects must be installable Python packages."
),
hint="Run `crewai create crew <name>` to scaffold a valid project layout.",
)
return False
try:
self._pyproject = parse_toml(pyproject_path.read_text())
except Exception as e:
self._add(
Severity.ERROR,
"invalid_pyproject",
"pyproject.toml is not valid TOML",
detail=str(e),
)
return False
project = self._pyproject.get("project") or {}
name = project.get("name")
if not isinstance(name, str) or not name.strip():
self._add(
Severity.ERROR,
"missing_project_name",
"pyproject.toml is missing [project].name",
detail=(
"Without a project name the platform cannot resolve your "
"package directory (this produces errors like "
"'Cannot find src//crew.py')."
),
hint='Set a `name = "..."` field under `[project]` in pyproject.toml.',
)
return False
self._project_name = name
self._package_name = normalize_package_name(name)
self._is_flow = (self._pyproject.get("tool") or {}).get("crewai", {}).get(
"type"
) == "flow"
return True
def _check_lockfile(self) -> None:
uv_lock = self.project_root / "uv.lock"
poetry_lock = self.project_root / "poetry.lock"
pyproject = self.project_root / "pyproject.toml"
if not uv_lock.exists() and not poetry_lock.exists():
self._add(
Severity.ERROR,
"missing_lockfile",
"Expected to find at least one of these files: uv.lock or poetry.lock",
hint=(
"Run `uv lock` (recommended) or `poetry lock` in your project "
"directory, commit the lockfile, then redeploy."
),
)
return
lockfile = uv_lock if uv_lock.exists() else poetry_lock
try:
if lockfile.stat().st_mtime < pyproject.stat().st_mtime:
self._add(
Severity.WARNING,
"stale_lockfile",
f"{lockfile.name} is older than pyproject.toml",
detail=(
"Your lockfile may not reflect recent dependency changes. "
"The platform resolves from the lockfile, so deployed "
"dependencies may differ from local."
),
hint="Run `uv lock` (or `poetry lock`) and commit the result.",
)
except OSError:
pass
def _check_package_dir(self) -> bool:
if self._package_name is None:
return False
src_dir = self.project_root / "src"
if not src_dir.is_dir():
self._add(
Severity.ERROR,
"missing_src_dir",
"Missing src/ directory",
detail=(
"CrewAI deployments expect a src-layout project: "
f"src/{self._package_name}/crew.py (or main.py for flows)."
),
hint="Run `crewai create crew <name>` to see the expected layout.",
)
return False
package_dir = src_dir / self._package_name
if not package_dir.is_dir():
siblings = [
p.name
for p in src_dir.iterdir()
if p.is_dir() and not p.name.endswith(".egg-info")
]
egg_info = [
p.name for p in src_dir.iterdir() if p.name.endswith(".egg-info")
]
hint_parts = [
f'Create src/{self._package_name}/ to match [project].name = "{self._project_name}".'
]
if siblings:
hint_parts.append(
f"Found other package directories: {', '.join(siblings)}. "
f"Either rename one to '{self._package_name}' or update [project].name."
)
if egg_info:
hint_parts.append(
f"Delete stale build artifacts: {', '.join(egg_info)} "
"(these confuse the platform's package discovery)."
)
self._add(
Severity.ERROR,
"missing_package_dir",
f"Cannot find src/{self._package_name}/",
detail=(
"The platform looks for your crew source under "
"src/<package_name>/, derived from [project].name."
),
hint=" ".join(hint_parts),
)
return False
for p in src_dir.iterdir():
if p.name.endswith(".egg-info"):
self._add(
Severity.WARNING,
"stale_egg_info",
f"Stale build artifact in src/: {p.name}",
detail=(
".egg-info directories can be mistaken for your package "
"and cause 'Cannot find src/<name>.egg-info/crew.py' errors."
),
hint=f"Delete {p} and add `*.egg-info/` to .gitignore.",
)
self._package_dir = package_dir
return True
def _check_crew_entrypoint(self) -> None:
if self._package_dir is None:
return
crew_py = self._package_dir / "crew.py"
if not crew_py.is_file():
self._add(
Severity.ERROR,
"missing_crew_py",
f"Cannot find {crew_py.relative_to(self.project_root)}",
detail=(
"Standard crew projects must define a Crew class decorated "
"with @CrewBase inside crew.py."
),
hint=(
"Create crew.py with an @CrewBase-annotated class, or set "
'`[tool.crewai] type = "flow"` in pyproject.toml if this is a flow.'
),
)
def _check_config_yamls(self) -> None:
if self._package_dir is None:
return
config_dir = self._package_dir / "config"
if not config_dir.is_dir():
self._add(
Severity.ERROR,
"missing_config_dir",
f"Cannot find {config_dir.relative_to(self.project_root)}",
hint="Create a config/ directory with agents.yaml and tasks.yaml.",
)
return
for yaml_name in ("agents.yaml", "tasks.yaml"):
yaml_path = config_dir / yaml_name
if not yaml_path.is_file():
self._add(
Severity.ERROR,
f"missing_{yaml_name.replace('.', '_')}",
f"Cannot find {yaml_path.relative_to(self.project_root)}",
detail=(
"CrewAI loads agent and task config from these files; "
"missing them causes empty-config warnings and runtime crashes."
),
)
def _check_flow_entrypoint(self) -> None:
if self._package_dir is None:
return
main_py = self._package_dir / "main.py"
if not main_py.is_file():
self._add(
Severity.ERROR,
"missing_flow_main",
f"Cannot find {main_py.relative_to(self.project_root)}",
detail=(
"Flow projects must define a Flow subclass in main.py. "
'This project has `[tool.crewai] type = "flow"` set.'
),
hint="Create main.py with a `class MyFlow(Flow[...])`.",
)
def _check_hatch_wheel_target(self) -> None:
if not self._pyproject:
return
build_system = self._pyproject.get("build-system") or {}
backend = build_system.get("build-backend", "")
if "hatchling" not in backend:
return
hatch_wheel = (
(self._pyproject.get("tool") or {})
.get("hatch", {})
.get("build", {})
.get("targets", {})
.get("wheel", {})
)
if hatch_wheel.get("packages") or hatch_wheel.get("only-include"):
return
if self._package_dir and self._package_dir.is_dir():
return
self._add(
Severity.ERROR,
"hatch_wheel_target_missing",
"Hatchling cannot determine which files to ship",
detail=(
"Your pyproject uses hatchling but has no "
"[tool.hatch.build.targets.wheel] configuration and no "
"directory matching your project name."
),
hint=(
"Add:\n"
" [tool.hatch.build.targets.wheel]\n"
f' packages = ["src/{self._package_name}"]'
),
)
def _check_module_imports(self) -> None:
"""Import the user's crew/flow via `uv run` so the check sees the same
package versions as `crewai run` would. Result is reported as JSON on
the subprocess's stdout."""
script = (
"import json, sys, traceback, os\n"
"os.chdir(sys.argv[1])\n"
"try:\n"
" from crewai.cli.utils import get_crews, get_flows\n"
" is_flow = sys.argv[2] == 'flow'\n"
" if is_flow:\n"
" instances = get_flows()\n"
" kind = 'flow'\n"
" else:\n"
" instances = get_crews()\n"
" kind = 'crew'\n"
" print(json.dumps({'ok': True, 'kind': kind, 'count': len(instances)}))\n"
"except BaseException as e:\n"
" print(json.dumps({\n"
" 'ok': False,\n"
" 'error_type': type(e).__name__,\n"
" 'error': str(e),\n"
" 'traceback': traceback.format_exc(),\n"
" }))\n"
)
uv_path = shutil.which("uv")
if uv_path is None:
self._add(
Severity.WARNING,
"uv_not_found",
"Skipping import check: `uv` not installed",
hint="Install uv: https://docs.astral.sh/uv/",
)
return
try:
proc = subprocess.run( # noqa: S603 - args constructed from trusted inputs
[
uv_path,
"run",
"python",
"-c",
script,
str(self.project_root),
"flow" if self._is_flow else "crew",
],
cwd=self.project_root,
capture_output=True,
text=True,
timeout=120,
check=False,
)
except subprocess.TimeoutExpired:
self._add(
Severity.ERROR,
"import_timeout",
"Importing your crew/flow module timed out after 120s",
detail=(
"User code may be making network calls or doing heavy work "
"at import time. Move that work into agent methods."
),
)
return
# The payload is the last JSON object on stdout; user code may print
# other lines before it.
payload: dict[str, Any] | None = None
for line in reversed(proc.stdout.splitlines()):
line = line.strip()
if line.startswith("{") and line.endswith("}"):
try:
payload = json.loads(line)
break
except json.JSONDecodeError:
continue
if payload is None:
self._add(
Severity.ERROR,
"import_failed",
"Could not import your crew/flow module",
detail=(proc.stderr or proc.stdout or "").strip()[:1500],
hint="Run `crewai run` locally first to reproduce the error.",
)
return
if payload.get("ok"):
if payload.get("count", 0) == 0:
kind = payload.get("kind", "crew")
if kind == "flow":
self._add(
Severity.ERROR,
"no_flow_subclass",
"No Flow subclass found in the module",
hint=(
"main.py must define a class extending "
"`crewai.flow.Flow`, instantiable with no arguments."
),
)
else:
self._add(
Severity.ERROR,
"no_crewbase_class",
"Crew class annotated with @CrewBase not found",
hint=(
"Decorate your crew class with @CrewBase from "
"crewai.project (see `crewai create crew` template)."
),
)
return
err_msg = str(payload.get("error", ""))
err_type = str(payload.get("error_type", "Exception"))
tb = str(payload.get("traceback", ""))
self._classify_import_error(err_type, err_msg, tb)
def _classify_import_error(self, err_type: str, err_msg: str, tb: str) -> None:
"""Turn a raw import-time exception into a user-actionable finding."""
# Must be checked before the generic "native provider" branch below:
# the extras-missing message contains the same phrase.
m = re.search(
r"(?P<pkg>[A-Za-z0-9_ -]+?)\s+native provider not available.*?`([^`]+)`",
err_msg,
)
if m:
self._add(
Severity.ERROR,
"missing_provider_extra",
f"{m.group('pkg').strip()} provider extra not installed",
hint=f"Run: {m.group(2)}",
)
return
# crewai.llm.LLM.__new__ wraps provider init errors as
# ImportError("Error importing native provider: ...").
if "Error importing native provider" in err_msg or "native provider" in err_msg:
missing_key = self._extract_missing_api_key(err_msg)
if missing_key:
provider = _KNOWN_API_KEY_HINTS.get(missing_key, missing_key)
self._add(
Severity.WARNING,
"llm_init_missing_key",
f"LLM is constructed at import time but {missing_key} is not set",
detail=(
f"Your crew instantiates a {provider} LLM during module "
"load (e.g. in a class field default or @crew method). "
f"The {provider} provider currently requires {missing_key} "
"at construction time, so this will fail on the platform "
"unless the key is set in your deployment environment."
),
hint=(
f"Add {missing_key} to your deployment's Environment "
"Variables before deploying, or move LLM construction "
"inside agent methods so it runs lazily."
),
)
return
self._add(
Severity.ERROR,
"llm_provider_init_failed",
"LLM native provider failed to initialize",
detail=err_msg,
hint=(
"Check your LLM(model=...) configuration and provider-specific "
"extras (e.g. `uv add 'crewai[azure-ai-inference]'` for Azure)."
),
)
return
if err_type == "KeyError":
key = err_msg.strip("'\"")
if key in _KNOWN_API_KEY_HINTS or key.endswith("_API_KEY"):
self._add(
Severity.WARNING,
"env_var_read_at_import",
f"{key} is read at import time via os.environ[...]",
detail=(
"Using os.environ[...] (rather than os.getenv(...)) "
"at module scope crashes the build if the key isn't set."
),
hint=(
f"Either add {key} as a deployment env var, or switch "
"to os.getenv() and move the access inside agent methods."
),
)
return
if "Crew class annotated with @CrewBase not found" in err_msg:
self._add(
Severity.ERROR,
"no_crewbase_class",
"Crew class annotated with @CrewBase not found",
detail=err_msg,
)
return
if "No Flow subclass found" in err_msg:
self._add(
Severity.ERROR,
"no_flow_subclass",
"No Flow subclass found in the module",
detail=err_msg,
)
return
if (
err_type == "AttributeError"
and "has no attribute '_load_response_format'" in err_msg
):
self._add(
Severity.ERROR,
"stale_crewai_pin",
"Your lockfile pins a crewai version missing `_load_response_format`",
detail=err_msg,
hint=(
"Run `uv lock --upgrade-package crewai` (or `poetry update crewai`) "
"to pin a newer release."
),
)
return
if "pydantic" in tb.lower() or "validation error" in err_msg.lower():
self._add(
Severity.ERROR,
"pydantic_validation_error",
"Pydantic validation failed while loading your crew",
detail=err_msg[:800],
hint=(
"Check agent/task configuration fields. `crewai run` locally "
"will show the full traceback."
),
)
return
self._add(
Severity.ERROR,
"import_failed",
f"Importing your crew failed: {err_type}",
detail=err_msg[:800],
hint="Run `crewai run` locally to see the full traceback.",
)
@staticmethod
def _extract_missing_api_key(err_msg: str) -> str | None:
"""Pull 'FOO_API_KEY' out of '... FOO_API_KEY is required ...'."""
m = re.search(r"([A-Z][A-Z0-9_]*_API_KEY)\s+is required", err_msg)
if m:
return m.group(1)
m = re.search(r"['\"]([A-Z][A-Z0-9_]*_API_KEY)['\"]", err_msg)
if m:
return m.group(1)
return None
def _check_env_vars(self) -> None:
"""Warn about env vars referenced in user code but missing locally.
Best-effort only — the platform sets vars server-side, so we never error.
"""
if not self._package_dir:
return
referenced: set[str] = set()
pattern = re.compile(
r"""(?x)
(?:os\.environ\s*(?:\[\s*|\.get\s*\(\s*)
|os\.getenv\s*\(\s*
|getenv\s*\(\s*)
['"]([A-Z][A-Z0-9_]*)['"]
"""
)
for path in self._package_dir.rglob("*.py"):
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
referenced.update(pattern.findall(text))
for path in self._package_dir.rglob("*.yaml"):
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
referenced.update(re.findall(r"\$\{?([A-Z][A-Z0-9_]+)\}?", text))
env_file = self.project_root / ".env"
env_keys: set[str] = set()
if env_file.exists():
for line in env_file.read_text(errors="ignore").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
env_keys.add(line.split("=", 1)[0].strip())
missing_known: list[str] = sorted(
var
for var in referenced
if var in _KNOWN_API_KEY_HINTS
and var not in env_keys
and var not in os.environ
)
if missing_known:
self._add(
Severity.WARNING,
"env_vars_not_in_dotenv",
f"{len(missing_known)} referenced API key(s) not in .env",
detail=(
"These env vars are referenced in your source but not set "
f"locally: {', '.join(missing_known)}. Deploys will fail "
"unless they are added to the deployment's Environment "
"Variables in the CrewAI dashboard."
),
)
def _check_version_vs_lockfile(self) -> None:
"""Warn when the lockfile pins a crewai release older than 1.13.0,
which is where ``_load_response_format`` was introduced.
"""
uv_lock = self.project_root / "uv.lock"
poetry_lock = self.project_root / "poetry.lock"
lockfile = (
uv_lock
if uv_lock.exists()
else poetry_lock
if poetry_lock.exists()
else None
)
if lockfile is None:
return
try:
text = lockfile.read_text(errors="ignore")
except OSError:
return
m = re.search(
r'name\s*=\s*"crewai"\s*\nversion\s*=\s*"([^"]+)"',
text,
)
if not m:
return
locked = m.group(1)
try:
from packaging.version import Version
if Version(locked) < Version("1.13.0"):
self._add(
Severity.WARNING,
"old_crewai_pin",
f"Lockfile pins crewai=={locked} (older than 1.13.0)",
detail=(
"Older pinned versions are missing API surface the "
"platform builder expects (e.g. `_load_response_format`)."
),
hint="Run `uv lock --upgrade-package crewai` and redeploy.",
)
except Exception as e:
logger.debug("Could not parse crewai pin from lockfile: %s", e)
def render_report(results: list[ValidationResult]) -> None:
"""Pretty-print results to the shared rich console."""
if not results:
console.print("[bold green]Pre-deploy validation passed.[/bold green]")
return
errors = [r for r in results if r.severity is Severity.ERROR]
warnings = [r for r in results if r.severity is Severity.WARNING]
for result in errors:
console.print(f"[bold red]ERROR[/bold red] [{result.code}] {result.title}")
if result.detail:
console.print(f" {result.detail}")
if result.hint:
console.print(f" [dim]hint:[/dim] {result.hint}")
for result in warnings:
console.print(
f"[bold yellow]WARNING[/bold yellow] [{result.code}] {result.title}"
)
if result.detail:
console.print(f" {result.detail}")
if result.hint:
console.print(f" [dim]hint:[/dim] {result.hint}")
summary_parts: list[str] = []
if errors:
summary_parts.append(f"[bold red]{len(errors)} error(s)[/bold red]")
if warnings:
summary_parts.append(f"[bold yellow]{len(warnings)} warning(s)[/bold yellow]")
console.print(f"\n{' / '.join(summary_parts)}")
def validate_project(project_root: Path | None = None) -> DeployValidator:
"""Entrypoint: run validation, render results, return the validator.
The caller inspects ``validator.ok`` to decide whether to proceed with a
deploy.
"""
validator = DeployValidator(project_root=project_root)
validator.run()
render_report(validator.results)
return validator
def run_validate_command() -> None:
"""Implementation of `crewai deploy validate`."""
validator = validate_project()
if not validator.ok:
sys.exit(1)

View File

@@ -125,7 +125,7 @@ class TestDeployCommand(unittest.TestCase):
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.deploy_by_uuid.return_value = mock_response
self.deploy_command.deploy(uuid="test-uuid")
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
self.mock_client.deploy_by_uuid.assert_called_once_with("test-uuid")
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@@ -137,7 +137,7 @@ class TestDeployCommand(unittest.TestCase):
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.deploy_by_name.return_value = mock_response
self.deploy_command.deploy()
self.deploy_command.deploy(skip_validate=True)
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@@ -156,7 +156,7 @@ class TestDeployCommand(unittest.TestCase):
self.mock_client.create_crew.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.create_crew()
self.deploy_command.create_crew(skip_validate=True)
self.assertIn("Deployment created successfully!", fake_out.getvalue())
self.assertIn("new-uuid", fake_out.getvalue())

View File

@@ -0,0 +1,400 @@
"""Tests for `crewai.cli.deploy.validate`.
The fixtures here correspond 1:1 to the deployment-failure patterns observed
in the #crewai-deployment-failures Slack channel that motivated this work.
"""
from __future__ import annotations
from pathlib import Path
from textwrap import dedent
from typing import Iterable
from unittest.mock import patch
import pytest
from crewai.cli.deploy.validate import (
DeployValidator,
Severity,
normalize_package_name,
)
def _make_pyproject(
name: str = "my_crew",
dependencies: Iterable[str] = ("crewai>=1.14.0",),
*,
hatchling: bool = False,
flow: bool = False,
extra: str = "",
) -> str:
deps = ", ".join(f'"{d}"' for d in dependencies)
lines = [
"[project]",
f'name = "{name}"',
'version = "0.1.0"',
f"dependencies = [{deps}]",
]
if hatchling:
lines += [
"",
"[build-system]",
'requires = ["hatchling"]',
'build-backend = "hatchling.build"',
]
if flow:
lines += ["", "[tool.crewai]", 'type = "flow"']
if extra:
lines += ["", extra]
return "\n".join(lines) + "\n"
def _scaffold_standard_crew(
root: Path,
*,
name: str = "my_crew",
include_crew_py: bool = True,
include_agents_yaml: bool = True,
include_tasks_yaml: bool = True,
include_lockfile: bool = True,
pyproject: str | None = None,
) -> Path:
(root / "pyproject.toml").write_text(pyproject or _make_pyproject(name=name))
if include_lockfile:
(root / "uv.lock").write_text("# dummy uv lockfile\n")
pkg_dir = root / "src" / normalize_package_name(name)
pkg_dir.mkdir(parents=True)
(pkg_dir / "__init__.py").write_text("")
if include_crew_py:
(pkg_dir / "crew.py").write_text(
dedent(
"""
from crewai.project import CrewBase, crew
@CrewBase
class MyCrew:
agents_config = "config/agents.yaml"
tasks_config = "config/tasks.yaml"
@crew
def crew(self):
from crewai import Crew
return Crew(agents=[], tasks=[])
"""
).strip()
+ "\n"
)
config_dir = pkg_dir / "config"
config_dir.mkdir()
if include_agents_yaml:
(config_dir / "agents.yaml").write_text("{}\n")
if include_tasks_yaml:
(config_dir / "tasks.yaml").write_text("{}\n")
return pkg_dir
def _codes(validator: DeployValidator) -> set[str]:
return {r.code for r in validator.results}
def _run_without_import_check(root: Path) -> DeployValidator:
"""Run validation with the subprocess-based import check stubbed out;
the classifier is exercised directly in its own tests below."""
with patch.object(DeployValidator, "_check_module_imports", lambda self: None):
v = DeployValidator(project_root=root)
v.run()
return v
@pytest.mark.parametrize(
"project_name, expected",
[
("my-crew", "my_crew"),
("My Cool-Project", "my_cool_project"),
("crew123", "crew123"),
("crew.name!with$chars", "crewnamewithchars"),
],
)
def test_normalize_package_name(project_name: str, expected: str) -> None:
assert normalize_package_name(project_name) == expected
def test_valid_standard_crew_project_passes(tmp_path: Path) -> None:
_scaffold_standard_crew(tmp_path)
v = _run_without_import_check(tmp_path)
assert v.ok, f"expected clean run, got {v.results}"
def test_missing_pyproject_errors(tmp_path: Path) -> None:
v = _run_without_import_check(tmp_path)
assert "missing_pyproject" in _codes(v)
assert not v.ok
def test_invalid_pyproject_errors(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text("this is not valid toml ====\n")
v = _run_without_import_check(tmp_path)
assert "invalid_pyproject" in _codes(v)
def test_missing_project_name_errors(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text(
'[project]\nversion = "0.1.0"\ndependencies = ["crewai>=1.14.0"]\n'
)
v = _run_without_import_check(tmp_path)
assert "missing_project_name" in _codes(v)
def test_missing_lockfile_errors(tmp_path: Path) -> None:
_scaffold_standard_crew(tmp_path, include_lockfile=False)
v = _run_without_import_check(tmp_path)
assert "missing_lockfile" in _codes(v)
def test_poetry_lock_is_accepted(tmp_path: Path) -> None:
_scaffold_standard_crew(tmp_path, include_lockfile=False)
(tmp_path / "poetry.lock").write_text("# poetry lockfile\n")
v = _run_without_import_check(tmp_path)
assert "missing_lockfile" not in _codes(v)
def test_stale_lockfile_warns(tmp_path: Path) -> None:
_scaffold_standard_crew(tmp_path)
# Make lockfile older than pyproject.
lock = tmp_path / "uv.lock"
pyproject = tmp_path / "pyproject.toml"
old_time = pyproject.stat().st_mtime - 60
import os
os.utime(lock, (old_time, old_time))
v = _run_without_import_check(tmp_path)
assert "stale_lockfile" in _codes(v)
# Stale is a warning, so the run can still be ok (no errors).
assert v.ok
def test_missing_package_dir_errors(tmp_path: Path) -> None:
# pyproject says name=my_crew but we only create src/other_pkg/
(tmp_path / "pyproject.toml").write_text(_make_pyproject(name="my_crew"))
(tmp_path / "uv.lock").write_text("")
(tmp_path / "src" / "other_pkg").mkdir(parents=True)
v = _run_without_import_check(tmp_path)
codes = _codes(v)
assert "missing_package_dir" in codes
finding = next(r for r in v.results if r.code == "missing_package_dir")
assert "other_pkg" in finding.hint
def test_egg_info_only_errors_with_targeted_hint(tmp_path: Path) -> None:
"""Regression for the case where only src/<name>.egg-info/ exists."""
(tmp_path / "pyproject.toml").write_text(_make_pyproject(name="odoo_pm_agents"))
(tmp_path / "uv.lock").write_text("")
(tmp_path / "src" / "odoo_pm_agents.egg-info").mkdir(parents=True)
v = _run_without_import_check(tmp_path)
finding = next(r for r in v.results if r.code == "missing_package_dir")
assert "egg-info" in finding.hint
def test_stale_egg_info_sibling_warns(tmp_path: Path) -> None:
_scaffold_standard_crew(tmp_path)
(tmp_path / "src" / "my_crew.egg-info").mkdir()
v = _run_without_import_check(tmp_path)
assert "stale_egg_info" in _codes(v)
def test_missing_crew_py_errors(tmp_path: Path) -> None:
_scaffold_standard_crew(tmp_path, include_crew_py=False)
v = _run_without_import_check(tmp_path)
assert "missing_crew_py" in _codes(v)
def test_missing_agents_yaml_errors(tmp_path: Path) -> None:
_scaffold_standard_crew(tmp_path, include_agents_yaml=False)
v = _run_without_import_check(tmp_path)
assert "missing_agents_yaml" in _codes(v)
def test_missing_tasks_yaml_errors(tmp_path: Path) -> None:
_scaffold_standard_crew(tmp_path, include_tasks_yaml=False)
v = _run_without_import_check(tmp_path)
assert "missing_tasks_yaml" in _codes(v)
def test_flow_project_requires_main_py(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text(
_make_pyproject(name="my_flow", flow=True)
)
(tmp_path / "uv.lock").write_text("")
(tmp_path / "src" / "my_flow").mkdir(parents=True)
v = _run_without_import_check(tmp_path)
assert "missing_flow_main" in _codes(v)
def test_flow_project_with_main_py_passes(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text(
_make_pyproject(name="my_flow", flow=True)
)
(tmp_path / "uv.lock").write_text("")
pkg = tmp_path / "src" / "my_flow"
pkg.mkdir(parents=True)
(pkg / "main.py").write_text("# flow entrypoint\n")
v = _run_without_import_check(tmp_path)
assert "missing_flow_main" not in _codes(v)
def test_hatchling_without_wheel_config_passes_when_pkg_dir_matches(
tmp_path: Path,
) -> None:
_scaffold_standard_crew(
tmp_path, pyproject=_make_pyproject(name="my_crew", hatchling=True)
)
v = _run_without_import_check(tmp_path)
# src/my_crew/ exists, so hatch default should find it — no wheel error.
assert "hatch_wheel_target_missing" not in _codes(v)
def test_hatchling_with_explicit_wheel_config_passes(tmp_path: Path) -> None:
extra = (
"[tool.hatch.build.targets.wheel]\n"
'packages = ["src/my_crew"]'
)
_scaffold_standard_crew(
tmp_path,
pyproject=_make_pyproject(name="my_crew", hatchling=True, extra=extra),
)
v = _run_without_import_check(tmp_path)
assert "hatch_wheel_target_missing" not in _codes(v)
def test_classify_missing_openai_key_is_warning(tmp_path: Path) -> None:
v = DeployValidator(project_root=tmp_path)
v._classify_import_error(
"ImportError",
"Error importing native provider: 1 validation error for OpenAICompletion\n"
" Value error, OPENAI_API_KEY is required",
tb="",
)
assert len(v.results) == 1
result = v.results[0]
assert result.code == "llm_init_missing_key"
assert result.severity is Severity.WARNING
assert "OPENAI_API_KEY" in result.title
def test_classify_azure_extra_missing_is_error(tmp_path: Path) -> None:
v = DeployValidator(project_root=tmp_path)
v._classify_import_error(
"ImportError",
'Azure AI Inference native provider not available, to install: `uv add "crewai[azure-ai-inference]"`',
tb="",
)
assert "missing_provider_extra" in _codes(v)
def test_classify_keyerror_at_import_is_warning(tmp_path: Path) -> None:
"""Regression for `KeyError: 'SERPLY_API_KEY'` raised at import time."""
v = DeployValidator(project_root=tmp_path)
v._classify_import_error("KeyError", "'SERPLY_API_KEY'", tb="")
codes = _codes(v)
assert "env_var_read_at_import" in codes
def test_classify_no_crewbase_class_is_error(tmp_path: Path) -> None:
v = DeployValidator(project_root=tmp_path)
v._classify_import_error(
"ValueError",
"Crew class annotated with @CrewBase not found.",
tb="",
)
assert "no_crewbase_class" in _codes(v)
def test_classify_no_flow_subclass_is_error(tmp_path: Path) -> None:
v = DeployValidator(project_root=tmp_path)
v._classify_import_error("ValueError", "No Flow subclass found in the module.", tb="")
assert "no_flow_subclass" in _codes(v)
def test_classify_stale_crewai_pin_attribute_error(tmp_path: Path) -> None:
"""Regression for a stale crewai pin missing `_load_response_format`."""
v = DeployValidator(project_root=tmp_path)
v._classify_import_error(
"AttributeError",
"'EmploymentServiceDecisionSupportSystemCrew' object has no attribute '_load_response_format'",
tb="",
)
assert "stale_crewai_pin" in _codes(v)
def test_classify_unknown_error_is_fallback(tmp_path: Path) -> None:
v = DeployValidator(project_root=tmp_path)
v._classify_import_error("RuntimeError", "something weird happened", tb="")
assert "import_failed" in _codes(v)
def test_env_var_referenced_but_missing_warns(tmp_path: Path) -> None:
pkg = _scaffold_standard_crew(tmp_path)
(pkg / "tools.py").write_text(
'import os\nkey = os.getenv("TAVILY_API_KEY")\n'
)
import os
# Make sure the test doesn't inherit the key from the host environment.
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("TAVILY_API_KEY", None)
v = _run_without_import_check(tmp_path)
codes = _codes(v)
assert "env_vars_not_in_dotenv" in codes
def test_env_var_in_dotenv_does_not_warn(tmp_path: Path) -> None:
pkg = _scaffold_standard_crew(tmp_path)
(pkg / "tools.py").write_text(
'import os\nkey = os.getenv("TAVILY_API_KEY")\n'
)
(tmp_path / ".env").write_text("TAVILY_API_KEY=abc\n")
v = _run_without_import_check(tmp_path)
assert "env_vars_not_in_dotenv" not in _codes(v)
def test_old_crewai_pin_in_uv_lock_warns(tmp_path: Path) -> None:
_scaffold_standard_crew(tmp_path)
(tmp_path / "uv.lock").write_text(
'name = "crewai"\nversion = "1.10.0"\nsource = { registry = "..." }\n'
)
v = _run_without_import_check(tmp_path)
assert "old_crewai_pin" in _codes(v)
def test_modern_crewai_pin_does_not_warn(tmp_path: Path) -> None:
_scaffold_standard_crew(tmp_path)
(tmp_path / "uv.lock").write_text(
'name = "crewai"\nversion = "1.14.1"\nsource = { registry = "..." }\n'
)
v = _run_without_import_check(tmp_path)
assert "old_crewai_pin" not in _codes(v)
def test_create_crew_aborts_on_validation_error(tmp_path: Path) -> None:
"""`crewai deploy create` must not contact the API when validation fails."""
from unittest.mock import MagicMock, patch as mock_patch
from crewai.cli.deploy.main import DeployCommand
with (
mock_patch("crewai.cli.command.get_auth_token", return_value="tok"),
mock_patch("crewai.cli.deploy.main.get_project_name", return_value="p"),
mock_patch("crewai.cli.command.PlusAPI") as mock_api,
mock_patch(
"crewai.cli.deploy.main.validate_project"
) as mock_validate,
):
mock_validate.return_value = MagicMock(ok=False)
cmd = DeployCommand()
cmd.create_crew()
assert not cmd.plus_api_client.create_crew.called
del mock_api # silence unused-var lint