Remove Node.js cache files containing npm vulnerability data for vitest and vite packages
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
This commit is contained in:
@@ -11,6 +11,7 @@ mais pas dans la section Tasks (pour éviter de polluer l'interface).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
@@ -107,6 +108,16 @@ BUILTIN_PLAYBOOKS: Dict[str, BuiltinPlaybookDefinition] = {
|
||||
class BuiltinPlaybookService:
|
||||
"""Service pour gérer et exécuter les builtin playbooks."""
|
||||
|
||||
def _loads_jsonish(self, payload: str) -> dict:
|
||||
payload = payload.strip()
|
||||
payload = payload.replace('\\"', '"').replace('\\n', '\n')
|
||||
payload = re.sub(r"\\\s*\n", "", payload)
|
||||
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return ast.literal_eval(payload)
|
||||
|
||||
def __init__(self, ansible_dir: Path, ansible_service=None):
|
||||
"""
|
||||
Args:
|
||||
@@ -266,16 +277,18 @@ class BuiltinPlaybookService:
|
||||
matches = re.findall(pattern, stdout, re.DOTALL)
|
||||
|
||||
print(f"[BUILTIN] Found {len(matches)} METRICS_JSON matches")
|
||||
|
||||
parse_errors: list[str] = []
|
||||
|
||||
for match in matches:
|
||||
try:
|
||||
data = json.loads(match.strip())
|
||||
data = self._loads_jsonish(match)
|
||||
host = data.get("host", "unknown")
|
||||
metrics = data.get("data", {})
|
||||
metrics_by_host[host] = metrics
|
||||
print(f"[BUILTIN] Parsed metrics for host: {host}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[BUILTIN] JSON decode error: {e}")
|
||||
except (ValueError, SyntaxError) as e:
|
||||
parse_errors.append(str(e))
|
||||
continue
|
||||
|
||||
# Fallback: essayer de parser les debug outputs Ansible standards
|
||||
@@ -295,18 +308,20 @@ class BuiltinPlaybookService:
|
||||
try:
|
||||
# Le JSON est échappé dans le msg, il faut le décoder
|
||||
unescaped = match.replace('\\"', '"').replace('\\n', '\n')
|
||||
data = json.loads(unescaped.strip())
|
||||
data = self._loads_jsonish(unescaped)
|
||||
host = data.get("host", "unknown")
|
||||
metrics = data.get("data", {})
|
||||
metrics_by_host[host] = metrics
|
||||
print(f"[BUILTIN] Parsed metrics from msg for host: {host}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[BUILTIN] JSON decode error in msg pattern: {e}")
|
||||
except (ValueError, SyntaxError) as e:
|
||||
parse_errors.append(str(e))
|
||||
continue
|
||||
|
||||
print(f"[BUILTIN] Total hosts with metrics: {len(metrics_by_host)}")
|
||||
if not metrics_by_host and stdout:
|
||||
# Log un extrait du stdout pour debug
|
||||
if parse_errors:
|
||||
print(f"[BUILTIN] JSON decode error: {parse_errors[-1]}")
|
||||
print(f"[BUILTIN] Stdout sample (first 500 chars): {stdout[:500]}")
|
||||
|
||||
return metrics_by_host
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"""Docker container actions service.
|
||||
|
||||
Provides methods to execute actions on Docker containers via SSH.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
try:
|
||||
import asyncssh
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
asyncssh = None
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.database import async_session_maker
|
||||
from app.crud.host import HostRepository
|
||||
from app.crud.docker_container import DockerContainerRepository
|
||||
|
||||
logger = logging.getLogger("homelab.docker.actions")
|
||||
|
||||
# Standard log prefix
|
||||
LOG_PREFIX = "[DOCKER]"
|
||||
|
||||
|
||||
class DockerActionError(Exception):
|
||||
"""Error executing Docker action."""
|
||||
pass
|
||||
|
||||
|
||||
class DockerActionsService:
|
||||
"""Service for executing Docker container actions via SSH."""
|
||||
|
||||
def __init__(self):
|
||||
self.ssh_key_path = settings.ssh_key_path
|
||||
self.ssh_user = settings.ssh_user
|
||||
self.connect_timeout = 5
|
||||
self.exec_timeout = 30 # Longer timeout for actions
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
def _get_lock(self, container_key: str) -> asyncio.Lock:
|
||||
"""Get or create a lock for a container to prevent concurrent actions."""
|
||||
if container_key not in self._locks:
|
||||
self._locks[container_key] = asyncio.Lock()
|
||||
return self._locks[container_key]
|
||||
|
||||
async def _ssh_connect(self, host_ip: str) -> asyncssh.SSHClientConnection:
|
||||
"""Establish SSH connection to a host."""
|
||||
if asyncssh is None:
|
||||
raise DockerActionError(
|
||||
"Missing dependency: asyncssh. Install it to enable Docker container actions over SSH."
|
||||
)
|
||||
try:
|
||||
conn = await asyncio.wait_for(
|
||||
asyncssh.connect(
|
||||
host_ip,
|
||||
username=self.ssh_user,
|
||||
client_keys=[self.ssh_key_path],
|
||||
known_hosts=None,
|
||||
),
|
||||
timeout=self.connect_timeout
|
||||
)
|
||||
return conn
|
||||
except asyncio.TimeoutError:
|
||||
raise DockerActionError(f"SSH connection timeout to {host_ip}")
|
||||
except asyncssh.Error as e:
|
||||
raise DockerActionError(f"SSH error: {e}")
|
||||
except Exception as e:
|
||||
raise DockerActionError(f"Connection failed: {e}")
|
||||
|
||||
async def _ssh_exec(
|
||||
self,
|
||||
conn: asyncssh.SSHClientConnection,
|
||||
command: str,
|
||||
timeout: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a command and return result dict."""
|
||||
timeout = timeout or self.exec_timeout
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(command, check=False),
|
||||
timeout=timeout
|
||||
)
|
||||
return {
|
||||
"stdout": result.stdout or "",
|
||||
"stderr": result.stderr or "",
|
||||
"exit_code": result.exit_status,
|
||||
"success": result.exit_status == 0
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
raise DockerActionError(f"Command timeout")
|
||||
except Exception as e:
|
||||
raise DockerActionError(f"Execution error: {e}")
|
||||
|
||||
async def _run_docker(
|
||||
self,
|
||||
conn: asyncssh.SSHClientConnection,
|
||||
cmd: str,
|
||||
use_sudo_state: Dict[str, Optional[bool]],
|
||||
timeout: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run a docker command, preferring sudo -n to avoid docker.sock permission issues.
|
||||
|
||||
use_sudo_state is a mutable dict holding a cached decision for the current connection.
|
||||
"""
|
||||
|
||||
def _is_sudo_unavailable(stderr: str) -> bool:
|
||||
s = (stderr or "").lower()
|
||||
return any(
|
||||
token in s
|
||||
for token in (
|
||||
"sudo: a password is required",
|
||||
"sudo: no tty present",
|
||||
"not in the sudoers",
|
||||
"sudo: permission denied",
|
||||
"sudo: command not found",
|
||||
)
|
||||
)
|
||||
|
||||
use_sudo = use_sudo_state.get("value")
|
||||
|
||||
if use_sudo is True:
|
||||
return await self._ssh_exec(conn, f"sudo -n {cmd}", timeout=timeout)
|
||||
if use_sudo is False:
|
||||
return await self._ssh_exec(conn, cmd, timeout=timeout)
|
||||
|
||||
# First try with sudo -n
|
||||
sudo_res = await self._ssh_exec(conn, f"sudo -n {cmd}", timeout=timeout)
|
||||
if sudo_res["success"]:
|
||||
use_sudo_state["value"] = True
|
||||
return sudo_res
|
||||
|
||||
# If sudo is unavailable, fall back to plain docker
|
||||
if _is_sudo_unavailable(sudo_res.get("stderr", "")):
|
||||
use_sudo_state["value"] = False
|
||||
return await self._ssh_exec(conn, cmd, timeout=timeout)
|
||||
|
||||
# Sudo ran but failed; try plain docker (rootless docker)
|
||||
plain_res = await self._ssh_exec(conn, cmd, timeout=timeout)
|
||||
if plain_res["success"]:
|
||||
use_sudo_state["value"] = False
|
||||
return plain_res
|
||||
|
||||
# Keep sudo as preferred (best chance for next commands)
|
||||
use_sudo_state["value"] = True
|
||||
merged = (plain_res.get("stderr") or "").strip()
|
||||
sudo_err = (sudo_res.get("stderr") or "").strip()
|
||||
if sudo_err:
|
||||
merged = (merged + "\n" if merged else "") + f"sudo_err: {sudo_err}"
|
||||
plain_res["stderr"] = merged
|
||||
return plain_res
|
||||
|
||||
async def _get_host_info(self, host_id: str) -> Dict[str, Any]:
|
||||
"""Get host information from database."""
|
||||
async with async_session_maker() as session:
|
||||
host_repo = HostRepository(session)
|
||||
host = await host_repo.get(host_id)
|
||||
|
||||
if not host:
|
||||
raise DockerActionError(f"Host not found: {host_id}")
|
||||
|
||||
return {
|
||||
"id": host.id,
|
||||
"name": host.name,
|
||||
"ip": host.ip_address
|
||||
}
|
||||
|
||||
async def start_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Start a stopped container."""
|
||||
host = await self._get_host_info(host_id)
|
||||
lock_key = f"{host_id}:{container_id}"
|
||||
|
||||
async with self._get_lock(lock_key):
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
result = await self._run_docker(conn, f"docker start {container_id}", use_sudo_state)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Container started successfully" if result["success"] else "Failed to start container",
|
||||
"container_id": container_id,
|
||||
"action": "start",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"container_id": container_id,
|
||||
"action": "start",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def stop_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
timeout: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""Stop a running container."""
|
||||
host = await self._get_host_info(host_id)
|
||||
lock_key = f"{host_id}:{container_id}"
|
||||
|
||||
async with self._get_lock(lock_key):
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
result = await self._ssh_exec(
|
||||
conn,
|
||||
f"sudo -n docker stop -t {timeout} {container_id}",
|
||||
timeout=timeout + 10
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
# Use the common docker runner to handle sudo fallback and rootless docker
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker stop -t {timeout} {container_id}",
|
||||
use_sudo_state,
|
||||
timeout=timeout + 10,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Container stopped successfully" if result["success"] else "Failed to stop container",
|
||||
"container_id": container_id,
|
||||
"action": "stop",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"container_id": container_id,
|
||||
"action": "stop",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def restart_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
timeout: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""Restart a container."""
|
||||
host = await self._get_host_info(host_id)
|
||||
lock_key = f"{host_id}:{container_id}"
|
||||
|
||||
async with self._get_lock(lock_key):
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
result = await self._ssh_exec(
|
||||
conn,
|
||||
f"sudo -n docker restart -t {timeout} {container_id}",
|
||||
timeout=timeout + 15
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker restart -t {timeout} {container_id}",
|
||||
use_sudo_state,
|
||||
timeout=timeout + 15,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Container restarted successfully" if result["success"] else "Failed to restart container",
|
||||
"container_id": container_id,
|
||||
"action": "restart",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"container_id": container_id,
|
||||
"action": "restart",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def remove_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
force: bool = False,
|
||||
remove_volumes: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Remove a container."""
|
||||
host = await self._get_host_info(host_id)
|
||||
lock_key = f"{host_id}:{container_id}"
|
||||
|
||||
async with self._get_lock(lock_key):
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
flags = []
|
||||
if force:
|
||||
flags.append("-f")
|
||||
if remove_volumes:
|
||||
flags.append("-v")
|
||||
|
||||
flags_str = " ".join(flags)
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker rm {flags_str} {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Container removed successfully" if result["success"] else "Failed to remove container",
|
||||
"container_id": container_id,
|
||||
"action": "remove",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"container_id": container_id,
|
||||
"action": "remove",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def get_container_logs(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
tail: int = 200,
|
||||
timestamps: bool = False,
|
||||
since: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get container logs."""
|
||||
host = await self._get_host_info(host_id)
|
||||
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
flags = [f"--tail {tail}"]
|
||||
if timestamps:
|
||||
flags.append("--timestamps")
|
||||
if since:
|
||||
flags.append(f"--since {since}")
|
||||
|
||||
flags_str = " ".join(flags)
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker logs {flags_str} {container_id} 2>&1",
|
||||
use_sudo_state,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# Get container name
|
||||
name_result = await self._run_docker(
|
||||
conn,
|
||||
f"docker inspect --format '{{{{.Name}}}}' {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
container_name = name_result["stdout"].strip().lstrip("/")
|
||||
|
||||
logs = result["stdout"]
|
||||
lines = len(logs.split('\n')) if logs else 0
|
||||
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"container_name": container_name,
|
||||
"logs": logs,
|
||||
"lines": lines
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"container_name": "",
|
||||
"logs": f"Error retrieving logs: {e}",
|
||||
"lines": 0
|
||||
}
|
||||
|
||||
async def inspect_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Get detailed container information."""
|
||||
host = await self._get_host_info(host_id)
|
||||
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker inspect {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
import json
|
||||
try:
|
||||
inspect_data = json.loads(result["stdout"])
|
||||
if isinstance(inspect_data, list) and len(inspect_data) > 0:
|
||||
inspect_data = inspect_data[0]
|
||||
|
||||
container_name = inspect_data.get("Name", "").lstrip("/")
|
||||
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"container_name": container_name,
|
||||
"inspect_data": inspect_data
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"container_name": "",
|
||||
"inspect_data": {"error": result["stderr"] or "Failed to parse inspect data"}
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"container_name": "",
|
||||
"inspect_data": {"error": str(e)}
|
||||
}
|
||||
|
||||
async def redeploy_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Redeploy a container by pulling latest image and recreating.
|
||||
|
||||
This only works for containers started with docker-compose or with
|
||||
enough metadata to recreate them.
|
||||
"""
|
||||
host = await self._get_host_info(host_id)
|
||||
lock_key = f"{host_id}:{container_id}"
|
||||
|
||||
async with self._get_lock(lock_key):
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
# Get container info first
|
||||
inspect_result = await self._run_docker(
|
||||
conn,
|
||||
f"docker inspect --format '{{{{.Config.Image}}}}' {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
if not inspect_result["success"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get container image",
|
||||
"container_id": container_id,
|
||||
"action": "redeploy",
|
||||
"output": None,
|
||||
"error": inspect_result["stderr"]
|
||||
}
|
||||
|
||||
image = inspect_result["stdout"].strip()
|
||||
|
||||
# Check if it's a compose container
|
||||
labels_result = await self._run_docker(
|
||||
conn,
|
||||
f"docker inspect --format '{{{{index .Config.Labels \"com.docker.compose.project.working_dir\"}}}}' {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
compose_dir = labels_result["stdout"].strip()
|
||||
|
||||
if compose_dir:
|
||||
# Use docker-compose to redeploy
|
||||
service_result = await self._run_docker(
|
||||
conn,
|
||||
f"docker inspect --format '{{{{index .Config.Labels \"com.docker.compose.service\"}}}}' {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
service_name = service_result["stdout"].strip()
|
||||
|
||||
# Pull and recreate with docker-compose
|
||||
# Keep working directory change in shell, run docker via sudo -n
|
||||
result = await self._ssh_exec(
|
||||
conn,
|
||||
f"cd {compose_dir} && sudo -n docker compose pull {service_name} && sudo -n docker compose up -d {service_name}",
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
# Fallback: try without sudo (rootless docker / sudo unavailable)
|
||||
result = await self._ssh_exec(
|
||||
conn,
|
||||
f"cd {compose_dir} && docker compose pull {service_name} && docker compose up -d {service_name}",
|
||||
timeout=120,
|
||||
)
|
||||
else:
|
||||
# Simple pull and restart
|
||||
pull_result = await self._run_docker(
|
||||
conn,
|
||||
f"docker pull {image}",
|
||||
use_sudo_state,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if not pull_result["success"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to pull image",
|
||||
"container_id": container_id,
|
||||
"action": "redeploy",
|
||||
"output": pull_result["stdout"],
|
||||
"error": pull_result["stderr"]
|
||||
}
|
||||
|
||||
# Restart container with new image
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker restart {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Container redeployed successfully" if result["success"] else "Failed to redeploy container",
|
||||
"container_id": container_id,
|
||||
"action": "redeploy",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"container_id": container_id,
|
||||
"action": "redeploy",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def remove_image(
|
||||
self,
|
||||
host_id: str,
|
||||
image_id: str,
|
||||
force: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Remove a Docker image."""
|
||||
host = await self._get_host_info(host_id)
|
||||
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
flags = "-f" if force else ""
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker rmi {flags} {image_id}".strip(),
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Image removed successfully" if result["success"] else "Failed to remove image",
|
||||
"image_id": image_id,
|
||||
"action": "remove",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"image_id": image_id,
|
||||
"action": "remove",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
docker_actions = DockerActionsService()
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Docker alerts service.
|
||||
|
||||
Handles detection of container issues and alert notifications.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from app.models.database import async_session_maker
|
||||
from app.crud.host import HostRepository
|
||||
from app.crud.docker_container import DockerContainerRepository
|
||||
from app.crud.docker_alert import DockerAlertRepository
|
||||
from app.services.notification_service import notification_service
|
||||
from app.services.websocket_service import ws_manager
|
||||
|
||||
logger = logging.getLogger("homelab.docker.alerts")
|
||||
|
||||
# Cooldown period between notifications for the same alert (5 minutes)
|
||||
NOTIFICATION_COOLDOWN_SECONDS = 300
|
||||
|
||||
|
||||
class DockerAlertsService:
|
||||
"""Service for detecting Docker container issues and managing alerts."""
|
||||
|
||||
def __init__(self):
|
||||
self._last_check: Optional[datetime] = None
|
||||
|
||||
async def check_all_alerts(self) -> Dict[str, Any]:
|
||||
"""Check all Docker-enabled hosts for container issues.
|
||||
|
||||
Returns:
|
||||
Summary of alerts opened/closed
|
||||
"""
|
||||
result = {
|
||||
"hosts_checked": 0,
|
||||
"alerts_opened": 0,
|
||||
"alerts_closed": 0,
|
||||
"notifications_sent": 0
|
||||
}
|
||||
|
||||
async with async_session_maker() as session:
|
||||
host_repo = HostRepository(session)
|
||||
container_repo = DockerContainerRepository(session)
|
||||
alert_repo = DockerAlertRepository(session)
|
||||
|
||||
# Get all Docker-enabled hosts
|
||||
hosts = await host_repo.list_docker_enabled()
|
||||
result["hosts_checked"] = len(hosts)
|
||||
|
||||
for host in hosts:
|
||||
try:
|
||||
# Get all containers for this host
|
||||
containers = await container_repo.list_by_host(host.id)
|
||||
|
||||
for container in containers:
|
||||
await self._check_container(
|
||||
session, host, container, alert_repo, result
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking alerts for host {host.id}: {e}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
self._last_check = datetime.now(timezone.utc)
|
||||
return result
|
||||
|
||||
async def _check_container(
|
||||
self,
|
||||
session,
|
||||
host,
|
||||
container,
|
||||
alert_repo: DockerAlertRepository,
|
||||
result: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Check a single container for issues."""
|
||||
labels = container.labels or {}
|
||||
|
||||
# Only monitor containers with homelab.monitor=true label
|
||||
should_monitor = labels.get("homelab.monitor", "").lower() == "true"
|
||||
if not should_monitor:
|
||||
return
|
||||
|
||||
expected_state = labels.get("homelab.desired", "running")
|
||||
current_state = container.state
|
||||
health = container.health
|
||||
|
||||
alert_needed = False
|
||||
severity = "warning"
|
||||
message = None
|
||||
|
||||
# Case 1: Container should be running but isn't
|
||||
if expected_state == "running" and current_state != "running":
|
||||
alert_needed = True
|
||||
severity = "error"
|
||||
message = f"Container {container.name} is {current_state}, expected running"
|
||||
|
||||
# Case 2: Container is unhealthy
|
||||
elif health == "unhealthy":
|
||||
alert_needed = True
|
||||
severity = "warning"
|
||||
message = f"Container {container.name} health check failing"
|
||||
|
||||
# Case 3: Container is healthy and running - close any open alerts
|
||||
elif current_state == "running" and health in ("healthy", None, "none"):
|
||||
closed = await alert_repo.close_for_container(host.id, container.name)
|
||||
if closed > 0:
|
||||
result["alerts_closed"] += closed
|
||||
logger.info(f"Closed {closed} alert(s) for {container.name} on {host.name}")
|
||||
|
||||
# Broadcast alert closure
|
||||
await ws_manager.broadcast({
|
||||
"type": "docker_alert_closed",
|
||||
"data": {
|
||||
"host_id": host.id,
|
||||
"host_name": host.name,
|
||||
"container_name": container.name
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
if alert_needed:
|
||||
await self._open_or_update_alert(
|
||||
session, host, container.name, severity, message, alert_repo, result
|
||||
)
|
||||
|
||||
async def _open_or_update_alert(
|
||||
self,
|
||||
session,
|
||||
host,
|
||||
container_name: str,
|
||||
severity: str,
|
||||
message: str,
|
||||
alert_repo: DockerAlertRepository,
|
||||
result: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Open a new alert or update existing one."""
|
||||
# Check for existing open alert
|
||||
existing = await alert_repo.get_open_alert(host.id, container_name)
|
||||
|
||||
if existing:
|
||||
# Check if we should send another notification
|
||||
should_notify = False
|
||||
if existing.last_notified_at:
|
||||
cooldown_elapsed = (
|
||||
datetime.now(timezone.utc) - existing.last_notified_at.replace(tzinfo=timezone.utc)
|
||||
).total_seconds() > NOTIFICATION_COOLDOWN_SECONDS
|
||||
should_notify = cooldown_elapsed
|
||||
else:
|
||||
should_notify = True
|
||||
|
||||
if should_notify:
|
||||
await self._send_notification(host, container_name, severity, message)
|
||||
await alert_repo.update_last_notified(existing.id)
|
||||
result["notifications_sent"] += 1
|
||||
else:
|
||||
# Create new alert
|
||||
alert = await alert_repo.create(
|
||||
host_id=host.id,
|
||||
container_name=container_name,
|
||||
severity=severity,
|
||||
message=message
|
||||
)
|
||||
result["alerts_opened"] += 1
|
||||
logger.info(f"Opened alert for {container_name} on {host.name}: {message}")
|
||||
|
||||
# Send notification
|
||||
await self._send_notification(host, container_name, severity, message)
|
||||
await alert_repo.update_last_notified(alert.id)
|
||||
result["notifications_sent"] += 1
|
||||
|
||||
# Broadcast new alert via WebSocket
|
||||
await ws_manager.broadcast({
|
||||
"type": "docker_alert_opened",
|
||||
"data": {
|
||||
"id": alert.id,
|
||||
"host_id": host.id,
|
||||
"host_name": host.name,
|
||||
"container_name": container_name,
|
||||
"severity": severity,
|
||||
"message": message,
|
||||
"opened_at": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
})
|
||||
|
||||
async def _send_notification(
|
||||
self,
|
||||
host,
|
||||
container_name: str,
|
||||
severity: str,
|
||||
message: str
|
||||
) -> None:
|
||||
"""Send ntfy notification for an alert."""
|
||||
try:
|
||||
priority_map = {
|
||||
"warning": 3,
|
||||
"error": 4,
|
||||
"critical": 5
|
||||
}
|
||||
priority = priority_map.get(severity, 3)
|
||||
|
||||
emoji = "🚨" if severity == "critical" else "⚠️" if severity == "error" else "🔔"
|
||||
|
||||
await notification_service.send(
|
||||
topic="homelab-docker",
|
||||
title=f"{emoji} Docker Alert - {host.name}",
|
||||
message=f"{container_name}: {message}",
|
||||
priority=priority,
|
||||
tags=["docker", severity]
|
||||
)
|
||||
logger.info(f"Sent notification for {container_name} on {host.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification: {e}")
|
||||
|
||||
async def acknowledge_alert(
|
||||
self,
|
||||
alert_id: int,
|
||||
acknowledged_by: str,
|
||||
note: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Acknowledge an alert."""
|
||||
async with async_session_maker() as session:
|
||||
alert_repo = DockerAlertRepository(session)
|
||||
host_repo = HostRepository(session)
|
||||
|
||||
alert = await alert_repo.acknowledge(alert_id, acknowledged_by)
|
||||
|
||||
if alert:
|
||||
host = await host_repo.get(alert.host_id)
|
||||
await session.commit()
|
||||
|
||||
# Broadcast acknowledgement
|
||||
await ws_manager.broadcast({
|
||||
"type": "docker_alert_acknowledged",
|
||||
"data": {
|
||||
"id": alert.id,
|
||||
"host_id": alert.host_id,
|
||||
"host_name": host.name if host else "",
|
||||
"container_name": alert.container_name,
|
||||
"acknowledged_by": acknowledged_by,
|
||||
"acknowledged_at": alert.acknowledged_at.isoformat() if alert.acknowledged_at else None
|
||||
}
|
||||
})
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
return None
|
||||
|
||||
async def close_alert(self, alert_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Manually close an alert."""
|
||||
async with async_session_maker() as session:
|
||||
alert_repo = DockerAlertRepository(session)
|
||||
host_repo = HostRepository(session)
|
||||
|
||||
alert = await alert_repo.close(alert_id)
|
||||
|
||||
if alert:
|
||||
host = await host_repo.get(alert.host_id)
|
||||
await session.commit()
|
||||
|
||||
# Broadcast closure
|
||||
await ws_manager.broadcast({
|
||||
"type": "docker_alert_closed",
|
||||
"data": {
|
||||
"id": alert.id,
|
||||
"host_id": alert.host_id,
|
||||
"host_name": host.name if host else "",
|
||||
"container_name": alert.container_name
|
||||
}
|
||||
})
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
return None
|
||||
|
||||
async def get_alerts(
|
||||
self,
|
||||
host_id: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""Get alerts with optional filters."""
|
||||
async with async_session_maker() as session:
|
||||
alert_repo = DockerAlertRepository(session)
|
||||
host_repo = HostRepository(session)
|
||||
|
||||
alerts = await alert_repo.list_alerts(
|
||||
host_id=host_id,
|
||||
state=state,
|
||||
severity=severity,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
counts = await alert_repo.count_alerts(host_id=host_id)
|
||||
|
||||
# Enrich with host names
|
||||
result = []
|
||||
for alert in alerts:
|
||||
alert_dict = alert.to_dict()
|
||||
host = await host_repo.get(alert.host_id)
|
||||
alert_dict["host_name"] = host.name if host else ""
|
||||
result.append(alert_dict)
|
||||
|
||||
return {
|
||||
"alerts": result,
|
||||
"total": counts["total"],
|
||||
"open_count": counts["open"],
|
||||
"acknowledged_count": counts["acknowledged"]
|
||||
}
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get global Docker statistics."""
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select, func
|
||||
from app.models import Host, DockerContainer, DockerImage, DockerVolume, DockerAlert
|
||||
|
||||
# Count hosts
|
||||
total_hosts = await session.execute(
|
||||
select(func.count(Host.id)).where(Host.deleted_at.is_(None))
|
||||
)
|
||||
enabled_hosts = await session.execute(
|
||||
select(func.count(Host.id)).where(
|
||||
Host.deleted_at.is_(None),
|
||||
Host.docker_enabled == True
|
||||
)
|
||||
)
|
||||
online_hosts = await session.execute(
|
||||
select(func.count(Host.id)).where(
|
||||
Host.deleted_at.is_(None),
|
||||
Host.docker_enabled == True,
|
||||
Host.docker_status == "online"
|
||||
)
|
||||
)
|
||||
|
||||
# Count containers
|
||||
total_containers = await session.execute(
|
||||
select(func.count(DockerContainer.id))
|
||||
)
|
||||
running_containers = await session.execute(
|
||||
select(func.count(DockerContainer.id)).where(
|
||||
DockerContainer.state == "running"
|
||||
)
|
||||
)
|
||||
|
||||
# Count images and volumes
|
||||
total_images = await session.execute(
|
||||
select(func.count(DockerImage.id))
|
||||
)
|
||||
total_volumes = await session.execute(
|
||||
select(func.count(DockerVolume.id))
|
||||
)
|
||||
|
||||
# Count open alerts
|
||||
open_alerts = await session.execute(
|
||||
select(func.count(DockerAlert.id)).where(
|
||||
DockerAlert.state == "open"
|
||||
)
|
||||
)
|
||||
|
||||
# Last collection time
|
||||
last_collect = await session.execute(
|
||||
select(func.max(Host.docker_last_collect_at)).where(
|
||||
Host.docker_enabled == True
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total_hosts": total_hosts.scalar() or 0,
|
||||
"enabled_hosts": enabled_hosts.scalar() or 0,
|
||||
"online_hosts": online_hosts.scalar() or 0,
|
||||
"total_containers": total_containers.scalar() or 0,
|
||||
"running_containers": running_containers.scalar() or 0,
|
||||
"total_images": total_images.scalar() or 0,
|
||||
"total_volumes": total_volumes.scalar() or 0,
|
||||
"open_alerts": open_alerts.scalar() or 0,
|
||||
"last_collection": last_collect.scalar()
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
docker_alerts_service = DockerAlertsService()
|
||||
@@ -0,0 +1,610 @@
|
||||
"""Docker collection service using SSH.
|
||||
|
||||
Collects Docker information from hosts via SSH commands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
|
||||
try:
|
||||
import asyncssh
|
||||
_asyncssh_import_error: Optional[BaseException] = None
|
||||
except ModuleNotFoundError as e: # pragma: no cover
|
||||
asyncssh = None
|
||||
_asyncssh_import_error = e
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.database import async_session_maker
|
||||
from app.crud.host import HostRepository
|
||||
from app.crud.docker_container import DockerContainerRepository
|
||||
from app.crud.docker_image import DockerImageRepository
|
||||
from app.crud.docker_volume import DockerVolumeRepository
|
||||
from app.crud.docker_alert import DockerAlertRepository
|
||||
from app.services.ansible_service import ansible_service
|
||||
|
||||
logger = logging.getLogger("homelab.docker")
|
||||
|
||||
# Log prefix for standardized output
|
||||
LOG_PREFIX = "[DOCKER]"
|
||||
|
||||
|
||||
class SSHConnectionError(Exception):
|
||||
"""Error connecting to host via SSH."""
|
||||
pass
|
||||
|
||||
|
||||
class DockerNotAvailableError(Exception):
|
||||
"""Docker is not available on the host."""
|
||||
pass
|
||||
|
||||
|
||||
class DockerService:
|
||||
"""Service for collecting Docker information via SSH."""
|
||||
|
||||
def __init__(self):
|
||||
self.ssh_key_path = settings.ssh_key_path
|
||||
self.ssh_user = settings.ssh_user
|
||||
self.ssh_remote_user = settings.ssh_remote_user
|
||||
self.connect_timeout = 5
|
||||
self.exec_timeout = 15
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
def _get_lock(self, host_id: str) -> asyncio.Lock:
|
||||
"""Get or create a lock for a host to prevent concurrent operations."""
|
||||
if host_id not in self._locks:
|
||||
self._locks[host_id] = asyncio.Lock()
|
||||
return self._locks[host_id]
|
||||
|
||||
async def _ssh_connect(self, host_ip: str) -> asyncssh.SSHClientConnection:
|
||||
"""Establish SSH connection to a host."""
|
||||
if asyncssh is None:
|
||||
err = None
|
||||
try:
|
||||
err = str(_asyncssh_import_error) if _asyncssh_import_error else None
|
||||
except Exception:
|
||||
err = None
|
||||
raise SSHConnectionError(
|
||||
"Missing dependency: asyncssh. Install it to enable Docker SSH collection. "
|
||||
f"(python={sys.executable}, import_error={err})"
|
||||
)
|
||||
last_err: Optional[BaseException] = None
|
||||
tried_users: List[str] = []
|
||||
for username in [self.ssh_remote_user, self.ssh_user]:
|
||||
if not username or username in tried_users:
|
||||
continue
|
||||
tried_users.append(username)
|
||||
try:
|
||||
conn = await asyncio.wait_for(
|
||||
asyncssh.connect(
|
||||
host_ip,
|
||||
username=username,
|
||||
client_keys=[self.ssh_key_path],
|
||||
known_hosts=None, # Accept any host key (homelab environment)
|
||||
),
|
||||
timeout=self.connect_timeout
|
||||
)
|
||||
return conn
|
||||
except asyncio.TimeoutError as e:
|
||||
last_err = e
|
||||
continue
|
||||
except asyncssh.Error as e:
|
||||
last_err = e
|
||||
continue
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
continue
|
||||
|
||||
if isinstance(last_err, asyncio.TimeoutError):
|
||||
raise SSHConnectionError(f"SSH connection timeout to {host_ip} (users tried: {', '.join(tried_users)})")
|
||||
raise SSHConnectionError(
|
||||
f"SSH error connecting to {host_ip} (users tried: {', '.join(tried_users)}): {last_err}"
|
||||
)
|
||||
|
||||
async def _ssh_exec(
|
||||
self,
|
||||
conn: asyncssh.SSHClientConnection,
|
||||
command: str,
|
||||
timeout: Optional[int] = None
|
||||
) -> Tuple[str, str, int]:
|
||||
"""Execute a command via SSH and return stdout, stderr, exit_code."""
|
||||
timeout = timeout or self.exec_timeout
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(command, check=False),
|
||||
timeout=timeout
|
||||
)
|
||||
return result.stdout or "", result.stderr or "", result.exit_status
|
||||
except asyncio.TimeoutError:
|
||||
raise SSHConnectionError(f"Command timeout: {command[:50]}...")
|
||||
except Exception as e:
|
||||
raise SSHConnectionError(f"Command execution error: {e}")
|
||||
|
||||
async def _parse_docker_json_lines(self, output: str) -> List[Dict[str, Any]]:
|
||||
"""Parse Docker JSON output (one JSON object per line)."""
|
||||
results = []
|
||||
for line in output.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
results.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return results
|
||||
|
||||
async def collect_docker_host(self, host_id: str) -> Dict[str, Any]:
|
||||
"""Collect Docker information from a single host.
|
||||
|
||||
Returns:
|
||||
Dict with collection results including success status, counts, etc.
|
||||
"""
|
||||
start_time = time.time()
|
||||
result = {
|
||||
"success": False,
|
||||
"host_id": host_id,
|
||||
"host_name": "",
|
||||
"message": "",
|
||||
"docker_version": None,
|
||||
"containers_count": 0,
|
||||
"images_count": 0,
|
||||
"volumes_count": 0,
|
||||
"error": None,
|
||||
"duration_ms": 0
|
||||
}
|
||||
|
||||
async with self._get_lock(host_id):
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
# Get host info
|
||||
host_repo = HostRepository(session)
|
||||
host = await host_repo.get(host_id)
|
||||
|
||||
if not host:
|
||||
result["error"] = "Host not found"
|
||||
result["message"] = "Host not found"
|
||||
return result
|
||||
|
||||
result["host_name"] = host.name
|
||||
host_ip = host.ip_address
|
||||
|
||||
print(f"{LOG_PREFIX} Starting collection for host {host.name} ({host_id[:8]}...) at {host_ip}")
|
||||
|
||||
# Connect via SSH
|
||||
conn = await self._ssh_connect(host_ip)
|
||||
|
||||
try:
|
||||
use_sudo: Optional[bool] = None
|
||||
|
||||
async def run_docker(cmd: str) -> Tuple[str, str, int]:
|
||||
nonlocal use_sudo
|
||||
|
||||
def _is_sudo_unavailable(stderr: str) -> bool:
|
||||
s = (stderr or "").lower()
|
||||
return any(
|
||||
token in s
|
||||
for token in (
|
||||
"sudo: a password is required",
|
||||
"sudo: no tty present",
|
||||
"not in the sudoers",
|
||||
"sudo: permission denied",
|
||||
"sudo: command not found",
|
||||
)
|
||||
)
|
||||
|
||||
# If we've already determined sudo behavior, reuse it.
|
||||
if use_sudo is True:
|
||||
return await self._ssh_exec(conn, f"sudo -n {cmd}")
|
||||
if use_sudo is False:
|
||||
return await self._ssh_exec(conn, cmd)
|
||||
|
||||
# First call: prefer sudo -n (passwordless sudo) to avoid docker.sock permission issues.
|
||||
sudo_out, sudo_err, sudo_code = await self._ssh_exec(conn, f"sudo -n {cmd}")
|
||||
if sudo_code == 0:
|
||||
use_sudo = True
|
||||
return sudo_out, sudo_err, sudo_code
|
||||
|
||||
# If sudo is not available, fall back to non-sudo.
|
||||
if _is_sudo_unavailable(sudo_err):
|
||||
use_sudo = False
|
||||
return await self._ssh_exec(conn, cmd)
|
||||
|
||||
# Sudo ran but command failed; try non-sudo as a fallback (e.g., rootless docker).
|
||||
plain_out, plain_err, plain_code = await self._ssh_exec(conn, cmd)
|
||||
if plain_code == 0:
|
||||
use_sudo = False
|
||||
return plain_out, plain_err, plain_code
|
||||
|
||||
# Keep sudo as preferred for subsequent commands (best chance to avoid docker.sock issues)
|
||||
use_sudo = True
|
||||
merged_err = (plain_err or "").strip()
|
||||
if sudo_err:
|
||||
merged_err = (merged_err + "\n" if merged_err else "") + f"sudo_err: {(sudo_err or '').strip()}"
|
||||
return plain_out, merged_err, plain_code
|
||||
|
||||
# Get Docker version
|
||||
base_version_cmd = "docker version --format '{{.Server.Version}}'"
|
||||
version_out, version_err, version_code = await run_docker(base_version_cmd)
|
||||
|
||||
if version_code != 0:
|
||||
err = (version_err or "").strip()
|
||||
if "permission denied" in err.lower() and "docker.sock" in err.lower():
|
||||
raise DockerNotAvailableError(
|
||||
"Docker not available: permission denied while trying to connect to the Docker daemon socket. "
|
||||
"Fix one of: set SSH_REMOTE_USER=root, add the SSH user to the 'docker' group, "
|
||||
"or allow passwordless sudo for docker (sudo -n). "
|
||||
f"Raw error: {err}"
|
||||
)
|
||||
raise DockerNotAvailableError(f"Docker not available: {err}")
|
||||
|
||||
docker_version = version_out.strip()
|
||||
result["docker_version"] = docker_version
|
||||
print(f"{LOG_PREFIX} {host.name}: Docker version {docker_version}, using sudo={use_sudo}")
|
||||
|
||||
# Collect containers
|
||||
containers_out, _, _ = await run_docker(
|
||||
"docker ps -a --format '{{json .}}' --no-trunc"
|
||||
)
|
||||
containers_data = await self._parse_docker_json_lines(containers_out)
|
||||
print(f"{LOG_PREFIX} {host.name}: Found {len(containers_data)} container(s)")
|
||||
|
||||
# Collect images
|
||||
images_out, _, _ = await run_docker(
|
||||
"docker images --format '{{json .}}'"
|
||||
)
|
||||
images_data = await self._parse_docker_json_lines(images_out)
|
||||
print(f"{LOG_PREFIX} {host.name}: Found {len(images_data)} image(s)")
|
||||
|
||||
# Collect volumes
|
||||
volumes_out, _, _ = await run_docker(
|
||||
"docker volume ls --format '{{json .}}'"
|
||||
)
|
||||
volumes_data = await self._parse_docker_json_lines(volumes_out)
|
||||
print(f"{LOG_PREFIX} {host.name}: Found {len(volumes_data)} volume(s)")
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Store in database
|
||||
container_repo = DockerContainerRepository(session)
|
||||
image_repo = DockerImageRepository(session)
|
||||
volume_repo = DockerVolumeRepository(session)
|
||||
|
||||
# Process containers
|
||||
container_ids = []
|
||||
for c in containers_data:
|
||||
container_id = c.get("ID", "")
|
||||
container_ids.append(container_id)
|
||||
|
||||
# Parse created time
|
||||
created_at = None
|
||||
created_str = c.get("CreatedAt", "")
|
||||
if created_str:
|
||||
try:
|
||||
# Docker format: "2024-01-15 10:30:00 -0500 EST"
|
||||
created_at = datetime.fromisoformat(
|
||||
created_str.split(" ")[0] + "T" + created_str.split(" ")[1]
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Extract compose project from labels
|
||||
compose_project = None
|
||||
labels = c.get("Labels", "")
|
||||
if labels:
|
||||
for label in labels.split(","):
|
||||
if "com.docker.compose.project=" in label:
|
||||
compose_project = label.split("=", 1)[1]
|
||||
break
|
||||
|
||||
# Parse labels into dict
|
||||
labels_dict = {}
|
||||
if labels:
|
||||
for label in labels.split(","):
|
||||
if "=" in label:
|
||||
k, v = label.split("=", 1)
|
||||
labels_dict[k] = v
|
||||
|
||||
# Parse ports
|
||||
ports_str = c.get("Ports", "")
|
||||
ports = {"raw": ports_str} if ports_str else None
|
||||
|
||||
# Determine health from status
|
||||
status = c.get("Status", "")
|
||||
health = None
|
||||
if "(healthy)" in status.lower():
|
||||
health = "healthy"
|
||||
elif "(unhealthy)" in status.lower():
|
||||
health = "unhealthy"
|
||||
elif "(health: starting)" in status.lower():
|
||||
health = "starting"
|
||||
|
||||
await container_repo.upsert(
|
||||
host_id=host_id,
|
||||
container_id=container_id,
|
||||
name=c.get("Names", "").lstrip("/"),
|
||||
image=c.get("Image", ""),
|
||||
state=c.get("State", "unknown").lower(),
|
||||
status=status,
|
||||
health=health,
|
||||
created_at=created_at,
|
||||
ports=ports,
|
||||
labels=labels_dict if labels_dict else None,
|
||||
compose_project=compose_project
|
||||
)
|
||||
|
||||
# Remove stale containers
|
||||
await container_repo.delete_stale(host_id, container_ids)
|
||||
result["containers_count"] = len(containers_data)
|
||||
|
||||
# Process images
|
||||
image_ids = []
|
||||
for img in images_data:
|
||||
image_id = img.get("ID", "")
|
||||
image_ids.append(image_id)
|
||||
|
||||
# Parse size (e.g., "150MB" -> bytes)
|
||||
size_str = img.get("Size", "0")
|
||||
size = self._parse_size(size_str)
|
||||
|
||||
# Parse created time
|
||||
created = None
|
||||
created_str = img.get("CreatedAt", "")
|
||||
if created_str:
|
||||
try:
|
||||
created = datetime.fromisoformat(
|
||||
created_str.split(" ")[0] + "T" + created_str.split(" ")[1]
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Get repo tags
|
||||
repo = img.get("Repository", "<none>")
|
||||
tag = img.get("Tag", "<none>")
|
||||
repo_tags = None
|
||||
if repo != "<none>":
|
||||
repo_tags = [f"{repo}:{tag}"]
|
||||
|
||||
await image_repo.upsert(
|
||||
host_id=host_id,
|
||||
image_id=image_id,
|
||||
repo_tags=repo_tags,
|
||||
size=size,
|
||||
created=created
|
||||
)
|
||||
|
||||
await image_repo.delete_stale(host_id, image_ids)
|
||||
result["images_count"] = len(images_data)
|
||||
|
||||
# Process volumes
|
||||
volume_names = []
|
||||
for vol in volumes_data:
|
||||
name = vol.get("Name", "")
|
||||
volume_names.append(name)
|
||||
|
||||
await volume_repo.upsert(
|
||||
host_id=host_id,
|
||||
name=name,
|
||||
driver=vol.get("Driver", "local"),
|
||||
mountpoint=vol.get("Mountpoint", ""),
|
||||
scope=vol.get("Scope", "local")
|
||||
)
|
||||
|
||||
await volume_repo.delete_stale(host_id, volume_names)
|
||||
result["volumes_count"] = len(volumes_data)
|
||||
|
||||
# Update host Docker status
|
||||
await host_repo.update(
|
||||
host,
|
||||
docker_version=docker_version,
|
||||
docker_status="online",
|
||||
docker_last_collect_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
result["success"] = True
|
||||
result["message"] = "Collection completed"
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
print(f"{LOG_PREFIX} {host.name}: Collection completed in {duration_ms}ms - {len(containers_data)} containers, {len(images_data)} images, {len(volumes_data)} volumes")
|
||||
|
||||
except SSHConnectionError as e:
|
||||
result["error"] = str(e)
|
||||
result["message"] = "Collection failed"
|
||||
# Update host status to offline
|
||||
await host_repo.update(host, docker_status="offline")
|
||||
await session.commit()
|
||||
print(f"{LOG_PREFIX} {result.get('host_name', host_id)}: SSH connection error - {e}")
|
||||
logger.warning(f"{LOG_PREFIX} SSH error collecting Docker from {host_id}: {e}")
|
||||
|
||||
except DockerNotAvailableError as e:
|
||||
result["error"] = str(e)
|
||||
result["message"] = "Collection failed"
|
||||
await host_repo.update(host, docker_status="error")
|
||||
await session.commit()
|
||||
print(f"{LOG_PREFIX} {result.get('host_name', host_id)}: Docker not available - {e}")
|
||||
logger.warning(f"{LOG_PREFIX} Docker not available on {host_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
result["message"] = "Collection failed"
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
print(f"{LOG_PREFIX} {result.get('host_name', host_id)}: Collection error - {e}")
|
||||
logger.error(f"{LOG_PREFIX} Error collecting Docker from {host_id}: {e}\n{tb}")
|
||||
await session.rollback()
|
||||
|
||||
result["duration_ms"] = int((time.time() - start_time) * 1000)
|
||||
if not result.get("message"):
|
||||
result["message"] = "Collection completed" if result.get("success") else "Collection failed"
|
||||
return result
|
||||
|
||||
async def collect_all_hosts(self) -> Dict[str, Any]:
|
||||
"""Collect Docker information from all enabled hosts.
|
||||
|
||||
Returns:
|
||||
Summary of collection results
|
||||
"""
|
||||
results = {
|
||||
"success": True,
|
||||
"total_hosts": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"results": []
|
||||
}
|
||||
|
||||
async with async_session_maker() as session:
|
||||
host_repo = HostRepository(session)
|
||||
hosts = await host_repo.list_docker_enabled()
|
||||
results["total_hosts"] = len(hosts)
|
||||
|
||||
# Collect from all hosts concurrently (with limit)
|
||||
semaphore = asyncio.Semaphore(5) # Max 5 concurrent collections
|
||||
|
||||
async def collect_with_semaphore(host):
|
||||
async with semaphore:
|
||||
return await self.collect_docker_host(host.id)
|
||||
|
||||
if hosts:
|
||||
host_results = await asyncio.gather(
|
||||
*[collect_with_semaphore(host) for host in hosts],
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
for r in host_results:
|
||||
if isinstance(r, Exception):
|
||||
results["failed"] += 1
|
||||
results["results"].append({
|
||||
"success": False,
|
||||
"host_id": "",
|
||||
"host_name": "",
|
||||
"message": "Collection failed",
|
||||
"docker_version": None,
|
||||
"containers_count": 0,
|
||||
"images_count": 0,
|
||||
"volumes_count": 0,
|
||||
"duration_ms": 0,
|
||||
"error": str(r)
|
||||
})
|
||||
elif r.get("success"):
|
||||
results["successful"] += 1
|
||||
results["results"].append(r)
|
||||
else:
|
||||
results["failed"] += 1
|
||||
results["results"].append(r)
|
||||
|
||||
results["success"] = results["failed"] == 0
|
||||
return results
|
||||
|
||||
def _parse_size(self, size_str: str) -> int:
|
||||
"""Parse Docker size string to bytes."""
|
||||
if not size_str:
|
||||
return 0
|
||||
|
||||
size_str = size_str.upper().strip()
|
||||
multipliers = {
|
||||
"TB": 1024 ** 4,
|
||||
"GB": 1024 ** 3,
|
||||
"MB": 1024 ** 2,
|
||||
"KB": 1024,
|
||||
"B": 1,
|
||||
}
|
||||
|
||||
# Important: match longer suffixes first, otherwise 'B' would match 'MB'
|
||||
for suffix in ("TB", "GB", "MB", "KB", "B"):
|
||||
mult = multipliers[suffix]
|
||||
if size_str.endswith(suffix):
|
||||
try:
|
||||
num = float(size_str[:-len(suffix)])
|
||||
return int(num * mult)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
try:
|
||||
return int(size_str)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
async def get_docker_hosts(self) -> List[Dict[str, Any]]:
|
||||
"""Get all hosts with Docker info."""
|
||||
async with async_session_maker() as session:
|
||||
host_repo = HostRepository(session)
|
||||
container_repo = DockerContainerRepository(session)
|
||||
image_repo = DockerImageRepository(session)
|
||||
volume_repo = DockerVolumeRepository(session)
|
||||
alert_repo = DockerAlertRepository(session)
|
||||
|
||||
# Best-effort: read inventory and only show hosts in role_docker group.
|
||||
# Visibility is controlled by inventory membership; docker_enabled remains a user toggle.
|
||||
docker_host_names: Optional[set[str]] = None
|
||||
try:
|
||||
inv_hosts = ansible_service.get_hosts_from_inventory()
|
||||
docker_host_names = {
|
||||
h.name
|
||||
for h in inv_hosts
|
||||
if "role_docker" in (getattr(h, "groups", None) or [])
|
||||
}
|
||||
except Exception:
|
||||
# Inventory read is best-effort; do not break Docker UI
|
||||
docker_host_names = None
|
||||
|
||||
# Return all active hosts so the UI can show both enabled and disabled
|
||||
# Docker monitoring states (pause/play).
|
||||
hosts = await host_repo.list(limit=1000)
|
||||
hosts = [h for h in hosts if not h.deleted_at]
|
||||
if docker_host_names is not None:
|
||||
hosts = [h for h in hosts if h.name in docker_host_names]
|
||||
hosts = sorted(hosts, key=lambda h: (h.name or ""))
|
||||
result = []
|
||||
|
||||
for host in hosts:
|
||||
if host.deleted_at:
|
||||
continue
|
||||
|
||||
containers = await container_repo.count_by_host(host.id)
|
||||
images = await image_repo.count_by_host(host.id)
|
||||
volumes = await volume_repo.count_by_host(host.id)
|
||||
open_alerts = await alert_repo.count_open_by_host(host.id)
|
||||
|
||||
result.append({
|
||||
"host_id": host.id,
|
||||
"host_name": host.name,
|
||||
"host_ip": host.ip_address,
|
||||
"docker_enabled": host.docker_enabled,
|
||||
"docker_version": host.docker_version,
|
||||
"docker_status": host.docker_status,
|
||||
"docker_last_collect_at": host.docker_last_collect_at,
|
||||
"containers_total": containers.get("total", 0),
|
||||
"containers_running": containers.get("running", 0),
|
||||
"images_total": images.get("total", 0),
|
||||
"volumes_total": volumes,
|
||||
"open_alerts": open_alerts
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
async def enable_docker_monitoring(self, host_id: str, enabled: bool = True) -> bool:
|
||||
"""Enable or disable Docker monitoring on a host."""
|
||||
async with async_session_maker() as session:
|
||||
host_repo = HostRepository(session)
|
||||
host = await host_repo.get(host_id)
|
||||
|
||||
if not host:
|
||||
return False
|
||||
|
||||
await host_repo.update(host, docker_enabled=enabled)
|
||||
await session.commit()
|
||||
|
||||
# If enabling, trigger an immediate collection
|
||||
if enabled:
|
||||
asyncio.create_task(self.collect_docker_host(host_id))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Singleton instance
|
||||
docker_service = DockerService()
|
||||
@@ -23,6 +23,9 @@ from app.schemas.schedule_api import (
|
||||
ScheduleUpdateRequest,
|
||||
ScheduleStats,
|
||||
)
|
||||
from app.schemas.task_api import Task
|
||||
from app.services.task_log_service import TaskLogService
|
||||
from app.services.websocket_service import ws_manager
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
@@ -33,6 +36,7 @@ class SchedulerService:
|
||||
self._schedules_cache: Dict[str, Schedule] = {}
|
||||
self._timezone = pytz.timezone(settings.scheduler_timezone)
|
||||
self._started = False
|
||||
self._task_log_service = TaskLogService(settings.tasks_logs_dir)
|
||||
|
||||
@property
|
||||
def scheduler(self) -> AsyncIOScheduler:
|
||||
@@ -56,14 +60,14 @@ class SchedulerService:
|
||||
await self._load_active_schedules_from_db()
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
print(f"⏰ Scheduler démarré avec {len(self._schedules_cache)} schedule(s)")
|
||||
print(f" Scheduler démarré avec {len(self._schedules_cache)} schedule(s)")
|
||||
|
||||
def shutdown(self):
|
||||
"""Arrête le scheduler proprement."""
|
||||
if self._scheduler and self._started:
|
||||
self._scheduler.shutdown(wait=False)
|
||||
self._started = False
|
||||
print("⏰ Scheduler arrêté")
|
||||
print(" Scheduler arrêté")
|
||||
|
||||
async def _load_active_schedules_from_db(self):
|
||||
"""Charge les schedules actifs depuis la base de données."""
|
||||
@@ -201,10 +205,50 @@ class SchedulerService:
|
||||
status="running"
|
||||
)
|
||||
|
||||
task_id: Optional[str] = None
|
||||
|
||||
task_name = f"[Planifié] {schedule.name}"
|
||||
target = schedule.target
|
||||
|
||||
# Créer une Task (pour aligner l'affichage UI sur /api/tasks/logs)
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
from app.crud.task import TaskRepository
|
||||
|
||||
task_id = uuid.uuid4().hex
|
||||
run.task_id = task_id
|
||||
|
||||
repo = TaskRepository(session)
|
||||
await repo.create(
|
||||
id=task_id,
|
||||
action=task_name,
|
||||
target=target,
|
||||
playbook=schedule.playbook,
|
||||
status="running",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "task_created",
|
||||
"data": {
|
||||
"id": task_id,
|
||||
"name": task_name,
|
||||
"host": target,
|
||||
"status": "running",
|
||||
"progress": 0,
|
||||
"start_time": start_time,
|
||||
"end_time": None,
|
||||
"duration": None,
|
||||
"output": None,
|
||||
"error": None,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Erreur création Task pour schedule {schedule_id}: {e}")
|
||||
|
||||
try:
|
||||
# Importer les services nécessaires
|
||||
from app.services.ansible_service import ansible_service
|
||||
from app.services.websocket_service import ws_manager
|
||||
from app.services.notification_service import notification_service
|
||||
|
||||
# Mettre à jour le statut
|
||||
@@ -254,6 +298,69 @@ class SchedulerService:
|
||||
await self._persist_run(run)
|
||||
await self._update_schedule_stats_in_db(schedule)
|
||||
|
||||
# Mettre à jour la Task + générer le log markdown
|
||||
try:
|
||||
if task_id:
|
||||
status = "completed" if result.get("success") else "failed"
|
||||
|
||||
mem_task = Task(
|
||||
id=task_id,
|
||||
name=task_name,
|
||||
host=target,
|
||||
status=status,
|
||||
progress=100,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
duration=f"{duration:.1f}s",
|
||||
output=result.get("stdout", ""),
|
||||
error=result.get("stderr", "") if not result.get("success") else None,
|
||||
)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
from app.crud.task import TaskRepository
|
||||
|
||||
repo = TaskRepository(session)
|
||||
db_task = await repo.get(task_id)
|
||||
if db_task:
|
||||
await repo.update(
|
||||
db_task,
|
||||
status=status,
|
||||
completed_at=end_time,
|
||||
error_message=mem_task.error,
|
||||
result_data={"output": (result.get("stdout", "") or "")[:5000]},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
log_path = self._task_log_service.save_task_log(
|
||||
task=mem_task,
|
||||
output=result.get("stdout", ""),
|
||||
error=result.get("stderr", ""),
|
||||
source_type="scheduled",
|
||||
)
|
||||
created_log = self._task_log_service.index_log_file(log_path)
|
||||
if created_log:
|
||||
await ws_manager.broadcast({
|
||||
"type": "task_log_created",
|
||||
"data": created_log.dict()
|
||||
})
|
||||
except Exception as log_error:
|
||||
print(f"Erreur sauvegarde log markdown (schedule): {log_error}")
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "task_completed" if status == "completed" else "task_failed",
|
||||
"data": {
|
||||
"id": task_id,
|
||||
"status": status,
|
||||
"progress": 100,
|
||||
"duration": f"{duration:.1f}s",
|
||||
"success": result.get("success", False),
|
||||
"error": mem_task.error,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Erreur finalisation Task pour schedule {schedule_id}: {e}")
|
||||
|
||||
# Mettre à jour next_run
|
||||
job = self.scheduler.get_job(f"schedule_{schedule_id}")
|
||||
if job and job.next_run_time:
|
||||
@@ -304,6 +411,62 @@ class SchedulerService:
|
||||
await self._persist_run(run)
|
||||
await self._update_schedule_stats_in_db(schedule)
|
||||
|
||||
# Mettre à jour la Task + générer le log markdown (échec)
|
||||
try:
|
||||
if task_id:
|
||||
mem_task = Task(
|
||||
id=task_id,
|
||||
name=task_name,
|
||||
host=target,
|
||||
status="failed",
|
||||
progress=100,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
duration=f"{duration:.1f}s",
|
||||
output="",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
from app.crud.task import TaskRepository
|
||||
|
||||
repo = TaskRepository(session)
|
||||
db_task = await repo.get(task_id)
|
||||
if db_task:
|
||||
await repo.update(
|
||||
db_task,
|
||||
status="failed",
|
||||
completed_at=end_time,
|
||||
error_message=str(e),
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
log_path = self._task_log_service.save_task_log(
|
||||
task=mem_task,
|
||||
error=str(e),
|
||||
source_type="scheduled",
|
||||
)
|
||||
created_log = self._task_log_service.index_log_file(log_path)
|
||||
if created_log:
|
||||
await ws_manager.broadcast({
|
||||
"type": "task_log_created",
|
||||
"data": created_log.dict()
|
||||
})
|
||||
except Exception as log_error:
|
||||
print(f"Erreur sauvegarde log markdown (schedule fail): {log_error}")
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "task_failed",
|
||||
"data": {
|
||||
"id": task_id,
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
}
|
||||
})
|
||||
except Exception as inner_e:
|
||||
print(f"Erreur finalisation Task (exception) pour schedule {schedule_id}: {inner_e}")
|
||||
|
||||
print(f"Erreur exécution schedule {schedule_id}: {e}")
|
||||
|
||||
async def _persist_run(self, run: ScheduleRun):
|
||||
|
||||
Reference in New Issue
Block a user