errserve 0.6.0

Servidor de páginas de error para Traefik que devuelve el código de error real
# =============================================================================
# `errserve` para una infraestructura que YA tiene Traefik.
#
#   TRAEFIK_NETWORK=<red de Traefik> docker compose up -d     # o podman-compose
#
# Solo el servicio: Traefik va aparte, en su propio compose o donde viva. Lo que
# hay que darle a este contenedor es (a) la red por la que Traefik lo alcance y
# (b) el router con el middleware `errors` (los labels de abajo, comentados).
#
# El ejemplo local, con Traefik y un backend de demostración incluidos, está en
# `examples/traefik/`; este fichero no lo duplica.
# =============================================================================

name: errserve

services:
  errserve:
    # La imagen publicada está en **GHCR** (`docker pull ghcr.io/atareao/errserve`):
    # no hay nada en Docker Hub. Versiona la etiqueta en producción
    # (`ghcr.io/atareao/errserve:0.3.0`); `latest` solo para probar. Si la has
    # construido en local con `just build`, la etiqueta es esta misma.
    #
    # Si el `pull` responde `denied` sin credenciales, el paquete de GHCR sigue
    # privado: nace así aunque el repositorio sea público y hay que hacerlo
    # público a mano una vez (ver «Publicación» en README.md).
    image: ${ERRSERVE_IMAGE:-ghcr.io/atareao/errserve:latest}
    container_name: errserve
    restart: unless-stopped

    # --- Parada limpia --------------------------------------------------------
    # El binario es PID 1 del contenedor, y el kernel **no entrega a PID 1** las
    # señales con acción por defecto: un `SIGTERM` no lo termina, así que
    # `podman stop`/`docker stop` agota su tiempo de gracia y acaba en `SIGKILL`.
    # Medido con este mismo fichero: sin `init` la parada tarda **10,4 s** y
    # sale el aviso `StopSignal SIGTERM failed… resorting to SIGKILL`; con él,
    # **0,4 s** y sin aviso. `init: true` pone a tini delante —lo mismo que hace
    # la receta `just run` con `--init`—, que reenvía la señal al proceso. No es
    # por el estado (este servicio no guarda ninguno, y sin esto no se pierde
    # nada): es por no esperar diez segundos en cada `down`.
    init: true

    # --- Configuración, solo por entorno --------------------------------------
    # Son los valores por defecto de `src/config.rs`, escritos aquí para que
    # quien lea el fichero no tenga que abrirlos. Una variable presente pero
    # VACÍA es error de arranque, no "usa el defecto"; y `THEME` o `LOG_FORMAT`
    # con un valor que no esté en su lista también lo son.
    environment:
      PORT: "8080"              # puerto de escucha dentro del contenedor
      LOG_LEVEL: "info"         # filtro de tracing (debug, info, warn, error)
      LOG_FORMAT: "text"        # text | json (json para recolectores)
      CONTENT_DIR: "content"    # relativo a WORKDIR (/app/content en la imagen)
      DEFAULT_ERROR_PAGE: "404" # fuera de 400..=599 es error de arranque
      THEME: "default"          # default | minimal | terminal | paper | amber | compact

    # --- Puertos --------------------------------------------------------------
    # En producción NO se publica ninguno: Traefik llega al contenedor por la
    # red interna, y publicarlo dejaría el servicio accesible por su cuenta.
    # Para depurar el acceso directo (el caso que justifica el proyecto, y el
    # único en el que nuestro código decide), descomenta esto y usa
    # `curl -i localhost:8080/404`:
    #ports:
    #  - "127.0.0.1:8080:8080"

    healthcheck:
      # `/healthz` responde siempre 200 y no pasa por la resolución de páginas
      # de error. `wget` es el de busybox, ya presente en la imagen: no se
      # instala nada. La imagen ya declara este mismo healthcheck en su
      # Dockerfile; repetirlo aquí lo deja a la vista y, sobre todo, cubre a
      # quien construya la imagen en formato OCI (podman/buildah sin
      # `--format docker`), que **descarta el HEALTHCHECK del Dockerfile**.
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 2s

    # --- Endurecimiento -------------------------------------------------------
    # Las tres van puestas, y las tres son gratis aquí:
    #
    #   * `read_only`: el binario está enlazado estáticamente (musl: no carga
    #     nada en tiempo de ejecución) y las páginas viven **dentro** de la
    #     imagen (`CONTENT_DIR=content`), así que el proceso no escribe en
    #     disco: solo abre ficheros en lectura. **No hace falta `tmpfs`**: no
    #     hay temporales, ni caché, ni socket que crear —`wget` del healthcheck
    #     escribe en `/dev/null`, que siempre existe aunque el sistema de
    #     ficheros sea de solo lectura—. Añadir uno daría al contenedor un
    #     sitio donde escribir sin que nada lo necesite.
    #   * `cap_drop: [ALL]`: **ninguna** capacidad le hace falta. No abre
    #     puertos privilegiados (el 8080 es interno, quien mapea al host es el
    #     motor), no toca la red más allá de escuchar, no monta nada y no
    #     cambia de usuario: el proceso ya arranca como uid/gid 10001, sin
    #     shell de login (el `USER` del Dockerfile).
    #   * `no-new-privileges:true`: cierra la puerta a que un binario setuid
    #     que hubiera dentro de la imagen gane privilegios sobre el proceso que
    #     lo lanza.
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

    networks:
      - proxy

    # =========================================================================
    # Traefik — LABELS LISTOS PARA DESCOMENTAR
    #
    # Requisitos en el Traefik que ya tengas:
    #   --providers.docker=true
    #   --providers.docker.exposedByDefault=false   (recomendado: solo publica
    #                                                lo que lleve traefik.enable)
    # y que Traefik esté en la MISMA red que este contenedor (la de abajo).
    #
    # El puerto del servicio es el **8080 interno del contenedor**, no un puerto
    # del host: Traefik habla con el contenedor por la red del compose.
    #
    # Con esto montado, el servicio cubre dos sitios a la vez:
    #   * el router de respaldo (`errserve-catchall`): cualquier Host que no case
    #     con otro router, sin upstream cuyo código conservar. Aquí el código lo
    #     decide `errserve` — es el caso que justifica el proyecto;
    #   * `errpages`, el middleware para colgar de los routers de tus
    #     aplicaciones (añade `middlewares: errpages@docker` al router, o el
    #     label `traefik.http.routers.<tu-app>.middlewares`). Ahí el estado lo
    #     conserva Traefik y el cuerpo y las cabeceras son nuestros.
    #
    #labels:
    #  traefik.enable: "true"
    #
    #  # --- El servicio -------------------------------------------------------
    #  traefik.http.services.errserve.loadbalancer.server.port: "8080"
    #
    #  # --- El middleware ----------------------------------------------------
    #  # `/{status}` es la plantilla que produce la petición `/404`: el código
    #  # viaja en el path. No la cambies sin cambiar también el servicio.
    #  traefik.http.middlewares.errpages.errors.status: "400-599"
    #  traefik.http.middlewares.errpages.errors.service: errserve
    #  traefik.http.middlewares.errpages.errors.query: "/{status}"
    #
    #  # --- El router de respaldo --------------------------------------------
    #  # Prioridad explícita y baja: sin ella Traefik ordena por longitud de la
    #  # regla y este catch-all ganaría a tus routers.
    #  # La regla es `HostRegexp(.+)` (sintaxis de Go, v3). NO uses
    #  # `HostRegexp(`{host:.+}`)` de la guía de error-pages: es sintaxis v2 y en
    #  # v3 no casa con nada (medido), con lo que te quedarías con el 404 en
    #  # texto plano de Traefik. Alternativa v3: `PathPrefix(`/`)`.
    #  # Y lleva una segunda conjunción, `&& !Path(`/metrics`)`, que tampoco es
    #  # opcional: el respaldo atiende cualquier Host, así que sin ella publica
    #  # los endpoints de servicio del binario —y `/metrics` expone el contador
    #  # de páginas servidas, que es el perfil de fallos de tu infraestructura—
    #  # a quien pregunte por un Host que no exista. Con la exclusión, ese caso
    #  # lo contesta Traefik con su propio 404 en texto plano (19 B) y el
    #  # respaldo sigue sirviendo `404` y `503` reales en el mismo Host.
    #  traefik.http.routers.errserve-catchall.rule: HostRegexp(`.+`) && !Path(`/metrics`)
    #  traefik.http.routers.errserve-catchall.entrypoints: web
    #  traefik.http.routers.errserve-catchall.service: errserve
    #  traefik.http.routers.errserve-catchall.middlewares: errpages
    #  traefik.http.routers.errserve-catchall.priority: "1"
    #
    #  # Si prefieres no publicar el router de respaldo y solo usarlo como
    #  # servicio de errores de tus aplicaciones, borra las cuatro líneas de
    #  # `errserve-catchall` y quédate con el servicio y el middleware.
    # =========================================================================

# Sin volúmenes: el binario es estático y las páginas van dentro de la imagen
# (`CONTENT_DIR=content`), así que el contenedor no guarda estado y se puede
# recrear cuando haga falta. No hay nada que persistir.

networks:
  # La red por la que Traefik alcanza este contenedor: la MISMA que Traefik ya
  # usa. Por defecto se asume el nombre `traefik`; si el tuyo es otro, exporta
  # `TRAEFIK_NETWORK=<nombre>` (lo ves con `docker network ls`). Al ser
  # `external`, este compose no la crea: tiene que existir antes.
  proxy:
    external: true
    name: ${TRAEFIK_NETWORK:-traefik}