Files
Dockhand_Backup/app/dockhand.py

339 lines
13 KiB
Python

"""Dockhand REST API client.
Endpoints verified against the Dockhand MCP server source (real API):
GET /api/stacks?env=<id> -> list stacks
GET /api/stacks/{name}/compose?env=<id> -> compose content
GET /api/stacks/{name}/env/raw?env=<id> -> raw .env content
POST /api/stacks/{name}/start|stop|down?env= -> lifecycle (SSE stream)
GET /api/containers/{id}/inspect?env=<id> -> full inspect incl. Mounts
There is NO GET /api/stacks/{name} — mounts/bind paths come from container
inspect, compose/env from their own endpoints.
Auth is session-based: POST /api/auth/login sets an HttpOnly cookie stored by
requests.Session. On 401 we log in once and retry.
"""
import json
import logging
import threading
import time
import urllib.parse
import requests
from . import settings
log = logging.getLogger("dockhand")
class DockhandError(Exception):
pass
def _q(name: str) -> str:
return urllib.parse.quote(name, safe="")
def _extract_text(data) -> str:
"""Compose/env endpoints may return raw text or a JSON wrapper."""
if isinstance(data, str):
return data
if isinstance(data, dict):
for key in ("content", "compose", "raw", "data", "env"):
val = data.get(key)
if isinstance(val, str):
return val
return ""
class DockhandClient:
def __init__(self):
self._session = requests.Session()
self._session.verify = settings.DOCKHAND_VERIFY_TLS
self._lock = threading.Lock()
self._logged_in = False
def _url(self, path: str) -> str:
return f"{settings.DOCKHAND_URL}{path}"
def _login(self):
with self._lock:
resp = self._session.post(
self._url("/api/auth/login"),
json={
"username": settings.DOCKHAND_USER,
"password": settings.DOCKHAND_PASS,
"provider": settings.DOCKHAND_PROVIDER,
},
timeout=30,
)
if resp.status_code >= 400:
raise DockhandError(
f"Dockhand login failed ({resp.status_code}): {resp.text[:200]}"
)
self._logged_in = True
def _request(self, method, path, *, stream=False, **kwargs):
params = kwargs.pop("params", {}) or {}
params.setdefault("env", settings.DOCKHAND_ENV_ID)
if not self._logged_in:
self._login()
resp = self._session.request(
method, self._url(path), params=params, stream=stream, timeout=120, **kwargs
)
if resp.status_code == 401:
self._logged_in = False
self._login()
resp = self._session.request(
method, self._url(path), params=params, stream=stream, timeout=120, **kwargs
)
return resp
def _get_json(self, path):
resp = self._request("GET", path)
if resp.status_code >= 400:
raise DockhandError(f"GET {path} -> HTTP {resp.status_code}: {resp.text[:200]}")
try:
return resp.json()
except ValueError:
return resp.text
def _run_sse(self, path, body=None) -> str:
resp = self._request("POST", path, stream=True, json=body,
headers={"Accept": "text/event-stream"})
if resp.status_code >= 400:
raise DockhandError(f"POST {path} -> HTTP {resp.status_code}: {resp.text[:200]}")
last = None
output = []
for raw in resp.iter_lines(decode_unicode=True):
if not raw:
continue
line = raw.strip()
if line.startswith("data:"):
line = line[len("data:"):].strip()
if not line:
continue
try:
obj = json.loads(line)
except ValueError:
output.append(line)
continue
last = obj
if isinstance(obj, dict) and obj.get("output"):
output.append(str(obj["output"]))
if isinstance(last, dict) and last.get("success") is False:
raise DockhandError(f"{path} failed: {last.get('error') or last}")
return "\n".join(output)
# -- public API ------------------------------------------------------
def list_stacks(self) -> list:
data = self._get_json("/api/stacks")
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("stacks", data.get("data", []))
return []
def get_compose(self, name: str) -> str:
try:
return _extract_text(self._get_json(f"/api/stacks/{_q(name)}/compose"))
except DockhandError as exc:
log.warning("compose fetch failed for %s: %s", name, exc)
return ""
def get_env_raw(self, name: str) -> str:
try:
return _extract_text(self._get_json(f"/api/stacks/{_q(name)}/env/raw"))
except DockhandError as exc:
log.warning("env fetch failed for %s: %s", name, exc)
return ""
def inspect_container(self, container_id: str) -> dict:
data = self._get_json(f"/api/containers/{_q(container_id)}/inspect")
if isinstance(data, dict):
# Some responses wrap the inspect object under data/container.
for key in ("data", "container", "inspect"):
inner = data.get(key)
if isinstance(inner, dict) and ("Mounts" in inner or "mounts" in inner):
return inner
return data
return {}
def status(self, name: str):
"""Current status of a stack, or None if unknown/unreachable."""
try:
for s in self.list_stacks():
if s.get("name") == name:
return s.get("status")
except Exception as exc: # noqa: BLE001
log.warning("status check failed for %s: %s", name, exc)
return None
def _lifecycle(self, name, action, ok_states, *, tolerate=(), body=None,
attempts=5) -> str:
"""Run a lifecycle action, then VERIFY via status — with retries.
Dockhand is known to drop the connection during UP even though the
operation completes server-side, so we always re-check the status
after a (possibly failed) call instead of trusting the response alone.
"""
path = f"/api/stacks/{_q(name)}/{action}"
last = None
for i in range(attempts):
try:
self._run_sse(path, body=body)
except (requests.exceptions.RequestException, DockhandError) as exc:
last = exc
log.warning("%s %s attempt %d/%d: %s", action, name, i + 1, attempts, exc)
st = self.status(name)
if st in ok_states:
return f"{action} ok (status={st})"
if i < attempts - 1:
time.sleep(min(2 ** i, 15))
st = self.status(name)
if st in ok_states:
return f"{action} ok (status={st})"
if st in tolerate:
log.warning("%s %s ended in tolerated state '%s'", action, name, st)
return f"{action} tolerated (status={st})"
raise DockhandError(
f"{action} {name} failed after {attempts} tries (status={st}); last={last}")
def start(self, name: str) -> str:
# running = fully up; partial (some containers up) is tolerated, not down.
return self._lifecycle(name, "start", ok_states={"running"},
tolerate={"partial"})
def stop(self, name: str) -> str:
return self._lifecycle(name, "stop", ok_states={"stopped", "created", "exited"})
def down(self, name: str) -> str:
return self._lifecycle(name, "down", ok_states={"stopped", "created", "exited"},
body={"removeVolumes": False})
# ------------------------------------------------------------------ mock
class MockDockhandClient:
"""Fake Dockhand for local UI testing when no credentials are set."""
def __init__(self):
self._stacks = {
"media": {"name": "media", "status": "running", "type": "internal",
"containerDetails": [{"id": "c_jelly", "name": "jellyfin", "state": "running"},
{"id": "c_sonarr", "name": "sonarr", "state": "running"}]},
"nextcloud": {"name": "nextcloud", "status": "running", "type": "internal",
"containerDetails": [{"id": "c_ncapp", "name": "app", "state": "running"},
{"id": "c_ncdb", "name": "db", "state": "running"}]},
"monitoring": {"name": "monitoring", "status": "stopped", "type": "git",
"containerDetails": [{"id": "c_graf", "name": "grafana", "state": "exited"}]},
}
self._inspect = {
"c_jelly": {"Mounts": [{"Type": "bind", "Source": "/mnt/user/appdata/jellyfin"},
{"Type": "bind", "Source": "/mnt/user/media"}]},
"c_sonarr": {"Mounts": [{"Type": "bind", "Source": "/mnt/user/appdata/sonarr"}]},
"c_ncapp": {"Mounts": [{"Type": "bind", "Source": "/mnt/user/appdata/nextcloud"}]},
"c_ncdb": {"Mounts": [{"Type": "bind", "Source": "/mnt/user/appdata/nextcloud-db"},
{"Type": "volume", "Source": "some-named-volume"}]},
"c_graf": {"Mounts": [{"Type": "bind", "Source": "/mnt/user/appdata/grafana"}]},
}
def list_stacks(self):
return list(self._stacks.values())
def get_compose(self, name):
return f"# mock compose for {name}\nservices:\n {name}:\n image: example\n"
def get_env_raw(self, name):
return f"# mock env for {name}\nEXAMPLE=1\n"
def inspect_container(self, container_id):
return self._inspect.get(container_id, {"Mounts": []})
def start(self, name):
if name in self._stacks:
self._stacks[name]["status"] = "running"
return f"[mock] started {name}"
def stop(self, name):
if name in self._stacks:
self._stacks[name]["status"] = "stopped"
return f"[mock] stopped {name}"
def down(self, name):
if name in self._stacks:
self._stacks[name]["status"] = "stopped"
return f"[mock] down {name}"
# --------------------------------------------------------------- helpers
def container_ids(stack: dict) -> list:
"""Container IDs for a stack, from containerDetails (or containers)."""
ids = []
for c in stack.get("containerDetails") or []:
if isinstance(c, dict):
cid = c.get("id") or c.get("Id") or c.get("containerId")
if cid:
ids.append(cid)
if not ids:
for c in stack.get("containers") or []:
if isinstance(c, str):
ids.append(c)
elif isinstance(c, dict):
cid = c.get("id") or c.get("Id")
if cid:
ids.append(cid)
return ids
# System/infra bind mounts that are noise (or impossible) to back up.
_EXCLUDE_EXACT = {
"/var/run/docker.sock", "/run/docker.sock",
"/etc/localtime", "/etc/timezone", "/etc/hosts",
"/etc/resolv.conf", "/etc/hostname",
}
_EXCLUDE_PREFIX = ("/proc", "/sys", "/dev")
def _is_noise_bind(source: str) -> bool:
s = str(source)
if s in _EXCLUDE_EXACT or s.endswith(".sock"):
return True
return any(s == p or s.startswith(p + "/") for p in _EXCLUDE_PREFIX)
def bind_paths_for_stack(cli, stack: dict) -> list:
"""Resolve a stack's host bind-mount paths via container inspect.
Only real bind mounts (Type == 'bind') are returned; named volumes and
system/socket mounts (docker.sock, /etc/localtime, /proc, ...) are skipped.
"""
paths = []
for cid in container_ids(stack):
try:
insp = cli.inspect_container(cid)
except Exception as exc: # noqa: BLE001
log.warning("inspect failed for %s: %s", cid, exc)
continue
mounts = insp.get("Mounts") or insp.get("mounts") or []
for m in mounts:
if not isinstance(m, dict):
continue
mtype = (m.get("Type") or m.get("type") or "").lower()
source = m.get("Source") or m.get("source")
if not source or _is_noise_bind(source):
continue
# bind = explicit bind, or (no type given) an absolute host path
if mtype == "bind" or (not mtype and str(source).startswith("/")):
if source not in paths:
paths.append(source)
return paths
def _make_client():
if settings.dockhand_configured():
return DockhandClient()
return MockDockhandClient()
client = _make_client()