BREAKING CHANGE: ditch gosu, make rootless+distroless container. Reduced size from 1GB to to 500MB.

Ditch UID and GID variables. These are now set in docker-compose.yaml
This commit is contained in:
Xoconoch
2025-08-23 10:31:05 -06:00
parent df70928950
commit a28ab96605
7 changed files with 149 additions and 227 deletions

View File

@@ -1,62 +1,36 @@
# Git # Allowlist minimal build context
.git *
.gitignore
.gitattributes
# Docker # Backend
docker-compose.yaml !requirements.txt
docker-compose.yml !app.py
Dockerfile !routes/**
.dockerignore # Re-ignore caches and compiled files inside routes
routes/**/__pycache__/
routes/**/.pytest_cache/
routes/**/*.pyc
routes/**/*.pyo
# Node # Frontend: only what's needed to build
node_modules !spotizerr-ui/package.json
spotizerr-ui/node_modules !spotizerr-ui/pnpm-lock.yaml
npm-debug.log !spotizerr-ui/pnpm-workspace.yaml
pnpm-lock.yaml !spotizerr-ui/index.html
!spotizerr-ui/vite.config.ts
!spotizerr-ui/postcss.config.mjs
!spotizerr-ui/tsconfig.json
!spotizerr-ui/tsconfig.app.json
!spotizerr-ui/tsconfig.node.json
!spotizerr-ui/src/**
!spotizerr-ui/public/**
!spotizerr-ui/scripts/**
# Exclude heavy/unnecessary frontend folders
spotizerr-ui/node_modules/**
spotizerr-ui/dist/**
spotizerr-ui/dev-dist/**
# Python # Always exclude local data/logs/tests/etc.
__pycache__ .data/
*.pyc
*.pyo
*.pyd
.Python
.env
.venv
venv/
env/
.env.example
# Editor/OS
.vscode
.idea
.DS_Store
*.swp
# Application data
credentials.json
test.py
downloads/
creds/
Test.py
prgs/
flask_server.log
test.sh
routes/__pycache__/*
routes/utils/__pycache__/*
search_test.py
config/main.json
.cache
config/state/queue_state.json
output.log
queue_state.json
search_demo.py
celery_worker.log
static/js/*
logs/ logs/
data Downloads/
tests/ tests/
# Non-essential files
docs/
README.md

View File

@@ -19,12 +19,6 @@ REDIS_PASSWORD=CHANGE_ME
# Set to true to filter out explicit content. # Set to true to filter out explicit content.
EXPLICIT_FILTER=false EXPLICIT_FILTER=false
# User and group ID for the container. Sets the owner of the downloaded files.
PUID=1000
PGID=1000
# Optional: Sets the default file permissions for newly created files within the container. # Optional: Sets the default file permissions for newly created files within the container.
UMASK=0022 UMASK=0022

View File

@@ -7,40 +7,59 @@ RUN pnpm install --frozen-lockfile
COPY spotizerr-ui/. . COPY spotizerr-ui/. .
RUN pnpm build RUN pnpm build
# Stage 2: Final application image # Stage 2: Python dependencies builder (create relocatable deps dir)
FROM python:3.12-slim FROM python:3.11-slim AS py-deps
WORKDIR /app
COPY requirements.txt .
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/
RUN uv pip install --target /python -r requirements.txt
# Set an environment variable for non-interactive frontend installation # Stage 3: Fetch static ffmpeg/ffprobe binaries
ENV DEBIAN_FRONTEND=noninteractive FROM debian:stable-slim AS ffmpeg
ARG TARGETARCH
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils \
&& rm -rf /var/lib/apt/lists/*
RUN case "$TARGETARCH" in \
amd64) FFMPEG_PKG=ffmpeg-master-latest-linux64-gpl.tar.xz ;; \
arm64) FFMPEG_PKG=ffmpeg-master-latest-linuxarm64-gpl.tar.xz ;; \
*) echo "Unsupported arch: $TARGETARCH" && exit 1 ;; \
esac && \
curl -fsSL -o /tmp/ffmpeg.tar.xz https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/${FFMPEG_PKG} && \
tar -xJf /tmp/ffmpeg.tar.xz -C /tmp && \
mv /tmp/ffmpeg-* /ffmpeg
# Stage 4: Prepare world-writable runtime directories
FROM busybox:1.36.1-musl AS runtime-dirs
RUN mkdir -p /artifact/downloads /artifact/data/config /artifact/data/creds /artifact/data/watch /artifact/data/history /artifact/logs/tasks \
&& chmod -R 0777 /artifact
# Stage 5: Final application image (distroless)
FROM gcr.io/distroless/python3-debian12
LABEL org.opencontainers.image.source="https://github.com/Xoconoch/spotizerr" LABEL org.opencontainers.image.source="https://github.com/Xoconoch/spotizerr"
WORKDIR /app WORKDIR /app
# Install system dependencies # Ensure Python finds vendored site-packages and unbuffered output
RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PYTHONPATH=/python
ffmpeg gosu\ ENV PYTHONUNBUFFERED=1
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies # Copy application code
COPY requirements.txt . COPY --chown=65532:65532 . .
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/ # Copy compiled assets from the frontend build
RUN uv pip install --system -r requirements.txt COPY --from=frontend-builder --chown=65532:65532 /app/spotizerr-ui/dist ./spotizerr-ui/dist
# Copy application code (excluding UI source and TS source) # Copy vendored Python dependencies
COPY . . COPY --from=py-deps --chown=65532:65532 /python /python
# Copy compiled assets from previous stages # Copy static ffmpeg binaries
COPY --from=frontend-builder /app/spotizerr-ui/dist ./spotizerr-ui/dist COPY --from=ffmpeg --chown=65532:65532 /ffmpeg/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=ffmpeg --chown=65532:65532 /ffmpeg/bin/ffprobe /usr/local/bin/ffprobe
# Create necessary directories with proper permissions # Copy pre-created world-writable runtime directories
RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \ COPY --from=runtime-dirs --chown=65532:65532 /artifact/ ./
chmod -R 777 downloads data logs
# Make entrypoint script executable # No shell or package manager available in distroless
RUN chmod +x entrypoint.sh ENTRYPOINT ["python3", "app.py"]
# Set entrypoint to our script
ENTRYPOINT ["/app/entrypoint.sh"]

107
app.py
View File

@@ -13,43 +13,14 @@ import redis
import socket import socket
from urllib.parse import urlparse from urllib.parse import urlparse
# Run DB migrations as early as possible, before importing any routers that may touch DBs # Apply process umask from environment as early as possible
try: _umask_value = os.getenv("UMASK")
from routes.migrations import run_migrations_if_needed if _umask_value:
try:
run_migrations_if_needed() os.umask(int(_umask_value, 8))
logging.getLogger(__name__).info( except Exception:
"Database migrations executed (if needed) early in startup." # Defer logging setup; avoid failing on invalid UMASK
) pass
except Exception as e:
logging.getLogger(__name__).error(
f"Database migration step failed early in startup: {e}", exc_info=True
)
sys.exit(1)
# Import route routers (to be created)
from routes.auth.credentials import router as credentials_router
from routes.auth.auth import router as auth_router
from routes.content.artist import router as artist_router
from routes.content.album import router as album_router
from routes.content.track import router as track_router
from routes.content.playlist import router as playlist_router
from routes.core.search import router as search_router
from routes.core.history import router as history_router
from routes.system.progress import router as prgs_router
from routes.system.config import router as config_router
# Import Celery configuration and manager
from routes.utils.celery_manager import celery_manager
from routes.utils.celery_config import REDIS_URL
# Import authentication system
from routes.auth import AUTH_ENABLED
from routes.auth.middleware import AuthMiddleware
# Import watch manager controls (start/stop) without triggering side effects
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
# Import and initialize routes (this will start the watch manager) # Import and initialize routes (this will start the watch manager)
@@ -61,6 +32,17 @@ def setup_logging():
logs_dir = Path("logs") logs_dir = Path("logs")
logs_dir.mkdir(exist_ok=True) logs_dir.mkdir(exist_ok=True)
# Ensure required runtime directories exist
for p in [
Path("downloads"),
Path("data/config"),
Path("data/creds"),
Path("data/watch"),
Path("data/history"),
Path("logs/tasks"),
]:
p.mkdir(parents=True, exist_ok=True)
# Set up log file paths # Set up log file paths
main_log = logs_dir / "spotizerr.log" main_log = logs_dir / "spotizerr.log"
@@ -111,6 +93,8 @@ def setup_logging():
def check_redis_connection(): def check_redis_connection():
"""Check if Redis is available and accessible""" """Check if Redis is available and accessible"""
from routes.utils.celery_config import REDIS_URL
if not REDIS_URL: if not REDIS_URL:
logging.error("REDIS_URL is not configured. Please check your environment.") logging.error("REDIS_URL is not configured. Please check your environment.")
return False return False
@@ -156,6 +140,20 @@ async def lifespan(app: FastAPI):
# Startup # Startup
setup_logging() setup_logging()
# Run migrations before initializing services
try:
from routes.migrations import run_migrations_if_needed
run_migrations_if_needed()
logging.getLogger(__name__).info(
"Database migrations executed (if needed) early in startup."
)
except Exception as e:
logging.getLogger(__name__).error(
f"Database migration step failed early in startup: {e}", exc_info=True
)
sys.exit(1)
# Check Redis connection # Check Redis connection
if not check_redis_connection(): if not check_redis_connection():
logging.error( logging.error(
@@ -165,6 +163,8 @@ async def lifespan(app: FastAPI):
# Start Celery workers # Start Celery workers
try: try:
from routes.utils.celery_manager import celery_manager
celery_manager.start() celery_manager.start()
logging.info("Celery workers started successfully") logging.info("Celery workers started successfully")
except Exception as e: except Exception as e:
@@ -172,6 +172,8 @@ async def lifespan(app: FastAPI):
# Start Watch Manager after Celery is up # Start Watch Manager after Celery is up
try: try:
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
start_watch_manager() start_watch_manager()
logging.info("Watch Manager initialized and registered for shutdown.") logging.info("Watch Manager initialized and registered for shutdown.")
except Exception as e: except Exception as e:
@@ -184,12 +186,16 @@ async def lifespan(app: FastAPI):
# Shutdown # Shutdown
try: try:
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
stop_watch_manager() stop_watch_manager()
logging.info("Watch Manager stopped") logging.info("Watch Manager stopped")
except Exception as e: except Exception as e:
logging.error(f"Error stopping Watch Manager: {e}") logging.error(f"Error stopping Watch Manager: {e}")
try: try:
from routes.utils.celery_manager import celery_manager
celery_manager.stop() celery_manager.stop()
logging.info("Celery workers stopped") logging.info("Celery workers stopped")
except Exception as e: except Exception as e:
@@ -215,13 +221,30 @@ def create_app():
) )
# Add authentication middleware (only if auth is enabled) # Add authentication middleware (only if auth is enabled)
if AUTH_ENABLED: try:
app.add_middleware(AuthMiddleware) from routes.auth import AUTH_ENABLED
logging.info("Authentication system enabled") from routes.auth.middleware import AuthMiddleware
else:
logging.info("Authentication system disabled") if AUTH_ENABLED:
app.add_middleware(AuthMiddleware)
logging.info("Authentication system enabled")
else:
logging.info("Authentication system disabled")
except Exception as e:
logging.warning(f"Auth system initialization failed or unavailable: {e}")
# Register routers with URL prefixes # Register routers with URL prefixes
from routes.auth.auth import router as auth_router
from routes.system.config import router as config_router
from routes.core.search import router as search_router
from routes.auth.credentials import router as credentials_router
from routes.content.album import router as album_router
from routes.content.track import router as track_router
from routes.content.playlist import router as playlist_router
from routes.content.artist import router as artist_router
from routes.system.progress import router as prgs_router
from routes.core.history import router as history_router
app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
# Include SSO router if available # Include SSO router if available

View File

@@ -1,7 +1,7 @@
name: spotizerr name: spotizerr
services: services:
spotizerr: spotizerr:
image: cooldockerizer93/spotizerr image: cooldockerizer93/spotizerr:beta
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./downloads:/app/downloads - ./downloads:/app/downloads
@@ -9,6 +9,7 @@ services:
ports: ports:
- 7171:7171 - 7171:7171
container_name: spotizerr-app container_name: spotizerr-app
user: "1000:1000" # spotizerr user:group ids
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env

View File

@@ -1,91 +0,0 @@
#!/bin/bash
set -e
# Set umask if UMASK variable is provided
if [ -n "${UMASK}" ]; then
umask "${UMASK}"
fi
# Compose Redis URLs from base variables if not explicitly provided
if [ -z "${REDIS_URL}" ]; then
REDIS_HOST=${REDIS_HOST:-redis}
REDIS_PORT=${REDIS_PORT:-6379}
REDIS_DB=${REDIS_DB:-0}
if [ -n "${REDIS_PASSWORD}" ]; then
if [ -n "${REDIS_USERNAME}" ]; then
AUTH_PART="${REDIS_USERNAME}:${REDIS_PASSWORD}@"
else
AUTH_PART=":${REDIS_PASSWORD}@"
fi
else
AUTH_PART=""
fi
export REDIS_URL="redis://${AUTH_PART}${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}"
fi
if [ -z "${REDIS_BACKEND}" ]; then
export REDIS_BACKEND="${REDIS_URL}"
fi
# Redis is now in a separate container so we don't need to start it locally
echo "Using Redis at ${REDIS_URL}"
# Check if both PUID and PGID are not set
if [ -z "${PUID}" ] && [ -z "${PGID}" ]; then
# Run as root directly
echo "Running as root user (no PUID/PGID specified)"
exec python app.py
else
# Verify both PUID and PGID are set
if [ -z "${PUID}" ] || [ -z "${PGID}" ]; then
echo "ERROR: Must supply both PUID and PGID or neither"
exit 1
fi
# Check for root user request
if [ "${PUID}" -eq 0 ] && [ "${PGID}" -eq 0 ]; then
echo "Running as root user (PUID/PGID=0)"
exec python app.py
else
# Check if the group with the specified GID already exists
if getent group "${PGID}" >/dev/null; then
# If the group exists, use its name instead of creating a new one
GROUP_NAME=$(getent group "${PGID}" | cut -d: -f1)
echo "Using existing group: ${GROUP_NAME} (GID: ${PGID})"
else
# If the group doesn't exist, create it
GROUP_NAME="appgroup"
groupadd -g "${PGID}" "${GROUP_NAME}"
echo "Created group: ${GROUP_NAME} (GID: ${PGID})"
fi
# Check if the user with the specified UID already exists
if getent passwd "${PUID}" >/dev/null; then
# If the user exists, use its name instead of creating a new one
USER_NAME=$(getent passwd "${PUID}" | cut -d: -f1)
echo "Using existing user: ${USER_NAME} (UID: ${PUID})"
else
# If the user doesn't exist, create it
USER_NAME="appuser"
useradd -u "${PUID}" -g "${GROUP_NAME}" -d /app "${USER_NAME}"
echo "Created user: ${USER_NAME} (UID: ${PUID})"
fi
# Ensure proper permissions for all app directories unless skipped via env var
if [ "${SKIP_SET_PERMISSIONS}" = "true" ] || [ "${SKIP_SET_PERMISSIONS}" = "1" ]; then
echo "SKIP_SET_PERMISSIONS is set; skipping permissions for /app/downloads /app/data /app/logs"
else
echo "Setting permissions for /app directories..."
chown -R "${USER_NAME}:${GROUP_NAME}" /app/downloads /app/data /app/logs || true
fi
# Ensure Spotipy cache file exists and is writable (fast, local to container)
touch /app/.cache || true
chown "${USER_NAME}:${GROUP_NAME}" /app/.cache || true
# Run as specified user
echo "Starting application as ${USER_NAME}..."
exec gosu "${USER_NAME}" python app.py
fi
fi

View File

@@ -7,3 +7,5 @@ bcrypt==4.2.1
PyJWT==2.10.1 PyJWT==2.10.1
python-multipart==0.0.17 python-multipart==0.0.17
fastapi-sso==0.18.0 fastapi-sso==0.18.0
redis==5.0.7
async-timeout==4.0.3