Proof of concept: Container management with FastAPI backend

- FastAPI backend with REST API endpoints
- SQLite database for container metadata
- Docker/Podman SDK integration with label filtering
- Frontend: Server creation form and management page
- Container operations: create, list, start, stop, delete
- Single container deployment (nginx + Python + supervisor)
- Support for Docker and Podman (rootless)
- Volume management for persistent data
This commit is contained in:
robojerk 2025-10-31 11:50:31 -07:00
commit c31e48e2b1
25 changed files with 3382 additions and 0 deletions

37
backend/database.py Normal file
View file

@ -0,0 +1,37 @@
from sqlalchemy import create_engine, Column, String, Integer, Text, Boolean, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime
Base = declarative_base()
class Container(Base):
__tablename__ = "containers"
id = Column(Integer, primary_key=True, index=True)
container_id = Column(String, unique=True, index=True) # Docker container ID
name = Column(String, unique=True, index=True)
image = Column(String)
status = Column(String) # running, stopped, etc.
config_json = Column(Text) # Full config as JSON string
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
import os
# Create engine at module level
# Use /app/data for database storage (mounted volume)
data_dir = '/app/data'
os.makedirs(data_dir, exist_ok=True)
db_path = os.path.join(data_dir, 'stronghold.db')
engine = create_engine(f"sqlite:///{db_path}")
Base.metadata.create_all(bind=engine)
def get_db():
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
yield db
finally:
db.close()

116
backend/docker_manager.py Normal file
View file

@ -0,0 +1,116 @@
import docker
import os
import json
LABEL_MANAGED = "stronghold.managed"
LABEL_VALUE = "true"
def get_docker_client():
"""Get Docker client, automatically detecting Docker or Podman"""
try:
# Try Docker socket first
if os.path.exists('/var/run/docker.sock'):
return docker.DockerClient(base_url='unix://var/run/docker.sock')
# Try Podman rootful
if os.path.exists('/run/podman/podman.sock'):
return docker.DockerClient(base_url='unix://run/podman/podman.sock')
# Try Podman rootless
xdg_runtime = os.environ.get('XDG_RUNTIME_DIR', '')
podman_sock = f'{xdg_runtime}/podman/podman.sock'
if xdg_runtime and os.path.exists(podman_sock):
return docker.DockerClient(base_url=f'unix://{podman_sock}')
# Fallback to default
return docker.DockerClient(base_url='unix://var/run/docker.sock')
except Exception as e:
raise Exception(f"Failed to connect to Docker/Podman: {e}")
def list_containers(client):
"""List all Stronghold-managed containers"""
filters = {"label": f"{LABEL_MANAGED}={LABEL_VALUE}"}
return client.containers.list(all=True, filters=filters)
def create_container(client, name, config):
"""Create a new container with Stronghold label"""
# Build environment variables from config
environment = {
"EULA": "TRUE" if config.get("acceptEULA") else "FALSE",
"TYPE": config.get("serverType", "PAPER"),
}
# Add optional environment variables
if config.get("version") and config.get("version") != "LATEST":
environment["VERSION"] = config["version"]
if config.get("memory"):
environment["MEMORY"] = config["memory"]
if config.get("difficulty"):
environment["DIFFICULTY"] = config["difficulty"]
if config.get("gamemode"):
environment["MODE"] = config["gamemode"]
if config.get("levelType"):
environment["LEVEL_TYPE"] = config["levelType"]
if config.get("motd"):
environment["MOTD"] = config["motd"]
if config.get("maxPlayers"):
environment["MAX_PLAYERS"] = str(config["maxPlayers"])
if config.get("viewDistance"):
environment["VIEW_DISTANCE"] = str(config["viewDistance"])
if not config.get("pvpEnabled", True):
environment["PVP"] = "false"
if config.get("allowFlight"):
environment["ALLOW_FLIGHT"] = "true"
if config.get("enableRCON"):
environment["ENABLE_RCON"] = "true"
if config.get("rconPassword"):
environment["RCON_PASSWORD"] = config["rconPassword"]
# Port bindings (host_port:container_port)
port = config.get("port", "25565")
port_bindings = {f"{25565}/tcp": int(port)}
if config.get("enableRCON"):
rcon_port = config.get("rconPort", "25575")
port_bindings[f"{25575}/tcp"] = int(rcon_port)
# Create or get volume
volume_name = f"{name}_data"
try:
client.volumes.get(volume_name)
except:
client.volumes.create(name=volume_name, labels={LABEL_MANAGED: LABEL_VALUE})
# Create container with proper volume mount
restart_policy = config.get("restartPolicy", "unless-stopped")
container = client.containers.create(
image="itzg/minecraft-server",
name=name,
environment=environment,
ports=port_bindings,
volumes=[f"{volume_name}:/data"],
labels={LABEL_MANAGED: LABEL_VALUE},
restart_policy={"Name": restart_policy} if restart_policy != "no" else None,
detach=True
)
return container
def start_container(client, container_id):
"""Start a container"""
container = client.containers.get(container_id)
container.start()
return container
def stop_container(client, container_id):
"""Stop a container"""
container = client.containers.get(container_id)
container.stop()
return container
def remove_container(client, container_id):
"""Remove a container"""
container = client.containers.get(container_id)
container.remove(force=True)
return container

