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:
@@ -1,62 +1,36 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
# Allowlist minimal build context
|
||||
*
|
||||
|
||||
# Docker
|
||||
docker-compose.yaml
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
# Backend
|
||||
!requirements.txt
|
||||
!app.py
|
||||
!routes/**
|
||||
# Re-ignore caches and compiled files inside routes
|
||||
routes/**/__pycache__/
|
||||
routes/**/.pytest_cache/
|
||||
routes/**/*.pyc
|
||||
routes/**/*.pyo
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
spotizerr-ui/node_modules
|
||||
npm-debug.log
|
||||
pnpm-lock.yaml
|
||||
# Frontend: only what's needed to build
|
||||
!spotizerr-ui/package.json
|
||||
!spotizerr-ui/pnpm-lock.yaml
|
||||
!spotizerr-ui/pnpm-workspace.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
|
||||
__pycache__
|
||||
*.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/*
|
||||
# Always exclude local data/logs/tests/etc.
|
||||
.data/
|
||||
logs/
|
||||
data
|
||||
Downloads/
|
||||
tests/
|
||||
|
||||
# Non-essential files
|
||||
docs/
|
||||
README.md
|
||||
|
||||
@@ -19,12 +19,6 @@ REDIS_PASSWORD=CHANGE_ME
|
||||
# Set to true to filter out explicit content.
|
||||
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.
|
||||
UMASK=0022
|
||||
|
||||
|
||||
69
Dockerfile
69
Dockerfile
@@ -7,40 +7,59 @@ RUN pnpm install --frozen-lockfile
|
||||
COPY spotizerr-ui/. .
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Final application image
|
||||
FROM python:3.12-slim
|
||||
# Stage 2: Python dependencies builder (create relocatable deps dir)
|
||||
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
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
# Stage 3: Fetch static ffmpeg/ffprobe binaries
|
||||
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"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg gosu\
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Ensure Python finds vendored site-packages and unbuffered output
|
||||
ENV PYTHONPATH=/python
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
# Copy application code
|
||||
COPY --chown=65532:65532 . .
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/
|
||||
RUN uv pip install --system -r requirements.txt
|
||||
# Copy compiled assets from the frontend build
|
||||
COPY --from=frontend-builder --chown=65532:65532 /app/spotizerr-ui/dist ./spotizerr-ui/dist
|
||||
|
||||
# Copy application code (excluding UI source and TS source)
|
||||
COPY . .
|
||||
# Copy vendored Python dependencies
|
||||
COPY --from=py-deps --chown=65532:65532 /python /python
|
||||
|
||||
# Copy compiled assets from previous stages
|
||||
COPY --from=frontend-builder /app/spotizerr-ui/dist ./spotizerr-ui/dist
|
||||
# Copy static ffmpeg binaries
|
||||
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
|
||||
RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \
|
||||
chmod -R 777 downloads data logs
|
||||
# Copy pre-created world-writable runtime directories
|
||||
COPY --from=runtime-dirs --chown=65532:65532 /artifact/ ./
|
||||
|
||||
# Make entrypoint script executable
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Set entrypoint to our script
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
# No shell or package manager available in distroless
|
||||
ENTRYPOINT ["python3", "app.py"]
|
||||
|
||||
97
app.py
97
app.py
@@ -13,43 +13,14 @@ import redis
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Run DB migrations as early as possible, before importing any routers that may touch DBs
|
||||
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)
|
||||
|
||||
# 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
|
||||
# Apply process umask from environment as early as possible
|
||||
_umask_value = os.getenv("UMASK")
|
||||
if _umask_value:
|
||||
try:
|
||||
os.umask(int(_umask_value, 8))
|
||||
except Exception:
|
||||
# Defer logging setup; avoid failing on invalid UMASK
|
||||
pass
|
||||
|
||||
# Import and initialize routes (this will start the watch manager)
|
||||
|
||||
@@ -61,6 +32,17 @@ def setup_logging():
|
||||
logs_dir = Path("logs")
|
||||
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
|
||||
main_log = logs_dir / "spotizerr.log"
|
||||
|
||||
@@ -111,6 +93,8 @@ def setup_logging():
|
||||
|
||||
def check_redis_connection():
|
||||
"""Check if Redis is available and accessible"""
|
||||
from routes.utils.celery_config import REDIS_URL
|
||||
|
||||
if not REDIS_URL:
|
||||
logging.error("REDIS_URL is not configured. Please check your environment.")
|
||||
return False
|
||||
@@ -156,6 +140,20 @@ async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
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
|
||||
if not check_redis_connection():
|
||||
logging.error(
|
||||
@@ -165,6 +163,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Start Celery workers
|
||||
try:
|
||||
from routes.utils.celery_manager import celery_manager
|
||||
|
||||
celery_manager.start()
|
||||
logging.info("Celery workers started successfully")
|
||||
except Exception as e:
|
||||
@@ -172,6 +172,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Start Watch Manager after Celery is up
|
||||
try:
|
||||
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
|
||||
|
||||
start_watch_manager()
|
||||
logging.info("Watch Manager initialized and registered for shutdown.")
|
||||
except Exception as e:
|
||||
@@ -184,12 +186,16 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Shutdown
|
||||
try:
|
||||
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
|
||||
|
||||
stop_watch_manager()
|
||||
logging.info("Watch Manager stopped")
|
||||
except Exception as e:
|
||||
logging.error(f"Error stopping Watch Manager: {e}")
|
||||
|
||||
try:
|
||||
from routes.utils.celery_manager import celery_manager
|
||||
|
||||
celery_manager.stop()
|
||||
logging.info("Celery workers stopped")
|
||||
except Exception as e:
|
||||
@@ -215,13 +221,30 @@ def create_app():
|
||||
)
|
||||
|
||||
# Add authentication middleware (only if auth is enabled)
|
||||
try:
|
||||
from routes.auth import AUTH_ENABLED
|
||||
from routes.auth.middleware import AuthMiddleware
|
||||
|
||||
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
|
||||
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"])
|
||||
|
||||
# Include SSO router if available
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: spotizerr
|
||||
services:
|
||||
spotizerr:
|
||||
image: cooldockerizer93/spotizerr
|
||||
image: cooldockerizer93/spotizerr:beta
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./downloads:/app/downloads
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
ports:
|
||||
- 7171:7171
|
||||
container_name: spotizerr-app
|
||||
user: "1000:1000" # spotizerr user:group ids
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
@@ -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
|
||||
@@ -7,3 +7,5 @@ bcrypt==4.2.1
|
||||
PyJWT==2.10.1
|
||||
python-multipart==0.0.17
|
||||
fastapi-sso==0.18.0
|
||||
redis==5.0.7
|
||||
async-timeout==4.0.3
|
||||
|
||||
Reference in New Issue
Block a user