290 lines
10 KiB
Python
290 lines
10 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlalchemy import select, text
|
|
|
|
from src.api.auth import router as auth_router
|
|
from src.api.dashboard import router as dashboard_router
|
|
from src.api.git_repositories import router as git_repositories_router
|
|
from src.api.health import router as health_router
|
|
from src.api.projects import router as projects_router
|
|
from src.api.ssh_keys import router as ssh_keys_router
|
|
from src.api.terminal import router as terminal_router
|
|
from src.api.instance_proxy import router as instance_proxy_router
|
|
from src.api.config_folders import router as config_folders_router
|
|
from src.api.tool_configs import router as tool_configs_router
|
|
from src.api.tool_instances import router as tool_instances_router
|
|
from src.api.tool_instances import sessions_router
|
|
from src.api.tool_types import router as tool_types_router
|
|
from src.api.user_config import router as user_config_router
|
|
from src.api.users import router as users_router
|
|
from src.config import Settings
|
|
from src.database import SessionLocal, init_database
|
|
from src.logging_config import (
|
|
ExceptionLoggingMiddleware,
|
|
RequestLoggingMiddleware,
|
|
configure_logging,
|
|
)
|
|
from src.models.tool_type import ToolType
|
|
|
|
# Configure logging early
|
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
configure_logging(level=getattr(logging, log_level, logging.INFO))
|
|
|
|
logger = logging.getLogger(__name__)
|
|
settings = Settings()
|
|
app = FastAPI(title="Headquarter API")
|
|
|
|
# Configure CORS - must be before other middleware
|
|
# Build allowed origins list including web and api domains
|
|
cors_origins = [settings.web_base_url]
|
|
if settings.api_base_url != settings.web_base_url:
|
|
cors_origins.append(settings.api_base_url)
|
|
logger.info("CORS configured with origins: %s", cors_origins)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=cors_origins,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.add_middleware(RequestLoggingMiddleware)
|
|
app.add_middleware(ExceptionLoggingMiddleware)
|
|
|
|
|
|
def _sanitize_validation_errors(errors):
|
|
"""Convert validation errors to JSON-safe format."""
|
|
sanitized = []
|
|
for error in errors:
|
|
safe_error = {
|
|
"type": error.get("type"),
|
|
"loc": error.get("loc"),
|
|
"msg": error.get("msg"),
|
|
"input": str(error.get("input")) if error.get("input") is not None else None,
|
|
}
|
|
# Convert ctx to safe format
|
|
ctx = error.get("ctx")
|
|
if ctx:
|
|
safe_ctx = {}
|
|
for key, value in ctx.items():
|
|
if isinstance(value, Exception):
|
|
safe_ctx[key] = str(value)
|
|
elif isinstance(value, (str, int, float, bool, type(None))):
|
|
safe_ctx[key] = value
|
|
else:
|
|
safe_ctx[key] = str(value)
|
|
safe_error["ctx"] = safe_ctx
|
|
sanitized.append(safe_error)
|
|
return sanitized
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
"""Log validation errors and return detailed response."""
|
|
errors = exc.errors()
|
|
logger.warning(
|
|
"Validation error for %s %s: %s",
|
|
request.method,
|
|
request.url.path,
|
|
errors,
|
|
)
|
|
safe_errors = _sanitize_validation_errors(errors)
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content={"detail": safe_errors},
|
|
)
|
|
|
|
|
|
async def _table_exists(session, table_name: str) -> bool:
|
|
"""Check if a table exists in the database."""
|
|
try:
|
|
result = await session.execute(
|
|
text("""
|
|
SELECT EXISTS (
|
|
SELECT FROM information_schema.tables
|
|
WHERE table_schema = 'public'
|
|
AND table_name = :table_name
|
|
)
|
|
"""),
|
|
{"table_name": table_name},
|
|
)
|
|
return result.scalar() or False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
async def seed_builtin_tool_types():
|
|
async with SessionLocal() as session:
|
|
# Check if tool_types table exists before attempting to seed
|
|
if not await _table_exists(session, "tool_types"):
|
|
logger.warning(
|
|
"tool_types table does not exist. Skipping seeding. "
|
|
"Migrations may not have run yet."
|
|
)
|
|
return
|
|
|
|
builtin_types = [
|
|
{
|
|
"name": "code-server",
|
|
"display_name": "VS Code Server",
|
|
"description": "VS Code running in the browser via code-server",
|
|
"category": "editor",
|
|
"interface_type": "web",
|
|
"requires_port": True,
|
|
"compose_template": """version: "3.8"
|
|
services:
|
|
code-server:
|
|
image: lscr.io/linuxserver/code-server:latest
|
|
container_name: {{TOOL_NAME}}
|
|
environment:
|
|
- PUID=1000
|
|
- PGID=1000
|
|
- TZ=Europe/London
|
|
volumes:
|
|
- {{REPO_PATH}}:/config/workspace
|
|
ports:
|
|
- "8443:8443"
|
|
restart: unless-stopped""",
|
|
"default_port": 8443,
|
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
},
|
|
{
|
|
"name": "jupyter-notebook",
|
|
"display_name": "Jupyter Notebook",
|
|
"description": "Jupyter Lab for interactive development",
|
|
"category": "notebook",
|
|
"interface_type": "web",
|
|
"requires_port": True,
|
|
"default_port": 8888,
|
|
"compose_template": """version: "3.8"
|
|
services:
|
|
jupyter:
|
|
image: jupyter/scipy-notebook:latest
|
|
container_name: {{TOOL_NAME}}
|
|
environment:
|
|
- JUPYTER_ENABLE_LAB=yes
|
|
volumes:
|
|
- {{REPO_PATH}}:/home/jovyan/work
|
|
ports:
|
|
- "8888:8888"
|
|
restart: unless-stopped""",
|
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
},
|
|
{
|
|
"name": "opencode",
|
|
"display_name": "OpenCode",
|
|
"description": "AI coding assistant - run opencode in terminal",
|
|
"category": "ai-assistant",
|
|
"interface_type": "terminal",
|
|
"requires_port": False,
|
|
"default_port": 0,
|
|
"compose_template": """version: "3.8"
|
|
services:
|
|
opencode:
|
|
image: node:20-slim
|
|
container_name: {{TOOL_NAME}}
|
|
working_dir: /workspace
|
|
environment:
|
|
- HOME=/tmp
|
|
volumes:
|
|
- {{REPO_PATH}}:/workspace
|
|
- opencode_home:/tmp
|
|
command: >
|
|
sh -c "set -x &&
|
|
apt-get update && apt-get install -y git ca-certificates &&
|
|
echo 'Installing opencode...' &&
|
|
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
|
which opencode || echo 'ERROR: opencode not in PATH' &&
|
|
npm bin -g &&
|
|
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
|
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
|
|
echo 'cd /workspace' >> /root/.bashrc &&
|
|
echo 'OpenCode installation complete' &&
|
|
cd /workspace &&
|
|
exec tail -f /dev/null"
|
|
stdin_open: true
|
|
tty: true
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
opencode_home:""",
|
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
},
|
|
]
|
|
|
|
for tool_data in builtin_types:
|
|
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
|
if not existing:
|
|
tool_type = ToolType(
|
|
name=tool_data["name"],
|
|
display_name=tool_data["display_name"],
|
|
description=tool_data["description"],
|
|
category=tool_data["category"],
|
|
interface_type=tool_data["interface_type"],
|
|
requires_port=tool_data["requires_port"],
|
|
definition_type="compose",
|
|
compose_template=tool_data["compose_template"],
|
|
required_variables=tool_data["required_variables"],
|
|
default_port=tool_data.get("default_port"),
|
|
is_builtin=True,
|
|
)
|
|
session.add(tool_type)
|
|
logger.info("Created built-in tool type: %s", tool_data["name"])
|
|
else:
|
|
# Update existing built-in tool types to reflect code changes
|
|
existing.display_name = tool_data["display_name"]
|
|
existing.description = tool_data["description"]
|
|
existing.category = tool_data["category"]
|
|
existing.interface_type = tool_data["interface_type"]
|
|
existing.requires_port = tool_data["requires_port"]
|
|
existing.definition_type = "compose"
|
|
existing.compose_template = tool_data["compose_template"]
|
|
existing.required_variables = tool_data["required_variables"]
|
|
if "default_port" in tool_data:
|
|
existing.default_port = tool_data["default_port"]
|
|
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
|
|
|
await session.commit()
|
|
logger.info("Built-in tool types seeded successfully.")
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def on_startup():
|
|
logger.info("Starting up Headquarter API...")
|
|
|
|
# Initialize database (run migrations)
|
|
db_ready = await init_database()
|
|
if not db_ready:
|
|
logger.error("Database initialization failed. Shutting down.")
|
|
import sys
|
|
sys.exit(1)
|
|
|
|
# Seed built-in data
|
|
await seed_builtin_tool_types()
|
|
logger.info("Startup complete.")
|
|
|
|
app.include_router(health_router)
|
|
app.include_router(auth_router)
|
|
app.include_router(dashboard_router)
|
|
app.include_router(projects_router)
|
|
app.include_router(users_router)
|
|
app.include_router(ssh_keys_router)
|
|
app.include_router(git_repositories_router)
|
|
app.include_router(user_config_router)
|
|
app.include_router(tool_types_router)
|
|
app.include_router(config_folders_router)
|
|
app.include_router(tool_instances_router)
|
|
app.include_router(tool_configs_router)
|
|
app.include_router(sessions_router)
|
|
app.include_router(instance_proxy_router)
|
|
app.include_router(terminal_router)
|
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|