Skip to content
Merged
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
13 changes: 13 additions & 0 deletions docs/wiki/webui-container.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions src/wudup/container_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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("/")
}
17 changes: 17 additions & 0 deletions src/wudup/docker_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
from dataclasses import dataclass

from . import request_scope
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
108 changes: 87 additions & 21 deletions src/wudup/updater_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = ""
Expand All @@ -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:
Expand All @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions tests/fakes/docker
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading