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>
This commit is contained in:
Joao Moura
2026-09-14 09:00:18 -07:00
parent 39e2cacb2f
commit a722f105d4
2 changed files with 62 additions and 19 deletions

View File

@@ -51,33 +51,25 @@ def create_project_zip(
project_dir: Path | None = None,
repository: git.Repository | None = None,
) -> Path:
"""Create a deployable ZIP archive for a CrewAI project."""
"""Create a deployable ZIP archive for a CrewAI project.
Raises:
ArchiveError: Nothing deployable was found, or the project could not be
staged or written to the archive. No partial archive is left behind.
"""
root = (project_dir or Path.cwd()).resolve()
files = _project_files(root, repository)
if not files:
raise ArchiveError("No deployable project files were found.")
staged_root = _stage_project(root, files)
archive_handle = tempfile.NamedTemporaryFile(
prefix=f"{project_name}-",
suffix=".zip",
delete=False,
)
archive_path = Path(archive_handle.name)
archive_handle.close()
try:
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
for relative_path in _walk_files(staged_root):
absolute_path = staged_root / relative_path
zip_file.write(absolute_path, relative_path.as_posix())
staged_root = _stage_project(root, files)
try:
return _write_archive(project_name, staged_root)
finally:
shutil.rmtree(staged_root, ignore_errors=True)
except (OSError, zipfile.BadZipFile) as exc:
archive_path.unlink(missing_ok=True)
raise ArchiveError(f"Could not build the project ZIP: {exc}") from exc
finally:
shutil.rmtree(staged_root, ignore_errors=True)
return archive_path
def _project_files(root: Path, repository: git.Repository | None = None) -> list[Path]:
@@ -153,3 +145,24 @@ def _stage_project(root: Path, files: list[Path]) -> Path:
shutil.rmtree(staging_root, ignore_errors=True)
raise
return staging_root
def _write_archive(project_name: str, staged_root: Path) -> Path:
"""Zip the staged tree into a new temporary file, removing it if the write fails."""
archive_handle = tempfile.NamedTemporaryFile(
prefix=f"{project_name}-",
suffix=".zip",
delete=False,
)
archive_path = Path(archive_handle.name)
archive_handle.close()
try:
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
for relative_path in _walk_files(staged_root):
absolute_path = staged_root / relative_path
zip_file.write(absolute_path, relative_path.as_posix())
except (OSError, zipfile.BadZipFile):
archive_path.unlink(missing_ok=True)
raise
return archive_path

View File

@@ -1,5 +1,6 @@
from pathlib import Path
import subprocess
import tempfile
import zipfile
import pytest
@@ -333,3 +334,32 @@ def test_create_project_zip_wraps_a_write_failure_and_removes_the_partial_file(
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()) == []