# Phase A production runner image for the hobby-ci-runner fly.io app.
#
# Multi-stage build:
#   1. `toolchain` — debian-slim + OS deps + rustup + nightly + mold +
#      sccache + cargo-leptos + cargo-nextest. Forms the runtime base.
#   2. `chef`      — toolchain + cargo-chef (build-only; not in final).
#   3. `planner`   — runs `cargo chef prepare` over the repo to extract
#                    a Cargo.lock-keyed dep recipe.json.
#   4. `cooker`    — runs `cargo chef cook --tests` and `--clippy` to
#                    pre-compile every 3rd-party dep into /build/target.
#                    Writes `.chef-stamp` capturing Cargo.lock sha256 +
#                    toolchain + build metadata.
#   5. `final`     — toolchain + act_runner + entrypoint. Pulls the
#                    cooked target/ from `cooker` into /opt/prebuilt-target/.
#                    The runner entrypoint hardlink-seeds this into
#                    /work/cargo-target on boot, so every PR step starts
#                    with deps already compiled.
#
# Atlas is intentionally NOT baked: `make it` runs atlas as a docker
# container via rustainers (image arigaio/atlas:1.2.0-community-alpine),
# and deploy migrations run on the orchestrator's pre-installed atlas.
# The runner image only needs dockerd to host the atlas container.
#
# Tokens, secrets, and any *.env files are NEVER baked into image
# layers. They're injected at machine boot via Fly secrets (R2 cache
# credentials, app-level) or via the spawn payload (one-shot Forgejo
# runner registration token, per-machine). See ops/fly/README.md
# § "Security model" for the full credential flow.
#
# Version constants must stay in sync with ops/bootstrap-host.sh.

# ─── Stage 1: toolchain base ─────────────────────────────────────
FROM debian:bookworm-slim AS toolchain

ARG MOLD_VERSION=v2.41.0
ARG MOLD_SHA256=a3696680d99e692970590a178bc3a33d78d60d1c6dc9db7a11b557b02b751f5d
ARG SCCACHE_VERSION=v0.8.2
# act_runner version. Bump alongside any Forgejo server upgrade so
# the runner stays API-compatible. See Forgejo release notes for the
# matching pair: https://code.forgejo.org/forgejo/runner/releases
ARG ACT_RUNNER_VERSION=v12.10.1
ARG ACT_RUNNER_SHA256=9e0378b4a22b95da98b350cfcb1b3844c1929a354a961f1ca7b49122b42abcc1

ENV RUSTUP_HOME=/usr/local/rustup \
    CARGO_HOME=/usr/local/cargo \
    PATH=/usr/local/cargo/bin:$PATH

# ─── OS deps + Docker engine + compose plugin ────────────────────
# docker-compose-plugin lives in Docker's apt repo (not Debian's);
# we add Docker's repo and install docker-ce + plugin from there.
#
# pkg-config + libssl-dev + libpq-dev: required by Rust -sys crates
# in our dep tree (openssl-sys, libpq-sys etc). Discovered missing
# in Phase 0 spike — `bootstrap-host.sh` inherits them transitively
# from Debian's base build-essential, but the slim variant doesn't.
RUN apt-get update && apt-get install -y --no-install-recommends \
        ca-certificates \
        curl \
        jq \
        make \
        clang \
        git \
        git-lfs \
        gnupg \
        procps \
        fuse-overlayfs \
        fuse3 \
        pkg-config \
        libssl-dev \
        libpq-dev \
        nodejs \
        npm \
        zstd \
    && install -m 0755 -d /etc/apt/keyrings \
    && curl -fsSL https://download.docker.com/linux/debian/gpg \
        -o /etc/apt/keyrings/docker.asc \
    && chmod a+r /etc/apt/keyrings/docker.asc \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" \
        > /etc/apt/sources.list.d/docker.list \
    && apt-get update && apt-get install -y --no-install-recommends \
        docker-ce \
        docker-ce-cli \
        containerd.io \
        docker-compose-plugin \
    && rm -rf /var/lib/apt/lists/*

# ─── Docker daemon config: fuse-overlayfs storage driver ─────────
# fly.io's firecracker VM blocks BOTH the new containerd-snapshotter
# overlayfs AND the legacy overlay2 driver with
# "failed to mount overlay: invalid argument" — overlay-on-overlay
# isn't supported in the VM kernel. fuse-overlayfs runs in userspace
# (via FUSE) and works regardless. Small perf penalty vs native
# overlay but the only thing that works inside a fly.io machine.
# Also pin data-root to /work/docker so docker uses the per-machine
# fly volume (the default 7.8 GB rootfs is too small).
# Discovered during Phase 0 spike — see ops/fly/spike-notes.md.
RUN mkdir -p /etc/docker \
    && echo '{"storage-driver":"fuse-overlayfs","data-root":"/work/docker"}' \
        > /etc/docker/daemon.json

