Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ LABEL org.opencontainers.image.licenses="Apache-2.0"
LABEL org.opencontainers.image.vendor="changedetection.io"

RUN apt-get update && apt-get install -y --no-install-recommends \
gosu \
libxslt1.1 \
# For presenting price amounts correctly in the restock/price detection overview
locales \
Expand All @@ -101,18 +102,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxrender-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

# Create unprivileged user and required directories
RUN groupadd -g 911 changedetection && \
useradd -u 911 -g 911 -M -s /bin/false changedetection && \
mkdir -p /datastore /extra_packages && \
chown changedetection:changedetection /extra_packages

# https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops
ENV PYTHONUNBUFFERED=1

RUN [ ! -d "/datastore" ] && mkdir /datastore
# Redirect .pyc cache to a writable location since /app is root-owned.
# To disable bytecode caching entirely, set PYTHONDONTWRITEBYTECODE=1 at runtime.
ENV PYTHONPYCACHEPREFIX=/tmp/pycache
# Disable pytest's .pytest_cache directory (also writes to /app, which is root-owned).
# Only has an effect when running tests inside the container.
ENV PYTEST_ADDOPTS="-p no:cacheprovider"
# Redirect test logs to the datastore (writable) instead of /app/tests/logs (read-only in container).
ENV TEST_LOG_DIR=/datastore/test_logs

# Re #80, sets SECLEVEL=1 in openssl.conf to allow monitoring sites with weak/old cipher suites
RUN sed -i 's/^CipherString = .*/CipherString = DEFAULT@SECLEVEL=1/' /etc/ssl/openssl.cnf

# Copy modules over to the final image and add their dir to PYTHONPATH
COPY --from=builder /dependencies /usr/local
ENV PYTHONPATH=/usr/local
ENV PYTHONPATH=/usr/local:/extra_packages

EXPOSE 5000

Expand Down
5 changes: 3 additions & 2 deletions changedetectionio/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ def per_test_log_file(request):
"""Create a separate log file for each test function with pytest output."""
import re

# Create logs directory if it doesn't exist
log_dir = os.path.join(os.path.dirname(__file__), "logs")
# Create logs directory if it doesn't exist.
# TEST_LOG_DIR can be overridden e.g. to a writable path when /app is read-only (Docker).
log_dir = os.environ.get('TEST_LOG_DIR', os.path.join(os.path.dirname(__file__), "logs"))
os.makedirs(log_dir, exist_ok=True)

# Generate log filename from test name and worker ID (for parallel runs)
Expand Down
11 changes: 10 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ services:
# - ./proxies.json:/datastore/proxies.json

# environment:
# Run as a specific user/group (UID:GID). Defaults to 911:911.
# The container will automatically fix datastore ownership on first start if needed.
# Set SKIP_CHOWN=1 to disable the ownership migration (e.g. if you manage permissions yourself).
# - PUID=1000
# - PGID=1000
#
# Default listening port, can also be changed with the -p option (not to be confused with ports: below)
# - PORT=5000
#
Expand Down Expand Up @@ -80,8 +86,9 @@ services:
# RAM usage will be higher if you increase this.
# - SCREENSHOT_MAX_HEIGHT=16000
#
# HTTPS SSL Mode for webserver, unset both of these, you may need to volume mount these files also.
# HTTPS SSL Mode for webserver, volume mount the cert files and set these env vars.
# ./cert.pem:/app/cert.pem and ./privkey.pem:/app/privkey.pem
# Permissions are fixed automatically on startup.
# - SSL_CERT_FILE=cert.pem
# - SSL_PRIVKEY_FILE=privkey.pem
#
Expand All @@ -95,6 +102,8 @@ services:
ports:
- 127.0.0.1:5000:5000
restart: unless-stopped
security_opt:
- no-new-privileges:true

# Used for fetching pages via WebDriver+Chrome where you need Javascript support.
# Now working on arm64 (needs testing on rPi - tested on Oracle ARM instance)
Expand Down
76 changes: 58 additions & 18 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,28 +1,68 @@
#!/bin/bash
set -e
set -eu

# Install additional packages from EXTRA_PACKAGES env var
# Uses a marker file to avoid reinstalling on every container restart
INSTALLED_MARKER="/datastore/.extra_packages_installed"
CURRENT_PACKAGES="$EXTRA_PACKAGES"
DATASTORE_PATH="${DATASTORE_PATH:-/datastore}"

if [ -n "$EXTRA_PACKAGES" ]; then
# Check if we need to install/update packages
if [ ! -f "$INSTALLED_MARKER" ] || [ "$(cat $INSTALLED_MARKER 2>/dev/null)" != "$CURRENT_PACKAGES" ]; then
echo "Installing extra packages: $EXTRA_PACKAGES"
pip3 install --no-cache-dir $EXTRA_PACKAGES

if [ $? -eq 0 ]; then
echo "$CURRENT_PACKAGES" > "$INSTALLED_MARKER"
echo "Extra packages installed successfully"
else
echo "ERROR: Failed to install extra packages"
exit 1
# -----------------------------------------------------------------------
# Phase 1: Running as root — fix up PUID/PGID and datastore ownership,
# then re-exec as the unprivileged changedetection user via gosu.
# -----------------------------------------------------------------------
if [ "$(id -u)" = '0' ]; then
PUID=${PUID:-911}
PGID=${PGID:-911}

groupmod -o -g "$PGID" changedetection
usermod -o -u "$PUID" changedetection

# Keep /extra_packages writable by the (potentially re-mapped) user
chown changedetection:changedetection /extra_packages

# One-time ownership migration: only chown if the datastore isn't already
# owned by the target UID (e.g. existing root-owned installations).
if [ -z "${SKIP_CHOWN:-}" ]; then
datastore_uid=$(stat -c '%u' "$DATASTORE_PATH")
if [ "$datastore_uid" != "$PUID" ]; then
echo "Updating $DATASTORE_PATH ownership to $PUID:$PGID (one-time migration)..."
chown -R changedetection:changedetection "$DATASTORE_PATH"
echo "Done."
fi
fi

# Fix SSL certificate permissions so the unprivileged user can read them.
# SSL_CERT_FILE / SSL_PRIVKEY_FILE may be relative (to /app) or absolute.
fix_ssl_perm() {
local file="$1" mode="$2"
[ -z "$file" ] && return
[ "${file:0:1}" != "/" ] && file="/app/$file"
if [ -f "$file" ]; then
chown changedetection:changedetection "$file"
chmod "$mode" "$file"
fi
}
fix_ssl_perm "${SSL_CERT_FILE:-}" 644
fix_ssl_perm "${SSL_PRIVKEY_FILE:-}" 600

# Re-exec this script as the unprivileged user
exec gosu changedetection:changedetection "$0" "$@"
fi

# -----------------------------------------------------------------------
# Phase 2: Running as unprivileged user — install any EXTRA_PACKAGES into
# /extra_packages (already on PYTHONPATH) then exec the app.
# -----------------------------------------------------------------------

# Install additional packages from EXTRA_PACKAGES env var.
# Uses a marker file in the datastore to avoid reinstalling on every restart.
if [ -n "${EXTRA_PACKAGES:-}" ]; then
INSTALLED_MARKER="${DATASTORE_PATH}/.extra_packages_installed"
if [ ! -f "$INSTALLED_MARKER" ] || [ "$(cat "$INSTALLED_MARKER" 2>/dev/null)" != "$EXTRA_PACKAGES" ]; then
echo "Installing extra packages: $EXTRA_PACKAGES"
pip3 install --target=/extra_packages --no-cache-dir $EXTRA_PACKAGES
echo "$EXTRA_PACKAGES" > "$INSTALLED_MARKER"
echo "Extra packages installed successfully"
else
echo "Extra packages already installed: $EXTRA_PACKAGES"
fi
fi

# Execute the main command
exec "$@"
Loading