Files
stackpilot/backend/tests/test_docker_timeouts.py
T
menzeljandClaude Opus 5 faff2be944
CI / check (push) Successful in 13m53s
CI / build-and-push (push) Successful in 1m58s
Show live progress while pruning images (0.61.0)
docker image prune is a single blocking call that reports nothing until it
is done. With -a on a host with many images that meant minutes of a
spinner, and past the proxy timeout an error while the daemon carried on.

The prune now removes its candidates one by one and publishes how far it
is. The candidates are the ones Docker would pick: dangling only by
default, with -a every image no container (running or stopped) uses. The
UI starts the prune in the background (POST /api/images/prune?background=
true, 202) and polls GET /api/images/prune-status. A progress bar above
the image table shows x / y, the image being removed and any failures,
and a toast reports the outcome. It survives a reload and shows up in a
second tab.

- Multi-tag images lose their tags one by one instead of force=True, so a
  container created since the listing still makes the daemon refuse.
- One refused image does not stop the rest; up to ten errors are kept.
- Docker's own prune runs last to sweep up leftovers.
- Freed space is measured (image LayersSize before and after) rather than
  summed, which would count base layers shared with the images that stay.
- Without background=true the endpoint still waits and returns the old
  {ImagesDeleted, SpaceReclaimed} shape, so API-token scripts are
  unaffected. A second prune while one runs gets 409.
- The audit entry now counts deleted images, not Docker's untag entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 10:15:14 +02:00

85 lines
2.5 KiB
Python

"""Slow Docker calls must fail with a message, not a bare 500.
A volume or image prune that deletes a lot of data easily runs past docker-py's
60s read timeout. requests' ``ReadTimeout`` is not a ``DockerException``, so it
slipped past ``safe_call`` and the UI only showed "Request failed with status
code 500". Prunes now use a client with a long timeout, and any transport
timeout that still happens is turned into a ``DockerError`` (-> 502 + detail).
"""
from __future__ import annotations
import pytest
import requests
def test_safe_call_wraps_read_timeout():
from docker_client import DockerError, safe_call
def slow():
raise requests.exceptions.ReadTimeout("Read timed out. (read timeout=60)")
with pytest.raises(DockerError) as info:
safe_call(slow)
assert info.value.error == "docker_timeout"
def test_safe_call_wraps_connection_error():
from docker_client import DockerError, safe_call
def gone():
raise requests.exceptions.ConnectionError("socket went away")
with pytest.raises(DockerError) as info:
safe_call(gone)
assert info.value.error == "docker_unavailable"
def test_volume_prune_timeout_is_a_502_with_detail(as_admin, monkeypatch):
from services import volume_service
class Volumes:
def prune(self, filters=None):
raise requests.exceptions.ReadTimeout("Read timed out.")
class Client:
volumes = Volumes()
monkeypatch.setattr(volume_service, "get_long_client", lambda: Client())
r = as_admin.post("/api/volumes/prune")
assert r.status_code == 502
assert r.json()["error"] == "docker_timeout"
@pytest.mark.parametrize(
"module, fn, args",
[
("volume_service", "prune_volumes", ()),
("network_service", "prune_networks", ()),
],
)
def test_prunes_use_the_long_timeout_client(monkeypatch, module, fn, args):
import importlib
svc = importlib.import_module(f"services.{module}")
used = []
class Any_:
def prune(self, filters=None):
used.append("long")
return {}
class Client:
volumes = images = networks = Any_()
def short():
raise AssertionError("prune must not use the 60s client")
monkeypatch.setattr(svc, "get_client", short)
monkeypatch.setattr(svc, "get_long_client", lambda: Client())
getattr(svc, fn)(*args)
assert used == ["long"]
# The image prune goes image by image and has its own tests in
# test_image_prune.py, including that it uses the long-timeout client.