diff --git a/lib/cli/src/crewai_cli/deploy/main.py b/lib/cli/src/crewai_cli/deploy/main.py index 820296b24..1049752f0 100644 --- a/lib/cli/src/crewai_cli/deploy/main.py +++ b/lib/cli/src/crewai_cli/deploy/main.py @@ -8,14 +8,18 @@ from rich.console import Console from crewai_cli import git from crewai_cli.command import BaseCommand, PlusAPIMixin from crewai_cli.deploy.archive import create_project_zip -from crewai_cli.deploy.validate import validate_project +from crewai_cli.deploy.validate import DeployValidator, Severity, render_report from crewai_cli.utils import fetch_and_json_env_file, get_project_name console = Console() +_MISSING_LOCKFILE_ERROR_CODES = {"missing_lockfile"} -def _run_predeploy_validation(skip_validate: bool) -> bool: +def _run_predeploy_validation( + skip_validate: bool, + ignored_error_codes: set[str] | None = None, +) -> bool: """Run pre-deploy validation unless skipped. Returns True if deployment should proceed, False if it should abort. @@ -27,8 +31,22 @@ def _run_predeploy_validation(skip_validate: bool) -> bool: return True console.print("Running pre-deploy validation...", style="bold blue") - validator = validate_project() - if not validator.ok: + validator = DeployValidator() + validator.run() + + ignored_error_codes = ignored_error_codes or set() + visible_results = [ + result + for result in validator.results + if result.severity is not Severity.ERROR + or result.code not in ignored_error_codes + ] + render_report(visible_results) + + blocking_errors = [ + result for result in validator.errors if result.code not in ignored_error_codes + ] + if blocking_errors: console.print( "\n[bold red]Pre-deploy validation failed. " "Fix the issues above or re-run with --skip-validate.[/bold red]" @@ -61,12 +79,17 @@ def _env_summary(env_vars: dict[str, str]) -> str: return f"{len(env_vars)} env vars: {keys}" +def _needs_lockfile_for_deploy(project_root: Path | None = None) -> bool: + """Return True when deploy should create the project's first lockfile.""" + root = project_root or Path.cwd() + if not (root / "pyproject.toml").is_file(): + return False + return not (root / "uv.lock").is_file() and not (root / "poetry.lock").is_file() + + def _ensure_lockfile_for_deploy() -> None: """Create a uv lockfile before deploy when a project has not been run yet.""" - project_root = Path.cwd() - if not (project_root / "pyproject.toml").is_file(): - return - if (project_root / "uv.lock").is_file() or (project_root / "poetry.lock").is_file(): + if not _needs_lockfile_for_deploy(): return from crewai_cli.install_crew import install_crew @@ -83,6 +106,28 @@ def _ensure_lockfile_for_deploy() -> None: raise SystemExit(1) from e +def _prepare_project_for_deploy(skip_validate: bool) -> bool: + """Validate deploy inputs before creating a missing lockfile.""" + if skip_validate: + _run_predeploy_validation(skip_validate) + _ensure_lockfile_for_deploy() + return True + + needs_lockfile = _needs_lockfile_for_deploy() + ignored_error_codes = _MISSING_LOCKFILE_ERROR_CODES if needs_lockfile else None + if not _run_predeploy_validation( + skip_validate, + ignored_error_codes=ignored_error_codes, + ): + return False + + if not needs_lockfile: + return True + + _ensure_lockfile_for_deploy() + return _run_predeploy_validation(skip_validate) + + class DeployCommand(BaseCommand, PlusAPIMixin): """ A class to handle deployment-related operations for CrewAI projects. @@ -141,8 +186,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin): uuid (Optional[str]): The UUID of the crew to deploy. skip_validate (bool): Skip pre-deploy validation checks. """ - _ensure_lockfile_for_deploy() - if not _run_predeploy_validation(skip_validate): + if not _prepare_project_for_deploy(skip_validate): return self._telemetry.start_deployment_span(uuid) console.print("Starting deployment...", style="bold blue") @@ -194,8 +238,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin): confirm (bool): Whether to skip the interactive confirmation prompt. skip_validate (bool): Skip pre-deploy validation checks. """ - _ensure_lockfile_for_deploy() - if not _run_predeploy_validation(skip_validate): + if not _prepare_project_for_deploy(skip_validate): return self._telemetry.create_crew_deployment_span() console.print("Creating deployment...", style="bold blue") diff --git a/lib/cli/tests/deploy/test_deploy_main.py b/lib/cli/tests/deploy/test_deploy_main.py index 85c2cef0a..7d91c77f3 100644 --- a/lib/cli/tests/deploy/test_deploy_main.py +++ b/lib/cli/tests/deploy/test_deploy_main.py @@ -10,6 +10,7 @@ import json import crewai_cli.deploy.main as deploy_main import httpx +from crewai_cli.deploy.validate import Severity, ValidationResult from crewai_cli.utils import parse_toml @@ -79,6 +80,93 @@ def test_ensure_lockfile_for_deploy_failure_exits_nonzero( assert exc_info.value.code == 42 +class _FakeDeployValidator: + def __init__(self, results: list[ValidationResult]): + self.results = results + + @property + def errors(self) -> list[ValidationResult]: + return [ + result + for result in self.results + if result.severity is Severity.ERROR + ] + + def run(self) -> list[ValidationResult]: + return self.results + + +def test_prepare_project_for_deploy_blocks_install_when_validation_fails( + monkeypatch, tmp_path: Path +): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n") + install_calls = [] + rendered_results = [] + missing_lockfile = ValidationResult( + Severity.ERROR, + "missing_lockfile", + "Expected to find a lockfile", + ) + invalid_config = ValidationResult( + Severity.ERROR, + "invalid_crew_json", + "crew.jsonc has invalid configuration", + ) + + monkeypatch.setattr( + deploy_main, + "DeployValidator", + lambda: _FakeDeployValidator([missing_lockfile, invalid_config]), + ) + monkeypatch.setattr(deploy_main, "render_report", rendered_results.append) + + def fake_install_crew(proxy_options, *, raise_on_error=False): + install_calls.append((proxy_options, raise_on_error)) + + monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew) + + assert deploy_main._prepare_project_for_deploy(skip_validate=False) is False + + assert install_calls == [] + assert [[result.code for result in results] for results in rendered_results] == [ + ["invalid_crew_json"] + ] + + +def test_prepare_project_for_deploy_creates_missing_lock_after_validation( + monkeypatch, tmp_path: Path +): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n") + install_calls = [] + missing_lockfile = ValidationResult( + Severity.ERROR, + "missing_lockfile", + "Expected to find a lockfile", + ) + validators = [ + _FakeDeployValidator([missing_lockfile]), + _FakeDeployValidator([]), + ] + + def fake_validator(): + return validators.pop(0) + + def fake_install_crew(proxy_options, *, raise_on_error=False): + install_calls.append((proxy_options, raise_on_error)) + (tmp_path / "uv.lock").write_text("# lock\n") + + monkeypatch.setattr(deploy_main, "DeployValidator", fake_validator) + monkeypatch.setattr(deploy_main, "render_report", lambda results: None) + monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew) + + assert deploy_main._prepare_project_for_deploy(skip_validate=False) is True + + assert install_calls == [([], True)] + assert validators == [] + + class TestDeployCommand(unittest.TestCase): @patch("crewai_cli.command.get_auth_token") @patch("crewai_cli.deploy.main.get_project_name") diff --git a/lib/cli/tests/deploy/test_validate.py b/lib/cli/tests/deploy/test_validate.py index 64c1ffbcc..07ceef14f 100644 --- a/lib/cli/tests/deploy/test_validate.py +++ b/lib/cli/tests/deploy/test_validate.py @@ -481,7 +481,7 @@ def test_modern_crewai_pin_does_not_warn(tmp_path: Path) -> None: 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 unittest.mock import patch as mock_patch from crewai_cli.deploy.main import DeployCommand @@ -490,10 +490,10 @@ def test_create_crew_aborts_on_validation_error(tmp_path: Path) -> None: 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, + "crewai_cli.deploy.main._prepare_project_for_deploy", + return_value=False, + ), ): - mock_validate.return_value = MagicMock(ok=False) cmd = DeployCommand() cmd.create_crew() assert not cmd.plus_api_client.create_crew.called