"""Admin-enabled, bounded diagnostics with independent, short DB probes.""" import asyncio import json import os import re import sqlite3 import time from collections import deque from urllib.parse import quote, urlencode from .docker import DockerEngine, DockerError, decode_docker_output from .host_metrics import rates from .telemetry import RequestRecorder def redact_logs(value): value = re.sub(r"(?i)([a-z][a-z0-9+.-]*://)[^\s/@]+:[^\s/@]+@", r"\1[REDACTED]@", value) value = re.sub(r"(?i)(Bearer\s+)[^\s\"']+", r"\1[REDACTED]", value) value = re.sub(r"(?i)((?:password|passwd|api[_-]?key|access[_-]?token|secret|authorization|token)[\"']?\s*[:=]\s*[\"']?)[^\s,\"'}]+", r"\1[REDACTED]", value) value = re.sub(r"\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+", "[REDACTED_JWT]", value) value = re.sub(r"\bsk-[a-zA-Z0-9_-]{12,}", "[REDACTED_KEY]", value) return value def container_metrics(detail, stats, previous_stats=None): cpu = stats.get("cpu_stats", {}) previous = previous_stats.get("cpu_stats", {}) if previous_stats else stats.get("precpu_stats", {}) delta = cpu.get("cpu_usage", {}).get("total_usage", 0) - previous.get("cpu_usage", {}).get("total_usage", 0) system_delta = cpu.get("system_cpu_usage", 0) - previous.get("system_cpu_usage", 0) cores = cpu.get("online_cpus") or len(cpu.get("cpu_usage", {}).get("percpu_usage", [])) or 1 memory = stats.get("memory_stats", {}) cache = memory.get("stats", {}).get("inactive_file", memory.get("stats", {}).get("total_inactive_file", 0)) state = detail.get("State", {}) return { "name": detail.get("Name", "").lstrip("/"), "image": detail.get("Config", {}).get("Image", ""), "image_id": detail.get("Image", "")[:22], "created_at": detail.get("Created"), "started_at": state.get("StartedAt"), "status": state.get("Status"), "health": state.get("Health", {}).get("Status"), "restarts": detail.get("RestartCount", 0), "oom_killed": state.get("OOMKilled", False), "cpu_percent": round(delta / system_delta * cores * 100, 2) if previous.get("system_cpu_usage") and system_delta > 0 and delta >= 0 else None, "cpu_limit": detail.get("HostConfig", {}).get("NanoCpus", 0) / 1e9 or None, "memory_bytes": max(0, memory.get("usage", 0) - cache), "memory_limit": memory.get("limit"), "pids": stats.get("pids_stats", {}).get("current"), "log_policy": detail.get("HostConfig", {}).get("LogConfig", {}), "host_proc_mounted": any(m.get("Destination") == "/host/proc" for m in detail.get("Mounts", [])), "docker_socket_mounted": any(m.get("Destination") == "/var/run/docker.sock" for m in detail.get("Mounts", [])), } class Diagnostics: def __init__(self, pool, app): self.pool = pool self.app = app self.path = pool.store.directory / "diagnostics-settings.json" self.archive_directory = pool.store.directory / "log-archive" self.enabled = json.loads(self.path.read_text()).get("enabled", False) if self.path.exists() else False self.recorder = RequestRecorder() self.recorder.enabled = self.enabled app.state.request_recorder = self.recorder self.history = deque(maxlen=720) self.latest = None self.previous = None self.container_previous = {} from .revision import code_revision self.runtime_revision = code_revision() self.task = None self.lag_task = None self.loop_lag_ms = 0 self.db_engines = {} self.db = None if self.enabled: self._ensure_db() def _ensure_db(self): if self.db is None: self.db = sqlite3.connect(self.pool.store.directory / "diagnostics.sqlite3") self.db.execute("PRAGMA journal_mode=WAL") self.db.execute("CREATE TABLE IF NOT EXISTS snapshots (id INTEGER PRIMARY KEY, at REAL, body TEXT)") self.db.execute("CREATE TABLE IF NOT EXISTS requests (id INTEGER PRIMARY KEY, at REAL, body TEXT)") for (value,) in reversed(self.db.execute("SELECT body FROM snapshots ORDER BY id DESC LIMIT 720").fetchall()): self.history.append(json.loads(value)) for (value,) in reversed(self.db.execute("SELECT body FROM requests ORDER BY id DESC LIMIT 2000").fetchall()): self.recorder.completed.append(json.loads(value)) return self.db def start(self): if not self.enabled: return self._ensure_db() if self.task is not None and not self.task.done(): return self.task = asyncio.create_task(self.loop()) self.lag_task = asyncio.create_task(self.measure_lag()) async def close(self): for task in (self.task, self.lag_task): if task: task.cancel() await asyncio.gather(*(t for t in (self.task, self.lag_task) if t), return_exceptions=True) self.persist_requests() if self.db is not None: self.db.close() self.db = None for engine in self.db_engines.values(): await engine.dispose() async def set_enabled(self, enabled): self.enabled = enabled self.recorder.enabled = enabled temporary = self.path.with_suffix(".tmp") fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as stream: json.dump({"enabled": enabled}, stream) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, self.path) if enabled: self._ensure_db() self.start() else: tasks = [task for task in (self.task, self.lag_task) if task] for task in tasks: if task: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) self.task = None self.lag_task = None self.previous = None self.persist_requests() async def measure_lag(self): while True: before = asyncio.get_running_loop().time() await asyncio.sleep(1) self.loop_lag_ms = round(max(0, asyncio.get_running_loop().time() - before - 1) * 1000, 2) if self.enabled else 0 async def loop(self): while True: if self.enabled: try: await self.sample() except Exception as exc: self.latest = {"at": time.time(), "error": f"采集失败({type(exc).__name__})"} await asyncio.sleep(5) def persist_requests(self): batch = [] while self.recorder.pending: row = self.recorder.pending.popleft() batch.append((row["started"], json.dumps(row))) if batch and self.db is not None: self.db.executemany("INSERT INTO requests(at, body) VALUES (?, ?)", batch) self.db.execute("DELETE FROM requests WHERE id <= (SELECT COALESCE(MAX(id),0)-10000 FROM requests)") self.db.commit() async def sample(self): self._ensure_db() settings = self.pool.store.load() engine = DockerEngine(settings) names = [settings.source_container, *list(self.pool.nodes)] async def read_container(name): try: encoded = quote(name, safe="") detail = await asyncio.to_thread(engine.request, "GET", f"/containers/{encoded}/json") stats = await asyncio.to_thread(engine.request, "GET", f"/containers/{encoded}/stats?stream=false&one-shot=true") key = detail.get("Id", name) result = container_metrics(detail, stats, self.container_previous.get(key)) self.container_previous[key] = stats return result except Exception as exc: return {"name": name, "error": f"节点指标不可用({type(exc).__name__})"} async def host(): try: raw = await asyncio.to_thread(engine.host_snapshot, settings.source_container) result = rates(raw, self.previous) self.previous = raw return result except Exception as exc: detail = str(exc) if isinstance(exc, DockerError) else type(exc).__name__ return {"available": False, "message": f"宿主机采集不可用:{detail}。请检查只读挂载和 Docker 连接"} host_data, containers, databases = await asyncio.gather(host(), asyncio.gather(*(read_container(n) for n in names if n)), self.database_metrics()) result = {"at": time.time(), "host": host_data, "containers": containers, "databases": databases, "event_loop_lag_ms": self.loop_lag_ms, "nodes": self.pool.status()["nodes"], "runtime_revision": self.runtime_revision} self.latest = result summary = { "at": result["at"], "cpu_percent": host_data.get("cpu_percent"), "iowait_percent": host_data.get("iowait_percent"), "memory_used_percent": round(100 * (1 - host_data["memory_available"] / host_data["memory_total"]), 2) if host_data.get("memory_total") else None, "rx_bps": sum(v.get("rx_bps") or 0 for v in host_data.get("interfaces", {}).values()), "tx_bps": sum(v.get("tx_bps") or 0 for v in host_data.get("interfaces", {}).values()), "loop_lag_ms": self.loop_lag_ms, "mysql_connections": databases.get("mysql", {}).get("connected"), "postgres_connections": databases.get("postgres", {}).get("connected"), "p95_headers_ms": self.recorder.summary()["p95_headers_ms"], } self.history.append(summary) self.db.execute("INSERT INTO snapshots(at,body) VALUES (?,?)", (result["at"], json.dumps(summary))) self.db.execute("DELETE FROM snapshots WHERE id <= (SELECT COALESCE(MAX(id),0)-720 FROM snapshots)") self.db.commit() self.persist_requests() def status(self): return {"enabled": self.enabled, "sample_interval_seconds": 5, "retained_samples": 720, "latest": self.latest, "history": list(self.history), "http": self.recorder.summary()} async def database_metrics(self): config = getattr(self.app.state, "config", None) db = getattr(config, "database", None) checkpoint = getattr(config, "checkpointer", None) result = {} if getattr(db, "backend", None) == "mysql": result["mysql"] = await self.mysql_metrics(db.app_sqlalchemy_url) else: result["mysql"] = {"available": False, "reason": "业务库未使用 MySQL"} from .preflight import postgres_connection_string pg_url = postgres_connection_string(config) if pg_url: result["postgres"] = await self.postgres_metrics(pg_url) else: result["postgres"] = {"available": False, "configured": False, "reason": "当前聊天存储为 SQLite,未启用 PostgreSQL,不能显示为 0 个连接"} result["chat_backend"] = "postgres" if pg_url else ("sqlite" if checkpoint and checkpoint.type in {"sqlite", "postgres"} else "unknown") return result async def mysql_metrics(self, url): from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine key = "mysql" try: if key not in self.db_engines: self.db_engines[key] = create_async_engine(url, pool_size=1, max_overflow=0, pool_timeout=1, pool_pre_ping=False, connect_args={"connect_timeout": 2}) async with asyncio.timeout(4): async with self.db_engines[key].connect() as connection: statuses = dict((await connection.execute(text("SHOW GLOBAL STATUS WHERE Variable_name IN ('Threads_connected','Threads_running','Max_used_connections','Slow_queries','Aborted_connects')"))).all()) variables = dict((await connection.execute(text("SHOW GLOBAL VARIABLES WHERE Variable_name IN ('max_connections','long_query_time')"))).all()) rows = (await connection.execute(text("SHOW PROCESSLIST"))).mappings().all() running = [r for r in rows if str(r.get("Command", "")).lower() not in {"sleep", "daemon"}] return { "available": True, "connected": int(statuses.get("Threads_connected", 0)), "running": int(statuses.get("Threads_running", 0)), "max_connections": int(variables.get("max_connections", 0)), "peak_connections": int(statuses.get("Max_used_connections", 0)), "slow_queries": int(statuses.get("Slow_queries", 0)), "slow_query_threshold_seconds": float(variables.get("long_query_time", 0)), "longest_active_seconds": max((int(r.get("Time", 0)) for r in running), default=0), "processes_visible": len(rows), "note": "连接总数为 MySQL 全局值(包含采集连接);最长执行时间只覆盖当前账号可见进程,不读取 SQL 文本。", } except Exception as exc: return {"available": False, "error": f"MySQL 指标不可用({type(exc).__name__}),请检查连接与只读监控权限"} async def postgres_metrics(self, url): try: import psycopg async with asyncio.timeout(4): async with await psycopg.AsyncConnection.connect(url, connect_timeout=2, autocommit=True) as connection: async with connection.cursor() as cursor: query = ( "SELECT count(*), count(*) FILTER (WHERE state='active'), " "count(*) FILTER (WHERE wait_event_type='Lock'), " "COALESCE(max(EXTRACT(EPOCH FROM now()-query_start)) " "FILTER (WHERE state='active' AND pid<>pg_backend_pid()), 0) " "FROM pg_stat_activity" ) await cursor.execute(query) connected, active, locks, longest = await cursor.fetchone() await cursor.execute("SHOW max_connections") maximum = (await cursor.fetchone())[0] return { "available": True, "configured": True, "connected": connected, "active": active, "lock_waits": locks, "max_connections": int(maximum), "longest_active_seconds": float(longest), "note": "按 pg_stat_activity 采集,普通账号可能看不到其他账号的状态和耗时;包含采集连接,不读取 SQL 文本。", } except Exception as exc: return {"available": False, "configured": True, "error": f"PostgreSQL 指标不可用({type(exc).__name__}),请检查连接与监控权限"} async def logs(self, name, tail=200, minutes=60, query=""): if name.startswith("archive:"): return self.read_archive(name[len("archive:") :], query) settings = self.pool.store.load() if name != settings.source_container and name not in self.pool.nodes: raise DockerError("只能查看当前主服务和本 Worker 池的日志") engine = DockerEngine(settings) encoded = quote(name, safe="") detail = await asyncio.to_thread(engine.request, "GET", f"/containers/{encoded}/json") if name != settings.source_container and detail.get("Config", {}).get("Labels", {}).get("deerflow.worker-pool") != self.pool.store.raw()["instance"]: raise DockerError("节点不属于当前 Worker 池") params = urlencode({"stdout": 1, "stderr": 1, "timestamps": 1, "tail": min(1000, max(1, tail)), "since": int(time.time() - min(1440, max(1, minutes)) * 60)}) data = await asyncio.to_thread(engine.request, "GET", f"/containers/{encoded}/logs?{params}", raw=True, max_bytes=1024 * 1024) lines = redact_logs(decode_docker_output(data)).splitlines() if query: lines = [line for line in lines if query.casefold() in line.casefold()] return {"node": name, "lines": lines[-1000:], "log_policy": detail.get("HostConfig", {}).get("LogConfig", {}), "limited_bytes": 1024 * 1024} async def archive_logs(self, engine, name): """Keep a bounded redacted tail BEFORE removing an owned container.""" import gzip encoded = quote(name, safe="") params = urlencode({"stdout": 1, "stderr": 1, "timestamps": 1, "tail": 10000}) data = await asyncio.to_thread(engine.request, "GET", f"/containers/{encoded}/logs?{params}", raw=True, max_bytes=2 * 1024 * 1024) archive_id = f"{name}-{time.time_ns()}" payload = {"node": name, "created_at": time.time(), "lines": redact_logs(decode_docker_output(data)).splitlines(), "limited_bytes": 2 * 1024 * 1024, "note": "节点删除前的最近 10000 行 / 最多 2 MB 归档;保存 7 天,最多 50 份。"} self.archive_directory.mkdir(mode=0o700, parents=True, exist_ok=True) target = self.archive_directory / f"{archive_id}.json.gz" fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "wb") as out: out.write(gzip.compress(json.dumps(payload).encode())) files = sorted(self.archive_directory.glob("*.json.gz"), key=lambda p: p.stat().st_mtime, reverse=True) for index, path in enumerate(files): if index >= 50 or path.stat().st_mtime < time.time() - 7 * 86400: path.unlink() def archives(self): return [ {"id": p.name.removesuffix(".json.gz"), "name": p.name.removesuffix(".json.gz"), "created_at": p.stat().st_mtime} for p in sorted(self.archive_directory.glob("*.json.gz"), key=lambda p: p.stat().st_mtime, reverse=True)[:50] if p.stat().st_mtime >= time.time() - 7 * 86400 ] def read_archive(self, archive_id, query=""): import gzip if not re.fullmatch(r"[A-Za-z0-9_.-]{1,180}", archive_id): raise DockerError("无效日志归档") path = self.archive_directory / f"{archive_id}.json.gz" if not path.is_file() or path.stat().st_mtime < time.time() - 7 * 86400: raise DockerError("日志归档不存在或已过期") payload = json.loads(gzip.decompress(path.read_bytes())) payload["lines"] = [line for line in payload["lines"] if query.casefold() in line.casefold()][-1000:] return payload