diff --git a/src/meridian/cli.py b/src/meridian/cli.py index 41eeec8..6d28d5f 100644 --- a/src/meridian/cli.py +++ b/src/meridian/cli.py @@ -62,6 +62,20 @@ def _report_validation_failure(exc: SuiteValidationError) -> None: err(f" {'':<{width}} └─ {issue.message}") +def _write_drop_reasons(path: Path, reasons: dict[str, str]) -> None: + """Leave each build failure beside the task it removed, not only on the terminal.""" + sections = [ + "# Dropped tasks", + "", + "These tasks failed to build and were moved out of the suite by " + "`meridian suite build-images --drop-failed`. The tail of each build log:", + "", + ] + for slug in sorted(reasons): + sections += [f"## {slug}", "", "```text", reasons[slug].rstrip(), "```", ""] + path.write_text("\n".join(sections), encoding="utf-8") + + @app.command() def version() -> None: """Print the harness version that will be written into run manifests.""" @@ -107,29 +121,28 @@ def suite_build_images( err(f"cannot reach Docker: {exc}") raise typer.Exit(exit_codes.HARNESS_ERROR) from exc - failures = 0 - failed_slugs: list[str] = [] + failures: dict[str, str] = {} for task in todo: try: digest = build_snapshot( client, root / "images" / task.slug, tag=f"meridian-{suite.slug}-{task.slug}:dev" ) except SnapshotBuildError as exc: - failures += 1 - failed_slugs.append(task.slug) + failures[task.slug] = str(exc) err(f" FAILED {task.slug}: {exc}") continue rewrite_task_snapshot(root / "tasks" / f"{task.slug}.yaml", digest) out(f"{task.slug} {digest}") - err(f"built {len(todo) - failures} of {len(todo)} image(s)") - if drop_failed and failed_slugs: + err(f"built {len(todo) - len(failures)} of {len(todo)} image(s)") + if drop_failed and failures: dropped = root / "dropped" dropped.mkdir(exist_ok=True) - for slug in failed_slugs: + for slug in failures: (root / "tasks" / f"{slug}.yaml").rename(dropped / f"{slug}.yaml") - names = ", ".join(failed_slugs) - out(f"dropped {len(failed_slugs)} task(s) whose image did not build: {names}") - if failures == len(todo): + _write_drop_reasons(dropped / "REASONS.md", failures) + names = ", ".join(sorted(failures)) + out(f"dropped {len(failures)} task(s) whose image did not build: {names}") + if len(failures) == len(todo): raise typer.Exit(exit_codes.HARNESS_ERROR) return if failures: diff --git a/tests/unit/test_cli_build_images.py b/tests/unit/test_cli_build_images.py new file mode 100644 index 0000000..59c8763 --- /dev/null +++ b/tests/unit/test_cli_build_images.py @@ -0,0 +1,50 @@ +"""`suite build-images` keeps the reason a task was dropped, not just the warning.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from meridian import cli +from meridian.cli import app +from meridian.snapshots.build import SnapshotBuildError + +pytestmark = pytest.mark.unit + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "invalid-suites" + + +def _suite_with_one_image(tmp_path: Path) -> Path: + suite = tmp_path / "suite" + shutil.copytree(FIXTURES / "_valid-control", suite) + tasks = suite / "tasks" + (tasks / "valid-task.yaml").write_bytes((tasks / "valid-task-0.yaml").read_bytes()) + (tasks / "valid-task-0.yaml").unlink() + (suite / "images" / "valid-task").mkdir(parents=True) + (suite / "images" / "valid-task" / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + return suite + + +def test_a_dropped_task_leaves_its_reason_in_the_suite( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + suite = _suite_with_one_image(tmp_path) + monkeypatch.setattr(cli, "get_client", lambda: object()) + + def fail_build(*args: object, **kwargs: object) -> str: + raise SnapshotBuildError( + 'failed to solve: process "/bin/sh -c apk add git" did not complete' + ) + + monkeypatch.setattr(cli, "build_snapshot", fail_build) + + result = CliRunner().invoke(app, ["suite", "build-images", str(suite), "--drop-failed"]) + + assert result.exit_code == 2 # the suite's only task failed + reasons = (suite / "dropped" / "REASONS.md").read_text(encoding="utf-8") + assert "valid-task" in reasons + assert "failed to solve" in reasons + assert not (suite / "tasks" / "valid-task.yaml").exists()