feat(ansible): parameterize docker_prune with prune_target (containers/images/system)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C
This commit is contained in:
@@ -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:category: maintenance
|
||||||
# steward:confirm: true
|
# steward:confirm: true
|
||||||
# Reclaim disk on Docker / Docker Swarm nodes by removing unused data.
|
# Reclaim disk on Docker / Docker Swarm nodes by removing unused data.
|
||||||
# Safe by default: prunes dangling images, stopped containers, unused networks
|
# `prune_target` selects the scope (default `system` preserves the original
|
||||||
# and build cache. Set extra-vars to widen scope:
|
# behavior for existing manual/scheduled callers that don't set it):
|
||||||
# prune_all_images=true also remove ALL unused images (not just dangling)
|
# prune_target=containers remove stopped containers only (docker container prune)
|
||||||
# prune_volumes=true also remove unused named volumes (data loss risk)
|
# prune_target=images remove unused images (docker image prune;
|
||||||
- name: Docker system 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
|
hosts: all
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
become: true
|
become: true
|
||||||
vars:
|
vars:
|
||||||
|
prune_target: system
|
||||||
prune_all_images: false
|
prune_all_images: false
|
||||||
prune_volumes: false
|
prune_volumes: false
|
||||||
tasks:
|
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
|
- name: Run docker system prune
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
argv: >-
|
argv: >-
|
||||||
{{ ['docker', 'system', 'prune', '-f']
|
{{ ['docker', 'system', 'prune', '-f']
|
||||||
+ (['-a'] if prune_all_images | bool else [])
|
+ (['-a'] if prune_all_images | bool else [])
|
||||||
+ (['--volumes'] if prune_volumes | bool else []) }}
|
+ (['--volumes'] if prune_volumes | bool else []) }}
|
||||||
register: prune_result
|
register: system_prune
|
||||||
changed_when: "'Total reclaimed space: 0B' not in prune_result.stdout"
|
changed_when: "'Total reclaimed space: 0B' not in system_prune.stdout"
|
||||||
|
when: prune_target == 'system'
|
||||||
|
|
||||||
- name: Report reclaimed space
|
- name: Report reclaimed space
|
||||||
ansible.builtin.debug:
|
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 }}
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"""The bundled first-party playbook source is always present and discoverable."""
|
"""The bundled first-party playbook source is always present and discoverable."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from steward.ansible.sources import (
|
from steward.ansible.sources import (
|
||||||
BUILTIN_SOURCE_NAME,
|
BUILTIN_SOURCE_NAME,
|
||||||
|
discover_playbook_meta,
|
||||||
discover_playbooks,
|
discover_playbooks,
|
||||||
get_sources,
|
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():
|
def test_builtin_source_is_first_and_local():
|
||||||
sources = get_sources({"sources": []})
|
sources = get_sources({"sources": []})
|
||||||
assert sources[0]["name"] == BUILTIN_SOURCE_NAME
|
assert sources[0]["name"] == BUILTIN_SOURCE_NAME
|
||||||
@@ -17,3 +27,33 @@ def test_bundled_playbooks_are_discoverable():
|
|||||||
playbooks = discover_playbooks(builtin["path"])
|
playbooks = discover_playbooks(builtin["path"])
|
||||||
assert "maintenance/docker_prune.yml" in playbooks
|
assert "maintenance/docker_prune.yml" in playbooks
|
||||||
assert "host_agent/install.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
|
||||||
|
|||||||
Reference in New Issue
Block a user