feat(docker): admin-gated per-host prune buttons on the image/disk page
M78 step 2. The /disk page surfaced reclaimable space + stopped counts but
said "prune deferred to a later milestone" — this wires the cleanup.
Adds POST /plugins/docker/disk/<host_id>/prune (admin only) that resolves
the host's linked AnsibleTarget → steward:target:<id> scope and fires the
bundled maintenance/docker_prune.yml through the ansible.run_playbook
capability (audited runner; the collection agent stays read-only). Mirrors
the host_agent update route. `target` ∈ {containers, images, system}:
- containers → docker container prune -f
- images → docker image prune -af (prune_all_images)
- system → docker system prune -f (conservative)
The disk() route now resolves each host group's linked target + capability
availability; disk.html renders three confirm-gated buttons per host, shown
only to admins when Ansible is available and the host has a linked target,
otherwise a clear "link a target" / "runner unavailable" hint (rules 24/26/27).
Tests: the prune_target→extra_vars mapping (the -af-only-for-images rule) is
extracted to a pure helper and unit-tested; disk_prune added to the module
smoke; disk.html covered by the existing template-parse test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C
This commit is contained in:
@@ -3,7 +3,10 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
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 sqlalchemy import func, select
|
||||||
|
|
||||||
from steward.auth.middleware import require_role
|
from steward.auth.middleware import require_role
|
||||||
@@ -20,6 +23,24 @@ from .models import (
|
|||||||
docker_bp = Blueprint("docker", __name__, template_folder="templates")
|
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:
|
def _human_bytes(n: int | None) -> str:
|
||||||
"""Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B')."""
|
"""Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B')."""
|
||||||
if n is None:
|
if n is None:
|
||||||
@@ -499,9 +520,13 @@ async def swarm():
|
|||||||
async def disk():
|
async def disk():
|
||||||
"""Image/disk usage page: reclaimable space, per-image sizes, stopped count.
|
"""Image/disk usage page: reclaimable space, per-image sizes, stopped count.
|
||||||
|
|
||||||
Read-only — prune actions are deferred to the cleanup-actions milestone, so
|
Admins can reclaim space per host via the prune buttons, which fire the
|
||||||
this surfaces the numbers and notes where reclaim lives.
|
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:
|
async with current_app.db_sessionmaker() as db:
|
||||||
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
|
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
|
||||||
images = list((await db.execute(
|
images = list((await db.execute(
|
||||||
@@ -516,6 +541,16 @@ async def disk():
|
|||||||
for hid in stopped_rows:
|
for hid in stopped_rows:
|
||||||
stopped_by_host[hid] = stopped_by_host.get(hid, 0) + 1
|
stopped_by_host[hid] = stopped_by_host.get(hid, 0) + 1
|
||||||
hosts = await _host_map(db, {s.host_id for s in summaries})
|
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] = {}
|
images_by_host: dict[str, list] = {}
|
||||||
for im in images:
|
for im in images:
|
||||||
@@ -542,11 +577,60 @@ async def disk():
|
|||||||
},
|
},
|
||||||
"stopped": stopped_by_host.get(s.host_id, 0),
|
"stopped": stopped_by_host.get(s.host_id, 0),
|
||||||
"images": images_by_host.get(s.host_id, []),
|
"images": images_by_host.get(s.host_id, []),
|
||||||
|
"has_target": s.host_id in target_by_host,
|
||||||
}
|
}
|
||||||
for s in summaries
|
for s in summaries
|
||||||
]
|
]
|
||||||
host_groups.sort(key=lambda g: g["host_name"].lower())
|
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/<host_id>/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/<host_id>/<name>/history")
|
@docker_bp.get("/container/<host_id>/<name>/history")
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
<h1 class="page-title" style="margin-bottom:0.4rem;">Image & disk usage</h1>
|
<h1 class="page-title" style="margin-bottom:0.4rem;">Image & disk usage</h1>
|
||||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
||||||
Reclaimable = space held by images no container references. Cleanup actions
|
Reclaimable = space held by images no container references. Admins can prune
|
||||||
(prune) arrive in a later release — these are read-only figures for now.
|
per host below; each action runs an audited Ansible playbook on that host.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if host_groups %}
|
{% if host_groups %}
|
||||||
@@ -47,6 +47,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Cleanup actions (admin only; audited Ansible prune run) ───────────── #}
|
||||||
|
{% if session.user_role == 'admin' %}
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
{% if ansible_available and g.has_target %}
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;">
|
||||||
|
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||||
|
data-msg="Remove all STOPPED containers on {{ g.host_name|e }}? This cannot be undone."
|
||||||
|
onsubmit="return confirm(this.dataset.msg);">
|
||||||
|
<input type="hidden" name="target" value="containers">
|
||||||
|
<button type="submit" class="btn btn-sm">Prune stopped containers</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||||
|
data-msg="Remove ALL unused images on {{ g.host_name|e }} (docker image prune -a)? Any image not used by a container is deleted."
|
||||||
|
onsubmit="return confirm(this.dataset.msg);">
|
||||||
|
<input type="hidden" name="target" value="images">
|
||||||
|
<button type="submit" class="btn btn-sm">Prune unused images</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||||
|
data-msg="Run a full system prune on {{ g.host_name|e }}? Removes stopped containers, unused networks, dangling images and build cache."
|
||||||
|
onsubmit="return confirm(this.dataset.msg);">
|
||||||
|
<input type="hidden" name="target" value="system">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:0.4rem;">
|
||||||
|
Reclaimed space appears on the next agent sample.
|
||||||
|
</div>
|
||||||
|
{% elif not ansible_available %}
|
||||||
|
<div style="font-size:0.74rem;color:var(--text-muted);">Cleanup needs the Ansible runner (currently unavailable).</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="font-size:0.74rem;color:var(--text-muted);">
|
||||||
|
Link an <a href="/hosts/{{ g.host_id }}">Ansible target</a> to this host to enable prune actions.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── Per-image table ──────────────────────────────────────────────────── #}
|
{# ── Per-image table ──────────────────────────────────────────────────── #}
|
||||||
<div class="card-flush">
|
<div class="card-flush">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ def test_routes_module_exposes_new_views():
|
|||||||
assert r.docker_bp.name == "docker"
|
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():
|
def test_human_bytes_formats_binary_units():
|
||||||
assert r._human_bytes(None) == "—"
|
assert r._human_bytes(None) == "—"
|
||||||
assert r._human_bytes(0) == "0 B"
|
assert r._human_bytes(0) == "0 B"
|
||||||
|
|||||||
Reference in New Issue
Block a user