946 lines
33 KiB
Python
946 lines
33 KiB
Python
"""
|
|
backend/main.py — the real Fester FastAPI application.
|
|
|
|
Wires all routers, the WebSocket stream, and the singleton bus + registry.
|
|
Run with:
|
|
python -m backend.main
|
|
or:
|
|
uvicorn backend.main:app --host 0.0.0.0 --port 8080
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import shutil
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Body
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
|
|
# Singletons (must be created BEFORE routers that use them)
|
|
from backend.api.api import bus, registry, observability, cause_graph, attach_ws_stream
|
|
from backend.api.ws import WebSocketStream
|
|
|
|
# Routers
|
|
from backend.api.replay import router as replay_router
|
|
from backend.api.autopsy import router as autopsy_router
|
|
from backend.api.timeline import router as timeline_router
|
|
from backend.api.debugger import router as debugger_router, set_engine as dbg_set_engine
|
|
from backend.api.pipeline_control import router as pipeline_router, set_engine as pc_set_engine
|
|
from backend.api.nodes import router as nodes_router
|
|
from backend.api.metrics import router as metrics_router
|
|
from backend.api.cause import router as cause_router
|
|
|
|
# Other deps
|
|
from backend.events.schema import EventType
|
|
from backend.analysis.timeline_store import STORE as TIMELINE_STORE
|
|
from backend.analysis.failure_propagation import FailurePropagator
|
|
from backend.config import CONFIG
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# App
|
|
# ----------------------------------------------------------------
|
|
ROOT = Path(__file__).resolve().parent.parent # /home/z/my-project/fester
|
|
|
|
app = FastAPI(
|
|
title="Fester",
|
|
description="Distributed build + scheduler + observability system",
|
|
version="0.2.0",
|
|
)
|
|
|
|
# Attach WS stream to bus
|
|
ws_stream = WebSocketStream()
|
|
attach_ws_stream(ws_stream)
|
|
|
|
# Wire a timeline store subscriber so every bus event lands in the journal
|
|
def _timeline_subscriber(event: Dict[str, Any]):
|
|
TIMELINE_STORE.append(event)
|
|
|
|
bus.subscribe(_timeline_subscriber)
|
|
|
|
# Wire a failure propagator so we can compute blast radius later
|
|
_propagator = FailurePropagator()
|
|
bus.subscribe(_propagator.ingest_event)
|
|
|
|
# Wire metrics counters so /metrics reflects real activity
|
|
from backend.api.metrics import record_pipeline_action, record_cache_hit
|
|
|
|
def _metrics_subscriber(event: Dict[str, Any]):
|
|
"""Increment counters for task_update + cache_update events."""
|
|
etype = event.get("type")
|
|
if etype == "task_update":
|
|
state = event.get("state")
|
|
if state:
|
|
record_pipeline_action(state)
|
|
elif etype == "cache_update":
|
|
# Cache hit: try meta.target first, then top-level target, then "unknown"
|
|
target = event.get("target") or (event.get("meta", {}) or {}).get("target") or "unknown"
|
|
if event.get("state") == "hit":
|
|
record_cache_hit(target)
|
|
|
|
bus.subscribe(_metrics_subscriber)
|
|
|
|
# ----------------------------------------------------------------
|
|
# Build history (in-memory; replace with DB later)
|
|
# ----------------------------------------------------------------
|
|
# Build history lives in build_runner (defined below).
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Static UI (mount LAST so it doesn't shadow API routes)
|
|
# ----------------------------------------------------------------
|
|
@app.get("/")
|
|
@app.get("/index.html")
|
|
async def index():
|
|
return FileResponse(ROOT / "index.html")
|
|
|
|
|
|
@app.get("/style.css")
|
|
async def style_css():
|
|
return FileResponse(ROOT / "style.css", media_type="text/css")
|
|
|
|
|
|
@app.get("/ui/app.js")
|
|
async def app_js():
|
|
return FileResponse(ROOT / "ui" / "app.js", media_type="application/javascript")
|
|
|
|
|
|
@app.get("/ui/{name}.html")
|
|
async def ui_page(name: str):
|
|
p = ROOT / "ui" / f"{name}.html"
|
|
if not p.exists() or not p.is_file():
|
|
raise HTTPException(404)
|
|
return FileResponse(p, media_type="text/html")
|
|
|
|
|
|
# Mount cockpit folder (for the cockpit module)
|
|
app.mount("/cockpit", StaticFiles(directory=str(ROOT / "cockpit")), name="cockpit")
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Include API routers
|
|
# ----------------------------------------------------------------
|
|
app.include_router(replay_router)
|
|
app.include_router(autopsy_router)
|
|
app.include_router(timeline_router)
|
|
app.include_router(debugger_router)
|
|
app.include_router(pipeline_router)
|
|
app.include_router(nodes_router)
|
|
app.include_router(metrics_router)
|
|
app.include_router(cause_router)
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Build runner — real engine + synthetic fallback
|
|
# ----------------------------------------------------------------
|
|
from backend.pipeline.runner import BuildRunner
|
|
|
|
# Nodes for the runner come from CONFIG; the registry is the singleton.
|
|
_runner_nodes = [{"name": n["name"], "host": n.get("host"), "max_jobs": n.get("max_jobs", 8),
|
|
"state": "online"} for n in CONFIG.get("nodes", [])]
|
|
build_runner = BuildRunner(bus, registry, _runner_nodes)
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Top-level endpoints that don't fit a router
|
|
# ----------------------------------------------------------------
|
|
class BuildBody(BaseModel):
|
|
cmd: str = "make -j$(nproc)"
|
|
dir: str = "/home/user/linux"
|
|
project: str | None = None
|
|
arch: str = "x86_64"
|
|
target: str = "linux-gnu"
|
|
toolchain: str = "gcc"
|
|
|
|
|
|
@app.post("/api/build")
|
|
async def api_build(body: BuildBody):
|
|
"""Kick off a build. Returns a build_id immediately; events stream via /ws."""
|
|
build_id = await build_runner.start(body)
|
|
return {"status": "started", "build_id": build_id}
|
|
|
|
|
|
@app.get("/api/builds")
|
|
async def api_builds():
|
|
"""Build history."""
|
|
return {"builds": build_runner.list_builds()}
|
|
|
|
|
|
@app.get("/api/builds/{build_id}")
|
|
async def api_build_detail(build_id: str):
|
|
state = build_runner.state(build_id)
|
|
if state:
|
|
return state
|
|
raise HTTPException(404, "build not found")
|
|
|
|
|
|
@app.post("/api/builds/{build_id}/cancel")
|
|
async def api_build_cancel(build_id: str):
|
|
if build_runner.cancel(build_id):
|
|
return {"status": "cancelled", "build_id": build_id}
|
|
raise HTTPException(404, "build not found or not running")
|
|
|
|
|
|
@app.get("/api/sessions")
|
|
async def api_sessions():
|
|
"""List replay sessions known to the server (from SessionDB)."""
|
|
from backend.api.replay import SESSION_DB
|
|
return {"sessions": SESSION_DB.list_sessions()}
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Targets API — backed by backend/targets/catalog.py + ARCHES/RUNTIMES
|
|
# ----------------------------------------------------------------
|
|
from backend.targets.catalog import TARGET_SYSTEMS, ARCHES, RUNTIMES
|
|
|
|
|
|
def _build_targets_catalog():
|
|
"""Combine catalog target systems + arches into a unified toggle list."""
|
|
out = []
|
|
# Per-arch cross-compile targets (always present)
|
|
arch_defaults = {
|
|
"x86_64": True, # most common, default-on
|
|
"aarch64": True,
|
|
"riscv64": False,
|
|
"arm64": True,
|
|
}
|
|
for arch in ARCHES:
|
|
out.append({
|
|
"name": f"{arch}-linux-gnu",
|
|
"enabled": arch_defaults.get(arch, False),
|
|
"arch": arch,
|
|
"type": "cross-compile",
|
|
"source": "arch",
|
|
})
|
|
# Per-target-system entries (Gentoo, Buildroot, OpenWrt, etc.)
|
|
for tname, tinfo in TARGET_SYSTEMS.items():
|
|
out.append({
|
|
"name": tname,
|
|
"enabled": False, # opt-in
|
|
"type": tinfo.get("type", "unknown"),
|
|
"toolchain": tinfo.get("toolchain"),
|
|
"supports_cross": tinfo.get("supports_cross", False),
|
|
"source": "catalog",
|
|
})
|
|
return out
|
|
|
|
|
|
TARGETS_CATALOG = _build_targets_catalog()
|
|
|
|
|
|
@app.get("/api/targets")
|
|
async def api_targets():
|
|
"""List all known build targets (arches + catalog systems)."""
|
|
return TARGETS_CATALOG
|
|
|
|
|
|
@app.get("/api/targets/arches")
|
|
async def api_targets_arches():
|
|
"""List supported architectures."""
|
|
return {"arches": ARCHES}
|
|
|
|
|
|
@app.get("/api/targets/runtimes")
|
|
async def api_targets_runtimes():
|
|
"""List supported execution runtimes."""
|
|
return {"runtimes": RUNTIMES}
|
|
|
|
|
|
@app.post("/api/targets/{name}")
|
|
async def api_toggle_target(name: str, body: Dict[str, Any] = Body(default={})):
|
|
for t in TARGETS_CATALOG:
|
|
if t["name"] == name:
|
|
t["enabled"] = bool(body.get("enabled", False))
|
|
# Broadcast to ws-targets subscribers
|
|
await _broadcast_targets({
|
|
"type": "target-update",
|
|
"data": {"name": name, "enabled": t["enabled"]},
|
|
})
|
|
return {"status": "ok", "name": name, "enabled": t["enabled"]}
|
|
raise HTTPException(404, "target not found")
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Release API
|
|
# ----------------------------------------------------------------
|
|
class ReleaseBody(BaseModel):
|
|
repo: str = "https://forgejo.local/project.git"
|
|
project: str | None = None
|
|
target: str = "linux-gnu"
|
|
|
|
|
|
@app.post("/api/release")
|
|
async def api_release(body: ReleaseBody):
|
|
"""Kick off a release build. Mirrors /api/build but tags the build_id
|
|
with a 'release-' prefix and uses a longer release-shaped pipeline."""
|
|
release_id = str(uuid.uuid4())[:8]
|
|
build_id = f"release-{release_id}"
|
|
|
|
# Construct a release-shaped BuildBody and dispatch through BuildRunner
|
|
release_body = BuildBody(
|
|
cmd=f"make release REPO={body.repo}",
|
|
dir="/home/user/release",
|
|
project=body.project or body.repo,
|
|
arch="x86_64",
|
|
target=body.target,
|
|
toolchain="gcc",
|
|
)
|
|
actual_id = await build_runner.start(release_body)
|
|
|
|
return {
|
|
"status": "started",
|
|
"repo": body.repo,
|
|
"release_id": release_id,
|
|
"build_id": actual_id,
|
|
"pipeline": ["fetch_source", "configure", "build_kernel", "build_modules",
|
|
"package", "release"],
|
|
}
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Policy API — real implementation backed by an in-memory policy store
|
|
# ----------------------------------------------------------------
|
|
from backend.policy.engine import PolicyEngine
|
|
from backend.api.nodes import PolicyBody as NodePolicyBody # reuse for top-level policy too
|
|
|
|
# Singleton policy engine (no DB learning for now — just key/value overrides)
|
|
POLICY_ENGINE = PolicyEngine(db=None)
|
|
_POLICY_STORE: Dict[str, Any] = {} # simple key→value overrides
|
|
|
|
|
|
# Pydantic model for /api/policy/set
|
|
class PolicyBody(BaseModel):
|
|
key: str
|
|
value: Any
|
|
|
|
|
|
@app.post("/api/policy/set")
|
|
async def api_policy_set(body: PolicyBody):
|
|
"""Set a policy key=value. Currently understood keys:
|
|
- scheduler.mode (unified|weighted-thermal-aware|cache-first|target-isolated|experimental-intelligence)
|
|
- scheduler.thermal_cap (float, 0..1)
|
|
- scheduler.cache_first (bool)
|
|
- build.default_arch (string)
|
|
Other keys are stored but not yet acted upon.
|
|
"""
|
|
_POLICY_STORE[body.key] = body.value
|
|
bus.emit(EventType.PIPELINE_UPDATE, state="policy",
|
|
meta={"key": body.key, "value": body.value, "action": "set"})
|
|
return {"status": "ok", "key": body.key, "value": body.value,
|
|
"stored_keys": list(_POLICY_STORE.keys())}
|
|
|
|
|
|
@app.post("/api/policy/clear")
|
|
async def api_policy_clear(body: Dict[str, Any] = Body(default={})):
|
|
key = body.get("key")
|
|
if key and key in _POLICY_STORE:
|
|
del _POLICY_STORE[key]
|
|
bus.emit(EventType.PIPELINE_UPDATE, state="policy",
|
|
meta={"key": key, "action": "clear"})
|
|
return {"status": "cleared", "key": key, "remaining_keys": list(_POLICY_STORE.keys())}
|
|
|
|
|
|
@app.get("/api/policy")
|
|
async def api_policy_list():
|
|
"""List all current policy overrides."""
|
|
return {"policies": _POLICY_STORE}
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Failure propagation (blast radius)
|
|
# ----------------------------------------------------------------
|
|
@app.get("/api/propagation/{action}")
|
|
async def api_propagation(action: str):
|
|
"""Compute the downstream blast radius if `action` fails."""
|
|
return _propagator.impact_report(action)
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Sessions attach/detach (for the CLI / IDE feature)
|
|
# ----------------------------------------------------------------
|
|
@app.post("/api/session/attach")
|
|
async def api_session_attach(body: Dict[str, Any] = Body(default={})):
|
|
return {"status": "attached", "action": body.get("action")}
|
|
|
|
|
|
@app.post("/api/session/detach")
|
|
async def api_session_detach(body: Dict[str, Any] = Body(default={})):
|
|
return {"status": "detached", "action": body.get("action")}
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# WebSocket endpoints
|
|
# ----------------------------------------------------------------
|
|
class WSHub:
|
|
def __init__(self):
|
|
self.clients: List[WebSocket] = []
|
|
self.targets_clients: List[WebSocket] = []
|
|
self.debugger_clients: List[WebSocket] = []
|
|
|
|
async def accept(self, ws: WebSocket, kind: str = "main"):
|
|
await ws.accept()
|
|
if kind == "main":
|
|
self.clients.append(ws)
|
|
elif kind == "targets":
|
|
self.targets_clients.append(ws)
|
|
elif kind == "debugger":
|
|
self.debugger_clients.append(ws)
|
|
|
|
|
|
hub = WSHub()
|
|
|
|
# Wire ws_stream (singleton) to broadcast to /ws clients.
|
|
# We can't await inside the sync bus.emit, so we schedule sends on the
|
|
# running loop. If there's no running loop (called from sync context),
|
|
# we silently drop — the WS hub is async-only.
|
|
def _ws_broadcast(event: Dict[str, Any]):
|
|
payload = json.dumps(event)
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
return # no running loop; skip
|
|
for c in list(hub.clients):
|
|
try:
|
|
loop.create_task(c.send_text(payload))
|
|
except Exception:
|
|
pass
|
|
|
|
ws_stream.clients = hub.clients # share the list
|
|
bus.subscribe(_ws_broadcast)
|
|
|
|
|
|
async def _broadcast_targets(event: Dict[str, Any]):
|
|
payload = json.dumps(event)
|
|
dead = []
|
|
for c in hub.targets_clients:
|
|
try:
|
|
await c.send_text(payload)
|
|
except Exception:
|
|
dead.append(c)
|
|
for d in dead:
|
|
if d in hub.targets_clients:
|
|
hub.targets_clients.remove(d)
|
|
|
|
|
|
@app.websocket("/ws")
|
|
async def ws_main(ws: WebSocket):
|
|
await hub.accept(ws, "main")
|
|
try:
|
|
await ws.send_text(json.dumps({
|
|
"type": "session",
|
|
"timestamp": time.time(),
|
|
"data": {"action": "subscribe", "id": "live"},
|
|
}))
|
|
while True:
|
|
await ws.receive_text()
|
|
except WebSocketDisconnect:
|
|
if ws in hub.clients:
|
|
hub.clients.remove(ws)
|
|
|
|
|
|
@app.websocket("/ws-targets")
|
|
async def ws_targets(ws: WebSocket):
|
|
await hub.accept(ws, "targets")
|
|
try:
|
|
while True:
|
|
await ws.receive_text()
|
|
except WebSocketDisconnect:
|
|
if ws in hub.targets_clients:
|
|
hub.targets_clients.remove(ws)
|
|
|
|
|
|
@app.websocket("/ws-debugger")
|
|
async def ws_debugger(ws: WebSocket):
|
|
"""Real debugger WS — responds to step/pause/resume + streams timeline state."""
|
|
await hub.accept(ws, "debugger")
|
|
try:
|
|
# Send initial state on connect
|
|
eng = build_runner.get_engine()
|
|
active = eng is not None
|
|
await ws.send_text(json.dumps({
|
|
"type": "timeline-update",
|
|
"data": {
|
|
"events": _debugger_timeline_snapshot(),
|
|
"current": _debugger_current(),
|
|
"engine_attached": active,
|
|
},
|
|
}))
|
|
|
|
while True:
|
|
msg = await ws.receive_text()
|
|
try:
|
|
cmd = json.loads(msg)
|
|
except Exception:
|
|
continue
|
|
|
|
action = cmd.get("action")
|
|
eng = build_runner.get_engine()
|
|
|
|
if action == "pause":
|
|
if eng and hasattr(eng, "pause"):
|
|
eng.pause()
|
|
from backend.api.debugger import _DEBUG_STATE
|
|
_DEBUG_STATE["paused"] = True
|
|
response = {"type": "state-change", "data": {"state": "paused"}}
|
|
elif action == "resume":
|
|
if eng and hasattr(eng, "resume"):
|
|
eng.resume()
|
|
from backend.api.debugger import _DEBUG_STATE
|
|
_DEBUG_STATE["paused"] = False
|
|
response = {"type": "state-change", "data": {"state": "running"}}
|
|
elif action == "step_forward" or action == "step":
|
|
if eng and hasattr(eng, "step"):
|
|
eng.step()
|
|
from backend.api.debugger import _DEBUG_STATE
|
|
_DEBUG_STATE["current_step"] += 1
|
|
response = {"type": "state-change",
|
|
"data": {"state": "stepped", "step": _DEBUG_STATE["current_step"]}}
|
|
elif action == "step_back":
|
|
from backend.api.debugger import _DEBUG_STATE
|
|
if _DEBUG_STATE["current_step"] > 0:
|
|
_DEBUG_STATE["current_step"] -= 1
|
|
response = {"type": "state-change",
|
|
"data": {"state": "stepped_back", "step": _DEBUG_STATE["current_step"]}}
|
|
else:
|
|
response = {"type": "error", "data": {"error": f"unknown action: {action}"}}
|
|
|
|
# Augment with the latest timeline state
|
|
response["timeline"] = _debugger_timeline_snapshot()
|
|
response["current"] = _debugger_current()
|
|
await ws.send_text(json.dumps(response))
|
|
except WebSocketDisconnect:
|
|
if ws in hub.debugger_clients:
|
|
hub.debugger_clients.remove(ws)
|
|
|
|
|
|
def _debugger_timeline_snapshot():
|
|
"""Return a list of {name, completed, node, state} for the debugger UI."""
|
|
events = TIMELINE_STORE.all()
|
|
seen = {}
|
|
for e in events:
|
|
action = e.get("action")
|
|
if not action:
|
|
continue
|
|
d = e
|
|
state = d.get("state")
|
|
seen[action] = {
|
|
"name": action,
|
|
"completed": state == "done",
|
|
"state": state,
|
|
"node": d.get("node"),
|
|
"failed": state == "failed",
|
|
}
|
|
return list(seen.values())
|
|
|
|
|
|
def _debugger_current():
|
|
"""Return the most recent running or scheduled action."""
|
|
events = TIMELINE_STORE.all()
|
|
for e in reversed(events):
|
|
if e.get("state") in ("running", "scheduled"):
|
|
return {"action": e.get("action"), "state": e.get("state"),
|
|
"node": e.get("node")}
|
|
return None
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Health
|
|
# ----------------------------------------------------------------
|
|
@app.get("/api/health")
|
|
async def health():
|
|
return {
|
|
"status": "ok",
|
|
"version": "0.2.0",
|
|
"bus_subscribers": len(bus.subscribers),
|
|
"ws_clients": len(hub.clients),
|
|
"builds_known": len(build_runner.list_builds()) if build_runner else 0,
|
|
"timeline_events": TIMELINE_STORE.stats()["total_events"],
|
|
}
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Storage API — btrfs CAS, tmpfs, qcow2 snapshots
|
|
# ----------------------------------------------------------------
|
|
from backend.storage.router import (
|
|
prepare_workspace, maybe_freeze_workspace, storage_status,
|
|
update_storage_config,
|
|
)
|
|
from backend.storage.btrfs_cas import is_btrfs, store_reflink, reflink_snapshot, cas_store, cas_exists
|
|
from backend.storage.qcow2_freeze import freeze_to_qcow2, qcow2_info, list_snapshots
|
|
|
|
|
|
@app.get("/api/storage/status")
|
|
async def api_storage_status():
|
|
"""Return current storage configuration + capability detection."""
|
|
return storage_status()
|
|
|
|
|
|
@app.post("/api/storage/config")
|
|
async def api_storage_config_update(body: Dict[str, Any] = Body(default={})):
|
|
"""Update storage configuration (e.g. {btrfs: true, qcow2_freeze: true})."""
|
|
return update_storage_config(body)
|
|
|
|
|
|
@app.post("/api/storage/freeze")
|
|
async def api_storage_freeze(body: Dict[str, Any] = Body(default={})):
|
|
"""Freeze a workdir into a qcow2 snapshot.
|
|
|
|
Body: {"workdir": "/path/to/workdir", "project": "name"}"""
|
|
workdir = body.get("workdir")
|
|
project = body.get("project", "manual")
|
|
if not workdir:
|
|
raise HTTPException(400, "workdir required")
|
|
result = maybe_freeze_workspace(workdir, project)
|
|
if result is None:
|
|
return {"status": "disabled", "reason": "qcow2_freeze not enabled in config"}
|
|
return {"status": "frozen", "path": result, "project": project}
|
|
|
|
|
|
@app.get("/api/storage/snapshots")
|
|
async def api_storage_snapshots():
|
|
"""List all qcow2 snapshots."""
|
|
from backend.storage.config import load_storage_config
|
|
cfg = load_storage_config()
|
|
return {"snapshots": list_snapshots(cfg.get("qcow2_path", "/var/lib/fester/snapshots"))}
|
|
|
|
|
|
@app.post("/api/storage/reflink")
|
|
async def api_storage_reflink(body: Dict[str, Any] = Body(default={})):
|
|
"""Reflink-copy a file or directory (CoW snapshot on btrfs).
|
|
|
|
Body: {"src": "/path/to/src", "dst": "/path/to/dst"}"""
|
|
src = body.get("src")
|
|
dst = body.get("dst")
|
|
if not src or not dst:
|
|
raise HTTPException(400, "src and dst required")
|
|
if not Path(src).exists():
|
|
raise HTTPException(404, f"src not found: {src}")
|
|
|
|
from pathlib import Path as _P
|
|
if _P(src).is_dir():
|
|
stats = reflink_snapshot(src, dst)
|
|
return {"status": "ok", "mode": "directory", "stats": stats}
|
|
else:
|
|
reflinked = store_reflink(src, dst)
|
|
return {"status": "ok", "mode": "file", "reflinked": reflinked}
|
|
|
|
|
|
@app.post("/api/workspace/prepare")
|
|
async def api_workspace_prepare(body: Dict[str, Any] = Body(default={})):
|
|
"""Prepare a workspace for a build (tmpfs + optional btrfs CAS).
|
|
|
|
Body: {"project": "name"}"""
|
|
project = body.get("project", "default")
|
|
return prepare_workspace(project)
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Actions API — tmux session inspection + node shell attach
|
|
# ----------------------------------------------------------------
|
|
from backend.integrations.tmux import TmuxManager as _TmuxMgr
|
|
from backend.integrations.mosh import MoshManager as _MoshMgr
|
|
|
|
_tmux_mgr = _TmuxMgr()
|
|
_mosh_mgr = _MoshMgr()
|
|
|
|
|
|
@app.get("/api/actions/active")
|
|
async def api_actions_active():
|
|
"""List all active fester-* tmux sessions (running actions)."""
|
|
if not _tmux_mgr.available:
|
|
return {"available": False, "sessions": [], "reason": "tmux not installed"}
|
|
return {
|
|
"available": True,
|
|
"sessions": _tmux_mgr.list_fester_sessions(),
|
|
}
|
|
|
|
|
|
@app.get("/api/actions/{name}/tmux")
|
|
async def api_action_tmux_state(name: str):
|
|
"""Get state of the tmux session for a specific action."""
|
|
if not _tmux_mgr.available:
|
|
return {"available": False, "reason": "tmux not installed"}
|
|
# Try with and without build_id suffix
|
|
candidates = [f"fester-{name}"]
|
|
# Also try matching any session that starts with fester-{name}-
|
|
sessions = _tmux_mgr.list_fester_sessions()
|
|
for s in sessions:
|
|
if s["name"].startswith(f"fester-{name}-"):
|
|
candidates.append(s["name"])
|
|
for cand in candidates:
|
|
state = _tmux_mgr.session_state(cand)
|
|
if state:
|
|
output = _tmux_mgr.capture_output(cand, lines=200) or ""
|
|
return {
|
|
"available": True,
|
|
"name": cand,
|
|
"state": state,
|
|
"output_tail": output[-2000:] if output else None,
|
|
}
|
|
return {"available": True, "exists": False, "name": name}
|
|
|
|
|
|
@app.get("/api/actions/{name}/tmux/output")
|
|
async def api_action_tmux_output(name: str, lines: int = 1000):
|
|
"""Capture the last N lines of tmux pane output for an action."""
|
|
if not _tmux_mgr.available:
|
|
return {"available": False, "reason": "tmux not installed"}
|
|
# Find the session
|
|
candidates = [f"fester-{name}"]
|
|
sessions = _tmux_mgr.list_fester_sessions()
|
|
for s in sessions:
|
|
if s["name"].startswith(f"fester-{name}-"):
|
|
candidates.append(s["name"])
|
|
for cand in candidates:
|
|
if _tmux_mgr.session_exists(cand):
|
|
output = _tmux_mgr.capture_output(cand, lines=lines) or ""
|
|
return {
|
|
"available": True,
|
|
"name": cand,
|
|
"lines": len(output.split("\n")) if output else 0,
|
|
"output": output,
|
|
}
|
|
return {"available": True, "exists": False, "name": name}
|
|
|
|
|
|
@app.post("/api/actions/{name}/tmux/kill")
|
|
async def api_action_tmux_kill(name: str):
|
|
"""Kill the tmux session for an action."""
|
|
if not _tmux_mgr.available:
|
|
return {"available": False, "reason": "tmux not installed"}
|
|
candidates = [f"fester-{name}"]
|
|
sessions = _tmux_mgr.list_fester_sessions()
|
|
for s in sessions:
|
|
if s["name"].startswith(f"fester-{name}-"):
|
|
candidates.append(s["name"])
|
|
killed = []
|
|
for cand in candidates:
|
|
if _tmux_mgr.kill_session(cand):
|
|
killed.append(cand)
|
|
return {"killed": killed, "count": len(killed)}
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Node shell — mosh / ssh command builder
|
|
# ----------------------------------------------------------------
|
|
class ShellBody(BaseModel):
|
|
user: Optional[str] = None
|
|
port: Optional[int] = None
|
|
ssh_key: Optional[str] = None
|
|
method: str = "mosh" # mosh | ssh
|
|
|
|
|
|
@app.get("/api/nodes/{name}/shell")
|
|
async def api_node_shell(name: str):
|
|
"""Return the command string to attach a shell to a node (mosh or ssh).
|
|
Doesn't actually exec it — the caller's terminal does."""
|
|
# Find the node
|
|
cfg = None
|
|
for n in CONFIG.get("nodes", []):
|
|
if n["name"] == name:
|
|
cfg = n
|
|
break
|
|
if not cfg:
|
|
raise HTTPException(404, f"node {name} not configured")
|
|
host = cfg.get("host")
|
|
if not host:
|
|
raise HTTPException(400, f"node {name} has no host")
|
|
|
|
return {
|
|
"node": name,
|
|
"host": host,
|
|
"mosh_available": _mosh_mgr.available,
|
|
"ssh_available": shutil.which("ssh") is not None,
|
|
"commands": {
|
|
"mosh": f"mosh {host}",
|
|
"ssh": f"ssh {host}",
|
|
},
|
|
}
|
|
|
|
|
|
@app.post("/api/nodes/{name}/shell")
|
|
async def api_node_shell_custom(name: str, body: ShellBody):
|
|
"""Build a custom shell command for a node with user/port/ssh-key options."""
|
|
cfg = None
|
|
for n in CONFIG.get("nodes", []):
|
|
if n["name"] == name:
|
|
cfg = n
|
|
break
|
|
if not cfg:
|
|
raise HTTPException(404, f"node {name} not configured")
|
|
host = cfg.get("host")
|
|
if not host:
|
|
raise HTTPException(400, f"node {name} has no host")
|
|
|
|
target = f"{body.user}@{host}" if body.user else host
|
|
cmd = _mosh_mgr.build_command(target, port=body.port, ssh_key=body.ssh_key,
|
|
method=body.method)
|
|
return {
|
|
"node": name,
|
|
"host": host,
|
|
"method": body.method,
|
|
"command": cmd,
|
|
"copy_to_clipboard": cmd,
|
|
}
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# Ambient loop — probe real node agents, fall back to drift if unreachable.
|
|
# Set FESTER_NO_DRIFT=1 to disable synthetic drift entirely (production).
|
|
# ----------------------------------------------------------------
|
|
async def ambient_loop():
|
|
"""Probe each configured node's agent every 4s. If the agent responds,
|
|
update the registry with real metrics. If not, optionally drift the
|
|
last-known values so the UI doesn't go stale."""
|
|
import os
|
|
import random
|
|
from backend.nodes.probe import probe_async, normalize
|
|
from backend.storage.sqlite_db import STORAGE
|
|
|
|
NO_DRIFT = os.environ.get("FESTER_NO_DRIFT") == "1"
|
|
await asyncio.sleep(2)
|
|
|
|
while True:
|
|
for n in CONFIG.get("nodes", []):
|
|
name = n["name"]
|
|
host = n.get("host")
|
|
|
|
# Try real probe first
|
|
raw = await probe_async(host) if host else None
|
|
|
|
if raw is not None:
|
|
# Real agent responded — use its data
|
|
state = normalize(raw, name)
|
|
registry.update(
|
|
name,
|
|
cpu_load=state["cpu_load"],
|
|
memory_load=state["memory_load"],
|
|
temp=state["temp"],
|
|
instability=state["instability"],
|
|
active_jobs=state["active_jobs"],
|
|
last_seen=state["last_seen"],
|
|
)
|
|
cur = registry.get(name)
|
|
# Persist node state to SQLite
|
|
if STORAGE:
|
|
try:
|
|
STORAGE.save_node_state(name, {
|
|
"state": state.get("state", "online"),
|
|
"cpu_load": cur.cpu_load,
|
|
"memory_load": cur.memory_load,
|
|
"temp": cur.temp,
|
|
"instability": cur.instability,
|
|
"active_jobs": cur.active_jobs,
|
|
"max_jobs": state.get("max_jobs", n.get("max_jobs", 8)),
|
|
"last_seen": cur.last_seen,
|
|
"labels": state.get("labels", {}),
|
|
})
|
|
except Exception:
|
|
pass
|
|
# Emit with state from probe (may be "online" or "degraded")
|
|
bus.emit(
|
|
EventType.NODE_UPDATE,
|
|
node=name,
|
|
state=state.get("state", "online"),
|
|
meta={
|
|
"heat": cur.temp,
|
|
"jobs": cur.active_jobs,
|
|
"cpu_load": cur.cpu_load,
|
|
"instability": cur.instability,
|
|
"source": "agent",
|
|
},
|
|
)
|
|
elif not NO_DRIFT:
|
|
# Agent unreachable — drift the last-known values
|
|
existing = registry.get(name)
|
|
if existing is None:
|
|
# First-seen: initialize with plausible values
|
|
registry.update(
|
|
name,
|
|
cpu_load=random.uniform(20, 70),
|
|
memory_load=random.uniform(30, 60),
|
|
temp=random.uniform(40, 70),
|
|
instability=random.uniform(0, 0.15),
|
|
active_jobs=random.randint(0, max(1, n.get("max_jobs", 8) // 4)),
|
|
last_seen=time.time(),
|
|
)
|
|
else:
|
|
registry.update(
|
|
name,
|
|
cpu_load=max(0, min(100, existing.cpu_load + random.uniform(-5, 5))),
|
|
memory_load=max(0, min(100, existing.memory_load + random.uniform(-2, 2))),
|
|
temp=max(20, min(95, existing.temp + random.uniform(-3, 3))),
|
|
instability=max(0, min(1, existing.instability + random.uniform(-0.05, 0.05))),
|
|
active_jobs=max(0, min(n.get("max_jobs", 8), existing.active_jobs + random.randint(-1, 1))),
|
|
last_seen=time.time(),
|
|
)
|
|
|
|
cur = registry.get(name)
|
|
# Persist drifted state too
|
|
if STORAGE:
|
|
try:
|
|
STORAGE.save_node_state(name, {
|
|
"state": "online",
|
|
"cpu_load": cur.cpu_load,
|
|
"memory_load": cur.memory_load,
|
|
"temp": cur.temp,
|
|
"instability": cur.instability,
|
|
"active_jobs": cur.active_jobs,
|
|
"max_jobs": n.get("max_jobs", 8),
|
|
"last_seen": cur.last_seen,
|
|
"labels": {},
|
|
})
|
|
except Exception:
|
|
pass
|
|
bus.emit(
|
|
EventType.NODE_UPDATE,
|
|
node=name,
|
|
state="online",
|
|
meta={
|
|
"heat": cur.temp,
|
|
"jobs": cur.active_jobs,
|
|
"cpu_load": cur.cpu_load,
|
|
"instability": cur.instability,
|
|
"source": "drift",
|
|
},
|
|
)
|
|
# If NO_DRIFT and no agent, we simply skip — node stays offline
|
|
|
|
await asyncio.sleep(4)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def on_startup():
|
|
asyncio.create_task(ambient_loop())
|
|
# Optional: kick off a build every 60s for demo purposes.
|
|
# Disabled by default — set FESTER_AUTOBUILD=1 to enable.
|
|
import os
|
|
if os.environ.get("FESTER_AUTOBUILD") == "1":
|
|
async def looper():
|
|
await asyncio.sleep(15) # let things warm up first
|
|
while True:
|
|
await build_runner.start(
|
|
BuildBody(cmd="make -j$(nproc)", dir="/home/user/linux"),
|
|
)
|
|
await asyncio.sleep(60)
|
|
asyncio.create_task(looper())
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# CLI entrypoint
|
|
# ----------------------------------------------------------------
|
|
def main():
|
|
import uvicorn
|
|
uvicorn.run("backend.main:app", host="0.0.0.0", port=8080, reload=False)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|