fix(cli): validate before deploy lock install

This commit is contained in:
Joao Moura
2026-06-15 11:53:51 -07:00
parent 664b3a0174
commit 99face4c17
3 changed files with 147 additions and 16 deletions

View File

@@ -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")

View File

@@ -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")

View File

@@ -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