diff --git a/docs/wiki/webui-container.md b/docs/wiki/webui-container.md index 6ed36216..c8bf808b 100644 --- a/docs/wiki/webui-container.md +++ b/docs/wiki/webui-container.md @@ -262,6 +262,19 @@ Recreate-scope discovery also fails closed on lookup errors. Each approved scope is retained for execution, so a later label change cannot expand the services that the job stops or recreates. +When `DOCKER_HOST` points WUDup at a network endpoint such as +`tcp://socket-proxy:2375`, updates also reject a recreate scope that includes +a running container whose name, network alias, or network address matches the +`DOCKER_HOST` host. Stopping that container would cut WUDup off from Docker, so +it could not finish or roll back the update; update the socket proxy from the +host instead. If those containers cannot be inspected, the update is blocked +before mutation. The check does not recognize an endpoint reached through a +published host port (such as a LAN IP or `host.docker.internal`), a proxy that +shares another container's network with `network_mode`, or an endpoint set +through a Docker context; avoid selecting the proxy in those setups. Unix-socket +`DOCKER_HOST` values skip this check, and WUD itself can still be updated +because WUDup keeps Docker access while WUD restarts. + WebUI self-update pulls prepare an image but do not treat `docker restart` as adoption. The response verifies the running container image ID after the pull and reports `prepared_only` until an external Compose recreate moves the diff --git a/src/wudup/container_identity.py b/src/wudup/container_identity.py index a6fecaae..45695cb5 100644 --- a/src/wudup/container_identity.py +++ b/src/wudup/container_identity.py @@ -5,6 +5,7 @@ import re from collections.abc import Iterable, Mapping from pathlib import Path +from urllib.parse import urlsplit _CONTAINER_ID_RE = re.compile(r"\b[0-9a-f]{64}\b") @@ -46,3 +47,47 @@ def _unique(values: Iterable[object]) -> list[str]: seen.add(normalized) result.append(normalized) return result + + +_NETWORK_DOCKER_HOST_SCHEMES = frozenset({"tcp", "http", "https"}) + + +def docker_endpoint_host(environ: Mapping[str, str]) -> str: + """Return the lowercased host of a network ``DOCKER_HOST``, or "" for local sockets.""" + + value = environ.get("DOCKER_HOST", "").strip() + if not value: + return "" + try: + parsed = urlsplit(value) + if parsed.scheme.lower() not in _NETWORK_DOCKER_HOST_SCHEMES: + return "" + return (parsed.hostname or "").strip().lower() + except ValueError: + return "" + + +def container_dns_names(details: Mapping[str, object]) -> set[str]: + """Return names Docker DNS can resolve to the inspected container.""" + + # The Compose service label is not used: Docker DNS only resolves it through + # the network aliases below, on networks the container is attached to. + names: list[object] = [details.get("Name")] + container_id = details.get("Id") + if isinstance(container_id, str): + names.extend((container_id, container_id[:12])) + networks = details.get("Networks") + if isinstance(networks, Mapping): + for endpoint in networks.values(): + if not isinstance(endpoint, Mapping): + continue + for key in ("Aliases", "DNSNames"): + values = endpoint.get(key) + if isinstance(values, list): + names.extend(values) + names.extend((endpoint.get("IPAddress"), endpoint.get("GlobalIPv6Address"))) + return { + name.strip().lstrip("/").lower() + for name in names + if isinstance(name, str) and name.strip().lstrip("/") + } diff --git a/src/wudup/docker_cli.py b/src/wudup/docker_cli.py index 8337d679..dd923c67 100644 --- a/src/wudup/docker_cli.py +++ b/src/wudup/docker_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from dataclasses import dataclass from . import request_scope @@ -13,6 +14,11 @@ CONTAINER_ID_FORMAT = "{{.Id}}" CONTAINER_IMAGE_ID_FORMAT = "{{.Image}}" CONTAINER_STATE_ERROR_FORMAT = "{{.State.Error}}" +CONTAINER_DNS_FORMAT = ( + '{"Id":{{json .Id}},"Name":{{json .Name}},' + '"Service":{{json (index .Config.Labels "com.docker.compose.service")}},' + '"Networks":{{json .NetworkSettings.Networks}}}' +) @dataclass(frozen=True) @@ -152,6 +158,17 @@ def container_state_error(self, container: str) -> str: """Return the error from the container's last failed start, if any.""" return _first_nonblank(self.inspect(container, CONTAINER_STATE_ERROR_FORMAT)) + def container_dns_details(self, container: str) -> dict[str, object]: + """Return the container's name, Compose service, and network DNS details. + + Raises ``CommandError`` when Docker fails and ``ValueError`` or ``TypeError`` + when the output is not the expected JSON object. + """ + details = json.loads(_first_nonblank(self.inspect(container, CONTAINER_DNS_FORMAT))) + if not isinstance(details, dict): + raise TypeError("unexpected docker inspect output") + return details + def try_container_image_id(self, container: str) -> str: return _first_nonblank( self.try_inspect(container, CONTAINER_IMAGE_ID_FORMAT) diff --git a/src/wudup/updater_preflight.py b/src/wudup/updater_preflight.py index 82022dc9..bc053ed6 100644 --- a/src/wudup/updater_preflight.py +++ b/src/wudup/updater_preflight.py @@ -9,6 +9,7 @@ from . import compose_rewrite, updater_audit, updater_logging from .command import CommandError from .compose import ComposeBindMount, ComposeRuntimePortIssue, ComposeStack +from .container_identity import container_dns_names, docker_endpoint_host from .images import image_tag from .self_update import is_self_update_target from .updater_digest_pin import _digest_pin_match_tag @@ -55,8 +56,26 @@ def services_for(self, stack: ComposeStack) -> tuple[str, ...] | None: ] +_SELF_UPDATE_BLOCKED = "self-update-recreate-blocked" +_DOCKER_ENDPOINT_BLOCKED = "docker-endpoint-recreate-blocked" +_SELF_UPDATE_MESSAGE = ( + "This update would stop or recreate WUDup itself. " + "Use the self-update action for WUDup, or update this stack from " + "the host. Remove it from this selection to update other services." +) +_DOCKER_ENDPOINT_LOOKUP_MESSAGE = ( + "Could not check whether this update would stop the Docker endpoint " + "WUDup uses (DOCKER_HOST); no update was applied. Check Docker access " + "and the Compose configuration, then retry." +) + + def validate_self_update_scope(runner: Any, matches: Sequence[Match]) -> bool: - if runner.options.protected_container is None: + """Block stacks whose recreate scope includes WUDup or its Docker endpoint.""" + + protect_self = runner.options.protected_container is not None + endpoint_host = docker_endpoint_host(runner.environ) + if not protect_self and not endpoint_host: return True protected_id = "" identity_error = "" @@ -70,11 +89,15 @@ def validate_self_update_scope(runner: Any, matches: Sequence[Match]) -> bool: "Could not verify the WUDup container identity; no update was applied. " "Check Docker access and WUD_WEB_RESTART_CONTAINER, then retry." ) - records = _PreflightIssueRecords() + records_by_reason = { + _SELF_UPDATE_BLOCKED: _PreflightIssueRecords(), + _DOCKER_ENDPOINT_BLOCKED: _PreflightIssueRecords(), + } stacks = _stacks_to_update(matches) for stack in stacks: stack_matches = _matches_for_stack(matches, stack) message = identity_error + reason = _SELF_UPDATE_BLOCKED protected_services: set[str] = set() scope = None if not message: @@ -85,46 +108,89 @@ def validate_self_update_scope(runner: Any, matches: Sequence[Match]) -> bool: "Could not verify the update's recreate scope; no update was applied. " "Check Docker access and the Compose configuration, then retry." ) + if not protect_self: + reason = _DOCKER_ENDPOINT_BLOCKED if scope is not None: services = set(runtime_services_for_scope(scope)) - protected_services = { - item.service for item in stack.service_images - if item.service in services and is_self_update_target(item.image) - } - if protected_id: + if protect_self: + protected_services = { + item.service for item in stack.service_images + if item.service in services and is_self_update_target(item.image) + } + container_ids: list[str] = [] + if protected_id or endpoint_host: try: container_ids = runner.compose.ps_quiet_checked( stack.directory, stack.file, tuple(sorted(services)), project_directory=stack.project_directory, ) except CommandError: - message = ( - "Could not verify whether this update includes the WUDup container; " - "no update was applied. Check Docker access and the Compose " - "configuration, then retry." + if protected_id: + message = ( + "Could not verify whether this update includes the WUDup " + "container; no update was applied. Check Docker access and " + "the Compose configuration, then retry." + ) + else: + message = _DOCKER_ENDPOINT_LOOKUP_MESSAGE + reason = _DOCKER_ENDPOINT_BLOCKED + if protected_id and protected_id in container_ids: + protected_services.update(services) + if endpoint_host and not message and not protected_services: + try: + endpoint_services = _docker_endpoint_services( + runner, container_ids, endpoint_host, ) + except (CommandError, TypeError, ValueError): + message = _DOCKER_ENDPOINT_LOOKUP_MESSAGE + reason = _DOCKER_ENDPOINT_BLOCKED else: - if protected_id in container_ids: - protected_services.update(services) + if endpoint_services: + protected_services = endpoint_services + reason = _DOCKER_ENDPOINT_BLOCKED + message = _docker_endpoint_message(endpoint_host, endpoint_services) if not message and not protected_services: runner.lifecycle.verified_update_scopes[(stack.index, _update_services(stack_matches))] = scope continue - message = f"[{stack.name}] " + (message or ( - "This update would stop or recreate WUDup itself. " - "Use the self-update action for WUDup, or update this stack from " - "the host. Remove it from this selection to update other services." - )) + message = f"[{stack.name}] " + (message or _SELF_UPDATE_MESSAGE) runner.log.error(message) runner._progress( "preflight", "failure", message, stack=stack.name, services=tuple(sorted(protected_services)), matches=stack_matches, ) + records = records_by_reason[reason] records.messages_by_stack[stack.index] = [message] records.services_by_stack[stack.index] = protected_services - _record_compose_preflight_failures( - runner, matches, stacks, records, reason="self-update-recreate-blocked", + for stack in stacks: + for reason, records in records_by_reason.items(): + _record_compose_preflight_failures(runner, matches, (stack,), records, reason=reason) + return not any(records.messages_by_stack for records in records_by_reason.values()) + + +def _docker_endpoint_services( + runner: Any, + container_ids: Sequence[str], + endpoint_host: str, +) -> set[str]: + """Return services of scoped containers that Docker DNS resolves as ``endpoint_host``.""" + + services: set[str] = set() + for container_id in container_ids: + details = runner.docker.container_dns_details(container_id) + if endpoint_host in container_dns_names(details): + service = details.get("Service") + services.add(service if isinstance(service, str) and service else container_id) + return services + + +def _docker_endpoint_message(endpoint_host: str, services: Iterable[str]) -> str: + return ( + f"This update would stop or recreate {', '.join(sorted(services))}, which serves " + f"the Docker endpoint WUDup uses (DOCKER_HOST host {endpoint_host}). WUDup would " + "lose Docker access and could not finish or roll back the update. Update that " + "service from the host instead, and remove it from this selection to update " + "other services." ) - return not records.messages_by_stack def validate_tag_manifests(runner: Any, matches: Sequence[Match]) -> bool: diff --git a/tests/fakes/docker b/tests/fakes/docker index 27d2a51f..e5aac98f 100755 --- a/tests/fakes/docker +++ b/tests/fakes/docker @@ -571,6 +571,16 @@ inspect_cmd(){ target="${1:-}" log_call "inspect $target" + if [[ "$format" == *'.NetworkSettings.Networks'* ]]; then + local dns_file="$ROOT/containers/$target.dns.json" + if [[ -f "$dns_file" ]]; then + cat "$dns_file" + else + printf '{"Id":"%s","Name":"/%s","Service":"","Networks":{}}\n' "$target" "$target" + fi + return 0 + fi + if [[ "$format" == *'.Config.Labels'* ]]; then local labels_file="$ROOT/containers/$target.labels" if [[ -f "$labels_file" ]]; then diff --git a/tests/test_python_update_from_wud_docker_endpoint.py b/tests/test_python_update_from_wud_docker_endpoint.py new file mode 100644 index 00000000..ede6d984 --- /dev/null +++ b/tests/test_python_update_from_wud_docker_endpoint.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import json +import unittest +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import replace +from io import StringIO +from unittest import mock + +from tests.update_from_wud_helpers import ( + UpdateFromWudRunnerTestCase, +) + +from wudup.command import CommandError, CommandResult +from wudup.container_identity import container_dns_names, docker_endpoint_host + +PROXY_IMAGE = "repo/socket-proxy:latest" +WUD_IMAGE = "repo/wud:latest" +PROXY_HOST = "socket-proxy-wud-updater" +MUTATION_CALLS = r"compose -f .* (?:pull|stop|pause|up -d)" + + +class DockerEndpointHostTests(unittest.TestCase): + def test_network_docker_host_returns_lowercased_host(self) -> None: + cases = { + "tcp://Socket-Proxy:2375": "socket-proxy", + "http://socket-proxy:2375/": "socket-proxy", + "https://proxy.internal:2376": "proxy.internal", + "tcp://172.18.0.5:2375": "172.18.0.5", + "tcp://[fd00::5]:2375": "fd00::5", + } + for value, expected in cases.items(): + with self.subTest(value=value): + self.assertEqual(docker_endpoint_host({"DOCKER_HOST": value}), expected) + + def test_local_or_missing_docker_host_has_no_endpoint_host(self) -> None: + for value in ( + "", + "unix:///var/run/docker.sock", + "npipe:////./pipe/docker_engine", + "ssh://user@docker-host", + "tcp://[bad", + ): + with self.subTest(value=value): + self.assertEqual(docker_endpoint_host({"DOCKER_HOST": value}), "") + self.assertEqual(docker_endpoint_host({}), "") + + def test_container_dns_names_include_names_aliases_and_addresses(self) -> None: + names = container_dns_names({ + "Id": "0123456789abcdef0123", + "Name": "/Socket-Proxy-1", + "Service": "socket-proxy", + "Networks": { + "wud": { + "Aliases": ["socket-proxy-wud-updater"], + "DNSNames": ["dns-only"], + "IPAddress": "172.18.0.5", + "GlobalIPv6Address": "", + }, + "broken": None, + }, + }) + self.assertEqual(names, { + "0123456789abcdef0123", "0123456789ab", "socket-proxy-1", + "socket-proxy-wud-updater", "dns-only", "172.18.0.5", + }) + + def test_container_dns_names_ignore_service_label_without_network_alias(self) -> None: + # A network_mode container has no DNS entry of its own. + names = container_dns_names({ + "Id": "cid", "Name": "/app-vpn-client-1", "Service": "vpn-client", "Networks": {}, + }) + self.assertNotIn("vpn-client", names) + + +class UpdateFromWudDockerEndpointTests(UpdateFromWudRunnerTestCase): + def _endpoint_stack(self, *, docker_host: str = f"tcp://{PROXY_HOST}:2375"): + stack = self.make_stack("wud", [ + ("socket-proxy", PROXY_IMAGE, "cid-proxy"), + ("wud", WUD_IMAGE, "cid-wud"), + ]) + (self.fake_root / "containers/cid-proxy.dns.json").write_text(json.dumps({ + "Id": "cid-proxy", + "Name": "/wud-socket-proxy-1", + "Service": "socket-proxy", + "Networks": {"wud": {"Aliases": [PROXY_HOST], "IPAddress": "172.18.0.5"}}, + })) + for image in (PROXY_IMAGE, WUD_IMAGE): + self.set_image_state(image, "old") + self.set_image_after_pull(image, "new") + self.env["DOCKER_HOST"] = docker_host + return stack + + def _run(self, runner): + stderr = StringIO() + with redirect_stdout(StringIO()), redirect_stderr(stderr): + result = runner.run() + return result, stderr.getvalue() + + def test_scope_with_docker_endpoint_service_is_blocked_before_mutation(self) -> None: + stack = self._endpoint_stack() + original_compose = (stack / "docker-compose.yml").read_text() + self.wud_file.write_text(f"{PROXY_IMAGE}\n{WUD_IMAGE}\n") + runner = self.make_runner(db_path=self.db_path) + + result, stderr = self._run(runner) + + self.assertEqual(result, 1) + self.assertEqual( + [(f.reason, f.services) for f in runner.failures], + [("docker-endpoint-recreate-blocked", ("socket-proxy",))], + ) + self.assertIn("Docker endpoint WUDup uses (DOCKER_HOST", stderr) + self.assertIn("Update that service from the host", stderr) + self.assertEqual((stack / "docker-compose.yml").read_text(), original_compose) + self.assertEqual(self.wud_file.read_text(), f"{PROXY_IMAGE}\n{WUD_IMAGE}\n") + self.assertNotRegex(self.calls(), MUTATION_CALLS) + + def test_scope_with_only_wud_service_is_updated(self) -> None: + self._endpoint_stack() + self.wud_file.write_text(f"{WUD_IMAGE}\n") + runner = self.make_runner(db_path=self.db_path) + + result, _stderr = self._run(runner) + + self.assertEqual(result, 0) + self.assertEqual(runner.failures, []) + self.assertIn(" stop wud", self.calls()) + self.assertNotRegex(self.calls(), r"(?:stop|pause|up -d) [^\n]*\bsocket-proxy\b") + + def test_unix_socket_docker_host_does_not_check_endpoint(self) -> None: + self._endpoint_stack(docker_host="unix:///var/run/docker.sock") + self.wud_file.write_text(f"{PROXY_IMAGE}\n") + runner = self.make_runner(db_path=self.db_path) + + with mock.patch.object( + runner.docker, "container_dns_details", side_effect=AssertionError("unexpected"), + ): + result, _stderr = self._run(runner) + + self.assertEqual(result, 0) + self.assertIn(" stop socket-proxy", self.calls()) + + def test_endpoint_lookup_failure_fails_closed(self) -> None: + stack = self._endpoint_stack() + original_compose = (stack / "docker-compose.yml").read_text() + self.wud_file.write_text(f"{WUD_IMAGE}\n") + runner = self.make_runner(db_path=self.db_path) + failure = CommandError(CommandResult( + ("docker", "inspect"), None, 1, stderr="private daemon detail", + )) + + with mock.patch.object(runner.docker, "container_dns_details", side_effect=failure): + result, stderr = self._run(runner) + + self.assertEqual(result, 1) + self.assertEqual(runner.failures[0].reason, "docker-endpoint-recreate-blocked") + self.assertIn("Could not check whether this update would stop the Docker endpoint", stderr) + self.assertNotIn("private daemon detail", stderr) + self.assertEqual((stack / "docker-compose.yml").read_text(), original_compose) + self.assertEqual(self.wud_file.read_text(), f"{WUD_IMAGE}\n") + self.assertNotRegex(self.calls(), MUTATION_CALLS) + + def test_web_apply_blocks_docker_endpoint_with_self_protection_enabled(self) -> None: + self._endpoint_stack() + self.wud_file.write_text(f"{PROXY_IMAGE}\n") + runner = self.make_runner(db_path=self.db_path) + runner.options = replace(runner.options, protected_container="") + + result, _stderr = self._run(runner) + + self.assertEqual(result, 1) + self.assertEqual( + [f.reason for f in runner.failures], ["docker-endpoint-recreate-blocked"], + ) + self.assertNotRegex(self.calls(), MUTATION_CALLS) + + def test_dry_run_reports_docker_endpoint_without_writing_state(self) -> None: + self._endpoint_stack() + self.wud_file.write_text(f"{PROXY_IMAGE}\n") + runner = self.make_runner(db_path=self.db_path) + runner.options = replace(runner.options, dry_run=True) + + result, stderr = self._run(runner) + + self.assertEqual(result, 1) + self.assertIn("Docker endpoint WUDup uses (DOCKER_HOST", stderr) + self.assertFalse(self.db_path.exists()) + self.assertEqual(self.wud_file.read_text(), f"{PROXY_IMAGE}\n") + self.assertNotRegex(self.calls(), MUTATION_CALLS) + + def test_scope_container_lookup_failure_fails_closed(self) -> None: + self._endpoint_stack() + self.wud_file.write_text(f"{WUD_IMAGE}\n") + runner = self.make_runner(db_path=self.db_path) + update_scope = runner.lifecycle._update_scope + failing_ps = mock.patch.object( + runner.compose, "ps_quiet_checked", + side_effect=CommandError(CommandResult(("docker", "compose"), None, 1)), + ) + self.addCleanup(failing_ps.stop) + + def scope_then_fail_container_lookup(*args, **kwargs): + scope = update_scope(*args, **kwargs) + if kwargs.get("strict"): + failing_ps.start() + return scope + + with mock.patch.object( + runner.lifecycle, "_update_scope", side_effect=scope_then_fail_container_lookup, + ): + result, stderr = self._run(runner) + + self.assertEqual(result, 1) + self.assertEqual(runner.failures[0].reason, "docker-endpoint-recreate-blocked") + self.assertIn("Could not check whether this update would stop the Docker endpoint", stderr) + self.assertNotRegex(self.calls(), MUTATION_CALLS) + + def test_cli_scope_discovery_failure_uses_endpoint_reason(self) -> None: + self._endpoint_stack() + self.wud_file.write_text(f"{WUD_IMAGE}\n") + runner = self.make_runner(db_path=self.db_path) + update_scope = runner.lifecycle._update_scope + failure = CommandError(CommandResult(("docker", "compose"), None, 1)) + + def fail_strict_scope(*args, **kwargs): + if kwargs.get("strict"): + raise failure + return update_scope(*args, **kwargs) + + with mock.patch.object(runner.lifecycle, "_update_scope", side_effect=fail_strict_scope): + result, stderr = self._run(runner) + + self.assertEqual(result, 1) + self.assertEqual(runner.failures[0].reason, "docker-endpoint-recreate-blocked") + self.assertIn("Could not verify the update's recreate scope", stderr) + self.assertNotRegex(self.calls(), MUTATION_CALLS) + + def test_non_object_endpoint_details_fail_closed(self) -> None: + self._endpoint_stack() + (self.fake_root / "containers/cid-wud.dns.json").write_text("[]\n") + self.wud_file.write_text(f"{WUD_IMAGE}\n") + runner = self.make_runner(db_path=self.db_path) + + result, _stderr = self._run(runner) + + self.assertEqual(result, 1) + self.assertEqual(runner.failures[0].reason, "docker-endpoint-recreate-blocked") + self.assertNotRegex(self.calls(), MUTATION_CALLS) + + def test_unreadable_endpoint_details_fail_closed(self) -> None: + self._endpoint_stack() + (self.fake_root / "containers/cid-wud.dns.json").write_text("not json\n") + self.wud_file.write_text(f"{WUD_IMAGE}\n") + runner = self.make_runner(db_path=self.db_path) + + result, _stderr = self._run(runner) + + self.assertEqual(result, 1) + self.assertEqual(runner.failures[0].reason, "docker-endpoint-recreate-blocked") + self.assertNotRegex(self.calls(), MUTATION_CALLS) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/update_from_wud_helpers.py b/tests/update_from_wud_helpers.py index b0418da4..af82bafe 100644 --- a/tests/update_from_wud_helpers.py +++ b/tests/update_from_wud_helpers.py @@ -100,6 +100,7 @@ def setUp(self) -> None: (self.fake_root / "calls.log").write_text("", encoding="utf-8") self.env = os.environ.copy() + self.env.pop("DOCKER_HOST", None) self.env["FAKE_DOCKER_ROOT"] = str(self.fake_root) self.env["PATH"] = f"{self.repo_root / 'tests' / 'fakes'}:{self.env['PATH']}" self.env["PYTHONPATH"] = str(self.repo_root / "src")