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