Dispatcharr/scripts/wait_for_redis.py
None b30a24e2fb feat: add TLS connection support for Redis and PostgreSQL (#950)
- Add 10 env vars for Redis/PostgreSQL TLS (REDIS_SSL, REDIS_SSL_VERIFY, REDIS_SSL_CA_CERT, REDIS_SSL_CERT, REDIS_SSL_KEY, POSTGRES_SSL, POSTGRES_SSL_MODE, POSTGRES_SSL_CA_CERT, POSTGRES_SSL_CERT, POSTGRES_SSL_KEY)
- Propagate SSL params to all Redis connection points: RedisClient, stream view, PubSub, client manager, Celery broker/result backend, Django Channels, and pre-Django startup scripts
- Add Celery SSL config via URL query string params (required by Kombu's internal URL parsing)
- Add PostgreSQL sslmode and certificate options to DATABASES config
- Add startup validation: cert file existence, URL scheme conflict detection, TLS status logging
- Add TLS error hints to Redis connection failure messages
- Add Connection Security panel in System Settings (modular mode only) showing encryption, verification, and mTLS status
- Add PG client key permission fix in web and celery entrypoints for Docker Desktop compatibility (0777 to 0600)
- Add TLS env var passthrough in entrypoint for su - login shells
- Document TLS env vars in modular docker-compose.yml
- Change env_mode from dev/prod to actual DISPATCHARR_ENV value for modular mode detection
2026-03-21 17:24:09 -05:00

137 lines
5.3 KiB
Python

#!/usr/bin/env python
"""
Helper script to wait for Redis to be available before starting the application.
"""
import redis
import time
import os
import sys
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Key prefixes used by Celery's broker (Kombu) and result backend.
# These must be preserved in modular mode where Celery runs independently.
_CELERY_KEY_PREFIXES = ('celery', '_kombu', 'unacked')
def _flush_non_celery_keys(client):
"""Delete all Redis keys except those belonging to Celery."""
cursor = '0'
deleted = 0
while True:
cursor, keys = client.scan(cursor=cursor, count=500)
to_delete = [
k for k in keys
if not k.decode('utf-8', errors='replace').startswith(_CELERY_KEY_PREFIXES)
]
if to_delete:
deleted += client.delete(*to_delete)
if cursor == 0:
break
logger.info(f"Modular mode: selectively cleared {deleted} non-Celery Redis key(s)")
def _build_ssl_kwargs():
"""Build Redis SSL kwargs from environment variables.
Validates that any configured certificate file paths exist on disk
and logs a summary of the active TLS configuration.
"""
ssl_kwargs = {}
if os.environ.get("REDIS_SSL", "false").lower() == "true":
import ssl as _ssl
ssl_kwargs["ssl"] = True
ssl_kwargs["ssl_cert_reqs"] = (
_ssl.CERT_REQUIRED if os.environ.get("REDIS_SSL_VERIFY", "true").lower() == "true"
else _ssl.CERT_NONE
)
cert_paths = {
"REDIS_SSL_CA_CERT": os.environ.get("REDIS_SSL_CA_CERT", ""),
"REDIS_SSL_CERT": os.environ.get("REDIS_SSL_CERT", ""),
"REDIS_SSL_KEY": os.environ.get("REDIS_SSL_KEY", ""),
}
for env_var, path in cert_paths.items():
if path and not os.path.isfile(path):
logger.error(
f"Redis TLS: {env_var}={path!r} — file not found. "
f"Check that the certificate file exists and the volume is mounted correctly."
)
sys.exit(1)
if cert_paths["REDIS_SSL_CA_CERT"]:
ssl_kwargs["ssl_ca_certs"] = cert_paths["REDIS_SSL_CA_CERT"]
if cert_paths["REDIS_SSL_CERT"]:
ssl_kwargs["ssl_certfile"] = cert_paths["REDIS_SSL_CERT"]
if cert_paths["REDIS_SSL_KEY"]:
ssl_kwargs["ssl_keyfile"] = cert_paths["REDIS_SSL_KEY"]
logger.info("Redis TLS: enabled")
return ssl_kwargs
def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2):
"""Wait for Redis to become available"""
redis_client = None
retry_count = 0
ssl_kwargs = _build_ssl_kwargs()
logger.info(f"Waiting for Redis at {host}:{port}/{db}...")
while retry_count < max_retries:
try:
redis_client = redis.Redis(
host=host,
port=port,
db=db,
password=password if password else None,
username=username if username else None,
socket_timeout=2,
socket_connect_timeout=2,
**ssl_kwargs
)
redis_client.ping()
# Clear stale state on startup. In AIO mode, every service restarts
# together so a full flush is safe. In modular mode, Celery has its
# own lifecycle — preserve its broker/result keys and only wipe
# application state (stream locks, proxy metadata, etc.).
if os.environ.get('DISPATCHARR_ENV') == 'modular':
_flush_non_celery_keys(redis_client)
else:
redis_client.flushdb()
logger.info(f"Flushed Redis database")
logger.info(f"✅ Redis at {host}:{port}/{db} is now available!")
return True
except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError) as e:
retry_count += 1
_tls_hint = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)" if ssl_kwargs else ""
if retry_count >= max_retries:
logger.error(f"❌ Failed to connect to Redis after {max_retries} attempts: {e}{_tls_hint}")
return False
logger.info(f"⏳ Redis not available yet, retrying in {retry_interval}s... ({retry_count}/{max_retries})")
time.sleep(retry_interval)
except Exception as e:
_tls_hint = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)" if ssl_kwargs else ""
logger.error(f"❌ Unexpected error connecting to Redis: {e}{_tls_hint}")
return False
return False
if __name__ == "__main__":
host = os.environ.get('REDIS_HOST', 'localhost')
port = int(os.environ.get('REDIS_PORT', 6379))
db = int(os.environ.get('REDIS_DB', 0))
password = os.environ.get('REDIS_PASSWORD', '')
username = os.environ.get('REDIS_USER', '')
max_retries = int(os.environ.get('REDIS_WAIT_RETRIES', 30))
retry_interval = int(os.environ.get('REDIS_WAIT_INTERVAL', 2))
logger.info(f"Starting Redis availability check at {host}:{port}/{db}")
if wait_for_redis(host, port, db, password, username, max_retries, retry_interval):
sys.exit(0)
else:
sys.exit(1)