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:
@@ -0,0 +1,6 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
data
|
||||
*.db
|
||||
.env
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user