# sae-builder-cpu — the CI build image
#
# TRACES: DP-007 | PR-004
#
# Build/push:  scripts/ci/build_builder_image.sh --push
# Consumed by: .gitea/workflows/unit-tests.yml  (pinned by tag, never :latest)
# Docs:        docs/ci-image.md
#
# This is the CPU corner of the DP-008 builder matrix and the DP-007 CI image at
# the same time — one artifact, two uses. The CUDA and ROCm siblings differ only
# in the accelerator stack layered on top of this dependency set.
#
# CI runs on an Intel N100 with no discrete GPU. Everything here is chosen so
# that `-DSAE_INFERENCE_BACKEND=ORT -DSAE_GEMM_BACKEND=CPU -DSAE_BUILD_TESTS=ON`
# configures, builds and runs without a GPU, without a model, and without
# reaching GitHub.

# ─── Base image ──────────────────────────────────────────────────────────────
#
# Chosen for the OLDEST glibc to be supported, not for recency. A binary built
# in a container runs against the *host's* glibc; glibc is backward compatible
# but not forward, so the build base sets the floor for every machine DP-008's
# binaries can ever run on. Building on a newer base than the oldest supported
# host produces the classic `GLIBC_2.xx not found` failure at load time.
#
# Debian 12 "bookworm" = glibc 2.36 (Aug 2022). What that floor covers:
#
#   Distro                       glibc   Covered?
#   Arch / CachyOS (rolling)     2.41+   yes
#   Fedora 37 and later          2.36+   yes   ← DP-005's targets are Fedora+Arch
#   Debian 12 / 13               2.36+   yes
#   Ubuntu 24.04 LTS             2.39    yes
#   Ubuntu 22.04 LTS             2.35    NO
#   RHEL / Rocky / Alma 9        2.34    NO
#   Debian 11                    2.31    NO
#
# The three misses are accepted deliberately: DP-005 puts Debian/Ubuntu out of
# installer scope and names Fedora + Arch as the supported distros, and every
# supported Fedora is 2.36 or newer. Going lower costs the toolchain rather than
# buying reach — Debian 11 ships GCC 10 (incomplete C++20) and Python 3.9, which
# has no `tomllib` and therefore cannot read the traceability gate's
# traceability.toml.
#
# Escape hatch, recorded now so it is not rediscovered under pressure: if the
# floor must drop to glibc 2.28 (RHEL 8 / manylinux_2_28 — the same baseline the
# ONNX Runtime and PyTorch wheels target), the move is a Rocky 8 base plus
# gcc-toolset-13, and OpenCV/FFmpeg/HDF5 all leave apt for source or
# EPEL/RPM Fusion. That is a different image, not a flag on this one.
#
# Not a glibc problem but worth stating: the binaries this image produces also
# link OpenCV, FFmpeg and HDF5 shared objects by soname. Making a *portable*
# release binary (DP-008) is a separate question from the glibc floor, and is
# answered by static linking or bundling, not by the base image.
FROM debian:12-slim

# Pins. Every version this image installs from source is an ARG so a rebuild is
# a one-line diff and `docker history` records what a given tag actually holds.
#
# ORT 1.28.0 and OpenCV 5.0.0 match the developer machine, so CI and local
# builds exercise the same libraries rather than merely similar ones.
# Catch2 / nlohmann_json / nanobind match the FetchContent pins in
# CMakeLists.txt:248 and tests/CMakeLists.txt:12 exactly — a vendored copy at a
# different version would be a silent divergence, not a convenience.
ARG ORT_VERSION=1.28.0
ARG OPENCV_VERSION=5.0.0
ARG CATCH2_VERSION=v3.5.3
ARG NLOHMANN_JSON_VERSION=v3.11.3
ARG NANOBIND_VERSION=v2.4.0

# Stamped so a build can prove which image it ran in, and so a green tick can be
# traced back to a specific dependency set. See the "Confirm the builder image"
# step in .gitea/workflows/unit-tests.yml.
ARG IMAGE_TAG=dev
ENV SAE_BUILDER=cpu \
    SAE_BUILDER_VERSION=${IMAGE_TAG} \
    SAE_ORT_VERSION=${ORT_VERSION} \
    SAE_OPENCV_VERSION=${OPENCV_VERSION} \
    DEBIAN_FRONTEND=noninteractive

