# CluPilot — DEV stack. Everything runs in containers (host has only Docker).
# `app` = php-fpm + nginx + vite (supervisor). reverb/queue reuse the image.
# Ports + UID are env-driven (see .env). No secrets hardcoded here.

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
      args:
        HOST_UID: ${HOST_UID:-1000}
        HOST_GID: ${HOST_GID:-1000}
    image: clupilot-app:dev
    restart: unless-stopped
    volumes:
      - .:/var/www/html
      # RegisterHostDns/PurgeHost write here (see queue-provisioning below,
      # which is where those actually run); mounted here too so an ad hoc
      # `artisan` in this container sees and can touch the same entries.
      - dns-hosts:/etc/clupilot/dns-hosts
    ports:
      # Loopback by DEFAULT. Docker publishes ports ahead of UFW, so a backend
      # published on 0.0.0.0 is reachable from the internet even with the
      # firewall closed — and reaching it directly skips the reverse proxy, and
      # with it every hostname and address rule the console depends on.
      - "${APP_PORT:-127.0.0.1:8080}:80"
      - "${VITE_PORT:-5173}:5173"
    # Laravel reads .env from the bind mount. Do NOT inject it as real env vars
    # (env_file): that would override the test env (phpunit force) and let the
    # test suite run against the dev DB. Only vite needs these at process level.
    environment:
      VITE_HMR_HOST: ${VITE_HMR_HOST:-localhost}
      VITE_PORT: ${VITE_PORT:-5173}
      # Vite dev server is opt-in — see docker/supervisor/clupilot.conf.
      VITE_AUTOSTART: ${VITE_AUTOSTART:-false}
    depends_on:
      mariadb:
        condition: service_healthy
      redis:
        condition: service_started

  reverb:
    image: clupilot-app:dev
    restart: unless-stopped
    # www-data, for the same reason as the queue above: a Blade view compiled
    # by a root process can never be refreshed by the web process again.
    user: "www-data"
    command: php artisan reverb:start --host=0.0.0.0 --port=8080
    volumes:
      - .:/var/www/html
    ports:
      - "${REVERB_HOST_PORT:-127.0.0.1:8081}:8080"
    depends_on:
      - app
      - redis

  queue:
    image: clupilot-app:dev
    restart: unless-stopped
    # As www-data, like php-fpm in the app container — NOT as root, which is
    # what a compose service without `user:` gets.
    #
    # Blade compiles a view the first time it is rendered and then touch()es the
    # compiled file to the source's mtime. touch() with an explicit time needs
    # ownership, so a compiled file written by a ROOT worker cannot be refreshed
    # afterwards by the web process: every request for that view answered 500
    # with "touch(): Utime failed: Operation not permitted". Reported from live
    # right after a deployment, and it is the queue that renders mails.
    user: "www-data"
    command: php artisan queue:work redis --tries=3 --timeout=90
    volumes:
      - .:/var/www/html
    depends_on:
      - app
      - redis
      - mariadb

  # Dedicated worker for long-running provisioning steps (own timeout, single try;
  # the DB state machine owns retries). Separate from the fast default queue.
  # It also acts as the WireGuard hub (LocalWireguardHub runs `wg set wg0` here),
  # so it needs NET_ADMIN, the tun device, a persistent wg config, and the WG
  # UDP port published on the host for peers to reach. Set CLUPILOT_WG_ENDPOINT
  # to <vm-public-ip>:${WG_HUB_PORT} and CLUPILOT_WG_HUB_PUBKEY to wg0's key.
  queue-provisioning:
    image: clupilot-app:dev
    restart: unless-stopped
    # Bring up wg0 (from the mounted config, once the operator has installed it)
    # before starting the worker, so LocalWireguardHub can manage peers.
    # The one worker that stays root: it brings wg0 up and runs `wg set` for
    # every peer change, which needs NET_ADMIN on the running process. It
    # renders no mail, so it is not the one that was writing compiled views —
    # and update.sh normalises ownership at the end of every deployment, which
    # heals it if it ever does.
    command: sh -c 'wg-quick up wg0 2>/dev/null || true; exec php artisan queue:work provisioning --queue=provisioning --tries=1 --timeout=2100 --sleep=3'
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    sysctls:
      - net.ipv4.ip_forward=1
    ports:
      - "${WG_HUB_PORT:-51820}:51820/udp"
    volumes:
      - .:/var/www/html
      - wireguard:/etc/wireguard
      # RegisterHostDns writes one file per host here (name -> wg_ip);
      # PurgeHost removes it on host deletion. vpn-dns mounts the same volume
      # read-only and watches it (dnsmasq --hostsdir) — this is HOW a host
      # gets a name now, since it can no longer go in public DNS.
      - dns-hosts:/etc/clupilot/dns-hosts
    depends_on:
      - app
      - redis
      - mariadb

  # ── The console, reachable from inside the tunnel ───────────────────────────
  #
  # Both of these share the provisioning container's network namespace, which is
  # where wg0 (10.66.0.1) lives. That is the whole point: they answer ON the
  # tunnel address, so a VPN client needs no route beyond the management subnet
  # it already has, the host's docker bridge is never exposed to the tunnel, and
  # the console sees the client's real 10.66.0.x address rather than a proxy's.
  #
  # Nothing here is published to the host. If the tunnel is down, neither exists
  # as far as the outside world is concerned.
  #
  # Both are optional: an installation without VPN_INTERNAL_HOST configured
  # simply does not start them (profile `vpn`).
  vpn-dns:
    image: 4km3/dnsmasq:2.90-r3
    restart: unless-stopped
    profiles: ["vpn"]
    network_mode: "service:queue-provisioning"
    cap_add:
      - NET_ADMIN
    volumes:
      # Host names (fsn-01.node.… -> wg_ip), written by RegisterHostDns onto
      # the same volume — see queue-provisioning above. Read-only here:
      # dnsmasq only ever reads this directory, never writes it.
      - dns-hosts:/etc/clupilot/dns-hosts:ro
    command: >-
      --keep-in-foreground --log-facility=-
      --listen-address=${CLUPILOT_WG_HUB_ADDRESS:-10.66.0.1} --bind-interfaces
      --no-resolv --server=1.1.1.1 --server=9.9.9.9
      --address=/${VPN_INTERNAL_HOST:-admin.invalid}/${CLUPILOT_WG_HUB_ADDRESS:-10.66.0.1}
      --hostsdir=/etc/clupilot/dns-hosts
    depends_on:
      - queue-provisioning

  vpn-gateway:
    image: caddy:2-alpine
    restart: unless-stopped
    profiles: ["vpn"]
    network_mode: "service:queue-provisioning"
    volumes:
      - ./docker/caddy/vpn.Caddyfile:/etc/caddy/Caddyfile:ro
      # The certificate the public Caddy already obtains and renews. Read-only,
      # and shared rather than duplicated: a second ACME client would be a
      # second thing to renew, and the private address it would have to answer
      # on cannot satisfy an HTTP challenge anyway.
      - ${CADDY_DATA_DIR:-/var/lib/caddy/.local/share/caddy}:/certs:ro
    environment:
      VPN_INTERNAL_HOST: ${VPN_INTERNAL_HOST:-admin.invalid}
      # The same address the client is told to use as its resolver. Hard-coding
      # it here while the application reads a configurable one would produce a
      # config pointing at an address nothing listens on.
      VPN_HUB_ADDRESS: ${CLUPILOT_WG_HUB_ADDRESS:-10.66.0.1}
      # Plain-HTTP health port on the hub address, for the deployment to ask
      # whether this gateway is really listening in the tunnel's namespace.
      # Never published; 10.66.0.1 exists only inside the tunnel.
      VPN_HEALTH_PORT: ${VPN_HEALTH_PORT:-8081}
      VPN_CERT_PATH: ${VPN_CERT_PATH:-}
      VPN_KEY_PATH: ${VPN_KEY_PATH:-}
    depends_on:
      - queue-provisioning
      - app

  scheduler:
    image: clupilot-app:dev
    restart: unless-stopped
    # www-data, for the same reason as the queue above: a Blade view compiled
    # by a root process can never be refreshed by the web process again.
    user: "www-data"
    command: php artisan schedule:work
    volumes:
      - .:/var/www/html
    depends_on:
      - app
      - redis
      - mariadb

  # Gitea Actions runner. Opt-in via profile, because a development machine does
  # not need to burn cycles on CI:  docker compose --profile ci up -d runner
  runner:
    image: gitea/act_runner:${GITEA_RUNNER_VERSION:-0.2.6}
    restart: unless-stopped
    profiles: [ci]
    environment:
      GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.bave.dev}
      GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_TOKEN:-}
      GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-clupilot-local}
      # ubuntu-latest maps to a real image rather than the host, so a workflow
      # behaves the same here as anywhere else.
      GITEA_RUNNER_LABELS: ubuntu-latest:docker://catthehacker/ubuntu:act-22.04
    volumes:
      - runner-data:/data
      # The runner starts job containers as siblings, which needs the socket.
      - /var/run/docker.sock:/var/run/docker.sock

  mariadb:
    image: mariadb:11.4
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: ${DB_DATABASE:-clupilot}
      MARIADB_USER: ${DB_USERNAME:-clupilot}
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    ports:
      # host-side port is separate from DB_PORT (which Laravel uses to reach
      # mariadb:3306 inside the network) so changing it can't break the app.
      - "127.0.0.1:${DB_HOST_PORT:-3306}:3306"
    volumes:
      - db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      timeout: 5s
      retries: 20

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis-data:/data

  # Uptime Kuma bridge: Kuma has no write REST API (monitor CRUD is Socket.IO),
  # so this translates the REST contract CluPilot speaks. Opt-in:
  #   docker compose --profile monitoring up -d kuma-bridge
  # Then set MONITORING_API_URL=http://kuma-bridge:8080 in .env.
  kuma-bridge:
    build:
      context: ./docker/kuma-bridge
    image: clupilot-kuma-bridge:dev
    restart: unless-stopped
    profiles: ["monitoring"]
    environment:
      KUMA_URL: "${KUMA_URL:-}"
      KUMA_USERNAME: "${KUMA_USERNAME:-}"
      KUMA_PASSWORD: "${KUMA_PASSWORD:-}"
      KUMA_TOTP: "${KUMA_TOTP:-}"
      BRIDGE_TOKEN: "${MONITORING_API_TOKEN:-}"

volumes:
  db-data:
  runner-data:
  redis-data:
  wireguard:
  dns-hosts:
