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
+94 -1
View File
@@ -48,7 +48,7 @@ from starlette import status
from ci_orchestrator.operations.audit import AuditEntry, append_audit, read_audit
from ci_orchestrator.operations.cache import purge_cargo_cache
from ci_orchestrator.operations.host import backup_template
from ci_orchestrator.operations.host import backup_template, delete_template
from ci_orchestrator.operations.inventory import delete_artifact, delete_backup, list_templates
from ci_orchestrator.operations.services import ALLOWED_ACTIONS, control_service, find_service
from ci_orchestrator.operations.vms import RemovalResult, cleanup_orphans, remove_vm
@@ -715,6 +715,92 @@ async def host_backup(
)
# ─────────────────────────────────────────────────────── POST /host/delete-template
async def host_delete_template(
request: Request,
user: str = Depends(require_user),
_csrf: None = Depends(require_csrf),
) -> HTMLResponse:
"""Permanently remove a template directory, behind the confirm gate.
Same containment discipline as :func:`host_backup`: the submitted name
must be one of the templates this host actually discovers. No backup is
required first -- unlike a backup or an artifact, there is no built-in
undo here beyond restoring one manually, so the confirm summary spells
that out.
"""
config: Config = request.app.state.config
action_label = "Delete template"
form = await request.form()
template = _form_str(form, "template")
if not template:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="template is required")
known_templates = {t.name for t in list_templates(config)}
if template not in known_templates:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="template is not a recognized template name",
)
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
try:
# rmtree on a multi-GB template dir is not instant; offloaded like
# host_backup's compression step so a slow removal never blocks the
# event loop (every other request, all the dashboard polls, would
# otherwise freeze for the duration).
result = await asyncio.to_thread(delete_template, config, template, dry_run=not execute)
except Exception as exc: # never 500 on an operational failure
detail = f"error: {exc}"
_audit(
config, user=user, action="host.delete_template", target=template, result="error", detail=detail
)
return _result_partial(request, action_label=action_label, ok=False, detail=detail)
if execute:
detail = f"deleted template '{template}'" if result.ok else (result.error or "unknown error")
_audit(
config,
user=user,
action="host.delete_template",
target=template,
result="ok" if result.ok else "error",
detail=detail,
)
return _result_partial(request, action_label=action_label, ok=result.ok, detail=detail)
if not result.ok:
detail = result.error or "unknown error"
_audit(
config,
user=user,
action="host.delete_template",
target=template,
result="error",
detail=detail,
)
return _result_partial(request, action_label=action_label, ok=False, detail=detail)
detail = (
f"would permanently delete the template directory for '{template}' -- "
"clones can no longer be made from it until restored from a backup"
)
_audit(
config, user=user, action="host.delete_template", target=template, result="dry-run", detail=detail
)
return _confirm_partial(
request,
action_label=action_label,
target=template,
summary=detail,
action_url="/host/delete-template",
extra_fields={"template": template},
)
# ───────────────────────────────────────────────────── POST /services/{id}/{action}
#: The ``ManagedService.id`` whose stop/restart confirm step warns about
@@ -843,6 +929,12 @@ def register_action_routes(app: FastAPI) -> None:
response_class=HTMLResponse,
)
app.add_api_route("/host/backup", host_backup, methods=["POST"], response_class=HTMLResponse)
app.add_api_route(
"/host/delete-template",
host_delete_template,
methods=["POST"],
response_class=HTMLResponse,
)
app.add_api_route(
"/services/{service_id}/{action}",
services_control,
@@ -860,6 +952,7 @@ __all__ = [
"encode_artifact_id",
"encode_vmx_id",
"host_backup",
"host_delete_template",
"register_action_routes",
"services_control",
"vms_cleanup",
@@ -40,8 +40,10 @@
<ul class="backups-list">
{% for b in bks %}
<li>
<code title="{{ b.name }}">{{ b.name }}</code>
<span class="text-dim">{{ b.size_bytes | humanbytes }} · {{ b.mtime | relative_time }}</span>
<span class="backup-info">
<code title="{{ b.name }}">{{ b.name }}</code>
<span class="text-dim">{{ b.size_bytes | humanbytes }} · {{ b.mtime | relative_time }}</span>
</span>
<form method="post" action="/backups/{{ b.name | artifact_id }}/delete" class="backup-del"
aria-label="Delete backup {{ b.name }}"
hx-post="/backups/{{ b.name | artifact_id }}/delete" hx-target="#action-slot" hx-swap="innerHTML">
@@ -59,14 +61,24 @@
{% endif %}
</td>
<td>
<form method="post" action="/host/backup" aria-label="Backup template {{ tmpl.name }}"
hx-post="/host/backup" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="template" value="{{ tmpl.name }}">
<button type="submit" class="btn btn-sm" aria-label="Backup template {{ tmpl.name }}">
<svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>Backup
</button>
</form>
<div class="actions-row">
<form method="post" action="/host/backup" aria-label="Backup template {{ tmpl.name }}"
hx-post="/host/backup" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="template" value="{{ tmpl.name }}">
<button type="submit" class="btn btn-sm" aria-label="Backup template {{ tmpl.name }}">
<svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>Backup
</button>
</form>
<form method="post" action="/host/delete-template" aria-label="Delete template {{ tmpl.name }}"
hx-post="/host/delete-template" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="template" value="{{ tmpl.name }}">
<button type="submit" class="btn btn-danger btn-sm" aria-label="Delete template {{ tmpl.name }}">
<svg class="icon" aria-hidden="true"><use href="#i-trash"></use></svg>Delete
</button>
</form>
</div>
</td>
</tr>
{% endfor %}