diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index 0736a05..9aae5dc 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -3,7 +3,10 @@ from __future__ import annotations import json from datetime import datetime, timedelta, timezone -from quart import Blueprint, current_app, render_template, request +from quart import ( + Blueprint, current_app, jsonify, redirect, render_template, request, + session, url_for, +) from sqlalchemy import func, select from steward.auth.middleware import require_role @@ -20,6 +23,24 @@ from .models import ( docker_bp = Blueprint("docker", __name__, template_folder="templates") +def _error(status: int, code: str, detail: str | None = None): + body: dict = {"ok": False, "error": code} + if detail: + body["detail"] = detail + return jsonify(body), status + + +def _prune_extra_vars(prune_target: str) -> dict: + """Extra-vars for the bundled docker_prune playbook. "Prune unused images" + means ALL unused (docker image prune -a), not just dangling; the full system + prune stays conservative (-f, no -a) per the operator's choice (m78). Pure + helper so the mapping is unit-tested without the request/DB stack.""" + extra_vars: dict = {"prune_target": prune_target} + if prune_target == "images": + extra_vars["prune_all_images"] = True + return extra_vars + + def _human_bytes(n: int | None) -> str: """Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B').""" if n is None: @@ -499,9 +520,13 @@ async def swarm(): async def disk(): """Image/disk usage page: reclaimable space, per-image sizes, stopped count. - Read-only — prune actions are deferred to the cleanup-actions milestone, so - this surfaces the numbers and notes where reclaim lives. + Admins can reclaim space per host via the prune buttons, which fire the + audited bundled prune playbook through Ansible (m78) — the collection agent + itself stays read-only. """ + from steward.core.capabilities import has_capability + from steward.models.ansible_inventory import AnsibleTarget + async with current_app.db_sessionmaker() as db: summaries = list((await db.execute(select(DockerDiskUsage))).scalars()) images = list((await db.execute( @@ -516,6 +541,16 @@ async def disk(): for hid in stopped_rows: stopped_by_host[hid] = stopped_by_host.get(hid, 0) + 1 hosts = await _host_map(db, {s.host_id for s in summaries}) + # Which hosts have a linked Ansible target — gates the prune buttons + # (a target is required to route the playbook run to that host). + host_ids = {s.host_id for s in summaries} + target_by_host: dict[str, str] = {} + if host_ids: + for hid, tid in (await db.execute( + select(AnsibleTarget.host_id, AnsibleTarget.id) + .where(AnsibleTarget.host_id.in_(host_ids)) + )).all(): + target_by_host[hid] = tid images_by_host: dict[str, list] = {} for im in images: @@ -542,11 +577,60 @@ async def disk(): }, "stopped": stopped_by_host.get(s.host_id, 0), "images": images_by_host.get(s.host_id, []), + "has_target": s.host_id in target_by_host, } for s in summaries ] host_groups.sort(key=lambda g: g["host_name"].lower()) - return await render_template("docker/disk.html", host_groups=host_groups) + return await render_template( + "docker/disk.html", host_groups=host_groups, + ansible_available=has_capability("ansible.run_playbook"), + ) + + +@docker_bp.post("/disk//prune") +@require_role(UserRole.admin) +async def disk_prune(host_id: str): + """Reclaim Docker disk on a host by firing the bundled prune playbook via + Ansible (audited, admin-gated) — the collection agent stays read-only (m78). + `target` selects the scope: containers | images | system. + """ + from steward.core.capabilities import has_capability, invoke_capability + from steward.ansible.sources import BUILTIN_SOURCE_NAME + from steward.models.ansible_inventory import AnsibleTarget + + if not has_capability("ansible.run_playbook"): + return _error(400, "ansible_unavailable", "Ansible is not available") + + form = await request.form + prune_target = (form.get("target", "") or "").strip() + if prune_target not in ("containers", "images", "system"): + return _error(400, "bad_target", "Unknown prune target") + + async with current_app.db_sessionmaker() as db: + target = (await db.execute( + select(AnsibleTarget).where(AnsibleTarget.host_id == host_id) + )).scalar_one_or_none() + if target is None: + return _error(400, "no_target", + "Link an Ansible target to this host before pruning") + + # extra_vars_map outranks the playbook's own `vars:` defaults. + extra_vars = _prune_extra_vars(prune_target) + + actor_role = UserRole(session.get("user_role", "viewer")) + run, _source, err = await invoke_capability( + "ansible.run_playbook", actor_role, + current_app._get_current_object(), # type: ignore[attr-defined] + source_name=BUILTIN_SOURCE_NAME, + playbook_path="maintenance/docker_prune.yml", + inventory_scope=f"steward:target:{target.id}", + params={"extra_vars_map": extra_vars}, + triggered_by=session.get("user_id"), + ) + if err: + return _error(400, "prune_failed", err) + return redirect(url_for("ansible.run_detail", run_id=run.id)) @docker_bp.get("/container///history") diff --git a/plugins/docker/templates/docker/disk.html b/plugins/docker/templates/docker/disk.html index 22a0e16..988bd95 100644 --- a/plugins/docker/templates/docker/disk.html +++ b/plugins/docker/templates/docker/disk.html @@ -7,8 +7,8 @@

Image & disk usage

- Reclaimable = space held by images no container references. Cleanup actions - (prune) arrive in a later release — these are read-only figures for now. + Reclaimable = space held by images no container references. Admins can prune + per host below; each action runs an audited Ansible playbook on that host.

{% if host_groups %} @@ -47,6 +47,43 @@ + {# ── Cleanup actions (admin only; audited Ansible prune run) ───────────── #} + {% if session.user_role == 'admin' %} +
+ {% if ansible_available and g.has_target %} +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Reclaimed space appears on the next agent sample. +
+ {% elif not ansible_available %} +
Cleanup needs the Ansible runner (currently unavailable).
+ {% else %} +
+ Link an Ansible target to this host to enable prune actions. +
+ {% endif %} +
+ {% endif %} + {# ── Per-image table ──────────────────────────────────────────────────── #}
diff --git a/tests/plugins/docker/test_routes.py b/tests/plugins/docker/test_routes.py index d74776b..baa2705 100644 --- a/tests/plugins/docker/test_routes.py +++ b/tests/plugins/docker/test_routes.py @@ -30,6 +30,22 @@ def test_routes_module_exposes_new_views(): assert r.docker_bp.name == "docker" +def test_disk_prune_view_defined(): + # M78 admin-gated prune action. + assert callable(r.disk_prune) + + +def test_prune_extra_vars_mapping(): + """The prune buttons drive one playbook via prune_target; only 'images' + widens to ALL unused images (docker image prune -a), system stays -f.""" + assert r._prune_extra_vars("containers") == {"prune_target": "containers"} + assert r._prune_extra_vars("images") == { + "prune_target": "images", "prune_all_images": True, + } + # System prune stays conservative — no prune_all_images key. + assert r._prune_extra_vars("system") == {"prune_target": "system"} + + def test_human_bytes_formats_binary_units(): assert r._human_bytes(None) == "—" assert r._human_bytes(0) == "0 B"