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

This commit is contained in:
2025-12-15 20:36:06 -05:00
parent 27eed55c9b
commit 68a9b0f390
123 changed files with 13216 additions and 157 deletions
+21 -6
View File
@@ -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
+617
View File
@@ -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()
+385
View File
@@ -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()
+610
View File
@@ -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()
+166 -3
View File
@@ -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):