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
+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