- lock.py: 跨平台进程锁(Unix fcntl / Windows msvcrt / O_CREAT|O_EXCL降级)
- eng_mode.py/eng_orchestrator.py: hasattr(os, "getloadavg") Windows防护
- arc_mode.py: 路径分隔符 replace("\\", "/") Windows兼容
- deploy_runtime.py: 修复语法错误(清理 import tempfile 残留)
- P1-24(3.2.18): AMBIGUOUS_VERBS歧义词检测 + SAFE_VERBS安全动词 + validate_task_description()
- TaskNode.keep_constraints 保留约束字段 + JSON序列化
- _build_graph_from_todo 返回歧义警告 + Arc自检集成
- 3.2.9b: FORBIDDEN_DEGRADATION_PATTERNS + check_forbidden_degradation()
- AirRvr三层审查放行标准: ReviewVerdict + evaluate_review_pass() + is_forbidden_pass_reason()
- commands/arc.md: 弱模型安全重写(操作类型拆分+保留约束+自检)
- commands/do.md/eng.md/rvr.md: 禁止降级方案 + 三层审查标准
- 测试: 7个新测试 + 74全量通过
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
135 lines
4.7 KiB
Python
Executable File
135 lines
4.7 KiB
Python
Executable File
"""
|
|
AirDep 部署运行时 — V2 新增组件。
|
|
SSH 远程构建 + 二进制传输 + systemd 生命周期管理 + 部署验证。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import subprocess
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
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 = tempfile.gettempdir() + "/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))
|