fix(ansible): the swarm prune reads every image from Docker's API, untagged ones included (#6399)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m49s
CI / publish (push) Successful in 1m4s

`docker image ls` on Docker 29 leaves out images with no tag, which is every
image a swarm task pulled by digest: the check run on VDNT-Docker03 saw 4 of
its 58. Missing them only kept them, but superseded digests there would
never have gone. The list now comes from GET /images/json on the socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 11:15:10 -04:00
co-authored by Claude Opus 5.5
parent 6f0903f548
commit b40e672cda
2 changed files with 36 additions and 6 deletions
@@ -16,11 +16,14 @@ Stdlib only; shipped beside the playbook in Steward's built-in source.
"""
from __future__ import annotations
import http.client
import json
import socket
import subprocess
import sys
DEADLINE_SECONDS = 300
DOCKER_SOCKET = "/var/run/docker.sock"
def docker(*args: str) -> str:
@@ -31,6 +34,34 @@ def docker(*args: str) -> str:
return done.stdout
class _UnixConnection(http.client.HTTPConnection):
def __init__(self, path: str):
super().__init__("localhost", timeout=DEADLINE_SECONDS)
self.unix_path = path
def connect(self):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(self.timeout)
sock.connect(self.unix_path)
self.sock = sock
def api(path: str):
"""A GET on Docker's own API. The image list is read here, not from
`docker image ls`, which leaves out images with no tag: every image a
swarm task pulled by digest."""
conn = _UnixConnection(DOCKER_SOCKET)
try:
conn.request("GET", path)
resp = conn.getresponse()
body = resp.read()
finally:
conn.close()
if resp.status != 200:
raise RuntimeError(f"docker API {path}: HTTP {resp.status}")
return json.loads(body)
def short(repo: str) -> str:
"""A repository the way Docker lists it locally: no docker.io/ or library/."""
for prefix in ("docker.io/", "index.docker.io/"):
@@ -54,7 +85,7 @@ def wanted(service_images) -> tuple[set, set]:
def removable(images: list, digests: set, refs: set, used: set) -> list:
"""The images (`docker image inspect` rows) no container uses and no
"""The images (Docker API /images/json rows) no container uses and no
service is pinned to."""
out = []
for im in images:
@@ -80,9 +111,8 @@ def main(argv: list) -> int:
services = json.loads(docker("service", "inspect", *ids)) if ids else []
ids = docker("ps", "-aq", "--no-trunc").split()
containers = json.loads(docker("container", "inspect", *ids)) if ids else []
image_ids = sorted(set(docker("image", "ls", "-q", "--no-trunc").split()))
images = json.loads(docker("image", "inspect", *image_ids)) if image_ids else []
except (RuntimeError, ValueError, OSError, subprocess.TimeoutExpired) as exc:
images = api("/images/json")
except (RuntimeError, ValueError, OSError, subprocess.TimeoutExpired, http.client.HTTPException) as exc:
print(json.dumps({"error": f"nothing removed: {exc}"}))
return 2
# A container's image is kept by its id and by the reference it was made
+2 -2
View File
@@ -72,6 +72,7 @@ def _fake_docker(monkeypatch, prune, answers, removed):
raise answer
return answer
monkeypatch.setattr(prune, "docker", docker)
monkeypatch.setattr(prune, "api", lambda path: answers[path])
def _answers():
@@ -82,8 +83,7 @@ def _answers():
{"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([
"/images/json": ([
_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),