Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions src/meridian/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/test_cli_build_images.py
Original file line number Diff line number Diff line change
@@ -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()
Loading