# ─── System dependencies ─────────────────────────────────────────────────────
#
# One layer, ordered by why it is here rather than alphabetically.
RUN apt-get update && apt-get install -y --no-install-recommends \
    # Toolchain. bookworm's default gcc is 12.2 — enough for the C++20 the
    # project sets unconditionally (CMakeLists.txt:4). cmake is 3.25, above the
    # 3.21 minimum. Ninja because the N100 has four cores and every second of
    # build scheduling shows.
    build-essential \
    cmake \
    ninja-build \
    pkg-config \
    git \
    ca-certificates \
    curl \
    # Gitea's act_runner executes JS actions (actions/checkout, upload-artifact)
    # with the `node` found *inside* the container. Without this the job cannot
    # even check the repository out. Same reason as the kpnpp-builder image.
    nodejs \
    # HDF5 with the C++ API: galleries are HDF5-native and it is also the VR-001
    # dump format. find_package(HDF5 COMPONENTS CXX) at CMakeLists.txt:273.
    libhdf5-dev \
    # FFmpeg decode. swresample is on this list deliberately: the audio
    # signature (IR-004) downmixes to mono and resamples to 11025 Hz, and
    # tests/test_audio_signature.cpp decodes the golden FLAC fixture, so the
    # test build needs it as much as the main build does.
    libavformat-dev \
    libavcodec-dev \
    libavutil-dev \
    libswscale-dev \
    libswresample-dev \
    # OpenBLAS — required here, not optional. CI has no GPU, so SAE_GEMM_BACKEND
    # =CPU is the only path it ever exercises, and without OpenBLAS the CPU GEMM
    # falls back to a scalar loop that does not scale against a library-sized
    # gallery (AR-027). The build only *warns* when it is missing so a developer
    # without it still gets a working tree; the image must never be that case.
    # Both the main build (CMakeLists.txt:162) and the test target
    # (tests/CMakeLists.txt:42) discover it through pkg-config `openblas`.
    libopenblas-dev \
    # Python: the build itself needs the interpreter and headers
    # (find_package(Python COMPONENTS Interpreter Development.Module) at
    # CMakeLists.txt:254, for the nanobind modules). numpy/h5py/scipy are for
    # the Python-side tooling — fixture generation, replay, validation scripts.
    # From apt rather than pip: bookworm marks the environment externally
    # managed (PEP 668), and apt's h5py is already linked against the same
    # libhdf5 installed above. bookworm's python3 is 3.11, which has tomllib —
    # the traceability gate needs it to read traceability.toml.
    python3 \
    python3-dev \
    python3-numpy \
    python3-h5py \
    python3-scipy \
    # Image codecs for the OpenCV build below. Without these OpenCV silently
    # builds an imgcodecs that cannot read a JPEG, which fails at run time in a
    # gallery build rather than at compile time here.
    libjpeg62-turbo-dev \
    libpng-dev \
    libtiff-dev \
    libwebp-dev \
    libopenjp2-7-dev \
    zlib1g-dev \
    && rm -rf /var/lib/apt/lists/*

# Fail the image build, not the CI run, if OpenBLAS or swresample are not
# discoverable the way CMakeLists.txt discovers them. An image that ships
# libopenblas but no openblas.pc would compile the scalar fallback in silence.
RUN set -eux; \
    pkg-config --exists openblas; \
    echo "openblas    $(pkg-config --modversion openblas)"; \
    pkg-config --exists libswresample; \
    echo "swresample  $(pkg-config --modversion libswresample)"

# ─── ONNX Runtime, CPU provider only ─────────────────────────────────────────
#
# The official prebuilt linux-x64 tarball is the CPU build: no CUDA, no
# TensorRT, no ROCm execution providers. That is the whole requirement here —
# excluding the GPU providers is not a size optimisation, it is the point.
#
# Verified against the 1.28.0 tarball: the shared object's highest versioned
# symbol requirement is GLIBC_2.27 / GLIBCXX_3.4.21, well under this base's
# 2.36, so ORT does not raise the floor set above.
#
# Installed to /usr/local/{lib,include/onnxruntime} because CMakeLists.txt
# includes <onnxruntime/onnxruntime_cxx_api.h> and needs the *parent* of that
# directory on the include path (CMakeLists.txt:108-113).
#
# CI never calls a model — the embedder measures ~930 ms/frame on this CPU
# provider — so ORT is present to satisfy the link, not to run inference.
RUN set -eux; \
    curl -fsSL -o /tmp/ort.tgz \
      "https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/onnxruntime-linux-x64-${ORT_VERSION}.tgz"; \
    mkdir -p /tmp/ort; \
    tar -xzf /tmp/ort.tgz -C /tmp/ort --strip-components=1; \
    cp -a /tmp/ort/lib/libonnxruntime.so* /usr/local/lib/; \
    mkdir -p /usr/local/include/onnxruntime; \
    cp -a /tmp/ort/include/. /usr/local/include/onnxruntime/; \
    ldconfig; \
    rm -rf /tmp/ort /tmp/ort.tgz; \
    test -f /usr/local/include/onnxruntime/onnxruntime_cxx_api.h

# ─── OpenCV 5, from source ───────────────────────────────────────────────────
#
# This is the reason the image is prebuilt at all. CMakeLists.txt:25 probes for
# OpenCV 5 first and falls back to 4; the branch targets 5, which no Debian
# release ships (bookworm has 4.6), and building it inside every CI run would
# dominate the run on an N100.
#
# BUILD_LIST is exactly the seven components find_package asks for
# (CMakeLists.txt:25-29) — OpenCV resolves their internal dependencies itself.
# Everything else is off: tests, samples, Java/Python bindings, and the apps.
#
# No GUI backend. highgui still builds (find_package REQUIREs the component) but
# with a stub — CI never calls imshow, and pulling GTK/Qt into a headless build
# image buys nothing. scene_preview is a developer tool, not a CI target.
#
# CUDA/cuDNN explicitly off: DP-007 excludes the GPU stack outright.
#
# The source tree and build tree are removed in the same layer, so the ~3 GB of
# intermediates cost nothing in the published image.
RUN set -eux; \
    curl -fsSL -o /tmp/opencv.tar.gz \
      "https://github.com/opencv/opencv/archive/refs/tags/${OPENCV_VERSION}.tar.gz"; \
    mkdir -p /tmp/opencv-src; \
    tar -xzf /tmp/opencv.tar.gz -C /tmp/opencv-src --strip-components=1; \
    cmake -S /tmp/opencv-src -B /tmp/opencv-build -G Ninja \
      -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_INSTALL_PREFIX=/usr/local \
      -DBUILD_LIST=core,imgproc,imgcodecs,videoio,dnn,objdetect,highgui \
      -DBUILD_SHARED_LIBS=ON \
      -DBUILD_TESTS=OFF \
      -DBUILD_PERF_TESTS=OFF \
      -DBUILD_EXAMPLES=OFF \
      -DBUILD_DOCS=OFF \
      -DBUILD_opencv_apps=OFF \
      -DBUILD_JAVA=OFF \
      -DBUILD_opencv_python3=OFF \
      -DWITH_FFMPEG=ON \
      -DWITH_GTK=OFF \
      -DWITH_QT=OFF \
      -DWITH_OPENGL=OFF \
      -DWITH_CUDA=OFF \
      -DWITH_CUDNN=OFF \
      -DOPENCV_GENERATE_PKGCONFIG=ON \
      -DCMAKE_INSTALL_RPATH=/usr/local/lib; \
    cmake --build /tmp/opencv-build --parallel; \
    cmake --install /tmp/opencv-build; \
    ldconfig; \
    rm -rf /tmp/opencv-src /tmp/opencv-build /tmp/opencv.tar.gz

# ─── Vendored dependencies: Catch2, nlohmann/json, nanobind ──────────────────
#
# All three are FetchContent'ed by the build today, which makes every CI run
# depend on GitHub being reachable — a network outage would present as a code
# failure. Baking them in removes that dependency entirely.
#
# Catch2 is *installed*, so tests/CMakeLists.txt:6 `find_package(Catch2 3 QUIET)`
# succeeds and the FetchContent fallback is never reached. Its source is kept as
# well so the override below can cover the case where find_package somehow does
# not fire.
#
# nanobind must be cloned with submodules: its `ext/robin_map` is a git
# submodule, and a GitHub source tarball does not contain it. This is the one
# dependency where "download the tarball" produces a tree that configures and
# then fails to compile.
RUN set -eux; \
    mkdir -p /opt/vendor; \
    git clone --depth 1 --branch "${NLOHMANN_JSON_VERSION}" \
      https://github.com/nlohmann/json.git /opt/vendor/nlohmann_json; \
    git clone --depth 1 --branch "${NANOBIND_VERSION}" --recurse-submodules \
      https://github.com/wjakob/nanobind.git /opt/vendor/nanobind; \
    git clone --depth 1 --branch "${CATCH2_VERSION}" \
      https://github.com/catchorg/Catch2.git /opt/vendor/Catch2; \
    cmake -S /opt/vendor/Catch2 -B /tmp/catch2-build -G Ninja \
      -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_INSTALL_PREFIX=/usr/local \
      -DBUILD_TESTING=OFF; \
    cmake --build /tmp/catch2-build --parallel; \
    cmake --install /tmp/catch2-build; \
    rm -rf /tmp/catch2-build; \
    find /opt/vendor -maxdepth 2 -name .git -exec rm -rf {} +; \
    ldconfig

# The initial-cache script the build is configured with. It lives in the image,
# not in the workflow, so the vendor paths have exactly one owner: move a
# directory here and no consumer needs editing.
#
# FETCHCONTENT_FULLY_DISCONNECTED=ON is the load-bearing line. With it, any
# FetchContent dependency that is *not* covered by an override above is a hard
# configure error instead of a silent download — so "this build does not touch
# GitHub" is enforced by the build system rather than asserted in a comment.
RUN set -eux; \
    printf '%s\n' \
      '# Baked into sae-builder-cpu. Use with: cmake -C /opt/vendor/vendored-deps.cmake ...' \
      '# TRACES: DP-007' \
      'set(FETCHCONTENT_SOURCE_DIR_NLOHMANN_JSON "/opt/vendor/nlohmann_json" CACHE PATH "vendored in the CI image")' \
      'set(FETCHCONTENT_SOURCE_DIR_NANOBIND      "/opt/vendor/nanobind"      CACHE PATH "vendored in the CI image")' \
      'set(FETCHCONTENT_SOURCE_DIR_CATCH2        "/opt/vendor/Catch2"        CACHE PATH "vendored in the CI image")' \
      'set(FETCHCONTENT_FULLY_DISCONNECTED       ON  CACHE BOOL "no CI build may fetch from the network")' \
      > /opt/vendor/vendored-deps.cmake; \
    cat /opt/vendor/vendored-deps.cmake

# ─── Self-check ──────────────────────────────────────────────────────────────
#
# Run the project's own dependency discovery — the same find_package and
# pkg_check_modules calls CMakeLists.txt makes — against this image, at image
# build time. An image that cannot satisfy them should fail here, loudly, once,
# rather than in every CI run that pulls it.
#
# Deliberately not a build of the project: the image must be buildable without
# the repository, and the repository's own configure step is what CI is for.
RUN set -eux; \
    mkdir -p /tmp/selfcheck; \
    printf '%s\n' \
      'cmake_minimum_required(VERSION 3.21)' \
      'project(sae_image_selfcheck LANGUAGES CXX)' \
      'set(CMAKE_CXX_STANDARD 20)' \
      'set(CMAKE_CXX_STANDARD_REQUIRED ON)' \
      'find_package(OpenCV 5 REQUIRED COMPONENTS core imgproc imgcodecs videoio dnn objdetect highgui)' \
      'message(STATUS "OpenCV ${OpenCV_VERSION}")' \
      'find_package(HDF5 REQUIRED COMPONENTS CXX)' \
      'message(STATUS "HDF5 ${HDF5_VERSION}")' \
      'find_package(Catch2 3 REQUIRED)' \
      'message(STATUS "Catch2 ${Catch2_VERSION}")' \
      'find_package(Python 3.8 REQUIRED COMPONENTS Interpreter Development.Module)' \
      'find_package(PkgConfig REQUIRED)' \
      'pkg_check_modules(AVFORMAT   REQUIRED libavformat)' \
      'pkg_check_modules(AVCODEC    REQUIRED libavcodec)' \
      'pkg_check_modules(AVUTIL     REQUIRED libavutil)' \
      'pkg_check_modules(SWSCALE    REQUIRED libswscale)' \
      'pkg_check_modules(SWRESAMPLE REQUIRED libswresample)' \
      'pkg_check_modules(OPENBLAS   REQUIRED openblas)' \
      'find_library(ORT_LIB onnxruntime REQUIRED HINTS /usr/lib /usr/local/lib)' \
      'find_path(ORT_INCLUDE onnxruntime_cxx_api.h PATH_SUFFIXES onnxruntime' \
      '          HINTS /usr/include/onnxruntime /usr/local/include/onnxruntime /usr/local/include REQUIRED)' \
      'message(STATUS "ORT ${ORT_LIB} / ${ORT_INCLUDE}")' \
      > /tmp/selfcheck/CMakeLists.txt; \
    cmake -S /tmp/selfcheck -B /tmp/selfcheck/build -G Ninja; \
    rm -rf /tmp/selfcheck

# Python-side tooling the fixture and validation scripts import. Checked here so
# a missing wheel is an image failure rather than a mid-run traceback.
RUN python3 -c "import numpy, h5py, scipy; print('numpy', numpy.__version__, 'h5py', h5py.__version__, 'scipy', scipy.__version__)"

# ─── What is deliberately NOT here ───────────────────────────────────────────
#
#   CUDA, TensorRT, ROCm, and the ORT GPU execution providers
#       No GPU to use them. They belong to the sae-builder-cuda and
#       sae-builder-rocm siblings (DP-008).
#
#   The ONNX models
#       Seven files, ~725 MB, in Git LFS. T1/T2 tests are model-free by design
#       (tests/CMakeLists.txt:1-4), so the CI image needs none of them, and
#       baking them in would inflate the image roughly tenfold to serve the T3
#       smoke tests alone. Those pull the model they need via LFS in a separate
#       job. The CI workflow checks out with LFS off for the same reason.
#
#   The repository
#       Nothing from the source tree is COPYed in. The image is a toolchain, and
#       a toolchain that embeds the code it builds has to be rebuilt whenever the
#       code changes — which is exactly the per-run cost this image exists to
#       avoid.

WORKDIR /src