# ─── Install rustup minimal (no default toolchain) ──────────────
# We do NOT use rust:1.83-bookworm as base because it bakes ~533 MB
# of stable Rust we never use. The project's nightly toolchain is
# installed by `rustup show` from rust-toolchain.toml below.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
    | sh -s -- -y --no-modify-path --profile minimal --default-toolchain none

# ─── mold (fast linker, referenced by .cargo/config.toml) ───────
RUN curl -fsSL -o /tmp/mold.tar.gz \
        "https://github.com/rui314/mold/releases/download/${MOLD_VERSION}/mold-${MOLD_VERSION#v}-x86_64-linux.tar.gz" \
    && echo "${MOLD_SHA256}  /tmp/mold.tar.gz" | sha256sum -c - \
    && tar -xzf /tmp/mold.tar.gz -C /tmp \
    && cp "/tmp/mold-${MOLD_VERSION#v}-x86_64-linux/bin/mold" /usr/bin/mold \
    && ln -sf /usr/bin/mold /usr/bin/ld.mold \
    && rm -rf /tmp/mold*

# ─── sccache (rustc compile cache; reads/writes R2 via S3 API) ──
# SHA verification deliberately omitted — pin the SHA in a follow-up
# once we've settled on a sccache version that consistently works
# with our R2 setup.
RUN curl -fsSL -o /tmp/sccache.tar.gz \
        "https://github.com/mozilla/sccache/releases/download/${SCCACHE_VERSION}/sccache-${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
    && tar -xzf /tmp/sccache.tar.gz -C /tmp \
    && install -m 0755 \
        "/tmp/sccache-${SCCACHE_VERSION}-x86_64-unknown-linux-musl/sccache" \
        /usr/local/bin/sccache \
    && rm -rf /tmp/sccache*

# ─── Bake the nightly toolchain from rust-toolchain.toml ────────
# Keeping rust-toolchain.toml in /opt/toolchain serves two purposes:
#   1. `rustup show` reads it to install the right channel.
#   2. Subsequent `cargo install` invocations run in this dir so
#      rustup picks up the override (stable can't compile
#      `edition = "2024"` which cargo-leptos uses).
# Then promote the toolchain to global default so cargo works
# outside /opt/toolchain too (e.g., the cloned repo at /work/hobby).
RUN mkdir -p /opt/toolchain
COPY rust-toolchain.toml /opt/toolchain/rust-toolchain.toml
WORKDIR /opt/toolchain
RUN rustup show \
    && rustup default "$(rustup show active-toolchain | awk '{print $1}')"

# ─── cargo-leptos + cargo-nextest (built with nightly toolchain) ─
# Clean the cargo registry cache / git checkouts / build sources in
# the SAME RUN — they're ~1.4 GB of one-time-use data we don't need
# at runtime. The installed binaries live in $CARGO_HOME/bin (~50 MB).
RUN cargo install --locked cargo-leptos cargo-nextest \
    && rm -rf \
        ${CARGO_HOME}/registry/cache \
        ${CARGO_HOME}/registry/src \
        ${CARGO_HOME}/git
WORKDIR /

# ─── Stage 2: chef (toolchain + cargo-chef binary) ──────────────
# cargo-chef is the dep prebake tool. We install it in a stage that
# never gets COPYed into the final image, so chef itself isn't
# shipped to runner machines — only the cooked target/ is.
FROM toolchain AS chef
# Pinned to what Phase 0 spike validated (ops/fly/prebake-spike.md).
# Bump together with the spike re-run when newer chef versions land.
ARG CARGO_CHEF_VERSION=0.1.77
RUN cargo install --locked --version ${CARGO_CHEF_VERSION} cargo-chef \
    && rm -rf \
        ${CARGO_HOME}/registry/cache \
        ${CARGO_HOME}/registry/src \
        ${CARGO_HOME}/git
# WORKDIR MUST match the runtime consumer's cwd or cargo's fingerprint
# `path` hash breaks (cargo-chef README: "cargo chef cook and cargo build
# must be executed from the same working directory"). Consumer runs cargo
# from /work/repo (per our entrypoint convention — act_runner checks out
# to a stable path under /work).
WORKDIR /work/repo

# ─── Stage 3: planner — extract recipe.json ─────────────────────
# cargo chef prepare reads the workspace's Cargo.toml tree + Cargo.lock
# and writes a self-contained recipe.json describing every dep. The
# recipe is the single input to the (expensive) cooker stage; this
# layer's input is the full repo but the output is tiny + stable
# (~100 KB), so the cooker layer caches as long as recipe.json is
# byte-identical.
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

