Files
farm-manager/agent/docker_ops.py
T
pluto 446c01fb78 fix: agent timeouts hid hf-pdocker-01; refresh links and groups
- agent: stats one_shot=True + 20 workers (.192 went 12s -> ~2s);
  /health ignores Dead containers
- server: /containers fetch timeout 10s -> 30s (was silently returning
  [] for the busiest node)
- links page: drop FRIDAY Voice, add Onyx, Guacamole, Synapse Admin,
  n8n, CouchDB; MCPO -> /docs, Trivy -> /healthz, Jellyseerr -> Seerr
- config.json: drop dead gpu-workstation (192.168.86.27)
- groups.json: rebuilt for current container names (14 groups)
- includes earlier uncommitted work (models, styles, ports sorting)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQhgEUqcaV4WM2S4hfXgHD
2026-10-26 16:27:43 -05:00

426 lines
17 KiB
Python

"""Docker operations wrapper for managing Swarm and Compose containers."""
import socket
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
import docker
from docker.errors import NotFound
SWARM_SERVICE_LABEL = "com.docker.swarm.service.name"
class DockerOps:
"""Wraps the Docker SDK to manage containers on the local node.
Handles both standalone Compose containers and Swarm-managed
containers. Swarm containers are detected via the
``com.docker.swarm.service.name`` label and receive special
treatment: start/stop scale the service, restart force-updates it.
"""
def __init__(self) -> None:
self.client = docker.DockerClient.from_env()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _is_swarm(self, container) -> bool:
return SWARM_SERVICE_LABEL in container.labels
def _swarm_service_name(self, container) -> str | None:
return container.labels.get(SWARM_SERVICE_LABEL)
def _get_service(self, service_name):
"""Look up a Swarm service by name."""
services = self.client.services.list()
for svc in services:
if svc.name == service_name:
return svc
return None
@staticmethod
def _format_uptime(started_at: str) -> str:
"""Return a human-readable uptime string from an ISO timestamp."""
try:
# Docker timestamps may have nanosecond precision; truncate to
# microseconds so fromisoformat can parse them.
clean = started_at.replace("Z", "+00:00")
# Truncate fractional seconds to 6 digits if longer
if "." in clean:
parts = clean.split(".")
frac_and_tz = parts[1]
# Separate fractional part from timezone
for i, ch in enumerate(frac_and_tz):
if ch in ("+", "-"):
frac = frac_and_tz[:i][:6]
tz = frac_and_tz[i:]
clean = f"{parts[0]}.{frac}{tz}"
break
start = datetime.fromisoformat(clean)
delta = datetime.now(timezone.utc) - start
total_seconds = int(delta.total_seconds())
if total_seconds < 0:
return "just started"
days, remainder = divmod(total_seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, _ = divmod(remainder, 60)
parts = []
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
return " ".join(parts) if parts else "just started"
except Exception:
return "unknown"
@staticmethod
def _extract_ports(container) -> list[str]:
"""Extract exposed host ports from a container's network settings.
Returns a sorted, deduplicated list of strings like '8080', '443'.
Only includes ports bound to the host. For host-network containers,
reads the exposed ports from the image config instead.
"""
ports = set()
network_mode = container.attrs.get("HostConfig", {}).get("NetworkMode", "")
if network_mode == "host":
# Host networking: use exposed ports from the image/container config
exposed = container.attrs.get("Config", {}).get("ExposedPorts", {})
for port_proto in exposed:
port = port_proto.split("/")[0]
ports.add(port)
else:
# Bridge/overlay: use published port bindings
port_bindings = container.attrs.get("NetworkSettings", {}).get("Ports", {}) or {}
for port_proto, bindings in port_bindings.items():
if bindings:
for binding in bindings:
host_port = binding.get("HostPort")
if host_port:
ports.add(host_port)
return sorted(ports, key=lambda p: int(p) if p.isdigit() else 0)
def _get_container_memory(self, container_id: str) -> tuple[str, int, int]:
"""Fetch memory usage and limit for a single running container.
Returns (container_id, usage_bytes, limit_bytes).
"""
try:
stats = self.client.api.stats(container_id, stream=False, one_shot=True)
mem = stats.get("memory_stats", {})
usage = mem.get("usage", 0)
limit = mem.get("limit", 0)
# Subtract inactive file cache for accurate usage
cache = mem.get("stats", {}).get("inactive_file", 0)
return (container_id, max(usage - cache, 0), limit)
except Exception:
return (container_id, 0, 0)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def list_containers(self) -> list[dict]:
"""List all containers (running + stopped) with metadata.
Also includes Swarm services scaled to 0 replicas (which have no
containers) so they remain visible in the dashboard.
Returns a list of dicts, each containing:
id, name, status, image, created, uptime, is_swarm,
swarm_service, memory_usage, memory_limit
Uses the low-level API to get container IDs to avoid NotFound errors
from containers that disappear between list and inspect (e.g. Swarm
task containers in the Dead state).
"""
raw_ids = [r["Id"] for r in self.client.api.containers(all=True)]
result = []
swarm_services_with_containers = set()
running_ids = {} # full_id -> short_id for memory stats lookup
for cid in raw_ids:
try:
c = self.client.containers.get(cid)
except NotFound:
continue
image_tags = c.image.tags
image_name = image_tags[0] if image_tags else "<none>"
swarm_service = self._swarm_service_name(c)
if swarm_service:
swarm_services_with_containers.add(swarm_service)
started_at = c.attrs.get("State", {}).get("StartedAt", "")
if c.status == "running":
running_ids[cid] = c.short_id
result.append(
{
"id": c.short_id,
"name": c.name,
"status": c.status,
"image": image_name,
"created": c.attrs.get("Created", ""),
"uptime": self._format_uptime(started_at) if c.status == "running" else None,
"is_swarm": self._is_swarm(c),
"swarm_service": swarm_service,
"ports": self._extract_ports(c),
"memory_usage": 0,
"memory_limit": 0,
}
)
# Fetch memory stats for running containers in parallel
if running_ids:
mem_stats = {}
with ThreadPoolExecutor(max_workers=20) as pool:
futures = {
pool.submit(self._get_container_memory, full_id): short_id
for full_id, short_id in running_ids.items()
}
for future in futures:
full_id, usage, limit = future.result()
short_id = futures[future]
mem_stats[short_id] = (usage, limit)
for entry in result:
if entry["id"] in mem_stats:
entry["memory_usage"], entry["memory_limit"] = mem_stats[entry["id"]]
# Include Swarm services with 0 replicas (only works on manager nodes)
try:
for svc in self.client.services.list():
if svc.name not in swarm_services_with_containers:
image = (
svc.attrs.get("Spec", {})
.get("TaskTemplate", {})
.get("ContainerSpec", {})
.get("Image", "<none>")
)
if "@" in image:
image = image.split("@")[0]
# Extract published ports from Swarm service endpoint spec
svc_ports = []
endpoint_spec = svc.attrs.get("Spec", {}).get("EndpointSpec", {})
for p in endpoint_spec.get("Ports", []):
published = p.get("PublishedPort")
if published:
svc_ports.append(str(published))
svc_ports.sort(key=lambda x: int(x) if x.isdigit() else 0)
result.append(
{
"id": f"swarm:{svc.name}",
"name": svc.name,
"status": "stopped",
"image": image,
"created": svc.attrs.get("CreatedAt", ""),
"uptime": None,
"is_swarm": True,
"swarm_service": svc.name,
"ports": svc_ports,
}
)
except Exception:
pass # Not a manager node or Swarm not active
return result
def start_container(self, container_id: str) -> dict:
"""Start a container. For Swarm services, scale to 1 replica."""
# Handle 0-replica Swarm services (synthetic swarm: IDs)
if container_id.startswith("swarm:"):
svc_name = container_id[6:]
try:
service = self._get_service(svc_name)
if service:
service.scale(1)
return {
"success": True,
"message": f"Scaled Swarm service {svc_name} to 1 replica",
}
return {"success": False, "message": f"Swarm service {svc_name} not found"}
except Exception as exc:
return {"success": False, "message": str(exc)}
try:
container = self.client.containers.get(container_id)
except NotFound:
return {"success": False, "message": f"Container {container_id} not found"}
try:
if self._is_swarm(container):
svc_name = self._swarm_service_name(container)
service = self._get_service(svc_name)
if service:
service.scale(1)
return {
"success": True,
"message": f"Scaled Swarm service {svc_name} to 1 replica",
}
return {"success": False, "message": f"Swarm service {svc_name} not found"}
container.start()
return {"success": True, "message": f"Started container {container.name}"}
except Exception as exc:
return {"success": False, "message": str(exc)}
def stop_container(self, container_id: str) -> dict:
"""Stop a container. For Swarm services, scale to 0 replicas."""
if container_id.startswith("swarm:"):
svc_name = container_id[6:]
try:
service = self._get_service(svc_name)
if service:
service.scale(0)
return {
"success": True,
"message": f"Scaled Swarm service {svc_name} to 0 replicas",
}
return {"success": False, "message": f"Swarm service {svc_name} not found"}
except Exception as exc:
return {"success": False, "message": str(exc)}
try:
container = self.client.containers.get(container_id)
except NotFound:
return {"success": False, "message": f"Container {container_id} not found"}
try:
if self._is_swarm(container):
svc_name = self._swarm_service_name(container)
service = self._get_service(svc_name)
if service:
service.scale(0)
return {
"success": True,
"message": f"Scaled Swarm service {svc_name} to 0 replicas",
}
return {"success": False, "message": f"Swarm service {svc_name} not found"}
container.stop()
return {"success": True, "message": f"Stopped container {container.name}"}
except Exception as exc:
return {"success": False, "message": str(exc)}
def restart_container(self, container_id: str) -> dict:
"""Restart a container. For Swarm services, force-update."""
if container_id.startswith("swarm:"):
svc_name = container_id[6:]
try:
service = self._get_service(svc_name)
if service:
service.force_update()
return {
"success": True,
"message": f"Force-updated Swarm service {svc_name}",
}
return {"success": False, "message": f"Swarm service {svc_name} not found"}
except Exception as exc:
return {"success": False, "message": str(exc)}
try:
container = self.client.containers.get(container_id)
except NotFound:
return {"success": False, "message": f"Container {container_id} not found"}
try:
if self._is_swarm(container):
svc_name = self._swarm_service_name(container)
service = self._get_service(svc_name)
if service:
service.force_update()
return {
"success": True,
"message": f"Force-updated Swarm service {svc_name}",
}
return {"success": False, "message": f"Swarm service {svc_name} not found"}
container.restart()
return {"success": True, "message": f"Restarted container {container.name}"}
except Exception as exc:
return {"success": False, "message": str(exc)}
def get_logs(self, container_id: str, tail: int = 100) -> dict:
"""Get recent logs from a container.
Returns:
dict with keys ``container`` (name) and ``logs`` (string).
"""
if container_id.startswith("swarm:"):
svc_name = container_id[6:]
return {
"container": svc_name,
"logs": "Service is scaled to 0 replicas — no logs available.\nStart the service to view logs.",
}
try:
container = self.client.containers.get(container_id)
except NotFound:
return {"container": container_id, "logs": f"Container {container_id} not found"}
raw = container.logs(tail=tail)
return {
"container": container.name,
"logs": raw.decode("utf-8", errors="replace"),
}
def pull_image(self, container_id: str) -> dict:
"""Pull the latest version of the container's image."""
if container_id.startswith("swarm:"):
svc_name = container_id[6:]
service = self._get_service(svc_name)
if not service:
return {"success": False, "message": f"Swarm service {svc_name} not found"}
image = (
service.attrs.get("Spec", {})
.get("TaskTemplate", {})
.get("ContainerSpec", {})
.get("Image", "")
)
if "@" in image:
image = image.split("@")[0]
if not image:
return {"success": False, "message": f"No image found for service {svc_name}"}
try:
self.client.images.pull(image)
return {"success": True, "message": f"Pulled image {image}"}
except Exception as exc:
return {"success": False, "message": str(exc)}
try:
container = self.client.containers.get(container_id)
except NotFound:
return {"success": False, "message": f"Container {container_id} not found"}
tags = container.image.tags
if not tags:
return {"success": False, "message": f"No image tag found for container {container.name}"}
image_name = tags[0]
try:
self.client.images.pull(image_name)
return {"success": True, "message": f"Pulled image {image_name}"}
except Exception as exc:
return {"success": False, "message": str(exc)}
def get_health(self) -> dict:
"""Return node health info: hostname and container count.
Uses the low-level API to avoid NotFound errors from containers
stuck in Docker's 'Dead' state.
"""
containers = [
c for c in self.client.api.containers(all=True)
if c.get("State") != "dead"
]
return {
"status": "healthy",
"hostname": socket.gethostname(),
"containers_total": len(containers),
}