Files
crewAI/lib/cli/tests/deploy/test_archive.py
João Moura 5990ead5ec feat(cli): record why a deployment create failed (#7451)
* feat(cli): record why a deployment create failed

`crewai deploy create` counts every attempt (`Create Crew Deployment`) and every
success (`Crew Deployment Created`), but the gap between them carried no cause:
among clients able to emit the success span, the CLI succeeds 96.7% of the time
and the run TUI 36.4%, and nothing said why. A third span, `Crew Deployment
Failed`, now fires for every failure after the attempt is counted, with a closed
vocabulary `reason` (api_4xx, api_5xx, invalid_response, network_error,
zip_error, user_declined, unexpected), the HTTP `status_code` when the API
answered, and the existing `source`. Never the error message.

The request path is factored into `_request_crew_creation`; every exception is
classified, reported and re-raised unchanged, so CLI and TUI behaviour is the
same as before. HTTP failures are classified before `_validate_response`, which
still prints and exits as it did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(cli): classify deploy create failures by status and by stage

Review fixes on the failure span. Check the HTTP class before the body so a
gateway's HTML page counts as api_4xx / api_5xx with its code. Treat a 2xx
whose body is not a JSON object carrying uuid and status as invalid_response
and exit cleanly, instead of emitting a success span and crashing in the
display step. Recognise archive failures by a dedicated ArchiveError
(a ValueError) raised from create_project_zip, so the git helpers' own
ValueErrors no longer read as zip_error; a failed ZIP write is wrapped and
its partial file removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(cli): treat staging and temp-file failures as archive errors

`create_project_zip` only wrapped the ZIP write, so an `OSError` while
staging files or creating the temporary archive escaped as a bare
`OSError` and the deploy command recorded it as `unexpected` instead of
`zip_error`. The archive boundary now covers staging, temp-file creation
and the write; the staging directory is removed on every path, and no
partial archive is left behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(cli): tell a non-JSON 2xx apart from a non-creation 2xx

A proxy's 200 HTML page and a JSON body missing the creation fields were
both recorded as `invalid_response`. They are different failures, one in
the network path and one in the API contract, so the deploy failure span
now records `invalid_json` for the first and `invalid_creation_response`
for the second. Requested in review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-16 23:37:14 +05:30

366 lines
11 KiB
Python

from pathlib import Path
import subprocess
import tempfile
import zipfile
import pytest
from crewai_cli.deploy.archive import ArchiveError, create_project_zip
def test_create_project_zip_excludes_local_artifacts(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.py").write_text("print('hello')\n")
(tmp_path / ".env").write_text("OPENAI_API_KEY=secret\n")
(tmp_path / ".env.example").write_text("OPENAI_API_KEY=\n")
(tmp_path / "__pycache__").mkdir()
(tmp_path / "__pycache__" / "main.pyc").write_bytes(b"compiled")
(tmp_path / ".git").mkdir()
(tmp_path / ".git" / "config").write_text("[core]\n")
archive_path = create_project_zip("demo", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {
"pyproject.toml",
"uv.lock",
"src/main.py",
".env.example",
}
def test_create_project_zip_uses_repository_file_list(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
(tmp_path / "ignored.txt").write_text("ignored\n")
class RepositoryStub:
def deployable_files(self) -> list[str]:
return ["pyproject.toml", "uv.lock"]
archive_path = create_project_zip(
"demo",
project_dir=tmp_path,
repository=RepositoryStub(), # type: ignore[arg-type]
)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {"pyproject.toml", "uv.lock"}
def test_create_project_zip_without_repository_uses_git_ignore_rules(
tmp_path: Path,
):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / ".gitignore").write_text("node_modules/\nsecret.txt\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.py").write_text("print('hello')\n")
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / "package.json").write_text("{}\n")
(tmp_path / "secret.txt").write_text("secret\n")
try:
subprocess.run(
["git", "init"],
cwd=tmp_path,
capture_output=True,
check=True,
text=True,
)
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
pytest.skip(f"git is not available in this environment: {exc}")
archive_path = create_project_zip("demo", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {
".gitignore",
"pyproject.toml",
"src/main.py",
}
def test_create_project_zip_does_not_fallback_when_repository_listing_fails(
tmp_path: Path,
):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
class RepositoryStub:
def deployable_files(self) -> list[str]:
raise RuntimeError("git listing failed")
with pytest.raises(RuntimeError, match="git listing failed"):
create_project_zip(
"demo",
project_dir=tmp_path,
repository=RepositoryStub(), # type: ignore[arg-type]
)
def test_create_project_zip_excludes_symlinked_files(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
outside_file = tmp_path.parent / f"{tmp_path.name}-secret.txt"
outside_file.write_text("secret\n")
archive_path: Path | None = None
try:
try:
(tmp_path / "external-secret.txt").symlink_to(outside_file)
except OSError as exc:
pytest.skip(f"symlinks are not supported in this environment: {exc}")
archive_path = create_project_zip("demo", project_dir=tmp_path)
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
if archive_path is not None:
archive_path.unlink(missing_ok=True)
outside_file.unlink(missing_ok=True)
assert names == {"pyproject.toml"}
def test_create_project_zip_preserves_json_project_shape(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
version = "0.1.0"
dependencies = ["crewai[tools]>=1.15"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
+ "\n"
)
(tmp_path / "agents").mkdir()
(tmp_path / "agents" / "researcher.jsonc").write_text("{}\n")
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert "uv.lock" not in names
assert "crew.jsonc" in names
assert "agents/researcher.jsonc" in names
assert all(not name.startswith("src/") for name in names)
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
def test_create_project_zip_keeps_json_project_root_shape(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
version = "0.1.0"
dependencies = ["crewai[tools]>=1.15.0,<2.0.0"]
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
+ "\n"
)
(tmp_path / "uv.lock").write_text("# lock\n")
(tmp_path / "agents").mkdir()
(tmp_path / "agents" / "foo.jsonc").write_text("{}\n")
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert names == {
"agents/foo.jsonc",
"crew.jsonc",
"pyproject.toml",
"uv.lock",
}
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
def test_create_project_zip_does_not_rewrite_json_project_scripts(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
version = "0.1.0"
[project.scripts]
json_crew = "old.module:run"
run_crew = "old.module:run"
custom = "custom.module:main"
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
+ "\n"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert 'json_crew = "old.module:run"' in pyproject
assert 'run_crew = "old.module:run"' in pyproject
assert 'custom = "custom.module:main"' in pyproject
assert pyproject.count("[project.scripts]") == 1
assert "[tool.crewai]" in pyproject
@pytest.mark.parametrize(
"tool_config",
[
'tool = "invalid"\n',
'[tool]\ncrewai = "invalid"\n',
],
)
def test_create_project_zip_preserves_json_project_with_malformed_tool_config(
tmp_path: Path, tool_config: str
):
(tmp_path / "pyproject.toml").write_text(
f"""
[project]
name = "json_crew"
version = "0.1.0"
{tool_config}
""".strip()
+ "\n"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert names == {"crew.jsonc", "pyproject.toml"}
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
def test_create_project_zip_accepts_json_project_without_package_name(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "!!!"
version = "0.1.0"
[tool.crewai]
type = "crew"
""".strip()
+ "\n"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("invalid", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert names == {"crew.jsonc", "pyproject.toml"}
assert "run_crew" not in pyproject
assert "json_crew =" not in pyproject
assert "[project.scripts]" not in pyproject
def test_create_project_zip_with_nothing_to_deploy_raises_archive_error(
tmp_path: Path,
):
"""Still a ValueError for existing callers, and a distinct type for the deploy command."""
with pytest.raises(ArchiveError, match="No deployable project files were found"):
create_project_zip("demo", project_dir=tmp_path)
assert issubclass(ArchiveError, ValueError)
def test_create_project_zip_wraps_a_write_failure_and_removes_the_partial_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
created: list[Path] = []
def failing_zipfile(path, *args, **kwargs):
created.append(Path(path))
raise OSError("disk full")
monkeypatch.setattr("crewai_cli.deploy.archive.zipfile.ZipFile", failing_zipfile)
with pytest.raises(ArchiveError, match="Could not build the project ZIP: disk full"):
create_project_zip("demo", project_dir=tmp_path)
assert created and not created[0].exists()
@pytest.mark.parametrize(
"failing_step",
[
"crewai_cli.deploy.archive.shutil.copy2",
"crewai_cli.deploy.archive.tempfile.NamedTemporaryFile",
],
)
def test_create_project_zip_wraps_staging_and_temp_file_failures_and_cleans_up(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failing_step: str
):
"""A full disk while staging or creating the archive is an archive failure too."""
project = tmp_path / "project"
project.mkdir()
(project / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
scratch = tmp_path / "scratch"
scratch.mkdir()
monkeypatch.setattr(tempfile, "tempdir", str(scratch))
def fail(*args, **kwargs):
raise OSError("disk full")
monkeypatch.setattr(failing_step, fail)
with pytest.raises(ArchiveError, match="Could not build the project ZIP: disk full"):
create_project_zip("demo", project_dir=project)
assert list(scratch.iterdir()) == []