# ─── Stage 4: cooker — compile every dep into /build/target ─────
# Two cook passes: one for the regular `--tests` build profile (used
# by `cargo nextest run` and `cargo test`), one for `--clippy` (used
# by `cargo clippy`, which routes through clippy-driver and produces
# separately-fingerprinted artifacts cargo can NOT share with regular
# rustc). Both write into the same /build/target so a single tree
# carries both flavors.
#
# CARGO_INCREMENTAL=0 because incremental adds noise without value
# in a one-shot build; we want deterministic .rlib outputs.
#
# .chef-stamp captures the inputs that determine whether a given
# prebake is valid for a given PR. The entrypoint compares this
# stamp against the volume's existing one to decide whether to
# re-seed cargo-target.
FROM chef AS cooker
COPY --from=planner /work/repo/recipe.json /work/repo/Cargo.lock ./
COPY rust-toolchain.toml ./
COPY .cargo/config.toml ./.cargo/config.toml
ENV CARGO_INCREMENTAL=0
# CARGO_TARGET_DIR MUST match the runtime consumer's CARGO_TARGET_DIR.
# cargo-chef docs: "*.d files under target/debug/deps contain absolute
# paths referring to the project target directory. If moved around,
# cargo will not leverage them as cached dependencies." Empirically
# confirmed in prebake-tarzst local test: cook into /build/target ≠
# consumer's /work/cargo-target → cargo recompiles all 650+ 3rd-party
# deps. Fix: cooker writes to /work/cargo-target (same path the
# consumer expects per the ENV CARGO_TARGET_DIR set in the final stage).
ENV CARGO_TARGET_DIR=/work/cargo-target
RUN mkdir -p /work/cargo-target
RUN cargo chef cook --recipe-path recipe.json --tests --workspace
# CRITICAL: cargo-chef's --tests flag runs `cargo build --tests`, NOT
# `cargo test --no-run`. They differ — `cargo test --no-run` ALSO builds
# the cfg(test) variants of dep libs (which are different cargo-fingerprint
# units than the plain --tests build). nextest internally uses
# `cargo test --no-run`, so without this extra step it would recompile
# 650+ deps in their cfg(test) variants. We run `cargo test --no-run`
# directly against the cargo-chef skeleton to populate those missing
# fingerprints. Empirical (prebake-tarzst local test): without this step,
# nextest recompiles 655 deps; with it, nextest takes 0.6s.
#
# CARGO_BUILD_JOBS=2: fly.io's remote builder OOM-killed rustc on
# aws-sdk-s3 with default parallelism (run #75, 2026-05-13). Multiple
# concurrent rustc processes each consuming 1-2 GB exceeded the builder's
# memory. Cap at 2 parallel jobs — slower (~+60s) but reliable.
RUN CARGO_BUILD_JOBS=2 cargo test --no-run --workspace
# --all-targets on the clippy cook is REQUIRED: the PR step runs
# `cargo clippy --workspace --all-targets`, which selects lib + bin +
# tests + benches + examples. That selection drives a wider feature
# unification (dev-deps activate features in shared deps) than a plain
# `cargo clippy`. Without --all-targets here, the prebake's
# clippy-driver .rmeta files have a different fingerprint than what
# the PR asks for, and cargo re-checks ~56 deps at PR time instead of
# 0 (validated in Phase 0 spike — ops/fly/prebake-spike.md).
RUN cargo chef cook --recipe-path recipe.json --clippy --workspace --all-targets
RUN LOCK_SHA=$(sha256sum Cargo.lock | awk '{print $1}') \
    && TOOLCHAIN=$(rustc -V) \
    && BUILT_AT=$(date -Iseconds) \
    && CHEF_VER=$(cargo chef --version) \
    && printf 'sha256-of-Cargo.lock: %s\ntoolchain:            %s\nbuilt-at:             %s\nchef-version:         %s\n' \
        "$LOCK_SHA" "$TOOLCHAIN" "$BUILT_AT" "$CHEF_VER" \
        > /work/cargo-target/.chef-stamp \
    && echo "cooker stamp:" && cat /work/cargo-target/.chef-stamp \
    && echo "cooked target size: $(du -sh /work/cargo-target | awk '{print $1}')"

# ─── Stage 5: packer — compress cooked target into a single tar.zst ─
# Pivot from the original "COPY /build/target unpacked" model. Empirical
# data (tmpfs-runner-work Phase 3, run #70): `cp -a` of the ~30k-file
# unpacked tree from /opt/prebuilt-target onto tmpfs took 597s on fly's
# storage. Per-file overhead dominates. A single sequential read of a
# big compressed file should be 10x+ faster.
#
# Why zstd -3: balanced compress speed (~500 MB/s) vs ratio. -3 is the
# default. Higher levels (e.g., -19) take much longer for image-build
# time without meaningful runtime savings.
#
# Output: /build/prebuilt-target.tar.zst (one file, ~1-2 GB) plus
# /build/prebuilt-target.chef-stamp as a sidecar (so the entrypoint
# can compare stamps without extracting the tarball).
FROM cooker AS packer
RUN cp /work/cargo-target/.chef-stamp /tmp/prebuilt-target.chef-stamp \
    && tar -cf - -C /work/cargo-target . \
        | zstd -T0 -3 -o /tmp/prebuilt-target.tar.zst \
    && echo "tar.zst size: $(du -sh /tmp/prebuilt-target.tar.zst | awk '{print $1}')" \
    && echo "sidecar stamp:" && cat /tmp/prebuilt-target.chef-stamp

# ─── Stage 6: final runtime image ────────────────────────────────
FROM toolchain AS final

# ─── act_runner binary (Phase A addition over Dockerfile.spike) ──
# act_runner registers this container with Forgejo as a runner and
# picks up one job. See ops/fly/runner-entrypoint.sh for invocation.
# SHA pinned from Forgejo's published .sha256 sidecar — bumping
# ACT_RUNNER_VERSION requires also fetching and updating the SHA.
RUN curl -fsSL -o /tmp/act_runner \
        "https://code.forgejo.org/forgejo/runner/releases/download/${ACT_RUNNER_VERSION}/forgejo-runner-${ACT_RUNNER_VERSION#v}-linux-amd64" \
    && echo "${ACT_RUNNER_SHA256}  /tmp/act_runner" | sha256sum -c - \
    && install -m 0755 /tmp/act_runner /usr/local/bin/act_runner \
    && rm /tmp/act_runner

# ─── Pre-baked deps as a single tar.zst ──────────────────────────
# The packer stage produced /build/prebuilt-target.tar.zst (compressed
# cargo-chef-cooked target/, ~1-2 GB) and /build/prebuilt-target.chef-stamp
# (small sidecar with the cook's Cargo.lock sha + toolchain string).
# The entrypoint extracts the tarball into tmpfs-backed /work/cargo-target
# on machine boot, gated on a stamp comparison so a recycled stamp-match
# volume can skip the extract.
#
# This replaces the unpacked-tree approach (`COPY /build/target /opt/
# prebuilt-target`) — the unpacked tree contained ~30k small files
# whose per-file fsync overhead on fly's storage took ~10 min to copy.
# A single big compressed file reads sequentially in ~10s.
#
# CARGO_TARGET_DIR is set so every cargo invocation uses the extracted
# directory rather than the workspace's default ./target.
COPY --from=packer /tmp/prebuilt-target.tar.zst /opt/prebuilt-target.tar.zst
COPY --from=packer /tmp/prebuilt-target.chef-stamp /opt/prebuilt-target.chef-stamp
ENV CARGO_TARGET_DIR=/work/cargo-target

# ─── Entrypoint ──────────────────────────────────────────────────
# Starts dockerd (DiD), registers with Forgejo using the env-injected
# one-shot token, runs ONE job, then exits. Fly's auto_destroy=true
# (set on spawn) destroys the machine after exit.
COPY ops/fly/runner-entrypoint.sh /usr/local/bin/runner-entrypoint.sh
RUN chmod +x /usr/local/bin/runner-entrypoint.sh

# ─── Build-time verification ─────────────────────────────────────
# Fail the image build immediately if any tool is missing or
# unversioned. Cheap insurance against silent regressions. Verifies
# the prebake too — tar.zst and its sidecar stamp must be present.
RUN cargo --version \
    && rustc --version \
    && sccache --version \
    && cargo leptos --version \
    && cargo nextest --version \
    && mold --version | head -n1 \
    && docker --version \
    && act_runner --version \
    && zstd --version \
    && test -f /opt/prebuilt-target.tar.zst \
    && test -f /opt/prebuilt-target.chef-stamp \
    && echo "prebake tar.zst size: $(du -sh /opt/prebuilt-target.tar.zst | awk '{print $1}')" \
    && echo "sidecar stamp:" && cat /opt/prebuilt-target.chef-stamp \
    && ! command -v cargo-chef >/dev/null 2>&1 \
    && echo "cargo-chef correctly absent from runtime image"

ENTRYPOINT ["/usr/local/bin/runner-entrypoint.sh"]
