diff --git a/container/compliance/native_packages.yaml b/container/compliance/native_packages.yaml index bd4be516c300..7ca910dd97ae 100644 --- a/container/compliance/native_packages.yaml +++ b/container/compliance/native_packages.yaml @@ -78,17 +78,14 @@ packages: # generator), so it is intentionally NOT duplicated here. # LGPL media stack — built --disable-gpl in wheel_builder (h264_nvenc + libvpx) - # and copied into vllm/sglang runtime (container/templates/vllm_runtime.Dockerfile - # + sglang_runtime.Dockerfile). Replaces the purged GPL apt ffmpeg. + # and copied into the vLLM runtime. SGLang intentionally contains no FFmpeg. - name: ffmpeg - version: "8.1" + version: "8.1.2" license: LGPL-2.1-or-later source: https://ffmpeg.org/ images: - vllm-runtime - vllm-runtime-efa - - sglang-runtime - - sglang-runtime-efa - name: libvpx # VP8/VP9 codec library, ffmpeg's only non-NVIDIA encoder dependency. @@ -98,8 +95,6 @@ packages: images: - vllm-runtime - vllm-runtime-efa - - sglang-runtime - - sglang-runtime-efa # UCX — Unified Communication X, built from source in wheel_builder and used by # the NIXL transport. Installed to /usr/local (no dpkg metadata). diff --git a/container/compliance/policy/codec_policy.yaml b/container/compliance/policy/codec_policy.yaml new file mode 100644 index 000000000000..1828c15a4e70 --- /dev/null +++ b/container/compliance/policy/codec_policy.yaml @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Media-codec allowlist gate — consumed by container/compliance/scan_codecs.py. +# +# We ship an in-tree ffmpeg built to a narrow media-codec set (see +# wheel_builder.Dockerfile). This gate keeps the finished image aligned with +# that: it fails the image BUILD if a media-codec library/binary appears outside +# our in-tree allowlist (allow_paths) or a reasoned exception — catching a distro +# libav*, a wheel-bundled libavcodec, libx264/265, or a stray/imageio-bundled +# ffmpeg that slipped in via a base-image or dependency bump. +# +# Filesystem-first because bundled codec .so files don't appear as SBOM +# components; deny_components adds a supplementary SBOM version floor. +# +# Keep this in sync with the wheel_builder.Dockerfile ffmpeg allowlist and the +# vllm/sglang media-library cleanup. New third-party bundled media libraries must +# be either removed or added to `exceptions` with a reason + owner — never +# silently allowlisted. + +# Filesystem globs (matched against absolute paths under the scan root) for +# media-codec libraries / binaries that must not ship. libav*/libsw* are matched +# wholesale and re-permitted only for our in-tree copy via allow_paths, so any +# THIRD-PARTY bundled libavcodec (distro, a vendored wheel) is caught. +deny_globs: + - "**/libx264.so*" + - "**/libx265.so*" + - "**/libfdk-aac.so*" + - "**/libavcodec.so*" + - "**/libavcodec-*.so*" + - "**/libavdevice.so*" + - "**/libavdevice-*.so*" + - "**/libavformat.so*" + - "**/libavformat-*.so*" + - "**/libavfilter.so*" + - "**/libavfilter-*.so*" + - "**/libavutil.so*" + - "**/libavutil-*.so*" + - "**/libswscale.so*" + - "**/libswscale-*.so*" + - "**/libswresample.so*" + - "**/libswresample-*.so*" + - "**/libpostproc.so*" + # Standalone ffmpeg/ffprobe CLIs (e.g. a distro /usr/bin/ffmpeg). Our own copy + # at /usr/local/bin/ffmpeg is re-permitted by allow_paths below. + - "**/bin/ffmpeg" + - "**/bin/ffprobe" + # GPL prebuilt ffmpeg binary that imageio-ffmpeg bundles under site-packages. + - "**/imageio_ffmpeg/binaries/ffmpeg-*" + +# CycloneDX component gate (only applied when --sbom is passed). `min_fixed_version` +# flags any version strictly below a known-good floor; kept current with upstream +# ffmpeg maintenance releases. +deny_components: + - name: ffmpeg + min_fixed_version: "8.1.2" + - name: x264 + - name: x265 + - name: fdk-aac + +# Absolute-path prefixes whose media libraries are OUR in-tree ffmpeg, built with +# the narrow decoder/encoder allowlist in wheel_builder.Dockerfile. Matches here +# are reported as ALLOWED and never fail the build. +allow_paths: + - "/usr/local/lib/libav" + - "/usr/local/lib/libsw" + - "/usr/local/lib/libpostproc" + - "/usr/local/bin/ffmpeg" + - "/usr/local/src/ffmpeg" # retained source tree (kept for legal reasons) + +# Explicit third-party exceptions: LOGGED in the report but do NOT fail the build. +# Every entry must carry a reason and an owner so the waiver is auditable. +exceptions: + - glob: "**/nvidia/dali/.libs/*" + reason: >- + NVIDIA DALI (nvidia-dali-cuda130) vendors its own ffmpeg stack (libav* plus + the libsw*/libpostproc companions) under .libs/ for its video reader. It is + owned and handled by its own component team and tracked separately rather + than gated here. The glob covers the whole vendored dir so the libsw* + companions are waived too, not just libavcodec. + owner: DALI diff --git a/container/compliance/scan_codecs.py b/container/compliance/scan_codecs.py new file mode 100644 index 000000000000..1352ba94004f --- /dev/null +++ b/container/compliance/scan_codecs.py @@ -0,0 +1,260 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Media-codec allowlist gate for finished images. + +We ship an in-tree ffmpeg built to a narrow media-codec set, so the shipped +filesystem should not contain media-codec libraries/binaries from anywhere else. +Because SBOMs miss statically-bundled codec `.so` files, this is primarily a +FILESYSTEM scan of the built image; an optional `--sbom` adds a CycloneDX +component/version gate (an ffmpeg version floor). + +A denylist hit is classified as: + - ALLOWED — under an `allow_paths` prefix (our own in-tree ffmpeg) + - EXCEPTION — matches a reasoned `exceptions` entry (logged, does not fail) + - VIOLATION — anything else + +With --fail-on-findings, any VIOLATION exits non-zero, failing the image build +when this runs in the compliance licenses stage. + +Policy: container/compliance/policy/codec_policy.yaml +""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import logging +import os +import sys +from pathlib import Path + +import yaml + +try: + from packaging.version import Version +except ImportError: # packaging is optional; fall back to a numeric-tuple compare + Version = None + +logger = logging.getLogger("compliance.scan_codecs") + +# Virtual kernel filesystems only — pruned at the scan root because walking them +# is meaningless (and /proc is effectively unbounded). Real directories such as +# /tmp and /run are NOT pruned: a codec binary left there still ships in the +# image and must be scanned. +_PRUNE_DIRS = {"proc", "sys", "dev"} + + +def _glob_to_fullpath_pattern(glob: str) -> str: + """Translate a `**/`-style policy glob into an fnmatch pattern applied to the + full POSIX path. `**/` becomes `*` (fnmatch's `*` already spans `/`), so + `**/libx264.so*` -> `*libx264.so*` and `**/nvidia/dali/.libs/libav*` -> + `*nvidia/dali/.libs/libav*`.""" + return "*" + glob[3:] if glob.startswith("**/") else glob + + +def _matches_any(path: str, globs: list[str]) -> str | None: + for g in globs: + if fnmatch.fnmatch(path, _glob_to_fullpath_pattern(g)): + return g + return None + + +class CodecPolicy: + def __init__(self, doc: dict): + self.deny_globs: list[str] = doc.get("deny_globs", []) or [] + self.allow_paths: list[str] = doc.get("allow_paths", []) or [] + self.deny_components: list[dict] = doc.get("deny_components", []) or [] + self.exceptions: list[dict] = doc.get("exceptions", []) or [] + + @classmethod + def load(cls, path: Path) -> "CodecPolicy": + return cls(yaml.safe_load(path.read_text(encoding="utf-8")) or {}) + + def classify(self, abspath: str) -> tuple[str, str | None]: + """Return (verdict, detail) for a path already known to hit a deny glob. + verdict is 'allowed' | 'exception' | 'violation'.""" + if any(abspath.startswith(p) for p in self.allow_paths): + return "allowed", None + for exc in self.exceptions: + if fnmatch.fnmatch(abspath, _glob_to_fullpath_pattern(exc.get("glob", ""))): + return "exception", (exc.get("reason") or "").strip() + return "violation", None + + +def scan_filesystem(root: Path, policy: CodecPolicy): + """Walk `root`, returning (violations, exceptions, allowed) lists of dicts.""" + violations, exceptions, allowed = [], [], [] + root_str = os.path.normpath(str(root)) + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + # Prune virtual dirs only at the scan root (don't prune a legitimately + # named 'run'/'tmp' deeper in a package tree). + if os.path.normpath(dirpath) == root_str: + dirnames[:] = [d for d in dirnames if d not in _PRUNE_DIRS] + for name in filenames: + full = os.path.join(dirpath, name) + # Report paths as absolute-from-root ("/usr/...") so allow_paths and + # exceptions read naturally regardless of where root is mounted. + rel = os.path.relpath(full, root_str) + abspath = "/" + rel.replace(os.sep, "/") + hit = _matches_any(abspath, policy.deny_globs) + if not hit: + continue + verdict, detail = policy.classify(abspath) + entry = {"path": abspath, "glob": hit, "detail": detail} + {"allowed": allowed, "exception": exceptions, "violation": violations}[ + verdict + ].append(entry) + return violations, exceptions, allowed + + +def _version_lt(a: str, b: str) -> bool: + """a < b. Uses packaging.version when available, else a numeric-tuple fallback. + A malformed version under packaging raises (InvalidVersion) and fails the scan + rather than silently degrading to the naive comparator.""" + if Version is not None: + return Version(a) < Version(b) + + def key(v: str) -> list[int]: + return [int(x) if x.isdigit() else 0 for x in v.replace("-", ".").split(".")] + + return key(a) < key(b) + + +def scan_sbom(sbom_path: Path, policy: CodecPolicy) -> list[dict]: + """Flag SBOM components matching deny_components (optionally below a fixed + version). Returns a list of violation dicts.""" + doc = json.loads(sbom_path.read_text(encoding="utf-8")) + out: list[dict] = [] + denied = {d["name"].lower(): d for d in policy.deny_components if d.get("name")} + for comp in doc.get("components", []) or []: + name = (comp.get("name") or "").lower() + rule = denied.get(name) + if not rule: + continue + version = str(comp.get("version") or "") + floor = rule.get("min_fixed_version") + if floor: + # No version ⇒ we cannot prove the component is at/above the fixed + # floor, so flag it rather than let an unversioned denied component + # (e.g. an ffmpeg with no version in the SBOM) silently pass. + if not version: + out.append( + { + "path": f"sbom:{name}@(no version)", + "glob": f"version unknown, cannot verify >= {floor}", + "detail": f"no version in SBOM; fixed floor is {floor}", + } + ) + elif _version_lt(version, floor): + out.append( + { + "path": f"sbom:{name}@{version}", + "glob": f"version < {floor}", + "detail": f"below fixed version {floor}", + } + ) + else: + out.append( + { + "path": f"sbom:{name}@{version}", + "glob": "denied component", + "detail": None, + } + ) + return out + + +def _emit_summary(image, violations, exceptions, allowed): + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary: + return + with open(summary, "a", encoding="utf-8") as s: + s.write(f"### Codec gate{' — ' + image if image else ''}\n\n") + s.write( + f"- violations: **{len(violations)}** | " + f"exceptions: {len(exceptions)} | allowed (in-tree): {len(allowed)}\n\n" + ) + if violations: + s.write("| path | matched | note |\n|---|---|---|\n") + for v in violations: + s.write(f"| `{v['path']}` | `{v['glob']}` | {v['detail'] or ''} |\n") + else: + s.write("✅ no media-codec artifacts outside the allowlist.\n") + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(prog="compliance.scan_codecs") + p.add_argument( + "--root", type=Path, default=Path("/"), help="filesystem root to scan" + ) + p.add_argument( + "--policy", + type=Path, + default=Path(__file__).parent / "policy" / "codec_policy.yaml", + ) + p.add_argument( + "--sbom", + type=Path, + default=None, + help="optional CycloneDX SBOM for the component gate", + ) + p.add_argument("--image", default="", help="image label for the report header") + p.add_argument("--report", type=Path, default=None) + p.add_argument( + "--fail-on-findings", action="store_true", help="exit non-zero on any violation" + ) + p.add_argument("-v", "--verbose", action="store_true") + args = p.parse_args(argv) + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(levelname)s [%(name)s]: %(message)s", + ) + + policy = CodecPolicy.load(args.policy) + violations, exceptions, allowed = scan_filesystem(args.root, policy) + if args.sbom: + # An explicitly-supplied SBOM that is missing must fail loudly — silently + # skipping it would disable the version-floor gate without anyone noticing. + if not args.sbom.is_file(): + logger.error("--sbom given but not found: %s", args.sbom) + return 1 + violations.extend(scan_sbom(args.sbom, policy)) + + hdr = f"Codec gate{' for ' + args.image if args.image else ''}" + print(hdr) + print( + f" scanned root: {args.root} | violations: {len(violations)} | " + f"exceptions: {len(exceptions)} | allowed (in-tree): {len(allowed)}" + ) + for label, rows in ( + ("ALLOWED", allowed), + ("EXCEPTION", exceptions), + ("VIOLATION", violations), + ): + for r in rows: + line = f" {label:9} {r['path']}" + if r.get("detail"): + line += f" — {r['detail']}" + (logger.debug if label == "ALLOWED" else print)(line) + + if args.report: + args.report.write_text( + "\n".join(f"{r['path']}\t{r['glob']}" for r in violations) + "\n", + encoding="utf-8", + ) + _emit_summary(args.image, violations, exceptions, allowed) + + if violations: + logger.error( + "%d media-codec artifact(s) present outside the allowlist; " + "remove them or add a reasoned exception to codec_policy.yaml", + len(violations), + ) + if args.fail_on_findings: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/container/compliance/tests/test_scan_codecs.py b/container/compliance/tests/test_scan_codecs.py new file mode 100644 index 000000000000..b3480270b278 --- /dev/null +++ b/container/compliance/tests/test_scan_codecs.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the media-codec allowlist gate (compliance.scan_codecs). + +Run from the repo root with the compliance package on the path: + + PYTHONPATH=container python -m pytest container/compliance/tests/test_scan_codecs.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from compliance.scan_codecs import CodecPolicy, main, scan_filesystem, scan_sbom + +pytestmark = [ + pytest.mark.pre_merge, + pytest.mark.post_merge, + pytest.mark.gpu_0, + pytest.mark.unit, +] + +# The real shipped policy — the tests assert against it, not a fixture, so a +# policy edit that would let a non-allowlisted media codec through fails a test here. +_POLICY = CodecPolicy.load( + Path(__file__).resolve().parents[1] / "policy" / "codec_policy.yaml" +) + + +def _touch(root: Path, rel: str) -> None: + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"\x7fELF") + + +def test_in_tree_ffmpeg_is_allowed(tmp_path: Path): + # Our own /usr/local ffmpeg libs — must classify as allowed, never violate. + for rel in ( + "usr/local/lib/libavcodec.so.62", + "usr/local/lib/libswscale.so.9", + "usr/local/bin/ffmpeg", + ): + _touch(tmp_path, rel) + violations, _exceptions, allowed = scan_filesystem(tmp_path, _POLICY) + assert violations == [] + assert {a["path"] for a in allowed} == { + "/usr/local/lib/libavcodec.so.62", + "/usr/local/lib/libswscale.so.9", + "/usr/local/bin/ffmpeg", + } + + +def test_dali_bundled_ffmpeg_is_a_logged_exception(tmp_path: Path): + # DALI's whole vendored .libs/ is waived — both libavcodec and the libsw* + # companions (the latter regressed the trtllm build when the glob was libav* only). + dali = "usr/local/lib/python3.12/dist-packages/nvidia/dali/.libs" + _touch(tmp_path, f"{dali}/libavcodec-73c99a8b.so.62") + _touch(tmp_path, f"{dali}/libswscale-8cfd67c4.so.9") + violations, exceptions, _allowed = scan_filesystem(tmp_path, _POLICY) + assert violations == [] + assert len(exceptions) == 2 + assert all("DALI" in (e["detail"] or "") for e in exceptions) + + +@pytest.mark.parametrize( + "rel", + [ + "usr/lib/x86_64-linux-gnu/libx264.so.164", + "usr/lib/x86_64-linux-gnu/libx265.so.199", + # A third-party bundled libavcodec NOT under /usr/local and NOT DALI. + "usr/local/lib/python3.12/dist-packages/opencv_python_headless.libs/libavcodec-156beeea.so.62.11.100", + "opt/venv/lib/python3.12/site-packages/imageio_ffmpeg/binaries/ffmpeg-linux-x86_64-v7.1", + ], +) +def test_nonallowlisted_media_artifacts_are_violations(tmp_path: Path, rel: str): + _touch(tmp_path, rel) + violations, _, _ = scan_filesystem(tmp_path, _POLICY) + assert [v["path"] for v in violations] == ["/" + rel] + + +def test_unrelated_files_are_ignored(tmp_path: Path): + for rel in ("usr/lib/libc.so.6", "usr/local/lib/libvpx.so.9", "opt/dynamo/foo.py"): + _touch(tmp_path, rel) + violations, exceptions, allowed = scan_filesystem(tmp_path, _POLICY) + assert (violations, exceptions, allowed) == ([], [], []) + + +def test_sbom_flags_ffmpeg_below_cve_floor(tmp_path: Path): + sbom = tmp_path / "s.cdx.json" + sbom.write_text( + json.dumps( + { + "components": [ + {"name": "ffmpeg", "version": "8.0.1"}, # < 8.1.2 -> flagged + {"name": "ffmpeg", "version": "8.1.2"}, # == floor -> ok + {"name": "libvpx", "version": "1.14.1"}, # not denied + ] + } + ) + ) + hits = scan_sbom(sbom, _POLICY) + assert [h["path"] for h in hits] == ["sbom:ffmpeg@8.0.1"] + + +def test_codec_under_tmp_is_scanned(tmp_path: Path): + # /tmp is a real image directory (not a virtual kernel fs) — a codec left + # there still ships and must be flagged, not pruned from the walk. + _touch(tmp_path, "tmp/libx264.so.164") + violations, _, _ = scan_filesystem(tmp_path, _POLICY) + assert [v["path"] for v in violations] == ["/tmp/libx264.so.164"] + + +def test_sbom_unversioned_denied_component_is_flagged(tmp_path: Path): + # A denied component with no version cannot be proven >= the fixed floor, + # so it must be flagged rather than silently pass the version gate. + sbom = tmp_path / "s.cdx.json" + sbom.write_text(json.dumps({"components": [{"name": "ffmpeg"}]})) + hits = scan_sbom(sbom, _POLICY) + assert len(hits) == 1 and "no version" in hits[0]["path"] + + +def test_missing_sbom_path_fails(tmp_path: Path): + # An explicitly-supplied but missing --sbom must fail, not silently skip + # (which would disable the version-floor gate unnoticed). + assert main(["--root", str(tmp_path), "--sbom", str(tmp_path / "nope.json")]) == 1 + + +def test_fail_on_findings_exit_code(tmp_path: Path): + _touch(tmp_path, "usr/lib/x86_64-linux-gnu/libx264.so.164") + assert main(["--root", str(tmp_path), "--fail-on-findings"]) == 1 + # Same tree, report-only: findings reported but exit 0. + assert main(["--root", str(tmp_path)]) == 0 + + +def test_clean_tree_passes(tmp_path: Path): + _touch(tmp_path, "usr/local/lib/libavcodec.so.62") # ours -> allowed + assert main(["--root", str(tmp_path), "--fail-on-findings"]) == 0 diff --git a/container/context.yaml b/container/context.yaml index c253b24692c0..6fe314c076fd 100644 --- a/container/context.yaml +++ b/container/context.yaml @@ -43,7 +43,10 @@ dynamo: enable_kvbm: "true" enable_media_ffmpeg: "false" enable_gpu_memory_service: "true" - ffmpeg_version: "8.1" + # 8.1.2 is an upstream maintenance release that picks up security fixes over + # 8.1; combined with the narrowed decoder set in wheel_builder it trims the + # media decode surface. Keep in sync with native_packages.yaml's ffmpeg entry. + ffmpeg_version: "8.1.2" # ffmpeg build inputs (only consumed when ENABLE_MEDIA_FFMPEG=true). nv_codec_headers_ref: "n13.0.19.0" libvpx_ref: "v1.14.1" @@ -100,9 +103,9 @@ sglang: # lmsysorg/sglang is built FROM this nvidia/cuda cudnn-devel base, so this # attributes everything sglang adds over the clean NVIDIA CUDA+cuDNN floor # (framework, python stack, system libs) rather than hiding it. Per-arch stem; - # the licenses stage appends -${TARGETARCH}.cdx.json. (sglang ships no system - # GPL ffmpeg/codec dpkgs; the GPL imageio-ffmpeg pip binary is replaced by an - # in-tree LGPL ffmpeg in sglang_runtime.Dockerfile — no dpkg purge needed.) + # the licenses stage appends -${TARGETARCH}.cdx.json. SGLang's codec-bearing + # Python wheels are purged in sglang_runtime.Dockerfile; FFmpeg is not copied + # into the image and the Dynamo Rust wheel is built without media-ffmpeg. baseline_sbom: cuda@8b2705ea xpu: base_image: intel/deep-learning-essentials @@ -121,7 +124,7 @@ sglang: # NIXL Python stack — its wheel COPY is narrowed to ai_dynamo*.whl so the SDK # build doesn't leak into the runtime image. nixl_ref: v1.0.1 - enable_media_ffmpeg: "true" + enable_media_ffmpeg: "false" enable_gpu_memory_service: "true" enable_kvbm: "false" enable_modelexpress: "true" diff --git a/container/deps/requirements.sglang.txt b/container/deps/requirements.sglang.txt index e813813faf18..64c60a9e66cf 100644 --- a/container/deps/requirements.sglang.txt +++ b/container/deps/requirements.sglang.txt @@ -1,14 +1,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Third-party Python dependencies for the sglang runtime image. Installed -# with --force-reinstall --no-deps to replace the upstream lmsysorg/sglang -# base image's imageio-ffmpeg wheel (which ships a GPL-encumbered prebuilt -# ffmpeg binary) with a source build that leaves no binary on disk. -# IMAGEIO_FFMPEG_EXE points imageio at the in-tree LGPL ffmpeg CLI. - ---no-binary imageio-ffmpeg +# Third-party Python dependencies for the SGLang runtime image. Installed with +# --force-reinstall --no-deps so the upstream SGLang dependency stack remains +# otherwise unchanged. FFmpeg and codec-bearing wheels are removed separately +# in sglang_runtime.Dockerfile. blake3>=1.0.0,<2.0.0 # Dynamo SGLang multimodal request handlers import blake3 at startup -imageio-ffmpeg>=0.6.0 # binary skipped per --no-binary directive at top of file zstandard==0.23.0 diff --git a/container/templates/compliance.Dockerfile b/container/templates/compliance.Dockerfile index c660b15828fe..26132d0c80f3 100644 --- a/container/templates/compliance.Dockerfile +++ b/container/templates/compliance.Dockerfile @@ -100,6 +100,20 @@ RUN python3 -m compliance.policy.validate \ --policy /opt/compliance/policy/licenses.toml \ --input /legal/osrb-deps.csv +# Media-codec allowlist gate: scans THIS stage's filesystem (== +# the shipped image tree, since licenses is FROM pre_runtime) and fails the build +# if a media-codec library/binary (a third-party libav*, libx264/265, or a stray +# or imageio-bundled ffmpeg) ships outside our in-tree allowlist or a reasoned +# exception. Feeds the generated delta SBOM in too, for an ffmpeg-version floor. +# Files, not just the SBOM, because statically-bundled codec .so's don't appear +# as components. +RUN python3 -m compliance.scan_codecs \ + --root / \ + --policy /opt/compliance/policy/codec_policy.yaml \ + --sbom /legal/osrb.cdx.json \ + --image {{ framework }}-{{ target }} \ + --fail-on-findings -v + ####################################### ####### Compliance: artifact ########## diff --git a/container/templates/sglang_runtime.Dockerfile b/container/templates/sglang_runtime.Dockerfile index 02c3094659aa..2d6e0d35272c 100644 --- a/container/templates/sglang_runtime.Dockerfile +++ b/container/templates/sglang_runtime.Dockerfile @@ -61,25 +61,6 @@ $NIXL_PLUGIN_DIR:\ ${LD_LIBRARY_PATH:-} {% endif %} -# Copy ffmpeg from wheel_builder: versioned shared libs (libav*.so*, -# libsw*.so*) for the Rust media-ffmpeg decoder, plus the LGPL CLI binary -# (built with h264_nvenc + libvpx_vp9 encoders) that imageio targets via -# IMAGEIO_FFMPEG_EXE for video encoding. Ungated by enable_media_ffmpeg -# because the upstream lmsysorg/sglang base image always ships -# imageio-ffmpeg with a GPL-encumbered prebuilt binary that we replace -# unconditionally below; the LGPL CLI must be present so imageio has -# something to target. -RUN --mount=type=bind,from=wheel_builder,source=/usr/local/,target=/tmp/usr/local/ \ - mkdir -p /usr/local/lib/pkgconfig && \ - cp -rnL /tmp/usr/local/include/libav* /tmp/usr/local/include/libsw* /usr/local/include/ && \ - cp -nL /tmp/usr/local/lib/libav*.so* /tmp/usr/local/lib/libsw*.so* /usr/local/lib/ && \ - cp -nL /tmp/usr/local/lib/lib*vpx*.so* /usr/local/lib/ 2>/dev/null || true && \ - cp -nL /tmp/usr/local/lib/pkgconfig/libav*.pc /tmp/usr/local/lib/pkgconfig/libsw*.pc /usr/local/lib/pkgconfig/ && \ - cp -nL /tmp/usr/local/bin/ffmpeg /usr/local/bin/ffmpeg && \ - cp -r /tmp/usr/local/src/ffmpeg /usr/local/src/ && \ - ldconfig -ENV IMAGEIO_FFMPEG_EXE=/usr/local/bin/ffmpeg - {% if target not in ("dev", "local-dev") %} # Runtime target installs only the prebuilt Dynamo wheels. SGLang and its NIXL # packages come from the upstream lmsysorg/sglang runtime image; --no-deps keeps @@ -145,17 +126,64 @@ RUN --mount=type=bind,source=./container/deps/requirements.common.txt,target=/tm export PIP_CACHE_DIR=/root/.cache/pip && \ pip install --break-system-packages --no-deps $(grep -E '^nvtx==' /tmp/requirements.common.txt) -# Replace the upstream lmsysorg/sglang image's imageio-ffmpeg (which ships a -# GPL-encumbered prebuilt ffmpeg binary in /imageio_ffmpeg/binaries/) -# with a source install that leaves no binary on disk. IMAGEIO_FFMPEG_EXE points -# imageio at the LGPL CLI we copied from wheel_builder above. The --no-binary -# directive lives in the requirements file itself. +# Install SGLang-specific runtime dependencies without changing the upstream +# dependency solution. imageio-ffmpeg is intentionally absent. RUN --mount=type=bind,source=./container/deps/requirements.sglang.txt,target=/tmp/requirements.sglang.txt \ --mount=type=cache,target=/root/.cache/pip,sharing=locked \ export PIP_CACHE_DIR=/root/.cache/pip && \ pip install --break-system-packages --force-reinstall --no-deps \ --requirement /tmp/requirements.sglang.txt +# Remove every codec-bearing component found in the upstream SGLang image and +# fail the build if an executable or shared library for FFmpeg, H.264, H.265, or +# AAC remains in the merged runtime filesystem. +# +# Inkling image preprocessing uses Pillow. Its audio feature extractor imports +# soundfile and uses torchaudio only for resampling, so those paths remain +# available for formats supported by libsndfile (for example WAV and FLAC). +# AAC-backed M4A and all video encode/decode support are intentionally removed. +RUN set -eux; \ + python3 -m pip uninstall --yes \ + av \ + decord \ + decord2 \ + imageio-ffmpeg \ + opencv-python \ + opencv-python-headless \ + torchcodec; \ + SITE_PACKAGES="$(python3 -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"; \ + rm -rf \ + "${SITE_PACKAGES}"/av \ + "${SITE_PACKAGES}"/av-*.dist-info \ + "${SITE_PACKAGES}"/av.libs \ + "${SITE_PACKAGES}"/cv2 \ + "${SITE_PACKAGES}"/decord \ + "${SITE_PACKAGES}"/decord-*.dist-info \ + "${SITE_PACKAGES}"/decord.libs \ + "${SITE_PACKAGES}"/decord2 \ + "${SITE_PACKAGES}"/decord2-*.dist-info \ + "${SITE_PACKAGES}"/decord2.libs \ + "${SITE_PACKAGES}"/imageio_ffmpeg \ + "${SITE_PACKAGES}"/imageio_ffmpeg-*.dist-info \ + "${SITE_PACKAGES}"/opencv_python*.dist-info \ + "${SITE_PACKAGES}"/opencv_python*.libs \ + "${SITE_PACKAGES}"/torchcodec \ + "${SITE_PACKAGES}"/torchcodec-*.dist-info \ + /usr/local/bin/ffmpeg \ + /usr/local/bin/ffprobe \ + /usr/local/include/libav* \ + /usr/local/include/libsw* \ + /usr/local/lib/libav* \ + /usr/local/lib/libpostproc* \ + /usr/local/lib/libsw* \ + /usr/local/lib/pkgconfig/libav*.pc \ + /usr/local/lib/pkgconfig/libpostproc*.pc \ + /usr/local/lib/pkgconfig/libsw*.pc \ + /usr/local/src/ffmpeg \ + /root/.cache/pip; \ + ldconfig +ENV IMAGEIO_FFMPEG_EXE= + # Copy tests, deploy and components for CI with correct ownership COPY --chmod=775 --chown=dynamo:0 tests /workspace/tests COPY --chmod=775 --chown=dynamo:0 examples /workspace/examples @@ -201,6 +229,29 @@ RUN SITE_PACKAGES="$(python3 -c 'import site; print(site.getsitepackages()[0])') (python3 -m compileall -q -j0 /sgl-workspace/sglang/python || true) {%- endif %} +# Keep this guard at the end of the populated runtime stage so later COPY/RUN +# steps cannot silently reintroduce a codec. The extra AAC library names cover +# common non-FFmpeg implementations even though the current Syft baseline did +# not find them. +RUN set -eux; \ + remaining="$(find /usr /opt /workspace /sgl-workspace -xdev \ + \( -type f -o -type l \) \ + \( -name ffmpeg -o -name ffprobe \ + -o -name 'libavcodec*.so*' -o -name 'libavdevice*.so*' \ + -o -name 'libavfilter*.so*' -o -name 'libavformat*.so*' \ + -o -name 'libavutil*.so*' -o -name 'libpostproc*.so*' \ + -o -name 'libswresample*.so*' -o -name 'libswscale*.so*' \ + -o -name 'libx264*.so*' -o -name 'libx265*.so*' \ + -o -name 'libopenh264*.so*' -o -name 'libfdk-aac*.so*' \ + -o -name 'libfaac*.so*' -o -name 'libvo-aacenc*.so*' \ + -o -name 'libaacplus*.so*' \) -print)"; \ + if [ -n "${remaining}" ]; then \ + echo "ERROR: codec-bearing files remain in the SGLang image:" >&2; \ + echo "${remaining}" >&2; \ + exit 1; \ + fi; \ + python3 -c 'import soundfile, torchaudio; from PIL import Image' + USER dynamo ARG DYNAMO_COMMIT_SHA ENV DYNAMO_COMMIT_SHA=${DYNAMO_COMMIT_SHA} diff --git a/container/templates/trtllm_runtime.Dockerfile b/container/templates/trtllm_runtime.Dockerfile index 0e252f63c53c..ce0861cf4930 100644 --- a/container/templates/trtllm_runtime.Dockerfile +++ b/container/templates/trtllm_runtime.Dockerfile @@ -161,6 +161,19 @@ RUN --mount=type=bind,from=wheel_builder,source=/usr/local/,target=/tmp/usr/loca ldconfig ENV IMAGEIO_FFMPEG_EXE=/usr/local/bin/ffmpeg +# Drop upstream's preinstalled opencv-python-headless (and the libav*/ffmpeg +# copies its wheel vendors under opencv_python_headless.libs/). TRT-LLM made +# cv2 an optional video extra (NVIDIA/TensorRT-LLM#16206) and its imports are +# already function-local in 1.3.x, so nothing in the image needs it at import +# time; video-decode users can install it explicitly per upstream docs. +# Must run via the system interpreter: with VIRTUAL_ENV set, plain `pip` +# targets the venv and exits 0 WITHOUT touching the system-site install. +# The guards fail the build if the package survives. Mirrored by the +# pre_runtime whiteout below — the squash COPY cannot represent deletions. +RUN /usr/bin/python3 -m pip uninstall -y --break-system-packages opencv-python-headless && \ + ! /usr/bin/python3 -c "import cv2" 2>/dev/null && \ + [ ! -e /usr/local/lib/python3.12/dist-packages/opencv_python_headless.libs ] + # Pull /workspace_src (incl. LICENSE) from the transport stage and # wire up the launch screen in a single RUN — saves the standalone workspace COPY layer. RUN --mount=type=bind,from=workspace_files,source=/workspace_src,target=/tmp/workspace_src \ @@ -188,9 +201,14 @@ CMD ["/bin/bash"] # single layer. Only Dynamo-specific env needs redeclaring below. FROM ${RUNTIME_IMAGE}:${RUNTIME_IMAGE_TAG} AS pre_runtime # Whiteout paths runtime_full removed — COPY can't represent deletions, so -# without this, upstream's /workspace, /home/ubuntu, and single-file -# /usr/local/bin/etcd would leak alongside our content. -RUN rm -rf /workspace /home/ubuntu /usr/local/bin/etcd +# without this, upstream's /workspace, /home/ubuntu, single-file +# /usr/local/bin/etcd, and preinstalled opencv (cv2/ + vendored +# opencv_python_headless.libs/ + dist-info) would leak alongside our content. +# Keep this list in sync with any deletion RUNs in the stages above. +RUN rm -rf /workspace /home/ubuntu /usr/local/bin/etcd \ + /usr/local/lib/python3.12/dist-packages/cv2 \ + /usr/local/lib/python3.12/dist-packages/opencv_python_headless* && \ + ! /usr/bin/python3 -c "import cv2" 2>/dev/null COPY --from=runtime_full / / # Mirrors runtime_full's ENV — must stay in sync. Re-declaration is required diff --git a/container/templates/vllm_runtime.Dockerfile b/container/templates/vllm_runtime.Dockerfile index 2b922fa4e8f5..81687f5f4c2e 100644 --- a/container/templates/vllm_runtime.Dockerfile +++ b/container/templates/vllm_runtime.Dockerfile @@ -275,6 +275,30 @@ RUN --mount=type=bind,source=./container/deps/requirements.vllm.txt,target=/tmp/ # tool scripts referencing files not present in Dynamo's build context. RUN rm -rf /workspace/vllm +# Remove the codec-bearing video-DECODE wheels inherited from the vllm-openai +# base. Each bundles its own full ffmpeg carrying software H.264/H.265/AAC; +# PyAV and decord additionally ship GPL libx264/libx265. Dynamo's vLLM component +# imports none of them, so they are unused decode-side dead weight. The in-tree +# LGPL ffmpeg + imageio-ffmpeg installed above are intentionally KEPT for the +# omni HW video-encode path (h264_nvenc — the sanctioned path). Direct rm makes +# the removal robust regardless of how the base image's pip is configured; the +# guards fail the build if any of them survive. +RUN set -eux; \ + python3 -m pip uninstall --yes \ + av decord decord2 opencv-python opencv-python-headless torchcodec PyNvVideoCodec \ + || true; \ + SITE_PACKAGES="$(python3 -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"; \ + rm -rf \ + "${SITE_PACKAGES}"/av "${SITE_PACKAGES}"/av-*.dist-info "${SITE_PACKAGES}"/av.libs \ + "${SITE_PACKAGES}"/cv2 "${SITE_PACKAGES}"/opencv_python*.dist-info "${SITE_PACKAGES}"/opencv_python*.libs \ + "${SITE_PACKAGES}"/decord "${SITE_PACKAGES}"/decord-*.dist-info "${SITE_PACKAGES}"/decord.libs \ + "${SITE_PACKAGES}"/decord2 "${SITE_PACKAGES}"/decord2-*.dist-info "${SITE_PACKAGES}"/decord2.libs \ + "${SITE_PACKAGES}"/torchcodec "${SITE_PACKAGES}"/torchcodec-*.dist-info \ + "${SITE_PACKAGES}"/PyNvVideoCodec "${SITE_PACKAGES}"/PyNvVideoCodec-*.dist-info "${SITE_PACKAGES}"/PyNvVideoCodec.libs \ + /root/.cache/pip; \ + ! python3 -c "import cv2" 2>/dev/null; \ + ! python3 -c "import av" 2>/dev/null + USER dynamo # Copy the workspace surface needed by the current vLLM pre-merge test image. diff --git a/container/templates/wheel_builder.Dockerfile b/container/templates/wheel_builder.Dockerfile index 65ec1da77584..875f95033d43 100644 --- a/container/templates/wheel_builder.Dockerfile +++ b/container/templates/wheel_builder.Dockerfile @@ -286,12 +286,38 @@ RUN mkdir -p /tmp/native-sources ENV SCCACHE_BUCKET=${USE_SCCACHE:+${SCCACHE_BUCKET}} \ SCCACHE_REGION=${USE_SCCACHE:+${SCCACHE_REGION}} -# Always build FFmpeg so libs are available for Rust checks in CI. +# Build FFmpeg for frameworks that retain media encode/decode support. SGLang +# deliberately omits it: its Inkling image/audio path uses Pillow and +# soundfile/torchaudio, and the SGLang runtime must not contain H.264, H.265, or +# AAC implementations. +{% if framework != "sglang" %} +# Build FFmpeg so libs are available for Rust checks in CI. # We also build the ffmpeg CLI with h264_nvenc + libvpx_vp9 encoders so Python # code can encode video without the GPL-licensed binary shipped by imageio-ffmpeg. # Stays LGPL-only: --disable-gpl --disable-nonfree are preserved; H.264 comes from # NVIDIA's NVENC (proprietary HW encoder, already a runtime dependency of these # GPU images) and VP9 from libvpx (BSD). +# +# MEDIA CODEC ALLOWLIST: the in-tree libavcodec should carry only +# the media formats we actually build and use, not ffmpeg's full default decoder +# set. A blanket --disable-decoders/--disable-demuxers/--disable-parsers plus a +# narrow allowlist keeps the shipped libav*.so limited to that set (HW NVDEC can +# be re-added explicitly if a decode feature ever needs H.264/H.265). The +# allowlist covers exactly two paths: (1) the encode CLI ingesting rawvideo +# frames from imageio over a pipe and encoding with h264_nvenc (NVIDIA's HW +# encoder — the sanctioned path) or libvpx_vp9, and (2) the Rust media-ffmpeg +# VideoDecoder decoding VP8/VP9 in mp4/webm/mkv (test fixtures are VP9-in-mp4). +# The h264 *parser* is enabled — not the H.264 decoder — because the mp4 muxer +# needs it to package the h264_nvenc bitstream (extract SPS/PPS); a parser +# carries no codec implementation. Image decode does not use ffmpeg (it goes +# through the Rust `image` crate), so no still-image decoders are enabled here. +# The `fd` protocol is enabled alongside `pipe`: `ffmpeg -i -` reads stdin via +# the `fd:` protocol on ffmpeg 8.x (not `pipe:`), so omitting it breaks the +# imageio encode path with "Protocol not found. Did you mean file:fd:?". Both +# are pure fd/stream I/O and carry no codec implementation. +# +# Combined with the 8.1 -> 8.1.2 bump below (an upstream maintenance release), +# this also trims the decoder surface to what we ship. # Do not delete the source tarball for legal reasons. ARG FFMPEG_VERSION ARG NV_CODEC_HEADERS_REF @@ -344,9 +370,16 @@ RUN --mount=type=secret,id=aws-web-identity-token,target=/run/secrets/aws-token --enable-libvpx \ --disable-encoders \ --enable-encoder=h264_nvenc,libvpx_vp9 \ + --disable-decoders \ + --enable-decoder=vp8,vp9,rawvideo \ --disable-muxers \ --enable-muxer=mov,mp4,matroska,webm \ - --enable-protocol=file,pipe && \ + --disable-demuxers \ + --enable-demuxer=mov,matroska,rawvideo \ + --disable-parsers \ + --enable-parser=vp8,vp9,h264 \ + --disable-protocols \ + --enable-protocol=file,pipe,fd && \ make -j$(nproc) && \ make install && \ /tmp/use-sccache.sh show-stats "FFMPEG" && \ @@ -354,6 +387,7 @@ RUN --mount=type=secret,id=aws-web-identity-token,target=/run/secrets/aws-token mkdir -p /usr/local/src/ffmpeg && \ find /tmp/ffmpeg-${FFMPEG_VERSION} \( -name config.log -o -name config.status \) -delete && \ mv /tmp/ffmpeg-${FFMPEG_VERSION}* /usr/local/src/ffmpeg/ +{% endif %} # Build and install UCX RUN --mount=type=secret,id=aws-web-identity-token,target=/run/secrets/aws-token \ @@ -501,7 +535,9 @@ COPY components/ /opt/dynamo/components/ # Build ai-dynamo (pure Python) and ai-dynamo-runtime (maturin) wheels ARG USE_SCCACHE +{% if framework != "sglang" %} ARG ENABLE_MEDIA_FFMPEG +{% endif %} RUN --mount=type=secret,id=aws-web-identity-token,target=/run/secrets/aws-token \ --mount=type=secret,id=aws-role-arn,env=AWS_ROLE_ARN \ --mount=type=cache,target=/root/.cargo/registry,sharing=shared \ @@ -518,12 +554,13 @@ RUN --mount=type=secret,id=aws-web-identity-token,target=/run/secrets/aws-token cd /opt/dynamo && \ uv build --wheel --out-dir /opt/dynamo/dist && \ cd /opt/dynamo/lib/bindings/python && \ - if [ "$ENABLE_MEDIA_FFMPEG" = "true" ]; then \ +{% if framework == "sglang" %} maturin build --release --features "kv-indexer,slot-tracker,select-service,mm-routing,aic-forward-pass" --out /opt/dynamo/dist && \ +{% else %} if [ "$ENABLE_MEDIA_FFMPEG" = "true" ]; then \ maturin build --release --features "media-ffmpeg,kv-indexer,slot-tracker,select-service,mm-routing,aic-forward-pass" --out /opt/dynamo/dist; \ else \ maturin build --release --features "kv-indexer,slot-tracker,select-service,mm-routing,aic-forward-pass" --out /opt/dynamo/dist; \ fi && \ - /tmp/use-sccache.sh show-stats "Dynamo Runtime" +{% endif %} /tmp/use-sccache.sh show-stats "Dynamo Runtime" # Compliance: harvest each crate's real LICENSE files from the cargo registry # source cache so the rust NOTICES generator can inline upstream license text diff --git a/lib/llm/src/preprocessor/media/README.md b/lib/llm/src/preprocessor/media/README.md index ac9f8aa805f6..b21f7d052f1a 100644 --- a/lib/llm/src/preprocessor/media/README.md +++ b/lib/llm/src/preprocessor/media/README.md @@ -52,6 +52,9 @@ register_model( > [!WARNING] > **Video decoding**: Video decoding needs to be enabled via the `dynamo-llm/media-ffmpeg` rust feature. The following ffmpeg dynamic libraries must be available on the system: `libavcodec`, `libavdevice`, `libavfilter`, `libavformat`, `libswresample`, `libswscale`. These are available in dynamo dockerfiles rendered with `enable_media_ffmpeg` set to true in `container/context.yaml`. +> [!WARNING] +> **Supported input codecs**: The in-tree ffmpeg is built with a narrow decoder allowlist (VP8/VP9 video in mp4/webm/mkv) — it carries only the media formats we build and use, not ffmpeg's full default set (see `container/templates/wheel_builder.Dockerfile`). Other codecs, including H.264 and H.265, are intentionally **not** decodable in software; decoding them would require enabling the NVDEC hardware decoders (`h264_cuvid`/`hevc_cuvid`), which is not wired up today. + ## Image decoding options ### Limits (not overridable at runtime via `media_io_kwargs`) diff --git a/lib/llm/src/preprocessor/media/decoders/video.rs b/lib/llm/src/preprocessor/media/decoders/video.rs index 649823bf1ac7..dd13ede877fa 100644 --- a/lib/llm/src/preprocessor/media/decoders/video.rs +++ b/lib/llm/src/preprocessor/media/decoders/video.rs @@ -315,6 +315,9 @@ mod tests { /// Load test video and parse expected dimensions from filename. /// Filename format: "{resolution}_{frames}.mp4" (e.g., "240p_10.mp4" -> 320x240, 10 frames) + /// Fixtures are VP9-in-mp4: the in-tree ffmpeg only decodes a narrow + /// allowlist (VP8/VP9), so H.264 fixtures would not decode. Regenerate with + /// `ffmpeg -f lavfi -i testsrc2=size=WxH:rate=1 -frames:v N -c:v libvpx-vp9 -g 1 -strict -2 {resolution}_{frames}.mp4`. fn load_test_video(filename: &str) -> (EncodedMediaData, u32, u32, u32) { let path = format!( "{}/tests/data/media/{}", diff --git a/lib/llm/tests/data/media/2160p_10.mp4 b/lib/llm/tests/data/media/2160p_10.mp4 index 017ae1c3fee9..5ffc09eb959f 100644 --- a/lib/llm/tests/data/media/2160p_10.mp4 +++ b/lib/llm/tests/data/media/2160p_10.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f82a62d48a38e29ea004d0991adea5cdf2838e17647f24e8350aecc8297b8916 -size 5416 +oid sha256:462fa796a3099539df042a684b944e38acb2b19a2ddcceb760f88c092a1b805d +size 1009737 diff --git a/lib/llm/tests/data/media/240p_1.mp4 b/lib/llm/tests/data/media/240p_1.mp4 index 5d2a0fddf0f0..13cac1d3cb8e 100644 --- a/lib/llm/tests/data/media/240p_1.mp4 +++ b/lib/llm/tests/data/media/240p_1.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4442d098b32b29a3e7f9babac257a8c6ab3c4ed41cd2e1ad62df1e4734b92b6b -size 1597 +oid sha256:ac4dd818eb2b1371b0756704d7322bc64ef8ae9c68f8a6da1a4c12a26326485e +size 6541 diff --git a/lib/llm/tests/data/media/240p_10.mp4 b/lib/llm/tests/data/media/240p_10.mp4 index 86b57b122372..fd1f51bf0494 100644 --- a/lib/llm/tests/data/media/240p_10.mp4 +++ b/lib/llm/tests/data/media/240p_10.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:125d25fa5f09f44ae09e79e09d2e6c85a417ae8279a069eaab310b0e28cab18d -size 1913 +oid sha256:e72a3b7b83ec10000db8071bcafec959f110907d02f88da92ff792463ccb3eed +size 65804 diff --git a/lib/llm/tests/data/media/240p_100.mp4 b/lib/llm/tests/data/media/240p_100.mp4 index 7e021c9a7e82..77b2854b68e5 100644 --- a/lib/llm/tests/data/media/240p_100.mp4 +++ b/lib/llm/tests/data/media/240p_100.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c534d517ee77150ec971b3568dd999c09bbd4ec7850abed417f063bddb8e514f -size 4579 +oid sha256:13211b149c490748c2518313eae17181fca1082d7449e9b16a11e6728a0db3b4 +size 659773 diff --git a/lib/llm/tests/data/media/2p_10.mp4 b/lib/llm/tests/data/media/2p_10.mp4 index ce80424c5f7a..658c1965415e 100644 --- a/lib/llm/tests/data/media/2p_10.mp4 +++ b/lib/llm/tests/data/media/2p_10.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6cb1a8ca5c3ee2391a1606fac4004685e57fcc0ac5277773ccfdada534bb63f3 -size 1832 +oid sha256:ccf744a1e29f99d50d4988637f90a7d1110f3387ea059589c994a8f8ebdc25d4 +size 1180 diff --git a/tests/dependencies/test_no_opencv.py b/tests/dependencies/test_no_opencv.py new file mode 100644 index 000000000000..605868f05fb6 --- /dev/null +++ b/tests/dependencies/test_no_opencv.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Regression guard: opencv-python-headless is not installed in TRT-LLM images. + +Upstream TRT-LLM made cv2 an optional video extra +(https://github.com/NVIDIA/TensorRT-LLM/pull/16206) and its 1.3.x imports are +already function-local, so nothing needs it at import time. Dynamo's +trtllm_runtime.Dockerfile drops the preinstall (including the vendored media +libraries under opencv_python_headless.libs/) in both the runtime_full stage and +the pre_runtime whiteout, slimming the image. This test keeps a base-image bump +or Dockerfile refactor from silently reintroducing it. +""" + +import importlib.util +import pathlib + +import pytest + +pytestmark = [ + pytest.mark.trtllm, + pytest.mark.pre_merge, + pytest.mark.post_merge, + pytest.mark.gpu_0, + pytest.mark.unit, + pytest.mark.skipif( + importlib.util.find_spec("tensorrt_llm") is None, + reason="TRT-LLM images only (other frameworks may ship opencv)", + ), +] + + +def test_opencv_not_installed(): + """cv2 must not be importable anywhere on the image's python path.""" + spec = importlib.util.find_spec("cv2") + assert spec is None, ( + f"opencv-python-headless is installed (cv2 at {spec.origin}); " + "it must be removed from TRT-LLM images — see " + "trtllm_runtime.Dockerfile (uninstall RUN + pre_runtime whiteout)" + ) + + +def test_no_vendored_opencv_libs(): + """The wheel's vendored shared libraries must be gone from dist-packages.""" + leftovers = list( + pathlib.Path("/usr/local/lib/python3.12/dist-packages").glob( + "opencv_python_headless*" + ) + ) + assert not leftovers, ( + f"vendored opencv artifacts still on disk: {leftovers}; " + "the pre_runtime whiteout in trtllm_runtime.Dockerfile must remove them" + )