339 lines
13 KiB
Python
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()
|