Files
AirPlan-V2/lib/air_runtime/deploy_runtime.py
AirPlan 6130478c96 feat: AirPlan V2 — 全专家插件强制路由 + 事件系统规范化
P0-8 扩大: do_mode.py finish_worker 全专家插件强制路由
- GUI→XDB, network→NDB, C/C++→SDB, done→Rvr, blocked/failed→Dbg
- 证据去重: 已有 xdbSessions/ndbSessions/sdbReports/rvrReviewed 则跳过

P1-GAP17: 事件 emit 规范化
- 新增 7 个事件常量 (TASK_ENTERED, TASK_FINISHED, ENGINE_ENTERED 等)
- 全部 emit 调用替换字符串字面量为常量,零残留
- 30 个事件类型常量全部定义且唯一

P1-GAP18: 事件日志原子轮转
- emit 计数器每 128 次检查轮转,避免每次 emit 读文件
- 清除未使用的 _emit_with_completion/_pending_merge_complete
- 原子轮转: tempfile+os.replace 保证不损坏

eng 极端接管: 强制调用全部专家插件 (Dbg/XDB/NDB/SDB/Rvr)
commands/do.md: 更新为全专家插件路由文档

全量测试: 69 通过, 0 失败

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 15:56:44 +08:00

134 lines
4.6 KiB
Python
Executable File

"""
AirDep 部署运行时 — V2 新增组件。
SSH 远程构建 + 二进制传输 + systemd 生命周期管理 + 部署验证。
"""
from __future__ import annotations
import hashlib
import subprocess
from dataclasses import dataclass
from pathlib import Path
from air_runtime.contracts import DeploymentRecord, now_iso
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import dep_state_path
@dataclass
class DeployTarget:
host: str
user: str = "root"
port: int = 22
build_dir: str = "/tmp/airdep-build"
deploy_dir: str = "/opt/app"
@dataclass
class DeployResult:
task_id: str
success: bool
binary_md5: str = ""
service_status: str = ""
error: str = ""
journal_excerpt: str = ""
def _ssh(target: DeployTarget, cmd: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=accept-new", "-p", str(target.port),
f"{target.user}@{target.host}", cmd],
capture_output=True, text=True, timeout=timeout,
)
def _scp(target: DeployTarget, local: Path, remote: str, timeout: int = 300) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["scp", "-o", "StrictHostKeyChecking=accept-new", "-P", str(target.port),
str(local), f"{target.user}@{target.host}:{remote}"],
capture_output=True, text=True, timeout=timeout,
)
def _md5_file(path: Path) -> str:
h = hashlib.md5()
with open(path, "rb") as f:
while chunk := f.read(8192):
h.update(chunk)
return h.hexdigest()
def deploy(task_id: str, project_root: Path, target: DeployTarget,
local_binary: Path, service_name: str,
build_cmd: str | None = None,
smoke_test_cmd: str | None = None) -> DeployResult:
"""执行完整部署流程:构建 → 传输 → systemd → 验证。"""
state_file = dep_state_path(project_root)
try:
# Step 1: 远程构建(可选)
if build_cmd:
result = _ssh(target, f"cd {target.build_dir} && {build_cmd}", timeout=600)
if result.returncode != 0:
return DeployResult(task_id=task_id, success=False,
error=f"build failed: {result.stderr[-500:]}")
# Step 2: 二进制传输 + MD5 校验
remote_tmp = f"/tmp/{local_binary.name}"
scp_result = _scp(target, local_binary, remote_tmp)
if scp_result.returncode != 0:
return DeployResult(task_id=task_id, success=False,
error=f"scp failed: {scp_result.stderr[-500:]}")
local_md5 = _md5_file(local_binary)
md5_result = _ssh(target, f"md5sum {remote_tmp} | cut -d' ' -f1")
remote_md5 = md5_result.stdout.strip()
if local_md5 != remote_md5:
return DeployResult(task_id=task_id, success=False,
binary_md5=f"local={local_md5} remote={remote_md5}",
error="md5 mismatch after transfer")
# Step 3: 部署二进制
_ssh(target, f"mv {remote_tmp} {target.deploy_dir}/{local_binary.name}")
# Step 4: systemd 生命周期
_ssh(target, f"systemctl daemon-reload")
_ssh(target, f"systemctl restart {service_name}")
status_result = _ssh(target, f"systemctl is-active {service_name}")
service_status = status_result.stdout.strip()
# Step 5: 冒烟验证(可选)
smoke_passed: bool | None = None
if smoke_test_cmd:
smoke_result = _ssh(target, smoke_test_cmd, timeout=60)
smoke_passed = (smoke_result.returncode == 0)
# Step 6: 记录部署产物
record = DeploymentRecord(
task_id=task_id,
host=target.host,
binary_path=str(target.deploy_dir / local_binary.name),
md5=local_md5,
service_name=service_name,
service_status=service_status,
smoke_test_passed=smoke_passed,
)
# 持久化
sessions = safe_json_load(state_file) or {"sessions": []}
if isinstance(sessions, dict):
sessions.setdefault("sessions", []).append(record.__dict__)
atomic_json_write(state_file, sessions)
return DeployResult(
task_id=task_id,
success=service_status == "active",
binary_md5=local_md5,
service_status=service_status,
)
except subprocess.TimeoutExpired as exc:
return DeployResult(task_id=task_id, success=False, error=f"timeout: {exc}")
except Exception as exc:
return DeployResult(task_id=task_id, success=False, error=str(exc))