feat(userbot): add Kubernetes control panel
Manage Telegram instances through Kubernetes with legacy adoption for forust and anna. Build and deploy the panel image alongside the runtime.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI, Query, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .auth_service import TelegramAuthService
|
||||
from .config import settings
|
||||
from .errors import PanelError
|
||||
from .kubernetes_service import KubernetesService
|
||||
from .models import (
|
||||
AuthResult,
|
||||
CodeSubmit,
|
||||
DeleteRequest,
|
||||
InstanceSummary,
|
||||
PasswordSubmit,
|
||||
PhoneStart,
|
||||
StringSessionStart,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.kubernetes = KubernetesService(settings)
|
||||
app.state.telegram = TelegramAuthService(settings.auth_ttl_seconds)
|
||||
yield
|
||||
await app.state.telegram.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Userbot Kubernetes Control",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(PanelError)
|
||||
async def panel_error_handler(_request: Request, exc: PanelError) -> JSONResponse:
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error_handler(
|
||||
_request: Request,
|
||||
exc: RequestValidationError,
|
||||
) -> JSONResponse:
|
||||
errors = [
|
||||
{
|
||||
"loc": error.get("loc", ()),
|
||||
"msg": error.get("msg", "Invalid value"),
|
||||
"type": error.get("type", "value_error"),
|
||||
}
|
||||
for error in exc.errors()
|
||||
]
|
||||
return JSONResponse(status_code=422, content={"detail": errors})
|
||||
|
||||
|
||||
def kube(request: Request) -> KubernetesService:
|
||||
return request.app.state.kubernetes
|
||||
|
||||
|
||||
def telegram(request: Request) -> TelegramAuthService:
|
||||
return request.app.state.telegram
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health(request: Request) -> dict[str, object]:
|
||||
service = kube(request)
|
||||
try:
|
||||
service.apps.list_namespaced_deployment(
|
||||
settings.namespace,
|
||||
limit=1,
|
||||
)
|
||||
except Exception:
|
||||
return {"ok": False, "kubernetes": False}
|
||||
return {"ok": True, "kubernetes": True}
|
||||
|
||||
|
||||
@app.get("/api/instances", response_model=list[InstanceSummary])
|
||||
def list_instances(
|
||||
request: Request,
|
||||
query: str = "",
|
||||
status: str = "",
|
||||
) -> list[InstanceSummary]:
|
||||
return kube(request).list_instances(query=query, status=status)
|
||||
|
||||
|
||||
@app.get("/api/instances/{instance_id}", response_model=InstanceSummary)
|
||||
def get_instance(instance_id: str, request: Request) -> InstanceSummary:
|
||||
return kube(request).get_instance(instance_id)
|
||||
|
||||
|
||||
@app.get("/api/instances/{instance_id}/logs")
|
||||
def get_logs(
|
||||
instance_id: str,
|
||||
request: Request,
|
||||
tail: Annotated[int, Query(ge=1, le=1000)] = 250,
|
||||
) -> dict[str, str]:
|
||||
return {"logs": kube(request).logs(instance_id, tail)}
|
||||
|
||||
|
||||
@app.post("/api/instances/{instance_id}/start", response_model=InstanceSummary)
|
||||
def start_instance(instance_id: str, request: Request) -> InstanceSummary:
|
||||
return kube(request).scale(instance_id, 1)
|
||||
|
||||
|
||||
@app.post("/api/instances/{instance_id}/stop", response_model=InstanceSummary)
|
||||
def stop_instance(instance_id: str, request: Request) -> InstanceSummary:
|
||||
return kube(request).scale(instance_id, 0)
|
||||
|
||||
|
||||
@app.post("/api/instances/{instance_id}/restart", response_model=InstanceSummary)
|
||||
def restart_instance(instance_id: str, request: Request) -> InstanceSummary:
|
||||
return kube(request).restart(instance_id)
|
||||
|
||||
|
||||
@app.post("/api/instances/{instance_id}/delete", status_code=204)
|
||||
def delete_instance(
|
||||
instance_id: str,
|
||||
payload: DeleteRequest,
|
||||
request: Request,
|
||||
) -> Response:
|
||||
if payload.confirmation != instance_id:
|
||||
raise PanelError(422, "Type the instance id exactly to confirm deletion")
|
||||
kube(request).delete(instance_id, delete_data=payload.delete_data)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.post("/api/auth/phone/start", response_model=AuthResult)
|
||||
async def auth_phone_start(
|
||||
payload: PhoneStart,
|
||||
request: Request,
|
||||
) -> AuthResult:
|
||||
service = kube(request)
|
||||
service.ensure_prerequisites()
|
||||
service.assert_available(payload.instance_id)
|
||||
flow_id = await telegram(request).start_phone(payload)
|
||||
return AuthResult(status="code_required", flow_id=flow_id)
|
||||
|
||||
|
||||
@app.post("/api/auth/phone/{flow_id}/code", response_model=AuthResult)
|
||||
async def auth_phone_code(
|
||||
flow_id: str,
|
||||
payload: CodeSubmit,
|
||||
request: Request,
|
||||
) -> AuthResult:
|
||||
authorized = await telegram(request).submit_code(flow_id, payload.code)
|
||||
if authorized is None:
|
||||
return AuthResult(status="password_required", flow_id=flow_id)
|
||||
instance = kube(request).provision(authorized.account, authorized.session_string)
|
||||
return AuthResult(status="ready", instance=instance)
|
||||
|
||||
|
||||
@app.post("/api/auth/phone/{flow_id}/password", response_model=AuthResult)
|
||||
async def auth_phone_password(
|
||||
flow_id: str,
|
||||
payload: PasswordSubmit,
|
||||
request: Request,
|
||||
) -> AuthResult:
|
||||
authorized = await telegram(request).submit_password(flow_id, payload.password)
|
||||
instance = kube(request).provision(authorized.account, authorized.session_string)
|
||||
return AuthResult(status="ready", instance=instance)
|
||||
|
||||
|
||||
@app.post("/api/auth/string-session", response_model=AuthResult)
|
||||
async def auth_string_session(
|
||||
payload: StringSessionStart,
|
||||
request: Request,
|
||||
) -> AuthResult:
|
||||
service = kube(request)
|
||||
service.ensure_prerequisites()
|
||||
service.assert_available(payload.instance_id)
|
||||
authorized = await telegram(request).validate_string_session(payload)
|
||||
instance = service.provision(authorized.account, authorized.session_string)
|
||||
return AuthResult(status="ready", instance=instance)
|
||||
|
||||
|
||||
static_dir = settings.static_dir
|
||||
assets_dir = static_dir / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(static_dir / "index.html")
|
||||
|
||||
|
||||
@app.get("/{path:path}", include_in_schema=False)
|
||||
def spa_fallback(path: str) -> FileResponse:
|
||||
candidate = (static_dir / path).resolve()
|
||||
if candidate.is_file() and static_dir.resolve() in candidate.parents:
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(static_dir / "index.html")
|
||||
Reference in New Issue
Block a user