03e3df058f
보호 컨테이너(home-caddy, home-fail2ban, nanoclaude) 재시작 차단. MCP 11개 도구 + NanoClaude wrapper. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
156 lines
5.0 KiB
Python
156 lines
5.0 KiB
Python
"""Docker status and logs tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from ..config import validate_host, HOSTS
|
|
from ..schemas import DockerStatusResult, DockerLogsResult, ContainerInfo, BaseResult
|
|
from .ssh import run_command, run_local, SSHError, _is_local_host
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
async def docker_status(host: str) -> DockerStatusResult:
|
|
"""List all Docker containers on a host with structured status."""
|
|
try:
|
|
cfg = validate_host("docker_status", host)
|
|
except ValueError as e:
|
|
return DockerStatusResult(
|
|
ok=False, checked_at=_now(), host=host,
|
|
error_type="parse_error", error=str(e),
|
|
)
|
|
|
|
docker = cfg.docker_path
|
|
fmt = '{{.Names}}|{{.Status}}|{{.Ports}}|{{.Image}}'
|
|
cmd = f"{docker} ps -a --format '{fmt}'"
|
|
|
|
try:
|
|
stdout, _ = await run_command(cfg, cmd, use_sudo=cfg.needs_sudo)
|
|
except SSHError as e:
|
|
return DockerStatusResult(
|
|
ok=False, checked_at=_now(), host=host,
|
|
error_type=e.error_type, error=str(e),
|
|
)
|
|
|
|
containers: list[ContainerInfo] = []
|
|
for line in stdout.strip().splitlines():
|
|
parts = line.split("|", 3)
|
|
if len(parts) < 4:
|
|
continue
|
|
name, status_str, ports, image = parts
|
|
# Extract running state from status string
|
|
state = "running" if status_str.startswith("Up") else "exited"
|
|
if "Restarting" in status_str:
|
|
state = "restarting"
|
|
containers.append(ContainerInfo(
|
|
name=name, status=state, uptime=status_str, ports=ports, image=image,
|
|
))
|
|
|
|
running = sum(1 for c in containers if c.status == "running")
|
|
total = len(containers)
|
|
summary = f"{running}/{total} running"
|
|
if running < total:
|
|
non_running = [c.name for c in containers if c.status != "running"]
|
|
summary += f", down: {', '.join(non_running)}"
|
|
|
|
warnings: list[str] = []
|
|
for c in containers:
|
|
if c.status == "restarting":
|
|
warnings.append(f"{c.name} is restarting")
|
|
elif c.status == "exited":
|
|
warnings.append(f"{c.name} is exited")
|
|
|
|
return DockerStatusResult(
|
|
ok=running == total,
|
|
checked_at=_now(),
|
|
host=host,
|
|
containers=containers,
|
|
summary=summary,
|
|
warnings=warnings,
|
|
raw=stdout.strip(),
|
|
)
|
|
|
|
|
|
async def docker_logs(host: str, container: str, lines: int = 50) -> DockerLogsResult:
|
|
"""Get recent logs from a container."""
|
|
try:
|
|
cfg = validate_host("docker_logs", host)
|
|
except ValueError as e:
|
|
return DockerLogsResult(
|
|
ok=False, checked_at=_now(), host=host, container=container,
|
|
lines=lines, error_type="parse_error", error=str(e),
|
|
)
|
|
|
|
docker = cfg.docker_path
|
|
# Request one extra line to detect truncation
|
|
cmd = f"{docker} logs --tail {lines + 1} {container} 2>&1"
|
|
|
|
try:
|
|
stdout, stderr = await run_command(cfg, cmd, use_sudo=cfg.needs_sudo, timeout=15)
|
|
except SSHError as e:
|
|
return DockerLogsResult(
|
|
ok=False, checked_at=_now(), host=host, container=container,
|
|
lines=lines, error_type=e.error_type, error=str(e),
|
|
)
|
|
|
|
all_lines = stdout.strip().splitlines()
|
|
truncated = len(all_lines) > lines
|
|
content = "\n".join(all_lines[:lines]) if truncated else "\n".join(all_lines)
|
|
|
|
return DockerLogsResult(
|
|
ok=True,
|
|
checked_at=_now(),
|
|
host=host,
|
|
container=container,
|
|
lines=lines,
|
|
truncated=truncated,
|
|
content=content,
|
|
stderr=stderr.strip() if stderr else "",
|
|
raw=stdout.strip(),
|
|
)
|
|
|
|
|
|
# Containers that must NEVER be restarted via this tool
|
|
PROTECTED_CONTAINERS = {
|
|
"home-caddy", # ingress — 재시작 시 전체 서비스 일시 중단
|
|
"home-fail2ban", # 보안
|
|
"nanoclaude", # 자기 자신
|
|
}
|
|
|
|
|
|
async def docker_restart(host: str, container: str) -> BaseResult:
|
|
"""Restart a Docker container. Protected containers are blocked."""
|
|
try:
|
|
cfg = validate_host("docker_status", host) # same host validation as docker_status
|
|
except ValueError as e:
|
|
return BaseResult(ok=False, checked_at=_now(), error_type="parse_error", error=str(e))
|
|
|
|
if container in PROTECTED_CONTAINERS:
|
|
return BaseResult(
|
|
ok=False, checked_at=_now(),
|
|
error_type="command_failed",
|
|
error=f"보호된 컨테이너입니다: {container}. 직접 재시작하세요.",
|
|
)
|
|
|
|
docker = cfg.docker_path
|
|
cmd = f"{docker} restart {container}"
|
|
|
|
try:
|
|
if _is_local_host(cfg):
|
|
stdout, _ = await run_local(cmd, timeout=30)
|
|
else:
|
|
stdout, _ = await run_command(cfg, cmd, use_sudo=cfg.needs_sudo, timeout=30)
|
|
except SSHError as e:
|
|
return BaseResult(
|
|
ok=False, checked_at=_now(),
|
|
error_type=e.error_type, error=str(e),
|
|
)
|
|
|
|
return BaseResult(
|
|
ok=True, checked_at=_now(),
|
|
warnings=[f"{container} 재시작 완료 (host: {host})"],
|
|
)
|