CI / ruff · mypy · pytest (pull_request) Successful in 1m11s
- docker-socket-proxy в Compose (только GET /containers/*), сокет только у него - app/runtime/docker.py: read-only клиент; видны только контейнеры с меткой tgit.managed=true, чужие id отклоняются до запроса - app/runtime/checks.py: running, health, рестарты за окно, CPU/RAM, ошибки в логе, HTTP-проба; у каждой проверки свой таймаут, отчёт всегда полный - app/runtime/snapshots.py: снапшоты раз в 15 с в container_snapshots - бот: список с 🟢🟡🔴⚪, карточка, «🩺 Запустить проверки», «🔄 Обновить» - деплой ставит метки tgit.sha / tgit.deployed_at на контейнер бота - имя джоба CD без неотрисованного выражения Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
10 KiB
Python
284 lines
10 KiB
Python
"""Read-only Docker Engine API client. The bot talks to docker-socket-proxy (GET only), never to the socket itself.
|
||
|
||
Only containers carrying the managed label are visible: the host also runs other projects (Vobot, meditron),
|
||
and an id outside the list is refused before any request goes out.
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import struct
|
||
from dataclasses import dataclass
|
||
from datetime import UTC, datetime
|
||
from enum import StrEnum
|
||
from types import TracebackType
|
||
from typing import Any, Self
|
||
|
||
import httpx
|
||
|
||
from app.config import DockerSettings
|
||
|
||
SHA_LABEL = "tgit.sha"
|
||
DEPLOYED_LABEL = "tgit.deployed_at"
|
||
HEALTH_URL_LABEL = "tgit.health_url"
|
||
SERVICE_LABEL = "com.docker.compose.service"
|
||
|
||
_FRACTION = re.compile(r"(\.\d{6})\d+") # Docker gives nanoseconds, datetime takes microseconds
|
||
|
||
|
||
class DockerError(Exception):
|
||
pass
|
||
|
||
|
||
class Light(StrEnum):
|
||
"""S-1: 🟢 healthy / 🟡 starting / 🔴 unhealthy or crashed / ⚪ stopped."""
|
||
|
||
GREEN = "🟢"
|
||
YELLOW = "🟡"
|
||
RED = "🔴"
|
||
WHITE = "⚪"
|
||
|
||
|
||
def parse_time(value: str | None) -> datetime | None:
|
||
if not value or value.startswith("0001-"): # Docker's zero time: never started / no deploy label
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(_FRACTION.sub(r"\1", value).replace("Z", "+00:00")).astimezone(UTC)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def light(state: str, health: str | None, exit_code: int = 0) -> Light:
|
||
if state == "running":
|
||
if health == "unhealthy":
|
||
return Light.RED
|
||
if health == "starting":
|
||
return Light.YELLOW
|
||
return Light.GREEN
|
||
if state in ("restarting", "dead") or (state == "exited" and exit_code != 0):
|
||
return Light.RED
|
||
if state == "created":
|
||
return Light.YELLOW
|
||
return Light.WHITE
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Container:
|
||
"""A list entry (GET /containers/json): enough for the overview, no extra request per container."""
|
||
|
||
id: str
|
||
name: str
|
||
service: str
|
||
image: str
|
||
state: str # created | running | paused | restarting | removing | exited | dead
|
||
health: str | None # starting | healthy | unhealthy | None — no healthcheck
|
||
exit_code: int
|
||
labels: dict[str, str]
|
||
|
||
@property
|
||
def short_id(self) -> str:
|
||
return self.id[:12]
|
||
|
||
@property
|
||
def light(self) -> Light:
|
||
return light(self.state, self.health, self.exit_code)
|
||
|
||
|
||
_HEALTH_IN_STATUS = re.compile(r"\((healthy|unhealthy|health: starting)\)")
|
||
_EXIT_IN_STATUS = re.compile(r"^Exited \((-?\d+)\)")
|
||
|
||
|
||
def _health_from_status(status: str) -> str | None:
|
||
"""`Up 2 hours (healthy)` → healthy. The list endpoint has no structured health field."""
|
||
m = _HEALTH_IN_STATUS.search(status)
|
||
if m is None:
|
||
return None
|
||
return "starting" if m.group(1) == "health: starting" else m.group(1)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Details:
|
||
"""GET /containers/{id}/json — the card (S-2) and most checks."""
|
||
|
||
id: str
|
||
name: str
|
||
service: str
|
||
image: str
|
||
state: str
|
||
health: str | None
|
||
exit_code: int
|
||
restart_count: int
|
||
started_at: datetime | None
|
||
finished_at: datetime | None
|
||
oom_killed: bool
|
||
labels: dict[str, str]
|
||
|
||
@property
|
||
def light(self) -> Light:
|
||
return light(self.state, self.health, self.exit_code)
|
||
|
||
@property
|
||
def sha(self) -> str | None:
|
||
return self.labels.get(SHA_LABEL) or None
|
||
|
||
@property
|
||
def deployed_at(self) -> datetime | None:
|
||
return parse_time(self.labels.get(DEPLOYED_LABEL))
|
||
|
||
@property
|
||
def health_url(self) -> str | None:
|
||
return self.labels.get(HEALTH_URL_LABEL) or None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Stats:
|
||
cpu_pct: float # of one CPU × online CPUs, as `docker stats` shows it
|
||
mem_bytes: int
|
||
mem_limit: int # the cgroup limit; the host's RAM when the container has none
|
||
|
||
@property
|
||
def mem_pct(self) -> float:
|
||
return 100.0 * self.mem_bytes / self.mem_limit if self.mem_limit else 0.0
|
||
|
||
|
||
def parse_stats(data: dict[str, Any]) -> Stats:
|
||
cpu, pre = data.get("cpu_stats") or {}, data.get("precpu_stats") or {}
|
||
cpu_delta = (cpu.get("cpu_usage") or {}).get("total_usage", 0) - (pre.get("cpu_usage") or {}).get("total_usage", 0)
|
||
sys_delta = cpu.get("system_cpu_usage", 0) - pre.get("system_cpu_usage", 0)
|
||
cpus = cpu.get("online_cpus") or len((cpu.get("cpu_usage") or {}).get("percpu_usage") or []) or 1
|
||
cpu_pct = 100.0 * cpu_delta / sys_delta * cpus if cpu_delta > 0 and sys_delta > 0 else 0.0
|
||
mem = data.get("memory_stats") or {}
|
||
extra = mem.get("stats") or {}
|
||
# Page cache isn't pressure: cgroup v2 reports inactive_file, v1 total_inactive_file (docker CLI does the same)
|
||
cache = extra.get("inactive_file", extra.get("total_inactive_file", 0))
|
||
usage = max(int(mem.get("usage", 0)) - int(cache), 0)
|
||
return Stats(cpu_pct=round(cpu_pct, 1), mem_bytes=usage, mem_limit=int(mem.get("limit", 0)))
|
||
|
||
|
||
def demux_logs(raw: bytes) -> str:
|
||
"""Split Docker's multiplexed stream (8-byte header per frame) — containers without a TTY send it this way."""
|
||
if raw[:1] not in (b"\x00", b"\x01", b"\x02") or len(raw) < 8 or raw[1:4] != b"\x00\x00\x00":
|
||
return raw.decode(errors="replace") # TTY container: plain bytes
|
||
out: list[bytes] = []
|
||
pos = 0
|
||
while pos + 8 <= len(raw):
|
||
(size,) = struct.unpack(">I", raw[pos + 4 : pos + 8])
|
||
out.append(raw[pos + 8 : pos + 8 + size])
|
||
pos += 8 + size
|
||
return b"".join(out).decode(errors="replace")
|
||
|
||
|
||
class DockerClient:
|
||
def __init__(self, settings: DockerSettings, transport: httpx.AsyncBaseTransport | None = None) -> None:
|
||
self._s = settings
|
||
self._http = httpx.AsyncClient(base_url=settings.url, timeout=settings.timeout, transport=transport)
|
||
self._known: set[str] = set() # ids from the last listing: the only ones card/logs/stats may touch
|
||
|
||
async def __aenter__(self) -> Self:
|
||
return self
|
||
|
||
async def __aexit__(
|
||
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
||
) -> None:
|
||
await self.aclose()
|
||
|
||
async def aclose(self) -> None:
|
||
await self._http.aclose()
|
||
|
||
async def containers(self) -> list[Container]:
|
||
"""All managed containers, stopped ones too, sorted by name."""
|
||
filters = json.dumps({"label": [self._s.label]})
|
||
data = await self._get_json("/containers/json", params={"all": "1", "filters": filters})
|
||
result = []
|
||
for c in data:
|
||
labels = c.get("Labels") or {}
|
||
names = c.get("Names") or [c["Id"][:12]]
|
||
result.append(
|
||
Container(
|
||
id=c["Id"],
|
||
name=names[0].lstrip("/"),
|
||
service=labels.get(SERVICE_LABEL, ""),
|
||
image=c.get("Image", ""),
|
||
state=c.get("State", ""),
|
||
health=_health_from_status(c.get("Status", "")),
|
||
exit_code=int(m.group(1)) if (m := _EXIT_IN_STATUS.match(c.get("Status", ""))) else 0,
|
||
labels=labels,
|
||
)
|
||
)
|
||
self._known = {c.id for c in result}
|
||
return sorted(result, key=lambda c: c.name)
|
||
|
||
async def resolve(self, short_id: str) -> str:
|
||
"""Full id of a managed container by its 12-char prefix (callback data carries only that)."""
|
||
if not self._known:
|
||
await self.containers()
|
||
for full in self._known:
|
||
if full.startswith(short_id):
|
||
return full
|
||
await self.containers() # maybe recreated since the last listing
|
||
for full in self._known:
|
||
if full.startswith(short_id):
|
||
return full
|
||
raise DockerError(f"container {short_id} is not managed by tgit")
|
||
|
||
async def inspect(self, container_id: str) -> Details:
|
||
self._guard(container_id)
|
||
d = await self._get_json(f"/containers/{container_id}/json")
|
||
state = d.get("State") or {}
|
||
labels = (d.get("Config") or {}).get("Labels") or {}
|
||
return Details(
|
||
id=d["Id"],
|
||
name=d.get("Name", "").lstrip("/"),
|
||
service=labels.get(SERVICE_LABEL, ""),
|
||
image=(d.get("Config") or {}).get("Image", ""),
|
||
state=state.get("Status", ""),
|
||
health=(state.get("Health") or {}).get("Status"),
|
||
exit_code=int(state.get("ExitCode", 0)),
|
||
restart_count=int(d.get("RestartCount", 0)),
|
||
started_at=parse_time(state.get("StartedAt")),
|
||
finished_at=parse_time(state.get("FinishedAt")),
|
||
oom_killed=bool(state.get("OOMKilled", False)),
|
||
labels=labels,
|
||
)
|
||
|
||
async def stats(self, container_id: str) -> Stats:
|
||
"""One sample with a previous one to diff against (stream=false waits ~1 s for it)."""
|
||
self._guard(container_id)
|
||
return parse_stats(await self._get_json(f"/containers/{container_id}/stats", params={"stream": "false"}))
|
||
|
||
async def logs(self, container_id: str, since: datetime | None = None, tail: int | None = None) -> str:
|
||
self._guard(container_id)
|
||
params = {"stdout": "1", "stderr": "1"}
|
||
if since is not None:
|
||
params["since"] = str(int(since.timestamp()))
|
||
if tail is not None:
|
||
params["tail"] = str(tail)
|
||
response = await self._request(f"/containers/{container_id}/logs", params)
|
||
raw = response.content[: self._s.max_log_bytes]
|
||
return demux_logs(raw)
|
||
|
||
async def ping(self) -> bool:
|
||
try:
|
||
return (await self._request("/_ping", {})).text == "OK"
|
||
except DockerError:
|
||
return False
|
||
|
||
def _guard(self, container_id: str) -> None:
|
||
if container_id not in self._known:
|
||
raise DockerError(f"container {container_id[:12]} is not managed by tgit")
|
||
|
||
async def _get_json(self, path: str, params: dict[str, str] | None = None) -> Any:
|
||
response = await self._request(path, params or {})
|
||
try:
|
||
return response.json()
|
||
except ValueError as e:
|
||
raise DockerError(f"{path}: not JSON") from e
|
||
|
||
async def _request(self, path: str, params: dict[str, str]) -> httpx.Response:
|
||
try:
|
||
response = await self._http.get(path, params=params)
|
||
except httpx.TransportError as e: # timeouts included
|
||
raise DockerError(f"docker unreachable: {type(e).__name__}") from e
|
||
if response.status_code >= 400:
|
||
raise DockerError(f"{path}: HTTP {response.status_code} {response.text[:200]}")
|
||
return response
|