"""Minimal Docker Engine client and generation-fenced network helpers.""" from __future__ import annotations import http.client import json import logging import os import re import socket import threading from collections.abc import Callable from dataclasses import dataclass, field from ipaddress import ip_interface from urllib.parse import quote, urlencode MANAGED_LABEL = "io.creatorhub.managed" RUNTIME_ID_LABEL = "io.creatorhub.runtime-id" DISPLAY_NAME_LABEL = "io.creatorhub.display-name" BINDING_VERSION_LABEL = "io.creatorhub.binding-version" NETWORK_EXIT_LABEL = "io.creatorhub.network-exit-id" PROXY_PORT_LABEL = "io.creatorhub.proxy-port" NETWORK_ID_LABEL = "io.creatorhub.network-id" NETWORK_ROLE_LABEL = "io.creatorhub.network-role" GATEWAY_MEMBER_LABEL = "io.creatorhub.gateway-member" BROWSER_NETWORK_ROLE = "browser" BROWSER_PROXY_HOST = "docker-gateway" NAME_PREFIX = "creatorhub-browser-" RESERVATION_PREFIX = "creatorhub-reservation-" RESERVATION_LABEL = "io.creatorhub.alias-reservation" RESERVATION_GENERATION_LABEL = "io.creatorhub.reservation-generation" RESERVATION_OWNER_LABEL = "io.creatorhub.reservation-owner" RUNTIME_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$") NETWORK_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") LOG = logging.getLogger("creatorhub.docker") class DockerError(RuntimeError): def __init__(self, message: str, status: int | None = None) -> None: super().__init__(message) self.status = status class GenerationConflict(DockerError): pass class NetworkSetupError(DockerError): def __init__(self, message: str, generation: TenantNetworkGeneration) -> None: super().__init__(message) self.generation = generation class UnmanagedContainer(DockerError): pass @dataclass(frozen=True) class DockerResponse: status: int reason: str body: bytes class UnixHTTPConnection(http.client.HTTPConnection): def __init__(self, socket_path: str, timeout: float) -> None: super().__init__("docker", timeout=timeout) self.socket_path = socket_path def connect(self) -> None: self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.settimeout(self.timeout) self.sock.connect(self.socket_path) @dataclass class TenantNetworkGeneration: id: str = "" name: str = "" created: bool = False connected_self: bool = False connected_runtime: bool = False gateway_members: list[str] = field(default_factory=list) self_member: str = "" runtime_attached: bool = False class DockerClient: def __init__(self, socket_path: str, api_version: str = "v1.43") -> None: self.socket_path = socket_path self.base_path = "/" + api_version.strip("/") def request( self, method: str, path: str, payload: object | None = None, timeout: float = 30.0, body_limit: int = 16 * 1024 * 1024, ) -> DockerResponse: encoded = ( None if payload is None else json.dumps(payload, separators=(",", ":")).encode() ) connection: UnixHTTPConnection | None = None try: connection = UnixHTTPConnection(self.socket_path, timeout) headers = {"Accept": "application/json"} if encoded is not None: headers["Content-Type"] = "application/json" connection.request(method, self.base_path + path, encoded, headers) response = connection.getresponse() body = response.read(body_limit + 1) if len(body) > body_limit: raise DockerError( "Docker response exceeded the configured limit", response.status ) return DockerResponse(response.status, response.reason, body) except (OSError, http.client.HTTPException) as exc: raise DockerError("Docker API request failed") from exc finally: if connection is not None: connection.close() def expect( self, method: str, path: str, payload: object | None = None, allowed: tuple[int, ...] = (204,), ) -> None: response = self.request(method, path, payload) if response.status in allowed: return if response.status == 404: raise FileNotFoundError(path) raise DockerError( f"Docker returned HTTP {response.status}: {response.body[:4096].decode('utf-8', 'replace').strip()}", response.status, ) def pull_if_missing(self, image: str) -> None: encoded = quote(image, safe="") response = self.request("GET", f"/images/{encoded}/json") if response.status == 200: return if response.status != 404: raise DockerError( f"inspect image returned HTTP {response.status}", response.status ) repository, tag = split_image_ref(image) query = {"fromImage": image if "@" in image else repository} if "@" not in image and tag: query["tag"] = tag response = self.request( "POST", "/images/create?" + urlencode(query), timeout=600.0, body_limit=64 * 1024 * 1024, ) if response.status != 200: raise DockerError( f"pull image returned HTTP {response.status}: {response.body[:4096].decode('utf-8', 'replace').strip()}", response.status, ) def managed_container_state( self, alias: str ) -> tuple[str, dict[str, str], dict[str, str]]: if not RUNTIME_ID_RE.fullmatch(alias): raise ValueError("invalid runtime id") response = self.request( "GET", f"/containers/{quote(NAME_PREFIX + alias, safe='')}/json" ) if response.status == 404: raise FileNotFoundError(alias) if response.status != 200: raise DockerError( f"Docker inspect returned HTTP {response.status}", response.status ) try: inspected = json.loads(response.body) labels = inspected["Config"]["Labels"] or {} container_id = inspected["Id"] networks = inspected.get("NetworkSettings", {}).get("Networks", {}) or {} if ( not isinstance(labels, dict) or not isinstance(networks, dict) or not isinstance(container_id, str) ): raise TypeError("Docker inspect container metadata is invalid") if not all( isinstance(key, str) and isinstance(value, str) for key, value in labels.items() ): raise TypeError("Docker inspect labels are invalid") except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: raise DockerError( "Docker inspect response is invalid", response.status ) from exc if labels.get(MANAGED_LABEL) != "true" or labels.get(RUNTIME_ID_LABEL) != alias: raise UnmanagedContainer("refusing to operate on an unowned container") network_ids: dict[str, str] = {} for name, value in networks.items(): if ( not isinstance(name, str) or not isinstance(value, dict) or not isinstance(value.get("NetworkID", ""), str) ): raise DockerError("Docker inspect network metadata is invalid") network_ids[name] = value.get("NetworkID", "") return container_id, labels, network_ids def managed_container(self, alias: str) -> tuple[str, dict[str, str]]: container_id, labels, _ = self.managed_container_state(alias) return container_id, labels def trusted_gateway_member(self, container_id: str) -> bool: response = self.request( "GET", f"/containers/{quote(container_id, safe='')}/json" ) if response.status != 200: return False try: inspected = json.loads(response.body) if not isinstance(inspected, dict): return False config = inspected.get("Config", {}) labels = config.get("Labels", {}) if isinstance(config, dict) else {} return bool( isinstance(inspected.get("Id"), str) and isinstance(labels, dict) and labels.get(GATEWAY_MEMBER_LABEL) == "true" ) except (TypeError, ValueError, json.JSONDecodeError): return False def container_network_address(self, container_id: str, network_id: str) -> str: response = self.request( "GET", f"/containers/{quote(container_id, safe='')}/json" ) if response.status == 404: raise FileNotFoundError("Docker container is missing") if response.status != 200: raise DockerError("inspect browser container failed", response.status) try: inspected = json.loads(response.body) settings = inspected["NetworkSettings"] networks = settings["Networks"] if not isinstance(networks, dict): raise TypeError("Docker container networks are invalid") for value in networks.values(): if isinstance(value, dict) and value.get("NetworkID") == network_id: address = value.get("IPAddress") if isinstance(address, str) and address: return address except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: raise DockerError("Docker container network metadata is invalid") from exc raise GenerationConflict("browser container is not attached to its network") def inspect_tenant_network( self, base: str, alias: str, binding_version: int, runtime_id: str, self_name: str, expected_id: str = "", allow_unversioned: bool = False, ) -> tuple[TenantNetworkGeneration, dict[str, str], bool]: name = tenant_network_name(base, alias) generation = TenantNetworkGeneration(id=expected_id, name=name) reference = expected_id or name response = self.request("GET", f"/networks/{quote(reference, safe='')}") if response.status == 404: return generation, {}, False if response.status != 200: raise DockerError( f"Docker network inspect returned HTTP {response.status}", response.status, ) try: network = json.loads(response.body) labels = network["Labels"] or {} containers = network.get("Containers") or {} network_id = network["Id"] network_name = network["Name"] if ( not isinstance(labels, dict) or not isinstance(containers, dict) or not isinstance(network_id, str) or not isinstance(network_name, str) ): raise TypeError("Docker network metadata is invalid") if not all( isinstance(key, str) and isinstance(value, str) for key, value in labels.items() ): raise TypeError("Docker network labels are invalid") except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: raise DockerError("Docker network inspect response is invalid") from exc if ( not network_id or network_name != name or network.get("Driver") != "bridge" or network.get("Internal") or network.get("Attachable") or network.get("Ingress") or labels.get(MANAGED_LABEL) != "true" or labels.get(NETWORK_ROLE_LABEL) != BROWSER_NETWORK_ROLE or labels.get(RUNTIME_ID_LABEL) != alias ): raise UnmanagedContainer( "refusing to operate on an unowned browser network" ) if expected_id and network_id != expected_id: raise GenerationConflict("network generation does not match request") network_version = labels.get(BINDING_VERSION_LABEL, "") if network_version != str(binding_version) and not ( allow_unversioned and not network_version ): raise GenerationConflict("network generation does not match request") generation.id = network_id addresses: dict[str, str] = {} for member_id, member in containers.items(): if not isinstance(member, dict): raise DockerError("Docker network member is invalid") addresses[member_id] = str(member.get("IPv4Address", "")) if member_id == runtime_id: generation.runtime_attached = True continue if not self.trusted_gateway_member(member_id): raise GenerationConflict( "isolated network contains an untrusted member" ) generation.gateway_members.append(member_id) if same_container_reference( member_id, str(member.get("Name", "")), self_name ): generation.self_member = member_id return generation, addresses, True def _finish_network_create( self, base: str, alias: str, self_name: str, binding_version: int, runtime_id: str, response: DockerResponse, generation: TenantNetworkGeneration, ) -> TenantNetworkGeneration: try: created_id = json.loads(response.body)["Id"] except (KeyError, TypeError, json.JSONDecodeError) as exc: try: observed, _, observed_exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name ) except ( DockerError, FileNotFoundError, OSError, TypeError, ValueError, KeyError, ): observed_exists = False observed = TenantNetworkGeneration(name=generation.name) if observed_exists: generation = preserve_generation(generation, observed) raise NetworkSetupError( "create isolated browser network returned no id", generation ) from exc if not isinstance(created_id, str) or not created_id: try: observed, _, observed_exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name ) except ( DockerError, FileNotFoundError, OSError, TypeError, ValueError, KeyError, ) as exc: raise NetworkSetupError( "create isolated browser network returned no id", generation ) from exc if observed_exists: generation = preserve_generation(generation, observed) raise NetworkSetupError( "create isolated browser network returned no id", generation ) generation.id = created_id generation.created = True try: observed, _, observed_exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name, created_id ) except ( DockerError, FileNotFoundError, OSError, TypeError, ValueError, KeyError, ) as exc: raise NetworkSetupError( "created browser network could not be verified", generation ) from exc generation = preserve_generation(generation, observed) if not observed_exists: raise NetworkSetupError("created browser network disappeared", generation) return generation def ensure_tenant_network( self, base: str, alias: str, self_name: str, binding_version: int, runtime_id: str = "", expected_id: str = "", allow_unversioned: bool = False, ) -> tuple[TenantNetworkGeneration, str]: if not self_name: raise DockerError("gateway identity is invalid") generation, addresses, exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name, expected_id, allow_unversioned, ) if not exists: if expected_id: raise GenerationConflict("network generation does not match request") response = self.request( "POST", "/networks/create", { "Name": generation.name, "CheckDuplicate": True, "Driver": "bridge", "Labels": { MANAGED_LABEL: "true", NETWORK_ROLE_LABEL: BROWSER_NETWORK_ROLE, RUNTIME_ID_LABEL: alias, BINDING_VERSION_LABEL: str(binding_version), }, }, ) if response.status != 201: try: observed, _, observed_exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name ) except ( DockerError, FileNotFoundError, OSError, TypeError, ValueError, KeyError, ): observed_exists = False observed = TenantNetworkGeneration(name=generation.name) if not observed_exists: raise DockerError( "create isolated browser network failed", response.status ) generation = preserve_generation(generation, observed) else: generation = self._finish_network_create( base, alias, self_name, binding_version, runtime_id, response, generation, ) if runtime_id and not generation.runtime_attached: generation.connected_runtime = True try: self.expect( "POST", f"/networks/{quote(generation.id, safe='')}/connect", {"Container": runtime_id}, (200,), ) except ( DockerError, FileNotFoundError, OSError, TypeError, ValueError, KeyError, ) as exc: raise self._network_setup_error( base, alias, binding_version, runtime_id, self_name, generation, "connecting the browser to its isolated network failed", ) from exc generation.runtime_attached = True if not generation.self_member: generation.connected_self = True try: self.expect( "POST", f"/networks/{quote(generation.id, safe='')}/connect", { "Container": self_name, "EndpointConfig": {"Aliases": [BROWSER_PROXY_HOST]}, }, (200,), ) except ( DockerError, FileNotFoundError, OSError, TypeError, ValueError, KeyError, ) as exc: raise self._network_setup_error( base, alias, binding_version, runtime_id, self_name, generation, "connecting the gateway to its isolated network failed", ) from exc try: observed, addresses, observed_exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name, generation.id, allow_unversioned, ) generation = preserve_generation(generation, observed) if not observed_exists or not generation.self_member: raise NetworkSetupError( "Docker did not connect gateway to the isolated network", generation ) bind_host = str(ip_interface(addresses[generation.self_member]).ip) except NetworkSetupError: raise except ( DockerError, FileNotFoundError, OSError, TypeError, ValueError, KeyError, ) as exc: raise NetworkSetupError( "Docker could not verify the isolated network", generation ) from exc return generation, bind_host def _network_setup_error( self, base: str, alias: str, binding_version: int, runtime_id: str, self_name: str, generation: TenantNetworkGeneration, message: str, ) -> NetworkSetupError: try: observed, _, exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name, generation.id ) generation = ( preserve_generation(generation, observed) if exists else generation ) except ( DockerError, FileNotFoundError, OSError, TypeError, ValueError, KeyError, ) as verification_error: return NetworkSetupError( f"{message}; network state verification failed: {verification_error}", generation, ) return NetworkSetupError(message, generation) def disconnect_member( self, base: str, alias: str, binding_version: int, runtime_id: str, generation: TenantNetworkGeneration, member: str, self_name: str, missing_ok: bool = False, ) -> TenantNetworkGeneration: current, _, exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name, generation.id ) if not exists: if missing_ok: return current raise GenerationConflict("isolated network generation is missing") if not same_network_members(current, generation): raise GenerationConflict("isolated network generation changed") if not member_present(current, member, runtime_id): return current self.expect( "POST", f"/networks/{quote(generation.id, safe='')}/disconnect", {"Container": member, "Force": True}, (200,), ) observed, _, observed_exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name, generation.id ) if not observed_exists or not same_network_members( observed, generation_without_member(generation, member, runtime_id) ): raise GenerationConflict( "Docker retained an isolated network member after disconnect" ) return observed def delete_tenant_network( self, base: str, alias: str, binding_version: int, runtime_id: str, generation: TenantNetworkGeneration, self_name: str, missing_ok: bool = False, ) -> None: current, _, exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name, generation.id ) if not exists: if missing_ok: return raise GenerationConflict("isolated network generation is missing") if current.runtime_attached or current.gateway_members: raise GenerationConflict("isolated network still has members") self.expect( "DELETE", f"/networks/{quote(generation.id, safe='')}", allowed=(204,) ) _, _, exists = self.inspect_tenant_network( base, alias, binding_version, runtime_id, self_name, generation.id ) if exists: raise DockerError("Docker retained isolated browser network") def split_image_ref(ref: str) -> tuple[str, str]: if "@" in ref: return ref.split("@", 1)[0], ref.split("@", 1)[1] colon = ref.rfind(":") slash = ref.rfind("/") return (ref[:colon], ref[colon + 1 :]) if colon > slash else (ref, "") def tenant_network_name(base: str, alias: str) -> str: name = f"{base}-{alias}" if not NETWORK_NAME_RE.fullmatch(name): raise ValueError("isolated browser network name is invalid") return name def same_container_reference(container_id: str, name: str, reference: str) -> bool: return bool( reference and ( container_id == reference or name == reference or container_id.startswith(reference) or reference.startswith(container_id) ) ) def preserve_generation( known: TenantNetworkGeneration, observed: TenantNetworkGeneration ) -> TenantNetworkGeneration: if not observed.id: observed.id = known.id if not observed.name: observed.name = known.name observed.created |= known.created observed.runtime_attached |= known.runtime_attached observed.connected_runtime |= known.connected_runtime observed.connected_self |= known.connected_self if not observed.self_member: observed.self_member = known.self_member for member in known.gateway_members: if not member_present(observed, member, ""): observed.gateway_members.append(member) return observed def same_network_members( current: TenantNetworkGeneration, expected: TenantNetworkGeneration ) -> bool: return ( current.id == expected.id and current.name == expected.name and current.runtime_attached == expected.runtime_attached and current.self_member == expected.self_member and set(current.gateway_members) == set(expected.gateway_members) ) def member_present( generation: TenantNetworkGeneration, member: str, runtime_id: str ) -> bool: if ( generation.runtime_attached and runtime_id and same_container_reference(member, "", runtime_id) ): return True return any( same_container_reference(existing, "", member) for existing in generation.gateway_members ) or same_container_reference(member, "", generation.self_member) def generation_without_member( generation: TenantNetworkGeneration, member: str, runtime_id: str ) -> TenantNetworkGeneration: result = TenantNetworkGeneration( id=generation.id, name=generation.name, created=generation.created, connected_self=generation.connected_self, connected_runtime=generation.connected_runtime, gateway_members=list(generation.gateway_members), self_member=generation.self_member, runtime_attached=generation.runtime_attached, ) if runtime_id and same_container_reference(member, "", runtime_id): result.runtime_attached = False if same_container_reference(member, "", result.self_member): result.self_member = "" result.gateway_members = [ x for x in result.gateway_members if not same_container_reference(x, "", member) ] return result def random_reservation_generation() -> str: return os.urandom(16).hex() class AliasReservationManager: def __init__(self, docker: DockerClient, self_name: str) -> None: self.docker = docker self.self_name = self_name self._locks: dict[str, threading.Lock] = {} self._locks_guard = threading.Lock() def acquire(self, alias: str) -> Callable[[], None]: with self._locks_guard: lock = self._locks.setdefault(alias, threading.Lock()) lock.acquire() generation = random_reservation_generation() created_id = "" try: response = self.docker.request( "GET", f"/containers/{quote(self.self_name, safe='')}/json" ) if response.status != 200: raise DockerError( "inspect trusted gateway for alias reservation", response.status ) inspected = json.loads(response.body) config = inspected.get("Config", {}) labels = config.get("Labels", {}) if isinstance(config, dict) else {} image = inspected.get("Image", "") if ( not isinstance(labels, dict) or not isinstance(image, str) or not image or labels.get(GATEWAY_MEMBER_LABEL) != "true" ): raise DockerError("inspect trusted gateway for alias reservation") reservation_payload = { "Image": image, "Labels": { RESERVATION_LABEL: "true", RUNTIME_ID_LABEL: alias, RESERVATION_GENERATION_LABEL: generation, RESERVATION_OWNER_LABEL: self.self_name, }, "HostConfig": {"NetworkMode": "none"}, } response = self.docker.request( "POST", "/containers/create?" + urlencode({"name": RESERVATION_PREFIX + alias}), reservation_payload, ) if response.status == 409 and self._reclaim_stale(alias): response = self.docker.request( "POST", "/containers/create?" + urlencode({"name": RESERVATION_PREFIX + alias}), reservation_payload, ) if response.status == 409: raise GenerationConflict("browser alias is already in use") if response.status != 201: raise DockerError("create alias reservation failed", response.status) created_id = json.loads(response.body).get("Id", "") if not isinstance(created_id, str) or not created_id: raise DockerError("Docker returned an invalid alias reservation id") check = self.docker.request( "GET", f"/containers/{quote(RESERVATION_PREFIX + alias, safe='')}/json" ) if check.status != 200: raise DockerError( "alias reservation could not be verified", check.status ) observed = json.loads(check.body) observed_config = observed.get("Config", {}) observed_labels = ( observed_config.get("Labels", {}) if isinstance(observed_config, dict) else {} ) if ( not isinstance(observed_labels, dict) or observed.get("Id") != created_id or observed_labels.get(RESERVATION_LABEL) != "true" or observed_labels.get(RESERVATION_GENERATION_LABEL) != generation ): raise GenerationConflict( "alias reservation generation is not immutable" ) except ( DockerError, FileNotFoundError, OSError, TypeError, ValueError, KeyError, ): try: self._reconcile(alias, generation, created_id) except ( DockerError, FileNotFoundError, OSError, TypeError, ValueError, KeyError, ): LOG.exception( "failed to reconcile alias reservation", extra={"alias": alias, "generation": generation}, ) lock.release() raise def release() -> None: try: self._reconcile(alias, generation, created_id) finally: lock.release() return release def _reclaim_stale(self, alias: str) -> bool: response = self.docker.request( "GET", f"/containers/{quote(RESERVATION_PREFIX + alias, safe='')}/json" ) if response.status == 404: return True if response.status != 200: raise DockerError("inspect alias reservation failed", response.status) try: observed = json.loads(response.body) config = observed.get("Config", {}) labels = config.get("Labels", {}) if isinstance(config, dict) else {} observed_id = observed.get("Id") except (TypeError, ValueError, json.JSONDecodeError) as exc: raise DockerError("alias reservation inspect response is invalid") from exc if ( not isinstance(labels, dict) or labels.get(RESERVATION_LABEL) != "true" or labels.get(RUNTIME_ID_LABEL) != alias or not isinstance(observed_id, str) or not observed_id ): raise GenerationConflict("alias reservation generation changed") owner = labels.get(RESERVATION_OWNER_LABEL) if not isinstance(owner, str) or not owner: return False if owner != self.self_name: owner_response = self.docker.request( "GET", f"/containers/{quote(owner, safe='')}/json" ) if owner_response.status == 200: return False if owner_response.status != 404: raise DockerError( "inspect alias reservation owner failed", owner_response.status ) self.docker.expect( "DELETE", f"/containers/{quote(observed_id, safe='')}?force=1&v=0", allowed=(204, 404), ) return ( self.docker.request( "GET", f"/containers/{quote(RESERVATION_PREFIX + alias, safe='')}/json" ).status == 404 ) def _reconcile(self, alias: str, generation: str, created_id: str) -> None: response = self.docker.request( "GET", f"/containers/{quote(RESERVATION_PREFIX + alias, safe='')}/json" ) if response.status == 404: return if response.status != 200: raise DockerError("inspect alias reservation failed", response.status) try: observed = json.loads(response.body) config = observed.get("Config", {}) labels = config.get("Labels", {}) if isinstance(config, dict) else {} observed_id = observed.get("Id") except (TypeError, ValueError, json.JSONDecodeError) as exc: raise DockerError("alias reservation inspect response is invalid") from exc if ( not isinstance(labels, dict) or not isinstance(observed_id, str) or labels.get(RESERVATION_LABEL) != "true" or labels.get(RUNTIME_ID_LABEL) != alias or labels.get(RESERVATION_GENERATION_LABEL) != generation or (created_id and observed_id != created_id) ): raise GenerationConflict("alias reservation generation changed") self.docker.expect( "DELETE", f"/containers/{quote(observed_id, safe='')}?force=1&v=0", allowed=(204,), ) if ( self.docker.request( "GET", f"/containers/{quote(RESERVATION_PREFIX + alias, safe='')}/json" ).status != 404 ): raise DockerError("Docker retained alias reservation")