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