diff --git a/backend/DOCKER_DEPLOYMENT_STATUS.md b/backend/DOCKER_DEPLOYMENT_STATUS.md new file mode 100644 index 0000000..d0b70e7 --- /dev/null +++ b/backend/DOCKER_DEPLOYMENT_STATUS.md @@ -0,0 +1,62 @@ +# MTG Online Backend - Docker Deployment + +## Project Overview +MTG Online backend REST API with FastAPI, SQLAlchemy, and Alembic migrations. Provides user authentication, deck management, card collection tracking, game rooms, and protocol message handling for MTG Online client compatibility. + +## Docker Deployment Status + +### Current State +- **Docker Compose Stack**: Configured and tested +- **Port Mapping**: 8990:8000 (backend exposed on host port 8990) +- **Services**: 4 containers (backend, postgres, postgres_mtgdata, postgres_mirror, redis) +- **Logging**: JSON file logging with rotation (10MB max, 3 files) + +### Recent Fixes Applied +1. **Database URL Fix**: Updated `.env` to use Docker service names instead of localhost + - `postgres_mtgdata` for mtgdata database + - `postgres` for mtgo_platform database + - `postgres_mirror` for mtgo_mirror database + +2. **Docker Build**: Successfully built backend image +3. **Service Health**: All containers started and became healthy + +### Known Issues +- **Backend Connection Error**: Backend container couldn't connect to PostgreSQL due to localhost vs Docker service name mismatch +- **Fix Applied**: Updated `.env` file with correct Docker service names +- **Status**: Fix applied but NOT yet re-tested after restart + +### Next Steps Required +1. Restart Docker containers with updated `.env` +2. Verify backend can connect to all three PostgreSQL databases +3. Test health endpoint on port 8990 +4. Test authentication and CRUD operations +5. Verify logging output + +## Architecture +- **Primary Database**: mtgo_platform (user data, decks, cards) +- **MTG Data Database**: mtgdata (canonical card data) +- **Mirror Database**: mtgo_mirror (fast deckbuilding queries) +- **Cache**: Redis for session/cache management +- **Async Driver**: asyncpg for async SQLAlchemy support + +## Commands +```bash +# Start stack +cd /home/wall-o/projects/mtgonline/backend +docker compose up -d + +# View logs +docker compose logs -f backend +docker compose logs -f postgres + +# Stop stack +docker compose down + +# Rebuild after changes +docker compose up -d --build +``` + +## API Access +- **Health Check**: http://localhost:8990/health +- **API Docs**: http://localhost:8990/docs +- **Backend Port**: 8990 (mapped to container port 8000) diff --git a/backend/Dockerfile b/backend/Dockerfile index b86df3d..a32e485 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,33 +3,45 @@ FROM python:3.12-slim as builder WORKDIR /app +# Install build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ libpq-dev \ + curl \ && rm -rf /var/lib/apt/lists/* -RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser - +# Install Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir --user -r requirements.txt # Application stage FROM python:3.12-slim +LABEL org.label-schema.name="mtgonline-backend" \ + org.label-schema.description="MTG Online Backend API" \ + org.label-schema.version="0.2.0" \ + org.label-schema.build-date="${BUILD_DATE}" \ + org.label-schema.vcs-url="https://gitea.wallomation.com/wall-o/mtgonline" + WORKDIR /app +# Install runtime dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ libpq5 \ curl \ && rm -rf /var/lib/apt/lists/* +# Copy installed packages from builder COPY --from=builder /root/.local /app/.local ENV PATH=/app/.local/bin:$PATH +# Create non-root user RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser -RUN mkdir -p /app/data /app/uploads /app/logs /app/scripts /app/alembic/versions && chown -R appuser:appuser /app +# Create necessary directories +RUN mkdir -p /app/data /app/uploads /app/logs /app/scripts /app/alembic/versions && \ + chown -R appuser:appuser /app # Copy application code COPY --chown=appuser:appuser app/ ./app/ @@ -38,31 +50,34 @@ COPY --chown=appuser:appuser app/ ./app/ COPY --chown=appuser:appuser alembic.ini ./ COPY --chown=appuser:appuser alembic/ ./alembic/ -# Copy interaction pipeline scripts +# Copy scripts (including entrypoint) COPY --chown=appuser:appuser scripts/ ./scripts/ -RUN chmod +x /app/scripts/*.py +RUN chmod +x /app/scripts/*.py /app/scripts/docker-entrypoint.sh +# Copy environment example COPY --chown=appuser:appuser .env.example ./.env.example COPY --chown=appuser:appuser pyproject.toml ./ -# Python environment variables for proper logging +# Python environment variables ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONPATH=/app # Logging configuration ENV MTG_LOG_LEVEL=INFO -ENV MTG_LOG_FORMAT=json +ENV MTG_LOG_FORMAT=text # Create logging directory with proper permissions RUN mkdir -p /app/logs && chown appuser:appuser /app/logs -HEALTHCHECK --interval=30s --timeout=20s --start-period=5s --retries=3 \ +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ CMD curl -f http://localhost:8000/health || exit 1 USER appuser EXPOSE 8000 -# Run migrations before starting the app -CMD ["sh", "-c", "python -m alembic upgrade head && python -m uvicorn app.main:app --host 0.0.0.0 --port 8000"] +# Use entrypoint script for dependency checking and migrations +ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--forwarded-allow-ips", "*"] diff --git a/backend/app/core/logging_config.py b/backend/app/core/logging_config.py new file mode 100644 index 0000000..7a33a76 --- /dev/null +++ b/backend/app/core/logging_config.py @@ -0,0 +1,97 @@ +""" +Structured logging configuration for MTG Online backend. + +Supports JSON and text log formats with container metadata. +Configured via MTG_LOG_LEVEL and MTG_LOG_FORMAT environment variables. +""" +import json +import logging +import os +import sys +from datetime import datetime, timezone + + +def _get_container_name() -> str: + """Get container name from HOSTNAME env var or fallback.""" + return os.environ.get("HOSTNAME", "unknown") + + +class ContainerFormatter(logging.Formatter): + """Formatter that adds container metadata to log records. + + Supports both text and JSON output formats. + """ + + SERVICE_NAME = "mtgonline-backend" + + def __init__(self, fmt_type: str = "text"): + super().__init__() + self.fmt_type = fmt_type + self.container_name = _get_container_name() + + def format(self, record: logging.LogRecord) -> str: + record.service = self.SERVICE_NAME + record.container = self.container_name + + if self.fmt_type == "json": + return self._format_json(record) + return self._format_text(record) + + def _format_text(self, record: logging.LogRecord) -> str: + return ( + f"{self.formatTime(record)} [{record.service}] " + f"{record.levelname} - {record.getMessage()}" + ) + + def _format_json(self, record: logging.LogRecord) -> str: + log_entry = { + "timestamp": datetime.fromtimestamp( + record.created, tz=timezone.utc + ).isoformat(), + "level": record.levelname, + "service": record.service, + "container": record.container, + "logger": record.name, + "message": record.getMessage(), + } + if record.exc_info and record.exc_info[0] is not None: + log_entry["exception"] = self.formatException(record.exc_info) + return json.dumps(log_entry, default=str) + + +def configure_logging() -> None: + """Configure application logging based on environment variables. + + Reads: + MTG_LOG_LEVEL - Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + MTG_LOG_FORMAT - Output format ("text" or "json") + """ + log_level = os.environ.get("MTG_LOG_LEVEL", "INFO").upper() + log_format = os.environ.get("MTG_LOG_FORMAT", "text").lower() + + level = getattr(logging, log_level, logging.INFO) + + formatter = ContainerFormatter(fmt_type=log_format) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.setLevel(level) + root_logger.handlers.clear() + root_logger.addHandler(handler) + + # Suppress noisy loggers unless DEBUG + if level != logging.DEBUG: + logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING) + + logger = logging.getLogger(__name__) + logger.info( + f"Logging configured: level={log_level}, " + f"format={log_format}, container={_get_container_name()}" + ) + + +# Auto-configure on import +configure_logging() diff --git a/backend/app/main.py b/backend/app/main.py index ff4bf5c..2b6875a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,32 +21,14 @@ from typing import AsyncGenerator from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text from app.core.settings import get_settings from app.core.database import engine, mtg_engine, async_session, mtg_async_session from app.routers import auth, users, decks, rooms, admin, card_router, interactions, refresh, user_data, card_import from app.routers.games import router as games_router from app.services.mtgjson_manager import MTGJSONManager - - -def setup_logging(debug: bool = False) -> None: - """Configure application logging with verbose support.""" - level = logging.DEBUG if debug else logging.INFO - - logging.basicConfig( - level=level, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(), - ] - ) - - if debug: - logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING) - logging.getLogger('sqlalchemy.pool').setLevel(logging.WARNING) - - logger = logging.getLogger(__name__) - logger.info(f"Logging initialized at level {logging.getLevelName(level)}") +from app.core.logging_config import configure_logging async def run_initial_download(): @@ -91,8 +73,8 @@ def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan events for startup and shutdown.""" settings = get_settings() - # Startup - setup_logging(debug=settings.DEBUG) + # Configure logging + configure_logging() logger = logging.getLogger(__name__) logger.info(f"MTG Online Backend starting (v{settings.APP_VERSION})") logger.info(f"Database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'configured'}") @@ -146,24 +128,46 @@ app.include_router(card_import.router, prefix="/api/v1/card-import", tags=["Card @app.get("/health", tags=["Health"]) async def health_check(): - """Health check endpoint with MTGJSON data status.""" + """Health check endpoint with full service status.""" from app.services.mtgjson_manager import get_manager - # Get MTGJSON health status + health_status = {"status": "healthy", "version": settings.APP_VERSION} + + # Check MTGJSON status try: manager = get_manager() mtg_status = await manager.get_health_status() + health_status["mtgjson"] = mtg_status + if mtg_status.get("status") != "healthy": + health_status["status"] = "degraded" except Exception as e: - mtg_status = { - "status": "unhealthy", - "error": str(e), - } + health_status["mtgjson"] = {"status": "unhealthy", "error": str(e)} + health_status["status"] = "degraded" - return { - "status": "healthy" if mtg_status.get("status") == "healthy" else "degraded", - "version": settings.APP_VERSION, - "mtgjson": mtg_status, - } + # Check primary database + try: + from app.core.database import engine + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + health_status["database"] = "healthy" + except Exception as e: + health_status["database"] = {"status": "unhealthy", "error": str(e)} + health_status["status"] = "unhealthy" + + # Check Redis + try: + from app.core.redis_client import get_redis + redis_client = await get_redis() + if redis_client: + health_status["redis"] = "healthy" + else: + health_status["redis"] = {"status": "unhealthy", "error": "Connection failed"} + health_status["status"] = "unhealthy" + except Exception as e: + health_status["redis"] = {"status": "unhealthy", "error": str(e)} + health_status["status"] = "unhealthy" + + return health_status @app.get("/", tags=["Root"]) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index f7e5679..494491b 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -3,6 +3,8 @@ services: build: context: . dockerfile: Dockerfile + container_name: mtgonline_backend + hostname: mtgonline_backend ports: - "8990:8000" environment: @@ -16,7 +18,7 @@ services: - MTG_INTERACTION_BATCH_SIZE=1000 - MTG_INTERACTION_REVIEW_QUEUE=true - MTG_LOG_LEVEL=INFO - - MTG_LOG_FORMAT=json + - MTG_LOG_FORMAT=text - REDIS_URL=redis://redis:6379/0 - SECRET_KEY=change-this-secret-key-in-production - JWT_SECRET_KEY=change-this-jwt-secret-key-in-production @@ -38,42 +40,72 @@ services: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s - timeout: 20s + timeout: 5s retries: 3 start_period: 40s logging: driver: "json-file" options: - max-size: "10m" - max-file: "3" + max-size: "50m" + max-file: "5" tag: "{{.Name}}" networks: - - mtgonline_network + mtgonline_network: + aliases: + - backend + - mtgonline-api + restart: unless-stopped + deploy: + resources: + limits: + memory: 1G + cpus: "1.0" + reservations: + memory: 512M + cpus: "0.5" postgres_platform: image: postgres:16-alpine + container_name: mtgonline_postgres_platform + hostname: postgres_platform environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - POSTGRES_DB=mtgo_platform volumes: - postgres_platform_data:/var/lib/postgresql/data + - ./scripts/init_platform.sql:/docker-entrypoint-initdb.d/01-init.sql healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d mtgo_platform"] interval: 10s timeout: 5s retries: 5 + start_period: 10s logging: driver: "json-file" options: - max-size: "10m" - max-file: "3" + max-size: "50m" + max-file: "5" tag: "{{.Name}}" networks: - - mtgonline_network + mtgonline_network: + aliases: + - postgres-platform + - platform-db + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + reservations: + memory: 256M + cpus: "0.25" postgres_mtgdata: image: postgres:16-alpine + container_name: mtgonline_postgres_mtgdata + hostname: postgres_mtgdata environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres @@ -85,17 +117,32 @@ services: interval: 10s timeout: 5s retries: 5 + start_period: 10s logging: driver: "json-file" options: - max-size: "10m" - max-file: "3" + max-size: "50m" + max-file: "5" tag: "{{.Name}}" networks: - - mtgonline_network + mtgonline_network: + aliases: + - postgres-mtgdata + - mtgdata-db + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + reservations: + memory: 256M + cpus: "0.25" postgres_mirror: image: postgres:16-alpine + container_name: mtgonline_postgres_mirror + hostname: postgres_mirror environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres @@ -107,39 +154,79 @@ services: interval: 10s timeout: 5s retries: 5 + start_period: 10s logging: driver: "json-file" options: - max-size: "10m" - max-file: "3" + max-size: "50m" + max-file: "5" tag: "{{.Name}}" networks: - - mtgonline_network + mtgonline_network: + aliases: + - postgres-mirror + - mirror-db + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + reservations: + memory: 256M + cpus: "0.25" redis: image: redis:7-alpine + container_name: mtgonline_redis + hostname: redis + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 + start_period: 5s logging: driver: "json-file" options: - max-size: "10m" - max-file: "3" + max-size: "50m" + max-file: "5" tag: "{{.Name}}" networks: - - mtgonline_network + mtgonline_network: + aliases: + - redis + - cache + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + reservations: + memory: 256M + cpus: "0.25" volumes: postgres_platform_data: + driver: local postgres_mtgdata_data: + driver: local postgres_mirror_data: + driver: local mtg_data: + driver: local mtg_uploads: + driver: local mtg_logs: + driver: local + redis_data: + driver: local networks: mtgonline_network: driver: bridge + name: mtgonline_network diff --git a/backend/scripts/docker-entrypoint.sh b/backend/scripts/docker-entrypoint.sh new file mode 100755 index 0000000..faeb091 --- /dev/null +++ b/backend/scripts/docker-entrypoint.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -e + +echo "============================================" +echo " MTG Online Backend - Starting" +echo " $(date -u '+%Y-%m-%d %H:%M:%S UTC')" +echo "============================================" + +# Wait for PostgreSQL to be ready +echo "[entrypoint] Waiting for PostgreSQL databases..." +for db in mtgo_platform mtgdata mtgo_mirror; do + host="postgres_${db}" + echo " Checking ${host}..." + until python -c " +import asyncio, asyncpg +async def check(): + try: + conn = await asyncpg.connect(host='${host}', port=5432, user='postgres', password='postgres', database='${db}') + await conn.close() + return True + except Exception: + return False +asyncio.run(check()) +" 2>/dev/null; do + echo " ${host} not ready, waiting 2s..." + sleep 2 + done + echo " ${host} is ready!" +done + +# Wait for Redis to be ready +echo "[entrypoint] Waiting for Redis..." +until python -c " +import asyncio, redis.asyncio as aioredis +async def check(): + try: + r = aioredis.from_url('redis://redis:6379/0') + await r.ping() + await r.close() + return True + except Exception: + return False +asyncio.run(check()) +" 2>/dev/null; do + echo " Redis not ready, waiting 2s..." + sleep 2 +done +echo " Redis is ready!" + +# Run database migrations +echo "[entrypoint] Running Alembic migrations..." +python -m alembic upgrade head +echo "[entrypoint] Migrations complete." + +# Start the application +echo "[entrypoint] Starting uvicorn..." +exec python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --forwarded-allow-ips '*' --log-level info diff --git a/backend/scripts/docker_deploy.sh b/backend/scripts/docker_deploy.sh new file mode 100755 index 0000000..1d7fec0 --- /dev/null +++ b/backend/scripts/docker_deploy.sh @@ -0,0 +1,236 @@ +#!/bin/bash +# MTG Online Backend - Docker Deployment Helper Scripts +# ============================================================ + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Project root directory +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE} MTG Online Backend - Docker Deployment${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" + +# Function to display usage +usage() { + echo "Usage: $0 " + echo "" + echo "Commands:" + echo " start - Start all services" + echo " stop - Stop all services" + echo " restart - Restart all services" + echo " logs - View logs (follow mode)" + echo " logs-app - View application logs only" + echo " logs-db - View database logs only" + echo " status - Show service status" + echo " health - Check health of all services" + echo " exec-app - Execute command in backend container" + echo " exec-db - Execute command in database container" + echo " migrate - Run database migrations" + echo " cleanup - Stop and remove all containers and volumes" + echo " rebuild - Rebuild and start all services" + echo " help - Show this help message" + echo "" + exit 0 +} + +# Function to check if docker is running +check_docker() { + if ! docker info > /dev/null 2>&1; then + echo -e "${RED}Error: Docker is not running${NC}" + exit 1 + fi +} + +# Function to check if docker-compose is available +check_compose() { + if ! command -v docker-compose &> /dev/null; then + echo -e "${RED}Error: docker-compose is not installed${NC}" + exit 1 + fi +} + +# Function to start services +start_services() { + echo -e "${GREEN}Starting MTG Online Backend services...${NC}" + cd "$PROJECT_ROOT/backend" + docker-compose up -d + echo -e "${GREEN}✓ Services started successfully${NC}" + echo "" + echo -e "${BLUE}Access the application at: http://localhost:8990${NC}" + echo -e "${BLUE}View logs with: $0 logs${NC}" +} + +# Function to stop services +stop_services() { + echo -e "${YELLOW}Stopping MTG Online Backend services...${NC}" + cd "$PROJECT_ROOT/backend" + docker-compose down + echo -e "${GREEN}✓ Services stopped${NC}" +} + +# Function to restart services +restart_services() { + echo -e "${YELLOW}Restarting MTG Online Backend services...${NC}" + cd "$PROJECT_ROOT/backend" + docker-compose restart + echo -e "${GREEN}✓ Services restarted${NC}" +} + +# Function to view logs +view_logs() { + cd "$PROJECT_ROOT/backend" + docker-compose logs -f --tail=100 +} + +# Function to view application logs only +view_app_logs() { + cd "$PROJECT_ROOT/backend" + docker-compose logs -f --tail=100 backend +} + +# Function to view database logs only +view_db_logs() { + cd "$PROJECT_ROOT/backend" + docker-compose logs -f --tail=100 postgres_platform postgres_mtgdata postgres_mirror +} + +# Function to show status +show_status() { + cd "$PROJECT_ROOT/backend" + echo -e "${BLUE}Service Status:${NC}" + docker-compose ps +} + +# Function to check health +check_health() { + cd "$PROJECT_ROOT/backend" + echo -e "${BLUE}Checking service health...${NC}" + docker-compose exec -T backend curl -s http://localhost:8000/health | python3 -m json.tool + echo "" + echo -e "${BLUE}Database Health:${NC}" + docker-compose exec -T postgres_platform pg_isready -U postgres + docker-compose exec -T postgres_mtgdata pg_isready -U postgres + docker-compose exec -T postgres_mirror pg_isready -U postgres + echo "" + echo -e "${BLUE}Redis Health:${NC}" + docker-compose exec -T redis redis-cli ping +} + +# Function to execute command in backend container +exec_app() { + cd "$PROJECT_ROOT/backend" + docker-compose exec backend "$@" +} + +# Function to execute command in database container +exec_db() { + local db_name=$1 + shift + cd "$PROJECT_ROOT/backend" + docker-compose exec "$db_name" psql -U postgres "$@" +} + +# Function to run migrations +run_migrations() { + echo -e "${YELLOW}Running database migrations...${NC}" + cd "$PROJECT_ROOT/backend" + docker-compose exec backend python -m alembic upgrade head + echo -e "${GREEN}✓ Migrations completed${NC}" +} + +# Function to cleanup +cleanup() { + echo -e "${RED}WARNING: This will stop and remove all containers and volumes!${NC}" + read -p "Are you sure? (y/N) " -n 1 -r + echo "" + if [[ $REPLY =~ ^[Yy]$ ]]; then + cd "$PROJECT_ROOT/backend" + docker-compose down -v + echo -e "${GREEN}✓ All containers and volumes removed${NC}" + fi +} + +# Function to rebuild +rebuild() { + echo -e "${YELLOW}Rebuilding and starting all services...${NC}" + cd "$PROJECT_ROOT/backend" + docker-compose down + docker-compose build --no-cache + docker-compose up -d + echo -e "${GREEN}✓ Services rebuilt and started${NC}" + echo "" + echo -e "${BLUE}Access the application at: http://localhost:8990${NC}" +} + +# Main command handler +case "${1:-help}" in + start) + check_docker + start_services + ;; + stop) + check_docker + stop_services + ;; + restart) + check_docker + restart_services + ;; + logs) + check_docker + view_logs + ;; + logs-app) + check_docker + view_app_logs + ;; + logs-db) + check_docker + view_db_logs + ;; + status) + check_docker + show_status + ;; + health) + check_docker + check_health + ;; + exec-app) + check_docker + shift + exec_app "$@" + ;; + exec-db) + check_docker + exec_db "$@" + ;; + migrate) + check_docker + run_migrations + ;; + cleanup) + check_docker + cleanup + ;; + rebuild) + check_docker + rebuild + ;; + help|--help|-h) + usage + ;; + *) + echo -e "${RED}Unknown command: $1${NC}" + usage + ;; +esac