# ============================================================================
# TrakopLens / Trakop web — CakePHP 3.5 application image (Apache + PHP 7.4)
# ----------------------------------------------------------------------------
# WHY php:7.4-apache and not php-fpm:
#   The host runs Apache with mod_php-style request handling and an .htaccess
#   chain (root .htaccess rewrites into webroot/, webroot/.htaccess routes to
#   index.php). Reproducing that under nginx+fpm would mean rewriting both
#   .htaccess files — a behaviour change on the request path. Apache in the
#   image keeps AllowOverride semantics byte-identical.
#
# WHY the version is pinned to a digest-able tag and not `latest`:
#   PHP 7.4 is end-of-life. The image is the ONLY thing keeping this app on a
#   known-good interpreter; a floating tag would silently move it.
#
# WHY python3 lives in THIS image and not a sidecar:
#   ToolCatalog::runDeliveryInventory() executes
#       exec('python3 <script> ... 2>/dev/null')
#   A separate container is unreachable from exec(), so splitting Python out
#   means rewriting every delivery tool to speak HTTP. That is a redesign, not
#   a packaging change. The stderr redirect also means a missing module shows
#   up only as `tool_failed` with no error text — hence the explicit pip list
#   and the import assertion in docker/php/entrypoint.sh.
#
# WHAT THIS IMAGE DOES NOT DO — deliberately:
#   * No `composer install`. vendor/ is COMMITTED at CakePHP 3.5.17 while
#     composer.lock pins 3.4.6; running composer install downgrades the
#     framework and every page 500s ("getSession does not exist"). The
#     application source, including vendor/, is bind-mounted at runtime.
#   * No COPY of config/paths.php or webroot/db_records.txt. Those two files
#     hold every credential in the platform (DB passwords, AWS keys, Twilio,
#     the FCM private key, payment salts). Baking them into an image that can
#     be pushed to a registry is a credential leak. They arrive via the mount.
# ============================================================================
FROM php:7.4.33-apache-bullseye

