From e8ac99174a21be523a435c03489e7f7d0cc8e5cf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 19 Jul 2026 16:09:39 -0400 Subject: [PATCH] feat(ansible): parameterize docker_prune with prune_target (containers/images/system) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M78 step 1. The bundled maintenance/docker_prune.yml was a single `docker system prune -f`; the upcoming per-host prune buttons need a granular split. Add a `prune_target` extra-var: - containers → docker container prune -f - images → docker image prune -f (+ -a when prune_all_images) - system → docker system prune -f (+ -a / --volumes) — the DEFAULT, so existing manual/scheduled callers (which set no var) keep today's behavior. Reuses the existing bundled playbook rather than adding parallel files; keeps the description/category/confirm meta so it still self-describes as a confirm-gated maintenance run. Adds unit coverage: the playbook stays valid YAML, its meta still discovers, and all three targets are guarded. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C --- .../bundled/maintenance/docker_prune.yml | 53 +++++++++++++++---- tests/core/test_builtin_source.py | 40 ++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/steward/ansible/bundled/maintenance/docker_prune.yml b/steward/ansible/bundled/maintenance/docker_prune.yml index 51c5bcf..550aa8f 100644 --- a/steward/ansible/bundled/maintenance/docker_prune.yml +++ b/steward/ansible/bundled/maintenance/docker_prune.yml @@ -1,29 +1,64 @@ --- -# description: Reclaim disk on Docker / Swarm nodes by pruning unused images, containers, networks and build cache. +# description: Reclaim disk on Docker / Swarm nodes — prune stopped containers, unused images, or a full system prune. # steward:category: maintenance # steward:confirm: true # Reclaim disk on Docker / Docker Swarm nodes by removing unused data. -# Safe by default: prunes dangling images, stopped containers, unused networks -# and build cache. Set extra-vars to widen scope: -# prune_all_images=true also remove ALL unused images (not just dangling) -# prune_volumes=true also remove unused named volumes (data loss risk) -- name: Docker system prune +# `prune_target` selects the scope (default `system` preserves the original +# behavior for existing manual/scheduled callers that don't set it): +# prune_target=containers remove stopped containers only (docker container prune) +# prune_target=images remove unused images (docker image prune; +# + prune_all_images=true → -a, i.e. ALL unused, not just dangling) +# prune_target=system docker system prune (dangling images, stopped +# containers, unused networks, build cache). Widen with: +# prune_all_images=true also ALL unused images +# prune_volumes=true also unused named volumes (data loss risk) +- name: Docker prune hosts: all gather_facts: false become: true vars: + prune_target: system prune_all_images: false prune_volumes: false tasks: + - name: Validate prune_target + ansible.builtin.assert: + that: prune_target in ['containers', 'images', 'system'] + fail_msg: "prune_target must be one of: containers, images, system (got '{{ prune_target }}')" + quiet: true + + # ── Stopped containers only ─────────────────────────────────────────────── + - name: Prune stopped containers + ansible.builtin.command: + argv: ['docker', 'container', 'prune', '-f'] + register: container_prune + changed_when: "'Total reclaimed space: 0B' not in container_prune.stdout" + when: prune_target == 'containers' + + # ── Unused images (dangling, or all unused with -a) ─────────────────────── + - name: Prune unused images + ansible.builtin.command: + argv: >- + {{ ['docker', 'image', 'prune', '-f'] + + (['-a'] if prune_all_images | bool else []) }} + register: image_prune + changed_when: "'Total reclaimed space: 0B' not in image_prune.stdout" + when: prune_target == 'images' + + # ── Full system prune (default) ─────────────────────────────────────────── - name: Run docker system prune ansible.builtin.command: argv: >- {{ ['docker', 'system', 'prune', '-f'] + (['-a'] if prune_all_images | bool else []) + (['--volumes'] if prune_volumes | bool else []) }} - register: prune_result - changed_when: "'Total reclaimed space: 0B' not in prune_result.stdout" + register: system_prune + changed_when: "'Total reclaimed space: 0B' not in system_prune.stdout" + when: prune_target == 'system' - name: Report reclaimed space ansible.builtin.debug: - msg: "{{ prune_result.stdout_lines | select | list }}" + msg: >- + {{ ((container_prune.stdout_lines | default([])) + + (image_prune.stdout_lines | default([])) + + (system_prune.stdout_lines | default([]))) | select | list }} diff --git a/tests/core/test_builtin_source.py b/tests/core/test_builtin_source.py index 323590a..e4f919d 100644 --- a/tests/core/test_builtin_source.py +++ b/tests/core/test_builtin_source.py @@ -1,11 +1,21 @@ """The bundled first-party playbook source is always present and discoverable.""" +from pathlib import Path + +import yaml + from steward.ansible.sources import ( BUILTIN_SOURCE_NAME, + discover_playbook_meta, discover_playbooks, get_sources, ) +def _bundled_content(rel_path: str) -> str: + builtin = get_sources({"sources": []})[0] + return (Path(builtin["path"]) / rel_path).read_text() + + def test_builtin_source_is_first_and_local(): sources = get_sources({"sources": []}) assert sources[0]["name"] == BUILTIN_SOURCE_NAME @@ -17,3 +27,33 @@ def test_bundled_playbooks_are_discoverable(): playbooks = discover_playbooks(builtin["path"]) assert "maintenance/docker_prune.yml" in playbooks assert "host_agent/install.yml" in playbooks + + +def test_docker_prune_playbook_parses_and_keeps_meta(): + """The prune playbook stays valid YAML and keeps its self-describing meta + (a destructive maintenance run that must prompt for confirmation).""" + content = _bundled_content("maintenance/docker_prune.yml") + plays = yaml.safe_load(content) + assert isinstance(plays, list) and plays # at least one play + + meta = discover_playbook_meta(content) + assert meta["confirm"] is True + assert meta["category"] == "maintenance" + assert meta["description"] + + +def test_docker_prune_supports_all_three_targets(): + """M78 drives the three prune buttons via a single `prune_target` var; + `system` is the default so pre-M78 callers (no var set) are unchanged.""" + content = _bundled_content("maintenance/docker_prune.yml") + play = yaml.safe_load(content)[0] + assert play["vars"]["prune_target"] == "system" + + # Every target the routes/UI can send has a matching guarded task. + guards = { + task.get("when") + for task in play["tasks"] + if isinstance(task.get("when"), str) and "prune_target" in task["when"] + } + for target in ("containers", "images", "system"): + assert f"prune_target == '{target}'" in guards