mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-01 05:08:12 +00:00
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
* fix: scaffold deployable json crews * fix: keep json crew scaffolds python-free * fix: keep json deploy archives python-free * fix: tighten json crew deploy validation * fix: address json crew pr checks * fix: clear langsmith audit advisory
999 lines
36 KiB
Python
999 lines
36 KiB
Python
"""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 crewai.project.json_loader import (
|
|
JSONProjectValidationError,
|
|
find_crew_json_file,
|
|
find_json_project_file,
|
|
validate_crew_project,
|
|
)
|
|
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 = ""
|
|
|
|
|
|
_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
|
|
|
|
@property
|
|
def _is_json_crew(self) -> bool:
|
|
"""True for JSON crew projects, deferring to the declared type.
|
|
|
|
A flow project that also contains a crew.json(c) file validates as
|
|
the flow it declares in pyproject.toml, not as a JSON crew.
|
|
"""
|
|
if find_crew_json_file(self.project_root) is None:
|
|
return False
|
|
pyproject_path = self.project_root / "pyproject.toml"
|
|
if not pyproject_path.exists():
|
|
return True
|
|
try:
|
|
data = parse_toml(pyproject_path.read_text())
|
|
except Exception:
|
|
return True
|
|
declared_type: str | None = (
|
|
(data.get("tool") or {}).get("crewai", {}).get("type")
|
|
)
|
|
return declared_type != "flow"
|
|
|
|
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 self._is_json_crew:
|
|
return self._run_json_checks()
|
|
|
|
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 _run_json_checks(self) -> list[ValidationResult]:
|
|
"""Validation suite for JSON-defined crew projects."""
|
|
crew_path = find_crew_json_file(self.project_root)
|
|
if crew_path is None:
|
|
return self.results
|
|
|
|
agents_dir = self.project_root / "agents"
|
|
|
|
self._check_pyproject()
|
|
self._check_lockfile()
|
|
agents_dir_ok = self._check_json_agents_dir(agents_dir)
|
|
|
|
project = None
|
|
try:
|
|
if agents_dir_ok:
|
|
project = validate_crew_project(crew_path, agents_dir)
|
|
except JSONProjectValidationError as e:
|
|
self._add(
|
|
Severity.ERROR,
|
|
"invalid_crew_json",
|
|
f"{crew_path.name} has invalid JSON crew configuration",
|
|
detail="\n".join(e.errors),
|
|
hint="Fix the JSON crew, agent, and task references before deploying.",
|
|
)
|
|
return self.results
|
|
except Exception as e:
|
|
self._add(
|
|
Severity.ERROR,
|
|
"invalid_crew_json",
|
|
f"Cannot parse {crew_path.name}",
|
|
detail=str(e),
|
|
)
|
|
return self.results
|
|
|
|
if project is not None:
|
|
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
|
|
self._check_version_vs_lockfile()
|
|
|
|
return self.results
|
|
|
|
def _check_json_agents_dir(self, agents_dir: Path) -> bool:
|
|
if agents_dir.is_dir():
|
|
return True
|
|
self._add(
|
|
Severity.ERROR,
|
|
"missing_agents_dir",
|
|
"Cannot find agents/ directory",
|
|
detail=(
|
|
"JSON crew projects load agent definitions from "
|
|
f"{agents_dir.relative_to(self.project_root)}/*.jsonc or *.json."
|
|
),
|
|
hint="Create agents/ and add one JSON or JSONC file per agent.",
|
|
)
|
|
return False
|
|
|
|
def _check_env_vars_json(
|
|
self, crew_path: Path, agents_dir: Path, agent_names: list[str]
|
|
) -> None:
|
|
"""Check for env var references in JSON crew files."""
|
|
referenced: set[str] = set()
|
|
pattern = re.compile(r"\$\{?([A-Z][A-Z0-9_]+)\}?")
|
|
|
|
try:
|
|
referenced.update(pattern.findall(crew_path.read_text(errors="ignore")))
|
|
except OSError as exc:
|
|
logger.debug("Skipping unreadable crew file %s: %s", crew_path, exc)
|
|
|
|
for name in agent_names:
|
|
agent_path = find_json_project_file(agents_dir, name)
|
|
if agent_path is None:
|
|
continue
|
|
try:
|
|
referenced.update(
|
|
pattern.findall(agent_path.read_text(errors="ignore"))
|
|
)
|
|
except OSError as exc:
|
|
logger.debug("Skipping unreadable agent file %s: %s", agent_path, exc)
|
|
|
|
for py_path in self.project_root.rglob("*.py"):
|
|
if ".venv" in py_path.parts:
|
|
continue
|
|
try:
|
|
text = py_path.read_text(encoding="utf-8", errors="ignore")
|
|
except OSError:
|
|
continue
|
|
env_pattern = re.compile(
|
|
r"""(?x)
|
|
(?:os\.environ\s*(?:\[\s*|\.get\s*\(\s*)
|
|
|os\.getenv\s*\(\s*
|
|
|getenv\s*\(\s*)
|
|
['"]([A-Z][A-Z0-9_]*)['"]
|
|
"""
|
|
)
|
|
referenced.update(env_pattern.findall(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 = 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 project 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_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.utilities.project_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. Providers
|
|
# format the install command as plain text (`to install: uv add
|
|
# "crewai[extra]"`); also tolerate backtick-delimited variants.
|
|
m = re.search(
|
|
r"(?P<pkg>[A-Za-z0-9_ -]+?)\s+native provider not available"
|
|
r".*?to install:\s*`?(?P<cmd>uv add [\"']crewai\[[^\]]+\][\"'])`?",
|
|
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('cmd')}",
|
|
)
|
|
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)
|