One asset per image is deliberate (env A must not retract what env B still reports), but with the same image on a local and a Hawser-connected host nothing said where it ran: the asset was named after the image and carried no host at all. The sync now writes the asset description per Dockhand environment: the environment name, the Docker host's own hostname from GET /api/host (docker info Name) when it differs, connection type, host and public IP when set, the short image id, the containers (name + 12-char id as docker ps shows them) and stacks. Different image ids on two hosts show which one is still on the old build. Container ids change on every recreate and are shown, never matched on; Dockhand exposes no Docker daemon id and Podman has none. A failing /api/host only drops the hostname. ingest_image takes an optional description that replaces the digest line; the push path is unchanged. No schema change.
112 lines
5.2 KiB
Python
112 lines
5.2 KiB
Python
"""Dockhand client (Docker management UI with built-in Grype / Trivy scans).
|
||
|
||
Thin httpx wrapper, no DB writes — same contract as the other integration
|
||
clients. Read-only endpoints over Dockhand's cached scan results, plus one
|
||
that rescans:
|
||
|
||
* `GET /api/environments` — the Docker hosts Dockhand manages;
|
||
* `GET /api/host?env=<id>` — the Docker host's real hostname, for display;
|
||
* `GET /api/images?env=<id>` — only to tell a reachable host from one
|
||
Dockhand cannot reach (see dockhand_service);
|
||
* `GET /api/vulnerabilities/export?env=<id>&format=json` — that host's
|
||
findings, one row per (image, vulnerability id, package, version), for
|
||
images still present on the host (Dockhand ≥ 1.0.37, #415);
|
||
* `POST /api/vulnerabilities/scan-all?env=<id>` — rescans every image of
|
||
the host, sequentially. Dockhand has no scan schedule of its own (it scans
|
||
on pull / update / by hand), so the nightly sync calls this first.
|
||
|
||
Auth: `Authorization: Bearer dh_…`, an API token created in Dockhand under
|
||
the user's profile. The user needs `images:view` (and `environments:view` to
|
||
list the hosts; `images:inspect` for the rescan). An instance with auth
|
||
disabled needs no token.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import List
|
||
|
||
import httpx
|
||
|
||
|
||
# One image takes 10–60 s, a Grype DB update on the first scan longer. Bounded
|
||
# so a hung Dockhand cannot hold the nightly job (and its lock) forever.
|
||
SCAN_ALL_READ_TIMEOUT = 3 * 3600.0
|
||
|
||
|
||
class DockhandError(RuntimeError):
|
||
"""Connection / authentication / API failure, with Dockhand's message kept."""
|
||
|
||
|
||
class DockhandClient:
|
||
def __init__(self, base_url: str, token: str = "", verify_ssl: bool = True):
|
||
headers = {"Accept": "application/json"}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token.strip()}"
|
||
# Short connect, generous read: the export flattens every scan of a
|
||
# host in one response and is slow on a big one.
|
||
self._client = httpx.Client(
|
||
base_url=base_url.rstrip("/"), verify=verify_ssl, headers=headers,
|
||
timeout=httpx.Timeout(connect=8.0, read=120.0, write=30.0, pool=5.0),
|
||
)
|
||
|
||
def _request(self, method: str, path: str, timeout=None, **params) -> object:
|
||
try:
|
||
r = self._client.request(method, path, params=params,
|
||
timeout=timeout or self._client.timeout)
|
||
except httpx.HTTPError as e:
|
||
raise DockhandError(f"Dockhand unreachable: {e}") from e
|
||
if r.status_code in (401, 403):
|
||
raise DockhandError(f"Dockhand refused the token (HTTP {r.status_code}) — it needs "
|
||
"images:view and environments:view (images:inspect to rescan).")
|
||
if r.status_code >= 400:
|
||
raise DockhandError(f"Dockhand {path} failed (HTTP {r.status_code}): {r.text[:300]}")
|
||
try:
|
||
return r.json()
|
||
except ValueError as e:
|
||
# A reverse proxy's login page answers 200 with HTML.
|
||
raise DockhandError(f"Dockhand {path} returned no JSON — wrong URL?") from e
|
||
|
||
def _get(self, path: str, **params) -> object:
|
||
return self._request("GET", path, **params)
|
||
|
||
def environments(self) -> List[dict]:
|
||
got = self._get("/api/environments")
|
||
if not isinstance(got, list):
|
||
raise DockhandError("Dockhand /api/environments returned no list")
|
||
return got
|
||
|
||
def images(self, env_id: int) -> List[dict]:
|
||
"""The host's images. Dockhand answers `[]` — not an error — when it
|
||
cannot reach the Docker host."""
|
||
got = self._get("/api/images", env=env_id)
|
||
if not isinstance(got, list):
|
||
raise DockhandError(f"Dockhand /api/images for environment {env_id} returned no list")
|
||
return got
|
||
|
||
def host(self, env_id: int) -> dict:
|
||
"""The Docker daemon's host: `hostname` is `docker info` Name, the real
|
||
host's name even behind a socket or a Hawser agent."""
|
||
got = self._get("/api/host", env=env_id)
|
||
return got if isinstance(got, dict) else {}
|
||
|
||
def findings(self, env_id: int) -> List[dict]:
|
||
got = self._get("/api/vulnerabilities/export", env=env_id, format="json")
|
||
if not isinstance(got, dict) or not isinstance(got.get("findings"), list):
|
||
raise DockhandError(f"Dockhand export for environment {env_id} has no findings list "
|
||
"— Dockhand older than 1.0.37?")
|
||
return got["findings"]
|
||
|
||
def scan_all(self, env_id: int) -> dict:
|
||
"""Rescan every image of the host and wait for it. `Accept: application/json`
|
||
makes Dockhand answer synchronously with the summary
|
||
`{scanned, failed, total, cancelled}` (or `{success: false, error}`)
|
||
instead of a job id."""
|
||
got = self._request("POST", "/api/vulnerabilities/scan-all", env=env_id,
|
||
timeout=httpx.Timeout(connect=8.0, read=SCAN_ALL_READ_TIMEOUT,
|
||
write=30.0, pool=5.0))
|
||
if not isinstance(got, dict):
|
||
raise DockhandError(f"Dockhand scan-all for environment {env_id} returned no object")
|
||
return got
|
||
|
||
def close(self) -> None:
|
||
self._client.close()
|