d
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user