This commit is contained in:
Your Name
2026-04-22 14:13:21 +08:00
parent e0406b5d0e
commit 7cbde2aa78
145 changed files with 23086 additions and 2243 deletions

View File

@@ -1,8 +1,16 @@
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,
@@ -76,6 +84,7 @@ def execute_ssh_action(node: dict, action: str, payload: dict | None = None) ->
}
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(
@@ -105,28 +114,69 @@ def execute_ssh_action(node: dict, action: str, payload: dict | None = None) ->
"PY",
]
)
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,
]
timeout_seconds = int(_SSH_REMOTE_TIMEOUT_SECONDS.get(normalized_action, 45) or 45) + _SSH_CONNECT_TIMEOUT_SECONDS
completed = subprocess.run(
ssh_command,
capture_output=True,
text=True,
timeout=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",
@@ -134,6 +184,7 @@ def execute_ssh_action(node: dict, action: str, payload: dict | None = None) ->
"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),
}
@@ -166,6 +217,93 @@ def execute_ssh_action(node: dict, action: str, payload: dict | None = None) ->
}
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,
@@ -199,6 +337,25 @@ def trim_output(text, 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()