Files
getDomain/domain-api/app/services/ops_runtime_executor_service.py
Your Name 7cbde2aa78 d
2026-04-22 14:13:21 +08:00

619 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import shlex
import subprocess
from io import StringIO
try:
import paramiko
except ImportError: # pragma: no cover - exercised via graceful fallback tests
paramiko = None
from app.core.db import get_db
from app.core.config import settings
from app.services.ops_action_executor_core import (
STRUCTURED_ACTIONS,
build_service_name_map,
execute_structured_action,
trim_output,
)
from app.services.ops_release_executor_core import build_remote_release_action_script, execute_release_action
_SSH_SUPPORTED_ACTIONS = set(STRUCTURED_ACTIONS) | {"deploy.release"}
_SSH_CONNECT_TIMEOUT_SECONDS = 12
_SSH_REMOTE_TIMEOUT_SECONDS = {
"health.snapshot": 30,
"service.status": 45,
"service.restart": 45,
"service.start": 45,
"service.stop": 45,
"logs.collect": 45,
"diagnostics.collect": 90,
"runtime.start_worker": 45,
"runtime.stop_worker": 45,
"runtime.start_detection": 45,
"runtime.stop_detection": 45,
"runtime.pull_tasks": 60,
"runtime.restart_api": 45,
"runtime.start_sync_agent": 45,
"runtime.stop_sync_agent": 45,
"deploy.release": 180,
}
def supports_ssh_execution(action: str) -> bool:
return str(action or "").strip() in _SSH_SUPPORTED_ACTIONS
def execute_local_support_action(action: str, payload: dict | None = None) -> tuple[bool, str, dict]:
normalized_action = str(action or "").strip()
if normalized_action not in _SSH_SUPPORTED_ACTIONS:
return False, f"当前未实现本机支持动作: {normalized_action}", {}
if normalized_action == "deploy.release":
return execute_release_action(
dict(payload or {}),
run_command=_run_local_command,
default_api_service_name=settings.api_service_name,
user_agent="domaincheck-local-runtime/0.1",
)
return execute_structured_action(
normalized_action,
dict(payload or {}),
service_names=_service_name_map(),
runner=_run_local_command,
)
def execute_ssh_action(node: dict, action: str, payload: dict | None = None) -> tuple[bool, str, dict]:
normalized_action = str(action or "").strip()
if normalized_action not in _SSH_SUPPORTED_ACTIONS:
return False, f"当前 SSH 执行器暂不支持动作: {normalized_action}", {}
ssh_host = str(node.get("ssh_host") or "").strip()
ssh_user = str(node.get("ssh_user") or "").strip()
ssh_port = max(1, int(node.get("ssh_port") or 22))
node_code = str(node.get("node_code") or "").strip()
if not ssh_host or not ssh_user:
return False, "目标节点缺少 SSH 主机或用户信息", {
"node_code": node_code,
"ssh_host": ssh_host,
"ssh_user": ssh_user,
"ssh_port": ssh_port,
}
normalized_payload = dict(payload or {})
node_secret = _load_ssh_secret(node_code)
service_names = _service_name_map()
if normalized_action == "deploy.release":
remote_script = build_remote_release_action_script(
normalized_payload,
default_api_service_name=service_names.get("api") or "domaincheck-api",
user_agent="domaincheck-ssh/0.1",
)
else:
remote_script = _build_remote_python_script(
action=normalized_action,
payload=normalized_payload,
service_names=service_names,
)
remote_command = "\n".join(
[
"set -euo pipefail",
"if command -v python3 >/dev/null 2>&1; then",
" PYTHON_BIN=python3",
"elif command -v python >/dev/null 2>&1; then",
" PYTHON_BIN=python",
"else",
' echo "{\\"ok\\": false, \\"message\\": \\"python interpreter missing on remote node\\", \\"result\\": {\\"executor\\": \\"ssh\\"}}"',
" exit 127",
"fi",
'"${PYTHON_BIN}" - <<\'PY\'',
remote_script.rstrip("\n"),
"PY",
]
)
timeout_seconds = int(_SSH_REMOTE_TIMEOUT_SECONDS.get(normalized_action, 45) or 45) + _SSH_CONNECT_TIMEOUT_SECONDS
auth_mode = str(node.get("auth_mode") or "").strip() or ("password" if node_secret.get("ssh_password") else "key")
ssh_password = str(node_secret.get("ssh_password") or "").strip()
ssh_private_key = str(node_secret.get("ssh_private_key") or "").strip()
if auth_mode == "key" and not ssh_private_key and ssh_password:
auth_mode = "password"
elif auth_mode == "password" and not ssh_password and ssh_private_key:
auth_mode = "key"
if (auth_mode == "password" and ssh_password) or ssh_private_key:
if paramiko is None:
return False, "当前环境未安装 paramiko无法使用密码或私钥 SSH 执行", {
"executor": "ssh",
"action": normalized_action,
"transport": {
"executor": "ssh",
"node_code": node_code,
"ssh_host": ssh_host,
"ssh_user": ssh_user,
"ssh_port": ssh_port,
"auth_mode": auth_mode,
"action": normalized_action,
},
}
if auth_mode == "password" and ssh_password:
completed = _run_paramiko_command(
ssh_host=ssh_host,
ssh_port=ssh_port,
ssh_user=ssh_user,
remote_command=remote_command,
timeout_seconds=timeout_seconds,
ssh_password=ssh_password,
)
elif ssh_private_key:
completed = _run_paramiko_command(
ssh_host=ssh_host,
ssh_port=ssh_port,
ssh_user=ssh_user,
remote_command=remote_command,
timeout_seconds=timeout_seconds,
ssh_private_key=ssh_private_key,
)
else:
ssh_command = [
"ssh",
"-o",
"BatchMode=yes",
"-o",
"PreferredAuthentications=publickey",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
f"ConnectTimeout={_SSH_CONNECT_TIMEOUT_SECONDS}",
"-p",
str(ssh_port),
f"{ssh_user}@{ssh_host}",
remote_command,
]
completed = subprocess.run(
ssh_command,
capture_output=True,
text=True,
timeout=timeout_seconds,
)
transport = {
"executor": "ssh",
"node_code": node_code,
"ssh_host": ssh_host,
"ssh_user": ssh_user,
"ssh_port": ssh_port,
"auth_mode": auth_mode,
"action": normalized_action,
"returncode": int(completed.returncode or 0),
}
parsed_ok, parsed_payload = _extract_json_from_output(completed.stdout or "")
if parsed_ok:
ok = bool(parsed_payload.get("ok", False))
message = str(parsed_payload.get("message") or "").strip() or ("SSH 动作执行成功" if ok else "SSH 动作执行失败")
result = dict(parsed_payload.get("result") or {})
result["transport"] = {
**transport,
"stdout_preview": trim_output(completed.stdout, 4000),
"stderr_preview": trim_output(completed.stderr, 2000),
}
if completed.returncode != 0 and ok:
ok = False
message = f"SSH 命令返回码异常: {completed.returncode}"
return ok, message, result
stderr_preview = trim_output(completed.stderr, 4000)
stdout_preview = trim_output(completed.stdout, 4000)
message = stderr_preview or stdout_preview or f"SSH 执行失败,返回码 {completed.returncode}"
return False, message, {
"executor": "ssh",
"action": normalized_action,
"transport": {
**transport,
"stdout_preview": stdout_preview,
"stderr_preview": stderr_preview,
},
}
def _load_ssh_secret(node_code: str) -> dict:
normalized_node_code = str(node_code or "").strip()
if not normalized_node_code:
return {}
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT ssh_password, ssh_private_key
FROM ops_managed_node_secrets
WHERE node_code = %s
LIMIT 1
""",
(normalized_node_code,),
)
row = cur.fetchone()
except Exception:
return {}
if not row:
return {}
return {
"ssh_password": str(row[0] or ""),
"ssh_private_key": str(row[1] or ""),
}
def _load_private_key(private_key_text: str) -> paramiko.PKey:
if paramiko is None:
raise RuntimeError("paramiko is not installed")
key_text = str(private_key_text or "")
for key_cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey, paramiko.DSSKey):
try:
return key_cls.from_private_key(StringIO(key_text))
except Exception:
continue
raise ValueError("无法识别 SSH 私钥格式")
def _run_paramiko_command(
*,
ssh_host: str,
ssh_port: int,
ssh_user: str,
remote_command: str,
timeout_seconds: int,
ssh_password: str = "",
ssh_private_key: str = "",
) -> subprocess.CompletedProcess:
if paramiko is None:
raise RuntimeError("paramiko is not installed")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs = {
"hostname": ssh_host,
"port": int(ssh_port),
"username": ssh_user,
"timeout": _SSH_CONNECT_TIMEOUT_SECONDS,
"banner_timeout": _SSH_CONNECT_TIMEOUT_SECONDS,
"auth_timeout": _SSH_CONNECT_TIMEOUT_SECONDS,
"look_for_keys": False,
"allow_agent": False,
}
if ssh_password:
connect_kwargs["password"] = ssh_password
elif ssh_private_key:
connect_kwargs["pkey"] = _load_private_key(ssh_private_key)
else:
connect_kwargs["look_for_keys"] = True
connect_kwargs["allow_agent"] = True
try:
client.connect(**connect_kwargs)
wrapped_command = f"bash -lc {shlex.quote(remote_command)}"
_, stdout, stderr = client.exec_command(wrapped_command, timeout=timeout_seconds)
returncode = int(stdout.channel.recv_exit_status())
stdout_text = stdout.read().decode("utf-8", errors="replace")
stderr_text = stderr.read().decode("utf-8", errors="replace")
return subprocess.CompletedProcess(
args=["paramiko", f"{ssh_user}@{ssh_host}"],
returncode=returncode,
stdout=stdout_text,
stderr=stderr_text,
)
finally:
client.close()
def _service_name_map() -> dict[str, str]:
return build_service_name_map(
api_service_name=settings.api_service_name,
worker_service_name=settings.worker_service_name,
sync_agent_service_name=settings.sync_agent_service_name,
)
def _run_local_command(command: list[str], *, timeout: int = 60) -> tuple[int, str, str]:
completed = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
return int(completed.returncode or 0), str(completed.stdout or "").strip(), str(completed.stderr or "").strip()
def _build_remote_python_script(*, action: str, payload: dict, service_names: dict[str, str]) -> str:
return f"""import json
import subprocess
import sys
from pathlib import Path
SERVICE_NAMES = {json.dumps(service_names, ensure_ascii=False)}
ACTION = {json.dumps(str(action or '').strip(), ensure_ascii=False)}
PAYLOAD = {json.dumps(dict(payload or {}), ensure_ascii=False)}
def trim_output(text, limit):
value = str(text or "").strip()
if len(value) <= int(limit):
return value
return value[-int(limit):]
def run(cmd, timeout=60):
completed = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
normalized_cmd = [str(part or "").strip() for part in cmd]
combined_output = f"{{completed.stdout or ''}}\\n{{completed.stderr or ''}}".lower()
needs_sudo_retry = (
normalized_cmd
and normalized_cmd[0] == "systemctl"
and completed.returncode != 0
and "sudo" not in normalized_cmd
and any(
marker in combined_output
for marker in (
"interactive authentication required",
"authentication is required",
"authorization not available",
"polkit",
)
)
)
if needs_sudo_retry:
completed = subprocess.run(["sudo", "-n", *normalized_cmd], capture_output=True, text=True, timeout=timeout)
return int(completed.returncode or 0), str(completed.stdout or "").strip(), str(completed.stderr or "").strip()
def read_tail_lines(path, max_lines):
file_path = Path(path)
if not file_path.exists() or not file_path.is_file():
return []
with file_path.open("r", encoding="utf-8", errors="replace") as handle:
return handle.read().splitlines()[-int(max_lines):]
def candidate_domain_roots():
roots = []
for candidate in (
Path.cwd() / "domainCheck",
Path("/opt/domaincheck/domainCheck"),
Path("/www/wwwroot/getDomain/domainCheck"),
):
if candidate not in roots:
roots.append(candidate)
return roots
def candidate_runtime_logs_dirs():
dirs = []
for candidate in (
Path.cwd() / "domain-api" / "runtime" / "logs",
Path("/opt/domaincheck/domain-api/runtime/logs"),
Path("/www/wwwroot/getDomain/domain-api/runtime/logs"),
):
if candidate not in dirs:
dirs.append(candidate)
return dirs
def runtime_module_roots():
roots = []
for candidate in (
Path.cwd() / "domain-api",
Path("/opt/domaincheck/domain-api"),
Path("/www/wwwroot/getDomain/domain-api"),
):
if candidate not in roots:
roots.append(candidate)
return roots
def service_log_file_candidates(service_name):
normalized_service_name = str(service_name or "").strip()
if not normalized_service_name:
return []
candidates = []
def append_candidate(path):
if path not in candidates:
candidates.append(path)
if normalized_service_name == (SERVICE_NAMES.get("worker") or "domaincheck-worker"):
for domain_root in candidate_domain_roots():
append_candidate(domain_root / "detect_worker.log")
append_candidate(domain_root / "logs" / "detect_worker.log")
elif normalized_service_name == (SERVICE_NAMES.get("api") or "domaincheck-api"):
for runtime_logs_dir in candidate_runtime_logs_dirs():
append_candidate(runtime_logs_dir / "domain-api.stderr.log")
append_candidate(runtime_logs_dir / "domain-api.stdout.log")
for domain_root in candidate_domain_roots():
append_candidate(domain_root / "logs" / "app.log")
return candidates
def collect_log_file_fallback(service_name, lines):
aggregated_lines = []
used_paths = []
for path in service_log_file_candidates(service_name):
current_lines = read_tail_lines(path, lines)
if not current_lines:
continue
aggregated_lines.extend(current_lines)
used_paths.append(str(path))
if not aggregated_lines:
return "", []
return "\\n".join(aggregated_lines[-int(lines):]), used_paths
def service_name_from_payload(payload):
value = str((payload or {{}}).get("service_name") or "").strip()
return value or SERVICE_NAMES.get("worker") or "domaincheck-worker"
def service_name_for_action(action, payload):
if action in ("service.status", "service.restart", "service.start", "service.stop"):
return service_name_from_payload(payload)
runtime_action_map = {{
"runtime.start_worker": SERVICE_NAMES.get("worker") or "domaincheck-worker",
"runtime.stop_worker": SERVICE_NAMES.get("worker") or "domaincheck-worker",
"runtime.restart_api": SERVICE_NAMES.get("api") or "domaincheck-api",
"runtime.start_sync_agent": SERVICE_NAMES.get("sync_agent") or "domaincheck-sync-agent",
"runtime.stop_sync_agent": SERVICE_NAMES.get("sync_agent") or "domaincheck-sync-agent",
}}
return str(runtime_action_map.get(action) or "").strip()
def systemctl_action_name(action):
if action in ("service.restart", "runtime.restart_api"):
return "restart"
if action in ("service.start", "runtime.start_worker", "runtime.start_sync_agent"):
return "start"
if action in ("service.stop", "runtime.stop_worker", "runtime.stop_sync_agent"):
return "stop"
return ""
def execute_runtime_action(action, payload):
runtime_action_name = str(action or "").strip().split(".", 1)[-1]
for root in runtime_module_roots():
app_dir = root / "app"
if not app_dir.exists():
continue
root_text = str(root)
if root_text not in sys.path:
sys.path.insert(0, root_text)
try:
from app.services.runtime_control_service import runtime_action
except Exception:
continue
ok, message, result = runtime_action(runtime_action_name, dict(payload or {{}}))
normalized_result = dict(result or {{}})
normalized_result["runtime_action"] = runtime_action_name
normalized_result["payload"] = dict(payload or {{}})
normalized_result["executor"] = "ssh"
normalized_result["runtime_root"] = root_text
return ok, message, normalized_result
return False, f"runtime action import failed: {{runtime_action_name}}", {{"action": action, "executor": "ssh"}}
def execute(action, payload):
if action == "health.snapshot":
checks = {{}}
for key, service_name in SERVICE_NAMES.items():
code, stdout, stderr = run(["systemctl", "is-active", service_name], timeout=15)
checks[key] = {{
"service_name": service_name,
"returncode": int(code or 0),
"state": stdout or stderr,
"ok": int(code or 0) == 0 and (stdout or stderr) == "active",
}}
return True, "health snapshot collected", {{"checks": checks}}
if action == "service.status":
service_name = service_name_from_payload(payload)
code, stdout, stderr = run(["systemctl", "status", service_name, "--no-pager", "-l"], timeout=45)
result = {{
"service_name": service_name,
"stdout": trim_output(stdout, 12000),
"stderr": trim_output(stderr, 4000),
}}
return int(code or 0) == 0, stdout or stderr or f"{{service_name}} status collected", result
if action in ("runtime.start_detection", "runtime.stop_detection", "runtime.pull_tasks"):
return execute_runtime_action(action, payload)
systemctl_action = systemctl_action_name(action)
if systemctl_action:
service_name = service_name_for_action(action, payload)
if not service_name:
return False, f"missing service name for action: {{action}}", {{"action": action}}
code, stdout, stderr = run(["systemctl", systemctl_action, service_name], timeout=45)
result = {{
"service_name": service_name,
"systemctl_action": systemctl_action,
"stdout": trim_output(stdout, 12000),
"stderr": trim_output(stderr, 4000),
}}
return int(code or 0) == 0, stdout or stderr or f"{{service_name}} {{systemctl_action}} completed", result
if action == "logs.collect":
service_name = service_name_from_payload(payload)
lines = max(20, min(int((payload or {{}}).get("lines") or 120), 500))
code, stdout, stderr = run(["journalctl", "-u", service_name, "-n", str(lines), "--no-pager"], timeout=45)
journal_output = stdout or stderr
fallback_output, fallback_paths = collect_log_file_fallback(service_name, lines)
collection_source = "journal"
effective_output = journal_output
ok = int(code or 0) == 0
if (not ok or not stdout.strip()) and fallback_output:
collection_source = "file_fallback"
effective_output = fallback_output
ok = True
result = {{
"service_name": service_name,
"lines": lines,
"collection_source": collection_source,
"fallback_used": bool(collection_source == "file_fallback"),
"fallback_reason": trim_output(journal_output, 4000) if collection_source == "file_fallback" else "",
"log_paths": fallback_paths,
"journal_returncode": int(code or 0),
"stdout": trim_output(effective_output, 20000),
"stderr": trim_output(stderr, 4000),
}}
return ok, effective_output or f"{{service_name}} logs collected", result
if action == "diagnostics.collect":
lines = max(20, min(int((payload or {{}}).get("lines") or 200), 800))
diagnostics = {{
"services": {{}},
"lines": lines,
}}
for key, service_name in SERVICE_NAMES.items():
active_code, active_stdout, active_stderr = run(["systemctl", "is-active", service_name], timeout=15)
status_code, status_stdout, status_stderr = run(["systemctl", "status", service_name, "--no-pager", "-l"], timeout=45)
log_code, log_stdout, log_stderr = run(["journalctl", "-u", service_name, "-n", str(lines), "--no-pager"], timeout=45)
log_output = log_stdout or log_stderr
fallback_output, fallback_paths = collect_log_file_fallback(service_name, lines)
log_source = "journal"
if (int(log_code or 0) != 0 or not log_stdout.strip()) and fallback_output:
log_output = fallback_output
log_source = "file_fallback"
diagnostics["services"][key] = {{
"service_name": service_name,
"active_returncode": int(active_code or 0),
"active_state": active_stdout or active_stderr,
"status_returncode": int(status_code or 0),
"status_output": trim_output(status_stdout or status_stderr, 12000),
"log_returncode": int(log_code or 0),
"log_source": log_source,
"log_paths": fallback_paths,
"log_output": trim_output(log_output, 20000),
}}
return True, "diagnostics collected", diagnostics
return False, f"unsupported action: {{action}}", {{"action": action}}
def main():
try:
ok, message, result = execute(ACTION, PAYLOAD)
except Exception as exc:
ok, message, result = False, str(exc), {{"exception": str(exc), "action": ACTION}}
print(json.dumps({{"ok": ok, "message": message, "result": result}}, ensure_ascii=False))
raise SystemExit(0 if ok else 1)
if __name__ == "__main__":
main()
"""
def _extract_json_from_output(text: str) -> tuple[bool, dict]:
for raw_line in reversed(str(text or "").splitlines()):
line = str(raw_line or "").strip()
if not line:
continue
try:
data = json.loads(line)
except Exception:
continue
if isinstance(data, dict) and "ok" in data:
return True, data
return False, {}