From 1227aec81f0ed2d88d59bee0f1b7b5f8ea544b0a Mon Sep 17 00:00:00 2001 From: Rashid Kaleem <230885705+arekay-nv@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:34:28 -0500 Subject: [PATCH 1/2] feat(provenance): resolve source git SHA via a validated multi-channel chain Record the endpoints source commit in every run's report. resolve_git_sha falls through live git of the package's own source checkout (dev, bind-mounted sources) -> baked build-info (wheels) -> ENDPOINTS_GIT_SHA (containers) -> none, validating each channel against a hex-SHA grammar ([0-9a-fA-F]{7,64}, optional -dirty) and appending -dirty for uncommitted tracked changes. The report now always prints `Git SHA: (source: )` and records git_sha_source in result_summary.json. - utils/version.py: _valid_sha grammar on all channels; git is consulted only from the /src/inference_endpoint/ layout, behind a toplevel guard; git-location env family scrub (GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE/...); fail-to-dirty `git status --porcelain --untracked-files=no` probe under GIT_OPTIONAL_LOCKS=0 (copied/rsynced checkouts aren't falsely dirty, the index is never written); corrupt-bake tolerance; resolved once per report (no lru_cache) - metrics/report.py: new required git_sha_source field; unconditional Git SHA display line - scripts/write_build_info.py: build-time generator (toplevel + hex validated, own env scrub, same dirty probe, atomic write); .dockerignore keeps a stray local bake out of images; uv_build packs it into wheels - scripts/Dockerfile.dev: ARG GIT_SHA -> ENV ENDPOINTS_GIT_SHA, declared last so the per-commit value doesn't bust cached layers - scripts/push_docker_image.sh: forward GIT_SHA (+ -dirty) so published images report their commit at runtime - docs/metrics/*, docs/utils/DESIGN.md: document the field + fallback chain - tests: version, generator, report serialization Co-Authored-By: Claude Opus 5.5 (1M context) --- .dockerignore | 7 + .gitignore | 6 + docs/metrics/DESIGN.md | 1 + docs/metrics/report_design.md | 2 +- docs/utils/DESIGN.md | 17 +- scripts/Dockerfile.dev | 30 +- scripts/push_docker_image.sh | 10 + scripts/write_build_info.py | 193 ++++++++ src/inference_endpoint/metrics/report.py | 14 +- src/inference_endpoint/utils/version.py | 194 +++++++- tests/unit/metrics/test_report_builder.py | 71 +++ tests/unit/scripts/test_write_build_info.py | 234 +++++++++ tests/unit/test_version.py | 519 +++++++++++++++++--- 13 files changed, 1190 insertions(+), 108 deletions(-) create mode 100644 .dockerignore create mode 100644 scripts/write_build_info.py create mode 100644 tests/unit/scripts/test_write_build_info.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..8a8aed287 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# Generated build-time provenance (scripts/write_build_info.py). A locally-run +# generator leaves this in the source tree; excluding it from the Docker build +# context stops a stale baked SHA from being COPYd into the image and silently +# overriding the ENDPOINTS_GIT_SHA build-arg (resolve_git_sha ranks baked above +# env). +src/inference_endpoint/_build_info.py +src/inference_endpoint/_build_info.py.tmp diff --git a/.gitignore b/.gitignore index 1c4ab65a5..10be56147 100644 --- a/.gitignore +++ b/.gitignore @@ -212,3 +212,9 @@ CLAUDE.local.md # Generated dataset cache (created by Dataset.get_dataloader()) dataset_cache/ + +# Generated build-time provenance (scripts/write_build_info.py). Packed into +# wheels by uv_build; NOT into images (.dockerignored — images carry the SHA via +# the ENDPOINTS_GIT_SHA build-arg). The .tmp is the atomic-write staging file. +src/inference_endpoint/_build_info.py +src/inference_endpoint/_build_info.py.tmp diff --git a/docs/metrics/DESIGN.md b/docs/metrics/DESIGN.md index e8f7b044c..8671a847e 100644 --- a/docs/metrics/DESIGN.md +++ b/docs/metrics/DESIGN.md @@ -94,6 +94,7 @@ single object that `display()` and `to_json()` render. class Report(msgspec.Struct, frozen=True): version: str git_sha: str | None + git_sha_source: str # which channel resolved git_sha: git | baked | env | none test_started_at: int n_samples_issued: int n_samples_completed: int diff --git a/docs/metrics/report_design.md b/docs/metrics/report_design.md index 7819ba77e..c9a38d899 100644 --- a/docs/metrics/report_design.md +++ b/docs/metrics/report_design.md @@ -74,7 +74,7 @@ returns `{}` (or an all-null early-stopping map if the feature is enabled). ### `Report` (frozen `msgspec.Struct`) -Fields: `version`, `git_sha`, `test_started_at`, +Fields: `version`, `git_sha`, `git_sha_source`, `test_started_at`, `n_samples_issued/completed/failed`, `duration_ns`, `state`, `complete`, the five rollup dicts (`ttft`, `tpot`, `latency`, `input_sequence_lengths`, `output_sequence_lengths`), `legacy_loadgen_window_duration_ns`, `qps`, `tps`, diff --git a/docs/utils/DESIGN.md b/docs/utils/DESIGN.md index ea2d357be..4b879a918 100644 --- a/docs/utils/DESIGN.md +++ b/docs/utils/DESIGN.md @@ -39,9 +39,11 @@ benchmarking tool that lives here for convenience but does import from other `logging.py` and `dataset_utils.py` stay lightweight and broadly reusable. `version.py` is also small, but it intentionally imports -`inference_endpoint.__version__` and shells out to `git` to report build -metadata. `benchmark_httpclient.py` is exempt entirely: it is a standalone tool, -not a reusable helper. +`inference_endpoint.__version__` and resolves a build SHA through a fallback +chain — a live `git` query of its own checkout, a baked `_build_info.py`, then +the `ENDPOINTS_GIT_SHA` env var — to report build metadata. +`benchmark_httpclient.py` is exempt entirely: it is a standalone tool, not a +reusable helper. **`benchmark_httpclient.py` is a standalone tool** @@ -58,7 +60,8 @@ uv run python -m inference_endpoint.utils.benchmark_httpclient --endpoint URL -- ## Integration Points -| Consumer | Usage | -| ------------------ | -------------------------------------------- | -| `main.py` | Calls `setup_logging()` at startup | -| `commands/info.py` | Imports `__version__` for the `info` command | +| Consumer | Usage | +| ------------------- | ------------------------------------------------------------------------------------------------------ | +| `main.py` | Calls `setup_logging()` at startup | +| `commands/info.py` | Imports `__version__` for the `info` command | +| `metrics/report.py` | `Report.from_snapshot` calls `get_version_info()` to record `git_sha` / `git_sha_source` in the report | diff --git a/scripts/Dockerfile.dev b/scripts/Dockerfile.dev index 32db12721..4452ed592 100644 --- a/scripts/Dockerfile.dev +++ b/scripts/Dockerfile.dev @@ -1,6 +1,7 @@ # Development Dockerfile for the MLPerf Inference Endpoint Benchmarking System -# From project root: -# docker build -f scripts/Dockerfile.dev --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g) -t inference-endpoint-dev . +# From project root (pass GIT_SHA so the client reports its source commit; see the +# provenance note at the bottom of this file): +# docker build -f scripts/Dockerfile.dev --build-arg GIT_SHA=$(git rev-parse --short=7 HEAD)$(git diff --quiet HEAD -- || echo -dirty) --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g) -t inference-endpoint-dev . # docker run -v $(pwd):/mnt/inference-endpoint -it --shm-size=512m inference-endpoint-dev bash # # VBench (WAN 2.2) accuracy scorer is ON by default (consistent with the DeepSeek-R1 @@ -130,8 +131,9 @@ RUN if [ "${PROVISION_ROUGE}" = "1" ]; then \ fi # OCI image metadata so `docker inspect` self-identifies this image (the endpoints benchmark -# client, distinct from the lcb-service evaluator image). Declared last so a wording change -# only rebuilds this tiny config layer, not the apt/uv/DSR1/VBench stages above. +# client, distinct from the lcb-service evaluator image). Declared after the build stages +# so a wording change only rebuilds the trailing config layers, not the +# apt/uv/DSR1/VBench stages above. # push_docker_image.sh additionally stamps dynamic source/revision/version/description # annotations on the pushed manifest. The description is deliberately capability-neutral: # PROVISION_DSR1 / PROVISION_VBENCH can be 0, so it names the evaluators without asserting @@ -139,3 +141,23 @@ RUN if [ "${PROVISION_ROUGE}" = "1" ]; then \ LABEL org.opencontainers.image.title="inference-endpoint" \ org.opencontainers.image.description="MLPerf inference endpoint benchmarking client (the inference-endpoint CLI); DeepSeek-R1 and VBench accuracy evaluators are provisioned by default, toggled by PROVISION_DSR1 / PROVISION_VBENCH." \ org.opencontainers.image.source="https://github.com/mlcommons/endpoints" + +# Runtime provenance: expose the source git SHA so `inference-endpoint` prints it +# in its report. This image COPYs src/ but not .git, so live `git rev-parse` finds +# no repo; resolve_git_sha() reads ENDPOINTS_GIT_SHA instead. A checkout +# bind-mounted over it (the `docker run -v` above) resolves via live git first +# when git in the container can read it; if it can't (a uid mismatch git refuses +# as dubious ownership, or a git worktree whose .git points outside the mount), +# this value is reported instead and describes the image, not the mounted code. +# Kept LAST so the per-commit SHA (changes every build) doesn't invalidate the +# cached dependency and provisioning layers above. Pass it at build time; the +# -dirty suffix matters because src/ is COPYed from the working tree as-is: +# docker build ... --build-arg GIT_SHA=$(git rev-parse --short=7 HEAD)$(git diff --quiet HEAD -- || echo -dirty) +# Default is empty (NOT "unknown"): resolve_git_sha validates the value, so an +# empty/omitted arg falls through to source=none rather than recording a bogus +# "unknown" as an env attestation. +# push_docker_image.sh passes GIT_SHA automatically and also stamps image-level +# provenance (the OCI image.revision annotation / : tag) on the pushed +# manifest, so the LABEL above carries no revision. +ARG GIT_SHA= +ENV ENDPOINTS_GIT_SHA=${GIT_SHA} diff --git a/scripts/push_docker_image.sh b/scripts/push_docker_image.sh index 9a920e407..b19896b96 100755 --- a/scripts/push_docker_image.sh +++ b/scripts/push_docker_image.sh @@ -38,6 +38,8 @@ # ALLOW_DIRTY 1=build HEAD with uncommitted tracked changes (default: 0) # PROVISION_DSR1 passthrough build-arg (default: Dockerfile default = 1) # PROVISION_VBENCH passthrough build-arg (default: Dockerfile default = 1) +# (always passed) GIT_SHA build-arg = short HEAD SHA (+"-dirty"), surfaced at +# runtime as ENDPOINTS_GIT_SHA for the report's provenance # IMAGE_DESCRIPTION GHCR package-page description (default: "endpoints client image (ref , commit )") # GHCR_USER/GHCR_TOKEN optional registry login (fallback: GITHUB_ACTOR/GITHUB_TOKEN) set -euo pipefail @@ -213,6 +215,14 @@ BUILD_ARGS=() [[ "$NO_CACHE" == "1" ]] && BUILD_ARGS+=(--no-cache) [[ -n "${PROVISION_DSR1:-}" ]] && BUILD_ARGS+=(--build-arg "PROVISION_DSR1=${PROVISION_DSR1}") [[ -n "${PROVISION_VBENCH:-}" ]] && BUILD_ARGS+=(--build-arg "PROVISION_VBENCH=${PROVISION_VBENCH}") +# Runtime provenance for the report: forward the source SHA into the image env +# (Dockerfile ARG GIT_SHA -> ENV ENDPOINTS_GIT_SHA), so a benchmark run inside the +# published image self-reports its commit. Mirror the -dirty suffix +# resolve_git_sha() applies when building from a dirty tree (reachable only via +# --allow-dirty / --no-push, since a clean :SHA is otherwise required above). +GIT_SHA_VALUE="$(git rev-parse --short=7 HEAD)" +tree_has_tracked_changes && GIT_SHA_VALUE="${GIT_SHA_VALUE}-dirty" +BUILD_ARGS+=(--build-arg "GIT_SHA=${GIT_SHA_VALUE}") # Don't attach SLSA provenance attestations. buildx adds them by default on --push, # which surfaces a spurious `unknown/unknown` entry on the GHCR package page and diff --git a/scripts/write_build_info.py b/scripts/write_build_info.py new file mode 100644 index 000000000..92376837e --- /dev/null +++ b/scripts/write_build_info.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bake the build-time git SHA into ``src/inference_endpoint/_build_info.py``. + +Run from a source checkout (where ``.git`` exists) BEFORE building a wheel so the +SHA travels with the artifact and is readable at runtime without git:: + + python scripts/write_build_info.py + uv build + +The generated file is gitignored but uv_build still packs it into the wheel (it +selects by module tree, not git tracking). ``utils.version.resolve_git_sha`` +reads it whenever no live git checkout of the package is visible (an installed +wheel), ahead of the ``ENDPOINTS_GIT_SHA`` env var. Container images do NOT use +this file (it is ``.dockerignore``d); they carry the SHA via the +``ENDPOINTS_GIT_SHA`` build-arg instead. + +The SHA format mirrors ``get_git_sha``: the 7-char short SHA plus a ``-dirty`` +suffix when tracked files have uncommitted changes. Stdlib-only on purpose — this +runs before ``inference_endpoint`` is installed, so it cannot import from it. +""" + +import os +import re +import subprocess +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_OUTPUT = _REPO_ROOT / "src" / "inference_endpoint" / "_build_info.py" + +# Build-time git budget: more lenient than runtime's snappy 1.0s (the build can +# afford to wait); intentionally not shared with utils.version (stdlib-only here). +_GIT_TIMEOUT_S = 5.0 +# Bare hex object name (SHA-1 short/full or SHA-256). This is the hex CORE of +# utils.version._SHA_RE, which additionally allows a trailing "-dirty"; here the +# SHA is validated before write_build_info appends "-dirty" itself. +_SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}") + +_LICENSE_HEADER = """\ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" + + +# Git's repo-location env family. A CI wrapper or ``git submodule foreach`` can +# export any of these to point git at a foreign repo/index; dropping the whole +# family (plus the toplevel check below) stops the generator from baking a SHA or +# dirty bit that isn't this source tree's. Mirrors utils.version's copy (that +# module can't be imported here — this script runs pre-install, stdlib-only). +_GIT_LOCATION_ENV_VARS = ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", +) + + +def _git_env() -> dict[str, str]: + """Environment for read-only git probes (mirrors ``utils.version._git_env``). + + Drops the repo-location family (see the note above) and sets + ``GIT_OPTIONAL_LOCKS=0`` so ``git status`` never rewrites the index. + """ + env = dict(os.environ) + for var in _GIT_LOCATION_ENV_VARS: + env.pop(var, None) + env["GIT_OPTIONAL_LOCKS"] = "0" + return env + + +def _short_sha(repo_root: Path) -> str: + """Return the validated short SHA of the repo rooted at ``repo_root``. + + Verifies the discovered toplevel IS ``repo_root`` (mirrors get_git_sha's + foreign-repo guard) and that the value is hex, so the generator cannot bake a + foreign or malformed SHA that runtime would otherwise trust as baked ground + truth. Raises on any mismatch so a bad bake fails loudly instead of writing + a value runtime silently discards. + """ + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel", "--short=7", "HEAD"], + cwd=repo_root, + env=_git_env(), + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT_S, + check=True, + ) + lines = result.stdout.splitlines() + if len(lines) != 2: + raise RuntimeError(f"unexpected git rev-parse output: {result.stdout!r}") + toplevel, sha = lines + if Path(toplevel).resolve() != repo_root.resolve(): + raise RuntimeError( + f"refusing to bake a SHA from a different repo: git toplevel " + f"{toplevel!r} != {repo_root}" + ) + sha = sha.strip() + if not _SHA_RE.fullmatch(sha): + raise RuntimeError(f"git returned a non-hex SHA: {sha!r}") + return sha + + +def _tree_dirty(repo_root: Path) -> bool: + """True iff tracked files have uncommitted changes; fails to dirty. + + Mirrors ``utils.version._git_tree_dirty``: untracked files are ignored, + stat-only differences (a copied or rsynced checkout) are content-checked + without rewriting the index, and anything other than exit 0 with no entries + (error, timeout, OSError) is reported as dirty so a build never bakes a clean + SHA it could not verify. + + Caveat: because untracked files are ignored, a NEW untracked file under the + packaged tree (``src/inference_endpoint/``) is NOT flagged, yet uv_build + would pack it — so baking + building a new module without committing it + yields a clean SHA for a wheel whose code differs from that commit. Commit + new modules before baking. + """ + try: + # Bytes, not text: see utils.version._git_tree_dirty. + result = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=no"], + cwd=repo_root, + env=_git_env(), + capture_output=True, + timeout=_GIT_TIMEOUT_S, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return True + return result.returncode != 0 or bool(result.stdout.strip()) + + +def write_build_info(repo_root: Path = _REPO_ROOT, output: Path = _OUTPUT) -> str: + """Generate ``output`` with the (validated) resolved SHA and return that SHA.""" + sha = _short_sha(repo_root) + if _tree_dirty(repo_root): + sha = f"{sha}-dirty" + content = ( + f"{_LICENSE_HEADER}\n" + '"""Generated by scripts/write_build_info.py at build time. Do not edit or commit."""\n' + "\n" + # _short_sha validated the SHA is hex; !r additionally guarantees the + # generated module parses regardless of the value. + f"GIT_SHA = {sha!r}\n" + ) + # Atomic: write a sibling temp file then rename, so a crash mid-write cannot + # leave a truncated _build_info.py that would SyntaxError on import. The + # finally cleans up the temp on any failure before the rename. + tmp = output.with_name(output.name + ".tmp") + try: + tmp.write_text(content) + tmp.replace(output) + finally: + tmp.unlink(missing_ok=True) + return sha + + +def main() -> None: + sha = write_build_info() + print(f"Wrote {_OUTPUT} (GIT_SHA={sha})") + + +if __name__ == "__main__": + main() diff --git a/src/inference_endpoint/metrics/report.py b/src/inference_endpoint/metrics/report.py index 98834de34..5890975fe 100644 --- a/src/inference_endpoint/metrics/report.py +++ b/src/inference_endpoint/metrics/report.py @@ -211,6 +211,11 @@ class Report(msgspec.Struct, frozen=True): # type: ignore[call-arg] version: str git_sha: str | None + # Which channel resolved git_sha, in resolution order: "git" (live checkout), + # "baked" (build-info file), "env" (ENDPOINTS_GIT_SHA), or "none". Records how + # much to trust the SHA — a clean git/baked SHA is ground truth, an env one an + # attestation, a "-dirty" one a warning. See utils/version.resolve_git_sha. + git_sha_source: str test_started_at: int n_samples_issued: int # Terminal responses; failed samples are a subset of this count. @@ -429,6 +434,7 @@ def _series_dict(key: str) -> dict[str, Any]: return cls( version=str(version_info.get("version", "unknown")), git_sha=version_info.get("git_sha"), + git_sha_source=str(version_info.get("git_sha_source", "none")), test_started_at=0, # TODO: surface session_started_ns via snapshot n_samples_issued=n_issued, n_samples_completed=n_completed, @@ -528,8 +534,12 @@ def display( f"final snapshot received) — some async metrics may be missing.{newline}" ) fn(f"Version: {self.version}{newline}") - if self.git_sha: - fn(f"Git SHA: {self.git_sha}{newline}") + # Always emitted (even when unresolved) so a missing SHA is unambiguous — + # a blank line could mean capture failed or an old report format; the + # explicit "unknown (source: none)" says provenance was attempted. + fn( + f"Git SHA: {self.git_sha or 'unknown'} (source: {self.git_sha_source}){newline}" + ) if self.run_config: fn(f"Run config:{newline}") for section, params in self.run_config.items(): diff --git a/src/inference_endpoint/utils/version.py b/src/inference_endpoint/utils/version.py index 4ef2e52de..c39e44c74 100644 --- a/src/inference_endpoint/utils/version.py +++ b/src/inference_endpoint/utils/version.py @@ -15,31 +15,136 @@ """Version and git information utilities.""" +import os +import re import subprocess -from functools import lru_cache from pathlib import Path from .. import __version__ +# Build-time provenance for installed WHEELS: scripts/write_build_info.py writes +# this file and uv_build packs it (it selects by module tree, not git tracking, +# so the gitignored file is still included). Container images do NOT use it — the +# file is .dockerignored and images carry the SHA via the ENDPOINTS_GIT_SHA env +# channel instead, so a stray local bake can't override the build-arg. Absent in +# a plain source checkout (generated + gitignored). +try: + from .._build_info import GIT_SHA as _BAKED_GIT_SHA +except Exception: + # Broad by design: a missing (ImportError), truncated (SyntaxError) or + # hand-edited unquoted (NameError) generated file must degrade to "no baked + # SHA" rather than break version import and, with it, Report.from_snapshot. + # KeyboardInterrupt/SystemExit are BaseException and still propagate. + _BAKED_GIT_SHA = None # type: ignore[assignment] -def _resolve_repo_root() -> Path: +# Explicit attestation, primarily for containers/CI: `docker build --build-arg +# GIT_SHA=...` -> ENV (or a launcher) sets this for code that has neither a git +# checkout nor a baked file. +_GIT_SHA_ENV_VAR = "ENDPOINTS_GIT_SHA" + +# A resolved SHA is a git hex object name (SHA-1 short/full or SHA-256, any case) +# with an optional -dirty marker. EVERY channel is validated against this so a +# sentinel ("unknown"), a stray value, a non-string baked value, or an injected +# newline/ANSI sequence cannot masquerade as an attestation or corrupt report.txt. +_SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}(?:-dirty)?") + + +def _valid_sha(value: object) -> str | None: + """Return the value if it is a well-formed (optionally dirty) git SHA, else None. + + Guards against a non-string baked value (e.g. an unquoted ``GIT_SHA = 123`` + in a hand-edited _build_info.py that imports cleanly) as well as sentinels + and injected control characters. + """ + if not isinstance(value, str): + return None + value = value.strip() + return value if _SHA_RE.fullmatch(value) else None + + +def _resolve_repo_root() -> Path | None: """Repo root of this source checkout, anchored to the module location. - ``src/inference_endpoint/utils/version.py`` -> ``parents[3]`` is the repo root. - Falls back to the filesystem root when the package lives in a shallower tree - (e.g. an installed wheel copied to ``/inference_endpoint``) so import never - crashes; get_git_sha's toplevel guard then yields None, not a foreign SHA. + Only the source layout ``/src/inference_endpoint/utils/version.py`` has + one. Any other location (an installed wheel, a copy vendored inside another + repo) yields None, so get_git_sha never asks git about a foreign repo. """ parents = Path(__file__).resolve().parents - return parents[3] if len(parents) > 3 else parents[-1] + if len(parents) < 4 or [p.name for p in parents[2::-1]] != [ + "src", + "inference_endpoint", + "utils", + ]: + return None + return parents[3] _REPO_ROOT = _resolve_repo_root() -@lru_cache(maxsize=1) +# Git's repo-location env family. A stray value (a CI wrapper, ``git submodule +# foreach``, a parent-process hook) can point git at a foreign repo/index while +# ``--show-toplevel`` still reports our cwd — slipping a foreign SHA past the +# toplevel guard, or a foreign index past the dirty probe. Scrubbing the whole +# family re-anchors every git call to its ``cwd``. Kept in sync with the +# generator's copy in scripts/write_build_info.py (stdlib-only there). +_GIT_LOCATION_ENV_VARS = ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", +) + + +def _git_env() -> dict[str, str]: + """Environment for read-only git probes. + + Drops the repo-location family (see the note above) and sets + ``GIT_OPTIONAL_LOCKS=0`` so ``git status`` never rewrites the caller's index. + The env var, unlike the ``--no-optional-locks`` flag, is ignored rather than + rejected by a git too old to know it (common on HPC hosts). + """ + env = dict(os.environ) + for var in _GIT_LOCATION_ENV_VARS: + env.pop(var, None) + env["GIT_OPTIONAL_LOCKS"] = "0" + return env + + +def _git_tree_dirty(repo_root: Path) -> bool: + """True iff tracked files have uncommitted changes (via ``git status``). + + Untracked files are ignored — only modifications to tracked content mark the + tree dirty, so scratch artifacts a dev checkout accumulates (e.g. run output + ``*.jsonl``) don't taint the provenance SHA. Stat-only differences (a copied, + rsynced or re-mounted checkout) are content-checked in memory. + + Fails to dirty: anything other than exit 0 with no entries — a git error + (e.g. a corrupt index or a dubious-ownership refusal), a timeout, or an + OSError — is reported as dirty. An unverifiable tree is flagged ``-dirty`` + rather than silently blessed as clean, so provenance never understates + uncertainty (e.g. a slow probe on NFS/HPC homes). + """ + try: + # Bytes, not text: with core.quotePath=false a non-UTF-8 path would + # otherwise raise UnicodeDecodeError out of report building. + result = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=no"], + capture_output=True, + timeout=1.0, + check=False, + cwd=repo_root, + env=_git_env(), + ) + except (OSError, subprocess.TimeoutExpired): + return True + return result.returncode != 0 or bool(result.stdout.strip()) + + def get_git_sha() -> str | None: - """Get the git commit SHA of the endpoints source checkout. + """Get the git commit SHA of the endpoints source checkout via live git. The query is anchored to this package's own location (``_REPO_ROOT``) rather than the process working directory, so the SHA reflects the endpoints repo @@ -47,9 +152,13 @@ def get_git_sha() -> str | None: Returns: The short git SHA (at least 7 chars; git lengthens it if a 7-char - prefix is ambiguous), or None if the package is not in a git checkout - (e.g. an installed wheel) or git is unavailable. + prefix is ambiguous), suffixed with ``-dirty`` when the working tree has + uncommitted tracked changes; or None if the package is not in a source + checkout (e.g. an installed wheel) or git is unavailable. """ + repo_root = _REPO_ROOT + if repo_root is None: + return None try: result = subprocess.run( ["git", "rev-parse", "--show-toplevel", "--short=7", "HEAD"], @@ -57,7 +166,8 @@ def get_git_sha() -> str | None: text=True, timeout=1.0, check=False, - cwd=_REPO_ROOT, + cwd=repo_root, + env=_git_env(), ) if result.returncode != 0: return None @@ -65,24 +175,68 @@ def get_git_sha() -> str | None: if len(lines) != 2: return None toplevel, sha = lines - # Only trust the SHA if the discovered repo IS this source tree. Guards against - # an installed wheel nested inside an unrelated repo reporting a foreign SHA — + # Only trust the SHA if the discovered repo IS this source tree (e.g. not + # an enclosing repo whose subdirectory merely holds a checkout's src/) — # a wrong provenance SHA is worse than None. - if Path(toplevel).resolve() != _REPO_ROOT: + if Path(toplevel).resolve() != repo_root: return None - return sha.strip() + sha = sha.strip() + return f"{sha}-dirty" if _git_tree_dirty(repo_root) else sha except (OSError, subprocess.TimeoutExpired): return None -@lru_cache(maxsize=1) +def resolve_git_sha() -> tuple[str | None, str]: + """Resolve the source SHA and record which channel provided it. + + Not cached: the env var and the working-tree dirty state can change within a + process (a launcher exporting ``ENDPOINTS_GIT_SHA`` after import; a run that + dirties the tree; a transient dirty-probe failure that must not stick), and + resolution is cold-path (built once per report), so it is re-evaluated on + each call rather than frozen at first use. + + Priority (first hit wins), ordered so the value most tightly bound to the + running code wins over a claim recorded about it elsewhere: + + 1. ``"git"`` — a live ``git`` query against this package's own source + checkout. It observes the code actually running, ``-dirty`` included, so + while git can read the checkout, a stale bake left in it or an image env + value under a bind-mounted checkout can't mask it. If git can't (no git, + a dubious-ownership refusal, a timeout), resolution falls through. + 2. ``"baked"`` — ``_build_info.GIT_SHA`` packed into the wheel at build time + from the exact packaged tree; travels with the artifact, no runtime git. + 3. ``"env"`` — the ``ENDPOINTS_GIT_SHA`` environment variable (container + build-arg or launch-script attestation). + 4. ``"none"`` — nothing resolved; SHA is None. + + Returns: + ``(sha, source)`` where ``source`` is one of the labels above. ``sha`` is + None only when ``source == "none"``. + """ + # Validate the live-git result too: get_git_sha reads git stdout, so a wrapping + # git on PATH or polluted output could otherwise carry ANSI/newline into the + # report. Uniform validation also keeps all three channels to one grammar. + live_sha = _valid_sha(get_git_sha()) + if live_sha: + return live_sha, "git" + baked_sha = _valid_sha(_BAKED_GIT_SHA) + if baked_sha: + return baked_sha, "baked" + env_sha = _valid_sha(os.environ.get(_GIT_SHA_ENV_VAR)) + if env_sha: + return env_sha, "env" + return None, "none" + + def get_version_info() -> dict[str, str | None]: - """Get version and git information. + """Get version and git provenance information. Returns: - Dictionary with 'version' and 'git_sha' keys. + Dictionary with 'version', 'git_sha', and 'git_sha_source' keys. """ + git_sha, git_sha_source = resolve_git_sha() return { "version": __version__, - "git_sha": get_git_sha(), + "git_sha": git_sha, + "git_sha_source": git_sha_source, } diff --git a/tests/unit/metrics/test_report_builder.py b/tests/unit/metrics/test_report_builder.py index b284f983d..21473fc34 100644 --- a/tests/unit/metrics/test_report_builder.py +++ b/tests/unit/metrics/test_report_builder.py @@ -225,6 +225,24 @@ def test_empty_registry(self): assert report.output_sequence_lengths == {} assert report.tpot == {} + def test_git_provenance_comes_from_version_info(self, monkeypatch): + monkeypatch.setattr( + "inference_endpoint.metrics.report.get_version_info", + lambda: {"version": "v", "git_sha": "abc1234", "git_sha_source": "env"}, + ) + report = _build_report(_make_registry(n_samples=5)) + assert (report.git_sha, report.git_sha_source) == ("abc1234", "env") + data = json.loads(report.to_json()) + assert (data["git_sha"], data["git_sha_source"]) == ("abc1234", "env") + + def test_git_sha_source_defaults_to_none_when_absent(self, monkeypatch): + monkeypatch.setattr( + "inference_endpoint.metrics.report.get_version_info", + lambda: {"version": "v", "git_sha": None}, + ) + report = _build_report(_make_registry(n_samples=5)) + assert report.git_sha_source == "none" + def test_isl_series_absent(self): # settings.metrics_isl=false: the aggregator never registers the # series, so the snapshot lacks it entirely (not merely count==0). @@ -549,6 +567,7 @@ def test_display_no_started_at(self): report = Report( version="test", git_sha=None, + git_sha_source="none", test_started_at=0, n_samples_issued=0, n_samples_completed=0, @@ -567,11 +586,61 @@ def test_display_no_started_at(self): output = "\n".join(lines) assert "Test started at" not in output + def test_display_always_prints_git_sha_line_with_source(self): + """The Git SHA line is emitted unconditionally, showing 'unknown' + source.""" + report = Report( + version="test", + git_sha=None, + git_sha_source="none", + test_started_at=0, + n_samples_issued=0, + n_samples_completed=0, + n_samples_failed=0, + duration_ns=None, + state="complete", + complete=True, + ttft={}, + tpot={}, + latency={}, + input_sequence_lengths={}, + output_sequence_lengths={}, + ) + lines: list[str] = [] + report.display(fn=lines.append, summary_only=True) + output = "\n".join(lines) + assert "Git SHA: unknown" in output + assert "source: none" in output + + def test_display_prints_git_sha_with_source_when_present(self): + """A resolved SHA renders with its provenance source annotated.""" + report = Report( + version="test", + git_sha="abc1234-dirty", + git_sha_source="git", + test_started_at=0, + n_samples_issued=0, + n_samples_completed=0, + n_samples_failed=0, + duration_ns=None, + state="complete", + complete=True, + ttft={}, + tpot={}, + latency={}, + input_sequence_lengths={}, + output_sequence_lengths={}, + ) + lines: list[str] = [] + report.display(fn=lines.append, summary_only=True) + output = "\n".join(lines) + assert "Git SHA: abc1234-dirty (source: git)" in output + def test_display_warns_when_incomplete(self): """Reports with ``complete=False`` surface a WARNING in display().""" report = Report( version="test", git_sha=None, + git_sha_source="none", test_started_at=0, n_samples_issued=10, n_samples_completed=10, @@ -594,6 +663,7 @@ def _report_with_full_run_osl(self, fr, *, osl_windowed=None, complete=True): return Report( version="test", git_sha=None, + git_sha_source="none", test_started_at=0, n_samples_issued=10, n_samples_completed=10, @@ -690,6 +760,7 @@ def test_display_warns_when_interrupted(self): report = Report( version="test", git_sha=None, + git_sha_source="none", test_started_at=0, n_samples_issued=10, n_samples_completed=5, diff --git a/tests/unit/scripts/test_write_build_info.py b/tests/unit/scripts/test_write_build_info.py new file mode 100644 index 000000000..867140127 --- /dev/null +++ b/tests/unit/scripts/test_write_build_info.py @@ -0,0 +1,234 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the scripts/write_build_info.py build-time SHA generator.""" + +import importlib.util +import re +import shutil +import subprocess +from pathlib import Path + +import pytest +from inference_endpoint.utils import version as version_mod + +_SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "write_build_info.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("write_build_info", _SCRIPT) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _git(*args: str, cwd: Path) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() + + +def _exec_git_sha(path: Path) -> str: + """Execute the generated module and return its GIT_SHA (proves importability).""" + namespace: dict = {} + exec(compile(path.read_text(), str(path), "exec"), namespace) + return namespace["GIT_SHA"] + + +@pytest.fixture +def git_repo(tmp_path): + _git("init", cwd=tmp_path) + _git("config", "user.email", "t@t.co", cwd=tmp_path) + _git("config", "user.name", "t", cwd=tmp_path) + (tmp_path / "tracked.txt").write_text("v1\n") + _git("add", "tracked.txt", cwd=tmp_path) + _git("commit", "-m", "init", cwd=tmp_path) + return tmp_path + + +@pytest.mark.unit +def test_writes_importable_hex_sha_on_clean_tree(git_repo): + mod = _load_module() + out = git_repo / "_build_info.py" + sha = mod.write_build_info(repo_root=git_repo, output=out) + + assert out.exists() + assert re.fullmatch(r"[0-9a-f]{7}", sha) # clean tree -> bare short SHA + assert _exec_git_sha(out) == sha # generated file is importable and matches + assert "SPDX-License-Identifier" in out.read_text() # license header emitted + + +@pytest.mark.unit +def test_appends_dirty_on_modified_tracked_file(git_repo): + mod = _load_module() + (git_repo / "tracked.txt").write_text("v2-uncommitted\n") # modify tracked + out = git_repo / "_build_info.py" + sha = mod.write_build_info(repo_root=git_repo, output=out) + + assert sha.endswith("-dirty") + assert _exec_git_sha(out) == sha + + +@pytest.mark.unit +def test_untracked_file_does_not_mark_dirty(git_repo): + mod = _load_module() + (git_repo / "scratch.jsonl").write_text("noise\n") # untracked scratch + out = git_repo / "_build_info.py" + sha = mod.write_build_info(repo_root=git_repo, output=out) + + assert not sha.endswith("-dirty") + + +@pytest.mark.unit +def test_copied_checkout_is_not_dirty(git_repo, tmp_path_factory): + """A copied checkout (new inodes/ctimes, same content) must not bake -dirty.""" + mod = _load_module() + copy = tmp_path_factory.mktemp("copy") / "repo" + shutil.copytree(git_repo, copy) + index_before = (copy / ".git" / "index").read_bytes() + + assert mod._tree_dirty(copy) is False + assert (copy / ".git" / "index").read_bytes() == index_before + (copy / "tracked.txt").write_text("v2-uncommitted\n") + assert mod._tree_dirty(copy) is True + + +@pytest.mark.unit +def test_short_sha_raises_without_git(tmp_path): + mod = _load_module() + with pytest.raises(subprocess.CalledProcessError): + mod._short_sha(tmp_path) # no .git -> check=True fails loudly + + +@pytest.mark.unit +def test_write_is_atomic_leaves_no_tmp_file(git_repo): + mod = _load_module() + out = git_repo / "_build_info.py" + mod.write_build_info(repo_root=git_repo, output=out) + leftovers = list(git_repo.glob("_build_info.py*.tmp")) + list( + git_repo.glob("*.tmp") + ) + assert leftovers == [] + + +@pytest.mark.unit +def test_short_sha_rejects_foreign_toplevel(git_repo): + """Running against a subdir whose git toplevel != repo_root is refused.""" + mod = _load_module() + subdir = git_repo / "sub" + subdir.mkdir() + with pytest.raises(RuntimeError, match="different repo"): + mod._short_sha(subdir) + + +@pytest.mark.unit +def test_short_sha_ignores_stray_git_dir(git_repo, tmp_path, monkeypatch): + """A stray GIT_DIR must not bake a foreign SHA (the toplevel guard alone passes). + + With GIT_DIR set + GIT_WORK_TREE unset, ``--show-toplevel`` reports repo_root + (guard passes) while HEAD would resolve the foreign repo; _git_env's scrub is + what re-anchors the bake to repo_root's own commit. + """ + mod = _load_module() + foreign = tmp_path / "foreign" + foreign.mkdir() + _git("init", cwd=foreign) + _git( + "-c", + "user.email=t@t.co", + "-c", + "user.name=t", + "commit", + "--allow-empty", + "-m", + "foreign", + cwd=foreign, + ) + foreign_sha = _git("rev-parse", "--short=7", "HEAD", cwd=foreign) + our_sha = _git("rev-parse", "--short=7", "HEAD", cwd=git_repo) + assert foreign_sha != our_sha + + monkeypatch.setenv("GIT_DIR", str(foreign / ".git")) + monkeypatch.delenv("GIT_WORK_TREE", raising=False) + assert mod._short_sha(git_repo) == our_sha + + +@pytest.mark.unit +def test_tree_dirty_fails_to_dirty_on_probe_error(git_repo, monkeypatch): + """A dirty-probe timeout/error is reported as dirty, mirroring runtime.""" + mod = _load_module() + + def boom(*args, **kwargs): + raise subprocess.TimeoutExpired(cmd="git", timeout=5.0) + + monkeypatch.setattr(mod.subprocess, "run", boom) + assert mod._tree_dirty(git_repo) is True + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("returncode", "stdout"), + [(0, b" M tracked.txt\n"), (128, b"")], # a listed change; a git error +) +def test_tree_dirty_scrubs_git_env_and_fails_to_dirty( + git_repo, monkeypatch, returncode, stdout +): + """The generator's probe runs with a scrubbed, lock-free env and errs to dirty.""" + mod = _load_module() + monkeypatch.setenv("GIT_DIR", "/foreign/.git") + monkeypatch.setenv("GIT_INDEX_FILE", "/foreign/.git/index") + seen = {} + + def fake_run(cmd, *args, **kwargs): + seen["env"] = kwargs.get("env") + return subprocess.CompletedProcess( + args=cmd, returncode=returncode, stdout=stdout + ) + + monkeypatch.setattr(mod.subprocess, "run", fake_run) + assert mod._tree_dirty(git_repo) is True + assert seen["env"] is not None + assert "GIT_DIR" not in seen["env"] + assert "GIT_INDEX_FILE" not in seen["env"] + assert seen["env"]["GIT_OPTIONAL_LOCKS"] == "0" + + +@pytest.mark.unit +def test_git_location_env_vars_in_sync_with_runtime(): + """The generator's scrub list must not drift from utils.version's.""" + mod = _load_module() + assert mod._GIT_LOCATION_ENV_VARS == version_mod._GIT_LOCATION_ENV_VARS + + +@pytest.mark.unit +def test_atomic_write_preserves_prior_output_on_failure(git_repo, monkeypatch): + """A mid-write failure leaves the previous _build_info.py intact, not truncated.""" + mod = _load_module() + out = git_repo / "_build_info.py" + out.write_text('GIT_SHA = "previous"\n') + + original_replace = Path.replace + + def failing_replace(self, target): + raise OSError("simulated rename failure") + + monkeypatch.setattr(Path, "replace", failing_replace) + with pytest.raises(OSError, match="simulated rename failure"): + mod.write_build_info(repo_root=git_repo, output=out) + monkeypatch.setattr(Path, "replace", original_replace) + + assert out.read_text() == 'GIT_SHA = "previous"\n' # untouched + assert list(git_repo.glob("*.tmp")) == [] # temp cleaned up diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index 56a43fdab..bed3fe9d6 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -17,12 +17,40 @@ import shutil import subprocess +import sys from pathlib import Path import pytest from inference_endpoint import __version__ from inference_endpoint.utils import version as version_mod -from inference_endpoint.utils.version import _REPO_ROOT, get_git_sha, get_version_info +from inference_endpoint.utils.version import ( + _REPO_ROOT, + _valid_sha, + get_git_sha, + get_version_info, + resolve_git_sha, +) + + +def _fake_git_run(sha: str, *, dirty: bool): + """subprocess.run stand-in dispatching on the git subcommand. + + ``rev-parse`` returns a matching toplevel + ``sha``; ``status`` lists one + modified tracked file (dirty) or nothing (clean). + """ + + def fake_run(cmd, *args, **kwargs): + if "rev-parse" in cmd: + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout=f"{_REPO_ROOT}\n{sha}\n" + ) + if "status" in cmd: + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout=b" M tracked.py\n" if dirty else b"" + ) + raise AssertionError(f"unexpected git invocation: {cmd}") + + return fake_run def _git(*args: str, cwd: Path) -> str: @@ -35,38 +63,167 @@ def _git(*args: str, cwd: Path) -> str: ).stdout.strip() +# --------------------------------------------------------------------------- +# _valid_sha +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.parametrize( + "value", + [ + "abc1234", # 7-char short SHA-1 + "abc1234-dirty", # with dirty marker + "a" * 40, # full SHA-1 + "A1B2C3D", # uppercase accepted + "f" * 64, # full SHA-256 + " abc1234 ", # surrounding whitespace stripped + ], +) +def test_valid_sha_accepts(value): + assert _valid_sha(value) == value.strip() + + +@pytest.mark.unit +@pytest.mark.parametrize( + "value", + [ + None, + 1234567, # non-string (unquoted GIT_SHA in a hand-edited _build_info.py) + "", + " ", + "unknown", # the display sentinel + "not-a-sha", + "abc123", # too short (6) + "a" * 65, # too long (>64) + "abc1234\ndef5678", # embedded newline (injection) — fullmatch anchors + "abc1234-dirty-dirty", # trailing junk + "abc1234\x1b[31m", # ANSI escape + ], +) +def test_valid_sha_rejects(value): + assert _valid_sha(value) is None + + +# --------------------------------------------------------------------------- +# get_git_sha (live-git channel) +# --------------------------------------------------------------------------- + + @pytest.mark.unit def test_get_git_sha(): """Test that get_git_sha returns a string or None.""" sha = get_git_sha() if sha is not None: assert isinstance(sha, str) + # A dirty working tree appends a "-dirty" suffix; strip it before the + # shape checks on the bare SHA. + base = sha.removesuffix("-dirty") # --short=7 is a minimum width; git lengthens it on prefix collision. - assert 7 <= len(sha) <= 40 - assert sha.isalnum() # Should only contain alphanumeric chars + assert 7 <= len(base) <= 40 + assert base.isalnum() @pytest.mark.unit -def test_get_version_info(): - """Test that get_version_info returns correct structure.""" - info = get_version_info() - assert isinstance(info, dict) - assert "version" in info - assert "git_sha" in info - assert info["version"] == __version__ - # git_sha can be None if not in a git repo - if info["git_sha"] is not None: - assert isinstance(info["git_sha"], str) - assert 7 <= len(info["git_sha"]) <= 40 +def test_get_git_sha_appends_dirty_when_tree_has_tracked_changes(monkeypatch): + """Uncommitted tracked changes surface as a ``-dirty`` suffix on the SHA.""" + monkeypatch.setattr( + version_mod.subprocess, "run", _fake_git_run("abc1234", dirty=True) + ) + assert get_git_sha() == "abc1234-dirty" + + +@pytest.mark.unit +def test_git_tree_dirty_fails_to_dirty_on_error_returncode(monkeypatch): + """A non-zero status code (e.g. 128 from a corrupt index) errs to dirty.""" + + def fake_run(cmd, *args, **kwargs): + if "rev-parse" in cmd: + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout=f"{_REPO_ROOT}\nabc1234\n" + ) + if "status" in cmd: + return subprocess.CompletedProcess(args=cmd, returncode=128, stdout=b"") + raise AssertionError(f"unexpected git invocation: {cmd}") + + monkeypatch.setattr(version_mod.subprocess, "run", fake_run) + assert get_git_sha() == "abc1234-dirty" + + +@pytest.mark.unit +def test_git_tree_dirty_fails_to_dirty_on_exception(monkeypatch): + """If the dirty probe raises (timeout/OSError), err on the side of dirty.""" + + def fake_run(cmd, *args, **kwargs): + if "rev-parse" in cmd: + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout=f"{_REPO_ROOT}\nabc1234\n" + ) + if "status" in cmd: + raise subprocess.TimeoutExpired(cmd="git", timeout=1.0) + raise AssertionError(f"unexpected git invocation: {cmd}") + + monkeypatch.setattr(version_mod.subprocess, "run", fake_run) + assert get_git_sha() == "abc1234-dirty" + + +@pytest.mark.unit +def test_git_tree_dirty_handles_non_utf8_output(tmp_path, monkeypatch): + """A non-UTF-8 path in status output (core.quotePath=false) reads as dirty.""" + monkeypatch.setattr( + version_mod.subprocess, + "run", + lambda cmd, *a, **k: subprocess.CompletedProcess( + args=cmd, returncode=0, stdout=b" M caf\xe9.txt\n" + ), + ) + assert version_mod._git_tree_dirty(tmp_path) is True + + +@pytest.mark.unit +def test_get_git_sha_none_outside_source_layout(monkeypatch): + """Without a source-checkout root, git is never consulted.""" + + def fail_run(*args, **kwargs): + raise AssertionError("git must not run outside a source checkout") + + monkeypatch.setattr(version_mod, "_REPO_ROOT", None) + monkeypatch.setattr(version_mod.subprocess, "run", fail_run) + assert get_git_sha() is None + + +@pytest.mark.unit +@pytest.mark.parametrize( + "fake", + [ + FileNotFoundError(), + subprocess.TimeoutExpired(cmd="git", timeout=1.0), + subprocess.CompletedProcess(args=[], returncode=128, stdout="", stderr="x"), + subprocess.CompletedProcess(args=[], returncode=0, stdout="only-one-line\n"), + subprocess.CompletedProcess( + args=[], returncode=0, stdout="/some/other/repo\ndeadbee\n" + ), + ], +) +def test_git_sha_returns_none_on_untrusted_or_missing_repo(fake, monkeypatch): + """A foreign/absent repo yields None rather than a wrong provenance SHA.""" + + def fake_run(*args, **kwargs): + if isinstance(fake, BaseException): + raise fake + return fake + + monkeypatch.setattr(version_mod.subprocess, "run", fake_run) + assert get_git_sha() is None @pytest.mark.unit -def test_version_info_cached(): - """Test that get_version_info is properly cached.""" - info1 = get_version_info() - info2 = get_version_info() - # Should return the same object due to lru_cache - assert info1 is info2 +def test_git_sha_returned_when_toplevel_matches(monkeypatch): + """Happy path: toplevel == _REPO_ROOT and a clean tree -> bare sha, no suffix.""" + monkeypatch.setattr( + version_mod.subprocess, "run", _fake_git_run("abc1234", dirty=False) + ) + assert get_git_sha() == "abc1234" @pytest.mark.unit @@ -79,13 +236,11 @@ def test_git_sha_is_endpoints_repo_not_cwd(tmp_path, monkeypatch): if shutil.which("git") is None: pytest.skip("git not available") - # The endpoints repo SHA, resolved independently of the process CWD. try: expected = _git("rev-parse", "--short=7", "HEAD", cwd=_REPO_ROOT) except subprocess.CalledProcessError: pytest.skip("Source tree is not a git repository") - # A distinct, unrelated git repo the CLI is pretend-launched from. other = tmp_path / "other_repo" other.mkdir() _git("init", cwd=other) @@ -101,77 +256,293 @@ def test_git_sha_is_endpoints_repo_not_cwd(tmp_path, monkeypatch): cwd=other, ) other_sha = _git("rev-parse", "--short=7", "HEAD", cwd=other) - assert other_sha != expected # sanity: the two repos differ + assert other_sha != expected monkeypatch.chdir(other) - get_git_sha.cache_clear() - try: - sha = get_git_sha() - finally: - # Don't leak the cache-cleared value into other tests' assumptions. - get_git_sha.cache_clear() + sha = get_git_sha() - assert sha == expected - assert sha != other_sha + # A dirty source tree (e.g. mid-development) appends "-dirty"; compare bases. + assert sha is not None + base = sha.removesuffix("-dirty") + assert base == expected + assert base != other_sha -@pytest.mark.unit -@pytest.mark.parametrize( - "fake", - [ - # git missing / cwd gone / timed out - FileNotFoundError(), - subprocess.TimeoutExpired(cmd="git", timeout=1.0), - # not a git repo (non-zero exit) - subprocess.CompletedProcess(args=[], returncode=128, stdout="", stderr="x"), - # malformed output (not exactly two lines) - subprocess.CompletedProcess(args=[], returncode=0, stdout="only-one-line\n"), - # discovered repo is NOT this source tree (e.g. wheel nested in another repo) - subprocess.CompletedProcess( - args=[], returncode=0, stdout="/some/other/repo\ndeadbee\n" - ), - ], +_GIT_LOCATION_VARS = ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", ) -def test_git_sha_returns_none_on_untrusted_or_missing_repo(fake, monkeypatch): - """A foreign/absent repo yields None rather than a wrong provenance SHA.""" - def fake_run(*args, **kwargs): - if isinstance(fake, BaseException): - raise fake - return fake + +@pytest.mark.unit +def test_git_env_drops_git_location_family(monkeypatch): + """_git_env scrubs the git-location family, disables optional locks, keeps the rest.""" + for var in _GIT_LOCATION_VARS: + monkeypatch.setenv(var, "/foreign") + monkeypatch.setenv("PATH", "/usr/bin") + env = version_mod._git_env() + for var in _GIT_LOCATION_VARS: + assert var not in env + assert env["GIT_OPTIONAL_LOCKS"] == "0" + assert env["PATH"] == "/usr/bin" + + +@pytest.mark.unit +def test_get_git_sha_scrubs_git_env_and_preserves_dirty(monkeypatch): + """Both git calls get a scrubbed env, and a stray index can't fake a clean tree. + + GIT_DIR/GIT_INDEX_FILE set in the ambient env must not reach either + subprocess, and a dirty status still yields a -dirty SHA. + """ + monkeypatch.setenv("GIT_DIR", "/foreign/.git") + monkeypatch.setenv("GIT_INDEX_FILE", "/foreign/.git/index") + seen_envs = [] + fake_git = _fake_git_run("abc1234", dirty=True) + + def fake_run(cmd, *args, **kwargs): + seen_envs.append(kwargs.get("env")) + return fake_git(cmd, *args, **kwargs) monkeypatch.setattr(version_mod.subprocess, "run", fake_run) - get_git_sha.cache_clear() + assert get_git_sha() == "abc1234-dirty" + assert len(seen_envs) == 2 # rev-parse + status + for env in seen_envs: + assert env is not None + assert "GIT_DIR" not in env + assert "GIT_INDEX_FILE" not in env + + +@pytest.mark.unit +def test_get_git_sha_ignores_stray_git_dir(tmp_path, monkeypatch): + """A stray GIT_DIR must not slip a foreign repo's SHA past the toplevel guard. + + With GIT_DIR set (and GIT_WORK_TREE unset) git defaults the work-tree to cwd, + so ``--show-toplevel`` reports our repo (guard passes) while ``HEAD`` would + otherwise resolve the foreign repo. Scrubbing GIT_DIR closes that. + """ + if shutil.which("git") is None: + pytest.skip("git not available") try: - assert get_git_sha() is None - finally: - get_git_sha.cache_clear() + our_sha = _git("rev-parse", "--short=7", "HEAD", cwd=_REPO_ROOT) + except subprocess.CalledProcessError: + pytest.skip("Source tree is not a git repository") + + foreign = tmp_path / "foreign" + foreign.mkdir() + _git("init", cwd=foreign) + _git( + "-c", + "user.email=t@t.co", + "-c", + "user.name=t", + "commit", + "--allow-empty", + "-m", + "foreign", + cwd=foreign, + ) + foreign_sha = _git("rev-parse", "--short=7", "HEAD", cwd=foreign) + assert foreign_sha != our_sha + + monkeypatch.setenv("GIT_DIR", str(foreign / ".git")) + monkeypatch.delenv("GIT_WORK_TREE", raising=False) + sha = get_git_sha() + + assert sha is not None + base = sha.removesuffix("-dirty") + assert base == our_sha + assert base != foreign_sha @pytest.mark.unit -def test_git_sha_returned_when_toplevel_matches(monkeypatch): - """Happy path without a real git repo: toplevel == _REPO_ROOT -> return sha. +def test_git_tree_dirty_ignores_stat_only_changes_of_a_copied_checkout(tmp_path): + """A copied checkout (new inodes/ctimes, same content) is clean, not -dirty. - Covers the success branch deterministically so it holds in a no-git - environment (installed wheel / clean container). + Untracked files don't count, and the probe leaves the index untouched. """ + if shutil.which("git") is None: + pytest.skip("git not available") + repo = tmp_path / "repo" + repo.mkdir() + _git("init", cwd=repo) + (repo / "tracked.txt").write_text("v1\n") + _git("add", "tracked.txt", cwd=repo) + _git("-c", "user.email=t@t.co", "-c", "user.name=t", "commit", "-m", "x", cwd=repo) + copy = tmp_path / "copy" + shutil.copytree(repo, copy) + (copy / "scratch.jsonl").write_text("untracked\n") + index_before = (copy / ".git" / "index").read_bytes() - def fake_run(*args, **kwargs): - return subprocess.CompletedProcess( - args=[], returncode=0, stdout=f"{_REPO_ROOT}\nabc1234\n" - ) + assert version_mod._git_tree_dirty(copy) is False + assert (copy / ".git" / "index").read_bytes() == index_before + (copy / "tracked.txt").write_text("v2\n") + assert version_mod._git_tree_dirty(copy) is True - monkeypatch.setattr(version_mod.subprocess, "run", fake_run) - get_git_sha.cache_clear() + +# --------------------------------------------------------------------------- +# resolve_git_sha (channel precedence + validation) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_resolve_git_sha_prefers_live_git_over_baked_and_env(monkeypatch): + """Live git of the package's own checkout wins; source is 'git'.""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", "abc1234") + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "0000fff-dirty") + assert resolve_git_sha() == ("0000fff-dirty", "git") + + +@pytest.mark.unit +def test_resolve_git_sha_uses_baked_when_no_git(monkeypatch): + """Without a git checkout, a baked SHA wins over env; source is 'baked'.""" + monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", "abc1234") + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + assert resolve_git_sha() == ("abc1234", "baked") + + +@pytest.mark.unit +def test_resolve_git_sha_uses_env_when_no_git_or_baked(monkeypatch): + """With neither git nor a baked SHA, the env var wins; source is 'env'.""" + monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + assert resolve_git_sha() == ("def5678", "env") + + +@pytest.mark.unit +def test_resolve_git_sha_accepts_dirty_and_uppercase_env(monkeypatch): + """A dirty-suffixed / uppercase env attestation is honored verbatim.""" + monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "ABC1234-dirty") + assert resolve_git_sha() == ("ABC1234-dirty", "env") + + +@pytest.mark.unit +def test_resolve_git_sha_none_when_nothing_resolves(monkeypatch): + """No git checkout, no baked SHA, no env -> (None, 'none').""" + monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) + assert resolve_git_sha() == (None, "none") + + +@pytest.mark.unit +@pytest.mark.parametrize("value", ["", " ", "unknown", "not-a-sha"]) +def test_resolve_git_sha_rejects_blank_or_non_sha_env(monkeypatch, value): + """A blank, sentinel or non-hex ENDPOINTS_GIT_SHA is treated as unset.""" + monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", value) + assert resolve_git_sha() == (None, "none") + + +@pytest.mark.unit +@pytest.mark.parametrize("baked", ["not-a-real-sha", 1234567]) +def test_resolve_git_sha_rejects_malformed_baked_falls_through(monkeypatch, baked): + """A malformed or non-string baked value is ignored; resolution reaches env.""" + monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", baked) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + assert resolve_git_sha() == ("def5678", "env") + + +@pytest.mark.unit +def test_resolve_git_sha_validates_live_git_channel(monkeypatch): + """Polluted git output is not trusted; resolution continues to baked.""" + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "abc1234\x1b[31m") + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", "def5678") + monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) + assert resolve_git_sha() == ("def5678", "baked") + + +@pytest.mark.unit +def test_resolve_git_sha_reflects_later_env_change(monkeypatch): + """resolve_git_sha is not cached: a later env change is observed.""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) + monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) + assert resolve_git_sha() == (None, "none") + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + assert resolve_git_sha() == ("def5678", "env") + + +# --------------------------------------------------------------------------- +# get_version_info +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_get_version_info(): + """Test that get_version_info returns correct structure.""" + info = get_version_info() + assert isinstance(info, dict) + assert "version" in info + assert "git_sha" in info + assert "git_sha_source" in info + assert info["version"] == __version__ + assert info["git_sha_source"] in {"baked", "env", "git", "none"} + if info["git_sha"] is not None: + assert isinstance(info["git_sha"], str) + assert 7 <= len(info["git_sha"].removesuffix("-dirty")) <= 64 + + +@pytest.mark.unit +def test_corrupt_baked_file_degrades_not_crashes(): + """A corrupt _build_info.py must degrade (fall through), not crash version import. + + A hand-edited unquoted ``GIT_SHA = abc1234`` raises NameError at import — not + ImportError/SyntaxError — so the baked-import handler must be broad enough to + catch it, honoring the "degrade, don't crash Report.from_snapshot" invariant. + Run in a subprocess (import-time behavior can't be re-exercised in-process). + """ + pkg_dir = Path(version_mod.__file__).resolve().parents[1] + baked = pkg_dir / "_build_info.py" + if baked.exists(): + pytest.skip("a real _build_info.py is present; not overwriting it") + baked.write_text("GIT_SHA = abc1234\n") # unquoted -> NameError on import + probe = ( + "from inference_endpoint.utils.version import resolve_git_sha; " + "print(resolve_git_sha()[1])" + ) try: - assert get_git_sha() == "abc1234" + result = subprocess.run( + [sys.executable, "-c", probe], + capture_output=True, + text=True, + timeout=60, + ) finally: - get_git_sha.cache_clear() + baked.unlink(missing_ok=True) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() in {"baked", "env", "git", "none"} + + +@pytest.mark.unit +def test_resolve_repo_root_in_source_layout(monkeypatch): + monkeypatch.setattr( + version_mod, "__file__", "/repo/src/inference_endpoint/utils/version.py" + ) + assert version_mod._resolve_repo_root() == Path("/repo").resolve() @pytest.mark.unit -def test_resolve_repo_root_falls_back_on_shallow_tree(monkeypatch): - """A too-shallow module path yields the filesystem root, not IndexError.""" - monkeypatch.setattr(version_mod, "__file__", "/inference_endpoint/utils/version.py") - root = version_mod._resolve_repo_root() - assert root == Path("/inference_endpoint/utils/version.py").resolve().parents[-1] +@pytest.mark.parametrize( + "module_path", + [ + "/inference_endpoint/utils/version.py", # too shallow + "/venv/lib/python3.12/site-packages/inference_endpoint/utils/version.py", + "/other_repo/vendor/inference_endpoint/utils/version.py", # vendored copy + ], +) +def test_resolve_repo_root_none_outside_source_layout(monkeypatch, module_path): + """Only /src/inference_endpoint/utils/ is a source checkout.""" + monkeypatch.setattr(version_mod, "__file__", module_path) + assert version_mod._resolve_repo_root() is None From 30dc176deeb736c8c17a944fcd75bdf0185724f5 Mon Sep 17 00:00:00 2001 From: Rashid Kaleem <230885705+arekay-nv@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:39:32 -0500 Subject: [PATCH 2/2] fix(provenance): report live git or nothing for checkouts; pin SHA at setup Address review findings on the git SHA provenance chain: - Drop the baked _build_info.py channel (generator, its tests, .dockerignore, .gitignore entry); it returns together with the CI step that produces it. The chain is now git -> env -> none, and ENDPOINTS_GIT_SHA is ignored when the package sits in a git checkout that git can't read, so a bind-mounted checkout never reports an image's SHA for other code. - Scope the dirty probe to the code that runs and its pins (src/inference_endpoint, pyproject.toml, uv.lock, the VBench scorer subproject) and run it with core.checkStat=minimal, so LFS data and synthetic inode numbers (Docker Desktop bind mounts) no longer read as -dirty. A failed probe still reports -dirty and now logs why; the probe budget is 5s. - Resolve provenance once per process: setup_benchmark calls get_version_info() first and logs it, so main-run and audit-phase reports agree and no git subprocess runs after the benchmark. - Read rev-parse output as bytes so a non-UTF-8 checkout path can't fail report building. - push_docker_image.sh / Dockerfile.dev: --short=7 for the image tag and GIT_SHA, dirty check with the runtime's pathspec. - Tests: hermetic git helpers, a parametrized resolver table, and real-git tests for dirty scope, copied checkouts, filtered files with stat-only changes, unreadable checkouts and index immutability, plus a test keeping the image-build pathspec in sync with the runtime's. Co-Authored-By: Claude Opus 5.5 (1M context) --- .dockerignore | 7 - .gitignore | 6 - docs/metrics/DESIGN.md | 2 +- docs/utils/DESIGN.md | 17 +- scripts/Dockerfile.dev | 30 +- scripts/push_docker_image.sh | 21 +- scripts/write_build_info.py | 193 ----- .../commands/benchmark/execute.py | 10 + src/inference_endpoint/metrics/report.py | 11 +- src/inference_endpoint/utils/version.py | 195 ++--- tests/unit/commands/test_benchmark.py | 22 + tests/unit/metrics/test_report_builder.py | 61 +- tests/unit/scripts/test_write_build_info.py | 234 ------ tests/unit/test_version.py | 673 ++++++++++-------- 14 files changed, 574 insertions(+), 908 deletions(-) delete mode 100644 .dockerignore delete mode 100644 scripts/write_build_info.py delete mode 100644 tests/unit/scripts/test_write_build_info.py diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 8a8aed287..000000000 --- a/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -# Generated build-time provenance (scripts/write_build_info.py). A locally-run -# generator leaves this in the source tree; excluding it from the Docker build -# context stops a stale baked SHA from being COPYd into the image and silently -# overriding the ENDPOINTS_GIT_SHA build-arg (resolve_git_sha ranks baked above -# env). -src/inference_endpoint/_build_info.py -src/inference_endpoint/_build_info.py.tmp diff --git a/.gitignore b/.gitignore index 10be56147..1c4ab65a5 100644 --- a/.gitignore +++ b/.gitignore @@ -212,9 +212,3 @@ CLAUDE.local.md # Generated dataset cache (created by Dataset.get_dataloader()) dataset_cache/ - -# Generated build-time provenance (scripts/write_build_info.py). Packed into -# wheels by uv_build; NOT into images (.dockerignored — images carry the SHA via -# the ENDPOINTS_GIT_SHA build-arg). The .tmp is the atomic-write staging file. -src/inference_endpoint/_build_info.py -src/inference_endpoint/_build_info.py.tmp diff --git a/docs/metrics/DESIGN.md b/docs/metrics/DESIGN.md index 8671a847e..042ec3b1b 100644 --- a/docs/metrics/DESIGN.md +++ b/docs/metrics/DESIGN.md @@ -94,7 +94,7 @@ single object that `display()` and `to_json()` render. class Report(msgspec.Struct, frozen=True): version: str git_sha: str | None - git_sha_source: str # which channel resolved git_sha: git | baked | env | none + git_sha_source: str # which channel resolved git_sha: git | env | none test_started_at: int n_samples_issued: int n_samples_completed: int diff --git a/docs/utils/DESIGN.md b/docs/utils/DESIGN.md index 4b879a918..c7de50ae6 100644 --- a/docs/utils/DESIGN.md +++ b/docs/utils/DESIGN.md @@ -39,9 +39,9 @@ benchmarking tool that lives here for convenience but does import from other `logging.py` and `dataset_utils.py` stay lightweight and broadly reusable. `version.py` is also small, but it intentionally imports -`inference_endpoint.__version__` and resolves a build SHA through a fallback -chain — a live `git` query of its own checkout, a baked `_build_info.py`, then -the `ENDPOINTS_GIT_SHA` env var — to report build metadata. +`inference_endpoint.__version__` and resolves the source SHA to report build +metadata: a live `git` query of its own checkout, or, only outside a git +checkout, the `ENDPOINTS_GIT_SHA` env var. It is resolved once per process. `benchmark_httpclient.py` is exempt entirely: it is a standalone tool, not a reusable helper. @@ -60,8 +60,9 @@ uv run python -m inference_endpoint.utils.benchmark_httpclient --endpoint URL -- ## Integration Points -| Consumer | Usage | -| ------------------- | ------------------------------------------------------------------------------------------------------ | -| `main.py` | Calls `setup_logging()` at startup | -| `commands/info.py` | Imports `__version__` for the `info` command | -| `metrics/report.py` | `Report.from_snapshot` calls `get_version_info()` to record `git_sha` / `git_sha_source` in the report | +| Consumer | Usage | +| ------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `main.py` | Calls `setup_logging()` at startup | +| `commands/info.py` | Imports `__version__` for the `info` command | +| `commands/benchmark/execute.py` | `setup_benchmark` calls `get_version_info()` first, pinning provenance before the run | +| `metrics/report.py` | `Report.from_snapshot` calls `get_version_info()` to record `git_sha` / `git_sha_source` in the report | diff --git a/scripts/Dockerfile.dev b/scripts/Dockerfile.dev index 4452ed592..d3f168c31 100644 --- a/scripts/Dockerfile.dev +++ b/scripts/Dockerfile.dev @@ -1,7 +1,6 @@ # Development Dockerfile for the MLPerf Inference Endpoint Benchmarking System -# From project root (pass GIT_SHA so the client reports its source commit; see the -# provenance note at the bottom of this file): -# docker build -f scripts/Dockerfile.dev --build-arg GIT_SHA=$(git rev-parse --short=7 HEAD)$(git diff --quiet HEAD -- || echo -dirty) --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g) -t inference-endpoint-dev . +# From project root (GIT_SHA is the source commit the report prints; see the end of this file): +# docker build -f scripts/Dockerfile.dev --build-arg GIT_SHA=$(git rev-parse --short=7 HEAD)$(git diff --quiet HEAD -- src/inference_endpoint pyproject.toml uv.lock examples/09_Wan22_VideoGen_Example/accuracy || echo -dirty) --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g) -t inference-endpoint-dev . # docker run -v $(pwd):/mnt/inference-endpoint -it --shm-size=512m inference-endpoint-dev bash # # VBench (WAN 2.2) accuracy scorer is ON by default (consistent with the DeepSeek-R1 @@ -142,22 +141,13 @@ LABEL org.opencontainers.image.title="inference-endpoint" \ org.opencontainers.image.description="MLPerf inference endpoint benchmarking client (the inference-endpoint CLI); DeepSeek-R1 and VBench accuracy evaluators are provisioned by default, toggled by PROVISION_DSR1 / PROVISION_VBENCH." \ org.opencontainers.image.source="https://github.com/mlcommons/endpoints" -# Runtime provenance: expose the source git SHA so `inference-endpoint` prints it -# in its report. This image COPYs src/ but not .git, so live `git rev-parse` finds -# no repo; resolve_git_sha() reads ENDPOINTS_GIT_SHA instead. A checkout -# bind-mounted over it (the `docker run -v` above) resolves via live git first -# when git in the container can read it; if it can't (a uid mismatch git refuses -# as dubious ownership, or a git worktree whose .git points outside the mount), -# this value is reported instead and describes the image, not the mounted code. -# Kept LAST so the per-commit SHA (changes every build) doesn't invalidate the -# cached dependency and provisioning layers above. Pass it at build time; the -# -dirty suffix matters because src/ is COPYed from the working tree as-is: -# docker build ... --build-arg GIT_SHA=$(git rev-parse --short=7 HEAD)$(git diff --quiet HEAD -- || echo -dirty) -# Default is empty (NOT "unknown"): resolve_git_sha validates the value, so an -# empty/omitted arg falls through to source=none rather than recording a bogus -# "unknown" as an env attestation. -# push_docker_image.sh passes GIT_SHA automatically and also stamps image-level -# provenance (the OCI image.revision annotation / : tag) on the pushed -# manifest, so the LABEL above carries no revision. +# Runtime provenance. This image COPYs src/ without .git, so the report prints +# this value (source: env). A git checkout bind-mounted over the workdir (the +# `docker run -v` above) reports live git instead, or no SHA if git can't read +# it: this value describes the image, not the mounted code. Mount the whole +# checkout: code mounted without .git (a tarball, or src/ alone) is reported +# with this value. Declared last so a new SHA doesn't invalidate the cached +# layers above; empty or non-hex values are ignored. push_docker_image.sh passes +# it and tags the image with the same SHA. ARG GIT_SHA= ENV ENDPOINTS_GIT_SHA=${GIT_SHA} diff --git a/scripts/push_docker_image.sh b/scripts/push_docker_image.sh index b19896b96..13bfd487b 100755 --- a/scripts/push_docker_image.sh +++ b/scripts/push_docker_image.sh @@ -38,10 +38,11 @@ # ALLOW_DIRTY 1=build HEAD with uncommitted tracked changes (default: 0) # PROVISION_DSR1 passthrough build-arg (default: Dockerfile default = 1) # PROVISION_VBENCH passthrough build-arg (default: Dockerfile default = 1) -# (always passed) GIT_SHA build-arg = short HEAD SHA (+"-dirty"), surfaced at -# runtime as ENDPOINTS_GIT_SHA for the report's provenance # IMAGE_DESCRIPTION GHCR package-page description (default: "endpoints client image (ref , commit )") # GHCR_USER/GHCR_TOKEN optional registry login (fallback: GITHUB_ACTOR/GITHUB_TOKEN) +# +# Always passes --build-arg GIT_SHA=[-dirty], which the image reports +# at runtime as its source commit. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -155,7 +156,7 @@ else ENDPOINTS_REF="$ORIGINAL_REF" fi -SHORT_SHA="$(git rev-parse --short HEAD)" +SHORT_SHA="$(git rev-parse --short=7 HEAD)" # Docker tags allow only [A-Za-z0-9_.-], must not start with '.' or '-', and cap at # 128 chars. Map every other char (e.g. '/', '+', '~', '#') to '-' so a legitimate # git ref name can't yield an invalid tag that fails `-t` only after the whole build. @@ -215,13 +216,13 @@ BUILD_ARGS=() [[ "$NO_CACHE" == "1" ]] && BUILD_ARGS+=(--no-cache) [[ -n "${PROVISION_DSR1:-}" ]] && BUILD_ARGS+=(--build-arg "PROVISION_DSR1=${PROVISION_DSR1}") [[ -n "${PROVISION_VBENCH:-}" ]] && BUILD_ARGS+=(--build-arg "PROVISION_VBENCH=${PROVISION_VBENCH}") -# Runtime provenance for the report: forward the source SHA into the image env -# (Dockerfile ARG GIT_SHA -> ENV ENDPOINTS_GIT_SHA), so a benchmark run inside the -# published image self-reports its commit. Mirror the -dirty suffix -# resolve_git_sha() applies when building from a dirty tree (reachable only via -# --allow-dirty / --no-push, since a clean :SHA is otherwise required above). -GIT_SHA_VALUE="$(git rev-parse --short=7 HEAD)" -tree_has_tracked_changes && GIT_SHA_VALUE="${GIT_SHA_VALUE}-dirty" +# Source SHA the image reports at runtime (Dockerfile ARG GIT_SHA -> ENV +# ENDPOINTS_GIT_SHA): the :$SHORT_SHA tag, formatted like live git's +# (--short=7). The -dirty check uses the runtime check's scope +# (utils/version._DIRTY_PATHSPEC); it is reachable only via --allow-dirty / +# --no-push, since a clean :SHA is otherwise required above. +GIT_SHA_VALUE="$SHORT_SHA" +git diff --quiet HEAD -- src/inference_endpoint pyproject.toml uv.lock examples/09_Wan22_VideoGen_Example/accuracy || GIT_SHA_VALUE="${GIT_SHA_VALUE}-dirty" BUILD_ARGS+=(--build-arg "GIT_SHA=${GIT_SHA_VALUE}") # Don't attach SLSA provenance attestations. buildx adds them by default on --push, diff --git a/scripts/write_build_info.py b/scripts/write_build_info.py deleted file mode 100644 index 92376837e..000000000 --- a/scripts/write_build_info.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Bake the build-time git SHA into ``src/inference_endpoint/_build_info.py``. - -Run from a source checkout (where ``.git`` exists) BEFORE building a wheel so the -SHA travels with the artifact and is readable at runtime without git:: - - python scripts/write_build_info.py - uv build - -The generated file is gitignored but uv_build still packs it into the wheel (it -selects by module tree, not git tracking). ``utils.version.resolve_git_sha`` -reads it whenever no live git checkout of the package is visible (an installed -wheel), ahead of the ``ENDPOINTS_GIT_SHA`` env var. Container images do NOT use -this file (it is ``.dockerignore``d); they carry the SHA via the -``ENDPOINTS_GIT_SHA`` build-arg instead. - -The SHA format mirrors ``get_git_sha``: the 7-char short SHA plus a ``-dirty`` -suffix when tracked files have uncommitted changes. Stdlib-only on purpose — this -runs before ``inference_endpoint`` is installed, so it cannot import from it. -""" - -import os -import re -import subprocess -from pathlib import Path - -_REPO_ROOT = Path(__file__).resolve().parents[1] -_OUTPUT = _REPO_ROOT / "src" / "inference_endpoint" / "_build_info.py" - -# Build-time git budget: more lenient than runtime's snappy 1.0s (the build can -# afford to wait); intentionally not shared with utils.version (stdlib-only here). -_GIT_TIMEOUT_S = 5.0 -# Bare hex object name (SHA-1 short/full or SHA-256). This is the hex CORE of -# utils.version._SHA_RE, which additionally allows a trailing "-dirty"; here the -# SHA is validated before write_build_info appends "-dirty" itself. -_SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}") - -_LICENSE_HEADER = """\ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" - - -# Git's repo-location env family. A CI wrapper or ``git submodule foreach`` can -# export any of these to point git at a foreign repo/index; dropping the whole -# family (plus the toplevel check below) stops the generator from baking a SHA or -# dirty bit that isn't this source tree's. Mirrors utils.version's copy (that -# module can't be imported here — this script runs pre-install, stdlib-only). -_GIT_LOCATION_ENV_VARS = ( - "GIT_DIR", - "GIT_WORK_TREE", - "GIT_INDEX_FILE", - "GIT_COMMON_DIR", - "GIT_OBJECT_DIRECTORY", - "GIT_ALTERNATE_OBJECT_DIRECTORIES", -) - - -def _git_env() -> dict[str, str]: - """Environment for read-only git probes (mirrors ``utils.version._git_env``). - - Drops the repo-location family (see the note above) and sets - ``GIT_OPTIONAL_LOCKS=0`` so ``git status`` never rewrites the index. - """ - env = dict(os.environ) - for var in _GIT_LOCATION_ENV_VARS: - env.pop(var, None) - env["GIT_OPTIONAL_LOCKS"] = "0" - return env - - -def _short_sha(repo_root: Path) -> str: - """Return the validated short SHA of the repo rooted at ``repo_root``. - - Verifies the discovered toplevel IS ``repo_root`` (mirrors get_git_sha's - foreign-repo guard) and that the value is hex, so the generator cannot bake a - foreign or malformed SHA that runtime would otherwise trust as baked ground - truth. Raises on any mismatch so a bad bake fails loudly instead of writing - a value runtime silently discards. - """ - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel", "--short=7", "HEAD"], - cwd=repo_root, - env=_git_env(), - capture_output=True, - text=True, - timeout=_GIT_TIMEOUT_S, - check=True, - ) - lines = result.stdout.splitlines() - if len(lines) != 2: - raise RuntimeError(f"unexpected git rev-parse output: {result.stdout!r}") - toplevel, sha = lines - if Path(toplevel).resolve() != repo_root.resolve(): - raise RuntimeError( - f"refusing to bake a SHA from a different repo: git toplevel " - f"{toplevel!r} != {repo_root}" - ) - sha = sha.strip() - if not _SHA_RE.fullmatch(sha): - raise RuntimeError(f"git returned a non-hex SHA: {sha!r}") - return sha - - -def _tree_dirty(repo_root: Path) -> bool: - """True iff tracked files have uncommitted changes; fails to dirty. - - Mirrors ``utils.version._git_tree_dirty``: untracked files are ignored, - stat-only differences (a copied or rsynced checkout) are content-checked - without rewriting the index, and anything other than exit 0 with no entries - (error, timeout, OSError) is reported as dirty so a build never bakes a clean - SHA it could not verify. - - Caveat: because untracked files are ignored, a NEW untracked file under the - packaged tree (``src/inference_endpoint/``) is NOT flagged, yet uv_build - would pack it — so baking + building a new module without committing it - yields a clean SHA for a wheel whose code differs from that commit. Commit - new modules before baking. - """ - try: - # Bytes, not text: see utils.version._git_tree_dirty. - result = subprocess.run( - ["git", "status", "--porcelain", "--untracked-files=no"], - cwd=repo_root, - env=_git_env(), - capture_output=True, - timeout=_GIT_TIMEOUT_S, - check=False, - ) - except (OSError, subprocess.TimeoutExpired): - return True - return result.returncode != 0 or bool(result.stdout.strip()) - - -def write_build_info(repo_root: Path = _REPO_ROOT, output: Path = _OUTPUT) -> str: - """Generate ``output`` with the (validated) resolved SHA and return that SHA.""" - sha = _short_sha(repo_root) - if _tree_dirty(repo_root): - sha = f"{sha}-dirty" - content = ( - f"{_LICENSE_HEADER}\n" - '"""Generated by scripts/write_build_info.py at build time. Do not edit or commit."""\n' - "\n" - # _short_sha validated the SHA is hex; !r additionally guarantees the - # generated module parses regardless of the value. - f"GIT_SHA = {sha!r}\n" - ) - # Atomic: write a sibling temp file then rename, so a crash mid-write cannot - # leave a truncated _build_info.py that would SyntaxError on import. The - # finally cleans up the temp on any failure before the rename. - tmp = output.with_name(output.name + ".tmp") - try: - tmp.write_text(content) - tmp.replace(output) - finally: - tmp.unlink(missing_ok=True) - return sha - - -def main() -> None: - sha = write_build_info() - print(f"Wrote {_OUTPUT} (GIT_SHA={sha})") - - -if __name__ == "__main__": - main() diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index ff7f95ade..361c79f6e 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -108,6 +108,7 @@ SessionResult, ) from inference_endpoint.metrics.report import Report +from inference_endpoint.utils.version import get_version_info if TYPE_CHECKING: from inference_endpoint.async_utils.event_publisher import EventPublisherService @@ -471,6 +472,15 @@ def setup_benchmark( ``audit_run_spec``, when set, overrides the issue count and sample order for a compliance-audit phase (see ``commands/audit.py:run_audit``). """ + # Pins source provenance for the process before any work runs; every report + # (main run and audit phases) records this value. + version_info = get_version_info() + logger.info( + "Endpoints %s, git SHA %s (source: %s)", + version_info["version"], + version_info["git_sha"] or "unknown", + version_info["git_sha_source"], + ) # Accuracy-only runs force single-stream (1 worker / 1 connection) for # deterministic sample ordering. Bake it into the config here — before CPU # affinity, report_dir/config.yaml persistence, and RuntimeSettings — so the diff --git a/src/inference_endpoint/metrics/report.py b/src/inference_endpoint/metrics/report.py index 5890975fe..6f4261850 100644 --- a/src/inference_endpoint/metrics/report.py +++ b/src/inference_endpoint/metrics/report.py @@ -211,10 +211,9 @@ class Report(msgspec.Struct, frozen=True): # type: ignore[call-arg] version: str git_sha: str | None - # Which channel resolved git_sha, in resolution order: "git" (live checkout), - # "baked" (build-info file), "env" (ENDPOINTS_GIT_SHA), or "none". Records how - # much to trust the SHA — a clean git/baked SHA is ground truth, an env one an - # attestation, a "-dirty" one a warning. See utils/version.resolve_git_sha. + # Which channel resolved git_sha: "git" (read from the running checkout), + # "env" (ENDPOINTS_GIT_SHA, an attestation by the image or launcher), or + # "none". See utils/version.resolve_git_sha. git_sha_source: str test_started_at: int n_samples_issued: int @@ -534,9 +533,7 @@ def display( f"final snapshot received) — some async metrics may be missing.{newline}" ) fn(f"Version: {self.version}{newline}") - # Always emitted (even when unresolved) so a missing SHA is unambiguous — - # a blank line could mean capture failed or an old report format; the - # explicit "unknown (source: none)" says provenance was attempted. + # Always emitted, so an unresolved SHA reads "unknown (source: none)". fn( f"Git SHA: {self.git_sha or 'unknown'} (source: {self.git_sha_source}){newline}" ) diff --git a/src/inference_endpoint/utils/version.py b/src/inference_endpoint/utils/version.py index c39e44c74..d9daa5f3c 100644 --- a/src/inference_endpoint/utils/version.py +++ b/src/inference_endpoint/utils/version.py @@ -15,6 +15,8 @@ """Version and git information utilities.""" +import functools +import logging import os import re import subprocess @@ -22,40 +24,35 @@ from .. import __version__ -# Build-time provenance for installed WHEELS: scripts/write_build_info.py writes -# this file and uv_build packs it (it selects by module tree, not git tracking, -# so the gitignored file is still included). Container images do NOT use it — the -# file is .dockerignored and images carry the SHA via the ENDPOINTS_GIT_SHA env -# channel instead, so a stray local bake can't override the build-arg. Absent in -# a plain source checkout (generated + gitignored). -try: - from .._build_info import GIT_SHA as _BAKED_GIT_SHA -except Exception: - # Broad by design: a missing (ImportError), truncated (SyntaxError) or - # hand-edited unquoted (NameError) generated file must degrade to "no baked - # SHA" rather than break version import and, with it, Report.from_snapshot. - # KeyboardInterrupt/SystemExit are BaseException and still propagate. - _BAKED_GIT_SHA = None # type: ignore[assignment] - -# Explicit attestation, primarily for containers/CI: `docker build --build-arg -# GIT_SHA=...` -> ENV (or a launcher) sets this for code that has neither a git -# checkout nor a baked file. +logger = logging.getLogger(__name__) + +# Attestation for code running outside a git checkout, e.g. a container image +# built with `--build-arg GIT_SHA=...` (scripts/Dockerfile.dev). _GIT_SHA_ENV_VAR = "ENDPOINTS_GIT_SHA" -# A resolved SHA is a git hex object name (SHA-1 short/full or SHA-256, any case) -# with an optional -dirty marker. EVERY channel is validated against this so a -# sentinel ("unknown"), a stray value, a non-string baked value, or an injected -# newline/ANSI sequence cannot masquerade as an attestation or corrupt report.txt. +# A git hex object name (SHA-1 short/full or SHA-256, any case) with an optional +# -dirty marker. Every channel is validated so a sentinel ("unknown") or an +# injected newline/ANSI sequence can't pose as a SHA or corrupt report.txt. _SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}(?:-dirty)?") +# Per-probe budget. Provenance is resolved once per process before the run +# starts, so slow filesystems (NFS/HPC homes) can afford it. +_GIT_TIMEOUT_S = 5.0 + +# What "dirty" covers: code a run executes and its dependency pins, i.e. the +# package plus the VBench scorer subproject it launches from examples/. Edits +# elsewhere (other examples, docs, LFS datasets) don't change what runs. +# scripts/push_docker_image.sh and scripts/Dockerfile.dev repeat this list. +_DIRTY_PATHSPEC = ( + "src/inference_endpoint", + "pyproject.toml", + "uv.lock", + "examples/09_Wan22_VideoGen_Example/accuracy", +) -def _valid_sha(value: object) -> str | None: - """Return the value if it is a well-formed (optionally dirty) git SHA, else None. - Guards against a non-string baked value (e.g. an unquoted ``GIT_SHA = 123`` - in a hand-edited _build_info.py that imports cleanly) as well as sentinels - and injected control characters. - """ +def _valid_sha(value: object) -> str | None: + """Return ``value`` stripped if it is a well-formed (optionally dirty) SHA.""" if not isinstance(value, str): return None value = value.strip() @@ -83,11 +80,9 @@ def _resolve_repo_root() -> Path | None: # Git's repo-location env family. A stray value (a CI wrapper, ``git submodule -# foreach``, a parent-process hook) can point git at a foreign repo/index while -# ``--show-toplevel`` still reports our cwd — slipping a foreign SHA past the -# toplevel guard, or a foreign index past the dirty probe. Scrubbing the whole -# family re-anchors every git call to its ``cwd``. Kept in sync with the -# generator's copy in scripts/write_build_info.py (stdlib-only there). +# foreach``) can point git at a foreign repo/index while ``--show-toplevel`` still +# reports our cwd, slipping a foreign SHA past the toplevel guard or a foreign +# index past the dirty probe. Scrubbing it anchors every probe to its ``cwd``. _GIT_LOCATION_ENV_VARS = ( "GIT_DIR", "GIT_WORK_TREE", @@ -101,10 +96,9 @@ def _resolve_repo_root() -> Path | None: def _git_env() -> dict[str, str]: """Environment for read-only git probes. - Drops the repo-location family (see the note above) and sets - ``GIT_OPTIONAL_LOCKS=0`` so ``git status`` never rewrites the caller's index. - The env var, unlike the ``--no-optional-locks`` flag, is ignored rather than - rejected by a git too old to know it (common on HPC hosts). + Drops the repo-location family and sets ``GIT_OPTIONAL_LOCKS=0`` so + ``git status`` never rewrites the caller's index. A git too old to know the + variable ignores it, where it would reject ``--no-optional-locks``. """ env = dict(os.environ) for var in _GIT_LOCATION_ENV_VARS: @@ -114,33 +108,48 @@ def _git_env() -> dict[str, str]: def _git_tree_dirty(repo_root: Path) -> bool: - """True iff tracked files have uncommitted changes (via ``git status``). - - Untracked files are ignored — only modifications to tracked content mark the - tree dirty, so scratch artifacts a dev checkout accumulates (e.g. run output - ``*.jsonl``) don't taint the provenance SHA. Stat-only differences (a copied, - rsynced or re-mounted checkout) are content-checked in memory. - - Fails to dirty: anything other than exit 0 with no entries — a git error - (e.g. a corrupt index or a dubious-ownership refusal), a timeout, or an - OSError — is reported as dirty. An unverifiable tree is flagged ``-dirty`` - rather than silently blessed as clean, so provenance never understates - uncertainty (e.g. a slow probe on NFS/HPC homes). + """True iff ``_DIRTY_PATHSPEC`` has uncommitted tracked changes. + + Untracked files don't count. ``core.checkStat=minimal`` compares only mtime + and size, so a copied checkout, or a bind mount presenting new inode numbers, + isn't re-hashed: re-hashing a git-lfs file where git-lfs isn't installed would + always report it modified. + + Fails to dirty: a git error (e.g. a dubious-ownership refusal), a timeout or + an OSError reports dirty, with a warning, so an unverifiable tree is never + blessed as clean. """ try: # Bytes, not text: with core.quotePath=false a non-UTF-8 path would - # otherwise raise UnicodeDecodeError out of report building. + # otherwise raise UnicodeDecodeError. result = subprocess.run( - ["git", "status", "--porcelain", "--untracked-files=no"], + [ + "git", + "-c", + "core.checkStat=minimal", + "status", + "--porcelain", + "--untracked-files=no", + "--", + *_DIRTY_PATHSPEC, + ], capture_output=True, - timeout=1.0, + timeout=_GIT_TIMEOUT_S, check=False, cwd=repo_root, env=_git_env(), ) - except (OSError, subprocess.TimeoutExpired): - return True - return result.returncode != 0 or bool(result.stdout.strip()) + except (OSError, subprocess.TimeoutExpired) as e: + reason = str(e) + else: + if result.returncode == 0: + return bool(result.stdout.strip()) + stderr = result.stderr.decode(errors="replace").strip() + reason = f"git status exited {result.returncode}: {stderr}" + logger.warning( + "Could not verify %s is clean (%s); reporting -dirty", repo_root, reason + ) + return True def get_git_sha() -> str | None: @@ -152,7 +161,7 @@ def get_git_sha() -> str | None: Returns: The short git SHA (at least 7 chars; git lengthens it if a 7-char - prefix is ambiguous), suffixed with ``-dirty`` when the working tree has + prefix is ambiguous), suffixed with ``-dirty`` when ``_DIRTY_PATHSPEC`` has uncommitted tracked changes; or None if the package is not in a source checkout (e.g. an installed wheel) or git is unavailable. """ @@ -160,11 +169,12 @@ def get_git_sha() -> str | None: if repo_root is None: return None try: + # Bytes, not text: --show-toplevel prints the path raw, and a directory + # name need not be UTF-8. result = subprocess.run( ["git", "rev-parse", "--show-toplevel", "--short=7", "HEAD"], capture_output=True, - text=True, - timeout=1.0, + timeout=_GIT_TIMEOUT_S, check=False, cwd=repo_root, env=_git_env(), @@ -174,67 +184,80 @@ def get_git_sha() -> str | None: lines = result.stdout.splitlines() if len(lines) != 2: return None - toplevel, sha = lines + toplevel = os.fsdecode(lines[0]) + sha = lines[1].decode("ascii", "replace").strip() # Only trust the SHA if the discovered repo IS this source tree (e.g. not # an enclosing repo whose subdirectory merely holds a checkout's src/) — # a wrong provenance SHA is worse than None. if Path(toplevel).resolve() != repo_root: return None - sha = sha.strip() return f"{sha}-dirty" if _git_tree_dirty(repo_root) else sha except (OSError, subprocess.TimeoutExpired): return None +def _in_git_checkout() -> bool: + # lexists: a .git symlink whose target isn't visible (outside a bind mount) + # still marks a checkout. + return _REPO_ROOT is not None and os.path.lexists(_REPO_ROOT / ".git") + + def resolve_git_sha() -> tuple[str | None, str]: """Resolve the source SHA and record which channel provided it. - Not cached: the env var and the working-tree dirty state can change within a - process (a launcher exporting ``ENDPOINTS_GIT_SHA`` after import; a run that - dirties the tree; a transient dirty-probe failure that must not stick), and - resolution is cold-path (built once per report), so it is re-evaluated on - each call rather than frozen at first use. - - Priority (first hit wins), ordered so the value most tightly bound to the - running code wins over a claim recorded about it elsewhere: - - 1. ``"git"`` — a live ``git`` query against this package's own source - checkout. It observes the code actually running, ``-dirty`` included, so - while git can read the checkout, a stale bake left in it or an image env - value under a bind-mounted checkout can't mask it. If git can't (no git, - a dubious-ownership refusal, a timeout), resolution falls through. - 2. ``"baked"`` — ``_build_info.GIT_SHA`` packed into the wheel at build time - from the exact packaged tree; travels with the artifact, no runtime git. - 3. ``"env"`` — the ``ENDPOINTS_GIT_SHA`` environment variable (container - build-arg or launch-script attestation). - 4. ``"none"`` — nothing resolved; SHA is None. + 1. ``"git"`` — a live query of this package's own source checkout, + ``-dirty`` included. + 2. ``"env"`` — ``ENDPOINTS_GIT_SHA``, honored only when the package is not in + a git checkout (an installed package, or an image's copy of ``src/``). + A checkout git can't read (git missing, a timeout, a dubious-ownership + refusal, a ``.git`` pointing outside a bind mount) reports nothing + instead: the variable describes whichever image or launcher set it, not + this tree. Code mounted without its ``.git`` (a tarball, or ``src/`` + alone) can't be told apart from the image's copy and gets the variable. + 3. ``"none"`` — nothing resolved. + + Every value is validated, so a wrapping git on PATH can't carry ANSI or + newlines into the report. Returns: ``(sha, source)`` where ``source`` is one of the labels above. ``sha`` is None only when ``source == "none"``. """ - # Validate the live-git result too: get_git_sha reads git stdout, so a wrapping - # git on PATH or polluted output could otherwise carry ANSI/newline into the - # report. Uniform validation also keeps all three channels to one grammar. live_sha = _valid_sha(get_git_sha()) if live_sha: return live_sha, "git" - baked_sha = _valid_sha(_BAKED_GIT_SHA) - if baked_sha: - return baked_sha, "baked" + if _in_git_checkout(): + logger.warning( + "Not reporting a git SHA: %s is a git checkout but git could not " + "read it (e.g. git missing, a timeout, dubious ownership, or a .git " + "pointing outside a mount)", + _REPO_ROOT, + ) + return None, "none" env_sha = _valid_sha(os.environ.get(_GIT_SHA_ENV_VAR)) if env_sha: return env_sha, "env" return None, "none" +@functools.cache +def _process_git_sha() -> tuple[str | None, str]: + return resolve_git_sha() + + def get_version_info() -> dict[str, str | None]: """Get version and git provenance information. + The SHA is resolved once per process, on the first call (``setup_benchmark`` + makes it before the run starts), so every report the process writes + describes the checkout as it was at setup rather than whenever a report is + built. Subprocesses started later (e.g. audit-phase services) import + whatever the checkout holds then. + Returns: Dictionary with 'version', 'git_sha', and 'git_sha_source' keys. """ - git_sha, git_sha_source = resolve_git_sha() + git_sha, git_sha_source = _process_git_sha() return { "version": __version__, "git_sha": git_sha, diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 9b4729a32..27343f4cd 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -3162,6 +3162,28 @@ def test_perf_run_leaves_target_concurrency_untouched( assert ctx.config.settings.client.num_workers == 4 assert ctx.config.settings.load_pattern.target_concurrency == 10 + @pytest.mark.unit + def test_setup_resolves_source_provenance_before_the_run( + self, tmp_path, _base_patches, _simple_dataset, _rt_settings, caplog + ): + """The report's SHA is sampled at setup, not after a long run.""" + config = OfflineConfig(**_OFFLINE_KWARGS, report_dir=str(tmp_path)) + resolve = MagicMock( + return_value={ + "version": "0.1.0", + "git_sha": "abc1234-dirty", + "git_sha_source": "git", + } + ) + with ( + patch.object(execute_mod, "get_version_info", resolve), + caplog.at_level(logging.INFO, logger=execute_mod.__name__), + ): + self._setup(config, TestMode.PERF, (_simple_dataset, []), _rt_settings) + + resolve.assert_called_once_with() + assert "abc1234-dirty (source: git)" in caplog.text + @pytest.mark.unit def test_accuracy_dataset_named_performance_is_counted_by_type( self, tmp_path, _base_patches, _simple_dataset, _rt_settings diff --git a/tests/unit/metrics/test_report_builder.py b/tests/unit/metrics/test_report_builder.py index 21473fc34..7db277497 100644 --- a/tests/unit/metrics/test_report_builder.py +++ b/tests/unit/metrics/test_report_builder.py @@ -586,54 +586,25 @@ def test_display_no_started_at(self): output = "\n".join(lines) assert "Test started at" not in output - def test_display_always_prints_git_sha_line_with_source(self): - """The Git SHA line is emitted unconditionally, showing 'unknown' + source.""" - report = Report( - version="test", - git_sha=None, - git_sha_source="none", - test_started_at=0, - n_samples_issued=0, - n_samples_completed=0, - n_samples_failed=0, - duration_ns=None, - state="complete", - complete=True, - ttft={}, - tpot={}, - latency={}, - input_sequence_lengths={}, - output_sequence_lengths={}, - ) - lines: list[str] = [] - report.display(fn=lines.append, summary_only=True) - output = "\n".join(lines) - assert "Git SHA: unknown" in output - assert "source: none" in output - - def test_display_prints_git_sha_with_source_when_present(self): - """A resolved SHA renders with its provenance source annotated.""" - report = Report( - version="test", - git_sha="abc1234-dirty", - git_sha_source="git", - test_started_at=0, - n_samples_issued=0, - n_samples_completed=0, - n_samples_failed=0, - duration_ns=None, - state="complete", - complete=True, - ttft={}, - tpot={}, - latency={}, - input_sequence_lengths={}, - output_sequence_lengths={}, + @pytest.mark.parametrize( + ("git_sha", "source", "line"), + [ + (None, "none", "Git SHA: unknown (source: none)"), + ("abc1234-dirty", "git", "Git SHA: abc1234-dirty (source: git)"), + ], + ids=["unresolved", "resolved"], + ) + def test_display_always_prints_git_sha_line_with_source( + self, git_sha, source, line + ): + report = msgspec.structs.replace( + _build_report(_make_registry(n_samples=5)), + git_sha=git_sha, + git_sha_source=source, ) lines: list[str] = [] report.display(fn=lines.append, summary_only=True) - output = "\n".join(lines) - assert "Git SHA: abc1234-dirty (source: git)" in output + assert line in lines def test_display_warns_when_incomplete(self): """Reports with ``complete=False`` surface a WARNING in display().""" diff --git a/tests/unit/scripts/test_write_build_info.py b/tests/unit/scripts/test_write_build_info.py deleted file mode 100644 index 867140127..000000000 --- a/tests/unit/scripts/test_write_build_info.py +++ /dev/null @@ -1,234 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for the scripts/write_build_info.py build-time SHA generator.""" - -import importlib.util -import re -import shutil -import subprocess -from pathlib import Path - -import pytest -from inference_endpoint.utils import version as version_mod - -_SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "write_build_info.py" - - -def _load_module(): - spec = importlib.util.spec_from_file_location("write_build_info", _SCRIPT) - assert spec and spec.loader - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -def _git(*args: str, cwd: Path) -> str: - return subprocess.run( - ["git", *args], cwd=cwd, check=True, capture_output=True, text=True - ).stdout.strip() - - -def _exec_git_sha(path: Path) -> str: - """Execute the generated module and return its GIT_SHA (proves importability).""" - namespace: dict = {} - exec(compile(path.read_text(), str(path), "exec"), namespace) - return namespace["GIT_SHA"] - - -@pytest.fixture -def git_repo(tmp_path): - _git("init", cwd=tmp_path) - _git("config", "user.email", "t@t.co", cwd=tmp_path) - _git("config", "user.name", "t", cwd=tmp_path) - (tmp_path / "tracked.txt").write_text("v1\n") - _git("add", "tracked.txt", cwd=tmp_path) - _git("commit", "-m", "init", cwd=tmp_path) - return tmp_path - - -@pytest.mark.unit -def test_writes_importable_hex_sha_on_clean_tree(git_repo): - mod = _load_module() - out = git_repo / "_build_info.py" - sha = mod.write_build_info(repo_root=git_repo, output=out) - - assert out.exists() - assert re.fullmatch(r"[0-9a-f]{7}", sha) # clean tree -> bare short SHA - assert _exec_git_sha(out) == sha # generated file is importable and matches - assert "SPDX-License-Identifier" in out.read_text() # license header emitted - - -@pytest.mark.unit -def test_appends_dirty_on_modified_tracked_file(git_repo): - mod = _load_module() - (git_repo / "tracked.txt").write_text("v2-uncommitted\n") # modify tracked - out = git_repo / "_build_info.py" - sha = mod.write_build_info(repo_root=git_repo, output=out) - - assert sha.endswith("-dirty") - assert _exec_git_sha(out) == sha - - -@pytest.mark.unit -def test_untracked_file_does_not_mark_dirty(git_repo): - mod = _load_module() - (git_repo / "scratch.jsonl").write_text("noise\n") # untracked scratch - out = git_repo / "_build_info.py" - sha = mod.write_build_info(repo_root=git_repo, output=out) - - assert not sha.endswith("-dirty") - - -@pytest.mark.unit -def test_copied_checkout_is_not_dirty(git_repo, tmp_path_factory): - """A copied checkout (new inodes/ctimes, same content) must not bake -dirty.""" - mod = _load_module() - copy = tmp_path_factory.mktemp("copy") / "repo" - shutil.copytree(git_repo, copy) - index_before = (copy / ".git" / "index").read_bytes() - - assert mod._tree_dirty(copy) is False - assert (copy / ".git" / "index").read_bytes() == index_before - (copy / "tracked.txt").write_text("v2-uncommitted\n") - assert mod._tree_dirty(copy) is True - - -@pytest.mark.unit -def test_short_sha_raises_without_git(tmp_path): - mod = _load_module() - with pytest.raises(subprocess.CalledProcessError): - mod._short_sha(tmp_path) # no .git -> check=True fails loudly - - -@pytest.mark.unit -def test_write_is_atomic_leaves_no_tmp_file(git_repo): - mod = _load_module() - out = git_repo / "_build_info.py" - mod.write_build_info(repo_root=git_repo, output=out) - leftovers = list(git_repo.glob("_build_info.py*.tmp")) + list( - git_repo.glob("*.tmp") - ) - assert leftovers == [] - - -@pytest.mark.unit -def test_short_sha_rejects_foreign_toplevel(git_repo): - """Running against a subdir whose git toplevel != repo_root is refused.""" - mod = _load_module() - subdir = git_repo / "sub" - subdir.mkdir() - with pytest.raises(RuntimeError, match="different repo"): - mod._short_sha(subdir) - - -@pytest.mark.unit -def test_short_sha_ignores_stray_git_dir(git_repo, tmp_path, monkeypatch): - """A stray GIT_DIR must not bake a foreign SHA (the toplevel guard alone passes). - - With GIT_DIR set + GIT_WORK_TREE unset, ``--show-toplevel`` reports repo_root - (guard passes) while HEAD would resolve the foreign repo; _git_env's scrub is - what re-anchors the bake to repo_root's own commit. - """ - mod = _load_module() - foreign = tmp_path / "foreign" - foreign.mkdir() - _git("init", cwd=foreign) - _git( - "-c", - "user.email=t@t.co", - "-c", - "user.name=t", - "commit", - "--allow-empty", - "-m", - "foreign", - cwd=foreign, - ) - foreign_sha = _git("rev-parse", "--short=7", "HEAD", cwd=foreign) - our_sha = _git("rev-parse", "--short=7", "HEAD", cwd=git_repo) - assert foreign_sha != our_sha - - monkeypatch.setenv("GIT_DIR", str(foreign / ".git")) - monkeypatch.delenv("GIT_WORK_TREE", raising=False) - assert mod._short_sha(git_repo) == our_sha - - -@pytest.mark.unit -def test_tree_dirty_fails_to_dirty_on_probe_error(git_repo, monkeypatch): - """A dirty-probe timeout/error is reported as dirty, mirroring runtime.""" - mod = _load_module() - - def boom(*args, **kwargs): - raise subprocess.TimeoutExpired(cmd="git", timeout=5.0) - - monkeypatch.setattr(mod.subprocess, "run", boom) - assert mod._tree_dirty(git_repo) is True - - -@pytest.mark.unit -@pytest.mark.parametrize( - ("returncode", "stdout"), - [(0, b" M tracked.txt\n"), (128, b"")], # a listed change; a git error -) -def test_tree_dirty_scrubs_git_env_and_fails_to_dirty( - git_repo, monkeypatch, returncode, stdout -): - """The generator's probe runs with a scrubbed, lock-free env and errs to dirty.""" - mod = _load_module() - monkeypatch.setenv("GIT_DIR", "/foreign/.git") - monkeypatch.setenv("GIT_INDEX_FILE", "/foreign/.git/index") - seen = {} - - def fake_run(cmd, *args, **kwargs): - seen["env"] = kwargs.get("env") - return subprocess.CompletedProcess( - args=cmd, returncode=returncode, stdout=stdout - ) - - monkeypatch.setattr(mod.subprocess, "run", fake_run) - assert mod._tree_dirty(git_repo) is True - assert seen["env"] is not None - assert "GIT_DIR" not in seen["env"] - assert "GIT_INDEX_FILE" not in seen["env"] - assert seen["env"]["GIT_OPTIONAL_LOCKS"] == "0" - - -@pytest.mark.unit -def test_git_location_env_vars_in_sync_with_runtime(): - """The generator's scrub list must not drift from utils.version's.""" - mod = _load_module() - assert mod._GIT_LOCATION_ENV_VARS == version_mod._GIT_LOCATION_ENV_VARS - - -@pytest.mark.unit -def test_atomic_write_preserves_prior_output_on_failure(git_repo, monkeypatch): - """A mid-write failure leaves the previous _build_info.py intact, not truncated.""" - mod = _load_module() - out = git_repo / "_build_info.py" - out.write_text('GIT_SHA = "previous"\n') - - original_replace = Path.replace - - def failing_replace(self, target): - raise OSError("simulated rename failure") - - monkeypatch.setattr(Path, "replace", failing_replace) - with pytest.raises(OSError, match="simulated rename failure"): - mod.write_build_info(repo_root=git_repo, output=out) - monkeypatch.setattr(Path, "replace", original_replace) - - assert out.read_text() == 'GIT_SHA = "previous"\n' # untouched - assert list(git_repo.glob("*.tmp")) == [] # temp cleaned up diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index bed3fe9d6..680a4646f 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -15,54 +15,139 @@ """Tests for version utilities.""" +import os import shutil import subprocess -import sys from pathlib import Path import pytest from inference_endpoint import __version__ from inference_endpoint.utils import version as version_mod -from inference_endpoint.utils.version import ( - _REPO_ROOT, - _valid_sha, - get_git_sha, - get_version_info, - resolve_git_sha, -) +# Fixed mtime for files committed by _commit_tree. Keeping it well before the +# index write means no entry is "racily clean", so git trusts the index stat data. +_PAST_MTIME = 1_000_000_000 -def _fake_git_run(sha: str, *, dirty: bool): - """subprocess.run stand-in dispatching on the git subcommand. - ``rev-parse`` returns a matching toplevel + ``sha``; ``status`` lists one - modified tracked file (dirty) or nothing (clean). - """ +@pytest.fixture(autouse=True) +def _fresh_process_provenance(): + version_mod._process_git_sha.cache_clear() + yield + version_mod._process_git_sha.cache_clear() - def fake_run(cmd, *args, **kwargs): - if "rev-parse" in cmd: - return subprocess.CompletedProcess( - args=cmd, returncode=0, stdout=f"{_REPO_ROOT}\n{sha}\n" - ) - if "status" in cmd: - return subprocess.CompletedProcess( - args=cmd, returncode=0, stdout=b" M tracked.py\n" if dirty else b"" - ) - raise AssertionError(f"unexpected git invocation: {cmd}") - return fake_run +def _hermetic_git_env() -> dict[str, str]: + """Env for git calls that build throwaway repos. + + Drops every ambient ``GIT_*`` variable (a stray ``GIT_DIR`` would redirect + commits into a foreign repo) and the user's global/system config (commit + signing, hooks, templates), and supplies an identity. + """ + env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env.update( + GIT_CONFIG_GLOBAL=os.devnull, + GIT_CONFIG_NOSYSTEM="1", + GIT_AUTHOR_NAME="t", + GIT_AUTHOR_EMAIL="t@t.co", + GIT_COMMITTER_NAME="t", + GIT_COMMITTER_EMAIL="t@t.co", + ) + return env def _git(*args: str, cwd: Path) -> str: return subprocess.run( ["git", *args], cwd=cwd, + env=_hermetic_git_env(), capture_output=True, text=True, check=True, ).stdout.strip() +def _commit_tree(repo: Path, files: dict[str, str], *add_flags: str) -> Path: + """Create a repo at ``repo`` holding ``files`` in one commit.""" + if shutil.which("git") is None: + pytest.skip("git not available") + repo.mkdir(parents=True) + _git("init", "-q", cwd=repo) + for rel, content in files.items(): + path = repo / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + os.utime(path, (_PAST_MTIME, _PAST_MTIME)) + _git(*add_flags, "add", "-A", cwd=repo) + _git("commit", "-q", "-m", "init", cwd=repo) + return repo + + +def _real_checkout_sha() -> str: + """Short SHA of the checkout this package runs from; skip if there is none. + + Reads the checkout the way get_git_sha does: ambient config (so CI's + safe.directory applies) without a stray GIT_DIR. + """ + root = version_mod._REPO_ROOT + if shutil.which("git") is None or root is None: + pytest.skip("not running from a git source checkout") + + def rev_parse(*args: str) -> str: + return subprocess.run( + ["git", "rev-parse", *args], + cwd=root, + env=version_mod._git_env(), + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + try: + toplevel = rev_parse("--show-toplevel") + sha = rev_parse("--short=7", "HEAD") + except subprocess.CalledProcessError: + pytest.skip("source tree is not a git checkout") + if Path(toplevel).resolve() != root: + pytest.skip("source tree is nested inside another repo") + return sha + + +def _fake_git(rev_parse=None, status=b""): + """subprocess.run stand-in dispatching on the git subcommand. + + Each outcome is stdout bytes, a CompletedProcess, or an exception to raise; + ``rev_parse`` defaults to this checkout's toplevel plus ``abc1234``. Output is + decoded whenever the caller's arguments would make the real call decode it, + with the same encoding and error handler, so a probe switched to text mode + fails here too. + """ + if rev_parse is None: + rev_parse = os.fsencode(version_mod._REPO_ROOT) + b"\nabc1234\n" + + def run(cmd, *args, **kwargs): + if "rev-parse" in cmd: + outcome = rev_parse + elif "status" in cmd: + outcome = status + else: + raise AssertionError(f"unexpected git invocation: {cmd}") + if isinstance(outcome, BaseException): + raise outcome + if isinstance(outcome, bytes): + outcome = subprocess.CompletedProcess(cmd, 0, stdout=outcome) + text_keys = ("text", "universal_newlines", "encoding", "errors") + if any(kwargs.get(key) for key in text_keys): + stdout = outcome.stdout.decode( + kwargs.get("encoding") or "utf-8", kwargs.get("errors") or "strict" + ) + outcome = subprocess.CompletedProcess( + outcome.args, outcome.returncode, stdout=stdout, stderr=outcome.stderr + ) + return outcome + + return run + + # --------------------------------------------------------------------------- # _valid_sha # --------------------------------------------------------------------------- @@ -81,7 +166,7 @@ def _git(*args: str, cwd: Path) -> str: ], ) def test_valid_sha_accepts(value): - assert _valid_sha(value) == value.strip() + assert version_mod._valid_sha(value) == value.strip() @pytest.mark.unit @@ -89,7 +174,7 @@ def test_valid_sha_accepts(value): "value", [ None, - 1234567, # non-string (unquoted GIT_SHA in a hand-edited _build_info.py) + 1234567, # non-string "", " ", "unknown", # the display sentinel @@ -102,7 +187,7 @@ def test_valid_sha_accepts(value): ], ) def test_valid_sha_rejects(value): - assert _valid_sha(value) is None + assert version_mod._valid_sha(value) is None # --------------------------------------------------------------------------- @@ -111,73 +196,95 @@ def test_valid_sha_rejects(value): @pytest.mark.unit -def test_get_git_sha(): - """Test that get_git_sha returns a string or None.""" - sha = get_git_sha() - if sha is not None: - assert isinstance(sha, str) - # A dirty working tree appends a "-dirty" suffix; strip it before the - # shape checks on the bare SHA. - base = sha.removesuffix("-dirty") - # --short=7 is a minimum width; git lengthens it on prefix collision. - assert 7 <= len(base) <= 40 - assert base.isalnum() +def test_get_git_sha_reports_a_valid_sha_or_none(): + sha = version_mod.get_git_sha() + assert sha is None or version_mod._valid_sha(sha) == sha + + +@pytest.mark.unit +def test_get_git_sha_returns_bare_sha_for_clean_tree(monkeypatch): + monkeypatch.setattr(version_mod.subprocess, "run", _fake_git(status=b"")) + assert version_mod.get_git_sha() == "abc1234" @pytest.mark.unit def test_get_git_sha_appends_dirty_when_tree_has_tracked_changes(monkeypatch): - """Uncommitted tracked changes surface as a ``-dirty`` suffix on the SHA.""" monkeypatch.setattr( - version_mod.subprocess, "run", _fake_git_run("abc1234", dirty=True) + version_mod.subprocess, "run", _fake_git(status=b" M src/x.py\n") ) - assert get_git_sha() == "abc1234-dirty" + assert version_mod.get_git_sha() == "abc1234-dirty" @pytest.mark.unit -def test_git_tree_dirty_fails_to_dirty_on_error_returncode(monkeypatch): - """A non-zero status code (e.g. 128 from a corrupt index) errs to dirty.""" +@pytest.mark.parametrize( + "status", + [ + subprocess.CompletedProcess( + [], 128, stdout=b"", stderr=b"fatal: index file corrupt" + ), + subprocess.TimeoutExpired(cmd="git", timeout=5.0), + OSError("exec failed"), + ], + ids=["error-returncode", "timeout", "oserror"], +) +def test_dirty_probe_failure_reports_dirty_and_warns(monkeypatch, caplog, status): + """An unverifiable tree is flagged -dirty, and the log says it wasn't verified.""" + monkeypatch.setattr(version_mod.subprocess, "run", _fake_git(status=status)) + with caplog.at_level("WARNING", logger=version_mod.__name__): + assert version_mod.get_git_sha() == "abc1234-dirty" + assert "Could not verify" in caplog.text - def fake_run(cmd, *args, **kwargs): - if "rev-parse" in cmd: - return subprocess.CompletedProcess( - args=cmd, returncode=0, stdout=f"{_REPO_ROOT}\nabc1234\n" - ) - if "status" in cmd: - return subprocess.CompletedProcess(args=cmd, returncode=128, stdout=b"") - raise AssertionError(f"unexpected git invocation: {cmd}") - monkeypatch.setattr(version_mod.subprocess, "run", fake_run) - assert get_git_sha() == "abc1234-dirty" +@pytest.mark.unit +def test_git_probes_use_cold_path_timeout(monkeypatch): + timeouts = [] + fake = _fake_git() + + def run(cmd, *args, **kwargs): + timeouts.append(kwargs["timeout"]) + return fake(cmd, *args, **kwargs) + + monkeypatch.setattr(version_mod.subprocess, "run", run) + version_mod.get_git_sha() + assert timeouts == [5.0, 5.0] @pytest.mark.unit -def test_git_tree_dirty_fails_to_dirty_on_exception(monkeypatch): - """If the dirty probe raises (timeout/OSError), err on the side of dirty.""" +def test_dirty_probe_reads_non_utf8_status_output(tmp_path, monkeypatch): + """A non-UTF-8 path in status output (core.quotePath=false) reads as dirty.""" + monkeypatch.setattr( + version_mod.subprocess, "run", _fake_git(status=b" M caf\xe9.txt\n") + ) + assert version_mod._git_tree_dirty(tmp_path) is True - def fake_run(cmd, *args, **kwargs): - if "rev-parse" in cmd: - return subprocess.CompletedProcess( - args=cmd, returncode=0, stdout=f"{_REPO_ROOT}\nabc1234\n" - ) - if "status" in cmd: - raise subprocess.TimeoutExpired(cmd="git", timeout=1.0) - raise AssertionError(f"unexpected git invocation: {cmd}") - monkeypatch.setattr(version_mod.subprocess, "run", fake_run) - assert get_git_sha() == "abc1234-dirty" +@pytest.mark.unit +def test_get_git_sha_reads_non_utf8_checkout_path(monkeypatch): + """A checkout under a non-UTF-8 directory name still resolves (Linux allows it).""" + raw_root = b"/nonexistent/caf\xe9/endpoints" + monkeypatch.setattr( + version_mod, "_REPO_ROOT", Path(os.fsdecode(raw_root)).resolve() + ) + monkeypatch.setattr( + version_mod.subprocess, + "run", + _fake_git(rev_parse=raw_root + b"\nabc1234\n", status=b""), + ) + assert version_mod.get_git_sha() == "abc1234" @pytest.mark.unit -def test_git_tree_dirty_handles_non_utf8_output(tmp_path, monkeypatch): - """A non-UTF-8 path in status output (core.quotePath=false) reads as dirty.""" +def test_non_ascii_sha_line_is_read_and_then_rejected(monkeypatch): + """A wrapping git printing garbage for the SHA can't crash resolution.""" + root = os.fsencode(version_mod._REPO_ROOT) monkeypatch.setattr( version_mod.subprocess, "run", - lambda cmd, *a, **k: subprocess.CompletedProcess( - args=cmd, returncode=0, stdout=b" M caf\xe9.txt\n" - ), + _fake_git(rev_parse=root + b"\n\xff\xfe\n", status=b""), ) - assert version_mod._git_tree_dirty(tmp_path) is True + sha = version_mod.get_git_sha() + assert sha is not None + assert version_mod._valid_sha(sha) is None @pytest.mark.unit @@ -189,83 +296,40 @@ def fail_run(*args, **kwargs): monkeypatch.setattr(version_mod, "_REPO_ROOT", None) monkeypatch.setattr(version_mod.subprocess, "run", fail_run) - assert get_git_sha() is None + assert version_mod.get_git_sha() is None @pytest.mark.unit @pytest.mark.parametrize( - "fake", + "rev_parse", [ FileNotFoundError(), - subprocess.TimeoutExpired(cmd="git", timeout=1.0), - subprocess.CompletedProcess(args=[], returncode=128, stdout="", stderr="x"), - subprocess.CompletedProcess(args=[], returncode=0, stdout="only-one-line\n"), - subprocess.CompletedProcess( - args=[], returncode=0, stdout="/some/other/repo\ndeadbee\n" - ), + subprocess.TimeoutExpired(cmd="git", timeout=5.0), + subprocess.CompletedProcess([], 128, stdout=b"", stderr=b"x"), + b"only-one-line\n", + b"/some/other/repo\ndeadbee\n", ], + ids=["no-git", "timeout", "not-a-repo", "short-output", "foreign-toplevel"], ) -def test_git_sha_returns_none_on_untrusted_or_missing_repo(fake, monkeypatch): +def test_get_git_sha_none_on_untrusted_or_missing_repo(rev_parse, monkeypatch): """A foreign/absent repo yields None rather than a wrong provenance SHA.""" - - def fake_run(*args, **kwargs): - if isinstance(fake, BaseException): - raise fake - return fake - - monkeypatch.setattr(version_mod.subprocess, "run", fake_run) - assert get_git_sha() is None + monkeypatch.setattr(version_mod.subprocess, "run", _fake_git(rev_parse=rev_parse)) + assert version_mod.get_git_sha() is None @pytest.mark.unit -def test_git_sha_returned_when_toplevel_matches(monkeypatch): - """Happy path: toplevel == _REPO_ROOT and a clean tree -> bare sha, no suffix.""" - monkeypatch.setattr( - version_mod.subprocess, "run", _fake_git_run("abc1234", dirty=False) - ) - assert get_git_sha() == "abc1234" - - -@pytest.mark.unit -def test_git_sha_is_endpoints_repo_not_cwd(tmp_path, monkeypatch): - """get_git_sha reports the endpoints repo SHA, not the launch dir's repo. - - Without anchoring to the package location, running the CLI from an unrelated - git repo would record that repo's SHA into run provenance. - """ - if shutil.which("git") is None: - pytest.skip("git not available") - - try: - expected = _git("rev-parse", "--short=7", "HEAD", cwd=_REPO_ROOT) - except subprocess.CalledProcessError: - pytest.skip("Source tree is not a git repository") - - other = tmp_path / "other_repo" - other.mkdir() - _git("init", cwd=other) - _git( - "-c", - "user.email=t@t.co", - "-c", - "user.name=t", - "commit", - "--allow-empty", - "-m", - "unrelated", - cwd=other, - ) +def test_get_git_sha_is_endpoints_repo_not_cwd(tmp_path, monkeypatch): + """Launching from an unrelated repo must not record that repo's SHA.""" + expected = _real_checkout_sha() + other = _commit_tree(tmp_path / "other_repo", {"f.txt": "unrelated\n"}) other_sha = _git("rev-parse", "--short=7", "HEAD", cwd=other) assert other_sha != expected monkeypatch.chdir(other) - sha = get_git_sha() + sha = version_mod.get_git_sha() - # A dirty source tree (e.g. mid-development) appends "-dirty"; compare bases. assert sha is not None - base = sha.removesuffix("-dirty") - assert base == expected - assert base != other_sha + assert sha.removesuffix("-dirty") == expected _GIT_LOCATION_VARS = ( @@ -280,7 +344,6 @@ def test_git_sha_is_endpoints_repo_not_cwd(tmp_path, monkeypatch): @pytest.mark.unit def test_git_env_drops_git_location_family(monkeypatch): - """_git_env scrubs the git-location family, disables optional locks, keeps the rest.""" for var in _GIT_LOCATION_VARS: monkeypatch.setenv(var, "/foreign") monkeypatch.setenv("PATH", "/usr/bin") @@ -292,23 +355,18 @@ def test_git_env_drops_git_location_family(monkeypatch): @pytest.mark.unit -def test_get_git_sha_scrubs_git_env_and_preserves_dirty(monkeypatch): - """Both git calls get a scrubbed env, and a stray index can't fake a clean tree. - - GIT_DIR/GIT_INDEX_FILE set in the ambient env must not reach either - subprocess, and a dirty status still yields a -dirty SHA. - """ +def test_get_git_sha_passes_scrubbed_env_to_both_probes(monkeypatch): monkeypatch.setenv("GIT_DIR", "/foreign/.git") monkeypatch.setenv("GIT_INDEX_FILE", "/foreign/.git/index") seen_envs = [] - fake_git = _fake_git_run("abc1234", dirty=True) + fake = _fake_git(status=b" M src/x.py\n") - def fake_run(cmd, *args, **kwargs): + def run(cmd, *args, **kwargs): seen_envs.append(kwargs.get("env")) - return fake_git(cmd, *args, **kwargs) + return fake(cmd, *args, **kwargs) - monkeypatch.setattr(version_mod.subprocess, "run", fake_run) - assert get_git_sha() == "abc1234-dirty" + monkeypatch.setattr(version_mod.subprocess, "run", run) + assert version_mod.get_git_sha() == "abc1234-dirty" assert len(seen_envs) == 2 # rev-parse + status for env in seen_envs: assert env is not None @@ -322,155 +380,199 @@ def test_get_git_sha_ignores_stray_git_dir(tmp_path, monkeypatch): With GIT_DIR set (and GIT_WORK_TREE unset) git defaults the work-tree to cwd, so ``--show-toplevel`` reports our repo (guard passes) while ``HEAD`` would - otherwise resolve the foreign repo. Scrubbing GIT_DIR closes that. + resolve the foreign repo. """ - if shutil.which("git") is None: - pytest.skip("git not available") - try: - our_sha = _git("rev-parse", "--short=7", "HEAD", cwd=_REPO_ROOT) - except subprocess.CalledProcessError: - pytest.skip("Source tree is not a git repository") - - foreign = tmp_path / "foreign" - foreign.mkdir() - _git("init", cwd=foreign) - _git( - "-c", - "user.email=t@t.co", - "-c", - "user.name=t", - "commit", - "--allow-empty", - "-m", - "foreign", - cwd=foreign, - ) + our_sha = _real_checkout_sha() + foreign = _commit_tree(tmp_path / "foreign", {"f.txt": "foreign\n"}) foreign_sha = _git("rev-parse", "--short=7", "HEAD", cwd=foreign) assert foreign_sha != our_sha monkeypatch.setenv("GIT_DIR", str(foreign / ".git")) monkeypatch.delenv("GIT_WORK_TREE", raising=False) - sha = get_git_sha() + sha = version_mod.get_git_sha() assert sha is not None - base = sha.removesuffix("-dirty") - assert base == our_sha - assert base != foreign_sha - - -@pytest.mark.unit -def test_git_tree_dirty_ignores_stat_only_changes_of_a_copied_checkout(tmp_path): - """A copied checkout (new inodes/ctimes, same content) is clean, not -dirty. - - Untracked files don't count, and the probe leaves the index untouched. - """ - if shutil.which("git") is None: - pytest.skip("git not available") - repo = tmp_path / "repo" - repo.mkdir() - _git("init", cwd=repo) - (repo / "tracked.txt").write_text("v1\n") - _git("add", "tracked.txt", cwd=repo) - _git("-c", "user.email=t@t.co", "-c", "user.name=t", "commit", "-m", "x", cwd=repo) - copy = tmp_path / "copy" - shutil.copytree(repo, copy) - (copy / "scratch.jsonl").write_text("untracked\n") - index_before = (copy / ".git" / "index").read_bytes() - - assert version_mod._git_tree_dirty(copy) is False - assert (copy / ".git" / "index").read_bytes() == index_before - (copy / "tracked.txt").write_text("v2\n") - assert version_mod._git_tree_dirty(copy) is True + assert sha.removesuffix("-dirty") == our_sha # --------------------------------------------------------------------------- -# resolve_git_sha (channel precedence + validation) +# _git_tree_dirty (real git) # --------------------------------------------------------------------------- +_VBENCH_RUNNER = "examples/09_Wan22_VideoGen_Example/accuracy/vbench_runner.py" +_LAYOUT = { + "src/inference_endpoint/mod.py": "x = 1\n", + "pyproject.toml": "[project]\n", + "uv.lock": "version = 1\n", + _VBENCH_RUNNER: "run()\n", + "examples/data.bin": "payload\n", + "docs/notes.md": "notes\n", +} + @pytest.mark.unit -def test_resolve_git_sha_prefers_live_git_over_baked_and_env(monkeypatch): - """Live git of the package's own checkout wins; source is 'git'.""" - monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", "abc1234") - monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") - monkeypatch.setattr(version_mod, "get_git_sha", lambda: "0000fff-dirty") - assert resolve_git_sha() == ("0000fff-dirty", "git") +@pytest.mark.parametrize( + ("path", "dirty"), + [ + ("src/inference_endpoint/mod.py", True), + ("pyproject.toml", True), + ("uv.lock", True), + (_VBENCH_RUNNER, True), + ("examples/data.bin", False), + ("docs/notes.md", False), + ], +) +def test_dirty_probe_covers_code_that_runs_and_its_pins(tmp_path, path, dirty): + repo = _commit_tree(tmp_path / "repo", _LAYOUT) + (repo / path).write_text("edited\n") + assert version_mod._git_tree_dirty(repo) is dirty @pytest.mark.unit -def test_resolve_git_sha_uses_baked_when_no_git(monkeypatch): - """Without a git checkout, a baked SHA wins over env; source is 'baked'.""" - monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) - monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", "abc1234") - monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") - assert resolve_git_sha() == ("abc1234", "baked") +@pytest.mark.parametrize( + "script", ["scripts/push_docker_image.sh", "scripts/Dockerfile.dev"] +) +def test_image_build_stamps_sha_like_the_runtime_probe(script): + """Image builds compute GIT_SHA with the runtime's SHA length and dirty scope.""" + text = (Path(__file__).resolve().parents[2] / script).read_text() + assert "rev-parse --short=7 HEAD" in text + assert f"git diff --quiet HEAD -- {' '.join(version_mod._DIRTY_PATHSPEC)}" in text @pytest.mark.unit -def test_resolve_git_sha_uses_env_when_no_git_or_baked(monkeypatch): - """With neither git nor a baked SHA, the env var wins; source is 'env'.""" - monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) - monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) - monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") - assert resolve_git_sha() == ("def5678", "env") +def test_dirty_probe_treats_copied_checkout_as_clean(tmp_path): + """New inodes/ctimes with unchanged content are clean; untracked files don't count.""" + repo = _commit_tree(tmp_path / "repo", _LAYOUT) + copy = tmp_path / "copy" + shutil.copytree(repo, copy) + (copy / "src" / "inference_endpoint" / "scratch.jsonl").write_text("untracked\n") + + assert version_mod._git_tree_dirty(copy) is False + (copy / "src" / "inference_endpoint" / "mod.py").write_text("x = 2\n") + assert version_mod._git_tree_dirty(copy) is True @pytest.mark.unit -def test_resolve_git_sha_accepts_dirty_and_uppercase_env(monkeypatch): - """A dirty-suffixed / uppercase env attestation is honored verbatim.""" - monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) - monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) - monkeypatch.setenv("ENDPOINTS_GIT_SHA", "ABC1234-dirty") - assert resolve_git_sha() == ("ABC1234-dirty", "env") +def test_dirty_probe_never_rewrites_the_index(tmp_path): + """A probe that refreshes stale stat data leaves the caller's index untouched.""" + repo = _commit_tree(tmp_path / "repo", _LAYOUT) + stale = repo / "src" / "inference_endpoint" / "mod.py" + os.utime(stale, (_PAST_MTIME + 60, _PAST_MTIME + 60)) # new mtime, same content + index_before = (repo / ".git" / "index").read_bytes() + + assert version_mod._git_tree_dirty(repo) is False + assert (repo / ".git" / "index").read_bytes() == index_before @pytest.mark.unit -def test_resolve_git_sha_none_when_nothing_resolves(monkeypatch): - """No git checkout, no baked SHA, no env -> (None, 'none').""" - monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) - monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) - monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) - assert resolve_git_sha() == (None, "none") +def test_dirty_probe_skips_rehash_of_filtered_file_on_stat_only_change(tmp_path): + """A filtered file (git-lfs style) with only inode/ctime changes stays clean. + + The repo is committed through a clean filter that isn't configured afterwards, + like an LFS checkout opened where git-lfs isn't installed (a container over a + Docker Desktop bind mount, which presents new inode numbers). Re-hashing the + raw file can never match the filtered blob, so only a stat check limited to + mtime and size reports the tree correctly. + """ + repo = _commit_tree( + tmp_path / "repo", + { + ".gitattributes": "src/inference_endpoint/blob.bin filter=fake\n", + "src/inference_endpoint/blob.bin": "aaaa\n", + }, + "-c", + "filter.fake.clean=tr a b", + ) + copy = tmp_path / "copy" + shutil.copytree(repo, copy) + assert version_mod._git_tree_dirty(copy) is False + + +# --------------------------------------------------------------------------- +# resolve_git_sha (channel precedence + validation) +# --------------------------------------------------------------------------- @pytest.mark.unit -@pytest.mark.parametrize("value", ["", " ", "unknown", "not-a-sha"]) -def test_resolve_git_sha_rejects_blank_or_non_sha_env(monkeypatch, value): - """A blank, sentinel or non-hex ENDPOINTS_GIT_SHA is treated as unset.""" - monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) - monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) - monkeypatch.setenv("ENDPOINTS_GIT_SHA", value) - assert resolve_git_sha() == (None, "none") +@pytest.mark.parametrize( + ("live", "env", "expected"), + [ + ("0000fff-dirty", "def5678", ("0000fff-dirty", "git")), + (None, "def5678", ("def5678", "env")), + (None, "ABC1234-dirty", ("ABC1234-dirty", "env")), + (None, None, (None, "none")), + (None, "", (None, "none")), + (None, " ", (None, "none")), + (None, "unknown", (None, "none")), + (None, "not-a-sha", (None, "none")), + ("abc1234\x1b[31m", "def5678", ("def5678", "env")), + ], + ids=[ + "git-wins", + "env", + "env-dirty-uppercase", + "nothing", + "env-blank", + "env-whitespace", + "env-sentinel", + "env-non-hex", + "polluted-git-falls-through", + ], +) +def test_resolve_git_sha_outside_a_checkout(tmp_path, monkeypatch, live, env, expected): + """Without a .git at the source root, live git then env, both validated.""" + monkeypatch.setattr(version_mod, "_REPO_ROOT", tmp_path) + monkeypatch.setattr(version_mod, "get_git_sha", lambda: live) + if env is None: + monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) + else: + monkeypatch.setenv("ENDPOINTS_GIT_SHA", env) + assert version_mod.resolve_git_sha() == expected @pytest.mark.unit -@pytest.mark.parametrize("baked", ["not-a-real-sha", 1234567]) -def test_resolve_git_sha_rejects_malformed_baked_falls_through(monkeypatch, baked): - """A malformed or non-string baked value is ignored; resolution reaches env.""" - monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) - monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", baked) +def test_resolve_git_sha_uses_env_for_installed_package(monkeypatch): + monkeypatch.setattr(version_mod, "_REPO_ROOT", None) monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") - assert resolve_git_sha() == ("def5678", "env") + assert version_mod.resolve_git_sha() == ("def5678", "env") @pytest.mark.unit -def test_resolve_git_sha_validates_live_git_channel(monkeypatch): - """Polluted git output is not trusted; resolution continues to baked.""" - monkeypatch.setattr(version_mod, "get_git_sha", lambda: "abc1234\x1b[31m") - monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", "def5678") - monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) - assert resolve_git_sha() == ("def5678", "baked") +@pytest.mark.parametrize( + "make_git", + [ + lambda git: git.write_text("gitdir: /nonexistent/.git/worktrees/x\n"), + lambda git: git.symlink_to("/nonexistent/.git"), + ], + ids=["gitfile-outside-mount", "dangling-symlink"], +) +def test_unreadable_checkout_reports_none_not_env( + tmp_path, monkeypatch, caplog, make_git +): + """A checkout git can't read reports no SHA; the env SHA describes other code. + + Models a bind-mounted checkout whose ``.git`` points outside the mount: the + image's ENDPOINTS_GIT_SHA names the image's commit, not this tree's. + """ + root = tmp_path / "mnt" + (root / "src" / "inference_endpoint").mkdir(parents=True) + make_git(root / ".git") + monkeypatch.setattr(version_mod, "_REPO_ROOT", root.resolve()) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + + with caplog.at_level("WARNING", logger=version_mod.__name__): + assert version_mod.resolve_git_sha() == (None, "none") + assert str(root.resolve()) in caplog.text @pytest.mark.unit -def test_resolve_git_sha_reflects_later_env_change(monkeypatch): - """resolve_git_sha is not cached: a later env change is observed.""" - monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) - monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) - monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) - assert resolve_git_sha() == (None, "none") +def test_source_tree_without_git_uses_env(tmp_path, monkeypatch): + """A source layout with no .git (an image's COPY of src/) takes the env SHA.""" + root = tmp_path / "image" + (root / "src" / "inference_endpoint").mkdir(parents=True) + monkeypatch.setattr(version_mod, "_REPO_ROOT", root.resolve()) monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") - assert resolve_git_sha() == ("def5678", "env") + assert version_mod.resolve_git_sha() == ("def5678", "env") # --------------------------------------------------------------------------- @@ -479,50 +581,39 @@ def test_resolve_git_sha_reflects_later_env_change(monkeypatch): @pytest.mark.unit -def test_get_version_info(): - """Test that get_version_info returns correct structure.""" - info = get_version_info() - assert isinstance(info, dict) - assert "version" in info - assert "git_sha" in info - assert "git_sha_source" in info +def test_get_version_info_shape(): + info = version_mod.get_version_info() + assert set(info) == {"version", "git_sha", "git_sha_source"} assert info["version"] == __version__ - assert info["git_sha_source"] in {"baked", "env", "git", "none"} - if info["git_sha"] is not None: - assert isinstance(info["git_sha"], str) - assert 7 <= len(info["git_sha"].removesuffix("-dirty")) <= 64 + assert info["git_sha_source"] in {"env", "git", "none"} + assert (info["git_sha"] is None) == (info["git_sha_source"] == "none") @pytest.mark.unit -def test_corrupt_baked_file_degrades_not_crashes(): - """A corrupt _build_info.py must degrade (fall through), not crash version import. +def test_get_version_info_resolves_once_per_process(monkeypatch): + """Every report a process writes describes the code it started with.""" + calls = [] - A hand-edited unquoted ``GIT_SHA = abc1234`` raises NameError at import — not - ImportError/SyntaxError — so the baked-import handler must be broad enough to - catch it, honoring the "degrade, don't crash Report.from_snapshot" invariant. - Run in a subprocess (import-time behavior can't be re-exercised in-process). - """ - pkg_dir = Path(version_mod.__file__).resolve().parents[1] - baked = pkg_dir / "_build_info.py" - if baked.exists(): - pytest.skip("a real _build_info.py is present; not overwriting it") - baked.write_text("GIT_SHA = abc1234\n") # unquoted -> NameError on import - probe = ( - "from inference_endpoint.utils.version import resolve_git_sha; " - "print(resolve_git_sha()[1])" - ) - try: - result = subprocess.run( - [sys.executable, "-c", probe], - capture_output=True, - text=True, - timeout=60, - ) - finally: - baked.unlink(missing_ok=True) + def resolve(): + calls.append(None) + return ("abc1234", "git") + + monkeypatch.setattr(version_mod, "resolve_git_sha", resolve) + first = version_mod.get_version_info() + first["git_sha"] = "mutated" + second = version_mod.get_version_info() - assert result.returncode == 0, result.stderr - assert result.stdout.strip() in {"baked", "env", "git", "none"} + assert second == { + "version": __version__, + "git_sha": "abc1234", + "git_sha_source": "git", + } + assert len(calls) == 1 + + +# --------------------------------------------------------------------------- +# _resolve_repo_root +# --------------------------------------------------------------------------- @pytest.mark.unit @@ -537,12 +628,12 @@ def test_resolve_repo_root_in_source_layout(monkeypatch): @pytest.mark.parametrize( "module_path", [ - "/inference_endpoint/utils/version.py", # too shallow + "/inference_endpoint/utils/version.py", "/venv/lib/python3.12/site-packages/inference_endpoint/utils/version.py", - "/other_repo/vendor/inference_endpoint/utils/version.py", # vendored copy + "/other_repo/vendor/inference_endpoint/utils/version.py", ], + ids=["too-shallow", "site-packages", "vendored"], ) def test_resolve_repo_root_none_outside_source_layout(monkeypatch, module_path): - """Only /src/inference_endpoint/utils/ is a source checkout.""" monkeypatch.setattr(version_mod, "__file__", module_path) assert version_mod._resolve_repo_root() is None