diff --git a/steward/ansible/bundled/maintenance/files/swarm_prune.py b/steward/ansible/bundled/maintenance/swarm_prune/files/swarm_prune.py similarity index 98% rename from steward/ansible/bundled/maintenance/files/swarm_prune.py rename to steward/ansible/bundled/maintenance/swarm_prune/files/swarm_prune.py index d4c7920..f6509f8 100644 --- a/steward/ansible/bundled/maintenance/files/swarm_prune.py +++ b/steward/ansible/bundled/maintenance/swarm_prune/files/swarm_prune.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Steward's swarm prune (maintenance/swarm_prune.yml), run on each node. +"""Steward's swarm prune (maintenance/swarm_prune/swarm_prune.yml), run on each node. Removes the images on this node that no swarm service is pinned to and no container uses (by id, or by the reference it was made from), and keeps every image a service is pinned to even when its diff --git a/steward/ansible/bundled/maintenance/swarm_prune.yml b/steward/ansible/bundled/maintenance/swarm_prune/swarm_prune.yml similarity index 51% rename from steward/ansible/bundled/maintenance/swarm_prune.yml rename to steward/ansible/bundled/maintenance/swarm_prune/swarm_prune.yml index cdd587a..8a4f8a3 100644 --- a/steward/ansible/bundled/maintenance/swarm_prune.yml +++ b/steward/ansible/bundled/maintenance/swarm_prune/swarm_prune.yml @@ -4,10 +4,11 @@ # steward:confirm: true # Run on the swarm's nodes; each must be a manager (only a manager can list # services). On each node: -# 1. docker system prune: stopped containers, unused networks, dangling -# images, build cache (+ prune_volumes=true: unused anonymous volumes). -# 2. files/swarm_prune.py: removes every other image no container uses and -# no swarm service is pinned to, wherever that service's task runs. A +# 1. Stopped containers, unused networks and build cache are pruned +# (+ prune_volumes=true: unused anonymous volumes). No image is: Docker +# calls an untagged image dangling, and swarm pulls by digest, untagged. +# 2. files/swarm_prune.py: removes every image no container uses and no +# swarm service is pinned to, wherever that service's task runs. A # node keeps the images for the services running elsewhere, so it can # start them at once when the other node fails or is drained. # A node that can't list the swarm's services removes no images and the run @@ -19,13 +20,19 @@ vars: prune_volumes: false tasks: - - name: Prune stopped containers, unused networks, dangling images and build cache + # Never `docker system prune` or `docker image prune`: Docker counts an + # image with no tag as dangling, and every image a swarm task pulled by + # digest has none. Images are left to the script below. + - name: Prune stopped containers, unused networks and build cache ansible.builtin.command: - argv: >- - {{ ['docker', 'system', 'prune', '-f'] - + (['--volumes'] if prune_volumes | bool else []) }} - register: system_prune - changed_when: "'Total reclaimed space: 0B' not in system_prune.stdout" + argv: "{{ item }}" + loop: >- + {{ [['docker', 'container', 'prune', '-f'], + ['docker', 'network', 'prune', '-f'], + ['docker', 'builder', 'prune', '-f']] + + ([['docker', 'volume', 'prune', '-f']] if prune_volumes | bool else []) }} + register: cleanup + changed_when: "'Total reclaimed space: 0B' not in cleanup.stdout and cleanup.stdout | trim | length > 0" - name: Remove images no container uses and no swarm service is pinned to ansible.builtin.script: @@ -39,5 +46,6 @@ - name: Report what went ansible.builtin.debug: msg: >- - {{ (system_prune.stdout_lines | default([]) | select | list)[-1:] + {{ (cleanup.results | default([]) | map(attribute='stdout', default='') + | select('search', 'reclaimed') | list) + [image_prune.stdout | from_json] }} diff --git a/tests/core/test_swarm_prune.py b/tests/core/test_swarm_prune.py index 1b3799d..61804e6 100644 --- a/tests/core/test_swarm_prune.py +++ b/tests/core/test_swarm_prune.py @@ -16,7 +16,7 @@ BUILTIN = Path(get_sources({"sources": []})[0]["path"]) @pytest.fixture def prune(): spec = importlib.util.spec_from_file_location( - "swarm_prune", BUILTIN / "maintenance" / "files" / "swarm_prune.py") + "swarm_prune", BUILTIN / "maintenance" / "swarm_prune" / "files" / "swarm_prune.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod @@ -27,13 +27,19 @@ def _image(id_, tags=(), digests=(), size=100): def test_the_playbook_is_listed_and_asks_before_it_runs(): - assert "maintenance/swarm_prune.yml" in discover_playbooks(str(BUILTIN)) - content = (BUILTIN / "maintenance" / "swarm_prune.yml").read_text() + assert "maintenance/swarm_prune/swarm_prune.yml" in discover_playbooks(str(BUILTIN)) + content = (BUILTIN / "maintenance" / "swarm_prune" / "swarm_prune.yml").read_text() meta = discover_playbook_meta(content) assert meta["confirm"] is True and meta["category"] == "maintenance" and meta["description"] play = yaml.safe_load(content)[0] script = next(t for t in play["tasks"] if "ansible.builtin.script" in t) assert script["check_mode"] is False and "--dry-run" in script["ansible.builtin.script"]["cmd"] + # No docker prune touches images: an image swarm pulled by digest has no + # tag, and Docker calls that dangling (on 2026-10-25 a `system prune` + # took 29 a node was keeping for the other's services). + commands = yaml.safe_dump([t for t in play["tasks"] if "ansible.builtin.command" in t]) + assert "'system'" not in commands and "'image'" not in commands + assert "'container', 'prune'" in commands and "'builder', 'prune'" in commands def test_services_are_matched_by_digest_or_else_by_tag(prune):