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
+56
View File
@@ -0,0 +1,56 @@
# FlashOps Host Agent
The Host Agent is a pull-mode process that runs on a test host. The first
implemented slice supports:
- read-only host fingerprint discovery;
- enrollment into a Test Host record;
- one-time bearer token issuance;
- local credential persistence with mode `0600`;
- authenticated heartbeats and Host status updates;
- server-side heartbeat aging to `DEGRADED` after 15s and `OFFLINE` after 30s;
- optional gateway Basic Auth for deployments that enable an authenticated proxy.
Step leases, command templates, event uploads, artifacts, and completion
reporting remain the next slice.
## Local run
Start the control plane in one terminal:
```bash
make dev
```
Register and send one heartbeat in another:
```bash
make dev-agent
```
Run continuously:
```bash
make dev-agent-loop
```
By default the Agent state is stored at `~/.flashops/agent-state.json`. It
contains the raw bearer token and is created with mode `0600`. The control
plane stores only its SHA-256 digest.
## Configuration
| Variable | Purpose |
|---|---|
| `FLASHOPS_AGENT_SERVER` | Control plane base URL |
| `FLASHOPS_AGENT_HOST_ID` | Bind to an existing Test Host ID |
| `FLASHOPS_AGENT_HOST_NAME` | Bind to or create a Test Host by name |
| `FLASHOPS_AGENT_STATE_PATH` | Local credential state path |
| `FLASHOPS_AGENT_ENROLLMENT_TOKEN` | First-registration secret |
| `FLASHOPS_AGENT_BASIC_USER` | Optional authenticated-proxy user |
| `FLASHOPS_AGENT_BASIC_PASSWORD` | Optional authenticated-proxy password |
Production registration is denied when the control plane does not have
`FLASHOPS_AGENT_ENROLLMENT_TOKEN` configured. Store that value in
`/etc/flashops/flashops.env` with root-only permissions; never put it in the
systemd unit or repository.
@@ -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()
@@ -0,0 +1,141 @@
from pathlib import Path
import stat
import uuid
import httpx
import pytest
from flashops_agent.client import AgentUnauthorized, ControlPlaneClient
from flashops_agent.state import AgentState, StateStore
def test_control_plane_client_registers_and_sends_heartbeat():
requests = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.url.path == "/api/v1/agent/register":
return httpx.Response(
201,
json={
"agent_id": "agent_test",
"host_id": "host_test",
"token": "raw-token",
"heartbeat_interval_s": 5,
},
)
if request.url.path == "/api/v1/agent/agent_test/heartbeat":
return httpx.Response(
200,
json={
"ok": True,
"server_time": "2026-07-27T00:00:00Z",
"commands": [],
},
)
return httpx.Response(404)
with ControlPlaneClient(
"http://control.test",
enrollment_token="enroll-test",
transport=httpx.MockTransport(handler),
) as client:
registered = client.register(
{
"host_name": "pytest-host",
"agent_version": "0.1.0",
"os_family": "linux",
}
)
heartbeat = client.heartbeat(
registered["agent_id"],
registered["token"],
{"healthy": True},
)
assert heartbeat["ok"] is True
assert requests[0].headers["x-flashops-enrollment-token"] == "enroll-test"
assert requests[1].headers["authorization"] == "Bearer raw-token"
def test_control_plane_client_rejects_unauthorized_heartbeat():
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"detail": "invalid"})
with ControlPlaneClient(
"http://control.test",
transport=httpx.MockTransport(handler),
) as client:
with pytest.raises(AgentUnauthorized):
client.heartbeat("agent_test", "bad-token", {"healthy": True})
def test_state_store_round_trip_uses_private_permissions(tmp_path: Path):
path = tmp_path / "agent-state.json"
store = StateStore(path)
expected = AgentState(
server="http://control.test",
agent_id="agent_test",
host_id="host_test",
token="raw-token",
heartbeat_interval_s=5.0,
)
store.save(expected)
assert store.load() == expected
assert stat.S_IMODE(path.stat().st_mode) == 0o600
def test_control_plane_client_runs_step_lease_protocol():
requests = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
path = request.url.path
if path.endswith("/lease"):
return httpx.Response(
200,
json={"step_id": "step_test", "attempt": 1, "dry_run": True},
)
if path.endswith("/ack"):
return httpx.Response(200, json={"step_id": "step_test", "state": "RUNNING"})
if path.endswith("/renew"):
return httpx.Response(200, json={"step_id": "step_test", "state": "RUNNING"})
if path.endswith("/events"):
return httpx.Response(200, json={"step_id": "step_test", "accepted": 1})
if path.endswith("/complete"):
return httpx.Response(200, json={"step_id": "step_test", "state": "SUCCEEDED"})
return httpx.Response(404)
token = "raw-token"
keys = [str(uuid.uuid4()) for _ in range(4)]
with ControlPlaneClient(
"http://control.test", transport=httpx.MockTransport(handler)
) as client:
lease = client.lease("agent_test", token)
assert lease is not None
client.ack("agent_test", token, "step_test", 1, keys[0])
client.renew("agent_test", token, "step_test", 1, keys[1])
client.report_events(
"agent_test",
token,
"step_test",
1,
[{"message": "hello"}],
keys[2],
)
client.complete(
"agent_test",
token,
"step_test",
1,
status="SUCCEEDED",
exit_code=0,
result={"simulated": True},
idempotency_key=keys[3],
)
assert len(requests) == 5
assert requests[0].headers["authorization"] == "Bearer raw-token"
assert requests[1].headers["idempotency-key"] == keys[0]