# Debian 11 (bullseye) is EOL, and as of 2026-09 deb.debian.org is in a BROKEN
# HALF-STATE that this file has to route around. The bullseye-security INDEX
# still resolves, so `apt-get update` SUCCEEDS and looks healthy — but the .deb
# files behind it have been purged from the pool, so the build dies much later
# with 404s on libssl1.1, libssl-dev, libexpat1(-dev), libsqlite3-0(-dev),
# liblzma5, libfreetype6(-dev), libpng-dev and ucf. That is what broke the
# staging pipeline on 45a24250. Because apt resolves those packages to
# security-suite versions that no longer exist ANYWHERE on deb.debian.org, no
# retry and no amount of `apt-get update` fixes it.
#
# APT_SNAPSHOT (the default) pins every suite to an immutable snapshot.debian.org
# timestamp. snapshot is the only remaining source that still carries the
# bullseye-security .debs, so this path is both reproducible AND fully patched:
# it resolves libssl1.1 to 1.1.1w-0+deb11u8, the exact package that 404s on
# deb.debian.org. Verified 2026-09-17 by a full build against this pinned base.
#
# APT_MIRROR is the fallback for the day snapshot.debian.org is rate-limiting or
# down. Set it in .env.docker (see ops/docker/DEPLOYMENT.md):
#     APT_MIRROR=archive.debian.org
# archive.debian.org carries NO bullseye-security suite at all, so that branch
# DROPS the suite instead of pointing at a URL that 404s. The cost is real and
# is accepted deliberately: archive serves the final 11.11 point release, so
# security revisions land a few Debian revisions behind (libssl1.1 is
# 1.1.1w-0+deb11u1 there, not u8). Nothing is downgraded relative to the base
# image — 11.11 is strictly newer than the 2022-11-14 snapshot it was built from.
#
# Both branches REWRITE sources.list rather than sed-patching it. The previous
# `/security.debian.org/d` was dead code: this base image's line reads
# `deb http://deb.debian.org/debian-security bullseye-security main`, which that
# pattern never matches, so the documented archive.debian.org workaround would
# have rewritten the security line to archive.debian.org/debian-security — a
# path that 404s outright, turning a pool 404 into an apt-get update failure.
#
# APT_MIRROR=deb.debian.org is treated as UNSET, deliberately. Hosts provisioned
# before this change carry that literal value in their .env.docker, and honouring
# it would route them straight back into the broken half-state above — silently,
# because the snapshot default would never run and the 404s only surface minutes
# later in an unrelated layer. deb.debian.org is not a valid source for bullseye
# any more, so there is no legitimate reason to select it by name.
ARG APT_SNAPSHOT=20260801T000000Z
ARG APT_MIRROR=
ARG APT_ALLOW_EXPIRED=true
RUN set -eux; \
    if [ "$APT_MIRROR" = "deb.debian.org" ]; then \
        echo "APT_MIRROR=deb.debian.org is obsolete for EOL bullseye — using APT_SNAPSHOT=${APT_SNAPSHOT}"; \
        APT_MIRROR=; \
    fi; \
    if [ -n "$APT_MIRROR" ]; then \
        printf 'deb http://%s/debian bullseye main\ndeb http://%s/debian bullseye-updates main\n' \
            "$APT_MIRROR" "$APT_MIRROR" > /etc/apt/sources.list; \
    else \
        printf 'deb http://snapshot.debian.org/archive/debian/%s bullseye main\ndeb http://snapshot.debian.org/archive/debian-security/%s bullseye-security main\ndeb http://snapshot.debian.org/archive/debian/%s bullseye-updates main\n' \
            "$APT_SNAPSHOT" "$APT_SNAPSHOT" "$APT_SNAPSHOT" > /etc/apt/sources.list; \
    fi; \
    if [ "$APT_ALLOW_EXPIRED" = "true" ]; then \
        printf 'Acquire::Check-Valid-Until "false";\nAcquire::AllowInsecureRepositories "false";\n' \
            > /etc/apt/apt.conf.d/99trakop-expired; \
    fi; \
    cat /etc/apt/sources.list; \
    # Fail HERE, on the layer that owns the mirror choice, rather than 200 lines
    # and several minutes later inside an unrelated apt-get install. The lists are
    # dropped again so this probe does not bake ~40 MB into the layer; every apt
    # layer below already runs its own `apt-get update`.
    apt-get update; \
    rm -rf /var/lib/apt/lists/*

# Build + runtime libraries for the extension set below, plus the two CLI
# clients the entrypoint/healthcheck scripts and the reindex service use
# (mysql for the MySQL reachability probe, psql for the pgvector schema
# assertions).
RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends \
        libfreetype6-dev libjpeg62-turbo-dev libpng-dev libwebp-dev \
        libicu-dev libxml2-dev libxslt1-dev libzip-dev libpq-dev \
        libbz2-dev libgmp-dev libldap2-dev libc-client-dev libkrb5-dev libffi-dev \
        python3 python3-pip \
        default-mysql-client postgresql-client \
        curl ca-certificates unzip; \
    rm -rf /var/lib/apt/lists/*

# This list is `php -m` on the host minus what php:7.4-apache already ships.
# It is not aspirational: the app reaches for soap (Fiscal/Adeo), ldap, imap,
# gmp, bcmath (money), intl (locale), xsl and the sysv* family at runtime.
# pdo_pgsql/pgsql are what the RAG VectorStore uses.
RUN set -eux; \
    docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp; \
    docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu; \
    docker-php-ext-configure imap --with-kerberos --with-imap-ssl; \
    docker-php-ext-install -j"$(nproc)" \
        bcmath bz2 calendar exif ffi gd gettext gmp imap intl ldap \
        mysqli pcntl pdo_mysql pdo_pgsql pgsql shmop soap sockets \
        sysvmsg sysvsem sysvshm xsl zip; \
    docker-php-ext-enable opcache

# phpredis, pinned. 5.3.7 is the last line with first-class PHP 7.4 support and
# is what config/app.php's 'trakoplens' Redis pool needs. Without it that pool
# silently falls back to the File engine (see the fail-safe ternary in app.php)
# — which is correct behaviour, but then Redis is not actually in use.
#
# BUILT FROM THE GITHUB TARBALL, NOT `pecl install`. pecl.php.net's package
# listing no longer resolves — the observed failure is:
#     No releases available for package "pecl.php.net/redis"
#     install failed
# which is a dead upstream channel, not a local network problem, so retrying or
# switching mirrors does not help. Extracting the release into
# /usr/src/php/ext/redis lets docker-php-ext-install build it the same way the
# bundled extensions above are built.
#
# The `php -m | grep -qx redis` at the end is deliberate: it makes a failed
# build LOUD. Without the extension, app.php quietly uses the File engine, so
# the stack would come up looking healthy while "Redis is enabled" was false.
ARG PHPREDIS_VERSION=5.3.7
RUN set -eux; \
    curl -fsSL "https://github.com/phpredis/phpredis/archive/refs/tags/${PHPREDIS_VERSION}.tar.gz" \
        -o /tmp/phpredis.tar.gz; \
    mkdir -p /usr/src/php/ext/redis; \
    tar -xzf /tmp/phpredis.tar.gz -C /usr/src/php/ext/redis --strip-components=1; \
    rm /tmp/phpredis.tar.gz; \
    docker-php-ext-install -j"$(nproc)" redis; \
    php -m | grep -qx redis

# Python dependencies for the scripts the app shells out to. The pins and the
# reasoning behind each of them live in docker/python/requirements.txt — ONE
# declared source rather than a list of build ARGs, so `pip3 install -r` here
# and a developer's local venv install the identical set.
#
# The file is COPYed (not bind-mounted at runtime) so the image is self-contained
# and the layer invalidates precisely when a dependency actually changes. It is
# kept at a stable path in the image because the entrypoint re-asserts the same
# imports on every boot: `2>/dev/null` on the exec swallows ImportError, so a
# broken module set is otherwise invisible until a delivery tool returns
# `tool_failed` with no error text.
COPY docker/python/requirements.txt /usr/local/share/trakop/requirements.txt
RUN set -eux; \
    pip3 install --no-cache-dir -r /usr/local/share/trakop/requirements.txt; \
    python3 -c "import pymysql, pandas, numpy, openpyxl, xlsxwriter, requests; \
import googleapiclient.discovery, googleapiclient.http, google_auth_oauthlib.flow; \
from google.oauth2.credentials import Credentials; \
from google.auth.transport.requests import Request; \
from google.auth.exceptions import RefreshError; \
print('python deps ok:', 'pandas', pandas.__version__, 'numpy', numpy.__version__)"

# ---------------------------------------------------------------------------
# LEGACY INTERPRETERS — Python 2.7.17 and 3.6.9, to match the staging host
# (ubuntu@ip-172-31-43-197: `python` 2.7.17, `python3` 3.6.9).
#
# ADDED ALONGSIDE, NEVER REPLACING. `make altinstall` is what makes this safe:
# it installs versioned binaries (python2.7, python3.6) and does NOT touch the
# `python3` symlink. That matters because the application's own dependency set
# CANNOT run on 3.6 —
#     pandas 2.1.4   Requires-Python: >=3.9
#     numpy  1.26.4  Requires-Python: >=3.9
# and the delivery sheet those two produce is a financial artefact whose output
# differs between pandas 1.x and 2.x (append() removed, groupby defaults, dtype
# inference). So `python3` stays 3.9.2 and every exec('python3 …') in the app is
# byte-for-byte unaffected; the legacy interpreters are additional, for anything
# that specifically needs the staging host's versions.
#
# BUILT FROM SOURCE because Debian bullseye has neither at these patch levels
# (it ships 2.7.18, and no 3.6 at all). Pinned to the exact versions read off
# that host rather than "whatever 3.6 resolves to" — a floating patch level is
# how two environments quietly stop matching.
#
# `python` -> 2.7.17 mirrors the host, where bare `python` is Python 2. Nothing
# in this application calls bare `python` (every call site is `python3`, and all
# five shebangs in code/webroot/python_script/ are `#!/usr/bin/env python3`), so
# this cannot redirect application work onto the old interpreter.
#
# Set INSTALL_LEGACY_PYTHON=0 to skip both builds — they add several minutes and
# roughly 200 MB, and a deployment that does not need them should not pay for it.
# Both are END OF LIFE (2.7: Jan 2020, 3.6: Dec 2021) and receive no security
# fixes; they are here to match an environment, not because they are safe to
# build new work on.
# ---------------------------------------------------------------------------
ARG INSTALL_LEGACY_PYTHON=1
ARG LEGACY_PY2_VERSION=2.7.17
ARG LEGACY_PY3_VERSION=3.6.9
RUN set -eux; \
    if [ "$INSTALL_LEGACY_PYTHON" != "1" ]; then \
        echo "INSTALL_LEGACY_PYTHON=$INSTALL_LEGACY_PYTHON — skipping the legacy interpreter builds"; \
    else \
        savedAptMark="$(apt-mark showmanual)"; \
        apt-get update; \
        apt-get install -y --no-install-recommends \
            build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
            libsqlite3-dev libffi-dev libncursesw5-dev libgdbm-dev liblzma-dev \
            uuid-dev tk-dev wget; \
        for v in "$LEGACY_PY2_VERSION" "$LEGACY_PY3_VERSION"; do \
            wget -q -O /tmp/py.tgz "https://www.python.org/ftp/python/${v}/Python-${v}.tgz"; \
            mkdir -p /tmp/pysrc; tar -xzf /tmp/py.tgz -C /tmp/pysrc --strip-components=1; \
            cd /tmp/pysrc; \
            # No --enable-optimizations: PGO roughly triples the build for a speed
            # gain these interpreters are not here to deliver.
            ./configure --prefix=/usr/local --enable-shared --with-ensurepip=install \
                LDFLAGS="-Wl,-rpath=/usr/local/lib" >/dev/null; \
            make -j"$(nproc)" >/dev/null; \
            # altinstall, NOT install: `make install` would overwrite /usr/local/bin/python3
            # and point the application at an interpreter its dependencies reject.
            make altinstall >/dev/null; \
            cd /; rm -rf /tmp/pysrc /tmp/py.tgz; \
        done; \
        ldconfig; \
        # `python` -> 2.7.x, as on the staging host. python3 is deliberately NOT relinked.
        ln -sf "/usr/local/bin/python${LEGACY_PY2_VERSION%.*}" /usr/local/bin/python; \
        # Drop the toolchain again so it does not ship in the runtime image, then
        # let apt re-add anything the runtime actually links against.
        apt-mark auto '.*' >/dev/null; \
        [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark >/dev/null; \
        find /usr/local -type f -executable -not -path '*/lib/python*' \
            -exec ldd '{}' ';' 2>/dev/null \
            | awk '/=>/ { so = $(NF-1); if (index(so, "/usr/local/") == 1) next; gsub("^/(usr/)?", "", so); print so }' \
            | sort -u | xargs -r dpkg-query --search 2>/dev/null \
            | cut -d: -f1 | sort -u | xargs -r apt-mark manual >/dev/null; \
        apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
            build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
            libsqlite3-dev libffi-dev libncursesw5-dev libgdbm-dev liblzma-dev \
            uuid-dev tk-dev wget; \
        rm -rf /var/lib/apt/lists/*; \
        # Prove all three coexist and that the APPLICATION's interpreter is untouched.
        python  --version; \
        "python${LEGACY_PY2_VERSION%.*}" --version; \
        "python${LEGACY_PY3_VERSION%.*}" --version; \
        python3 --version; \
        python3 -c "import pandas, numpy; print('app python3 intact:', pandas.__version__, numpy.__version__)"; \
    fi

# ---------------------------------------------------------------------------
# wkhtmltox — the delivery-receipt PNGs (wkhtmltoimage) and the invoice PDFs
# (wkhtmltopdf). NOTHING installed these before, on any image or any Contabo
# host. config/app_identity.php probes for them and falls back to the historical
# AWS /home/ubuntu prefix, which this fleet never had; exec() then returned 127
# into a variable no caller read, so /transactions/print-receipt answered
# "Image generation failed" with nothing in any log, and the CakePdf invoice
# download 500'd. See config/app_identity.php for the probe order.
#
# THE UPSTREAM .deb, NOT Debian's `wkhtmltopdf` PACKAGE. The wkhtmltopdf project
# builds against a PATCHED Qt, which is what renders with no X server; Debian's
# own package is stock Qt and needs xvfb-run wrapped around every call. That is
# why there is no xvfb here — adding it would be treating the symptom of having
# installed the wrong build. It unpacks to /usr/local/bin, the second entry in
# the probe list (there is no linux-generic tarball for this release, so /opt is
# reserved for a hand-installed host copy).
#
# Placed LATE on purpose: an apt layer up beside the extension build would
# invalidate it and the ~20-minute legacy-interpreter build on every rebuild.
#
# The deb's own Depends pull ca-certificates, fontconfig, libjpeg62-turbo,
# libpng16-16, libssl1.1, libx11-6, libxcb1, libxext6, libxrender1, xfonts-75dpi
# and xfonts-base. Those X fonts are bitmap-only, so the scalable families are
# added explicitly — without a usable sans-serif wkhtmltoimage still exits 0 and
# writes a VALID, correctly sized, BLANK png. That is why the assertion below is
# a real render with a size check and not just --version.
#
# Pin by digest: `apt-get install ./file.deb` does not verify a signature.
# ---------------------------------------------------------------------------
ARG WKHTMLTOX_VERSION=0.12.6.1-3
ARG WKHTMLTOX_SHA256=9c687f0c58cf50e01f2a6375d2e34372f8feeec56a84690ea113d298fccadd98
RUN set -eux; \
    curl -fsSL -o /tmp/wkhtmltox.deb \
        "https://github.com/wkhtmltopdf/packaging/releases/download/${WKHTMLTOX_VERSION}/wkhtmltox_${WKHTMLTOX_VERSION}.bullseye_amd64.deb"; \
    echo "${WKHTMLTOX_SHA256}  /tmp/wkhtmltox.deb" | sha256sum -c -; \
    apt-get update; \
    apt-get install -y --no-install-recommends \
        /tmp/wkhtmltox.deb fonts-dejavu-core fonts-liberation; \
    rm -f /tmp/wkhtmltox.deb; \
    rm -rf /var/lib/apt/lists/*; \
    fc-cache -f; \
    # /opt compatibility symlinks. The deb unpacks to /usr/local/bin, which the
    # CURRENT config/app_identity.php probe finds — but the PREVIOUS one was a
    # two-path ternary that checked /opt/wkhtmltox/bin and nothing else before
    # falling back to the AWS path. Not hypothetical: the USA box is running that
    # older config today, and India/UK have not been refreshed either. Without
    # these two links a rebuilt image still renders nothing on any of them, and
    # the image would be silently coupled to a per-VM file's refresh state.
    mkdir -p /opt/wkhtmltox/bin; \
    ln -s /usr/local/bin/wkhtmltoimage /usr/local/bin/wkhtmltopdf /opt/wkhtmltox/bin/; \
    ! ldd /usr/local/bin/wkhtmltoimage | grep 'not found'; \
    wkhtmltopdf  --version; \
    wkhtmltoimage --version; \
    # Through the symlink too, i.e. exactly the path the old probe hands to exec().
    /opt/wkhtmltox/bin/wkhtmltoimage --version; \
    printf '<html><body style="font:16px sans-serif">trakop wkhtmltox smoke</body></html>' > /tmp/wk.html; \
    /opt/wkhtmltox/bin/wkhtmltoimage --quality 10 /tmp/wk.html /tmp/wk.png; \
    test -s /tmp/wk.png; \
    rm -f /tmp/wk.html /tmp/wk.png

# Composer is present for manual use (bake, a deliberate dependency change).
# It is NEVER run during the build — see the header.
COPY --from=composer:2.2 /usr/bin/composer /usr/local/bin/composer

# mod_rewrite is required by both .htaccess files; headers/deflate mirror the
# host module set. Apache answers on 8081 so the container can run alongside
# the host's Apache on :80 during the parallel-run A/B window.
ARG APACHE_PORT=8081
RUN set -eux; \
    a2enmod rewrite headers deflate remoteip; \
    sed -i "s/^Listen 80$/Listen ${APACHE_PORT}/" /etc/apache2/ports.conf
COPY docker/php/apache-trakop.conf /etc/apache2/sites-available/000-default.conf
RUN sed -i "s/__APACHE_PORT__/${APACHE_PORT}/" /etc/apache2/sites-available/000-default.conf

COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-trakop.ini

COPY docker/php/entrypoint.sh   /usr/local/bin/trakop-entrypoint
COPY docker/php/healthcheck.php /usr/local/bin/trakop-healthcheck.php
# The rag-reindex service reuses THIS image (same PHP, same extensions, same
# python, same mounted source as the app) and only swaps the entrypoint. Baking
# the script in rather than bind-mounting it means the reindex service's volume
# list is EXACTLY the app's shared set — see the &app_volumes anchor in
# docker-compose.yml. That is not tidiness: when the two lists were maintained
# separately, the MySQL socket mount was added to the web service only and the
# reindex silently lost its database.
COPY docker/php/rag-reindex-entrypoint.sh /usr/local/bin/trakop-rag-reindex
# The SCHEDULE for that reindex, for the same reason: it must run the identical
# runner from the identical image and mount set, so there is no second reindex
# implementation to drift. It replaces a host crontab that pointed outside the
# deployment and stopped working the moment the checkout was renamed.
COPY docker/php/reindex-scheduler.sh             /usr/local/bin/trakop-reindex-scheduler
COPY docker/php/reindex-scheduler-healthcheck.sh /usr/local/bin/trakop-reindex-scheduler-healthcheck
RUN chmod +x /usr/local/bin/trakop-entrypoint /usr/local/bin/trakop-rag-reindex \
             /usr/local/bin/trakop-reindex-scheduler \
             /usr/local/bin/trakop-reindex-scheduler-healthcheck

# LOAD-BEARING, NOT COSMETIC. config/paths.php derives the environment from the
# filesystem path:
#     getBetween(getcwd(), "html/", "webroot")  must equal  "trakop-web/code/"
# Note the trailing slash: it only appears when the CWD is .../code/webroot.
# From .../code the expression yields "trakop-web/code" (no slash), no branch
# matches, and the app falls through to DBNAME=27215_vendor — the wrong
# database, silently. Apache's DocumentRoot chain lands in webroot per request;
# WORKDIR here is webroot so that `docker compose exec` / CLI shells inherit the
# correct resolution too.
WORKDIR /var/www/html/trakop-web/code/webroot

ENTRYPOINT ["trakop-entrypoint"]
CMD ["apache2-foreground"]
