diff --git a/app/orchestrator.py b/app/orchestrator.py index 4c096f99..935e02e9 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -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, @@ -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) diff --git a/tests/test_update_feature.py b/tests/test_update_feature.py index e8d58a80..b7271927 100644 --- a/tests/test_update_feature.py +++ b/tests/test_update_feature.py @@ -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",