# syntax=docker/dockerfile:1

# One image, holding both halves of the app.
#
# The frontend is not served from here: it is lifted out of this image onto the
# directory Caddy already serves, which is why it sits at a known path.
#
# The frontend addresses the API as `/api/...`, relative, so this artefact is
# the same everywhere and nothing is baked in per environment.

# ---------------------------------------------------------------- frontend --
FROM rust:1.89-slim-bookworm AS web

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl \
 && rm -rf /var/lib/apt/lists/*

# Node from the official image rather than Debian's, which ships npm 9.
# Tailwind v4's `oxide` is a native binary chosen by an optional dependency
# keyed on platform *and* libc, and npm 9 does not understand the `libc` field
# -- so it silently installs no binding at all and the build dies on
# `Cannot find module '@tailwindcss/oxide-linux-x64-gnu'`. Copied from a pinned
# image rather than piped from a setup script into a shell, so which Node this
# is stays a line in this file.
COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node
COPY --from=node:22-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
 && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx

# Trunk as a released binary rather than `cargo install trunk`, which builds it
# from source and takes longer than everything else here put together.
ARG TRUNK_VERSION=0.21.14
RUN curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
  | tar -xzC /usr/local/bin trunk

RUN rustup target add wasm32-unknown-unknown

WORKDIR /app

# Tailwind runs from Trunk's pre-build hook as `npx --prefix ../..`, so the
# packages have to be at the workspace root. Copied before the source so a
# change to the Rust does not reinstall them.
COPY package.json package-lock.json ./
RUN npm ci

COPY . .

WORKDIR /app/crates/news-web
# Cache mounts sharing an id are ONE volume, and this stage builds in parallel
# with the server stage below. A shared registry volume let two cargo
# processes unpack the same crate into the same directory at once
# (".cargo-ok: File exists" in one build, "manifest is missing [package]"
# where the other read a half-unpacked tree), and cargo's package-cache lock
# cannot prevent it: only the registry directory is mounted, never
# CARGO_HOME's .package-cache lock file. Hence per-stage ids. A side benefit:
# fresh ids start from empty caches, so a crate some earlier race
# half-unpacked into the shared volume cannot poison later builds.
RUN --mount=type=cache,id=web-registry,target=/usr/local/cargo/registry \
    --mount=type=cache,id=web-target,target=/app/target \
    trunk build --release

# ----------------------------------------------------------------- backend --
FROM rust:1.89-slim-bookworm AS server

# Build metadata for /api/version, baked in by build.rs (see that file for
# the fallbacks). release.yml passes the real values; a bare `docker build`
# gets `dev`/`unknown`, matching a plain `cargo build` on a dev machine.
ARG NEWS_BUILD_VERSION=dev
ARG NEWS_BUILD_SHA=unknown

WORKDIR /app
COPY . .

ENV NEWS_BUILD_VERSION=${NEWS_BUILD_VERSION} \
    NEWS_BUILD_SHA=${NEWS_BUILD_SHA}

# The binary is copied out of the cache mount because a cache mount is not part
# of the layer: whatever is written there is gone by the time the next stage
# looks. This is also why there is no dummy-source dance -- the cache does what
# that trick was inventing.
# Cache ids are per-stage (this one never shares volumes with the web stage);
# see the comment above the web stage's RUN for why that must stay true.
RUN --mount=type=cache,id=server-registry,target=/usr/local/cargo/registry \
    --mount=type=cache,id=server-target,target=/app/target \
    cargo build --release --locked -p news-server \
 && cp target/release/news-server /news-server

# ----------------------------------------------------------------- runtime --
FROM debian:bookworm-slim AS runtime

# TLS is rustls with its roots compiled in, so there is no OpenSSL here and
# nothing to keep patched. `tzdata` is not optional: the digest scheduler and
# quiet-hour gate depend on the zone database.
RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates tzdata \
 && rm -rf /var/lib/apt/lists/*

COPY --from=server /news-server /usr/local/bin/news-server
# Where the updater looks for the frontend. Nothing serves it from inside this
# container.
COPY --from=web /app/crates/news-web/dist /srv/dist

# Migrations are compiled into the binary by `sqlx::migrate!` and run on
# startup, and the database is created if it is missing -- so there is no
# entrypoint script, no `sqlx-cli` in the image, and nothing that can decide to
# carry on after a failed migration the way a shell entrypoint can.
# The mount point, created here rather than left to the volume. SQLite creates
# the database *file* if it is missing but not the directory holding it, so an
# image without this starts only when something happens to have mounted
# something at /db, and fails with "unable to open database file" when nothing
# has -- which says nothing about what is actually wrong.
RUN mkdir -p /db

ENV NEWS_DATABASE_URL=sqlite:///db/news.db

EXPOSE 3000
CMD ["news-server"]
