diff --git a/src/ci_orchestrator/web/routes.py b/src/ci_orchestrator/web/routes.py index c5ac83a..344442a 100644 --- a/src/ci_orchestrator/web/routes.py +++ b/src/ci_orchestrator/web/routes.py @@ -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", diff --git a/src/ci_orchestrator/web/templates/partials/templates.html b/src/ci_orchestrator/web/templates/partials/templates.html index 4e470ec..72e186a 100644 --- a/src/ci_orchestrator/web/templates/partials/templates.html +++ b/src/ci_orchestrator/web/templates/partials/templates.html @@ -40,8 +40,10 @@
{{ b.name }}
- {{ b.size_bytes | humanbytes }} · {{ b.mtime | relative_time }}
+
+ {{ b.name }}
+ {{ b.size_bytes | humanbytes }} · {{ b.mtime | relative_time }}
+