Files
creator-hub/cmd/docker_gateway/docker_client.py
T

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")