# Robot Dev Team Project # File: Dockerfile # Description: Container image for the Robot Dev Team Project webhook service. # License: MIT # SPDX-License-Identifier: MIT # Copyright (c) 2025 MCKNLY LLC ARG PYTHON_IMAGE=python:3.14.7-slim-trixie@sha256:656d12e70054d5fda18a045e2494c96701e9792dd1445f95b3d038df954f57e9 FROM ${PYTHON_IMAGE} ARG PIP_VERSION=26.2 ARG UV_VERSION=0.12.1 ARG GLAB_VERSION=1.111.0 ARG DEBIAN_SNAPSHOT=20261006T081244Z ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ UV_PROJECT_ENVIRONMENT=/opt/venv # OS dependencies pinned via Debian snapshot. The base image ships its own deb822 source # pointing at the live mirrors, so it is cleared first: APT reads sources.list.d/ as well as # sources.list, and loading both leaves the snapshot governing nothing. The check after # `update` enforces the property -- every active index is under the snapshot -- rather than # the filename, so a future base-image layout change cannot quietly reintroduce a live mirror. # The upgrade step covers the debs the base image ships preinstalled, which `install` alone # never touches; the snapshot is frozen, so it resolves to the same versions on every rebuild. # `util-linux` supplies setpriv, which docker-entrypoint.sh uses for the privilege drop. It is a # Required-priority package and is already present, so apt reports 0 newly installed -- naming it # is what binds it to the snapshot and stops a base-image change from silently dropping setpriv. # It replaced `gosu`, whose bookworm build is statically linked against an EOL Go 1.19.8 # toolchain that no snapshot bump could move, because Debian would not rebuild it. # Trixie does ship a gosu rebuilt on a current toolchain, but setpriv stays: it carries no Go # runtime and therefore no Go-stdlib CVE surface at all, which is the stronger property and # the one that does not decay as the new toolchain ages. `scripts/ci-smoke-image.sh` asserts # gosu is absent, so reintroducing it on trixie is a regression rather than an alternative. RUN set -eux; \ rm -rf /etc/apt/sources.list.d/*; \ echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ trixie main" > /etc/apt/sources.list; \ echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ trixie-updates main" >> /etc/apt/sources.list; \ echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/ trixie-security main" >> /etc/apt/sources.list; \ printf 'Acquire::Check-Valid-Until "false";\nAcquire::Retries "5";\n' > /etc/apt/apt.conf.d/99snapshot; \ apt-get update; \ apt-get indextargets --no-release-info 'Created-By: Packages' \ | awk '/^URI:/ { print $2 }' | sort -u > /tmp/apt-sources.txt; \ test -s /tmp/apt-sources.txt; \ if grep -v '^http://snapshot\.debian\.org/' /tmp/apt-sources.txt; then \ echo "ERROR: the APT sources listed above are outside the pinned snapshot" >&2; \ exit 1; \ fi; \ DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ -o Dpkg::Options::=--force-confold; \ upgrade_plan="$(LC_ALL=C apt-get -s upgrade)"; \ held="$(printf '%s\n' "${upgrade_plan}" | sed -n 's/^.* \([0-9][0-9]*\) not upgraded\.$/\1/p')"; \ case "${held}" in \ '' | *[!0-9]*) \ echo "ERROR: could not read a held-back count from the apt-get upgrade" >&2; \ echo "simulation below; refusing to assume nothing was held back" >&2; \ printf '%s\n' "${upgrade_plan}" >&2; \ exit 1; \ ;; \ esac; \ if [ "${held}" -ne 0 ]; then \ echo "ERROR: apt-get upgrade held back ${held} package(s); it never installs new" >&2; \ echo "packages, so a fix that needs a new dependency is skipped with exit 0" >&2; \ printf '%s\n' "${upgrade_plan}" >&2; \ exit 1; \ fi; \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ bash \ bzip2 \ ca-certificates \ curl \ git \ tini \ util-linux \ xz-utils; \ rm -f /tmp/apt-sources.txt; \ rm -rf /var/lib/apt/lists/* # Install GitLab CLI (glab). `-f` makes an HTTP error fail on the curl line with its status # instead of saving the error page; `--retry-all-errors` also retries a truncated body. # Keep `-o`: curl discards a partial body before a retry only from a file it opened itself, # and cannot rewind a pipe or `>` redirect, so a retry there appends a second copy. The speed # guard turns a stalled transfer into a retryable timeout. `sha256sum -c` stays the install # gate. The checksum list is fetched first so a bad response fails in seconds. RUN set -eux; \ arch="$(dpkg --print-architecture)"; \ case "${arch}" in \ amd64) glab_selector="linux_amd64" ;; \ arm64) glab_selector="linux_arm64" ;; \ armhf) glab_selector="linux_armv6" ;; \ i386) glab_selector="linux_386" ;; \ *) echo "unsupported architecture: ${arch}" >&2; exit 1 ;; \ esac; \ glab_deb="glab_${GLAB_VERSION}_${glab_selector}.deb"; \ glab_base="https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads"; \ curl -fsSL --retry 3 --retry-all-errors --retry-connrefused \ --retry-max-time 300 \ --connect-timeout 30 --speed-limit 1024 --speed-time 60 \ "${glab_base}/checksums.txt" -o /tmp/glab_checksums.txt; \ curl -fsSL --retry 3 --retry-all-errors --retry-connrefused \ --retry-max-time 300 \ --connect-timeout 30 --speed-limit 1024 --speed-time 60 \ "${glab_base}/${glab_deb}" -o "/tmp/${glab_deb}"; \ (cd /tmp && grep " ${glab_deb}$" glab_checksums.txt > glab.sha256) || { \ echo "no checksum for ${glab_deb} in glab v${GLAB_VERSION} checksums.txt; has the release asset naming changed?" >&2; \ exit 1; \ }; \ (cd /tmp && sha256sum -c glab.sha256); \ dpkg -i "/tmp/${glab_deb}"; \ rm "/tmp/${glab_deb}" /tmp/glab_checksums.txt /tmp/glab.sha256 # License notices for glab and for uv (installed below), neither of whose packages ships the # notices of the Go modules and crates compiled into it. `notices/` is the upstream files # for the pinned versions, collected by `scripts/third_party_notices.py` and committed, so the # build fetches nothing. glab's own license becomes /usr/share/doc/glab/copyright, the file its # .deb omits. `scripts/ci-build-image.sh` checks the installed copy against the image's SBOM, as # the app user, before anything is pushed. COPY notices/glab/ /usr/share/doc/glab/ COPY notices/uv/ /usr/share/doc/uv/ WORKDIR /work COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh COPY gitlab-connect /usr/local/bin/ COPY glab-usr /usr/local/bin/ RUN chmod +x \ /usr/local/bin/gitlab-connect \ /usr/local/bin/glab-usr COPY pyproject.toml README.md uv.lock /work/ COPY app/ /work/app/ RUN pip install --no-cache-dir --upgrade "pip==${PIP_VERSION}" && \ pip install --no-cache-dir "uv==${UV_VERSION}" && \ uv sync --frozen --no-dev --no-install-project --no-cache COPY prompts/ /work/prompts/ COPY config/ /work/config/ COPY scripts/ /work/scripts/ RUN mkdir -p /work/run-logs ENV HOME=/home/appuser \ PATH="/opt/venv/bin:/home/appuser/.local/bin:${PATH}" RUN useradd -u 10001 -ms /bin/bash appuser && chown -R appuser:appuser /work /home/appuser # The public source repository and the Debian snapshot the APT sources above used, readable from # the pulled image with `docker inspect` as part of the corresponding-source pointer # (docs/LICENSE_REVIEW.md). The snapshot values come from the same ARG, so they cannot disagree. # Both archive roots are named because the timestamp alone does not say which one a security # update came from. Packages inherited from the base image can be newer than the snapshot, which # is why the labels are a record of the sources, not of every package. Never put the canonical # GitLab host in a label: the image is public. `tests/test_runtime_install_contract.py` pins this. LABEL org.opencontainers.image.source="https://github.com/mcknly/robot-dev-team" \ com.mcknly.robot-dev-team.debian-snapshot="${DEBIAN_SNAPSHOT}" \ com.mcknly.robot-dev-team.debian-source="http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/ http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/" EXPOSE 8080 ENTRYPOINT ["/usr/bin/tini", "--", "docker-entrypoint.sh"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]