diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..af0df5ae6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# 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 prefers baked). +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 cc6e4e144..7f28696e6 100644 --- a/docs/metrics/DESIGN.md +++ b/docs/metrics/DESIGN.md @@ -76,6 +76,7 @@ subscriber's last live snapshot and the resulting `Report.complete` is `False`. class Report(msgspec.Struct, frozen=True): version: str git_sha: str | None + git_sha_source: str # which channel resolved git_sha: baked | env | git | 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 7e3b0f73d..ce4a58d63 100644 --- a/docs/metrics/report_design.md +++ b/docs/metrics/report_design.md @@ -66,7 +66,7 @@ p50. A zero-count series returns `{}` (or an all-null early-stopping map if the ### `Report` (frozen `msgspec.Struct`) -Fields: `version`, `git_sha`, `test_started_at`, `n_samples_issued/completed/failed`, +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 29b526b1a..03f05e079 100644 --- a/docs/utils/DESIGN.md +++ b/docs/utils/DESIGN.md @@ -27,8 +27,9 @@ does import from other `inference_endpoint` subpackages. **No cross-imports from `utils/` helper modules** `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 +small, but it intentionally imports `inference_endpoint.__version__` and resolves a build SHA +through a fallback chain — a baked `_build_info.py`, the `ENDPOINTS_GIT_SHA` env var, then a live +`git` query — 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** @@ -44,7 +45,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 2751dc830..39816744a 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) --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 @@ -102,3 +103,17 @@ RUN if [ "${PROVISION_VBENCH}" = "1" ]; then \ else \ echo "PROVISION_VBENCH=${PROVISION_VBENCH}: skipping VBench provisioning" ; \ fi + +# 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. 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: +# docker build ... --build-arg GIT_SHA=$(git rev-parse --short=7 HEAD) +# 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. +# Image-level provenance (the OCI image.revision annotation / : tag) is set +# separately by the publish script on the pushed manifest, so no LABEL here. +ARG GIT_SHA= +ENV ENDPOINTS_GIT_SHA=${GIT_SHA} diff --git a/scripts/write_build_info.py b/scripts/write_build_info.py new file mode 100644 index 000000000..71248027d --- /dev/null +++ b/scripts/write_build_info.py @@ -0,0 +1,185 @@ +#!/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`` +imports it as the highest-priority provenance source; without it, runtime falls +back to the ``ENDPOINTS_GIT_SHA`` env var (containers) then a live ``git`` query +(dev). 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 with the git repo-location family dropped (see the note above).""" + env = dict(os.environ) + for var in _GIT_LOCATION_ENV_VARS: + env.pop(var, None) + 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, and + any outcome other than a clean exit 0 (non-zero code, timeout, OSError) is + reported as dirty so a build never bakes a clean SHA it could not verify. + + Caveat (matches ``git describe --dirty`` semantics): 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: + result = subprocess.run( + ["git", "diff-index", "--quiet", "HEAD", "--"], + 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 + + +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 668437ee7..15c5f5e07 100644 --- a/src/inference_endpoint/metrics/report.py +++ b/src/inference_endpoint/metrics/report.py @@ -181,6 +181,11 @@ class Report(msgspec.Struct, frozen=True): # type: ignore[call-arg] version: str git_sha: str | None + # Which channel resolved git_sha: "baked" (build-info file), "env" + # (ENDPOINTS_GIT_SHA), "git" (live checkout), or "none". Records how much to + # trust the SHA — a baked/committed SHA is ground truth, an env one an + # attestation, a dirty "git" 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. @@ -365,6 +370,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, @@ -423,8 +429,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..e5346e4d1 100644 --- a/src/inference_endpoint/utils/version.py +++ b/src/inference_endpoint/utils/version.py @@ -15,12 +15,56 @@ """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). A missing or corrupt bake is +# caught below (see the except) and degrades to "no baked SHA" rather than +# crashing this module — and with it Report.from_snapshot. +try: + from .._build_info import GIT_SHA as _BAKED_GIT_SHA # type: ignore[attr-defined] +except Exception: + # Broad by design: this file is generated (write_build_info.py always emits a + # quoted string), so the only way the import fails is a missing file or a + # corrupt/hand-edited one — a missing module (ImportError), a truncated string + # (SyntaxError), or an unquoted value like `GIT_SHA = abc1234` (NameError). + # ALL of these must degrade to "no baked SHA" rather than crash version import + # and, with it, Report.from_snapshot. KeyboardInterrupt/SystemExit are + # BaseException and still propagate; a 3-line import can't realistically OOM. + _BAKED_GIT_SHA = None + +# Explicit override, primarily for containers/CI: the launcher (or `docker build +# --build-arg GIT_SHA=...` -> ENV) sets this when no baked file is present. +_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: """Repo root of this source checkout, anchored to the module location. @@ -37,9 +81,62 @@ def _resolve_repo_root() -> Path: _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 ``cwd=_REPO_ROOT``. 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 with the git repo-location family dropped (see the note above).""" + env = dict(os.environ) + for var in _GIT_LOCATION_ENV_VARS: + env.pop(var, None) + return env + + +def _git_tree_dirty() -> bool: + """True iff tracked files have uncommitted changes (via ``git diff-index``). + + 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. No index is refreshed, so a + stat-only touch can rarely over-report dirty; that errs conservative and + never mutates the caller's git state. + + Fails to dirty: any outcome other than a clean exit 0 — a non-zero code + (diff present, or an error like a stale index.lock / no HEAD) as well as a + timeout / 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 diff-index on NFS/HPC homes). + """ + try: + result = subprocess.run( + ["git", "diff-index", "--quiet", "HEAD", "--"], + capture_output=True, + timeout=1.0, + check=False, + cwd=_REPO_ROOT, + env=_git_env(), + ) + except (OSError, subprocess.TimeoutExpired): + return True + # exit 0 => confirmed clean; anything else (1 = differences, 128 = error) => dirty. + return result.returncode != 0 + + 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,8 +144,9 @@ 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 git + checkout (e.g. an installed wheel) or git is unavailable. """ try: result = subprocess.run( @@ -58,6 +156,7 @@ def get_git_sha() -> str | None: timeout=1.0, check=False, cwd=_REPO_ROOT, + env=_git_env(), ) if result.returncode != 0: return None @@ -70,19 +169,59 @@ def get_git_sha() -> str | None: # a wrong provenance SHA is worse than None. if Path(toplevel).resolve() != _REPO_ROOT: return None - return sha.strip() + sha = sha.strip() + return f"{sha}-dirty" if _git_tree_dirty() 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 looser runtime claim: + + 1. ``"baked"`` — ``_build_info.GIT_SHA`` packed into the wheel at build time + from the exact packaged tree; travels with the artifact, no runtime git. + 2. ``"env"`` — the ``ENDPOINTS_GIT_SHA`` environment variable (container + build-arg or launch-script attestation). + 3. ``"git"`` — a live ``git`` query against the source checkout (dev). + 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"``. + """ + 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" + # 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" + 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 0102efc69..e948f9f49 100644 --- a/tests/unit/metrics/test_report_builder.py +++ b/tests/unit/metrics/test_report_builder.py @@ -507,6 +507,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, @@ -525,11 +526,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, @@ -553,6 +604,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, @@ -800,3 +852,12 @@ def test_scrub_nonfinite_round_trip_yields_none(): json.dumps(d, allow_nan=False) # Sanity: original NaN was indeed non-finite. assert not math.isfinite(float("nan")) + + +@pytest.mark.unit +def test_from_snapshot_populates_and_serializes_git_sha_source(): + """from_snapshot sets git_sha_source from version info and to_json carries it.""" + report = _build_report(_make_registry(n_samples=5)) + assert report.git_sha_source in {"baked", "env", "git", "none"} + data = json.loads(report.to_json()) + assert data["git_sha_source"] == report.git_sha_source 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..798af46c0 --- /dev/null +++ b/tests/unit/scripts/test_write_build_info.py @@ -0,0 +1,211 @@ +# 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 subprocess +from pathlib import Path + +import pytest + +_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_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 +def test_tree_dirty_scrubs_git_env(git_repo, monkeypatch): + """The generator's dirty probe also runs with the git-location family scrubbed.""" + 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=1, stdout=b"") + + 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"] + + +@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.""" + from inference_endpoint.utils import version + + mod = _load_module() + assert mod._GIT_LOCATION_ENV_VARS == version._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..fb1e935ed 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -17,12 +17,41 @@ import shutil import subprocess +import sys from pathlib import Path +import inference_endpoint 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``; ``diff-index`` returns + exit 1 (dirty) or 0 (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 "diff-index" in cmd: + return subprocess.CompletedProcess( + args=cmd, returncode=1 if dirty else 0, stdout="" + ) + raise AssertionError(f"unexpected git invocation: {cmd}") + + return fake_run def _git(*args: str, cwd: Path) -> str: @@ -35,38 +64,151 @@ 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_get_git_sha_no_dirty_suffix_when_clean(monkeypatch): + """A clean tree yields the bare SHA with 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_tree_dirty_fails_to_dirty_on_error_returncode(monkeypatch): + """A non-0/1 diff-index code (e.g. 128, no HEAD/lock) errs to dirty, not clean.""" + + 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 "diff-index" in cmd: + return subprocess.CompletedProcess(args=cmd, returncode=128, stdout="") + 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_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_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 "diff-index" 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 +@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_git_sha_returned_when_toplevel_matches(monkeypatch): + """Happy path without a real git repo: toplevel == _REPO_ROOT -> return sha.""" + monkeypatch.setattr( + version_mod.subprocess, "run", _fake_git_run("abc1234", dirty=False) + ) + assert get_git_sha() == "abc1234" @pytest.mark.unit @@ -79,13 +221,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,72 +241,285 @@ 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 - monkeypatch.setattr(version_mod.subprocess, "run", fake_run) - get_git_sha.cache_clear() - try: - assert get_git_sha() is None - finally: - get_git_sha.cache_clear() +@pytest.mark.unit +def test_git_env_drops_git_location_family(monkeypatch): + """_git_env scrubs the whole git-location env family but preserves 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["PATH"] == "/usr/bin" @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_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. - Covers the success branch deterministically so it holds in a no-git - environment (installed wheel / clean container). + Pins the fix directly: GIT_DIR/GIT_INDEX_FILE set in the ambient env must not + reach either subprocess, and a dirty diff-index still yields a -dirty SHA. """ + monkeypatch.setenv("GIT_DIR", "/foreign/.git") + monkeypatch.setenv("GIT_INDEX_FILE", "/foreign/.git/index") + seen_envs = [] - def fake_run(*args, **kwargs): - return subprocess.CompletedProcess( - args=[], returncode=0, stdout=f"{_REPO_ROOT}\nabc1234\n" - ) + def fake_run(cmd, *args, **kwargs): + seen_envs.append(kwargs.get("env")) + if "rev-parse" in cmd: + return subprocess.CompletedProcess( + args=cmd, returncode=0, stdout=f"{_REPO_ROOT}\nabc1234\n" + ) + if "diff-index" in cmd: + return subprocess.CompletedProcess(args=cmd, returncode=1, stdout="") + raise AssertionError(f"unexpected git invocation: {cmd}") 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 + diff-index + 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() == "abc1234" + 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 + + +# --------------------------------------------------------------------------- +# resolve_git_sha (channel precedence + validation) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_resolve_git_sha_prefers_baked_over_env_and_git(monkeypatch): + """A baked build-info SHA wins over env and live git; source is 'baked'.""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", "abc1234") + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "0000fff") + assert resolve_git_sha() == ("abc1234", "baked") + + +@pytest.mark.unit +def test_resolve_git_sha_uses_env_when_no_baked(monkeypatch): + """With no baked SHA, the ENDPOINTS_GIT_SHA env var wins; source is 'env'.""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "def5678") + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "0000fff") + 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, "_BAKED_GIT_SHA", None) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "ABC1234-dirty") + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "0000fff") + assert resolve_git_sha() == ("ABC1234-dirty", "env") + + +@pytest.mark.unit +def test_resolve_git_sha_falls_back_to_live_git(monkeypatch): + """With no baked SHA and no env var, live git wins; source is 'git'.""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "abc1234") + assert resolve_git_sha() == ("abc1234", "git") + + +@pytest.mark.unit +def test_resolve_git_sha_none_when_nothing_resolves(monkeypatch): + """No baked SHA, no env, no git checkout -> (None, 'none').""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) + monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) + assert resolve_git_sha() == (None, "none") + + +@pytest.mark.unit +@pytest.mark.parametrize("blank", ["", " "]) +def test_resolve_git_sha_ignores_blank_env(monkeypatch, blank): + """An empty / whitespace-only env var is treated as unset (falls to git).""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", blank) + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "abc1234") + assert resolve_git_sha() == ("abc1234", "git") + + +@pytest.mark.unit +def test_resolve_git_sha_rejects_unknown_sentinel_env(monkeypatch): + """A stray/sentinel ENDPOINTS_GIT_SHA=unknown is rejected, not attested.""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "unknown") + monkeypatch.setattr(version_mod, "get_git_sha", lambda: None) + assert resolve_git_sha() == (None, "none") + + +@pytest.mark.unit +def test_resolve_git_sha_rejects_non_hex_env_falls_through(monkeypatch): + """A non-SHA env value is ignored; resolution continues to live git.""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.setenv("ENDPOINTS_GIT_SHA", "not-a-sha") + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "abc1234") + assert resolve_git_sha() == ("abc1234", "git") + + +@pytest.mark.unit +def test_resolve_git_sha_rejects_garbage_baked_falls_through(monkeypatch): + """A malformed baked value is ignored rather than reported as provenance.""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", "not-a-real-sha") + monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "abc1234") + assert resolve_git_sha() == ("abc1234", "git") + + +@pytest.mark.unit +def test_resolve_git_sha_rejects_non_str_baked_without_crashing(monkeypatch): + """A non-string baked GIT_SHA (unquoted int) is ignored, not a crash.""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", 1234567) + monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "abc1234") + assert resolve_git_sha() == ("abc1234", "git") + + +@pytest.mark.unit +def test_resolve_git_sha_validates_live_git_channel(monkeypatch): + """Even the git channel is validated: polluted git output -> not trusted.""" + monkeypatch.setattr(version_mod, "_BAKED_GIT_SHA", None) + monkeypatch.delenv("ENDPOINTS_GIT_SHA", raising=False) + monkeypatch.setattr(version_mod, "get_git_sha", lambda: "abc1234\x1b[31m") + assert resolve_git_sha() == (None, "none") + + +@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_version_info_is_deterministic(): + """Two calls (now uncached) return equal structures.""" + assert get_version_info() == get_version_info() + + +@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(inference_endpoint.__file__).resolve().parent + 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 + try: + result = subprocess.run( + [ + sys.executable, + "-c", + "from inference_endpoint.utils.version import resolve_git_sha;" + "print(resolve_git_sha()[1])", + ], + 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