Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions app/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,42 @@ def _read_file_from_container(container: Any, path: str) -> bytes:
raise ValueError(f"{path} is not a file in the archive")


def _ensure_updater_image(client: docker.DockerClient) -> None:
"""Pull the helper image if this host does not have it yet.

``containers.run`` pulls a missing image by itself only when the daemon
answers "No such image". Behind the socket proxy the create is refused
before it reaches the daemon, with the proxy's own 404 ("owner policy
could not resolve image"), so ``run`` raised instead of pulling, and the
first in-app update on a proxied install failed with "Docker refused to
start the update helper". Pulling first works the same with or without
the proxy.
"""
try:
client.images.get(UPDATER_IMAGE)
return
except NotFound:
pass
except APIError as exc:
refusal = proxy_refusal(exc)
if refusal:
raise SelfUpdateUnavailable(f"Not updating. {refusal}") from exc
logger.warning("Could not check for the update helper image: %s", exc)
try:
# The client's read timeout (60 s by default) ends a pull that stalls;
# a slow pull that keeps sending progress goes on.
client.images.pull(UPDATER_IMAGE)
except APIError as exc:
logger.warning("Could not pull the update helper image %s: %s", UPDATER_IMAGE, exc)
refusal = proxy_refusal(exc)
if refusal:
raise SelfUpdateUnavailable(f"Not updating. {refusal}") from exc
raise RuntimeError(f"Docker could not pull the update helper image {UPDATER_IMAGE}.") from exc
except Exception as exc: # noqa: BLE001 - a read timeout or a dropped socket, not a daemon answer
logger.warning("Pulling the update helper image %s did not finish: %s", UPDATER_IMAGE, type(exc).__name__)
raise RuntimeError(f"Docker could not pull the update helper image {UPDATER_IMAGE}.") from exc


def _resolve_compose_config(
client: docker.DockerClient,
working_dir: str,
Expand Down Expand Up @@ -2031,6 +2067,7 @@ def spawn_self_update() -> dict[str, Any]:
logger.warning("Self-update lock check failed: %s", exc)
raise RuntimeError("Docker refused to start the update helper.") from exc

_ensure_updater_image(client)
cfg = _resolve_compose_config(client, working_dir, environment, services, {**project_mount, **env_mounts})
if proxied():
refused = _proxy_compose_refusals(cfg, services)
Expand Down
119 changes: 119 additions & 0 deletions tests/test_update_feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,125 @@ def test_other_daemon_errors_surface_as_outage_not_500(self):
orchestrator.spawn_self_update()


class TestUpdaterImage:
"""The helper image has to be on the host before the preflight starts. Behind
the socket proxy, a create naming a missing image gets the proxy's 404
("owner policy could not resolve image"), not Docker's "No such image", so
containers.run never pulls it by itself."""

@staticmethod
def _proxy_404():
from docker.errors import NotFound

return NotFound(
"404 Client Error: Not Found",
response=MagicMock(status_code=404),
explanation=f'owner policy could not resolve image "{orchestrator.UPDATER_IMAGE}" referenced by container Image',
)

def test_a_missing_helper_image_is_pulled_before_the_preflight(self):
me = _fake_container(labels=_COMPOSE_LABELS)
client = _fake_docker(me=me)
_wire_runs(client)
order = []
missing = self._proxy_404()
run = client.containers.run.side_effect

def get(ref):
order.append(("get", ref))
raise missing

def pull(ref, *args, **kwargs):
order.append(("pull", ref))

def tracked_run(image, *args, **kwargs):
order.append(("run", image))
return run(image, *args, **kwargs)

client.images.get.side_effect = get
client.images.pull.side_effect = pull
client.containers.run.side_effect = tracked_run
with patch.object(orchestrator, "_get_client", return_value=client):
orchestrator.spawn_self_update()
assert order[:3] == [
("get", orchestrator.UPDATER_IMAGE),
("pull", orchestrator.UPDATER_IMAGE),
("run", orchestrator.UPDATER_IMAGE),
]
assert _updater_started(client)

def test_a_present_helper_image_is_not_pulled_again(self):
me = _fake_container(labels=_COMPOSE_LABELS)
client = _fake_docker(me=me)
_wire_runs(client)
with patch.object(orchestrator, "_get_client", return_value=client):
orchestrator.spawn_self_update()
client.images.get.assert_called_once_with(orchestrator.UPDATER_IMAGE)
client.images.pull.assert_not_called()

def test_a_pull_the_daemon_refuses_is_an_outage_and_starts_nothing(self):
from docker.errors import APIError

me = _fake_container(labels=_COMPOSE_LABELS)
client = _fake_docker(me=me)
_wire_runs(client)
client.images.get.side_effect = self._proxy_404()
client.images.pull.side_effect = APIError("pull access denied", response=MagicMock(status_code=500))
with (
patch.object(orchestrator, "_get_client", return_value=client),
pytest.raises(RuntimeError, match="could not pull the update helper image"),
):
orchestrator.spawn_self_update()
client.containers.run.assert_not_called()

def test_a_failed_image_check_still_pulls(self):
from docker.errors import APIError

me = _fake_container(labels=_COMPOSE_LABELS)
client = _fake_docker(me=me)
_wire_runs(client)
client.images.get.side_effect = APIError("daemon busy", response=MagicMock(status_code=500))
with patch.object(orchestrator, "_get_client", return_value=client):
orchestrator.spawn_self_update()
client.images.pull.assert_called_once_with(orchestrator.UPDATER_IMAGE)
assert _updater_started(client)

def test_a_pull_that_stalls_is_an_outage_and_starts_nothing(self):
import requests

me = _fake_container(labels=_COMPOSE_LABELS)
client = _fake_docker(me=me)
_wire_runs(client)
client.images.get.side_effect = self._proxy_404()
client.images.pull.side_effect = requests.exceptions.ReadTimeout("read timed out")
with (
patch.object(orchestrator, "_get_client", return_value=client),
pytest.raises(RuntimeError, match="could not pull the update helper image"),
):
orchestrator.spawn_self_update()
client.containers.run.assert_not_called()

def test_a_pull_the_proxy_refuses_says_why_and_starts_nothing(self):
from docker.errors import APIError

me = _fake_container(labels=_COMPOSE_LABELS)
client = _fake_docker(me=me)
_wire_runs(client)
client.images.get.side_effect = self._proxy_404()
response = MagicMock(status_code=403)
response.json.return_value = {
"message": "request denied by sockguard policy",
"reason": "image pull denied: registry docker.io is not allowlisted",
}
client.images.pull.side_effect = APIError("denied", response=response)
with (
patch.object(orchestrator, "_get_client", return_value=client),
pytest.raises(orchestrator.SelfUpdateUnavailable, match="registry docker.io is not allowlisted"),
):
orchestrator.spawn_self_update()
client.containers.run.assert_not_called()


_UI_LABELS = {
"com.docker.compose.project": "cashpilot",
"com.docker.compose.service": "cashpilot-ui",
Expand Down
Loading