From e5ba624aa9ed3da5867b4a63fbcddf0e8287d1f1 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Tue, 13 Jan 2026 16:03:04 +0100 Subject: [PATCH] File changer never ran - changed. --- CLAUDE.md | 25 +++++++++++++++++++- Dockerfile | 7 +++--- gunicorn_config.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 gunicorn_config.py diff --git a/CLAUDE.md b/CLAUDE.md index 7db41ab..476ce60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ The ESPHome container remains immutable and handles all compilation/deployment o - Tracks last modification time per file in `ESPHomeFileHandler.last_modified` - Monitors all `.yaml`/`.yml` files directly in the config directory - Optionally triggers `git push` when `AUTO_PUSH=true` + - In production (Gunicorn), started via `post_fork` hook in first worker only 3. **Git Operations** (app.py) - `git_clone()` - Initial clone of Gitea repository on startup @@ -59,6 +60,15 @@ The service expects a flat structure with each device having its own `.yaml` fil - Prevents concurrent operations on the same device - Operations run in separate threads spawned from file watcher or webhook handlers +## Project Files + +- `app.py` - Main Flask application with file watcher, git operations, and API endpoints +- `gunicorn_config.py` - Gunicorn configuration with hooks for file watcher initialization +- `requirements.txt` - Python dependencies +- `Dockerfile` - Container image definition +- `docker-compose.yml` - Multi-container orchestration (webhook + esphome services) +- `CLAUDE.md` - This documentation file + ## Development Commands ### Local Development (Without Docker) @@ -259,12 +269,25 @@ On startup, the service: 3. If yes, configures git user and pulls latest changes 4. All git operations have 60-second timeout -### Gunicorn Configuration (Dockerfile) +### Gunicorn Configuration + +**Configuration File:** `gunicorn_config.py` - 2 workers - 600 second timeout - Binds to 0.0.0.0:5000 +**Hooks for File Watcher:** +- `on_starting` hook: Initializes git repository once before workers are forked +- `post_fork` hook: Starts file watcher thread in the first worker only (worker.age == 0) +- This ensures the file watcher runs correctly under Gunicorn without duplicates + +**Why hooks are needed:** +When running under Gunicorn, the `if __name__ == '__main__':` block in app.py is never executed. The hooks ensure that: +1. Git initialization happens once at startup +2. File watcher starts in exactly one worker process +3. Multiple workers don't create duplicate file watchers + ## Docker Compose Architecture Two services work together: diff --git a/Dockerfile b/Dockerfile index 0d2cedc..0a7834f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY app.py . +COPY gunicorn_config.py . # Create config directory RUN mkdir -p /config @@ -27,9 +28,7 @@ EXPOSE 5000 # Environment variables ENV ESPHOME_CONFIG_DIR=/config -ENV AUTO_COMPILE=true -ENV AUTO_UPLOAD=false ENV DEBOUNCE_SECONDS=5 -# Run the webhook service -CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "600", "app:app"] +# Run the webhook service with Gunicorn config +CMD ["gunicorn", "-c", "gunicorn_config.py", "app:app"] diff --git a/gunicorn_config.py b/gunicorn_config.py new file mode 100644 index 0000000..09f3997 --- /dev/null +++ b/gunicorn_config.py @@ -0,0 +1,58 @@ +"""Gunicorn configuration file for ESPHome Gitea Sync Service""" +import logging +from threading import Thread + +# Gunicorn configuration +bind = "0.0.0.0:5000" +workers = 2 +timeout = 600 +loglevel = "info" + +logger = logging.getLogger(__name__) + +# Track if file watcher has been started +_watcher_started = False + + +def on_starting(server): + """ + Called just before the master process is initialized. + Perfect for one-time setup like git initialization. + """ + logger.info("Gunicorn master process starting - initializing git repository") + + # Import here to avoid circular imports + from app import initialize_git_repo + + try: + initialize_git_repo() + logger.info("Git repository initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize git repository: {e}") + + +def post_fork(server, worker): + """ + Called after a worker has been forked. + Start the file watcher only in the first worker to avoid duplicates. + """ + global _watcher_started + + # Only start the file watcher in the first worker (age/number would be 0) + # Check worker.age which is the worker's sequential number + if worker.age == 0 and not _watcher_started: + _watcher_started = True + logger.info(f"Starting file watcher in worker {worker.pid}") + + # Import here to avoid circular imports + from app import start_file_watcher + + try: + # Start file watcher in a daemon thread + watcher_thread = Thread(target=start_file_watcher, daemon=True) + watcher_thread.start() + logger.info("File watcher started successfully") + except Exception as e: + logger.error(f"Failed to start file watcher: {e}") + else: + logger.info(f"Worker {worker.pid} started (file watcher already running in another worker)")