feat(ansible): a swarm prune that keeps every image a swarm service is pinned to on every node (#6399)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m47s
CI / publish (push) Successful in 1m10s

The nightly prune ran `docker system prune -a`, which deletes every image no
local container uses, so each swarm node kept only its own tasks' images.
When the other node failed or was drained, its services had to pull first:
on 2026-10-25 Traefik took 11.5 min to start on Docker03.

maintenance/swarm_prune.yml (built in) prunes stopped containers, unused
networks, dangling images and build cache, then removes only the images that
no container uses and no swarm service is pinned to, wherever that service
runs. Old digests left by redeploys still go.

- A container's image is kept by its id and by the reference it was made
  from, since on the containerd store the id alone hasn't held a tag.
- A node that can't list the swarm's services removes no images and fails
  rather than pruning blind.
- Check mode lists what would go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 11:08:37 -04:00
co-authored by Claude Opus 5.5
parent 74a5b26cab
commit 6f0903f548
3 changed files with 272 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
"""The swarm prune keeps every image a swarm service is pinned to on every
node, removes the rest no container uses, and removes nothing when it can't
see the swarm."""
import importlib.util
import json
from pathlib import Path
import pytest
import yaml
from steward.ansible.sources import discover_playbook_meta, discover_playbooks, get_sources
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")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _image(id_, tags=(), digests=(), size=100):
return {"Id": id_, "RepoTags": list(tags), "RepoDigests": list(digests), "Size": size}
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()
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"]
def test_services_are_matched_by_digest_or_else_by_tag(prune):
digests, refs = prune.wanted([
"registry.example/team/app:dev@sha256:aaa",
"docker.io/library/redis:7",
"registry.example:5000/tool",
None,
])
assert digests == {"sha256:aaa"}
assert refs == {"redis:7", "registry.example:5000/tool:latest"}
def test_an_image_a_service_or_a_container_needs_stays(prune):
images = [
_image("sha256:1", ["registry.example/team/app:dev"], ["registry.example/team/app@sha256:aaa"]),
_image("sha256:2", ["registry.example/team/app:dev-old"], ["registry.example/team/app@sha256:old"]),
_image("sha256:3", ["redis:7"], ["redis@sha256:rrr"]),
_image("sha256:4", ["tool:1"]),
_image("sha256:5", [], ["registry.example/team/app@sha256:bbb"]),
]
gone = prune.removable(images, {"sha256:aaa"}, {"redis:7"}, used={"sha256:4"})
assert [im["Id"] for im in gone] == ["sha256:2", "sha256:5"]
def _fake_docker(monkeypatch, prune, answers, removed):
def docker(*args):
if args[:2] == ("image", "rm"):
removed.append(args[-1])
return ""
key = " ".join(args[:2])
if key not in answers:
raise RuntimeError(f"docker {key}: unexpected")
answer = answers[key]
if isinstance(answer, Exception):
raise answer
return answer
monkeypatch.setattr(prune, "docker", docker)
def _answers():
return {
"service ls": "s1 s2\n",
"service inspect": json.dumps([
{"Spec": {"TaskTemplate": {"ContainerSpec": {"Image": "registry.example/app:dev@sha256:aaa"}}}},
{"Spec": {"TaskTemplate": {"ContainerSpec": {"Image": "proxy:3@sha256:ppp"}}}}]),
"ps -aq": "c1\n",
"container inspect": json.dumps([{"Image": "sha256:9", "Config": {"Image": "local:1"}}]),
"image ls": "sha256:1\nsha256:2\nsha256:3\nsha256:9\n",
"image inspect": json.dumps([
_image("sha256:1", ["registry.example/app:dev"], ["registry.example/app@sha256:aaa"]),
_image("sha256:2", ["proxy:3"], ["proxy@sha256:ppp"]),
_image("sha256:3", ["registry.example/app:old"], ["registry.example/app@sha256:old"], size=500),
_image("sha256:9", ["local:1"]),
]),
}
def test_a_run_removes_only_what_nothing_needs_and_says_so(prune, monkeypatch, capsys):
removed: list = []
_fake_docker(monkeypatch, prune, _answers(), removed)
assert prune.main([]) == 0
out = json.loads(capsys.readouterr().out)
assert removed == ["sha256:3"]
assert out == {"kept": 3, "removed": ["registry.example/app:old"], "failed": [],
"freed_bytes": 500, "dry_run": False}
def test_a_dry_run_removes_nothing(prune, monkeypatch, capsys):
removed: list = []
_fake_docker(monkeypatch, prune, _answers(), removed)
assert prune.main(["--dry-run"]) == 0
assert removed == [] and json.loads(capsys.readouterr().out)["removed"] == ["registry.example/app:old"]
def test_a_node_that_cant_list_the_swarm_removes_nothing(prune, monkeypatch, capsys):
removed: list = []
answers = _answers()
answers["service ls"] = RuntimeError("docker service ls: This node is not a swarm manager.")
_fake_docker(monkeypatch, prune, answers, removed)
assert prune.main([]) == 2
assert removed == [] and "nothing removed" in json.loads(capsys.readouterr().out)["error"]