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,5 @@
"""FlashOps 控制平面。"""
from .config import APP_NAME
__all__ = ["APP_NAME"]
@@ -0,0 +1 @@
"""FastAPI 路由。"""
@@ -0,0 +1,276 @@
"""拉模式 Host Agent 的注册、认证与心跳 API。"""
from __future__ import annotations
import asyncio
import hmac
import secrets
import time
import uuid
from typing import Dict, List, Optional
from fastapi import APIRouter, Header, HTTPException, Response, status
from sqlalchemy import select
from ..config import get_settings
from ..db import session_scope
from ..enums import HostStatus
from ..models import TestHost, iso_z, utcnow
from ..schemas import (
AgentHeartbeatBody,
AgentLeaseBody,
AgentRegisterBody,
AgentStepAttemptBody,
AgentStepCompleteBody,
AgentStepEventsBody,
)
from ..services.agent_status import refresh_agent_host
from ..services.step_leases import (
StepLeaseConflict,
acknowledge_step,
complete_step,
record_step_events,
renew_step,
try_lease_next_step,
)
from .deps import agent_token_digest, require_agent
router = APIRouter(prefix="/api/v1/agent", tags=["host-agent"])
def _idempotency_key(value: str) -> str:
if len(value) > 60:
raise HTTPException(status_code=422, detail="Idempotency-Key 过长")
try:
uuid.UUID(value)
except (ValueError, AttributeError) as exc:
raise HTTPException(
status_code=422, detail="Idempotency-Key 必须是 UUID"
) from exc
return value
def _lease_conflict(exc: StepLeaseConflict) -> HTTPException:
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
def _check_enrollment_token(provided: Optional[str]) -> None:
settings = get_settings()
required = settings.agent_enrollment_token
if settings.env == "production" and not required:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="生产环境尚未配置 Agent 注册口令",
)
if required and not hmac.compare_digest(provided or "", required):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Agent 注册口令无效",
)
async def _resolve_host(body: AgentRegisterBody) -> Dict[str, object]:
async with session_scope() as session:
host: Optional[TestHost] = None
if body.host_id:
host = await session.get(TestHost, body.host_id)
if host is None:
raise HTTPException(status_code=404, detail="Test Host 不存在")
elif body.host_name:
host = await session.scalar(
select(TestHost).where(TestHost.name == body.host_name)
)
else:
host = await session.scalar(select(TestHost).order_by(TestHost.created_at))
if host is None:
host = TestHost(name=body.host_name or "agent-host-" + uuid.uuid4().hex[:8])
session.add(host)
await session.flush()
raw_token = secrets.token_urlsafe(32)
host.agent_id = host.agent_id or "agent_" + uuid.uuid4().hex[:20]
host.agent_token = agent_token_digest(raw_token)
host.agent_version = body.agent_version
host.os_family = body.os_family
if body.os_version is not None:
host.os_version = body.os_version
if body.kernel is not None:
host.kernel = body.kernel
if body.cpu is not None:
host.cpu = body.cpu
if body.motherboard is not None:
host.motherboard = body.motherboard
if body.bios_version is not None:
host.bios_version = body.bios_version
if body.ip_address is not None:
host.ip_address = body.ip_address
if body.toolchain:
host.toolchain = body.toolchain
if body.labels:
host.labels = {**(host.labels or {}), **body.labels}
host.status = HostStatus.ONLINE.value
host.last_heartbeat_at = utcnow()
payload = {
"agent_id": host.agent_id,
"host_id": host.id,
"token": raw_token,
"heartbeat_interval_s": get_settings().timing.heartbeat_interval_s,
"server_time": iso_z(host.last_heartbeat_at),
}
return payload
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register_agent(
body: AgentRegisterBody,
enrollment_token: Optional[str] = Header(
default=None, alias="X-FlashOps-Enrollment-Token"
),
) -> Dict[str, object]:
_check_enrollment_token(enrollment_token)
return await _resolve_host(body)
@router.post("/{agent_id}/heartbeat")
async def heartbeat(
agent_id: str,
body: AgentHeartbeatBody,
authorization: Optional[str] = Header(default=None),
) -> Dict[str, object]:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
host.status = (
HostStatus.ONLINE.value if body.healthy else HostStatus.DEGRADED.value
)
host.last_heartbeat_at = utcnow()
if body.agent_version:
host.agent_version = body.agent_version
if body.ip_address:
host.ip_address = body.ip_address
if body.toolchain:
host.toolchain = body.toolchain
if body.device_probe:
host.labels = {
**(host.labels or {}),
"agent_probe": body.device_probe,
}
return {
"ok": True,
"server_time": iso_z(host.last_heartbeat_at),
"heartbeat_interval_s": get_settings().timing.heartbeat_interval_s,
"commands": [],
}
@router.post("/{agent_id}/lease")
async def lease_step(
agent_id: str,
body: AgentLeaseBody,
authorization: Optional[str] = Header(default=None),
) -> object:
deadline = time.monotonic() + min(
body.wait_timeout_s, get_settings().timing.lease_poll_timeout_s
)
while True:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
lease = await try_lease_next_step(session, host)
if lease is not None:
return lease
remaining = deadline - time.monotonic()
if remaining <= 0:
return Response(status_code=status.HTTP_204_NO_CONTENT)
await asyncio.sleep(min(get_settings().engine_tick_s, remaining))
@router.post("/{agent_id}/steps/{step_id}/ack")
async def ack_step(
agent_id: str,
step_id: str,
body: AgentStepAttemptBody,
authorization: Optional[str] = Header(default=None),
idempotency_key: str = Header(alias="Idempotency-Key"),
) -> Dict[str, object]:
key = _idempotency_key(idempotency_key)
try:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
return await acknowledge_step(session, host, step_id, body.attempt, key)
except StepLeaseConflict as exc:
raise _lease_conflict(exc) from exc
@router.post("/{agent_id}/steps/{step_id}/renew")
async def renew_step_lease(
agent_id: str,
step_id: str,
body: AgentStepAttemptBody,
authorization: Optional[str] = Header(default=None),
idempotency_key: str = Header(alias="Idempotency-Key"),
) -> Dict[str, object]:
key = _idempotency_key(idempotency_key)
try:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
return await renew_step(session, host, step_id, body.attempt, key)
except StepLeaseConflict as exc:
raise _lease_conflict(exc) from exc
@router.post("/{agent_id}/steps/{step_id}/events")
async def step_events(
agent_id: str,
step_id: str,
body: AgentStepEventsBody,
authorization: Optional[str] = Header(default=None),
idempotency_key: str = Header(alias="Idempotency-Key"),
) -> Dict[str, object]:
key = _idempotency_key(idempotency_key)
try:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
return await record_step_events(
session, host, step_id, body.attempt, body.events, key
)
except StepLeaseConflict as exc:
raise _lease_conflict(exc) from exc
@router.post("/{agent_id}/steps/{step_id}/complete")
async def finish_step(
agent_id: str,
step_id: str,
body: AgentStepCompleteBody,
authorization: Optional[str] = Header(default=None),
idempotency_key: str = Header(alias="Idempotency-Key"),
) -> Dict[str, object]:
key = _idempotency_key(idempotency_key)
try:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
return await complete_step(session, host, step_id, body, key)
except StepLeaseConflict as exc:
raise _lease_conflict(exc) from exc
@router.get("/hosts")
async def list_agent_hosts() -> List[Dict[str, object]]:
async with session_scope() as session:
hosts = (
await session.scalars(select(TestHost).order_by(TestHost.name))
).all()
for host in hosts:
refresh_agent_host(host)
return [
{
"host_id": host.id,
"host_name": host.name,
"agent_id": host.agent_id,
"agent_version": host.agent_version,
"status": host.status,
"last_heartbeat_at": iso_z(host.last_heartbeat_at),
"ip_address": host.ip_address,
"toolchain": host.toolchain,
}
for host in hosts
]
@@ -0,0 +1,47 @@
"""HTTP dependencies shared by Agent endpoints."""
from __future__ import annotations
import hashlib
import hmac
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import TestHost
def agent_token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def bearer_token(authorization: Optional[str]) -> str:
scheme, separator, value = (authorization or "").partition(" ")
if not separator or scheme.lower() != "bearer" or not value.strip():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Agent bearer token 缺失",
headers={"WWW-Authenticate": "Bearer"},
)
return value.strip()
async def require_agent(
session: AsyncSession,
agent_id: str,
authorization: Optional[str],
) -> TestHost:
host = await session.scalar(select(TestHost).where(TestHost.agent_id == agent_id))
token = bearer_token(authorization)
if (
host is None
or not host.agent_token
or not hmac.compare_digest(host.agent_token, agent_token_digest(token))
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Agent 身份无效",
headers={"WWW-Authenticate": "Bearer"},
)
return host
@@ -0,0 +1,258 @@
"""控制台所需的最小纵向 API。"""
from __future__ import annotations
from typing import Dict, List
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import func, select
from ..db import session_scope
from ..enums import EventKind, EventSource, RunState, Severity
from ..events import append_event
from ..models import EvidenceBundle, Run, RunEvent, ResourceLock, utcnow
from ..schemas import CreateRunBody, HumanActionBody, PreflightBody
from ..services.runs import (
cancel_simulation,
create_run,
event_to_dict,
prepare_agent_run,
preflight_for_body,
run_to_dict,
start_simulation,
)
router = APIRouter(prefix="/api/v1")
@router.get("/health")
async def health() -> Dict[str, str]:
return {"status": "ok", "service": "flashops-control-plane"}
@router.post("/preflight")
async def preflight(body: PreflightBody) -> Dict[str, object]:
try:
async with session_scope() as session:
return await preflight_for_body(session, body)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/runs", status_code=status.HTTP_201_CREATED)
async def create_run_route(body: CreateRunBody) -> Dict[str, object]:
try:
async with session_scope() as session:
run = await create_run(session, body)
if body.execution_mode == "agent_dry_run":
await prepare_agent_run(session, run)
payload = run_to_dict(run)
if body.execution_mode == "simulated":
start_simulation(run.id)
return payload
except LookupError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/runs/demo", status_code=status.HTTP_201_CREATED)
async def create_demo_run() -> Dict[str, object]:
return await create_run_route(CreateRunBody())
@router.get("/runs")
async def list_runs(limit: int = 30) -> List[Dict[str, object]]:
async with session_scope() as session:
runs = (
await session.scalars(
select(Run).order_by(Run.created_at.desc()).limit(min(max(limit, 1), 100))
)
).all()
return [run_to_dict(run) for run in runs]
@router.get("/runs/{run_id}")
async def get_run(run_id: str) -> Dict[str, object]:
async with session_scope() as session:
run = await session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run 不存在")
payload = run_to_dict(run)
bundle = await session.scalar(
select(EvidenceBundle)
.where(EvidenceBundle.run_id == run_id)
.order_by(EvidenceBundle.created_at.desc())
)
payload["evidence"] = (
{
"id": bundle.id,
"uri": bundle.uri,
"completeness": bundle.completeness,
"size_bytes": bundle.size_bytes,
}
if bundle
else None
)
return payload
@router.get("/runs/{run_id}/events")
async def get_events(run_id: str, after_seq: int = 0) -> List[Dict[str, object]]:
async with session_scope() as session:
exists = await session.get(Run, run_id)
if exists is None:
raise HTTPException(status_code=404, detail="Run 不存在")
events = (
await session.scalars(
select(RunEvent)
.where(RunEvent.run_id == run_id, RunEvent.seq > after_seq)
.order_by(RunEvent.seq)
)
).all()
return [event_to_dict(event) for event in events]
@router.post("/runs/{run_id}/pause")
async def pause_run(run_id: str, body: HumanActionBody) -> Dict[str, object]:
async with session_scope() as session:
run = await session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run 不存在")
if run.state != RunState.RUNNING.value:
raise HTTPException(status_code=409, detail="只有 RUNNING 任务可以暂停")
await append_event(
session,
run,
EventKind.HUMAN_TOUCH.value,
"{0}: {1}".format(body.actor, body.reason),
source=EventSource.HUMAN.value,
projection={
"human_touches": run.human_touches + 1,
"unattended_completion": False,
},
)
await append_event(
session,
run,
EventKind.RUN_PAUSED.value,
"任务已在安全检查点暂停",
source=EventSource.HUMAN.value,
target_state=RunState.PAUSED.value,
)
return run_to_dict(run)
@router.post("/runs/{run_id}/resume")
async def resume_run(run_id: str, body: HumanActionBody) -> Dict[str, object]:
async with session_scope() as session:
run = await session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run 不存在")
if run.state != RunState.PAUSED.value:
raise HTTPException(status_code=409, detail="只有 PAUSED 任务可以继续")
await append_event(
session,
run,
EventKind.HUMAN_TOUCH.value,
"{0}: {1}".format(body.actor, body.reason),
source=EventSource.HUMAN.value,
projection={
"human_touches": run.human_touches + 1,
"unattended_completion": False,
},
)
await append_event(
session,
run,
EventKind.RUN_RESUMED.value,
"人工确认后从检查点继续",
source=EventSource.HUMAN.value,
target_state=RunState.RUNNING.value,
)
return run_to_dict(run)
@router.post("/runs/{run_id}/emergency-stop")
async def emergency_stop(run_id: str, body: HumanActionBody) -> Dict[str, object]:
async with session_scope() as session:
run = await session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run 不存在")
if run.state in {
RunState.COMPLETED.value,
RunState.ABORTED.value,
RunState.FROZEN.value,
RunState.REJECTED.value,
}:
raise HTTPException(status_code=409, detail="终态任务不能再次停止")
await append_event(
session,
run,
EventKind.HUMAN_TOUCH.value,
"{0}: {1}".format(body.actor, body.reason),
source=EventSource.HUMAN.value,
severity=Severity.CRITICAL.value,
projection={
"human_touches": run.human_touches + 1,
"unattended_completion": False,
},
)
await append_event(
session,
run,
EventKind.RUN_ABORTED.value,
"紧急停止已执行,现场与检查点已保留",
source=EventSource.HUMAN.value,
severity=Severity.CRITICAL.value,
target_state=RunState.ABORTED.value,
projection={"ended_at": utcnow()},
)
await session.execute(
ResourceLock.__table__.delete().where(ResourceLock.run_id == run.id)
)
payload = run_to_dict(run)
cancel_simulation(run_id)
return payload
@router.get("/dashboard/summary")
async def dashboard_summary() -> Dict[str, object]:
async with session_scope() as session:
total = await session.scalar(select(func.count()).select_from(Run)) or 0
running = (
await session.scalar(
select(func.count()).select_from(Run).where(
Run.state.in_(
[
RunState.QUEUED.value,
RunState.PREFLIGHT.value,
RunState.RUNNING.value,
RunState.RECOVERING.value,
]
)
)
)
or 0
)
completed = (
await session.scalar(
select(func.count()).select_from(Run).where(
Run.state == RunState.COMPLETED.value
)
)
or 0
)
unattended = (
await session.scalar(
select(func.count()).select_from(Run).where(
Run.state == RunState.COMPLETED.value,
Run.unattended_completion.is_(True),
)
)
or 0
)
ucr = round(unattended / completed * 100, 1) if completed else 0.0
return {
"total_runs": total,
"active_runs": running,
"completed_runs": completed,
"unattended_completion_rate": ucr,
}
@@ -0,0 +1,74 @@
"""开发与演示命令行。"""
from __future__ import annotations
import argparse
import asyncio
from sqlalchemy import select
from .db import drop_db, init_db, session_scope
from .models import EvidenceBundle, Run
from .schemas import CreateRunBody
from .seed import ensure_seed_data
from .services.runs import create_run, run_to_dict, simulate_run
async def _init_db() -> None:
await init_db()
async def _seed() -> None:
await init_db()
async with session_scope() as session:
await ensure_seed_data(session)
async def _reset() -> None:
await drop_db()
await init_db()
await _seed()
async def _demo() -> None:
await _seed()
async with session_scope() as session:
run = await create_run(
session,
CreateRunBody(loops=8, inject_failure=True, created_by="cli-demo"),
)
run_id = run.id
print("Run {0} 已启动:将注入一次心跳丢失并自动升级到 L3 恢复。".format(run_id))
await simulate_run(run_id)
async with session_scope() as session:
run = await session.get(Run, run_id)
bundle = await session.scalar(
select(EvidenceBundle)
.where(EvidenceBundle.run_id == run_id)
.order_by(EvidenceBundle.created_at.desc())
)
assert run is not None
payload = run_to_dict(run)
print(
"完成:{state} / {verdict},循环 {loop_done}/{loop_target},恢复 {recovery_count} 次。".format(
**payload
)
)
if bundle:
print("Evidence Bundle: {0}".format(bundle.uri))
def main() -> None:
parser = argparse.ArgumentParser(prog="flashops")
parser.add_argument("command", choices=["init-db", "seed", "reset-db", "demo"])
args = parser.parse_args()
commands = {
"init-db": _init_db,
"seed": _seed,
"reset-db": _reset,
"demo": _demo,
}
asyncio.run(commands[args.command]())
if __name__ == "__main__":
main()
@@ -0,0 +1,107 @@
"""配置。
产品名只出现在这里和 apps/console/lib/brand.ts —— 改名成本 = 改两个常量。
商标检索未完成前,任何地方都不要再硬编码产品名。
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
APP_NAME = "FlashOps"
APP_TAGLINE = "存储实验室无人值守执行层"
# 仓库根目录:.../flashops
REPO_ROOT = Path(__file__).resolve().parents[3]
def _env(key: str, default: str) -> str:
return os.environ.get(f"FLASHOPS_{key}", default)
def _env_int(key: str, default: int) -> int:
return int(os.environ.get(f"FLASHOPS_{key}", str(default)))
def _env_float(key: str, default: float) -> float:
return float(os.environ.get(f"FLASHOPS_{key}", str(default)))
@dataclass(frozen=True)
class TimingPolicy:
"""时间策略。
全部集中在这里,且引擎不直接读它 —— 引擎从 Snapshot 拿数值。
这样"30 秒心跳超时"这类规则可以在测试里用 0.03 秒验证。
"""
heartbeat_interval_s: float = 5.0
heartbeat_degraded_after_s: float = 15.0
heartbeat_lost_after_s: float = 30.0
lease_ttl_s: float = 60.0
lease_poll_timeout_s: float = 20.0
step_default_timeout_s: float = 600.0
recovery_settle_s: float = 20.0 # 恢复动作后等待主机回来的时间
ac_cycle_off_s: float = 10.0 # AC 断电后的安全间隔(方案 §7.2)
max_recovery_per_loop: int = 3
max_recovery_total: int = 20
@dataclass(frozen=True)
class Settings:
app_name: str = APP_NAME
env: str = field(default_factory=lambda: _env("ENV", "development"))
# 开发态:SQLite。生产:postgresql+asyncpg://...
database_url: str = field(
default_factory=lambda: _env(
"DATABASE_URL", "sqlite+aiosqlite:///" + str(REPO_ROOT / "var" / "flashops.db")
)
)
# 开发态:本地目录。生产:s3://minio:9000/flashops
object_store_url: str = field(
default_factory=lambda: _env("OBJECT_STORE_URL", str(REPO_ROOT / "var" / "objects"))
)
# 开发态:进程内 asyncio。生产:redis://... / nats://...
bus_url: Optional[str] = field(default_factory=lambda: os.environ.get("FLASHOPS_BUS_URL"))
# Agent 首次注册口令。开发态未配置时允许本机演示;生产态缺失时拒绝注册。
agent_enrollment_token: Optional[str] = field(
default_factory=lambda: os.environ.get("FLASHOPS_AGENT_ENROLLMENT_TOKEN")
)
api_host: str = field(default_factory=lambda: _env("API_HOST", "0.0.0.0"))
api_port: int = field(default_factory=lambda: _env_int("API_PORT", 8000))
cors_origins: str = field(default_factory=lambda: _env("CORS_ORIGINS", "http://localhost:3000"))
# 引擎主循环的 tick 间隔。demo/测试里调小以加速。
engine_tick_s: float = field(default_factory=lambda: _env_float("ENGINE_TICK_S", 0.5))
# 演示/仿真加速倍率:1.0 = 真实速度
time_scale: float = field(default_factory=lambda: _env_float("TIME_SCALE", 1.0))
timing: TimingPolicy = field(default_factory=TimingPolicy)
@property
def is_sqlite(self) -> bool:
return self.database_url.startswith("sqlite")
@property
def object_store_path(self) -> Path:
return Path(self.object_store_url)
_settings: Optional[Settings] = None
def get_settings() -> Settings:
global _settings
if _settings is None:
_settings = Settings()
return _settings
def reset_settings() -> None:
"""测试用:让下一次 get_settings() 重新读环境变量。"""
global _settings
_settings = None
@@ -0,0 +1,106 @@
"""异步数据库连接与会话。
连接按当前配置惰性创建,测试可以安全地切换到临时 SQLite 数据库。
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from typing import AsyncIterator, Optional
from weakref import WeakKeyDictionary
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from .config import get_settings
from .models import Base
_engine: Optional[AsyncEngine] = None
_engine_url: Optional[str] = None
_session_factory: Optional[async_sessionmaker[AsyncSession]] = None
_sqlite_session_locks: WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = (
WeakKeyDictionary()
)
def _sqlite_session_lock() -> asyncio.Lock:
"""Return one transaction lock per event loop for the SQLite prototype.
SQLite permits only one writer at a time. Serializing the complete
session_scope transaction prevents the simulator and human control routes
from reading the same event sequence and then racing on commit. PostgreSQL
deployments skip this application-level lock.
"""
loop = asyncio.get_running_loop()
lock = _sqlite_session_locks.get(loop)
if lock is None:
lock = asyncio.Lock()
_sqlite_session_locks[loop] = lock
return lock
def get_engine() -> AsyncEngine:
global _engine, _engine_url, _session_factory
url = get_settings().database_url
if _engine is None or _engine_url != url:
_engine = create_async_engine(url, future=True)
_engine_url = url
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
return _engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
get_engine()
assert _session_factory is not None
return _session_factory
@asynccontextmanager
async def _transaction_scope() -> AsyncIterator[AsyncSession]:
session = get_session_factory()()
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
@asynccontextmanager
async def session_scope() -> AsyncIterator[AsyncSession]:
if get_settings().is_sqlite:
async with _sqlite_session_lock():
async with _transaction_scope() as session:
yield session
return
async with _transaction_scope() as session:
yield session
async def init_db() -> None:
engine = get_engine()
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async def drop_db() -> None:
engine = get_engine()
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.drop_all)
async def dispose_db() -> None:
global _engine, _engine_url, _session_factory
if _engine is not None:
await _engine.dispose()
_engine = None
_engine_url = None
_session_factory = None
@@ -0,0 +1,5 @@
"""无 I/O 的编排规则。"""
from .states import IllegalTransition, assert_run_transition
__all__ = ["IllegalTransition", "assert_run_transition"]
@@ -0,0 +1,159 @@
"""Materialize a published Workflow snapshot into sequential RunStep rows.
The planner deliberately supports a small, deterministic subset of the workflow
schema. It never evaluates expressions and it never turns free-form text into a
command. Host Agents only receive already-published adapter/template references.
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..enums import OnFail, StepType
from ..models import Run, RunStep, Workflow
_PARAM = re.compile(r"^\{\{\s*params\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$")
_LOOP_INDEX = re.compile(r"^\{\{\s*loop\.index\s*\}\}$")
_HOST_STEP_TYPES = {
StepType.COMMAND.value,
StepType.DEVICE_CHECK.value,
StepType.WAIT.value,
}
class WorkflowPlanningError(ValueError):
"""The published workflow cannot be safely materialized."""
def _resolve(value: Any, run_params: Dict[str, Any], loop_index: Optional[int]) -> Any:
"""Resolve only exact, allow-listed template tokens; no expression evaluation."""
if isinstance(value, str):
parameter = _PARAM.fullmatch(value)
if parameter:
key = parameter.group(1)
if key not in run_params:
raise WorkflowPlanningError("工作流参数不存在: {0}".format(key))
return run_params[key]
if _LOOP_INDEX.fullmatch(value):
if loop_index is None:
raise WorkflowPlanningError("loop.index 只能在循环体内使用")
return loop_index
if "{{" in value or "}}" in value:
raise WorkflowPlanningError("模板只允许完整的 params.X 或 loop.index 占位符")
return value
if isinstance(value, dict):
return {key: _resolve(item, run_params, loop_index) for key, item in value.items()}
if isinstance(value, list):
return [_resolve(item, run_params, loop_index) for item in value]
return value
def _step_row(
*,
run: Run,
spec: Dict[str, Any],
seq: int,
loop_index: Optional[int],
checkpoint_after: bool,
) -> RunStep:
step_type = str(spec.get("type", ""))
if step_type not in _HOST_STEP_TYPES:
raise WorkflowPlanningError("Host Agent 不支持步骤类型: {0}".format(step_type or "<empty>"))
key = str(spec.get("key", "")).strip()
adapter = str(spec.get("adapter", "")).strip()
if not key or not adapter:
raise WorkflowPlanningError("步骤 key 与 adapter 不能为空")
retry = int(spec.get("retry", 0))
timeout_s = float(spec.get("timeout_s", get_settings().timing.step_default_timeout_s))
if retry < 0 or retry > 20:
raise WorkflowPlanningError("步骤 retry 必须在 0..20 之间")
if timeout_s <= 0:
raise WorkflowPlanningError("步骤 timeout_s 必须大于 0")
return RunStep(
run_id=run.id,
seq=seq,
loop_index=loop_index,
step_key=key,
step_type=step_type,
adapter=adapter,
template=spec.get("template"),
params=_resolve(dict(spec.get("params") or {}), run.params, loop_index),
max_retry=retry,
timeout_s=timeout_s,
idempotent=bool(spec.get("idempotent", False)),
danger=str(spec.get("danger", "none")),
on_fail=str(spec.get("on_fail", OnFail.RETRY.value)),
checkpoint_after=checkpoint_after,
)
def plan_run_steps(run: Run, workflow: Workflow) -> List[RunStep]:
"""Expand one workflow snapshot into the exact ordered steps for a Run."""
if not workflow.published:
raise WorkflowPlanningError("工作流尚未发布")
raw_steps = workflow.spec.get("steps") if isinstance(workflow.spec, dict) else None
if not isinstance(raw_steps, list) or not raw_steps:
raise WorkflowPlanningError("工作流没有可执行步骤")
planned: List[RunStep] = []
seq = 1
for raw in raw_steps:
if not isinstance(raw, dict):
raise WorkflowPlanningError("工作流步骤必须是 object")
step_type = str(raw.get("type", ""))
# Safety preflight and evidence/report generation live in the control plane.
if step_type in {StepType.PRECHECK.value, StepType.REPORT.value}:
continue
if step_type == StepType.LOOP.value:
body = raw.get("body")
if not isinstance(body, list) or not body:
raise WorkflowPlanningError("循环步骤必须包含非空 body")
for loop_index in range(1, run.loop_target + 1):
for index, child in enumerate(body):
if not isinstance(child, dict):
raise WorkflowPlanningError("循环体步骤必须是 object")
planned.append(
_step_row(
run=run,
spec=child,
seq=seq,
loop_index=loop_index,
checkpoint_after=bool(child.get("checkpoint", False))
or (bool(raw.get("checkpoint", False)) and index == len(body) - 1),
)
)
seq += 1
continue
planned.append(
_step_row(
run=run,
spec=raw,
seq=seq,
loop_index=None,
checkpoint_after=bool(raw.get("checkpoint", False)),
)
)
seq += 1
if not planned:
raise WorkflowPlanningError("工作流没有 Host Agent 可执行步骤")
return planned
async def materialize_run_steps(
session: AsyncSession, run: Run, workflow: Workflow
) -> List[RunStep]:
planned = plan_run_steps(run, workflow)
session.add_all(planned)
await session.flush()
return planned
@@ -0,0 +1,37 @@
"""五级恢复阶梯的纯函数决策。"""
from __future__ import annotations
from dataclasses import dataclass
from ..enums import RecoveryLevel, RecoveryTrigger
@dataclass(frozen=True)
class RecoveryContext:
trigger: RecoveryTrigger
attempt: int
agent_online: bool
host_network_online: bool
oob_online: bool
def next_recovery_level(context: RecoveryContext) -> RecoveryLevel:
if context.trigger == RecoveryTrigger.DATA_INTEGRITY:
return RecoveryLevel.FREEZE
ladder = []
if context.agent_online:
ladder.append(RecoveryLevel.AGENT_SOFT)
if context.host_network_online:
ladder.append(RecoveryLevel.OS_REBOOT)
if context.oob_online:
ladder.extend(
[
RecoveryLevel.OOB_RESET,
RecoveryLevel.OOB_ATX_POWER,
RecoveryLevel.OOB_AC_CYCLE,
]
)
if context.attempt >= len(ladder):
return RecoveryLevel.FREEZE
return ladder[context.attempt]
@@ -0,0 +1,44 @@
"""Run 状态转移表。"""
from __future__ import annotations
from typing import Dict, FrozenSet
from ..enums import RunState
class IllegalTransition(ValueError):
pass
RUN_TRANSITIONS: Dict[RunState, FrozenSet[RunState]] = {
RunState.QUEUED: frozenset({RunState.PREFLIGHT, RunState.ABORTED}),
RunState.PREFLIGHT: frozenset(
{RunState.RUNNING, RunState.REJECTED, RunState.ABORTED}
),
RunState.RUNNING: frozenset(
{
RunState.RECOVERING,
RunState.PAUSED,
RunState.FROZEN,
RunState.COMPLETED,
RunState.ABORTED,
}
),
RunState.RECOVERING: frozenset(
{RunState.RUNNING, RunState.FROZEN, RunState.ABORTED}
),
RunState.PAUSED: frozenset({RunState.RUNNING, RunState.ABORTED}),
RunState.FROZEN: frozenset(),
RunState.COMPLETED: frozenset(),
RunState.ABORTED: frozenset(),
RunState.REJECTED: frozenset(),
}
def assert_run_transition(current: str, target: str) -> None:
current_state = RunState(current)
target_state = RunState(target)
if target_state not in RUN_TRANSITIONS[current_state]:
raise IllegalTransition(
"Run 状态不允许从 {0} 转到 {1}".format(current_state.value, target_state.value)
)
@@ -0,0 +1,234 @@
"""全部领域枚举。
放在包根、不依赖任何东西 —— models / engine / schemas / api 都从这里取,
保证"状态"这个概念在全仓库只有一份定义。
"""
from __future__ import annotations
from enum import Enum
class StrEnum(str, Enum):
"""Python 3.9 没有 enum.StrEnum,自己来一个。"""
def __str__(self) -> str: # pragma: no cover - 只影响日志可读性
return self.value
# ---------------------------------------------------------------- Run / Step
class RunState(StrEnum):
QUEUED = "QUEUED" # 等资源
PREFLIGHT = "PREFLIGHT" # 安全门禁 + 环境指纹冻结
RUNNING = "RUNNING"
RECOVERING = "RECOVERING" # 恢复阶梯进行中
PAUSED = "PAUSED" # 人工暂停
FROZEN = "FROZEN" # 冻结现场,停止一切覆盖性动作(终态)
COMPLETED = "COMPLETED" # 终态
ABORTED = "ABORTED" # 紧急停止(终态)
REJECTED = "REJECTED" # 安全门禁拒绝(终态)
class StepState(StrEnum):
PENDING = "PENDING"
DISPATCHED = "DISPATCHED" # 已租给 Agent,等确认接手
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
TIMED_OUT = "TIMED_OUT"
SKIPPED = "SKIPPED"
CANCELLED = "CANCELLED"
class Verdict(StrEnum):
PASS = "PASS"
FAIL = "FAIL"
INCONCLUSIVE = "INCONCLUSIVE" # 非幂等步骤中断后不自动重跑 → 需人工确认
# ------------------------------------------------------------------ 恢复阶梯
class RecoveryLevel(StrEnum):
"""方案 §7.2 的五级阶梯。顺序即升级顺序。"""
AGENT_SOFT = "L1_AGENT_SOFT" # Agent 优雅停止 / 软重启进程
OS_REBOOT = "L2_OS_REBOOT" # OS 远程通道重启
OOB_RESET = "L3_OOB_RESET" # 带外触发主板 Reset
OOB_ATX_POWER = "L4_OOB_ATX_POWER" # 带外模拟 ATX 长按关机再开机
OOB_AC_CYCLE = "L5_OOB_AC_CYCLE" # 整机 AC 断电 → 安全间隔 → 上电
FREEZE = "FREEZE" # 冻结现场,不再自动恢复
@property
def requires_oob(self) -> bool:
return self in (
RecoveryLevel.OOB_RESET,
RecoveryLevel.OOB_ATX_POWER,
RecoveryLevel.OOB_AC_CYCLE,
)
class RecoveryOutcome(StrEnum):
RECOVERED = "RECOVERED"
FAILED = "FAILED"
ESCALATED = "ESCALATED"
FROZEN = "FROZEN"
class RecoveryTrigger(StrEnum):
HEARTBEAT_LOST = "HEARTBEAT_LOST"
STEP_TIMEOUT = "STEP_TIMEOUT"
HOST_CRASH = "HOST_CRASH" # 蓝屏 / kernel panic
DUT_NOT_ENUMERATED = "DUT_NOT_ENUMERATED"
DATA_INTEGRITY = "DATA_INTEGRITY" # → 直接 FREEZE,不走阶梯
MANUAL = "MANUAL"
# -------------------------------------------------------------------- 资产
class HostStatus(StrEnum):
ONLINE = "ONLINE"
DEGRADED = "DEGRADED" # 心跳迟到但未判失联
OFFLINE = "OFFLINE"
UNKNOWN = "UNKNOWN"
class DutStatus(StrEnum):
IDLE = "IDLE"
IN_USE = "IN_USE"
QUARANTINED = "QUARANTINED" # 疑似坏盘,隔离待人工确认
MISSING = "MISSING" # 探针找不到
class PowerState(StrEnum):
ON = "ON"
OFF = "OFF"
UNKNOWN = "UNKNOWN"
class DangerLevel(StrEnum):
NONE = "none"
LOW = "low"
HIGH = "high" # 需要审批记录
# -------------------------------------------------------------------- 失败
class FailureClass(StrEnum):
"""INFRA_FAILURE 必须与 DUT_DEFECT 分开统计 —— 见 domain-model.md。
把网络断了算成 SSD 缺陷,客户第一周就不信任这套系统。
"""
DUT_DEFECT = "DUT_DEFECT"
INFRA_FAILURE = "INFRA_FAILURE"
SCRIPT_FAILURE = "SCRIPT_FAILURE"
DATA_INTEGRITY = "DATA_INTEGRITY"
UNKNOWN = "UNKNOWN"
class IntegrityState(StrEnum):
OK = "OK"
MISMATCH = "MISMATCH"
NOT_CHECKED = "NOT_CHECKED"
# ------------------------------------------------------------- 事件(时间线)
class EventSource(StrEnum):
CONTROL_PLANE = "control_plane"
AGENT = "agent"
OOB = "oob" # 带外通道 —— 主机死后唯一还在说话的
HUMAN = "human"
class Severity(StrEnum):
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
class EventKind(StrEnum):
"""统一时间线的事件种类。
新增事件种类是常事;删除/改名不是 —— 历史事件已经落库,改名会让旧 Run 的
时间线读不出来。只增不改。
"""
# Run 生命周期
RUN_CREATED = "RUN_CREATED"
RUN_QUEUED = "RUN_QUEUED"
RUN_PREFLIGHT_STARTED = "RUN_PREFLIGHT_STARTED"
RUN_PREFLIGHT_PASSED = "RUN_PREFLIGHT_PASSED"
RUN_REJECTED = "RUN_REJECTED"
RUN_STARTED = "RUN_STARTED"
RUN_PAUSED = "RUN_PAUSED"
RUN_RESUMED = "RUN_RESUMED"
RUN_COMPLETED = "RUN_COMPLETED"
RUN_ABORTED = "RUN_ABORTED"
RUN_FROZEN = "RUN_FROZEN"
# 循环与步骤
LOOP_STARTED = "LOOP_STARTED"
LOOP_COMPLETED = "LOOP_COMPLETED"
STEP_DISPATCHED = "STEP_DISPATCHED"
STEP_LEASE_RENEWED = "STEP_LEASE_RENEWED"
STEP_LEASE_EXPIRED = "STEP_LEASE_EXPIRED"
STEP_STARTED = "STEP_STARTED"
STEP_OUTPUT = "STEP_OUTPUT"
STEP_SUCCEEDED = "STEP_SUCCEEDED"
STEP_FAILED = "STEP_FAILED"
STEP_TIMED_OUT = "STEP_TIMED_OUT"
STEP_RETRYING = "STEP_RETRYING"
STEP_CANCELLED = "STEP_CANCELLED"
# 检查点
CHECKPOINT_SAVED = "CHECKPOINT_SAVED"
CHECKPOINT_RESUMED = "CHECKPOINT_RESUMED"
# 心跳与双通道观测
HEARTBEAT_DEGRADED = "HEARTBEAT_DEGRADED"
HEARTBEAT_LOST = "HEARTBEAT_LOST"
HEARTBEAT_RECOVERED = "HEARTBEAT_RECOVERED"
OOB_OBSERVATION = "OOB_OBSERVATION"
OOB_POWER_STATE = "OOB_POWER_STATE"
# 恢复
RECOVERY_STARTED = "RECOVERY_STARTED"
RECOVERY_ESCALATED = "RECOVERY_ESCALATED"
RECOVERY_SUCCEEDED = "RECOVERY_SUCCEEDED"
RECOVERY_FAILED = "RECOVERY_FAILED"
# 设备与数据
DUT_ENUMERATION_LOST = "DUT_ENUMERATION_LOST"
DUT_ENUMERATION_RESTORED = "DUT_ENUMERATION_RESTORED"
INTEGRITY_MISMATCH = "INTEGRITY_MISMATCH"
# 安全与资源
SAFETY_APPROVED = "SAFETY_APPROVED"
SAFETY_REJECTED = "SAFETY_REJECTED"
LOCK_ACQUIRED = "LOCK_ACQUIRED"
LOCK_RELEASED = "LOCK_RELEASED"
# 人与产物
HUMAN_TOUCH = "HUMAN_TOUCH" # 每一次人工介入都记 —— UCR 的分母来源
FAILURE_SIGNED = "FAILURE_SIGNED"
EVIDENCE_BUNDLED = "EVIDENCE_BUNDLED"
class StepType(StrEnum):
COMMAND = "command"
DEVICE_CHECK = "device_check"
WAIT = "wait"
LOOP = "loop"
PRECHECK = "precheck"
REPORT = "report"
class OnFail(StrEnum):
RETRY = "retry"
FAIL_RUN = "fail_run"
CONTINUE = "continue"
FREEZE = "freeze"
RECOVER = "recover"
@@ -0,0 +1,5 @@
"""事件溯源写入。"""
from .recorder import append_event
__all__ = ["append_event"]
@@ -0,0 +1,54 @@
"""Run 事件唯一写入口,同时维护当前状态投影。"""
from __future__ import annotations
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from ..engine.states import assert_run_transition
from ..enums import EventSource, Severity
from ..models import Run, RunEvent, utcnow
async def append_event(
session: AsyncSession,
run: Run,
kind: str,
message: str,
*,
source: str = EventSource.CONTROL_PLANE.value,
severity: str = Severity.INFO.value,
payload: Optional[Dict[str, Any]] = None,
target_state: Optional[str] = None,
projection: Optional[Dict[str, Any]] = None,
idempotency_key: Optional[str] = None,
step_id: Optional[str] = None,
loop_index: Optional[int] = None,
) -> RunEvent:
if target_state is not None and target_state != run.state:
assert_run_transition(run.state, target_state)
run.event_seq += 1
event = RunEvent(
run_id=run.id,
seq=run.event_seq,
ts=utcnow(),
source=source,
kind=kind,
severity=severity,
message=message,
payload=payload or {},
idempotency_key=idempotency_key,
step_id=step_id,
loop_index=loop_index,
)
session.add(event)
if target_state is not None:
run.state = target_state
for key, value in (projection or {}).items():
if not hasattr(run, key):
raise AttributeError("Run 不存在投影字段: {0}".format(key))
setattr(run, key, value)
await session.flush()
return event
@@ -0,0 +1,5 @@
"""Evidence Bundle 生成。"""
from .bundle import build_evidence_bundle
__all__ = ["build_evidence_bundle"]
@@ -0,0 +1,69 @@
"""把一次 Run 的可重放事件打成最小证据包。"""
from __future__ import annotations
import json
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..models import EvidenceBundle, Run, RunEvent, iso_z
async def build_evidence_bundle(session: AsyncSession, run: Run) -> EvidenceBundle:
events = (
await session.scalars(
select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
)
).all()
root = get_settings().object_store_path / "runs" / run.id
root.mkdir(parents=True, exist_ok=True)
timeline_path = root / "events.ndjson"
timeline = "\n".join(
json.dumps(
{
"seq": event.seq,
"ts": iso_z(event.ts),
"source": event.source,
"kind": event.kind,
"severity": event.severity,
"message": event.message,
"payload": event.payload,
},
ensure_ascii=False,
sort_keys=True,
)
for event in events
)
timeline_path.write_text(timeline + ("\n" if timeline else ""), encoding="utf-8")
manifest = {
"schema_version": 1,
"run_id": run.id,
"workflow": {
"key": run.workflow_key,
"version": run.workflow_version,
"spec_hash": run.spec_hash,
},
"environment_fingerprint": run.env_fingerprint,
"checkpoint": run.checkpoint,
"event_count": len(events),
"files": [{"path": "events.ndjson", "bytes": timeline_path.stat().st_size}],
}
manifest_path = root / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True),
encoding="utf-8",
)
size = manifest_path.stat().st_size + timeline_path.stat().st_size
bundle = EvidenceBundle(
run_id=run.id,
uri=str(root),
manifest=manifest,
size_bytes=size,
completeness=1.0,
missing_fields=[],
)
session.add(bundle)
await session.flush()
return bundle
@@ -0,0 +1,53 @@
"""FlashOps FastAPI 应用。"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from .api.agents import router as agent_router
from .api.routes import router
from .config import APP_NAME, APP_TAGLINE, REPO_ROOT, get_settings
from .db import init_db, session_scope
from .seed import ensure_seed_data
from .services.agent_status import run_agent_watchdog, stop_agent_watchdog
@asynccontextmanager
async def lifespan(_: FastAPI):
await init_db()
async with session_scope() as session:
await ensure_seed_data(session)
watchdog = asyncio.create_task(run_agent_watchdog(), name="agent-heartbeat-watchdog")
try:
yield
finally:
await stop_agent_watchdog(watchdog)
app = FastAPI(title=APP_NAME, description=APP_TAGLINE, version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[origin.strip() for origin in get_settings().cors_origins.split(",")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
app.include_router(agent_router)
console_dir = REPO_ROOT.parent / "前端UI八页面完成"
if console_dir.exists():
app.mount("/console", StaticFiles(directory=str(console_dir), html=True), name="console")
@app.get("/", include_in_schema=False)
async def root() -> RedirectResponse:
if console_dir.exists():
return RedirectResponse("/console/Dashboard.dc.html")
return RedirectResponse("/docs")
@@ -0,0 +1,31 @@
"""11 张表 → 方案 §5.3 的 7 个核心领域对象。映射见 docs/domain-model.md。"""
from .analysis import AuditLog, EvidenceBundle, FailureSignature, RunFailure
from .assets import Dut, FirmwareArtifact, OobController, TestHost
from .base import Base, iso_z, new_id, utcnow
from .run import RecoveryAction, ResourceLock, Run, RunEvent, RunStep
from .workflow import Workflow
__all__ = [
"Base",
"utcnow",
"new_id",
"iso_z",
# 资产
"TestHost",
"OobController",
"Dut",
"FirmwareArtifact",
# 工作流
"Workflow",
# Run
"Run",
"RunStep",
"RunEvent",
"RecoveryAction",
"ResourceLock",
# 分析
"FailureSignature",
"RunFailure",
"EvidenceBundle",
"AuditLog",
]
@@ -0,0 +1,112 @@
"""失败签名、失败实例、Evidence Bundle、审计。"""
from __future__ import annotations
import datetime as _dt
from typing import Any, Dict, List, Optional
from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from ..enums import FailureClass, IntegrityState
from .base import Base, TimestampMixin, pk
class FailureSignature(Base, TimestampMixin):
"""失败签名 —— 聚类锚点。
8 要素哈希(方案 §8.3)。只用错误码会把"掉盘""脚本超时"归成一类,
聚类就废了;用原始日志则每条日志都是新签名。所以用模板化后的日志指纹。
"""
__tablename__ = "failure_signatures"
__table_args__ = (UniqueConstraint("hash", name="uq_signature_hash"),)
id: Mapped[str] = pk("sig")
hash: Mapped[str] = mapped_column(String(64), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
# 8 要素
workflow_step: Mapped[str] = mapped_column(String(64), nullable=False)
error_codes: Mapped[List[Any]] = mapped_column(JSON, default=list)
log_templates: Mapped[List[Any]] = mapped_column(JSON, default=list)
host_state: Mapped[str] = mapped_column(String(32), default="UNKNOWN")
dut_enumeration_state: Mapped[str] = mapped_column(String(32), default="UNKNOWN")
data_integrity_state: Mapped[str] = mapped_column(String(16), default=IntegrityState.NOT_CHECKED.value)
recovery_outcome: Mapped[Optional[str]] = mapped_column(String(16))
environment_fingerprint_hash: Mapped[Optional[str]] = mapped_column(String(64))
failure_class: Mapped[str] = mapped_column(String(24), default=FailureClass.UNKNOWN.value)
occurrences: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
first_seen_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
last_seen_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
# 人工确认后的处置:如已提单号、已知问题、误报
triage_note: Mapped[Optional[str]] = mapped_column(Text)
issue_ref: Mapped[Optional[str]] = mapped_column(String(64))
class RunFailure(Base):
"""一次具体的失败实例,挂在签名下面。"""
__tablename__ = "run_failures"
__table_args__ = (Index("ix_failure_run", "run_id"),)
id: Mapped[str] = pk("fail")
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
signature_id: Mapped[Optional[str]] = mapped_column(ForeignKey("failure_signatures.id"))
step_id: Mapped[Optional[str]] = mapped_column(String(32))
loop_index: Mapped[Optional[int]] = mapped_column(Integer)
ts: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False)
failure_class: Mapped[str] = mapped_column(String(24), default=FailureClass.UNKNOWN.value)
summary: Mapped[str] = mapped_column(Text, nullable=False)
detail: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
# 记录固件版本,A/B 对比直接从这里聚合
firmware_label: Mapped[Optional[str]] = mapped_column(String(64))
class EvidenceBundle(Base, TimestampMixin):
"""证据包。completeness < 1 说明有关键字段缺失 —— MVP 硬指标是 ≥95%"""
__tablename__ = "evidence_bundles"
id: Mapped[str] = pk("bundle")
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
uri: Mapped[str] = mapped_column(String(512), nullable=False)
manifest: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
completeness: Mapped[float] = mapped_column(Float, default=0.0)
missing_fields: Mapped[List[Any]] = mapped_column(JSON, default=list)
class AuditLog(Base):
"""审计:谁、什么时候、用哪个模板版本、对什么、做了什么、结果如何。
方案 §6.6 的硬要求。与决策放在同一处 —— 见 ADR-0004。
"""
__tablename__ = "audit_log"
__table_args__ = (Index("ix_audit_ts", "ts"),)
id: Mapped[str] = pk("audit")
ts: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False)
actor: Mapped[str] = mapped_column(String(64), nullable=False)
action: Mapped[str] = mapped_column(String(64), nullable=False)
target_type: Mapped[Optional[str]] = mapped_column(String(32))
target_id: Mapped[Optional[str]] = mapped_column(String(32))
command_template: Mapped[Optional[str]] = mapped_column(String(64))
template_version: Mapped[Optional[str]] = mapped_column(String(16))
params: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
result: Mapped[Optional[str]] = mapped_column(String(32))
approved_by: Mapped[Optional[str]] = mapped_column(String(64))
reason: Mapped[Optional[str]] = mapped_column(Text)
@@ -0,0 +1,111 @@
"""资产:测试主机、带外控制器、DUT、固件包。"""
from __future__ import annotations
import datetime as _dt
from typing import Any, Dict, List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from ..enums import DutStatus, HostStatus, PowerState
from .base import Base, TimestampMixin, pk
class OobController(Base, TimestampMixin):
"""带外控制器。
独立成表而不是挂在 TestHost 上,因为它的核心价值就是**主机死后独活**:
它有自己的网络、自己的心跳、自己的生命周期。主机 OFFLINE 时它必须还 ONLINE
这个"矛盾"状态是恢复阶梯 L3-L5 可用性的判断依据。
"""
__tablename__ = "oob_controllers"
id: Mapped[str] = pk("oob")
name: Mapped[str] = mapped_column(String(128), nullable=False)
kind: Mapped[str] = mapped_column(String(32), default="simulated") # raspberry_pi / jetkvm / pdu / simulated
endpoint: Mapped[Optional[str]] = mapped_column(String(255))
# 能力位:{"power": true, "reset": true, "atx": true, "ac": true, "hdmi": true, "temp": true}
capabilities: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
status: Mapped[str] = mapped_column(String(16), default=HostStatus.UNKNOWN.value)
power_state: Mapped[str] = mapped_column(String(16), default=PowerState.UNKNOWN.value)
last_seen_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
temperature_c: Mapped[Optional[float]] = mapped_column()
class TestHost(Base, TimestampMixin):
"""测试主机。环境指纹的主要来源 —— A/B 对比的可比性靠它固定。"""
__tablename__ = "test_hosts"
id: Mapped[str] = pk("host")
name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
os_family: Mapped[str] = mapped_column(String(32), default="linux") # linux / windows
os_version: Mapped[Optional[str]] = mapped_column(String(128))
kernel: Mapped[Optional[str]] = mapped_column(String(128))
cpu: Mapped[Optional[str]] = mapped_column(String(128))
motherboard: Mapped[Optional[str]] = mapped_column(String(128))
bios_version: Mapped[Optional[str]] = mapped_column(String(64))
agent_version: Mapped[Optional[str]] = mapped_column(String(32))
ip_address: Mapped[Optional[str]] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16), default=HostStatus.UNKNOWN.value)
last_heartbeat_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
agent_id: Mapped[Optional[str]] = mapped_column(String(32))
# 只保存 bearer token 的 SHA-256 摘要;原始 token 仅在注册响应中返回一次。
agent_token: Mapped[Optional[str]] = mapped_column(String(64))
oob_controller_id: Mapped[Optional[str]] = mapped_column(ForeignKey("oob_controllers.id"))
# 已安装工具与版本,来自 Agent 的 discover(){"nvme-cli": "2.8", "fio": "3.36"}
toolchain: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
labels: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
class Dut(Base, TimestampMixin):
"""被测设备。
allow_destructive 是防误盘的第二道信号(第一道是序列号,第三道是系统盘检测)。
它不给普通 API 改 —— 只能走带审批记录的资产接口,见 ADR-0004。
"""
__tablename__ = "duts"
__table_args__ = (UniqueConstraint("serial", name="uq_dut_serial"),)
id: Mapped[str] = pk("dut")
serial: Mapped[str] = mapped_column(String(64), nullable=False)
model: Mapped[str] = mapped_column(String(128), nullable=False)
vendor: Mapped[Optional[str]] = mapped_column(String(64))
capacity_gb: Mapped[Optional[int]] = mapped_column(Integer)
form_factor: Mapped[str] = mapped_column(String(32), default="M.2") # M.2 / U.2 / E1.S
interface: Mapped[str] = mapped_column(String(32), default="NVMe")
current_firmware: Mapped[Optional[str]] = mapped_column(String(64))
bdf: Mapped[Optional[str]] = mapped_column(String(32)) # PCIe 地址,如 0000:03:00.0
device_path: Mapped[Optional[str]] = mapped_column(String(64)) # /dev/nvme0n1
test_host_id: Mapped[Optional[str]] = mapped_column(ForeignKey("test_hosts.id"))
status: Mapped[str] = mapped_column(String(16), default=DutStatus.IDLE.value)
# 破坏性测试白名单标记。默认 False —— 安全默认拒绝。
allow_destructive: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
health: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) # SMART 关键项、温度、寿命
labels: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
class FirmwareArtifact(Base, TimestampMixin):
"""固件包。哈希 + 签名 + 适用型号,缺一样就不可审计。"""
__tablename__ = "firmware_artifacts"
id: Mapped[str] = pk("fw")
version: Mapped[str] = mapped_column(String(64), nullable=False)
label: Mapped[Optional[str]] = mapped_column(String(64)) # "A" / "B" / "候选版"
sha256: Mapped[str] = mapped_column(String(64), nullable=False)
signature: Mapped[Optional[str]] = mapped_column(String(512))
applicable_models: Mapped[List[Any]] = mapped_column(JSON, default=list)
flash_tool: Mapped[str] = mapped_column(String(64), default="nvme_cli")
file_uri: Mapped[Optional[str]] = mapped_column(String(512))
uploaded_by: Mapped[Optional[str]] = mapped_column(String(64))
@@ -0,0 +1,64 @@
"""ORM 基类与通用字段。
刻意保持"贫血模型":这些类只管持久化,不放业务逻辑。
业务逻辑在 engine/(纯函数)与 services/(编排),这样才能不起数据库单测。
"""
from __future__ import annotations
import datetime as _dt
import uuid
from typing import Any, Dict
from sqlalchemy import DateTime, JSON, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
"""所有表的基类。
type_annotation_map 里只用方言中立的类型:SQLite(开发)与 PostgreSQL(生产)
行为一致。不要引入 JSONB / ARRAY / UUID 这些 PG 特有类型 —— 见 ADR-0001。
"""
type_annotation_map = {
Dict[str, Any]: JSON,
dict: JSON,
list: JSON,
}
def utcnow() -> _dt.datetime:
"""统一的当前时间:naive UTC。
SQLite 不保存时区,所以全仓库统一存 naive UTC,序列化时再补 Z。
禁止在任何地方用 datetime.now()(本地时区)—— 实验室里跨时区协作会出事。
"""
return _dt.datetime.utcnow()
def new_id(prefix: str) -> str:
"""带前缀的 ID,如 run_7f3a9c2e...。
前缀让日志和时间线一眼可读 —— 排障时不用回表查这个 ID 是什么东西。
"""
return "{0}_{1}".format(prefix, uuid.uuid4().hex[:20])
def iso_z(value: Any) -> Any:
"""naive UTC datetime → ISO8601 带 Z。非 datetime 原样返回。"""
if isinstance(value, _dt.datetime):
return value.replace(microsecond=value.microsecond).isoformat() + "Z"
return value
class TimestampMixin:
created_at: Mapped[_dt.datetime] = mapped_column(DateTime, default=utcnow, nullable=False)
updated_at: Mapped[_dt.datetime] = mapped_column(
DateTime, default=utcnow, onupdate=utcnow, nullable=False
)
def pk(prefix: str) -> Mapped[str]:
return mapped_column(
String(32), primary_key=True, default=lambda: new_id(prefix)
)
@@ -0,0 +1,188 @@
"""Run 及其行为记录。
runs / run_steps / recovery_actions 是**读模型**(投影)。
唯一真相是 run_eventsappend-only)—— 见 ADR-0002。
仓储层刻意不提供 update_run_state():改状态的唯一入口是 events/recorder.py。
"""
from __future__ import annotations
import datetime as _dt
from typing import Any, Dict, List, Optional
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from ..enums import EventSource, RunState, Severity, StepState
from .base import Base, TimestampMixin, pk
class Run(Base, TimestampMixin):
__tablename__ = "runs"
id: Mapped[str] = pk("run")
name: Mapped[Optional[str]] = mapped_column(String(255))
workflow_id: Mapped[str] = mapped_column(ForeignKey("workflows.id"), nullable=False)
workflow_key: Mapped[str] = mapped_column(String(64), nullable=False)
workflow_version: Mapped[int] = mapped_column(Integer, nullable=False)
spec_hash: Mapped[str] = mapped_column(String(64), nullable=False)
state: Mapped[str] = mapped_column(String(16), default=RunState.QUEUED.value, nullable=False)
verdict: Mapped[Optional[str]] = mapped_column(String(16))
dut_id: Mapped[Optional[str]] = mapped_column(ForeignKey("duts.id"))
test_host_id: Mapped[Optional[str]] = mapped_column(ForeignKey("test_hosts.id"))
firmware_a_id: Mapped[Optional[str]] = mapped_column(ForeignKey("firmware_artifacts.id"))
firmware_b_id: Mapped[Optional[str]] = mapped_column(ForeignKey("firmware_artifacts.id"))
params: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
# 任务开始时冻结的不可变快照。固件之外任何一项变了,A/B 对比结论就不成立。
env_fingerprint: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
env_fingerprint_hash: Mapped[Optional[str]] = mapped_column(String(64))
loop_target: Mapped[int] = mapped_column(Integer, default=1)
loop_done: Mapped[int] = mapped_column(Integer, default=0)
# 最近一次检查点:{"loop_index": 37, "next_step_key": "...", "dut_fw": "A"}
checkpoint: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
started_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
ended_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
# ROI 凭据(方案 §9.3):这两个字段是销售武器,从第一行代码就记。
unattended_completion: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
human_touches: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
recovery_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# 事件序号分配器(单点递增,保证时间线可重放,见 ADR-0002)
event_seq: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_by: Mapped[Optional[str]] = mapped_column(String(64))
freeze_reason: Mapped[Optional[str]] = mapped_column(Text)
class RunStep(Base, TimestampMixin):
"""物化后的步骤实例。循环体在物化时按 loop_index 展开。"""
__tablename__ = "run_steps"
__table_args__ = (
UniqueConstraint("run_id", "seq", name="uq_step_run_seq"),
Index("ix_step_run_state", "run_id", "state"),
)
id: Mapped[str] = pk("step")
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
seq: Mapped[int] = mapped_column(Integer, nullable=False)
loop_index: Mapped[Optional[int]] = mapped_column(Integer)
step_key: Mapped[str] = mapped_column(String(64), nullable=False)
step_type: Mapped[str] = mapped_column(String(32), nullable=False)
adapter: Mapped[str] = mapped_column(String(32), nullable=False)
template: Mapped[Optional[str]] = mapped_column(String(64))
params: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
state: Mapped[str] = mapped_column(String(16), default=StepState.PENDING.value, nullable=False)
attempt: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
max_retry: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
timeout_s: Mapped[float] = mapped_column(Float, default=600.0, nullable=False)
# 非幂等步骤(默认)中断后不自动重跑 —— "自动重试把盘刷坏"是最容易砸招牌的失败模式
idempotent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
danger: Mapped[str] = mapped_column(String(16), default="none")
on_fail: Mapped[str] = mapped_column(String(16), default="retry")
checkpoint_after: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# 租约:Agent 领了活就死了 → 租约到期 → 步骤回 PENDING 重新派发
leased_by: Mapped[Optional[str]] = mapped_column(String(32))
lease_expires_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
started_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
ended_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
exit_code: Mapped[Optional[int]] = mapped_column(Integer)
error_class: Mapped[Optional[str]] = mapped_column(String(32))
result: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) # UnifiedResult
log_uri: Mapped[Optional[str]] = mapped_column(String(512))
class RunEvent(Base):
"""Append-only 事件流 —— 唯一真相。
seq 由控制平面单点分配(不按时间戳排序):主机断电、时钟漂移、
带外与主机时钟不同步都会让时间戳乱序。Agent 事件与带外事件进同一条序列,
双通道观测的交叉校验靠它。
"""
__tablename__ = "run_events"
__table_args__ = (
UniqueConstraint("run_id", "seq", name="uq_event_run_seq"),
# 网络抖动重发不产生重复事件(见 agent-protocol.md
UniqueConstraint("run_id", "idempotency_key", name="uq_event_idem"),
Index("ix_event_run_seq", "run_id", "seq"),
)
id: Mapped[str] = pk("ev")
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
seq: Mapped[int] = mapped_column(Integer, nullable=False)
ts: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False)
source: Mapped[str] = mapped_column(String(16), default=EventSource.CONTROL_PLANE.value)
kind: Mapped[str] = mapped_column(String(48), nullable=False)
severity: Mapped[str] = mapped_column(String(16), default=Severity.INFO.value)
step_id: Mapped[Optional[str]] = mapped_column(String(32))
loop_index: Mapped[Optional[int]] = mapped_column(Integer)
message: Mapped[Optional[str]] = mapped_column(Text)
payload: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
idempotency_key: Mapped[Optional[str]] = mapped_column(String(64))
class RecoveryAction(Base):
"""每一级恢复的留证记录。恢复前抓画面/温度,恢复后验证 DUT 重新枚举。"""
__tablename__ = "recovery_actions"
id: Mapped[str] = pk("rec")
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
step_id: Mapped[Optional[str]] = mapped_column(String(32))
loop_index: Mapped[Optional[int]] = mapped_column(Integer)
level: Mapped[str] = mapped_column(String(24), nullable=False)
trigger: Mapped[str] = mapped_column(String(32), nullable=False)
outcome: Mapped[Optional[str]] = mapped_column(String(16))
started_at: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False)
ended_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
detail: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
evidence_uris: Mapped[List[Any]] = mapped_column(JSON, default=list)
class ResourceLock(Base):
"""DUT / 主机独占锁。PREFLIGHT 获取,终态释放。
唯一约束在 (resource_type, resource_id) —— 数据库来保证互斥,不靠应用层自觉。
"""
__tablename__ = "resource_locks"
__table_args__ = (
UniqueConstraint("resource_type", "resource_id", name="uq_lock_resource"),
)
id: Mapped[str] = pk("lock")
resource_type: Mapped[str] = mapped_column(String(16), nullable=False) # dut / test_host / oob
resource_id: Mapped[str] = mapped_column(String(32), nullable=False)
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id"), nullable=False)
acquired_at: Mapped[_dt.datetime] = mapped_column(DateTime, nullable=False)
expires_at: Mapped[Optional[_dt.datetime]] = mapped_column(DateTime)
@@ -0,0 +1,35 @@
"""工作流:SOP 的可执行形态。"""
from __future__ import annotations
from typing import Any, Dict, Optional
from sqlalchemy import Boolean, Integer, JSON, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from ..enums import DangerLevel
from .base import Base, TimestampMixin, pk
class Workflow(Base, TimestampMixin):
"""工作流定义。
spec 存原文,spec_hash 存规范化后的哈希。spec_hash 变了而 version 没变 →
拒绝发布(见 workflow-spec.md)。理由:A/B 对比的结论必须能追溯到
具体哪一版流程,否则"上周跑的和这周跑的是不是同一个流程"说不清。
"""
__tablename__ = "workflows"
__table_args__ = (UniqueConstraint("key", "version", name="uq_workflow_key_version"),)
id: Mapped[str] = pk("wf")
key: Mapped[str] = mapped_column(String(64), nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
spec: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False)
spec_hash: Mapped[str] = mapped_column(String(64), nullable=False)
danger_level: Mapped[str] = mapped_column(String(16), default=DangerLevel.NONE.value)
source_sop: Mapped[Optional[str]] = mapped_column(String(255))
published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
approved_by: Mapped[Optional[str]] = mapped_column(String(64))
@@ -0,0 +1,5 @@
"""危险动作的安全门禁。"""
from .gates import PreflightRequest, PreflightResult, run_preflight
__all__ = ["PreflightRequest", "PreflightResult", "run_preflight"]
@@ -0,0 +1,90 @@
"""安全默认拒绝的预检规则。"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import PurePath
from typing import Dict, List, Sequence
@dataclass(frozen=True)
class Check:
key: str
name: str
passed: bool
note: str
@dataclass(frozen=True)
class PreflightRequest:
expected_serial: str
discovered_serial: str
allow_destructive: bool
device_path: str
system_device_paths: Sequence[str]
firmware_model_allowed: bool
host_online: bool
oob_online: bool
@dataclass(frozen=True)
class PreflightResult:
passed: bool
checks: List[Check]
def as_dict(self) -> Dict[str, object]:
return {
"passed": self.passed,
"checks": [asdict(check) for check in self.checks],
}
def _same_device(left: str, right: str) -> bool:
left_name = PurePath(left).name
right_name = PurePath(right).name
if left_name == right_name:
return True
# /dev/nvme0n1p2 属于 /dev/nvme0n1;拒绝把其任一分区当作测试盘。
return left_name.startswith(right_name + "p") or right_name.startswith(left_name + "p")
def run_preflight(request: PreflightRequest) -> PreflightResult:
serial_ok = bool(request.expected_serial) and (
request.expected_serial == request.discovered_serial
)
system_disk_safe = bool(request.device_path) and not any(
_same_device(request.device_path, path) for path in request.system_device_paths
)
checks = [
Check("serial", "DUT 序列号白名单绑定", serial_ok, request.discovered_serial or "未发现"),
Check(
"destructive",
"破坏性写入授权",
request.allow_destructive,
"已审批" if request.allow_destructive else "DUT 未授权破坏性写入",
),
Check(
"system_disk",
"系统盘 / 分区保护",
system_disk_safe,
"启动盘已排除" if system_disk_safe else "目标命中系统盘或其分区",
),
Check(
"firmware",
"固件适配型号",
request.firmware_model_allowed,
"型号匹配" if request.firmware_model_allowed else "固件不适用于该型号",
),
Check(
"host",
"测试主机与 Agent",
request.host_online,
"在线" if request.host_online else "主机离线",
),
Check(
"oob",
"带外控制器",
request.oob_online,
"Power / Reset 可用" if request.oob_online else "带外通道离线",
),
]
return PreflightResult(all(check.passed for check in checks), checks)
@@ -0,0 +1,80 @@
"""HTTP API 的 Pydantic 契约。"""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
class PreflightBody(BaseModel):
dut_id: Optional[str] = None
host_id: Optional[str] = None
firmware_id: Optional[str] = None
discovered_serial: Optional[str] = None
class CreateRunBody(BaseModel):
name: str = "固件 A/B 无人值守回归"
workflow_id: Optional[str] = None
dut_id: Optional[str] = None
host_id: Optional[str] = None
firmware_a_id: Optional[str] = None
firmware_b_id: Optional[str] = None
loops: int = Field(default=8, ge=1, le=500)
inject_failure: bool = True
execution_mode: Literal["simulated", "agent_dry_run"] = "simulated"
created_by: str = "console-demo"
class HumanActionBody(BaseModel):
actor: str = "console-user"
reason: str = "人工操作"
class AgentRegisterBody(BaseModel):
host_id: Optional[str] = None
host_name: Optional[str] = None
agent_version: str = Field(min_length=1, max_length=32)
os_family: str = Field(default="linux", min_length=1, max_length=32)
os_version: Optional[str] = Field(default=None, max_length=128)
kernel: Optional[str] = Field(default=None, max_length=128)
cpu: Optional[str] = Field(default=None, max_length=128)
motherboard: Optional[str] = Field(default=None, max_length=128)
bios_version: Optional[str] = Field(default=None, max_length=64)
ip_address: Optional[str] = Field(default=None, max_length=64)
toolchain: Dict[str, Any] = Field(default_factory=dict)
labels: Dict[str, Any] = Field(default_factory=dict)
class AgentHeartbeatBody(BaseModel):
healthy: bool = True
agent_version: Optional[str] = Field(default=None, max_length=32)
ip_address: Optional[str] = Field(default=None, max_length=64)
toolchain: Dict[str, Any] = Field(default_factory=dict)
device_probe: Dict[str, Any] = Field(default_factory=dict)
class AgentLeaseBody(BaseModel):
wait_timeout_s: float = Field(default=0.0, ge=0.0, le=20.0)
class AgentStepAttemptBody(BaseModel):
attempt: int = Field(ge=1)
class AgentStepEvent(BaseModel):
message: str = Field(min_length=1, max_length=2000)
severity: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
payload: Dict[str, Any] = Field(default_factory=dict)
class AgentStepEventsBody(AgentStepAttemptBody):
events: List[AgentStepEvent] = Field(min_length=1, max_length=100)
class AgentStepCompleteBody(AgentStepAttemptBody):
status: Literal["SUCCEEDED", "FAILED"]
exit_code: Optional[int] = None
error_class: Optional[str] = Field(default=None, max_length=32)
result: Dict[str, Any] = Field(default_factory=dict)
log_uri: Optional[str] = Field(default=None, max_length=512)
@@ -0,0 +1,141 @@
"""可重复执行的演示数据。"""
from __future__ import annotations
import hashlib
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .enums import DutStatus, HostStatus, PowerState
from .models import Dut, FirmwareArtifact, OobController, TestHost, Workflow, utcnow
def _spec_hash(spec: dict) -> str:
raw = json.dumps(spec, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def ensure_seed_data(session: AsyncSession) -> None:
existing = await session.scalar(select(Workflow.id).limit(1))
if existing:
return
oob = OobController(
name="OOB-05",
kind="simulated",
endpoint="sim://oob-05",
capabilities={"power": True, "reset": True, "atx": True, "ac": True, "hdmi": True},
status=HostStatus.ONLINE.value,
power_state=PowerState.ON.value,
last_seen_at=utcnow(),
temperature_c=42.6,
)
session.add(oob)
await session.flush()
host = TestHost(
name="WS-05",
os_family="linux",
os_version="Ubuntu 22.04.4 LTS",
kernel="6.8.0-40-generic",
cpu="Intel Core i7-13700",
motherboard="FlashOps SIM-X1",
bios_version="F26b",
agent_version="0.1.0-sim",
ip_address="192.0.2.15",
status=HostStatus.ONLINE.value,
last_heartbeat_at=utcnow(),
oob_controller_id=oob.id,
toolchain={"fio": "3.36", "nvme-cli": "2.9.1"},
labels={"system_device_paths": ["/dev/nvme0n1"]},
)
session.add(host)
await session.flush()
dut = Dut(
serial="S6XNNA0T609",
model="NV-E1T92",
vendor="Lab Sample",
capacity_gb=1920,
form_factor="U.2",
current_firmware="3.2.1",
bdf="0000:03:00.0",
device_path="/dev/nvme1n1",
test_host_id=host.id,
status=DutStatus.IDLE.value,
allow_destructive=True,
health={"temperature_c": 43, "percentage_used": 12, "critical_warning": 0},
labels={"purpose": "test-only"},
)
session.add(dut)
firmware_a = FirmwareArtifact(
version="3.2.1",
label="A / 基线",
sha256="4bd1" + "0" * 56 + "08ce",
signature="simulated-valid-signature",
applicable_models=[dut.model],
file_uri="sim://firmware/FW_3.2.1.bin",
uploaded_by="seed",
)
firmware_b = FirmwareArtifact(
version="3.3.0-rc3",
label="B / 候选",
sha256="9f2c" + "0" * 56 + "41aa",
signature="simulated-valid-signature",
applicable_models=[dut.model],
file_uri="sim://firmware/FW_3.3.0-rc3.bin",
uploaded_by="seed",
)
session.add_all([firmware_a, firmware_b])
spec = {
"key": "fw-ab-regression",
"name": "固件 A/B 电源状态回归",
"version": 3,
"danger_level": "high",
"params": {"loops": {"type": "int", "default": 8, "min": 1, "max": 10000}},
"steps": [
{"key": "preflight", "type": "precheck", "adapter": "safety"},
{
"key": "flash-fw-b",
"type": "command",
"adapter": "nvme_cli",
"template": "nvme_fw_download_commit",
"danger": "high",
"idempotent": False,
"checkpoint": True,
},
{
"key": "regression-loop",
"type": "loop",
"body": [
{
"key": "workload",
"type": "command",
"adapter": "fio",
"idempotent": True,
"retry": 1,
},
{"key": "enumeration-check", "type": "device_check", "adapter": "nvme_cli"},
{"key": "integrity-check", "type": "command", "adapter": "shell"},
],
"checkpoint": True,
},
{"key": "report", "type": "report", "adapter": "builtin"},
],
}
workflow = Workflow(
key=spec["key"],
name=spec["name"],
version=spec["version"],
spec=spec,
spec_hash=_spec_hash(spec),
danger_level="high",
source_sop="内置模拟 SOP v1",
published=True,
approved_by="system-seed",
)
session.add(workflow)
await session.flush()
@@ -0,0 +1 @@
"""应用服务。"""
@@ -0,0 +1,95 @@
"""Agent heartbeat aging and Host status projection."""
from __future__ import annotations
import asyncio
import datetime as dt
import logging
from contextlib import suppress
from typing import Optional
from sqlalchemy import select
from ..config import TimingPolicy, get_settings
from ..db import session_scope
from ..enums import HostStatus
from ..models import TestHost, utcnow
logger = logging.getLogger(__name__)
def projected_agent_status(
last_heartbeat_at: Optional[dt.datetime],
current_status: str,
*,
now: Optional[dt.datetime] = None,
timing: Optional[TimingPolicy] = None,
) -> str:
"""Return the status implied by heartbeat age.
A fresh explicit DEGRADED report remains degraded until the Agent sends a
healthy heartbeat. Only Agents with an ``agent_id`` use this projection;
seeded simulator Hosts retain their configured status.
"""
if last_heartbeat_at is None:
return HostStatus.OFFLINE.value
policy = timing or get_settings().timing
age_s = max(0.0, ((now or utcnow()) - last_heartbeat_at).total_seconds())
if age_s >= policy.heartbeat_lost_after_s:
return HostStatus.OFFLINE.value
if age_s >= policy.heartbeat_degraded_after_s:
return HostStatus.DEGRADED.value
if current_status == HostStatus.DEGRADED.value:
return HostStatus.DEGRADED.value
return HostStatus.ONLINE.value
def refresh_agent_host(host: TestHost, *, now: Optional[dt.datetime] = None) -> str:
"""Refresh one registered Host in the caller's transaction."""
if not host.agent_id:
return host.status
host.status = projected_agent_status(
host.last_heartbeat_at,
host.status,
now=now,
)
return host.status
async def refresh_all_agent_hosts(*, now: Optional[dt.datetime] = None) -> int:
"""Persist heartbeat-derived states for all registered Agents."""
changed = 0
async with session_scope() as session:
hosts = (
await session.scalars(
select(TestHost).where(TestHost.agent_id.is_not(None))
)
).all()
for host in hosts:
previous = host.status
refresh_agent_host(host, now=now)
if host.status != previous:
changed += 1
return changed
async def run_agent_watchdog() -> None:
"""Continuously age registered Agent heartbeats."""
interval_s = max(1.0, get_settings().timing.heartbeat_interval_s)
while True:
try:
await refresh_all_agent_hosts()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Agent heartbeat watchdog failed")
await asyncio.sleep(interval_s)
async def stop_agent_watchdog(task: "asyncio.Task[None]") -> None:
task.cancel()
with suppress(asyncio.CancelledError):
await task
@@ -0,0 +1,560 @@
"""Run 创建、查询与内置模拟执行器。"""
from __future__ import annotations
import asyncio
import hashlib
import json
from typing import Any, Dict, Optional, Set
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..db import session_scope
from ..enums import (
EventKind,
EventSource,
HostStatus,
RecoveryLevel,
RecoveryOutcome,
RecoveryTrigger,
RunState,
Severity,
Verdict,
)
from ..events import append_event
from ..evidence import build_evidence_bundle
from ..engine.planner import materialize_run_steps
from ..models import (
Dut,
FirmwareArtifact,
OobController,
RecoveryAction,
ResourceLock,
Run,
RunEvent,
TestHost,
Workflow,
iso_z,
utcnow,
)
from ..safety import PreflightRequest, run_preflight
from ..schemas import CreateRunBody, PreflightBody
from .agent_status import refresh_agent_host
_tasks: Dict[str, asyncio.Task[None]] = {}
_task_refs: Set[asyncio.Task[None]] = set()
async def _first(session: AsyncSession, model: Any) -> Any:
value = await session.scalar(select(model).limit(1))
if value is None:
raise LookupError("演示数据尚未初始化")
return value
async def resolve_assets(
session: AsyncSession,
*,
dut_id: Optional[str] = None,
host_id: Optional[str] = None,
firmware_id: Optional[str] = None,
) -> tuple[Dut, TestHost, Optional[FirmwareArtifact], Optional[OobController]]:
dut = await session.get(Dut, dut_id) if dut_id else await _first(session, Dut)
host = await session.get(TestHost, host_id) if host_id else await _first(session, TestHost)
firmware = (
await session.get(FirmwareArtifact, firmware_id)
if firmware_id
else await _first(session, FirmwareArtifact)
)
if dut is None or host is None:
raise LookupError("DUT 或测试主机不存在")
oob = await session.get(OobController, host.oob_controller_id) if host.oob_controller_id else None
return dut, host, firmware, oob
async def preflight_for_body(session: AsyncSession, body: PreflightBody) -> Dict[str, object]:
dut, host, firmware, oob = await resolve_assets(
session,
dut_id=body.dut_id,
host_id=body.host_id,
firmware_id=body.firmware_id,
)
refresh_agent_host(host)
system_paths = host.labels.get("system_device_paths", []) if host.labels else []
allowed = firmware is not None and (
not firmware.applicable_models or dut.model in firmware.applicable_models
)
result = run_preflight(
PreflightRequest(
expected_serial=dut.serial,
discovered_serial=body.discovered_serial or dut.serial,
allow_destructive=dut.allow_destructive,
device_path=dut.device_path or "",
system_device_paths=system_paths,
firmware_model_allowed=allowed,
host_online=host.status == HostStatus.ONLINE.value,
oob_online=oob is not None and oob.status == HostStatus.ONLINE.value,
)
)
payload = result.as_dict()
payload.update({"dut_id": dut.id, "host_id": host.id, "firmware_id": firmware.id if firmware else None})
return payload
async def create_run(session: AsyncSession, body: CreateRunBody) -> Run:
workflow = (
await session.get(Workflow, body.workflow_id)
if body.workflow_id
else await _first(session, Workflow)
)
dut, host, _, _ = await resolve_assets(
session, dut_id=body.dut_id, host_id=body.host_id
)
firmwares = (
await session.scalars(select(FirmwareArtifact).order_by(FirmwareArtifact.created_at))
).all()
if not firmwares:
raise LookupError("固件数据尚未初始化")
firmware_a = (
await session.get(FirmwareArtifact, body.firmware_a_id)
if body.firmware_a_id
else firmwares[0]
)
firmware_b = (
await session.get(FirmwareArtifact, body.firmware_b_id)
if body.firmware_b_id
else firmwares[-1]
)
assert workflow is not None and firmware_a is not None and firmware_b is not None
env = {
"host": host.name,
"os": host.os_version,
"kernel": host.kernel,
"bios": host.bios_version,
"agent": host.agent_version,
"toolchain": host.toolchain,
"dut_serial": dut.serial,
"dut_model": dut.model,
}
env_hash = hashlib.sha256(
json.dumps(env, ensure_ascii=False, sort_keys=True).encode("utf-8")
).hexdigest()
run = Run(
name=body.name,
workflow_id=workflow.id,
workflow_key=workflow.key,
workflow_version=workflow.version,
spec_hash=workflow.spec_hash,
dut_id=dut.id,
test_host_id=host.id,
firmware_a_id=firmware_a.id,
firmware_b_id=firmware_b.id,
params={
"inject_failure": body.inject_failure,
"execution_mode": body.execution_mode,
"loops": body.loops,
"fw_a": firmware_a.id,
"fw_b": firmware_b.id,
"firmware_a_version": firmware_a.version,
"firmware_b_version": firmware_b.version,
},
env_fingerprint=env,
env_fingerprint_hash=env_hash,
loop_target=body.loops,
created_by=body.created_by,
)
session.add(run)
await session.flush()
await append_event(
session,
run,
EventKind.RUN_CREATED.value,
"任务已创建,等待独占资源",
payload={"loops": body.loops, "inject_failure": body.inject_failure},
)
await append_event(
session, run, EventKind.RUN_QUEUED.value, "任务已进入调度队列"
)
return run
def run_to_dict(run: Run) -> Dict[str, Any]:
progress = round((run.loop_done / run.loop_target) * 100, 1) if run.loop_target else 0
return {
"id": run.id,
"name": run.name,
"state": run.state,
"verdict": run.verdict,
"workflow_key": run.workflow_key,
"workflow_version": run.workflow_version,
"execution_mode": run.params.get("execution_mode", "simulated"),
"dut_id": run.dut_id,
"test_host_id": run.test_host_id,
"loop_target": run.loop_target,
"loop_done": run.loop_done,
"progress": progress,
"checkpoint": run.checkpoint,
"environment": run.env_fingerprint,
"firmware_a_version": run.params.get("firmware_a_version"),
"firmware_b_version": run.params.get("firmware_b_version"),
"recovery_count": run.recovery_count,
"unattended_completion": run.unattended_completion,
"human_touches": run.human_touches,
"created_at": iso_z(run.created_at),
"started_at": iso_z(run.started_at),
"ended_at": iso_z(run.ended_at),
"freeze_reason": run.freeze_reason,
}
async def prepare_agent_run(session: AsyncSession, run: Run) -> None:
"""Run safety gates, lock resources and materialize an Agent dry-run."""
await append_event(
session,
run,
EventKind.RUN_PREFLIGHT_STARTED.value,
"已锁定执行计划,开始 Agent 任务安全预检",
target_state=RunState.PREFLIGHT.value,
)
preflight = await preflight_for_body(
session,
PreflightBody(
dut_id=run.dut_id,
host_id=run.test_host_id,
firmware_id=run.firmware_b_id,
),
)
if not preflight["passed"]:
await append_event(
session,
run,
EventKind.RUN_REJECTED.value,
"安全预检未通过",
severity=Severity.CRITICAL.value,
payload=preflight,
target_state=RunState.REJECTED.value,
projection={"ended_at": utcnow()},
)
return
existing_lock = await session.scalar(
select(ResourceLock.id).where(
ResourceLock.resource_type.in_(["dut", "test_host"]),
ResourceLock.resource_id.in_([run.dut_id or "", run.test_host_id or ""]),
)
)
if existing_lock:
raise LookupError("DUT 或测试主机已被其他任务锁定")
workflow = await session.get(Workflow, run.workflow_id)
if workflow is None:
raise LookupError("Run 关联工作流不存在")
steps = await materialize_run_steps(session, run, workflow)
session.add_all(
[
ResourceLock(
resource_type="dut",
resource_id=run.dut_id or "",
run_id=run.id,
acquired_at=utcnow(),
),
ResourceLock(
resource_type="test_host",
resource_id=run.test_host_id or "",
run_id=run.id,
acquired_at=utcnow(),
),
]
)
await append_event(
session,
run,
EventKind.SAFETY_APPROVED.value,
"6/6 安全门禁通过",
payload=preflight,
)
await append_event(
session,
run,
EventKind.LOCK_ACQUIRED.value,
"DUT 与测试主机独占锁已获取",
)
await append_event(
session,
run,
EventKind.RUN_STARTED.value,
"独立 Host Agent dry-run 已就绪,共 {0} 个步骤".format(len(steps)),
payload={"step_count": len(steps), "execution_mode": "agent_dry_run"},
target_state=RunState.RUNNING.value,
projection={"started_at": utcnow()},
)
def event_to_dict(event: RunEvent) -> Dict[str, Any]:
return {
"id": event.id,
"seq": event.seq,
"ts": iso_z(event.ts),
"source": event.source,
"kind": event.kind,
"severity": event.severity,
"message": event.message,
"payload": event.payload,
}
async def _sleep(seconds: float) -> None:
await asyncio.sleep(max(0.01, seconds * get_settings().time_scale))
async def _load_run(session: AsyncSession, run_id: str) -> Run:
run = await session.get(Run, run_id)
if run is None:
raise LookupError("Run 不存在: {0}".format(run_id))
return run
async def _run_recovery(run_id: str, loop_index: int) -> None:
levels = [
(RecoveryLevel.AGENT_SOFT, RecoveryOutcome.FAILED, "Agent 软恢复无响应"),
(RecoveryLevel.OS_REBOOT, RecoveryOutcome.FAILED, "OS 重启通道超时"),
(RecoveryLevel.OOB_RESET, RecoveryOutcome.RECOVERED, "带外 Reset 后主机与 DUT 已恢复"),
]
for level, outcome, message in levels:
async with session_scope() as session:
run = await _load_run(session, run_id)
action = RecoveryAction(
run_id=run.id,
loop_index=loop_index,
level=level.value,
trigger=RecoveryTrigger.HEARTBEAT_LOST.value,
outcome=outcome.value,
started_at=utcnow(),
ended_at=utcnow(),
detail={"simulated": True, "message": message},
evidence_uris=["sim://oob/frame-{0}".format(loop_index)],
)
session.add(action)
await append_event(
session,
run,
EventKind.RECOVERY_STARTED.value,
"{0} 已执行".format(level.value),
severity=Severity.WARNING.value,
payload={"level": level.value, "outcome": outcome.value},
projection={"recovery_count": run.recovery_count + 1},
)
if outcome == RecoveryOutcome.RECOVERED:
await append_event(
session,
run,
EventKind.RECOVERY_SUCCEEDED.value,
message,
source=EventSource.OOB.value,
payload={"level": level.value},
target_state=RunState.RUNNING.value,
)
else:
await append_event(
session,
run,
EventKind.RECOVERY_ESCALATED.value,
message + ",升级恢复阶梯",
severity=Severity.ERROR.value,
payload={"level": level.value},
)
await _sleep(0.28)
async def simulate_run(run_id: str) -> None:
try:
async with session_scope() as session:
run = await _load_run(session, run_id)
await append_event(
session,
run,
EventKind.RUN_PREFLIGHT_STARTED.value,
"已锁定工位,开始安全预检",
target_state=RunState.PREFLIGHT.value,
)
await _sleep(0.25)
async with session_scope() as session:
run = await _load_run(session, run_id)
preflight = await preflight_for_body(
session,
PreflightBody(
dut_id=run.dut_id,
host_id=run.test_host_id,
firmware_id=run.firmware_b_id,
),
)
if not preflight["passed"]:
await append_event(
session,
run,
EventKind.RUN_REJECTED.value,
"安全预检未通过",
severity=Severity.CRITICAL.value,
payload=preflight,
target_state=RunState.REJECTED.value,
projection={"ended_at": utcnow()},
)
return
session.add_all(
[
ResourceLock(
resource_type="dut",
resource_id=run.dut_id or "",
run_id=run.id,
acquired_at=utcnow(),
),
ResourceLock(
resource_type="test_host",
resource_id=run.test_host_id or "",
run_id=run.id,
acquired_at=utcnow(),
),
]
)
await append_event(
session,
run,
EventKind.SAFETY_APPROVED.value,
"6/6 安全门禁通过",
payload=preflight,
)
await append_event(
session,
run,
EventKind.LOCK_ACQUIRED.value,
"DUT 与测试主机独占锁已获取",
)
await append_event(
session,
run,
EventKind.RUN_STARTED.value,
"模拟 Agent 已接管执行",
target_state=RunState.RUNNING.value,
projection={"started_at": utcnow()},
)
async with session_scope() as session:
run = await _load_run(session, run_id)
target = run.loop_target
inject_failure = bool(run.params.get("inject_failure", False))
failure_loop = min(3, target) if inject_failure and target >= 2 else -1
for loop_index in range(1, target + 1):
while True:
async with session_scope() as session:
run = await _load_run(session, run_id)
if run.state != RunState.PAUSED.value:
break
await _sleep(0.2)
async with session_scope() as session:
run = await _load_run(session, run_id)
if run.state in {
RunState.ABORTED.value,
RunState.FROZEN.value,
RunState.REJECTED.value,
}:
return
await append_event(
session,
run,
EventKind.LOOP_STARTED.value,
"循环 {0}/{1} 开始".format(loop_index, target),
payload={"loop_index": loop_index},
)
await _sleep(0.22)
if loop_index == failure_loop:
async with session_scope() as session:
run = await _load_run(session, run_id)
await append_event(
session,
run,
EventKind.HEARTBEAT_LOST.value,
"Agent 心跳丢失;OOB 仍在线,启动自动恢复",
source=EventSource.OOB.value,
severity=Severity.ERROR.value,
payload={"loop_index": loop_index, "oob_online": True},
target_state=RunState.RECOVERING.value,
)
await _sleep(0.25)
await _run_recovery(run_id, loop_index)
async with session_scope() as session:
run = await _load_run(session, run_id)
await append_event(
session,
run,
EventKind.CHECKPOINT_RESUMED.value,
"主机指纹一致,从循环检查点续跑",
payload={"loop_index": loop_index},
)
async with session_scope() as session:
run = await _load_run(session, run_id)
checkpoint = {
"loop_index": loop_index,
"next_step_key": "regression-loop",
"dut_fw": "B",
}
await append_event(
session,
run,
EventKind.LOOP_COMPLETED.value,
"循环 {0}/{1} 通过,检查点已写入".format(loop_index, target),
payload={"loop_index": loop_index},
projection={"loop_done": loop_index, "checkpoint": checkpoint},
)
await _sleep(0.14)
async with session_scope() as session:
run = await _load_run(session, run_id)
bundle = await build_evidence_bundle(session, run)
await append_event(
session,
run,
EventKind.EVIDENCE_BUNDLED.value,
"Evidence Bundle 已生成,完整率 100%",
payload={"bundle_id": bundle.id, "uri": bundle.uri, "completeness": 1.0},
)
await append_event(
session,
run,
EventKind.LOCK_RELEASED.value,
"DUT 与测试主机独占锁已释放",
)
await session.execute(delete(ResourceLock).where(ResourceLock.run_id == run.id))
await append_event(
session,
run,
EventKind.RUN_COMPLETED.value,
"全部循环完成,结论 PASS",
target_state=RunState.COMPLETED.value,
projection={"verdict": Verdict.PASS.value, "ended_at": utcnow()},
)
except asyncio.CancelledError:
raise
finally:
_tasks.pop(run_id, None)
def start_simulation(run_id: str) -> None:
existing = _tasks.get(run_id)
if existing and not existing.done():
return
task = asyncio.create_task(simulate_run(run_id))
_tasks[run_id] = task
_task_refs.add(task)
task.add_done_callback(_task_refs.discard)
def cancel_simulation(run_id: str) -> None:
task = _tasks.pop(run_id, None)
if task and not task.done():
task.cancel()
@@ -0,0 +1,547 @@
"""Reliable pull-mode Step lease lifecycle for Host Agents."""
from __future__ import annotations
import datetime as _dt
from typing import Any, Dict, List, Optional
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..enums import (
EventKind,
EventSource,
OnFail,
RunState,
Severity,
StepState,
Verdict,
)
from ..events import append_event
from ..evidence import build_evidence_bundle
from ..models import ResourceLock, Run, RunEvent, RunStep, TestHost, iso_z, utcnow
from ..schemas import AgentStepCompleteBody, AgentStepEvent
_ACTIVE_STEP_STATES = {
StepState.PENDING.value,
StepState.DISPATCHED.value,
StepState.RUNNING.value,
}
class StepLeaseConflict(ValueError):
"""The Agent request does not match the current lease generation/state."""
def step_status(step: RunStep, *, replayed: bool = False) -> Dict[str, Any]:
return {
"step_id": step.id,
"run_id": step.run_id,
"state": step.state,
"attempt": step.attempt,
"lease_expires_at": iso_z(step.lease_expires_at),
"replayed": replayed,
}
def step_lease(step: RunStep, run: Run) -> Dict[str, Any]:
return {
"step_id": step.id,
"run_id": step.run_id,
"seq": step.seq,
"loop_index": step.loop_index,
"step_key": step.step_key,
"step_type": step.step_type,
"adapter": step.adapter,
"template": step.template,
"params": step.params,
"danger": step.danger,
"idempotent": step.idempotent,
"timeout_s": step.timeout_s,
"attempt": step.attempt,
"lease_expires_at": iso_z(step.lease_expires_at),
# This slice exercises the real process/network protocol but never executes
# a real hardware command. A future signed adapter registry will remove it.
"dry_run": run.params.get("execution_mode") == "agent_dry_run",
}
async def _locked_run(session: AsyncSession, run_id: str) -> Run:
run = await session.scalar(
select(Run).where(Run.id == run_id).with_for_update()
)
if run is None:
raise StepLeaseConflict("Run 不存在")
return run
async def _owned_step(
session: AsyncSession, host: TestHost, step_id: str
) -> tuple[RunStep, Run]:
step = await session.scalar(
select(RunStep)
.join(Run, Run.id == RunStep.run_id)
.where(RunStep.id == step_id, Run.test_host_id == host.id)
.with_for_update()
)
if step is None:
raise StepLeaseConflict("步骤不存在或不属于当前 Agent")
run = await _locked_run(session, step.run_id)
return step, run
async def _replayed(
session: AsyncSession,
*,
run_id: str,
step_id: str,
idempotency_key: str,
operation: str,
) -> bool:
event = await session.scalar(
select(RunEvent).where(
RunEvent.run_id == run_id,
RunEvent.idempotency_key == idempotency_key,
)
)
if event is None:
return False
if event.step_id != step_id or event.payload.get("_operation") != operation:
raise StepLeaseConflict("Idempotency-Key 已用于其他步骤或操作")
return True
def _assert_current(step: RunStep, agent_id: str, attempt: int) -> None:
now = utcnow()
if step.leased_by != agent_id or step.attempt != attempt:
raise StepLeaseConflict("租约代次不匹配,步骤可能已被重新派发")
if step.lease_expires_at is None or step.lease_expires_at <= now:
raise StepLeaseConflict("步骤租约已过期")
if (
step.state == StepState.RUNNING.value
and step.started_at is not None
and step.started_at + _dt.timedelta(seconds=step.timeout_s) <= now
):
raise StepLeaseConflict("步骤执行已超过 timeout_s")
async def _freeze_run(
session: AsyncSession, run: Run, step: RunStep, reason: str
) -> None:
if run.state not in {RunState.RUNNING.value, RunState.RECOVERING.value}:
return
await append_event(
session,
run,
EventKind.RUN_FROZEN.value,
reason,
severity=Severity.CRITICAL.value,
target_state=RunState.FROZEN.value,
projection={
"verdict": Verdict.INCONCLUSIVE.value,
"freeze_reason": reason,
"ended_at": utcnow(),
},
step_id=step.id,
loop_index=step.loop_index,
)
await session.execute(delete(ResourceLock).where(ResourceLock.run_id == run.id))
async def reclaim_expired_leases(session: AsyncSession, host: TestHost) -> int:
"""Requeue safe work and freeze ambiguous non-idempotent work."""
now = utcnow()
expired = (
await session.scalars(
select(RunStep)
.join(Run, Run.id == RunStep.run_id)
.where(
Run.test_host_id == host.id,
Run.state.in_([RunState.RUNNING.value, RunState.RECOVERING.value]),
RunStep.state.in_([StepState.DISPATCHED.value, StepState.RUNNING.value]),
RunStep.lease_expires_at.is_not(None),
RunStep.lease_expires_at <= now,
)
.order_by(RunStep.run_id, RunStep.seq)
.with_for_update()
)
).all()
for step in expired:
run = await _locked_run(session, step.run_id)
previous = step.state
step.leased_by = None
step.lease_expires_at = None
if previous == StepState.DISPATCHED.value:
step.state = StepState.PENDING.value
await append_event(
session,
run,
EventKind.STEP_LEASE_EXPIRED.value,
"Agent 未 ACK,步骤租约已回收",
severity=Severity.WARNING.value,
payload={"attempt": step.attempt, "previous_state": previous},
step_id=step.id,
loop_index=step.loop_index,
)
continue
can_retry = step.idempotent and step.attempt <= step.max_retry
if can_retry:
step.state = StepState.PENDING.value
await append_event(
session,
run,
EventKind.STEP_RETRYING.value,
"Agent 执行中失联,幂等步骤已安全回收等待重试",
severity=Severity.WARNING.value,
payload={"attempt": step.attempt, "max_retry": step.max_retry},
step_id=step.id,
loop_index=step.loop_index,
)
continue
step.state = StepState.TIMED_OUT.value
step.ended_at = now
await append_event(
session,
run,
EventKind.STEP_TIMED_OUT.value,
"执行租约到期,步骤结果不确定",
severity=Severity.ERROR.value,
payload={
"attempt": step.attempt,
"idempotent": step.idempotent,
"retry_exhausted": step.idempotent,
},
step_id=step.id,
loop_index=step.loop_index,
)
await _freeze_run(
session,
run,
step,
"步骤 {0} 执行结果不确定,已冻结现场禁止盲目重跑".format(step.step_key),
)
return len(expired)
async def try_lease_next_step(
session: AsyncSession, host: TestHost
) -> Optional[Dict[str, Any]]:
await reclaim_expired_leases(session, host)
runs = (
await session.scalars(
select(Run)
.where(
Run.test_host_id == host.id,
Run.state == RunState.RUNNING.value,
)
.order_by(Run.created_at, Run.id)
.with_for_update(skip_locked=True)
)
).all()
for run in runs:
steps = (
await session.scalars(
select(RunStep)
.where(RunStep.run_id == run.id, RunStep.state.in_(_ACTIVE_STEP_STATES))
.order_by(RunStep.seq)
.with_for_update(skip_locked=True)
)
).all()
if not steps:
continue
step = steps[0]
if step.state != StepState.PENDING.value:
continue
step.state = StepState.DISPATCHED.value
step.attempt += 1
step.leased_by = host.agent_id
step.lease_expires_at = utcnow() + _dt.timedelta(
seconds=get_settings().timing.lease_ttl_s
)
await append_event(
session,
run,
EventKind.STEP_DISPATCHED.value,
"步骤已租给 Host Agent,等待 ACK",
payload={"agent_id": host.agent_id, "attempt": step.attempt},
step_id=step.id,
loop_index=step.loop_index,
)
return step_lease(step, run)
return None
async def acknowledge_step(
session: AsyncSession,
host: TestHost,
step_id: str,
attempt: int,
idempotency_key: str,
) -> Dict[str, Any]:
step, run = await _owned_step(session, host, step_id)
if await _replayed(
session,
run_id=run.id,
step_id=step.id,
idempotency_key=idempotency_key,
operation="ack",
):
return step_status(step, replayed=True)
if step.state != StepState.DISPATCHED.value:
raise StepLeaseConflict("只有 DISPATCHED 步骤可以 ACK")
_assert_current(step, host.agent_id or "", attempt)
now = utcnow()
step.state = StepState.RUNNING.value
step.started_at = now
step.lease_expires_at = min(
now + _dt.timedelta(seconds=get_settings().timing.lease_ttl_s),
now + _dt.timedelta(seconds=step.timeout_s),
)
await append_event(
session,
run,
EventKind.STEP_STARTED.value,
"Host Agent 已确认接手步骤",
source=EventSource.AGENT.value,
payload={"_operation": "ack", "attempt": attempt},
idempotency_key=idempotency_key,
step_id=step.id,
loop_index=step.loop_index,
)
return step_status(step)
async def renew_step(
session: AsyncSession,
host: TestHost,
step_id: str,
attempt: int,
idempotency_key: str,
) -> Dict[str, Any]:
step, run = await _owned_step(session, host, step_id)
if await _replayed(
session,
run_id=run.id,
step_id=step.id,
idempotency_key=idempotency_key,
operation="renew",
):
return step_status(step, replayed=True)
if step.state != StepState.RUNNING.value:
raise StepLeaseConflict("只有 RUNNING 步骤可以续租")
_assert_current(step, host.agent_id or "", attempt)
assert step.started_at is not None
now = utcnow()
step.lease_expires_at = min(
now + _dt.timedelta(seconds=get_settings().timing.lease_ttl_s),
step.started_at + _dt.timedelta(seconds=step.timeout_s),
)
await append_event(
session,
run,
EventKind.STEP_LEASE_RENEWED.value,
"Host Agent 已续租执行步骤",
source=EventSource.AGENT.value,
severity=Severity.DEBUG.value,
payload={"_operation": "renew", "attempt": attempt},
idempotency_key=idempotency_key,
step_id=step.id,
loop_index=step.loop_index,
)
return step_status(step)
async def record_step_events(
session: AsyncSession,
host: TestHost,
step_id: str,
attempt: int,
events: List[AgentStepEvent],
idempotency_key: str,
) -> Dict[str, Any]:
step, run = await _owned_step(session, host, step_id)
first_key = "{0}:0".format(idempotency_key)
if await _replayed(
session,
run_id=run.id,
step_id=step.id,
idempotency_key=first_key,
operation="events",
):
return {**step_status(step, replayed=True), "accepted": len(events)}
if step.state != StepState.RUNNING.value:
raise StepLeaseConflict("只有 RUNNING 步骤可以上报事件")
_assert_current(step, host.agent_id or "", attempt)
for index, item in enumerate(events):
await append_event(
session,
run,
EventKind.STEP_OUTPUT.value,
item.message,
source=EventSource.AGENT.value,
severity=item.severity,
payload={"_operation": "events", "attempt": attempt, **item.payload},
idempotency_key="{0}:{1}".format(idempotency_key, index),
step_id=step.id,
loop_index=step.loop_index,
)
return {**step_status(step), "accepted": len(events)}
async def _finish_run(session: AsyncSession, run: Run, verdict: str) -> None:
await append_event(
session,
run,
EventKind.LOCK_RELEASED.value,
"DUT 与测试主机独占锁已释放",
)
await session.execute(delete(ResourceLock).where(ResourceLock.run_id == run.id))
await append_event(
session,
run,
EventKind.RUN_COMPLETED.value,
"独立 Host Agent 已完成全部步骤,结论 {0}".format(verdict),
target_state=RunState.COMPLETED.value,
projection={"verdict": verdict, "ended_at": utcnow()},
)
bundle = await build_evidence_bundle(session, run)
await append_event(
session,
run,
EventKind.EVIDENCE_BUNDLED.value,
"Evidence Bundle 已生成,完整率 100%",
payload={"bundle_id": bundle.id, "uri": bundle.uri, "completeness": 1.0},
)
async def complete_step(
session: AsyncSession,
host: TestHost,
step_id: str,
body: AgentStepCompleteBody,
idempotency_key: str,
) -> Dict[str, Any]:
step, run = await _owned_step(session, host, step_id)
if await _replayed(
session,
run_id=run.id,
step_id=step.id,
idempotency_key=idempotency_key,
operation="complete",
):
return step_status(step, replayed=True)
if step.state != StepState.RUNNING.value:
raise StepLeaseConflict("只有 RUNNING 步骤可以完成")
_assert_current(step, host.agent_id or "", body.attempt)
step.exit_code = body.exit_code
step.error_class = body.error_class
step.result = body.result
step.log_uri = body.log_uri
step.ended_at = utcnow()
step.leased_by = None
step.lease_expires_at = None
succeeded = body.status == StepState.SUCCEEDED.value
step.state = StepState.SUCCEEDED.value if succeeded else StepState.FAILED.value
await append_event(
session,
run,
EventKind.STEP_SUCCEEDED.value if succeeded else EventKind.STEP_FAILED.value,
"步骤执行成功" if succeeded else "步骤执行失败",
source=EventSource.AGENT.value,
severity=Severity.INFO.value if succeeded else Severity.ERROR.value,
payload={
"_operation": "complete",
"attempt": body.attempt,
"exit_code": body.exit_code,
"error_class": body.error_class,
"result": body.result,
"log_uri": body.log_uri,
},
idempotency_key=idempotency_key,
step_id=step.id,
loop_index=step.loop_index,
)
if not succeeded:
if (
step.on_fail == OnFail.RETRY.value
and step.idempotent
and step.attempt <= step.max_retry
):
step.state = StepState.PENDING.value
await append_event(
session,
run,
EventKind.STEP_RETRYING.value,
"幂等步骤失败,已进入受限重试队列",
severity=Severity.WARNING.value,
payload={"attempt": step.attempt, "max_retry": step.max_retry},
step_id=step.id,
loop_index=step.loop_index,
)
return step_status(step)
if step.on_fail in {OnFail.FREEZE.value, OnFail.RECOVER.value} or not step.idempotent:
await _freeze_run(
session,
run,
step,
"步骤 {0} 失败且不可安全自动重跑,已冻结现场".format(step.step_key),
)
return step_status(step)
if step.on_fail == OnFail.FAIL_RUN.value:
await _finish_run(session, run, Verdict.FAIL.value)
return step_status(step)
remaining = await session.scalar(
select(func.count())
.select_from(RunStep)
.where(RunStep.run_id == run.id, RunStep.state.in_(_ACTIVE_STEP_STATES))
)
if succeeded and step.checkpoint_after:
next_step = await session.scalar(
select(RunStep)
.where(RunStep.run_id == run.id, RunStep.seq > step.seq)
.order_by(RunStep.seq)
.limit(1)
)
checkpoint = {
"loop_index": step.loop_index,
"completed_step_key": step.step_key,
"next_step_key": next_step.step_key if next_step else None,
}
loop_done = max(run.loop_done, step.loop_index or run.loop_done)
await append_event(
session,
run,
EventKind.CHECKPOINT_SAVED.value,
"安全检查点已保存",
payload=checkpoint,
projection={"checkpoint": checkpoint, "loop_done": loop_done},
step_id=step.id,
loop_index=step.loop_index,
)
if not remaining:
failed = await session.scalar(
select(func.count())
.select_from(RunStep)
.where(RunStep.run_id == run.id, RunStep.state == StepState.FAILED.value)
)
await _finish_run(
session,
run,
Verdict.FAIL.value if failed else Verdict.PASS.value,
)
return step_status(step)
@@ -0,0 +1,19 @@
# 控制平面依赖。刻意保持短——每加一个依赖,客户内网私有化部署就多一分摩擦。
fastapi>=0.115,<1.0
uvicorn[standard]>=0.30
sqlalchemy>=2.0.30
greenlet>=3.0 # SQLAlchemy 异步会话的协程桥接
aiosqlite>=0.20 # 开发态数据库
pydantic>=2.9,<3.0
pyyaml>=6.0 # 工作流定义
httpx>=0.27 # 测试客户端 + Agent 侧复用
python-dotenv>=1.0
# 生产(docker-compose)才需要,本地开发可不装:
# asyncpg>=0.29
# redis>=5.0
# boto3>=1.34
# 开发/测试
pytest>=8.0
pytest-asyncio>=0.24
@@ -0,0 +1,24 @@
from fastapi import HTTPException
import pytest
from flashops_control.api.agents import _check_enrollment_token
from flashops_control.config import reset_settings
def test_production_agent_enrollment_is_fail_closed(monkeypatch):
monkeypatch.setenv("FLASHOPS_ENV", "production")
monkeypatch.delenv("FLASHOPS_AGENT_ENROLLMENT_TOKEN", raising=False)
reset_settings()
with pytest.raises(HTTPException) as missing:
_check_enrollment_token(None)
assert missing.value.status_code == 503
monkeypatch.setenv("FLASHOPS_AGENT_ENROLLMENT_TOKEN", "enroll-secret")
reset_settings()
with pytest.raises(HTTPException) as invalid:
_check_enrollment_token("wrong")
assert invalid.value.status_code == 401
_check_enrollment_token("enroll-secret")
# Leave the global cache empty so later tests read the restored environment.
reset_settings()
@@ -0,0 +1,53 @@
import datetime as dt
from flashops_control.config import TimingPolicy
from flashops_control.enums import HostStatus
from flashops_control.services.agent_status import projected_agent_status
NOW = dt.datetime(2026, 7, 27, 12, 0, 0)
TIMING = TimingPolicy(
heartbeat_interval_s=5,
heartbeat_degraded_after_s=15,
heartbeat_lost_after_s=30,
)
def test_fresh_agent_heartbeat_is_online():
status = projected_agent_status(
NOW - dt.timedelta(seconds=14),
HostStatus.ONLINE.value,
now=NOW,
timing=TIMING,
)
assert status == HostStatus.ONLINE.value
def test_late_agent_heartbeat_is_degraded():
status = projected_agent_status(
NOW - dt.timedelta(seconds=15),
HostStatus.ONLINE.value,
now=NOW,
timing=TIMING,
)
assert status == HostStatus.DEGRADED.value
def test_lost_agent_heartbeat_is_offline():
status = projected_agent_status(
NOW - dt.timedelta(seconds=30),
HostStatus.DEGRADED.value,
now=NOW,
timing=TIMING,
)
assert status == HostStatus.OFFLINE.value
def test_fresh_unhealthy_agent_stays_degraded_until_healthy_report():
status = projected_agent_status(
NOW,
HostStatus.DEGRADED.value,
now=NOW,
timing=TIMING,
)
assert status == HostStatus.DEGRADED.value
@@ -0,0 +1,413 @@
import os
import time
import uuid
from pathlib import Path
TEST_DB = Path("/tmp/flashops-e2e-{0}.db".format(os.getpid()))
TEST_OBJECTS = Path("/tmp/flashops-e2e-objects-{0}".format(os.getpid()))
os.environ["FLASHOPS_DATABASE_URL"] = "sqlite+aiosqlite:///" + str(TEST_DB)
os.environ["FLASHOPS_OBJECT_STORE_URL"] = str(TEST_OBJECTS)
os.environ["FLASHOPS_TIME_SCALE"] = "0.01"
from fastapi.testclient import TestClient
from flashops_control.config import reset_settings
from flashops_control.config import get_settings
from flashops_control.main import app
def setup_module():
reset_settings()
TEST_DB.unlink(missing_ok=True)
def teardown_module():
TEST_DB.unlink(missing_ok=True)
def test_demo_run_recovers_and_builds_evidence():
with TestClient(app) as client:
health = client.get("/api/v1/health")
assert health.status_code == 200
preflight = client.post("/api/v1/preflight", json={})
assert preflight.status_code == 200
assert preflight.json()["passed"] is True
assert len(preflight.json()["checks"]) == 6
created = client.post(
"/api/v1/runs",
json={"loops": 4, "inject_failure": True, "created_by": "pytest"},
)
assert created.status_code == 201
run_id = created.json()["id"]
run = None
for _ in range(100):
response = client.get("/api/v1/runs/" + run_id)
assert response.status_code == 200
run = response.json()
if run["state"] == "COMPLETED":
break
time.sleep(0.02)
assert run is not None
assert run["state"] == "COMPLETED"
assert run["verdict"] == "PASS"
assert run["loop_done"] == 4
assert run["recovery_count"] == 3
assert run["evidence"]["completeness"] == 1.0
assert Path(run["evidence"]["uri"], "manifest.json").exists()
events = client.get("/api/v1/runs/{0}/events".format(run_id)).json()
kinds = [event["kind"] for event in events]
assert [event["seq"] for event in events] == list(range(1, len(events) + 1))
assert "HEARTBEAT_LOST" in kinds
assert kinds.count("RECOVERY_STARTED") == 3
assert "RECOVERY_SUCCEEDED" in kinds
assert kinds[-1] == "RUN_COMPLETED"
def test_mismatched_serial_is_rejected_before_run_creation():
with TestClient(app) as client:
response = client.post(
"/api/v1/preflight", json={"discovered_serial": "SYSTEM-DISK"}
)
assert response.status_code == 200
payload = response.json()
assert payload["passed"] is False
serial = next(check for check in payload["checks"] if check["key"] == "serial")
assert serial["passed"] is False
def test_pause_resume_and_emergency_stop_are_audited():
with TestClient(app) as client:
created = client.post(
"/api/v1/runs",
json={"loops": 50, "inject_failure": False, "created_by": "pytest-actions"},
)
assert created.status_code == 201
run_id = created.json()["id"]
run = created.json()
for _ in range(50):
run = client.get("/api/v1/runs/" + run_id).json()
if run["state"] == "RUNNING":
break
time.sleep(0.01)
assert run["state"] == "RUNNING"
paused = client.post(
"/api/v1/runs/{0}/pause".format(run_id),
json={"actor": "pytest", "reason": "检查暂停"},
)
assert paused.status_code == 200
assert paused.json()["state"] == "PAUSED"
resumed = client.post(
"/api/v1/runs/{0}/resume".format(run_id),
json={"actor": "pytest", "reason": "检查继续"},
)
assert resumed.status_code == 200
assert resumed.json()["state"] == "RUNNING"
stopped = client.post(
"/api/v1/runs/{0}/emergency-stop".format(run_id),
json={"actor": "pytest", "reason": "检查急停"},
)
assert stopped.status_code == 200
assert stopped.json()["state"] == "ABORTED"
assert stopped.json()["human_touches"] == 3
events = client.get("/api/v1/runs/{0}/events".format(run_id)).json()
kinds = [event["kind"] for event in events]
assert "RUN_PAUSED" in kinds
assert "RUN_RESUMED" in kinds
assert kinds[-1] == "RUN_ABORTED"
def test_agent_registration_heartbeat_and_token_rotation():
with TestClient(app) as client:
registered = client.post(
"/api/v1/agent/register",
json={
"host_name": "WS-05",
"agent_version": "0.1.0-test",
"os_family": "linux",
"os_version": "pytest",
"kernel": "test-kernel",
"ip_address": "192.0.2.50",
"toolchain": {"fio": "fio-3.36"},
"labels": {"system_device_paths": ["/dev/nvme0n1"]},
},
)
assert registered.status_code == 201
credentials = registered.json()
assert credentials["agent_id"].startswith("agent_")
assert credentials["host_id"]
assert len(credentials["token"]) >= 32
missing_token = client.post(
"/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]),
json={"healthy": True},
)
assert missing_token.status_code == 401
heartbeat = client.post(
"/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]),
headers={"Authorization": "Bearer " + credentials["token"]},
json={
"healthy": True,
"agent_version": "0.1.1-test",
"device_probe": {"nvme": ["nvme1n1"]},
},
)
assert heartbeat.status_code == 200
assert heartbeat.json()["ok"] is True
assert heartbeat.json()["commands"] == []
rotated = client.post(
"/api/v1/agent/register",
json={
"host_id": credentials["host_id"],
"agent_version": "0.1.1-test",
},
)
assert rotated.status_code == 201
rotated_credentials = rotated.json()
assert rotated_credentials["agent_id"] == credentials["agent_id"]
assert rotated_credentials["token"] != credentials["token"]
old_token = client.post(
"/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]),
headers={"Authorization": "Bearer " + credentials["token"]},
json={"healthy": True},
)
assert old_token.status_code == 401
new_token = client.post(
"/api/v1/agent/{0}/heartbeat".format(credentials["agent_id"]),
headers={"Authorization": "Bearer " + rotated_credentials["token"]},
json={"healthy": False},
)
assert new_token.status_code == 200
hosts = client.get("/api/v1/agent/hosts")
assert hosts.status_code == 200
current = next(
item for item in hosts.json() if item["host_id"] == credentials["host_id"]
)
assert current["agent_id"] == credentials["agent_id"]
assert current["status"] == "DEGRADED"
assert "token" not in current
def _register_seed_agent(client):
response = client.post(
"/api/v1/agent/register",
json={"host_name": "WS-05", "agent_version": "0.2.0-pytest"},
)
assert response.status_code == 201
return response.json()
def _idem_headers(credentials, key=None):
return {
"Authorization": "Bearer " + credentials["token"],
"Idempotency-Key": key or str(uuid.uuid4()),
}
def _lease(client, credentials):
return client.post(
"/api/v1/agent/{0}/lease".format(credentials["agent_id"]),
headers={"Authorization": "Bearer " + credentials["token"]},
json={"wait_timeout_s": 0},
)
def _ack(client, credentials, lease, key=None):
return client.post(
"/api/v1/agent/{0}/steps/{1}/ack".format(
credentials["agent_id"], lease["step_id"]
),
headers=_idem_headers(credentials, key),
json={"attempt": lease["attempt"]},
)
def _complete(client, credentials, lease, key=None):
return client.post(
"/api/v1/agent/{0}/steps/{1}/complete".format(
credentials["agent_id"], lease["step_id"]
),
headers=_idem_headers(credentials, key),
json={
"attempt": lease["attempt"],
"status": "SUCCEEDED",
"exit_code": 0,
"result": {"simulated": True},
},
)
def test_agent_step_lease_idempotency_and_full_dry_run():
with TestClient(app) as client:
credentials = _register_seed_agent(client)
created = client.post(
"/api/v1/runs",
json={
"loops": 2,
"inject_failure": False,
"execution_mode": "agent_dry_run",
"created_by": "pytest-agent-lease",
},
)
assert created.status_code == 201
run_id = created.json()["id"]
assert created.json()["state"] == "RUNNING"
assert created.json()["execution_mode"] == "agent_dry_run"
unauthorized = client.post(
"/api/v1/agent/{0}/lease".format(credentials["agent_id"]),
json={"wait_timeout_s": 0},
)
assert unauthorized.status_code == 401
first = _lease(client, credentials)
assert first.status_code == 200
lease = first.json()
assert lease["step_key"] == "flash-fw-b"
assert lease["attempt"] == 1
assert lease["dry_run"] is True
ack_key = str(uuid.uuid4())
acked = _ack(client, credentials, lease, ack_key)
assert acked.status_code == 200
assert acked.json()["state"] == "RUNNING"
replayed_ack = _ack(client, credentials, lease, ack_key)
assert replayed_ack.status_code == 200
assert replayed_ack.json()["replayed"] is True
renew = client.post(
"/api/v1/agent/{0}/steps/{1}/renew".format(
credentials["agent_id"], lease["step_id"]
),
headers=_idem_headers(credentials),
json={"attempt": lease["attempt"]},
)
assert renew.status_code == 200
event_key = str(uuid.uuid4())
output = client.post(
"/api/v1/agent/{0}/steps/{1}/events".format(
credentials["agent_id"], lease["step_id"]
),
headers=_idem_headers(credentials, event_key),
json={
"attempt": lease["attempt"],
"events": [
{"message": "dry-run output 1", "payload": {"line": 1}},
{"message": "dry-run output 2", "severity": "DEBUG"},
],
},
)
assert output.status_code == 200
assert output.json()["accepted"] == 2
replayed_output = client.post(
"/api/v1/agent/{0}/steps/{1}/events".format(
credentials["agent_id"], lease["step_id"]
),
headers=_idem_headers(credentials, event_key),
json={
"attempt": lease["attempt"],
"events": [
{"message": "dry-run output 1", "payload": {"line": 1}},
{"message": "dry-run output 2", "severity": "DEBUG"},
],
},
)
assert replayed_output.status_code == 200
assert replayed_output.json()["replayed"] is True
complete_key = str(uuid.uuid4())
completed = _complete(client, credentials, lease, complete_key)
assert completed.status_code == 200
replayed_complete = _complete(client, credentials, lease, complete_key)
assert replayed_complete.status_code == 200
assert replayed_complete.json()["replayed"] is True
step_count = 1
while True:
leased = _lease(client, credentials)
if leased.status_code == 204:
break
assert leased.status_code == 200
item = leased.json()
assert _ack(client, credentials, item).status_code == 200
assert _complete(client, credentials, item).status_code == 200
step_count += 1
assert step_count == 7 # flash + (3 loop-body steps * 2 loops)
run = client.get("/api/v1/runs/" + run_id).json()
assert run["state"] == "COMPLETED"
assert run["verdict"] == "PASS"
assert run["loop_done"] == 2
assert run["evidence"]["completeness"] == 1.0
events = client.get("/api/v1/runs/{0}/events".format(run_id)).json()
assert [event["seq"] for event in events] == list(range(1, len(events) + 1))
assert sum(event["kind"] == "STEP_DISPATCHED" for event in events) == 7
assert sum(event["kind"] == "STEP_SUCCEEDED" for event in events) == 7
def test_expired_running_lease_retries_only_idempotent_steps():
timing = get_settings().timing
original_ttl = timing.lease_ttl_s
object.__setattr__(timing, "lease_ttl_s", 0.01)
try:
with TestClient(app) as client:
credentials = _register_seed_agent(client)
# The first workflow step is a non-idempotent firmware action. Once
# ACKed, an expired lease must freeze the run instead of rerunning it.
created = client.post(
"/api/v1/runs",
json={"loops": 1, "execution_mode": "agent_dry_run"},
)
assert created.status_code == 201
run_id = created.json()["id"]
lease = _lease(client, credentials).json()
assert lease["idempotent"] is False
assert _ack(client, credentials, lease).status_code == 200
time.sleep(0.02)
assert _lease(client, credentials).status_code == 204
frozen = client.get("/api/v1/runs/" + run_id).json()
assert frozen["state"] == "FROZEN"
assert frozen["verdict"] == "INCONCLUSIVE"
# On another run, finish the firmware step and let the idempotent fio
# workload expire. It is safely reissued once with attempt=2.
created_retry = client.post(
"/api/v1/runs",
json={"loops": 1, "execution_mode": "agent_dry_run"},
)
assert created_retry.status_code == 201
retry_run_id = created_retry.json()["id"]
firmware = _lease(client, credentials).json()
assert _ack(client, credentials, firmware).status_code == 200
assert _complete(client, credentials, firmware).status_code == 200
workload = _lease(client, credentials).json()
assert workload["step_key"] == "workload"
assert workload["idempotent"] is True
assert _ack(client, credentials, workload).status_code == 200
time.sleep(0.02)
retried = _lease(client, credentials)
assert retried.status_code == 200
assert retried.json()["step_id"] == workload["step_id"]
assert retried.json()["attempt"] == 2
stopped = client.post(
"/api/v1/runs/{0}/emergency-stop".format(retry_run_id),
json={"actor": "pytest", "reason": "清理重试场景"},
)
assert stopped.status_code == 200
finally:
object.__setattr__(timing, "lease_ttl_s", original_ttl)
@@ -0,0 +1,40 @@
from flashops_control.engine.recovery import RecoveryContext, next_recovery_level
from flashops_control.enums import RecoveryLevel, RecoveryTrigger
def test_integrity_failure_always_freezes():
level = next_recovery_level(
RecoveryContext(
trigger=RecoveryTrigger.DATA_INTEGRITY,
attempt=0,
agent_online=True,
host_network_online=True,
oob_online=True,
)
)
assert level == RecoveryLevel.FREEZE
def test_offline_oob_removes_destructive_recovery_levels():
level = next_recovery_level(
RecoveryContext(
trigger=RecoveryTrigger.HEARTBEAT_LOST,
attempt=2,
agent_online=True,
host_network_online=True,
oob_online=False,
)
)
assert level == RecoveryLevel.FREEZE
def test_recovery_escalates_in_order():
context = dict(
trigger=RecoveryTrigger.HEARTBEAT_LOST,
agent_online=True,
host_network_online=True,
oob_online=True,
)
assert next_recovery_level(RecoveryContext(attempt=0, **context)) == RecoveryLevel.AGENT_SOFT
assert next_recovery_level(RecoveryContext(attempt=1, **context)) == RecoveryLevel.OS_REBOOT
assert next_recovery_level(RecoveryContext(attempt=2, **context)) == RecoveryLevel.OOB_RESET
@@ -0,0 +1,39 @@
from flashops_control.safety import PreflightRequest, run_preflight
def valid_request(**overrides):
values = {
"expected_serial": "DUT-001",
"discovered_serial": "DUT-001",
"allow_destructive": True,
"device_path": "/dev/nvme1n1",
"system_device_paths": ["/dev/nvme0n1", "/dev/nvme0n1p2"],
"firmware_model_allowed": True,
"host_online": True,
"oob_online": True,
}
values.update(overrides)
return PreflightRequest(**values)
def test_all_safety_gates_pass_for_bound_test_disk():
result = run_preflight(valid_request())
assert result.passed is True
assert len(result.checks) == 6
def test_serial_mismatch_is_rejected():
result = run_preflight(valid_request(discovered_serial="WRONG-DISK"))
assert result.passed is False
assert next(check for check in result.checks if check.key == "serial").passed is False
def test_system_partition_is_rejected():
result = run_preflight(valid_request(device_path="/dev/nvme0n1p2"))
assert result.passed is False
assert next(check for check in result.checks if check.key == "system_disk").passed is False
def test_destructive_opt_in_is_required():
result = run_preflight(valid_request(allow_destructive=False))
assert result.passed is False
@@ -0,0 +1,22 @@
import pytest
from flashops_control.engine.states import IllegalTransition, assert_run_transition
from flashops_control.enums import RunState
def test_happy_path_transitions_are_allowed():
assert_run_transition(RunState.QUEUED.value, RunState.PREFLIGHT.value)
assert_run_transition(RunState.PREFLIGHT.value, RunState.RUNNING.value)
assert_run_transition(RunState.RUNNING.value, RunState.RECOVERING.value)
assert_run_transition(RunState.RECOVERING.value, RunState.RUNNING.value)
assert_run_transition(RunState.RUNNING.value, RunState.COMPLETED.value)
def test_terminal_state_cannot_transition():
with pytest.raises(IllegalTransition):
assert_run_transition(RunState.COMPLETED.value, RunState.RUNNING.value)
def test_rejected_cannot_be_resumed():
with pytest.raises(IllegalTransition):
assert_run_transition(RunState.REJECTED.value, RunState.QUEUED.value)
+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]