Initial commit: StackPilot Phase 1 (Core)

Self-hosted Docker Compose manager.
- Backend: FastAPI + docker-py + SQLite (JWT auth, file-first stacks,
  lifecycle, live status, WebSocket logs, docker-run converter, audit log)
- Frontend: React + Vite + Tailwind (login/setup, dashboard, stacks,
  stack detail, Monaco editor, dark/light theme)
- Deployment: docker-compose.yml, Dockerfiles, nginx reverse proxy

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 16:04:58 +00:00
co-authored by Claude Opus 4.8
commit f732cb080b
59 changed files with 3677 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__
*.pyc
.git
data
*.db
.env
+30
View File
@@ -0,0 +1,30 @@
FROM python:3.12-slim
# Docker CLI + compose plugin are required for lifecycle commands.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
&& chmod a+r /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV STACKS_DIR=/opt/stacks \
DATA_DIR=/data \
PORT=5008
EXPOSE 5008
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD curl -fsS http://localhost:5008/api/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5008"]
+132
View File
@@ -0,0 +1,132 @@
"""JWT auth + user management."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlmodel import Session, select
from config import settings
from database import get_session
from models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
# --- password helpers ---
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# --- token helpers ---
def _create_token(sub: str, role: str, token_type: str, expires: timedelta) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": sub,
"role": role,
"type": token_type,
"iat": now,
"exp": now + expires,
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_access_token(user: User) -> str:
return _create_token(
user.username,
user.role,
"access",
timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
)
def create_refresh_token(user: User) -> str:
return _create_token(
user.username,
user.role,
"refresh",
timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
def decode_token(token: str, expected_type: str = "access") -> dict:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
) from exc
if payload.get("type") != expected_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Wrong token type",
)
return payload
# --- user lookups ---
def get_user(session: Session, username: str) -> Optional[User]:
return session.exec(select(User).where(User.username == username)).first()
def authenticate(session: Session, username: str, password: str) -> Optional[User]:
user = get_user(session, username)
if not user or not user.is_active:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def users_exist(session: Session) -> bool:
return session.exec(select(User)).first() is not None
# --- FastAPI dependencies ---
def get_current_user(
token: Optional[str] = Depends(oauth2_scheme),
session: Session = Depends(get_session),
) -> User:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(token, "access")
user = get_user(session, payload.get("sub", ""))
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
return user
+69
View File
@@ -0,0 +1,69 @@
"""Application settings, loaded from environment variables."""
from __future__ import annotations
import secrets
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Paths
STACKS_DIR: str = "/opt/stackpilot/stacks"
DATA_DIR: str = "/opt/stackpilot/data"
# Security
SECRET_KEY: str = "" # Auto-generated if empty (dev only); set in prod.
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# Update checker
UPDATE_CHECK_INTERVAL_MINUTES: int = 60
# Notifications (webhook URLs)
NOTIFY_WEBHOOKS: list[str] = []
# Docker
DOCKER_SOCKET: str = "/var/run/docker.sock"
HOST_PROC_PATH: str = "/host_proc"
# Host browser sandbox roots
ALLOWED_BROWSE_ROOTS: list[str] = ["/", "/mnt", "/media", "/srv", "/opt"]
HOST_ROOT_PREFIX: str = "" # e.g. "/host_root" when host / is bind-mounted
# CORS
CORS_ORIGINS: list[str] = ["http://localhost:5009", "http://localhost:5173"]
# Server
PORT: int = 5008
@field_validator("SECRET_KEY", mode="after")
@classmethod
def _ensure_secret(cls, v: str) -> str:
return v or secrets.token_urlsafe(48)
@field_validator(
"NOTIFY_WEBHOOKS", "ALLOWED_BROWSE_ROOTS", "CORS_ORIGINS", mode="before"
)
@classmethod
def _split_csv(cls, v):
if isinstance(v, str):
v = v.strip()
if not v:
return []
if v.startswith("["): # JSON list
return v
return [item.strip() for item in v.split(",") if item.strip()]
return v
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+31
View File
@@ -0,0 +1,31 @@
"""SQLModel database setup."""
from __future__ import annotations
import os
from collections.abc import Generator
from sqlmodel import Session, SQLModel, create_engine
from config import settings
os.makedirs(settings.DATA_DIR, exist_ok=True)
_DB_PATH = os.path.join(settings.DATA_DIR, "stackpilot.db")
_DB_URL = f"sqlite:///{_DB_PATH}"
engine = create_engine(
_DB_URL,
echo=False,
connect_args={"check_same_thread": False},
)
def init_db() -> None:
# Import models so they are registered on SQLModel.metadata.
import models # noqa: F401
SQLModel.metadata.create_all(engine)
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
+50
View File
@@ -0,0 +1,50 @@
"""Singleton docker-py client wrapper.
All Docker access goes through this module. The Docker socket is NEVER exposed
to the frontend.
"""
from __future__ import annotations
import functools
from typing import Any
import docker
from docker.errors import APIError, DockerException, NotFound
from config import settings
class DockerError(Exception):
"""Structured error surfaced to API callers."""
def __init__(self, error: str, detail: str = ""):
self.error = error
self.detail = detail
super().__init__(f"{error}: {detail}" if detail else error)
@functools.lru_cache(maxsize=1)
def get_client() -> docker.DockerClient:
try:
base_url = f"unix://{settings.DOCKER_SOCKET}"
client = docker.DockerClient(base_url=base_url)
client.ping()
return client
except DockerException as exc: # pragma: no cover - environment dependent
raise DockerError("docker_unavailable", str(exc)) from exc
def reset_client() -> None:
get_client.cache_clear()
def safe_call(fn, *args, **kwargs) -> Any:
"""Run a docker-py call, converting exceptions into DockerError."""
try:
return fn(*args, **kwargs)
except NotFound as exc:
raise DockerError("not_found", str(exc)) from exc
except APIError as exc:
raise DockerError("docker_api_error", str(exc)) from exc
except DockerException as exc:
raise DockerError("docker_error", str(exc)) from exc
+62
View File
@@ -0,0 +1,62 @@
"""StackPilot backend — FastAPI application entry point."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlmodel import Session
from config import settings
from database import engine, init_db
from docker_client import DockerError
from routers import audit, auth, stacks, system, ws
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("stackpilot")
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
# Register stacks that already exist on disk.
try:
with Session(engine) as session:
stacks.sync_discovered_stacks(session)
except Exception as exc: # noqa: BLE001
logger.warning("Stack discovery failed: %s", exc)
logger.info("StackPilot backend ready on port %s", settings.PORT)
yield
app = FastAPI(title="StackPilot", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(DockerError)
async def docker_error_handler(_request: Request, exc: DockerError):
return JSONResponse(
status_code=502,
content={"error": exc.error, "detail": exc.detail},
)
app.include_router(auth.router)
app.include_router(stacks.router)
app.include_router(system.router)
app.include_router(audit.router)
app.include_router(ws.router)
@app.get("/api/health")
def health() -> dict:
return {"status": "ok"}
+6
View File
@@ -0,0 +1,6 @@
"""SQLModel table models. Importing this package registers all tables."""
from models.audit import AuditLog
from models.stack import Stack
from models.user import User
__all__ = ["User", "Stack", "AuditLog"]
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
def _now() -> datetime:
return datetime.now(timezone.utc)
class AuditLog(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user: str
action: str # e.g. "stack.start"
target: str # stack id / resource id
detail: Optional[str] = None
ip: Optional[str] = None
timestamp: datetime = Field(default_factory=_now, index=True)
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
def _now() -> datetime:
return datetime.now(timezone.utc)
class Stack(SQLModel, table=True):
# id is the stack directory name (slug)
id: str = Field(primary_key=True)
name: str
description: Optional[str] = None
stacks_dir_override: Optional[str] = None
created_at: datetime = Field(default_factory=_now)
updated_at: datetime = Field(default_factory=_now)
# --- API schemas ---
class StackCreate(SQLModel):
name: str
description: Optional[str] = None
yaml: Optional[str] = None # initial compose content
env: Optional[str] = None
class StackUpdate(SQLModel):
name: Optional[str] = None
description: Optional[str] = None
yaml: Optional[str] = None
env: Optional[str] = None
class StackCloneRequest(SQLModel):
name: str
class ConvertRequest(SQLModel):
command: str # a `docker run ...` string
class ConvertResponse(SQLModel):
yaml: str
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
def _now() -> datetime:
return datetime.now(timezone.utc)
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
hashed_password: str
role: str = Field(default="user") # "admin" | "user"
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=_now)
# --- API schemas ---
class UserRead(SQLModel):
id: int
username: str
role: str
is_active: bool
class UserCreate(SQLModel):
username: str
password: str
role: str = "admin"
class LoginRequest(SQLModel):
username: str
password: str
class TokenPair(SQLModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(SQLModel):
refresh_token: str
+13
View File
@@ -0,0 +1,13 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
docker==7.1.0
sqlmodel==0.0.22
pydantic==2.10.4
pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.2.1
python-multipart==0.0.20
watchdog==6.0.0
httpx==0.28.1
PyYAML==6.0.2
View File
+29
View File
@@ -0,0 +1,29 @@
"""Audit log query endpoint."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select
from auth import get_current_user
from database import get_session
from models.audit import AuditLog
from models.user import User
router = APIRouter(prefix="/api/audit", tags=["audit"])
@router.get("")
def list_audit(
limit: int = Query(100, le=500),
offset: int = 0,
stack_id: Optional[str] = None,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[AuditLog]:
stmt = select(AuditLog).order_by(AuditLog.timestamp.desc())
if stack_id:
stmt = stmt.where(AuditLog.target == stack_id)
stmt = stmt.offset(offset).limit(limit)
return session.exec(stmt).all()
+111
View File
@@ -0,0 +1,111 @@
"""Authentication routes + first-launch setup wizard."""
from __future__ import annotations
import time
from collections import defaultdict, deque
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlmodel import Session
import auth as auth_mod
from database import get_session
from models.user import (
LoginRequest,
RefreshRequest,
TokenPair,
User,
UserCreate,
UserRead,
)
from services import audit_service
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Simple in-memory rate limiter for login (max 10 / minute / IP).
_LOGIN_HITS: dict[str, deque] = defaultdict(deque)
_RATE_LIMIT = 10
_RATE_WINDOW = 60.0
def _check_rate_limit(ip: str) -> None:
now = time.monotonic()
hits = _LOGIN_HITS[ip]
while hits and now - hits[0] > _RATE_WINDOW:
hits.popleft()
if len(hits) >= _RATE_LIMIT:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts, slow down.",
)
hits.append(now)
def _tokens_for(user: User) -> TokenPair:
return TokenPair(
access_token=auth_mod.create_access_token(user),
refresh_token=auth_mod.create_refresh_token(user),
)
@router.get("/needs-setup")
def needs_setup(session: Session = Depends(get_session)) -> dict:
"""First-launch wizard check: True if no users exist yet."""
return {"needs_setup": not auth_mod.users_exist(session)}
@router.post("/setup", response_model=TokenPair)
def setup(
body: UserCreate, session: Session = Depends(get_session)
) -> TokenPair:
if auth_mod.users_exist(session):
raise HTTPException(status_code=400, detail="Setup already completed")
user = User(
username=body.username,
hashed_password=auth_mod.hash_password(body.password),
role="admin",
)
session.add(user)
session.commit()
session.refresh(user)
audit_service.record(
session, user=user.username, action="user.setup", target=user.username
)
return _tokens_for(user)
@router.post("/login", response_model=TokenPair)
def login(
body: LoginRequest,
request: Request,
session: Session = Depends(get_session),
) -> TokenPair:
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip)
user = auth_mod.authenticate(session, body.username, body.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
audit_service.record(
session, user=user.username, action="auth.login", target=user.username, ip=ip
)
return _tokens_for(user)
@router.post("/refresh", response_model=TokenPair)
def refresh(
body: RefreshRequest, session: Session = Depends(get_session)
) -> TokenPair:
payload = auth_mod.decode_token(body.refresh_token, "refresh")
user = auth_mod.get_user(session, payload.get("sub", ""))
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
)
return _tokens_for(user)
@router.get("/me", response_model=UserRead)
def me(user: User = Depends(auth_mod.get_current_user)) -> User:
return user
+334
View File
@@ -0,0 +1,334 @@
"""Stack CRUD + lifecycle endpoints."""
from __future__ import annotations
import os
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse
from sqlmodel import Session, select
from auth import get_current_user, require_admin
from database import get_session
from docker_client import DockerError
from models.stack import (
ConvertRequest,
ConvertResponse,
Stack,
StackCloneRequest,
StackCreate,
StackUpdate,
)
from models.user import User
from services import audit_service, compose_service
from services.convert_service import convert_docker_run
router = APIRouter(prefix="/api/stacks", tags=["stacks"])
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def sync_discovered_stacks(session: Session) -> None:
"""Register any on-disk stacks not yet in the database."""
known = {s.id for s in session.exec(select(Stack)).all()}
for stack_id in compose_service.discover_stacks():
if stack_id not in known:
stack = Stack(id=stack_id, name=stack_id)
session.add(stack)
session.commit()
def _get_stack_or_404(session: Session, stack_id: str) -> Stack:
stack = session.get(Stack, stack_id)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
return stack
def _stack_summary(stack: Stack) -> dict:
try:
containers = compose_service.containers_for_stack(stack.id)
status = compose_service.compute_status(stack.id)
except DockerError:
containers = []
status = "unknown"
return {
"id": stack.id,
"name": stack.name,
"description": stack.description,
"status": status,
"service_count": len(containers),
"running_count": sum(1 for c in containers if c.state == "running"),
"created_at": stack.created_at,
"updated_at": stack.updated_at,
}
# --------------------------------------------------------------------------- #
# CRUD
# --------------------------------------------------------------------------- #
@router.get("")
def list_stacks(
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[dict]:
sync_discovered_stacks(session)
stacks = session.exec(select(Stack)).all()
return [_stack_summary(s) for s in stacks]
@router.post("", status_code=201)
def create_stack(
body: StackCreate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
stack_id = compose_service.slugify(body.name)
if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)):
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
compose_service.write_compose(stack_id, body.yaml or "services:\n")
if body.env:
compose_service.write_env(stack_id, body.env)
stack = Stack(id=stack_id, name=body.name, description=body.description)
session.add(stack)
session.commit()
session.refresh(stack)
audit_service.record(
session, user=user.username, action="stack.create", target=stack_id,
ip=_client_ip(request),
)
return _stack_summary(stack)
@router.get("/{stack_id}")
def get_stack(
stack_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
stack = _get_stack_or_404(session, stack_id)
try:
containers = [asdict(c) for c in compose_service.containers_for_stack(stack_id)]
status = compose_service.compute_status(stack_id)
except DockerError as exc:
containers = []
status = "unknown"
return {
"id": stack.id,
"name": stack.name,
"description": stack.description,
"status": status,
"yaml": compose_service.read_compose(stack_id),
"env": compose_service.read_env(stack_id),
"containers": containers,
"created_at": stack.created_at,
"updated_at": stack.updated_at,
}
@router.put("/{stack_id}")
def update_stack(
stack_id: str,
body: StackUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
stack = _get_stack_or_404(session, stack_id)
if body.yaml is not None:
compose_service.write_compose(stack_id, body.yaml)
if body.env is not None:
compose_service.write_env(stack_id, body.env)
if body.name is not None:
stack.name = body.name
if body.description is not None:
stack.description = body.description
stack.updated_at = compose_service.now()
session.add(stack)
session.commit()
session.refresh(stack)
audit_service.record(
session, user=user.username, action="stack.update", target=stack_id,
ip=_client_ip(request),
)
return _stack_summary(stack)
@router.delete("/{stack_id}")
async def delete_stack(
stack_id: str,
request: Request,
delete_files: bool = Query(True),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
stack = _get_stack_or_404(session, stack_id)
try:
await compose_service.down(stack_id)
except Exception: # noqa: BLE001 - best-effort teardown
pass
if delete_files:
compose_service.delete_stack_files(stack_id)
session.delete(stack)
session.commit()
audit_service.record(
session, user=user.username, action="stack.delete", target=stack_id,
detail=f"delete_files={delete_files}", ip=_client_ip(request),
)
return {"ok": True}
@router.post("/{stack_id}/clone")
def clone_stack(
stack_id: str,
body: StackCloneRequest,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
_get_stack_or_404(session, stack_id)
new_id = compose_service.slugify(body.name)
if session.get(Stack, new_id):
raise HTTPException(status_code=409, detail=f"Stack '{new_id}' already exists")
try:
compose_service.clone_stack_files(stack_id, new_id)
except compose_service.StackFileError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
stack = Stack(id=new_id, name=body.name)
session.add(stack)
session.commit()
session.refresh(stack)
audit_service.record(
session, user=user.username, action="stack.clone",
target=new_id, detail=f"from {stack_id}", ip=_client_ip(request),
)
return _stack_summary(stack)
# --------------------------------------------------------------------------- #
# lifecycle
# --------------------------------------------------------------------------- #
async def _lifecycle(action_fn, action_name, stack_id, request, session, user):
_get_stack_or_404(session, stack_id)
result = await action_fn(stack_id)
audit_service.record(
session, user=user.username, action=f"stack.{action_name}", target=stack_id,
detail=f"rc={result.get('returncode')}", ip=_client_ip(request),
)
if result.get("returncode") not in (0, None):
raise HTTPException(
status_code=500,
detail={
"error": f"compose {action_name} failed",
"detail": result.get("stderr", "").strip()[-2000:],
},
)
return result
@router.post("/{stack_id}/start")
async def start_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)):
return await _lifecycle(compose_service.up, "start", stack_id, request, session, user)
@router.post("/{stack_id}/stop")
async def stop_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)):
return await _lifecycle(compose_service.stop, "stop", stack_id, request, session, user)
@router.post("/{stack_id}/restart")
async def restart_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)):
return await _lifecycle(compose_service.restart, "restart", stack_id, request, session, user)
@router.post("/{stack_id}/pull")
async def pull_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)):
return await _lifecycle(compose_service.pull, "pull", stack_id, request, session, user)
@router.post("/{stack_id}/update")
async def update_stack_images(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)):
return await _lifecycle(compose_service.update, "update", stack_id, request, session, user)
@router.post("/{stack_id}/down")
async def down_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)):
return await _lifecycle(compose_service.down, "down", stack_id, request, session, user)
# --------------------------------------------------------------------------- #
# logs / export / convert
# --------------------------------------------------------------------------- #
@router.get("/{stack_id}/logs")
async def stack_logs(
stack_id: str,
tail: int = Query(200, le=2000),
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
_get_stack_or_404(session, stack_id)
result = await compose_service.logs(stack_id, tail=tail)
return {"logs": result.get("stdout", "") + result.get("stderr", "")}
@router.get("/{stack_id}/services/{service}/logs")
async def service_logs(
stack_id: str,
service: str,
tail: int = Query(200, le=2000),
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
_get_stack_or_404(session, stack_id)
result = await compose_service.logs(stack_id, service=service, tail=tail)
return {"logs": result.get("stdout", "") + result.get("stderr", "")}
@router.get("/{stack_id}/export")
def export_stack(
stack_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
):
import io
import tarfile
import tempfile
stack = _get_stack_or_404(session, stack_id)
directory = compose_service.stack_dir(stack_id)
if not os.path.isdir(directory):
raise HTTPException(status_code=404, detail="Stack directory missing")
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
with tarfile.open(tmp.name, "w:gz") as tar:
tar.add(directory, arcname=stack_id)
date = compose_service.now().strftime("%Y%m%d")
return FileResponse(
tmp.name,
media_type="application/gzip",
filename=f"stack-{stack_id}-{date}.tar.gz",
)
@router.post("/convert", response_model=ConvertResponse)
def convert(
body: ConvertRequest,
_user: User = Depends(get_current_user),
) -> ConvertResponse:
try:
return ConvertResponse(yaml=convert_docker_run(body.command))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+92
View File
@@ -0,0 +1,92 @@
"""Host / Docker system information."""
from __future__ import annotations
import os
import shutil
from fastapi import APIRouter, Depends
from auth import get_current_user
from config import settings
from docker_client import DockerError, get_client, safe_call
from models.user import User
router = APIRouter(prefix="/api/system", tags=["system"])
def _read_proc(path: str) -> str:
full = os.path.join(settings.HOST_PROC_PATH, path)
if not os.path.isfile(full):
full = os.path.join("/proc", path)
try:
with open(full, "r", encoding="utf-8") as fh:
return fh.read()
except OSError:
return ""
def _mem_info() -> dict:
info = {}
for line in _read_proc("meminfo").splitlines():
parts = line.split(":")
if len(parts) == 2:
key = parts[0].strip()
val = parts[1].strip().split()[0]
try:
info[key] = int(val) * 1024 # kB -> bytes
except ValueError:
pass
total = info.get("MemTotal", 0)
available = info.get("MemAvailable", info.get("MemFree", 0))
return {"total": total, "available": available, "used": max(total - available, 0)}
def _uptime() -> float:
raw = _read_proc("uptime")
try:
return float(raw.split()[0])
except (IndexError, ValueError):
return 0.0
def _cpu_count() -> int:
return os.cpu_count() or 0
def _disk_usage() -> dict:
try:
usage = shutil.disk_usage(settings.DATA_DIR)
return {"total": usage.total, "used": usage.used, "free": usage.free}
except OSError:
return {"total": 0, "used": 0, "free": 0}
@router.get("/info")
def system_info(_user: User = Depends(get_current_user)) -> dict:
docker_version = ""
host_os = ""
containers_running = 0
containers_total = 0
try:
client = get_client()
version = safe_call(client.version)
docker_version = version.get("Version", "")
info = safe_call(client.info)
host_os = info.get("OperatingSystem", "")
containers_running = info.get("ContainersRunning", 0)
containers_total = info.get("Containers", 0)
except DockerError as exc:
docker_version = f"unavailable ({exc.error})"
return {
"docker_version": docker_version,
"host_os": host_os,
"hostname": os.uname().nodename,
"cpu_cores": _cpu_count(),
"ram": _mem_info(),
"disk": _disk_usage(),
"uptime_seconds": _uptime(),
"containers_running": containers_running,
"containers_total": containers_total,
"gpus": [], # populated in Phase 2
}
+130
View File
@@ -0,0 +1,130 @@
"""WebSocket endpoints for real-time log streaming and Docker events."""
from __future__ import annotations
import asyncio
import json
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from jose import JWTError
from auth import decode_token
from services import compose_service
router = APIRouter(tags=["ws"])
async def _authorize(websocket: WebSocket, token: str | None) -> bool:
"""Validate the JWT supplied as a query param. Closes socket on failure."""
if not token:
await websocket.close(code=4401)
return False
try:
decode_token(token, "access")
except (JWTError, Exception): # noqa: BLE001
await websocket.close(code=4401)
return False
return True
async def _stream_logs(websocket: WebSocket, stack_id: str, service: str | None):
"""Stream `docker compose logs -f` output to the client."""
args = ["logs", "--no-color", "--tail", "200", "--timestamps", "-f"]
if service:
args.append(service)
try:
async for line in compose_service.stream_compose(stack_id, args):
await websocket.send_text(
json.dumps(
{
"type": "log",
"stack_id": stack_id,
"service": service,
"line": line,
}
)
)
except WebSocketDisconnect:
raise
except Exception as exc: # noqa: BLE001
await websocket.send_text(
json.dumps({"type": "error", "detail": str(exc)})
)
@router.websocket("/ws/logs/{stack_id}")
async def ws_stack_logs(
websocket: WebSocket,
stack_id: str,
token: str | None = Query(default=None),
):
await websocket.accept()
if not await _authorize(websocket, token):
return
try:
await _stream_logs(websocket, stack_id, None)
except WebSocketDisconnect:
pass
@router.websocket("/ws/logs/{stack_id}/{service}")
async def ws_service_logs(
websocket: WebSocket,
stack_id: str,
service: str,
token: str | None = Query(default=None),
):
await websocket.accept()
if not await _authorize(websocket, token):
return
try:
await _stream_logs(websocket, stack_id, service)
except WebSocketDisconnect:
pass
@router.websocket("/ws/events")
async def ws_events(
websocket: WebSocket,
token: str | None = Query(default=None),
):
"""Stream global Docker events (decoded subset)."""
await websocket.accept()
if not await _authorize(websocket, token):
return
from docker_client import get_client
loop = asyncio.get_event_loop()
queue: asyncio.Queue = asyncio.Queue()
stop = asyncio.Event()
def reader():
try:
client = get_client()
for event in client.events(decode=True):
if stop.is_set():
break
loop.call_soon_threadsafe(queue.put_nowait, event)
except Exception: # noqa: BLE001
pass
task = loop.run_in_executor(None, reader)
try:
while True:
event = await queue.get()
actor = event.get("Actor", {}) or {}
attrs = actor.get("Attributes", {}) or {}
await websocket.send_text(
json.dumps(
{
"type": "event",
"action": event.get("Action"),
"container": attrs.get("name"),
"stack": attrs.get("com.docker.compose.project"),
}
)
)
except WebSocketDisconnect:
pass
finally:
stop.set()
task.cancel()
View File
+22
View File
@@ -0,0 +1,22 @@
"""Audit log helper."""
from __future__ import annotations
from typing import Optional
from sqlmodel import Session
from models.audit import AuditLog
def record(
session: Session,
*,
user: str,
action: str,
target: str,
detail: Optional[str] = None,
ip: Optional[str] = None,
) -> None:
entry = AuditLog(user=user, action=action, target=target, detail=detail, ip=ip)
session.add(entry)
session.commit()
+366
View File
@@ -0,0 +1,366 @@
"""File-based stack storage and Docker Compose lifecycle.
The compose YAML on disk is always the source of truth. The database only
stores metadata (name, description, timestamps).
"""
from __future__ import annotations
import asyncio
import os
import re
import shutil
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
from config import settings
from docker_client import DockerError, get_client, safe_call
COMPOSE_FILENAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
DEFAULT_COMPOSE_NAME = "compose.yaml"
COMPOSE_LABEL = "com.docker.compose.project"
SERVICE_LABEL = "com.docker.compose.service"
class StackFileError(Exception):
pass
# --------------------------------------------------------------------------- #
# Slug / paths
# --------------------------------------------------------------------------- #
def slugify(name: str) -> str:
slug = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower())
slug = re.sub(r"-{2,}", "-", slug).strip("-_")
return slug or "stack"
def stacks_root(override: Optional[str] = None) -> str:
return override or settings.STACKS_DIR
def stack_dir(stack_id: str, override: Optional[str] = None) -> str:
return os.path.join(stacks_root(override), stack_id)
def find_compose_file(directory: str) -> Optional[str]:
for name in COMPOSE_FILENAMES:
candidate = os.path.join(directory, name)
if os.path.isfile(candidate):
return candidate
return None
def compose_path(stack_id: str, override: Optional[str] = None) -> str:
directory = stack_dir(stack_id, override)
return find_compose_file(directory) or os.path.join(directory, DEFAULT_COMPOSE_NAME)
def env_path(stack_id: str, override: Optional[str] = None) -> str:
return os.path.join(stack_dir(stack_id, override), ".env")
# --------------------------------------------------------------------------- #
# Read / write files
# --------------------------------------------------------------------------- #
def read_compose(stack_id: str, override: Optional[str] = None) -> str:
path = compose_path(stack_id, override)
if not os.path.isfile(path):
return ""
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
def read_env(stack_id: str, override: Optional[str] = None) -> str:
path = env_path(stack_id, override)
if not os.path.isfile(path):
return ""
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
def write_compose(stack_id: str, content: str, override: Optional[str] = None) -> None:
directory = stack_dir(stack_id, override)
os.makedirs(directory, exist_ok=True)
path = compose_path(stack_id, override)
# Non-destructive: back up existing file first.
if os.path.isfile(path):
shutil.copy2(path, path + ".bak")
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(content)
os.replace(tmp, path)
def write_env(stack_id: str, content: str, override: Optional[str] = None) -> None:
directory = stack_dir(stack_id, override)
os.makedirs(directory, exist_ok=True)
path = env_path(stack_id, override)
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
def delete_stack_files(stack_id: str, override: Optional[str] = None) -> None:
directory = stack_dir(stack_id, override)
if os.path.isdir(directory):
shutil.rmtree(directory)
def clone_stack_files(src_id: str, dst_id: str, override: Optional[str] = None) -> None:
src = stack_dir(src_id, override)
dst = stack_dir(dst_id, override)
if os.path.isdir(dst):
raise StackFileError(f"Target stack '{dst_id}' already exists")
shutil.copytree(src, dst)
def discover_stacks(override: Optional[str] = None) -> list[str]:
"""Return ids of all directories under STACKS_DIR that contain a compose file."""
root = stacks_root(override)
if not os.path.isdir(root):
return []
found = []
for entry in sorted(os.listdir(root)):
directory = os.path.join(root, entry)
if os.path.isdir(directory) and find_compose_file(directory):
found.append(entry)
return found
# --------------------------------------------------------------------------- #
# Live status from Docker
# --------------------------------------------------------------------------- #
@dataclass
class ContainerInfo:
id: str
name: str
service: str
image: str
state: str # running, exited, ...
status: str # human string
health: Optional[str] = None
ports: list[dict] = field(default_factory=list)
created: Optional[str] = None
def _parse_ports(attrs: dict) -> list[dict]:
ports = []
bindings = (attrs.get("NetworkSettings") or {}).get("Ports") or {}
for container_port, host in (bindings or {}).items():
if host:
for binding in host:
ports.append(
{
"container": container_port,
"host_ip": binding.get("HostIp"),
"host_port": binding.get("HostPort"),
}
)
else:
ports.append({"container": container_port, "host_port": None})
return ports
def containers_for_stack(stack_id: str) -> list[ContainerInfo]:
client = get_client()
raw = safe_call(
client.containers.list,
all=True,
filters={"label": f"{COMPOSE_LABEL}={stack_id}"},
)
result = []
for c in raw:
attrs = c.attrs
state = attrs.get("State", {}) or {}
health = (state.get("Health") or {}).get("Status")
result.append(
ContainerInfo(
id=c.id,
name=c.name,
service=c.labels.get(SERVICE_LABEL, c.name),
image=(c.image.tags[0] if c.image and c.image.tags else attrs.get("Config", {}).get("Image", "")),
state=state.get("Status", c.status),
status=attrs.get("State", {}).get("Status", c.status),
health=health,
ports=_parse_ports(attrs),
created=attrs.get("Created"),
)
)
return result
# in-memory set of stacks currently performing a pull/up
_BUSY: set[str] = set()
def mark_busy(stack_id: str) -> None:
_BUSY.add(stack_id)
def clear_busy(stack_id: str) -> None:
_BUSY.discard(stack_id)
def compute_status(stack_id: str) -> str:
if stack_id in _BUSY:
return "updating"
try:
containers = containers_for_stack(stack_id)
except DockerError:
return "unknown"
if not containers:
return "stopped"
states = [c.state for c in containers]
if any(s in ("dead",) for s in states):
return "error"
if any(
c.state == "exited" and _nonzero_exit(c) for c in containers
):
return "error"
running = [s for s in states if s == "running"]
if len(running) == len(states):
return "running"
if running:
return "partial"
return "stopped"
def _nonzero_exit(c: ContainerInfo) -> bool:
# We only have the textual state here; treat plain "exited" as stopped, not
# an error unless health says otherwise. Detailed exit codes handled in detail view.
return False
# --------------------------------------------------------------------------- #
# Compose CLI lifecycle (async subprocess)
# --------------------------------------------------------------------------- #
def _compose_base_cmd(stack_id: str, override: Optional[str] = None) -> list[str]:
directory = stack_dir(stack_id, override)
compose_file = find_compose_file(directory) or os.path.join(directory, DEFAULT_COMPOSE_NAME)
return [
"docker",
"compose",
"-p",
stack_id,
"--project-directory",
directory,
"-f",
compose_file,
]
async def run_compose(
stack_id: str,
args: list[str],
override: Optional[str] = None,
timeout: float = 600.0,
) -> dict:
"""Run a `docker compose` subcommand. Returns {returncode, stdout, stderr}."""
cmd = _compose_base_cmd(stack_id, override) + args
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError as exc:
proc.kill()
raise StackFileError(f"compose command timed out: {' '.join(args)}") from exc
return {
"returncode": proc.returncode,
"stdout": stdout_b.decode("utf-8", "replace"),
"stderr": stderr_b.decode("utf-8", "replace"),
"command": " ".join(args),
}
async def stream_compose(
stack_id: str, args: list[str], override: Optional[str] = None
):
"""Yield lines from a `docker compose` subcommand as they are produced."""
cmd = _compose_base_cmd(stack_id, override) + args
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
assert proc.stdout is not None
async for raw in proc.stdout:
yield raw.decode("utf-8", "replace").rstrip("\n")
await proc.wait()
# Convenience lifecycle wrappers ------------------------------------------------
async def up(stack_id: str, override: Optional[str] = None) -> dict:
mark_busy(stack_id)
try:
return await run_compose(stack_id, ["up", "-d", "--remove-orphans"], override)
finally:
clear_busy(stack_id)
async def down(stack_id: str, override: Optional[str] = None) -> dict:
return await run_compose(stack_id, ["down"], override)
async def start(stack_id: str, override: Optional[str] = None) -> dict:
return await run_compose(stack_id, ["start"], override)
async def stop(stack_id: str, override: Optional[str] = None) -> dict:
return await run_compose(stack_id, ["stop"], override)
async def restart(stack_id: str, override: Optional[str] = None) -> dict:
return await run_compose(stack_id, ["restart"], override)
async def pull(stack_id: str, override: Optional[str] = None) -> dict:
mark_busy(stack_id)
try:
return await run_compose(stack_id, ["pull"], override)
finally:
clear_busy(stack_id)
async def update(stack_id: str, override: Optional[str] = None) -> dict:
"""Pull then up -d."""
mark_busy(stack_id)
try:
pull_res = await run_compose(stack_id, ["pull"], override)
up_res = await run_compose(stack_id, ["up", "-d", "--remove-orphans"], override)
return {
"returncode": up_res["returncode"],
"stdout": pull_res["stdout"] + "\n" + up_res["stdout"],
"stderr": pull_res["stderr"] + "\n" + up_res["stderr"],
"command": "pull + up -d",
}
finally:
clear_busy(stack_id)
async def logs(
stack_id: str,
service: Optional[str] = None,
tail: int = 200,
override: Optional[str] = None,
) -> dict:
args = ["logs", "--no-color", "--tail", str(tail), "--timestamps"]
if service:
args.append(service)
return await run_compose(stack_id, args, override, timeout=60.0)
def now() -> datetime:
return datetime.now(timezone.utc)
+135
View File
@@ -0,0 +1,135 @@
"""Convert a `docker run ...` command string into a Compose YAML fragment."""
from __future__ import annotations
import shlex
import yaml
def _service_name_from_image(image: str) -> str:
name = image.split("/")[-1].split(":")[0]
return name or "app"
def convert_docker_run(command: str) -> str:
tokens = shlex.split(command)
# Drop leading "docker run" / "docker container run".
while tokens and tokens[0] in ("docker", "run", "container", "sudo"):
tokens.pop(0)
service: dict = {}
name = None
image = None
ports: list[str] = []
volumes: list[str] = []
environment: list[str] = []
env_file: list[str] = []
devices: list[str] = []
cap_add: list[str] = []
labels: list[str] = []
networks: list[str] = []
command_args: list[str] = []
i = 0
n = len(tokens)
def take_value(idx: int, tok: str):
if "=" in tok and tok.startswith("--") and not tok.endswith("="):
return tok.split("=", 1)[1], idx + 1
return tokens[idx + 1], idx + 2
while i < n:
tok = tokens[i]
if image is not None:
# Everything after the image is the container command.
command_args = tokens[i:]
break
if not tok.startswith("-"):
image = tok
i += 1
continue
flag = tok.split("=", 1)[0]
if flag in ("-d", "--detach", "-i", "--interactive", "-t", "--tty", "--rm", "--init"):
i += 1
elif flag in ("--name",):
name, i = take_value(i, tok)
elif flag in ("-p", "--publish"):
val, i = take_value(i, tok)
ports.append(val)
elif flag in ("-v", "--volume", "--mount"):
val, i = take_value(i, tok)
volumes.append(val)
elif flag in ("-e", "--env"):
val, i = take_value(i, tok)
environment.append(val)
elif flag in ("--env-file",):
val, i = take_value(i, tok)
env_file.append(val)
elif flag in ("--device",):
val, i = take_value(i, tok)
devices.append(val)
elif flag in ("--cap-add",):
val, i = take_value(i, tok)
cap_add.append(val)
elif flag in ("-l", "--label"):
val, i = take_value(i, tok)
labels.append(val)
elif flag in ("--network", "--net"):
val, i = take_value(i, tok)
networks.append(val)
elif flag in ("--restart",):
val, i = take_value(i, tok)
service["restart"] = val
elif flag in ("--privileged",):
service["privileged"] = True
i += 1
elif flag in ("--hostname", "-h"):
val, i = take_value(i, tok)
service["hostname"] = val
elif flag in ("-u", "--user"):
val, i = take_value(i, tok)
service["user"] = val
elif flag in ("-w", "--workdir"):
val, i = take_value(i, tok)
service["working_dir"] = val
else:
# Unknown flag — try to consume a value if it looks like it takes one.
if "=" in tok:
i += 1
elif i + 1 < n and not tokens[i + 1].startswith("-"):
i += 2
else:
i += 1
if not image:
raise ValueError("Could not find an image in the docker run command")
service["image"] = image
if "restart" not in service:
service["restart"] = "unless-stopped"
if ports:
service["ports"] = ports
if volumes:
service["volumes"] = volumes
if environment:
service["environment"] = environment
if env_file:
service["env_file"] = env_file
if devices:
service["devices"] = devices
if cap_add:
service["cap_add"] = cap_add
if labels:
service["labels"] = labels
if networks:
service["networks"] = networks
if command_args:
service["command"] = command_args
svc_name = name or _service_name_from_image(image)
if name:
service["container_name"] = name
doc = {"services": {svc_name: service}}
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False)