errserve 0.3.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 --------------------------------------------------------------
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"]