chore(repo): initialize team collaboration repository
CI / Python 3.12 (push) Waiting to run
CI / Python 3.9 (push) Waiting to run

This commit is contained in:
2026-07-27 20:40:12 +08:00
commit c91a64fddb
109 changed files with 21121 additions and 0 deletions
@@ -0,0 +1,3 @@
"""FlashOps pull-mode Host Agent."""
__version__ = "0.1.0"
@@ -0,0 +1,180 @@
"""Command-line entry point for the pull-mode Host Agent."""
from __future__ import annotations
import argparse
import os
import sys
import time
import uuid
from pathlib import Path
from typing import Optional, Tuple
from .client import AgentUnauthorized, ControlPlaneClient, ControlPlaneError
from .fingerprint import heartbeat_payload, registration_payload
from .state import AgentState, StateStore
def _execute_dry_run(lease: dict) -> dict:
"""Exercise the distributed worker path without invoking any host command."""
if lease.get("dry_run") is not True:
raise RuntimeError("Agent 拒绝执行未注册的真实适配器")
return {
"simulated": True,
"adapter": lease.get("adapter"),
"template": lease.get("template"),
"step_key": lease.get("step_key"),
}
def _process_one_lease(
client: ControlPlaneClient, state: AgentState
) -> Optional[str]:
lease = client.lease(state.agent_id, state.token, wait_timeout_s=0)
if lease is None:
return None
step_id = str(lease["step_id"])
attempt = int(lease["attempt"])
client.ack(
state.agent_id,
state.token,
step_id,
attempt,
str(uuid.uuid4()),
)
client.report_events(
state.agent_id,
state.token,
step_id,
attempt,
[{"message": "Agent dry-run 正在验证步骤协议", "severity": "INFO"}],
str(uuid.uuid4()),
)
result = _execute_dry_run(lease)
client.complete(
state.agent_id,
state.token,
step_id,
attempt,
status="SUCCEEDED",
exit_code=0,
result=result,
idempotency_key=str(uuid.uuid4()),
)
return str(lease.get("step_key") or step_id)
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="FlashOps Host Agent")
parser.add_argument(
"--server",
default=os.environ.get("FLASHOPS_AGENT_SERVER", "http://127.0.0.1:8000"),
)
parser.add_argument("--host-id", default=os.environ.get("FLASHOPS_AGENT_HOST_ID"))
parser.add_argument("--host-name", default=os.environ.get("FLASHOPS_AGENT_HOST_NAME"))
parser.add_argument(
"--state-path",
type=Path,
default=Path(
os.environ.get(
"FLASHOPS_AGENT_STATE_PATH",
str(Path.home() / ".flashops" / "agent-state.json"),
)
),
)
parser.add_argument(
"--enrollment-token",
default=os.environ.get("FLASHOPS_AGENT_ENROLLMENT_TOKEN"),
)
parser.add_argument(
"--basic-user",
default=os.environ.get("FLASHOPS_AGENT_BASIC_USER"),
)
parser.add_argument(
"--basic-password",
default=os.environ.get("FLASHOPS_AGENT_BASIC_PASSWORD"),
)
parser.add_argument("--interval", type=float, default=None)
parser.add_argument("--once", action="store_true")
return parser
def _basic_auth(user: Optional[str], password: Optional[str]) -> Optional[Tuple[str, str]]:
if bool(user) != bool(password):
raise ValueError("Basic Auth 用户名和密码必须同时提供")
return (user, password) if user and password else None
def _register(
client: ControlPlaneClient,
store: StateStore,
server: str,
host_id: Optional[str],
host_name: Optional[str],
) -> AgentState:
payload = client.register(
registration_payload(host_id=host_id, host_name=host_name)
)
state = AgentState.from_payload(server, payload)
store.save(state)
print("Agent 已注册: {0} → Host {1}".format(state.agent_id, state.host_id))
return state
def main() -> int:
args = _parser().parse_args()
server = args.server.rstrip("/")
store = StateStore(args.state_path)
try:
basic_auth = _basic_auth(args.basic_user, args.basic_password)
with ControlPlaneClient(
server,
enrollment_token=args.enrollment_token,
basic_auth=basic_auth,
) as client:
state = store.load()
if state is None or state.server != server:
state = _register(
client, store, server, args.host_id, args.host_name
)
while True:
try:
response = client.heartbeat(
state.agent_id,
state.token,
heartbeat_payload(),
)
except AgentUnauthorized:
state = _register(
client, store, server, args.host_id, args.host_name
)
response = client.heartbeat(
state.agent_id,
state.token,
heartbeat_payload(),
)
print(
"心跳已确认: {0} · commands={1}".format(
response.get("server_time"),
len(response.get("commands", [])),
)
)
completed_step = _process_one_lease(client, state)
if completed_step:
print("dry-run 步骤已完成: {0}".format(completed_step))
if args.once:
return 0
interval = args.interval or state.heartbeat_interval_s
time.sleep(max(0.5, interval))
except KeyboardInterrupt:
print("Agent 已停止")
return 0
except (ControlPlaneError, OSError, RuntimeError, ValueError) as exc:
print("Agent 错误: {0}".format(exc), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,247 @@
"""Small HTTP client for the control-plane Agent contract."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
import httpx
class ControlPlaneError(RuntimeError):
pass
class AgentUnauthorized(ControlPlaneError):
pass
class StepLeaseConflict(ControlPlaneError):
pass
class ControlPlaneClient:
def __init__(
self,
server: str,
*,
enrollment_token: Optional[str] = None,
basic_auth: Optional[Tuple[str, str]] = None,
timeout_s: float = 15.0,
transport: Optional[httpx.BaseTransport] = None,
) -> None:
self.server = server.rstrip("/")
self.enrollment_token = enrollment_token
self._client = httpx.Client(
base_url=self.server,
auth=httpx.BasicAuth(*basic_auth) if basic_auth else None,
timeout=timeout_s,
transport=transport,
)
def close(self) -> None:
self._client.close()
def __enter__(self) -> "ControlPlaneClient":
return self
def __exit__(self, *_: object) -> None:
self.close()
@staticmethod
def _payload(response: httpx.Response) -> Dict[str, Any]:
try:
value = response.json()
except ValueError as exc:
raise ControlPlaneError(
"控制平面返回了非 JSON 响应: HTTP {0}".format(response.status_code)
) from exc
if not isinstance(value, dict):
raise ControlPlaneError("控制平面响应不是 JSON object")
return value
def register(self, fingerprint: Dict[str, Any]) -> Dict[str, Any]:
headers = {}
if self.enrollment_token:
headers["X-FlashOps-Enrollment-Token"] = self.enrollment_token
response = self._client.post(
"/api/v1/agent/register",
json=fingerprint,
headers=headers,
)
if response.status_code == 401:
raise AgentUnauthorized("Agent 注册口令无效")
if response.status_code >= 400:
raise ControlPlaneError(
"Agent 注册失败: HTTP {0} {1}".format(
response.status_code, response.text[:200]
)
)
payload = self._payload(response)
for field in ("agent_id", "host_id", "token"):
if not payload.get(field):
raise ControlPlaneError("注册响应缺少字段: {0}".format(field))
return payload
def heartbeat(
self,
agent_id: str,
token: str,
heartbeat: Dict[str, Any],
) -> Dict[str, Any]:
response = self._client.post(
"/api/v1/agent/{0}/heartbeat".format(agent_id),
json=heartbeat,
headers={"Authorization": "Bearer " + token},
)
if response.status_code == 401:
raise AgentUnauthorized("Agent token 已失效")
if response.status_code >= 400:
raise ControlPlaneError(
"Agent 心跳失败: HTTP {0} {1}".format(
response.status_code, response.text[:200]
)
)
payload = self._payload(response)
if payload.get("ok") is not True:
raise ControlPlaneError("控制平面未确认心跳")
return payload
@staticmethod
def _agent_headers(token: str, idempotency_key: Optional[str] = None) -> Dict[str, str]:
headers = {"Authorization": "Bearer " + token}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
return headers
def lease(
self,
agent_id: str,
token: str,
*,
wait_timeout_s: float = 0.0,
) -> Optional[Dict[str, Any]]:
response = self._client.post(
"/api/v1/agent/{0}/lease".format(agent_id),
json={"wait_timeout_s": wait_timeout_s},
headers=self._agent_headers(token),
)
if response.status_code == 204:
return None
return self._step_response(response, "领取步骤")
def _step_response(self, response: httpx.Response, action: str) -> Dict[str, Any]:
if response.status_code == 401:
raise AgentUnauthorized("Agent token 已失效")
if response.status_code == 409:
detail = response.text[:300]
try:
detail = str(response.json().get("detail", detail))
except (ValueError, AttributeError):
pass
raise StepLeaseConflict("{0}冲突: {1}".format(action, detail))
if response.status_code >= 400:
raise ControlPlaneError(
"{0}失败: HTTP {1} {2}".format(
action, response.status_code, response.text[:200]
)
)
return self._payload(response)
def _step_post(
self,
agent_id: str,
token: str,
step_id: str,
action: str,
payload: Dict[str, Any],
idempotency_key: str,
) -> Dict[str, Any]:
response = self._client.post(
"/api/v1/agent/{0}/steps/{1}/{2}".format(
agent_id, step_id, action
),
json=payload,
headers=self._agent_headers(token, idempotency_key),
)
return self._step_response(response, action)
def ack(
self,
agent_id: str,
token: str,
step_id: str,
attempt: int,
idempotency_key: str,
) -> Dict[str, Any]:
return self._step_post(
agent_id,
token,
step_id,
"ack",
{"attempt": attempt},
idempotency_key,
)
def renew(
self,
agent_id: str,
token: str,
step_id: str,
attempt: int,
idempotency_key: str,
) -> Dict[str, Any]:
return self._step_post(
agent_id,
token,
step_id,
"renew",
{"attempt": attempt},
idempotency_key,
)
def report_events(
self,
agent_id: str,
token: str,
step_id: str,
attempt: int,
events: List[Dict[str, Any]],
idempotency_key: str,
) -> Dict[str, Any]:
return self._step_post(
agent_id,
token,
step_id,
"events",
{"attempt": attempt, "events": events},
idempotency_key,
)
def complete(
self,
agent_id: str,
token: str,
step_id: str,
attempt: int,
*,
status: str,
idempotency_key: str,
exit_code: Optional[int] = None,
error_class: Optional[str] = None,
result: Optional[Dict[str, Any]] = None,
log_uri: Optional[str] = None,
) -> Dict[str, Any]:
return self._step_post(
agent_id,
token,
step_id,
"complete",
{
"attempt": attempt,
"status": status,
"exit_code": exit_code,
"error_class": error_class,
"result": result or {},
"log_uri": log_uri,
},
idempotency_key,
)
@@ -0,0 +1,97 @@
"""Read-only host discovery used during registration and heartbeat."""
from __future__ import annotations
import os
import platform
import shutil
import socket
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional
from . import __version__
def _ip_address() -> Optional[str]:
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect(("192.0.2.1", 9))
return str(sock.getsockname()[0])
except OSError:
return None
def _tool_version(command: str, args: List[str]) -> Optional[str]:
executable = shutil.which(command)
if not executable:
return None
try:
result = subprocess.run(
[executable] + args,
check=False,
capture_output=True,
text=True,
timeout=3,
)
except (OSError, subprocess.TimeoutExpired):
return None
output = (result.stdout or result.stderr).strip().splitlines()
return output[0][:128] if output else None
def discover_toolchain() -> Dict[str, str]:
probes = {
"fio": ("fio", ["--version"]),
"nvme-cli": ("nvme", ["version"]),
"smartctl": ("smartctl", ["--version"]),
}
versions: Dict[str, str] = {}
for name, (command, args) in probes.items():
version = _tool_version(command, args)
if version:
versions[name] = version
return versions
def system_device_paths() -> List[str]:
if platform.system().lower() != "linux":
return []
try:
for line in Path("/proc/mounts").read_text(encoding="utf-8").splitlines():
fields = line.split()
if len(fields) >= 2 and fields[1] == "/" and fields[0].startswith("/dev/"):
return [os.path.realpath(fields[0])]
except OSError:
return []
return []
def registration_payload(
*,
host_id: Optional[str] = None,
host_name: Optional[str] = None,
) -> Dict[str, Any]:
return {
"host_id": host_id,
"host_name": host_name or platform.node() or socket.gethostname(),
"agent_version": __version__,
"os_family": platform.system().lower() or "unknown",
"os_version": platform.platform(),
"kernel": platform.release(),
"cpu": platform.processor() or None,
"motherboard": None,
"bios_version": None,
"ip_address": _ip_address(),
"toolchain": discover_toolchain(),
"labels": {"system_device_paths": system_device_paths()},
}
def heartbeat_payload() -> Dict[str, Any]:
return {
"healthy": True,
"agent_version": __version__,
"ip_address": _ip_address(),
"toolchain": discover_toolchain(),
"device_probe": {"system_device_paths": system_device_paths()},
}
@@ -0,0 +1,65 @@
"""Agent credential persistence with restrictive local permissions."""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Dict, Optional
@dataclass(frozen=True)
class AgentState:
server: str
agent_id: str
host_id: str
token: str
heartbeat_interval_s: float = 5.0
@classmethod
def from_payload(
cls,
server: str,
payload: Dict[str, Any],
) -> "AgentState":
return cls(
server=server.rstrip("/"),
agent_id=str(payload["agent_id"]),
host_id=str(payload["host_id"]),
token=str(payload["token"]),
heartbeat_interval_s=float(payload.get("heartbeat_interval_s", 5.0)),
)
class StateStore:
def __init__(self, path: Path) -> None:
self.path = path
def load(self) -> Optional[AgentState]:
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
return AgentState(**raw)
except FileNotFoundError:
return None
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise RuntimeError("Agent state 文件无效: {0}".format(self.path)) from exc
def save(self, state: AgentState) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
descriptor = os.open(
str(temporary),
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
0o600,
)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(asdict(state), handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, self.path)
finally:
if temporary.exists():
temporary.unlink()