chore(repo): initialize team collaboration repository
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user