From be0932248701b20c3ffdac0fddb7d5ed22e32118 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:00:54 +0100 Subject: [PATCH 1/3] fix(update): pull the update helper image first, so the in-app update works behind the socket proxy containers.run pulls a missing image only when Docker answers "No such image". Behind the socket proxy the create is refused first with the proxy's own 404 ("owner policy could not resolve image"), so on any host that did not already hold docker:27.5.1-cli the Update button failed with "Docker refused to start the update helper". The worker now checks for the image and pulls it before the preflight. --- app/orchestrator.py | 29 +++++++++++++++ tests/test_update_feature.py | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/app/orchestrator.py b/app/orchestrator.py index 4c096f99..81d5296f 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -1738,6 +1738,34 @@ 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: + logger.warning("Could not check for the update helper image: %s", exc) + try: + 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 + + def _resolve_compose_config( client: docker.DockerClient, working_dir: str, @@ -2031,6 +2059,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) diff --git a/tests/test_update_feature.py b/tests/test_update_feature.py index e8d58a80..a84b73fd 100644 --- a/tests/test_update_feature.py +++ b/tests/test_update_feature.py @@ -476,6 +476,78 @@ 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() + + _UI_LABELS = { "com.docker.compose.project": "cashpilot", "com.docker.compose.service": "cashpilot-ui", From 8370895d155001fa78e76f70c5221e8e55ecfc25 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:20:20 +0100 Subject: [PATCH 2/3] test(update): cover a failed image check and a pull the proxy refuses --- tests/test_update_feature.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_update_feature.py b/tests/test_update_feature.py index a84b73fd..b09a35e8 100644 --- a/tests/test_update_feature.py +++ b/tests/test_update_feature.py @@ -547,6 +547,38 @@ def test_a_pull_the_daemon_refuses_is_an_outage_and_starts_nothing(self): 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_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", From 66f1f3623f251cd01ec4c64c01552109b8071464 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:27:40 +0100 Subject: [PATCH 3/3] fix(update): report a stalled helper-image pull as an outage, and a refused image check as the proxy's reason --- app/orchestrator.py | 8 ++++++++ tests/test_update_feature.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/app/orchestrator.py b/app/orchestrator.py index 81d5296f..935e02e9 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -1755,8 +1755,13 @@ def _ensure_updater_image(client: docker.DockerClient) -> None: 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) @@ -1764,6 +1769,9 @@ def _ensure_updater_image(client: docker.DockerClient) -> None: 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( diff --git a/tests/test_update_feature.py b/tests/test_update_feature.py index b09a35e8..b7271927 100644 --- a/tests/test_update_feature.py +++ b/tests/test_update_feature.py @@ -559,6 +559,21 @@ def test_a_failed_image_check_still_pulls(self): 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