errserve 0.8.0

Servidor de páginas de error para Traefik que devuelve el código de error real
###############################################################################
# errserve — servidor de páginas de error para Traefik
#
# Multi-stage:
#   * builder  -> `rust:alpine`, cuyo target nativo del toolchain **ya es**
#                 `x86_64-unknown-linux-musl`. Por eso aquí NO hay nada de la
#                 maquinaria de cross-compilación que arrastraba `simpleserver`:
#                 ni `rustup target add`, ni `--target`, ni `musl-tools`, ni
#                 `OPENSSL_LIB_DIR`, ni `cmake`/`pkg-config`. El proyecto no
#                 tiene ninguna dependencia con C (ni OpenSSL), así que el
#                 enlazado estático con musl sale "gratis" al elegir esta imagen.
#   * runtime  -> `alpine`, la libc contra la que se enlazó. El binario queda
#                 estático, de modo que builder y runtime no comparten ABI ni
#                 hay que igualar la versión de glibc.
###############################################################################

# Fijadas a una versión concreta para builds reproducibles. Basta con cambiarlas
# aquí (o con `--build-arg`) para subir de toolchain o de base.
ARG RUST_IMAGE=rust:1.99.1-alpine3.23
ARG RUNTIME_IMAGE=alpine:3.23.7

###############################################################################
## Stage: builder
###############################################################################
FROM ${RUST_IMAGE} AS builder

WORKDIR /app

# --- Capa de dependencias ----------------------------------------------------
# Los manifiestos cambian mucho menos que `src/`, así que se copian primero:
# mientras `Cargo.toml` y `Cargo.lock` no cambien, esta capa se reutiliza y el
# build final solo recompila el crate local en lugar de todo el árbol de deps.
# `Cargo.lock` se copia (y va versionado) porque esto es un binario: `--locked`
# garantiza que la imagen usa exactamente las versiones del lock, o falla.
# El `main.rs` de relleno existe solo porque `cargo` necesita un crate raíz para
# resolver el grafo; se borra antes de copiar el fuente real.
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p src \
    && echo 'fn main() {}' > src/main.rs \
    && cargo build --release --locked \
    && rm -rf src

# --- Crate real --------------------------------------------------------------
# `content/` es una **entrada de la compilación**, no un adorno del runtime: las
# macros `include_str!` de `src/embedded.rs` leen esos ficheros **al compilar** y
# resuelven la ruta relativa al propio fuente (`src/../content/…`). Sin ellos
# aquí, `cargo build` del crate real muere antes de que el stage de runtime
# exista. Va antes de
# `src/` porque cambia menos: mientras las páginas no cambien, esta capa y la de
# dependencias se reutilizan. El `COPY content` del stage de runtime es la
# **segunda** copia, y sigue **abierta** como decisión: `CONTENT_DIR` es la
# única ruta de estáticos y, como la capa del disco gana a la copia embebida
# (D1), quitarla cambiaría qué bytes se sirven en producción. Las dos opciones
# son defendibles —servir las páginas de la imagen, más fáciles de inspeccionar
# y de parchear, o las del binario, la fuente única que se publica—; el change
# `themes-as-pages` la deja en Open Questions (D8 de su `design.md`).
COPY content ./content
COPY src ./src
# `COPY` puede preservar el mtime del origen; tocar la raíz del crate asegura
# que `cargo` invalida la huella del crate de relleno y recompila el real.
RUN touch src/main.rs \
    && cargo build --release --locked

###############################################################################
## Stage: runtime
###############################################################################
FROM ${RUNTIME_IMAGE} AS runtime

# Etiquetas OCI en el **último** stage: las de un stage intermedio no llegan a
# la imagen final (comprobado: puestas en `builder`, la imagen salía sin ellas).
LABEL org.opencontainers.image.title="errserve" \
      org.opencontainers.image.description="Servidor de páginas de error para Traefik que devuelve el código HTTP real" \
      org.opencontainers.image.authors="Lorenzo Carbonell <a.k.a. atareao>"

# Usuario y grupo de sistema sin privilegios, con uid/gid **fijos** (`-S` y
# `-g 10001 -u 10001`) para que el propietario de ficheros sea determinista en
# cualquier host. Sin shell de login y sin HOME: el contenedor solo ejecuta un
# binario.
RUN addgroup -S -g 10001 app \
    && adduser -S -u 10001 -G app -H -s /sbin/nologin app

WORKDIR /app

# El binario lee los estáticos de `CONTENT_DIR`, cuyo valor por defecto es la
# ruta **relativa** `content`; con `WORKDIR /app` eso resuelve a `/app/content`.
# Aquí se copia `content/` a `/app/content`: una única ruta, coherente entre
# código (`src/config.rs`), Dockerfile y `.dockerignore`. (El bug de
# `simpleserver` era declarar `static` en el código y copiar `content/`.)
COPY --from=builder --chown=10001:10001 /app/target/release/errserve ./errserve
COPY --chown=10001:10001 content ./content

# Valores por defecto documentados; coinciden con `src/config.rs`.
# `CONTENT_DIR` es relativo a `WORKDIR`, de ahí `/app/content`.
ENV PORT=8080 \
    LOG_LEVEL=info \
    CONTENT_DIR=content \
    DEFAULT_ERROR_PAGE=404

# Healthcheck con el `wget` de busybox, ya presente en la base: `curl` no lo
# está y no se instala un paquete solo para esto. `/healthz` responde siempre
# 200 y no pasa por la resolución de páginas de error. `${PORT}` se expande
# desde el `ENV`, así que el healthcheck sigue a un `PORT` sobreescrito.
HEALTHCHECK --interval=30s --timeout=3s --start-period=2s --retries=3 \
    CMD wget -q -O /dev/null "http://127.0.0.1:${PORT}/healthz"

# El puerto que asume la guía de Traefik.
EXPOSE 8080

USER 10001:10001

CMD ["/app/errserve"]