# LibNovel homelab
#
# Runs on 192.168.0.109. Hosts:
#   - libnovel runner (background task worker)
#   - tooling: GlitchTip, Umami, Fider, Dozzle, Uptime Kuma, Gotify
#   - observability: OTel Collector, Tempo, Loki, Prometheus, Grafana
#   - cloudflared tunnel (public subdomains via Cloudflare Zero Trust)
#   - shared Postgres for tooling DBs
#
# All secrets come from Doppler (project=libnovel, config=prd_homelab).
# Run with: doppler run -- docker compose up -d
#
# Public subdomains (via Cloudflare Tunnel — no ports exposed to internet):
#   errors.libnovel.cc    → glitchtip-web:8000
#   analytics.libnovel.cc → umami:3000
#   feedback.libnovel.cc  → fider:3000
#   logs.libnovel.cc      → dozzle:8080
#   uptime.libnovel.cc    → uptime-kuma:3001
#   push.libnovel.cc      → gotify:80
#   grafana.libnovel.cc   → grafana:3000
#   faro.libnovel.cc      → alloy:12347

services:

  # ── Cloudflare Tunnel ───────────────────────────────────────────────────────
  # Outbound-only encrypted tunnel to Cloudflare.
  # Routes all public subdomains to their respective containers on this network.
  # No inbound ports needed — cloudflared initiates all connections outward.
  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN}
    environment:
      CLOUDFLARE_TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN}"

  # ── LibNovel Runner ─────────────────────────────────────────────────────────
  # Background task worker. Connects to prod PocketBase, MinIO, Meilisearch
  # via their public subdomains (pb.libnovel.cc, storage.libnovel.cc, etc.)
  runner:
    image: kalekber/libnovel-runner:latest
    restart: unless-stopped
    stop_grace_period: 135s
    labels:
      com.centurylinklabs.watchtower.enable: "true"
    environment:
      POCKETBASE_URL: "https://pb.libnovel.cc"
      POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
      POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"

      MINIO_ENDPOINT: "storage.libnovel.cc"
      MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}"
      MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}"
      MINIO_USE_SSL: "true"
      MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}"
      MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}"

      MEILI_URL: "${MEILI_URL}"
      MEILI_API_KEY: "${MEILI_API_KEY}"
      VALKEY_ADDR: ""
      GODEBUG: "preferIPv4=1"

      # ── LibreTranslate (internal Docker network) ──────────────────────────
      LIBRETRANSLATE_URL: "http://libretranslate:5000"
      LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"

      # ── Asynq / Redis ─────────────────────────────────────────────────────
      REDIS_ADDR: "${REDIS_ADDR}"
      REDIS_PASSWORD: "${REDIS_PASSWORD}"

      KOKORO_URL: "http://kokoro-fastapi:8880"
      KOKORO_VOICE: "${KOKORO_VOICE}"

      POCKET_TTS_URL: "http://pocket-tts:8000"

      RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}"
      RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
      RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
      RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
      RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
      RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
      RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
      RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"

      LOG_LEVEL: "${LOG_LEVEL}"
      GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"

      # OTel — send runner traces/metrics to the local collector (HTTP)
      OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318"
      OTEL_SERVICE_NAME: "runner"

    healthcheck:
      test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
      interval: 60s
      timeout: 5s
      retries: 3

  # ── Shared Postgres ─────────────────────────────────────────────────────────
  # Hosts glitchtip, umami, and fider databases.
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: "${POSTGRES_USER}"
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
      POSTGRES_DB: postgres
    expose:
      - "5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ── Postgres database initialisation ────────────────────────────────────────
  postgres-init:
    image: postgres:16-alpine
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      PGPASSWORD: "${POSTGRES_PASSWORD}"
    entrypoint: >
      /bin/sh -c "
        psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='fider'\" | grep -q 1 ||
          psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE fider\";
        psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='glitchtip'\" | grep -q 1 ||
          psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE glitchtip\";
        psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='umami'\" | grep -q 1 ||
          psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE umami\";
        echo 'postgres-init: databases ready';
      "
    restart: "no"

  # ── GlitchTip DB migration ──────────────────────────────────────────────────
  glitchtip-migrate:
    image: glitchtip/glitchtip:latest
    depends_on:
      postgres-init:
        condition: service_completed_successfully
      postgres:
        condition: service_healthy
    environment:
      DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
      SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
      GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
      EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
      DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
      VALKEY_URL: "redis://valkey:6379/1"
    command: "./manage.py migrate"
    restart: "no"

  # ── GlitchTip web ───────────────────────────────────────────────────────────
  glitchtip-web:
    image: glitchtip/glitchtip:latest
    restart: unless-stopped
    depends_on:
      glitchtip-migrate:
        condition: service_completed_successfully
    expose:
      - "8000"
    environment:
      DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
      SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
      GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
      EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
      DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
      VALKEY_URL: "redis://valkey:6379/1"
      PORT: "8000"
      ENABLE_USER_REGISTRATION: "false"
      MEDIA_ROOT: "/code/uploads"
    volumes:
      - glitchtip_uploads:/code/uploads
      # Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug)
      - ./glitchtip/files_api.py:/code/apps/files/api.py:ro
    healthcheck:
      test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
      interval: 15s
      timeout: 5s
      retries: 5

  # ── GlitchTip worker ────────────────────────────────────────────────────────
  glitchtip-worker:
    image: glitchtip/glitchtip:latest
    restart: unless-stopped
    depends_on:
      glitchtip-migrate:
        condition: service_completed_successfully
    environment:
      DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
      SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
      GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
      EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
      DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
      VALKEY_URL: "redis://valkey:6379/1"
      SERVER_ROLE: "worker"
      MEDIA_ROOT: "/code/uploads"
    volumes:
      - glitchtip_uploads:/code/uploads
      # Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug)
      - ./glitchtip/files_api.py:/code/apps/files/api.py:ro

  # ── Umami ───────────────────────────────────────────────────────────────────
  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    restart: unless-stopped
    depends_on:
      postgres-init:
        condition: service_completed_successfully
      postgres:
        condition: service_healthy
    expose:
      - "3000"
    environment:
      DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/umami"
      APP_SECRET: "${UMAMI_APP_SECRET}"
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:3000/api/heartbeat"]
      interval: 15s
      timeout: 5s
      retries: 5

  # ── Fider ───────────────────────────────────────────────────────────────────
  fider:
    image: getfider/fider:stable
    restart: unless-stopped
    depends_on:
      postgres-init:
        condition: service_completed_successfully
      postgres:
        condition: service_healthy
    expose:
      - "3000"
    environment:
      BASE_URL: "${FIDER_BASE_URL}"
      DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable"
      JWT_SECRET: "${FIDER_JWT_SECRET}"
      EMAIL_NOREPLY: "noreply@libnovel.cc"
      EMAIL_SMTP_HOST: "${FIDER_SMTP_HOST}"
      EMAIL_SMTP_PORT: "${FIDER_SMTP_PORT}"
      EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}"
      EMAIL_SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}"
      EMAIL_SMTP_ENABLE_STARTTLS: "${FIDER_SMTP_ENABLE_STARTTLS}"
      OAUTH_GOOGLE_CLIENTID: "${OAUTH_GOOGLE_CLIENTID}"
      OAUTH_GOOGLE_SECRET: "${OAUTH_GOOGLE_SECRET}"
      OAUTH_GITHUB_CLIENTID: "${OAUTH_GITHUB_CLIENTID}"
      OAUTH_GITHUB_SECRET: "${OAUTH_GITHUB_SECRET}"

  # ── Dozzle ──────────────────────────────────────────────────────────────────
  # Watches both homelab and prod containers.
  # Prod agent runs on 165.22.70.138:7007 (added separately to prod compose).
  dozzle:
    image: amir20/dozzle:latest
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./dozzle/users.yml:/data/users.yml:ro
    expose:
      - "8080"
    environment:
      DOZZLE_AUTH_PROVIDER: simple
      DOZZLE_HOSTNAME: "logs.libnovel.cc"
      DOZZLE_REMOTE_AGENT: "prod@165.22.70.138:7007"
    healthcheck:
      test: ["CMD", "/dozzle", "healthcheck"]
      interval: 15s
      timeout: 5s
      retries: 5

  # ── Uptime Kuma ─────────────────────────────────────────────────────────────
  uptime-kuma:
    image: louislam/uptime-kuma:1
    restart: unless-stopped
    volumes:
      - uptime_kuma_data:/app/data
    expose:
      - "3001"
    healthcheck:
      test: ["CMD", "extra/healthcheck"]
      interval: 15s
      timeout: 5s
      retries: 5

  # ── Gotify ──────────────────────────────────────────────────────────────────
  gotify:
    image: gotify/server:latest
    restart: unless-stopped
    volumes:
      - gotify_data:/app/data
    expose:
      - "80"
    environment:
      GOTIFY_DEFAULTUSER_NAME: "${GOTIFY_ADMIN_USER}"
      GOTIFY_DEFAULTUSER_PASS: "${GOTIFY_ADMIN_PASS}"
      GOTIFY_SERVER_PORT: "80"
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:80/health"]
      interval: 15s
      timeout: 5s
      retries: 5

  # ── Redis (Asynq task queue) ────────────────────────────────────────────────
  # Dedicated Redis instance for Asynq job dispatch.
  # The prod backend enqueues jobs via redis.libnovel.cc:6380 (Caddy TLS proxy →
  # host:6379). The runner reads from this instance directly on the Docker network.
  # Port is bound to 0.0.0.0:6379 so the Caddy layer4 proxy on prod can reach it.
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ── LibreTranslate ──────────────────────────────────────────────────────────
  # Self-hosted machine translation. Runner connects via http://libretranslate:5000.
  # Only English → configured target languages are loaded to save RAM.
  libretranslate:
    image: libretranslate/libretranslate:latest
    restart: unless-stopped
    environment:
      LT_API_KEYS: "true"
      LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
      LT_LOAD_ONLY: "en,ru,id,pt,fr"
      LT_DISABLE_WEB_UI: "true"
      LT_UPDATE_MODELS: "false"
    expose:
      - "5000"
    volumes:
      - libretranslate_data:/app/db
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:5000/languages"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 60s

  # ── Valkey ──────────────────────────────────────────────────────────────────
  # Used by GlitchTip for task queuing.
  valkey:
    image: valkey/valkey:7-alpine
    restart: unless-stopped
    expose:
      - "6379"
    volumes:
      - valkey_data:/data
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ── Grafana Alloy (Faro RUM receiver) ───────────────────────────────────────
  # Receives browser telemetry from @grafana/faro-web-sdk (Web Vitals, traces,
  # errors). Exposes POST /collect at faro.libnovel.cc via cloudflared.
  # Forwards traces to otel-collector (→ Tempo) and logs to Loki directly.
  alloy:
    image: grafana/alloy:latest
    restart: unless-stopped
    command: ["run", "--server.http.listen-addr=0.0.0.0:12348", "/etc/alloy/alloy.river"]
    volumes:
      - ./otel/alloy.river:/etc/alloy/alloy.river:ro
    expose:
      - "12347"  # Faro HTTP receiver (POST /collect)
      - "12348"  # Alloy UI / health endpoint
    depends_on:
      - otel-collector
      - loki

  # ── OTel Collector ──────────────────────────────────────────────────────────
  # Receives OTLP from backend/ui/runner, fans out to Tempo + Prometheus + Loki.
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    restart: unless-stopped
    volumes:
      - ./otel/collector.yaml:/etc/otelcol-contrib/config.yaml:ro
    expose:
      - "4317"  # OTLP gRPC
      - "4318"  # OTLP HTTP
      - "8888"  # Collector self-metrics (scraped by Prometheus)
    depends_on:
      - tempo
      - prometheus
      - loki
    # No healthcheck — distroless image has no shell or curl

  # ── Tempo ───────────────────────────────────────────────────────────────────
  # Distributed trace storage. Receives OTLP from the collector.
  tempo:
    image: grafana/tempo:2.6.1
    restart: unless-stopped
    command: ["-config.file=/etc/tempo.yaml"]
    volumes:
      - ./otel/tempo.yaml:/etc/tempo.yaml:ro
      - tempo_data:/var/tempo
    expose:
      - "3200"  # Tempo query API (queried by Grafana)
      - "4317"  # OTLP gRPC ingest (collector → tempo)
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3200/ready"]
      interval: 15s
      timeout: 5s
      retries: 5

  # ── Prometheus ──────────────────────────────────────────────────────────────
  # Scrapes metrics from backend (via prod), runner, and otel-collector.
  prometheus:
    image: prom/prometheus:latest
    restart: unless-stopped
    command:
      - "--config.file=/etc/prometheus/prometheus.yaml"
      - "--storage.tsdb.path=/prometheus"
      - "--storage.tsdb.retention.time=30d"
      - "--web.enable-remote-write-receiver"
    volumes:
      - ./otel/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
      - prometheus_data:/prometheus
    expose:
      - "9090"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
      interval: 15s
      timeout: 5s
      retries: 5

  # ── Loki ────────────────────────────────────────────────────────────────────
  # Log aggregation. Receives logs from OTel collector. Replaces manual Dozzle
  # tailing for structured log search.
  loki:
    image: grafana/loki:latest
    restart: unless-stopped
    command: ["-config.file=/etc/loki/loki.yaml"]
    volumes:
      - ./otel/loki.yaml:/etc/loki/loki.yaml:ro
      - loki_data:/loki
    expose:
      - "3100"
    # No healthcheck — distroless image has no shell or curl

  # ── Grafana ─────────────────────────────────────────────────────────────────
  # Single UI for traces (Tempo), metrics (Prometheus), and logs (Loki).
  # Accessible at grafana.libnovel.cc via Cloudflare Tunnel.
  grafana:
    image: grafana/grafana:latest
    restart: unless-stopped
    depends_on:
      - tempo
      - prometheus
      - loki
    expose:
      - "3000"
    volumes:
      - grafana_data:/var/lib/grafana
      - ./otel/grafana/provisioning:/etc/grafana/provisioning:ro
    environment:
      GF_SERVER_ROOT_URL: "https://grafana.libnovel.cc"
      GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER}"
      GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}"
      GF_AUTH_ANONYMOUS_ENABLED: "false"
      GF_FEATURE_TOGGLES_ENABLE: "traceqlEditor"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
      interval: 15s
      timeout: 5s
      retries: 5

  # ── Kokoro-FastAPI (GPU TTS) ────────────────────────────────────────────────
  # OpenAI-compatible TTS service backed by the Kokoro model, running on the
  # homelab RTX 3050 (8 GB VRAM). Replaces the broken kokoro.kalekber.cc DNS.
  # Voices match existing IDs: af_bella, af_sky, af_heart, etc.
  # The runner reaches it at http://kokoro-fastapi:8880 via the Docker network.
  kokoro-fastapi:
    image: kokoro-fastapi:latest
    restart: unless-stopped
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    expose:
      - "8880"
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:8880/health"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 60s

  # ── pocket-tts (CPU TTS) ────────────────────────────────────────────────────
  # Lightweight CPU-only TTS using kyutai-labs/pocket-tts.
  # Image is built locally on homelab from https://github.com/kyutai-labs/pocket-tts
  # (no prebuilt image published): cd /tmp && git clone --depth=1 https://github.com/kyutai-labs/pocket-tts.git && docker build -t pocket-tts:latest /tmp/pocket-tts
  # OpenAI-compatible: POST /tts (multipart form) on port 8000.
  # Voices: alba, marius, javert, jean, fantine, cosette, eponine, azelma, etc.
  # Not currently used by the runner (runner uses kokoro-fastapi), but available
  # for experimentation / fallback.
  pocket-tts:
    image: pocket-tts:latest
    restart: unless-stopped
    command: ["uv", "run", "pocket-tts", "serve", "--host", "0.0.0.0"]
    expose:
      - "8000"
    volumes:
      - pocket_tts_cache:/root/.cache/pocket_tts
      - hf_cache:/root/.cache/huggingface
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s

  # ── Watchtower ──────────────────────────────────────────────────────────────
  # Auto-updates runner image when CI pushes a new tag.
  # Only watches services with the watchtower label.
  # doppler binary is mounted from the host so watchtower fetches fresh secrets
  # on every start (notification URL, credentials) without baking them in.
  watchtower:
    image: containrrr/watchtower:latest
    restart: unless-stopped
    entrypoint: ["/usr/bin/doppler", "run", "--project", "libnovel", "--config", "prd_homelab", "--"]
    command: ["/watchtower", "--label-enable", "--interval", "300", "--cleanup"]
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /usr/bin/doppler:/usr/bin/doppler:ro
      - /root/.doppler:/root/.doppler:ro
    environment:
      HOME: "/root"
      DOCKER_API_VERSION: "1.44"

volumes:
  postgres_data:
  redis_data:
  libretranslate_data:
  valkey_data:
  uptime_kuma_data:
  gotify_data:
  tempo_data:
  prometheus_data:
  loki_data:
  grafana_data:
  pocket_tts_cache:
  hf_cache:
  glitchtip_uploads:
