feat(web): delete a template, not just back it up

POST /host/delete-template: same two-step confirm gate, CSRF check, and
known-template containment as /host/backup, offloaded to a thread the same
way (rmtree on a multi-GB template dir is not instant). No backup-required
safety rail -- same confirm-gate as every other destructive action, nothing
more; the confirm summary spells out that clones can no longer be made from
the template until one is restored.

Also cleaned up the per-backup Delete button it sits next to: name+meta
grouped into one shrinkable unit so the row wraps as a block instead of the
delete button drifting away from a name that wrapped, and each backup is
now its own bordered line instead of a run-on flex list.
This commit is contained in:
2026-07-25 19:33:36 +02:00
parent 7f2b03029a
commit a1fb620ca5
5 changed files with 280 additions and 14 deletions
+65
View File
@@ -22,7 +22,9 @@ from ci_orchestrator.operations.host import (
BackupResult,
DiskStatus,
RunnerStatus,
TemplateDeleteResult,
backup_template,
delete_template,
disk_status,
runner_status,
)
@@ -276,3 +278,66 @@ def test_backup_template_never_calls_real_vmrun_or_7z(monkeypatch: pytest.Monkey
backup_template(config, "WinBuild2025")
assert called == [True]
# ── delete_template ─────────────────────────────────────────────────────────
def test_delete_template_missing_template_dir_errors(tmp_path: Path) -> None:
templates = tmp_path / "templates"
templates.mkdir()
config = _make_config(templates)
result = delete_template(config, "NoSuchTemplate")
assert result == TemplateDeleteResult(
template="NoSuchTemplate", ok=False, error=result.error
)
assert result.error is not None
assert "NoSuchTemplate" in result.error or "not found" in result.error.lower()
def test_delete_template_dry_run_does_not_remove_the_directory(tmp_path: Path) -> None:
templates = tmp_path / "templates"
tmpl_dir = templates / "WinBuild2025"
tmpl_dir.mkdir(parents=True)
(tmpl_dir / "WinBuild2025.vmx").write_text("config.version = \"8\"")
config = _make_config(templates)
result = delete_template(config, "WinBuild2025", dry_run=True)
assert result == TemplateDeleteResult(template="WinBuild2025", ok=True, error=None)
assert tmpl_dir.is_dir()
def test_delete_template_removes_the_whole_directory(tmp_path: Path) -> None:
templates = tmp_path / "templates"
tmpl_dir = templates / "WinBuild2025"
tmpl_dir.mkdir(parents=True)
(tmpl_dir / "WinBuild2025.vmx").write_text("config.version = \"8\"")
config = _make_config(templates)
result = delete_template(config, "WinBuild2025")
assert result == TemplateDeleteResult(template="WinBuild2025", ok=True, error=None)
assert not tmpl_dir.exists()
def test_delete_template_removal_failure_reported(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
templates = tmp_path / "templates"
tmpl_dir = templates / "WinBuild2025"
tmpl_dir.mkdir(parents=True)
config = _make_config(templates)
def _boom(_path: Path) -> None:
raise OSError("device busy")
monkeypatch.setattr(shutil, "rmtree", _boom)
result = delete_template(config, "WinBuild2025")
assert result.ok is False
assert result.error is not None
assert tmpl_dir.is_dir()
+82
View File
@@ -918,6 +918,88 @@ def test_host_backup_execute_exception_never_500s(
assert entry.result == "error"
# ── POST /host/delete-template ─────────────────────────────────────────────
def test_host_delete_template_missing_template_field_is_400(tmp_path: Path) -> None:
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/host/delete-template", data={"csrf": csrf})
assert resp.status_code == 400
def test_host_delete_template_unknown_template_name_is_400_not_audited(tmp_path: Path) -> None:
"""Same B6 containment discipline as ``/host/backup``: an arbitrary name
is rejected before ``delete_template`` is ever called."""
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post(
"/host/delete-template", data={"csrf": csrf, "template": "NoSuchTemplate"}
)
assert resp.status_code == 400
assert not config.web.audit_log.exists()
def test_host_delete_template_preview_then_execute_full_cycle(tmp_path: Path) -> None:
template_dir = tmp_path / "templates" / "LinuxBuild2404"
template_dir.mkdir(parents=True)
(template_dir / "LinuxBuild2404.vmx").write_text("x", encoding="utf-8")
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
preview = client.post(
"/host/delete-template", data={"csrf": csrf, "template": "LinuxBuild2404"}
)
assert preview.status_code == 200
assert "Confirm" in preview.text
assert "permanently delete" in preview.text
assert _last_audit_entry(config.web.audit_log).result == "dry-run"
assert template_dir.is_dir() # nothing removed on the preview stage
execute = client.post(
"/host/delete-template",
data={"csrf": csrf, "stage": "execute", "template": "LinuxBuild2404"},
)
assert execute.status_code == 200
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "ok"
assert entry.action == "host.delete_template"
assert not template_dir.exists()
def test_host_delete_template_execute_exception_never_500s(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(*args: Any, **kwargs: Any) -> Any:
raise RuntimeError("device busy")
monkeypatch.setattr(routes_module, "delete_template", _boom)
(tmp_path / "templates" / "X").mkdir(parents=True)
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post(
"/host/delete-template", data={"csrf": csrf, "stage": "execute", "template": "X"}
)
assert resp.status_code == 200
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "error"
# ── web/security.py: direct unit coverage of the CSRF primitives ──────────
+17 -3
View File
@@ -65,10 +65,10 @@ def test_host_route_renders_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyP
resp = client.get("/host")
assert resp.status_code == 200
# Stage E: CPU%/RAM-used moved to the status-header tiles; the Host card
# keeps uptime, load average, core count and total memory.
# Stage F folded the retired tiles' CPU%/RAM readouts back into this card,
# alongside the hostname/kernel/CPU-model rows it gained at the same time.
assert "Host" in resp.text
assert "16.0" in resp.text # total memory GB
assert "16" in resp.text # total memory GB
def test_host_route_degrades_on_probe_failure(
@@ -289,6 +289,20 @@ def test_templates_partial_offers_per_row_backup_action(
assert "Backup" in resp.text
def test_templates_partial_offers_per_row_delete_action(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(app_module, "list_templates", lambda _config: [_TEMPLATE_ROW])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/templates")
assert resp.status_code == 200
assert 'action="/host/delete-template"' in resp.text
assert "Delete" in resp.text
def test_templates_partial_no_backup_action_when_no_templates(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: