From c405aed96ffe2eddf3bc27adb913d94edf41c9fb Mon Sep 17 00:00:00 2001 From: Aman Maharjan <38400817+mhrznamn068@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:11:23 +0100 Subject: [PATCH] docker for patchman --- .dockerignore | 27 ++++++++++ .env.example | 38 ++++++++++++++ .gitignore | 1 + Dockerfile | 31 +++++++++++ docker-compose.yml | 105 +++++++++++++++++++++++++++++++++++++ docker/entrypoint.sh | 80 +++++++++++++++++++++++++++++ docker/local_settings.py | 108 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 390 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 docker/local_settings.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5e0456b1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +.git +.github +.gitignore +*.pyc +__pycache__ +*.pyo +*.pyd +.Python +*.egg-info +dist/ +build/ +.tox +.pytest_cache +.coverage +htmlcov/ +.env +.env.* +!.env.example +debian/ +*.md +TODO +AUTHORS +COPYING +MANIFEST.in +tox.ini +patchman-client.spec +run/ diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..449f6895 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# Required - generate with: python -c "import secrets; print(secrets.token_urlsafe(50))" +SECRET_KEY=change-me-to-a-random-secret-key + +# Required +DB_PASSWORD=change-me-to-a-secure-password + +# Database (defaults shown) +DB_NAME=patchman +DB_USER=patchman + +# Web server +WEB_PORT=8000 +ALLOWED_HOSTS=* +DEBUG=False +TIME_ZONE=UTC + +# Gunicorn +GUNICORN_WORKERS=4 +GUNICORN_THREADS=2 +GUNICORN_TIMEOUT=120 + +# Celery +CELERY_LOG_LEVEL=info +CELERY_POOL_TYPE=threads +CELERY_CONCURRENCY=4 + +# Patchman +REQUIRE_API_KEY=True +CACHE_MIDDLEWARE_SECONDS=0 +MAX_MIRRORS=2 +MAX_MIRROR_FAILURES=14 +DAYS_WITHOUT_REPORT=14 + +# Errata - comma-separated, remove unwanted OS types +ERRATA_OS_UPDATES=yum,rocky,alma,arch,ubuntu,debian +ALMA_RELEASES=8,9,10 +DEBIAN_CODENAMES=bookworm,trixie +UBUNTU_CODENAMES=jammy,noble diff --git a/.gitignore b/.gitignore index 3a1397f5..b7fedea7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ pyvenv.cfg .vscode .venv *.xml +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..3814936f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +DockerfileFROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmagic1 \ + libpq-dev \ + gcc \ + git \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt gunicorn whitenoise psycopg2-binary + +COPY . . + +# Overwrite the local_settings.py with the Docker-specific one. +# settings.py resolves conf_path to ./etc/patchman when running from source. +COPY docker/local_settings.py /app/etc/patchman/local_settings.py + +RUN mkdir -p /var/lib/patchman/static + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..902566c1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,105 @@ +services: + + db: + image: postgres:16-alpine + restart: unless-stopped + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${DB_NAME:-patchman} + POSTGRES_USER: ${DB_USER:-patchman} + POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-patchman} -d ${DB_NAME:-patchman}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + web: + build: . + command: web + restart: unless-stopped + ports: + - "${WEB_PORT:-8000}:8000" + volumes: + - static_files:/var/lib/patchman/static + environment: + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required} + DB_NAME: ${DB_NAME:-patchman} + DB_USER: ${DB_USER:-patchman} + DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + DB_HOST: db + DB_PORT: "5432" + REDIS_HOST: redis + REDIS_PORT: "6379" + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-*} + DEBUG: ${DEBUG:-False} + TIME_ZONE: ${TIME_ZONE:-UTC} + GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4} + GUNICORN_THREADS: ${GUNICORN_THREADS:-2} + GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120} + REQUIRE_API_KEY: ${REQUIRE_API_KEY:-True} + CACHE_MIDDLEWARE_SECONDS: ${CACHE_MIDDLEWARE_SECONDS:-0} + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + celery-worker: + build: . + command: celery-worker + restart: unless-stopped + environment: + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required} + DB_NAME: ${DB_NAME:-patchman} + DB_USER: ${DB_USER:-patchman} + DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + DB_HOST: db + DB_PORT: "5432" + REDIS_HOST: redis + REDIS_PORT: "6379" + CELERY_LOG_LEVEL: ${CELERY_LOG_LEVEL:-info} + CELERY_POOL_TYPE: ${CELERY_POOL_TYPE:-threads} + CELERY_CONCURRENCY: ${CELERY_CONCURRENCY:-4} + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + celery-beat: + build: . + command: celery-beat + restart: unless-stopped + environment: + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required} + DB_NAME: ${DB_NAME:-patchman} + DB_USER: ${DB_USER:-patchman} + DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + DB_HOST: db + DB_PORT: "5432" + REDIS_HOST: redis + REDIS_PORT: "6379" + CELERY_LOG_LEVEL: ${CELERY_LOG_LEVEL:-info} + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + +volumes: + postgres_data: + redis_data: + static_files: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 00000000..8332e37e --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,80 @@ +#!/bin/bash +set -e + +# Wait for PostgreSQL to be ready +echo "Waiting for PostgreSQL..." +until python -c " +import psycopg2, os, sys +try: + psycopg2.connect( + host=os.environ['DB_HOST'], + port=os.environ.get('DB_PORT', '5432'), + dbname=os.environ['DB_NAME'], + user=os.environ['DB_USER'], + password=os.environ['DB_PASSWORD'], + ) + sys.exit(0) +except Exception: + sys.exit(1) +" 2>/dev/null; do + echo "PostgreSQL unavailable - retrying in 2s..." + sleep 2 +done +echo "PostgreSQL is ready." + +# Wait for Redis to be ready +echo "Waiting for Redis..." +until python -c " +import redis, os, sys +try: + r = redis.Redis(host=os.environ.get('REDIS_HOST', 'redis'), port=int(os.environ.get('REDIS_PORT', 6379))) + r.ping() + sys.exit(0) +except Exception: + sys.exit(1) +" 2>/dev/null; do + echo "Redis unavailable - retrying in 2s..." + sleep 2 +done +echo "Redis is ready." + +CMD="${1:-web}" + +if [ "$CMD" = "web" ]; then + echo "Running migrations..." + python manage.py migrate --noinput + + echo "Collecting static files..." + python manage.py collectstatic --noinput + + echo "Starting Gunicorn..." + exec gunicorn patchman.wsgi \ + --bind 0.0.0.0:8000 \ + --workers "${GUNICORN_WORKERS:-4}" \ + --threads "${GUNICORN_THREADS:-2}" \ + --timeout "${GUNICORN_TIMEOUT:-120}" \ + --access-logfile - \ + --error-logfile - + +elif [ "$CMD" = "celery-worker" ]; then + echo "Starting Celery worker..." + exec celery \ + --app patchman \ + worker \ + --loglevel "${CELERY_LOG_LEVEL:-info}" \ + --pool "${CELERY_POOL_TYPE:-threads}" \ + --concurrency "${CELERY_CONCURRENCY:-4}" \ + --task-events \ + --hostname "patchman-worker@%h" + +elif [ "$CMD" = "celery-beat" ]; then + echo "Starting Celery beat..." + exec celery \ + --app patchman \ + beat \ + --loglevel "${CELERY_LOG_LEVEL:-info}" \ + --scheduler django_celery_beat.schedulers:DatabaseScheduler + +else + exec "$@" +fi diff --git a/docker/local_settings.py b/docker/local_settings.py new file mode 100644 index 00000000..8abce630 --- /dev/null +++ b/docker/local_settings.py @@ -0,0 +1,108 @@ +import os + +DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true' + +ADMINS = [] + +SECRET_KEY = os.environ['SECRET_KEY'] + +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',') + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ['DB_NAME'], + 'USER': os.environ['DB_USER'], + 'PASSWORD': os.environ['DB_PASSWORD'], + 'HOST': os.environ.get('DB_HOST', 'db'), + 'PORT': os.environ.get('DB_PORT', '5432'), + } +} + +TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC') + +LANGUAGE_CODE = 'en-us' + +MAX_MIRRORS = int(os.environ.get('MAX_MIRRORS', '2')) +MAX_MIRROR_FAILURES = int(os.environ.get('MAX_MIRROR_FAILURES', '14')) +DAYS_WITHOUT_REPORT = int(os.environ.get('DAYS_WITHOUT_REPORT', '14')) + +ERRATA_OS_UPDATES = os.environ.get( + 'ERRATA_OS_UPDATES', 'yum,rocky,alma,arch,ubuntu,debian' +).split(',') + +ALMA_RELEASES = [int(r) for r in os.environ.get('ALMA_RELEASES', '8,9,10').split(',')] +DEBIAN_CODENAMES = os.environ.get('DEBIAN_CODENAMES', 'bookworm,trixie').split(',') +UBUNTU_CODENAMES = os.environ.get('UBUNTU_CODENAMES', 'jammy,noble').split(',') + +RUN_GUNICORN = True + +_redis_host = os.environ.get('REDIS_HOST', 'redis') +_redis_port = os.environ.get('REDIS_PORT', '6379') +_redis_url = f'redis://{_redis_host}:{_redis_port}' + +CELERY_BROKER_URL = f'{_redis_url}/0' +CELERY_BROKER_TRANSPORT_OPTIONS = { + 'queue_order_strategy': 'priority', +} +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True +CELERY_WORKER_PREFETCH_MULTIPLIER = 1 + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': _redis_url, + } +} +CACHE_MIDDLEWARE_SECONDS = int(os.environ.get('CACHE_MIDDLEWARE_SECONDS', '0')) + +REQUIRE_API_KEY = os.environ.get('REQUIRE_API_KEY', 'True').lower() != 'false' + +from datetime import timedelta # noqa +from celery.schedules import crontab # noqa + +CELERY_BEAT_SCHEDULE = { + 'process_all_unprocessed_reports': { + 'task': 'reports.tasks.process_reports', + 'schedule': crontab(minute='*/5'), + }, + 'refresh_repos_daily': { + 'task': 'repos.tasks.refresh_repos', + 'schedule': crontab(hour=4, minute=0), + }, + 'update_errata_cves_cwes_every_12_hours': { + 'task': 'errata.tasks.update_errata_and_cves', + 'schedule': timedelta(hours=12), + }, + 'run_database_maintenance_daily': { + 'task': 'util.tasks.clean_database', + 'schedule': crontab(hour=6, minute=0), + }, + 'remove_old_reports': { + 'task': 'reports.tasks.remove_reports_with_no_hosts', + 'schedule': timedelta(days=7), + }, + 'find_host_updates': { + 'task': 'hosts.tasks.find_all_host_updates_homogenous', + 'schedule': timedelta(hours=24), + }, +} + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + }, + 'loggers': { + 'urllib3': {'level': 'WARNING', 'handlers': ['console'], 'propagate': False}, + 'git': {'level': 'WARNING', 'handlers': ['console'], 'propagate': False}, + 'version_utils': {'level': 'WARNING', 'handlers': ['console'], 'propagate': False}, + 'celery': {'level': 'WARNING', 'handlers': ['console'], 'propagate': False}, + }, +}