""" 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))