976 lines
36 KiB
Python
976 lines
36 KiB
Python
"""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")
|