195
backend/main.py Normal file
View file

@ -0,0 +1,195 @@
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
import json
from backend.database import get_db, Container, Base, engine
from backend.docker_manager import (
get_docker_client, list_containers, create_container,
start_container, stop_container, remove_container
)
# Create tables
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Stronghold API")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files (frontend will be served by nginx in production)
# app.mount("/", StaticFiles(directory="../frontend", html=True), name="static")
# Pydantic models
class ContainerConfig(BaseModel):
serverName: str
serverType: str
version: Optional[str] = "LATEST"
memory: Optional[str] = "2G"
port: Optional[str] = "25565"
difficulty: Optional[str] = None
gamemode: Optional[str] = None
levelType: Optional[str] = None
motd: Optional[str] = None
maxPlayers: Optional[int] = 20
viewDistance: Optional[int] = 10
acceptEULA: bool = True
enableRCON: bool = False
rconPort: Optional[str] = "25575"
rconPassword: Optional[str] = None
pvpEnabled: bool = True
allowFlight: bool = False
restartPolicy: str = "unless-stopped"
class ContainerResponse(BaseModel):
id: int
container_id: str
name: str
image: str
status: str
created_at: str
# API Routes
@app.get("/api/containers")
def get_containers(db: Session = Depends(get_db)):
"""List all Stronghold-managed containers"""
try:
client = get_docker_client()
docker_containers = list_containers(client)
# Get containers from database
db_containers = db.query(Container).all()
# Merge data
result = []
for db_container in db_containers:
# Find matching docker container
docker_container = next(
(dc for dc in docker_containers if dc.id == db_container.container_id),
None
)
status = docker_container.status if docker_container else "unknown"
result.append({
"id": db_container.id,
"container_id": db_container.container_id,
"name": db_container.name,
"image": db_container.image,
"status": status,
"created_at": db_container.created_at.isoformat() if db_container.created_at else None
})
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/containers")
def create_container_endpoint(config: ContainerConfig, db: Session = Depends(get_db)):
"""Create a new Minecraft server container"""
try:
client = get_docker_client()
# Check if name already exists
existing = db.query(Container).filter(Container.name == config.serverName).first()
if existing:
raise HTTPException(status_code=400, detail="Container name already exists")
# Create container
docker_container = create_container(client, config.serverName, config.dict())
# Save to database
db_container = Container(
container_id=docker_container.id,
name=config.serverName,
image="itzg/minecraft-server",
status="created",
config_json=json.dumps(config.dict())
)
db.add(db_container)
db.commit()
db.refresh(db_container)
# Start container
docker_container.start()
# Update status
db_container.status = "running"
db.commit()
return {
"id": db_container.id,
"container_id": docker_container.id,
"name": config.serverName,
"status": "running"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/containers/{container_id}/start")
def start_container_endpoint(container_id: str, db: Session = Depends(get_db)):
"""Start a container"""
try:
client = get_docker_client()
container = start_container(client, container_id)
# Update database
db_container = db.query(Container).filter(Container.container_id == container_id).first()
if db_container:
db_container.status = "running"
db.commit()
return {"status": "started", "container_id": container_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/containers/{container_id}/stop")
def stop_container_endpoint(container_id: str, db: Session = Depends(get_db)):
"""Stop a container"""
try:
client = get_docker_client()
container = stop_container(client, container_id)
# Update database
db_container = db.query(Container).filter(Container.container_id == container_id).first()
if db_container:
db_container.status = "stopped"
db.commit()
return {"status": "stopped", "container_id": container_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/containers/{container_id}")
def delete_container_endpoint(container_id: str, db: Session = Depends(get_db)):
"""Delete a container"""
try:
client = get_docker_client()
remove_container(client, container_id)
# Remove from database
db_container = db.query(Container).filter(Container.container_id == container_id).first()
if db_container:
db.delete(db_container)
db.commit()
return {"status": "deleted", "container_id": container_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/health")
def health_check():
"""Health check endpoint"""
return {"status": "ok"}

5
backend/requirements.txt Normal file
View file

@ -0,0 +1,5 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
docker==7.0.0
sqlalchemy==2.0.23