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:
commit
c31e48e2b1
25 changed files with 3382 additions and 0 deletions
37
backend/database.py
Normal file
37
backend/database.py
Normal 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
116
backend/docker_manager.py
Normal 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
195
backend/main.py
Normal 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
5
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
docker==7.0.0
|
||||
sqlalchemy==2.0.23
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue