From 05730de46d1486a23775fede492cc75881c9c19a Mon Sep 17 00:00:00 2001 From: Dmitry Tokarev Date: Wed, 16 Sep 2026 14:39:06 -0400 Subject: [PATCH 1/8] build(container): bound PyNvVideoCodec at the validated 2.2.3 (DYN-4044) The spec has read `>=2.2.0` since it was introduced, when 2.2.0 was the newest release. A floor with no upper bound pins nothing, so builds resolved 2.2.1, 2.2.2 and now 2.2.3 as they were published without anything being restated: the runtime-test images for main@5e21f9c618 all carry 2.2.3 while every declared floor still read 2.2.0. `container/deps/README.md` already says why -- "Never use `>=` as it allows untested future versions" -- so the spec becomes `>=2.2.3,<3`, matching the bounded siblings in the same files. The TRT-LLM image installs a second, system-site copy of its own and takes the same specifier; its shell guards check the cap as a separate major-version test, because `sort -V` can express a floor but not an upper bound. 2.2.3 vendors FFmpeg 9.0.1 where 2.2.0-2.2.2 vendored 8.1.2, which moves the SONAMEs of the two libraries the wheel bundles: libavformat.so.62 -> .so.63 and libavutil.so.60 -> .so.61. They no longer collide with the in-tree FFmpeg 8.1.2, which ships .so.62/.so.60 built with different configure flags, so NVDEC decode no longer depends on which copy the loader resolves first. The bundled library content is unchanged by the FFmpeg major bump: enumerate_bundled_decoders.py reports 0 decoders and 0 encoders for libavformat.so.63 in all three images, the same as 2.2.0 reported for .so.62. Also replace the base image's copy explicitly rather than leaving it to the installer's upgrade path. Both the vLLM base (2.0.4, FFmpeg 8.1.1) and the TRT-LLM base (2.1.0, FFmpeg 7.0.2) ship a complete FFmpeg including a libavcodec, and both write files outside site-packages -- a version-stamped FFmpeg source tarball -- that a package-directory glob does not reach. The package manager uninstalls first, because its RECORD is the only complete list of what the wheel wrote (it also covers top-level samples/ and benchmarks/ trees), and the explicit rm follows it for the parts that must not depend on the installer honouring its own metadata. Each image then asserts what survived: exactly one distribution on sys.path inside the declared range, exactly one source tarball at the location its own RECORD names, a libavformat and libavutil present so the negative checks cannot pass vacuously, and none of the library families codec_policy.yaml denies. Presence is tested with importlib.util.find_spec, not an import: PyNvVideoCodec's __init__ dlopens libnvidia-encode.so.1, which no builder has, so an import raises whether or not the wheel is installed and could never fail a build. Co-Authored-By: Claude Opus 5 Signed-off-by: Dmitry Tokarev --- .../dynamo/common/multimodal/nvdec_decoder.py | 19 +- container/deps/requirements.sglang.txt | 23 ++- container/deps/requirements.trtllm.txt | 22 +- container/deps/requirements.vllm.txt | 19 +- container/templates/sglang_runtime.Dockerfile | 108 ++++++++++ container/templates/trtllm_runtime.Dockerfile | 157 +++++++++++---- container/templates/vllm_runtime.Dockerfile | 188 +++++++++++++++++- 7 files changed, 464 insertions(+), 72 deletions(-) diff --git a/components/src/dynamo/common/multimodal/nvdec_decoder.py b/components/src/dynamo/common/multimodal/nvdec_decoder.py index 413e4b35328b..ce394304cbfc 100644 --- a/components/src/dynamo/common/multimodal/nvdec_decoder.py +++ b/components/src/dynamo/common/multimodal/nvdec_decoder.py @@ -111,12 +111,17 @@ def nvdec_available() -> bool: try: with warnings.catch_warnings(): # A third-party deprecation must not decide whether the capability - # exists. PyNvVideoCodec 2.2.0's __init__ does `from ast import Str`, + # exists. PyNvVideoCodec 2.2.0's __init__ did `from ast import Str`, # which warns on Python 3.12; under pytest's filterwarnings=error # that warning is *raised*, the import "fails", and this lru_cache # then pins False for the whole process -- silently disabling NVDEC # for every test in the session (a second import would have # succeeded, since the warning is only raised once). + # + # 2.2.2 removed that import, so the shipped floor no longer trips + # this. The suppression is kept because it is not specific to that + # import: any third-party warning raised here would disable NVDEC + # for the session, and the only signal would be a debug log. warnings.simplefilter("ignore") import PyNvVideoCodec # noqa: F401 except Exception as exc: # noqa: BLE001 @@ -162,7 +167,9 @@ def _frame_to_rgb_hwc(frame) -> np.ndarray: A 2.x ``DecodedFrame`` (``output_color_type=RGB``) holds a CUDA buffer and supports the DLPack protocol, so torch wraps it zero-copy on the GPU and - ``.cpu()`` copies to host. Validated on PyNvVideoCodec 2.1.1 for H.264/H.265. + ``.cpu()`` copies to host. Returns a single frame: ``decode_video_nvdec`` stacks + these into ``(num_frames, H, W, 3)``, so a batched replacement here has to drop + that stack too. """ import torch @@ -184,10 +191,10 @@ def _source_fps(decoder) -> float: indices into timestamps with ``idx / fps`` -- a zero raises ZeroDivisionError and the request 500s. - PyNvVideoCodec 2.2.0 exposes the rate as - ``get_stream_metadata().average_fps``; older probes are kept for other - versions, then ``num_frames / duration``, then a documented fallback so a - future API change degrades timestamp accuracy instead of failing requests. + PyNvVideoCodec exposes the rate as ``get_stream_metadata().average_fps``; older + probes are kept for other versions, then ``num_frames / duration``, then a + documented fallback so a future API change degrades timestamp accuracy instead + of failing requests. """ meta = getattr(decoder, "get_stream_metadata", None) if callable(meta): diff --git a/container/deps/requirements.sglang.txt b/container/deps/requirements.sglang.txt index 87fbef4ba8a1..a941756d144d 100644 --- a/container/deps/requirements.sglang.txt +++ b/container/deps/requirements.sglang.txt @@ -30,9 +30,22 @@ mooncake-transfer-engine-cuda13>=0.3.13.post1,<0.4 pillow>=12.3.0,<13 # bounded: this file installs with --force-reinstall # NVDEC hardware decode for H.264/H.265 video input (dynamo common.multimodal. # nvdec_decoder), restoring those codecs after the video-DECODE purge in -# sglang_runtime.Dockerfile. Pin >=2.2.0: it bundles only libavutil + libavformat -# (container demux, no software codec); older 2.0.x bundles a full FFmpeg incl. -# libavcodec that the codec gate rejects. Links libnvcuvid at runtime; requires -# NVIDIA_DRIVER_CAPABILITIES to include "video" (set in sglang_runtime.Dockerfile). -PyNvVideoCodec>=2.2.0 +# sglang_runtime.Dockerfile. Pin >=2.2.3: it bundles only libavutil + libavformat +# (FFmpeg 9.0.1, container demux, no software codec); older 2.0.x/2.1.x bundle a +# full FFmpeg incl. libavcodec that the codec gate rejects. Links libnvcuvid at +# runtime; requires NVIDIA_DRIVER_CAPABILITIES to include "video" (set in +# sglang_runtime.Dockerfile). +# +# No pre-install removal here, unlike the vLLM and TRT-LLM images: the +# lmsysorg/sglang base ships no PyNvVideoCodec today, so this install is the only +# copy in the image. That is a property of the current base rather than a +# guarantee, so sglang_runtime.Dockerfile asserts the result -- exactly one +# dist-info, at or above this floor, and exactly one bundled FFmpeg source +# tarball -- instead of trusting the claim. +# +# Bounded above -- see the note in requirements.vllm.txt. Keep the whole +# specifier identical to the ones in requirements.{vllm,trtllm}.txt, and its lower +# bound identical to the guard constants in the three runtime templates; a test +# asserts they all agree. +PyNvVideoCodec>=2.2.3,<3 zstandard==0.23.0 diff --git a/container/deps/requirements.trtllm.txt b/container/deps/requirements.trtllm.txt index 95633dc6a56c..0891a933ce20 100644 --- a/container/deps/requirements.trtllm.txt +++ b/container/deps/requirements.trtllm.txt @@ -36,18 +36,22 @@ msgspec>=0.19.0 # the current patch line. pillow>=12.3.0 # NVDEC hardware decode for H.264/H.265 video input (dynamo common.multimodal. -# nvdec_decoder). Pin >=2.2.0: it bundles only libavutil + libavformat (container -# demux, no software codec); everything below 2.2.0 -- 2.1.0 included, not just -# the 2.0.x line -- bundles a full FFmpeg whose libavcodec the codec gate -# rejects on presence. Links libnvcuvid at runtime. Requires +# nvdec_decoder). Pin >=2.2.3: it bundles only libavutil + libavformat (FFmpeg +# 9.0.1, container demux, no software codec); everything below 2.2.0 -- 2.1.0 +# included, not just the 2.0.x line -- bundles a full FFmpeg whose libavcodec +# the codec gate rejects on presence. Links libnvcuvid at runtime. Requires # NVIDIA_DRIVER_CAPABILITIES to include "video" (set in # trtllm_runtime.Dockerfile) or it cannot import. # -# As of 1.3.0rc24 the upstream tensorrt-llm/release image DOES bundle this -# package, at 2.1.0, in system site-packages. This floor governs only the -# /opt/dynamo/venv copy; trtllm_runtime.Dockerfile raises the system one to the -# same floor. Keep the two specifiers in step. -PyNvVideoCodec>=2.2.0 +# This image carries two copies: the /opt/dynamo/venv one this file installs, and +# a system-site one trtllm_runtime.Dockerfile installs over the base image's. +# Their specifiers must stay identical, or the two diverge inside one image. +# +# Bounded above -- see the note in requirements.vllm.txt. Keep the whole +# specifier identical to the ones in requirements.{vllm,sglang}.txt and to the +# system-site install, and its lower bound identical to the guard constants in +# the three runtime templates; a test asserts they all agree. +PyNvVideoCodec>=2.2.3,<3 soupsieve>=2.8.4 # JupyterLab stack; see note above jupyter-server tornado>=6.5.6 # JupyterLab stack; see note above jupyter-server # Required by ai_dynamo_runtime (companion to msgspec above). diff --git a/container/deps/requirements.vllm.txt b/container/deps/requirements.vllm.txt index 5f787160bb68..3d5878fcdc5c 100644 --- a/container/deps/requirements.vllm.txt +++ b/container/deps/requirements.vllm.txt @@ -29,9 +29,18 @@ mooncake-transfer-engine-cuda13>=0.3.13.post1,<0.4 # floor it so the --no-deps install moves it to the current patch line. pillow>=12.3.0 # NVDEC hardware decode for H.264/H.265 video input (common/multimodal/ -# nvdec_decoder). Pin >=2.2.0 and force-reinstall (see the vllm_runtime.Dockerfile -# install): the vllm-openai base ships PyNvVideoCodec 2.0.4, which bundles a full -# FFmpeg (libavcodec + libavdevice/libavfilter/libsw*) that the codec gate -# rejects; 2.2.0 bundles only libavutil + libavformat (container demux, no codec). +# nvdec_decoder). Pin >=2.2.3, and remove the base copy before installing (see +# the vllm_runtime.Dockerfile install): the vllm-openai base ships +# PyNvVideoCodec 2.0.4, which bundles a full FFmpeg 8.1.1 (libavcodec + +# libavdevice/libavfilter/libsw*) that the codec gate rejects; 2.2.3 bundles +# only libavutil + libavformat (FFmpeg 9.0.1, container demux, no codec). # Requires NVIDIA_DRIVER_CAPABILITIES to include "video" or it cannot import. -PyNvVideoCodec>=2.2.0 +# +# Bounded above, per the pinning strategy in container/deps/README.md: the lower +# bound records the version the multimodal suite was validated against, and +# without an upper bound it records nothing, because the build resolves whatever +# is newest. Keep the whole specifier identical to the ones in +# requirements.{sglang,trtllm}.txt and to any inline install in a runtime +# template, and its lower bound identical to those templates' guard constants; a +# test asserts they all agree. +PyNvVideoCodec>=2.2.3,<3 diff --git a/container/templates/sglang_runtime.Dockerfile b/container/templates/sglang_runtime.Dockerfile index 27e673b60657..a0052ecd1981 100644 --- a/container/templates/sglang_runtime.Dockerfile +++ b/container/templates/sglang_runtime.Dockerfile @@ -190,6 +190,114 @@ RUN --mount=type=bind,source=./container/deps/requirements.sglang.txt,target=/tm [ "$CUDA_MAJOR" = "13" ] || { echo "ERROR: requirements.sglang.txt hardcodes the mooncake-transfer-engine-cuda13 distribution; got CUDA_MAJOR=$CUDA_MAJOR" >&2; exit 1; } && \ pip install --break-system-packages --force-reinstall --no-deps \ --requirement /tmp/requirements.sglang.txt + +# Assert that exactly one PyNvVideoCodec ended up in the image, at or above the +# floor, carrying none of the libraries the codec gate denies. +# +# The lmsysorg/sglang base ships no PyNvVideoCodec today, so unlike the vLLM and +# TRT-LLM images there is no base copy to delete first -- but that is a property +# of the current base, not a guarantee, and --force-reinstall above would replace +# a base copy in place rather than leave two. What a base copy could still leave +# behind is a second version-stamped FFmpeg source tarball under +# /external/ffmpeg, which the wheel installs outside site-packages through +# a `../../../external/ffmpeg/src/ffmpeg-.tar.xz` RECORD entry. Checking +# the result is what keeps that honest; a comment claiming the base is clean +# would not. +# +# FLOOR is duplicated from requirements.sglang.txt on purpose -- this stage must +# not parse the file it is checking; tests/dependencies/test_pynvvideocodec_floor.py +# asserts the two agree. +RUN python3 - <<'PYEOF' +import csv +import glob +import os +import re +import sys +from importlib.metadata import distributions + +FLOOR = "2.2.3" +NAME = "pynvvideocodec" +# Mirrors the deny globs in container/compliance/policy/codec_policy.yaml. Kept as +# families rather than the four this package happens to have shed, so a future +# release vendoring libpostproc or libx264 is caught here -- beside the install +# that introduced it -- instead of as an unattributed scan violation later. +DENIED = ( + "libavcodec", + "libavdevice", + "libavfilter", + "libswscale", + "libswresample", + "libpostproc", + "libx264", + "libx265", + "libfdk-aac", +) + + +def canonical(name): + return re.sub(r"[-_.]+", "-", name or "").lower() + + +def parse(version): + """Compare on the numeric release only, and never raise. + + A version this cannot split (a release candidate, a dev build) must not kill + the build with a traceback where this block has its own message to print. + """ + parts = [int(x) for x in re.findall(r"\d+", version)[:3]] + return tuple(parts + [0] * (3 - len(parts))) + + +# Enumerated over sys.path rather than one scheme directory: these images carry +# both /usr/local/lib/python3.12/dist-packages and /usr/lib/python3/dist-packages, +# and the wheel declares Root-Is-Purelib: false, so neither purelib nor platlib +# alone is guaranteed to be the install target or the only place a copy can hide. +# A surviving base copy beside the new one is the failure being looked for, which +# is also why this counts distributions instead of asking for one version. +installed = [d for d in distributions() if canonical(d.metadata["Name"]) == NAME] +versions = sorted(d.version for d in installed) +print("PyNvVideoCodec distributions on sys.path:", versions) +if len(installed) != 1: + sys.exit(f"ERROR: expected exactly one PyNvVideoCodec, found {versions}") +if parse(versions[0]) < parse(FLOOR): + sys.exit(f"ERROR: PyNvVideoCodec {versions[0]} is below the {FLOOR} floor") + +site = os.path.normpath(str(installed[0].locate_file(""))) +pkg = os.path.join(site, "PyNvVideoCodec") +bundled = sorted( + os.path.relpath(p, pkg) + for p in glob.glob(os.path.join(pkg, "**", "lib*.so*"), recursive=True) +) +print("PyNvVideoCodec bundles:", bundled) +# Positive first, and on both libraries: an empty package directory satisfies +# every negative check below while shipping no demuxer at all. +for required in ("libavformat", "libavutil"): + if not any(os.path.basename(n).startswith(required) for n in bundled): + sys.exit( + f"ERROR: PyNvVideoCodec bundles no {required}, so the checks below " + f"would pass vacuously; found {bundled}" + ) +denied = [n for n in bundled if os.path.basename(n).startswith(DENIED)] +if denied: + sys.exit(f"ERROR: PyNvVideoCodec bundles libraries the codec gate denies: {denied}") + +# The FFmpeg source tarball lands outside site-packages, so its directory is read +# from the wheel's own RECORD rather than guessed from a sysconfig path -- the +# RECORD is what the installer actually wrote, and it moves if the layout does. +record = installed[0].read_text("RECORD") or "" +declared = [ + row[0] + for row in csv.reader(record.splitlines()) + if row and row[0].endswith((".tar.xz", ".tar.gz", ".tar.bz2")) +] +if len(declared) != 1: + sys.exit(f"ERROR: expected one source tarball in the RECORD, found {declared}") +external = os.path.dirname(os.path.normpath(os.path.join(site, declared[0]))) +tarballs = sorted(os.path.basename(p) for p in glob.glob(os.path.join(external, "ffmpeg-*.tar.*"))) +print("bundled FFmpeg source tarballs in", external, "->", tarballs) +if len(tarballs) != 1: + sys.exit(f"ERROR: expected exactly one bundled FFmpeg source tarball, found {tarballs}") +PYEOF {% else %} # mooncake and PyNvVideoCodec are CUDA-only. The mooncake floor names the CUDA 13 # distribution, and PyNvVideoCodec decodes on NVDEC through libnvcuvid, so both diff --git a/container/templates/trtllm_runtime.Dockerfile b/container/templates/trtllm_runtime.Dockerfile index 732c0d7d4713..3ebf91e481c2 100644 --- a/container/templates/trtllm_runtime.Dockerfile +++ b/container/templates/trtllm_runtime.Dockerfile @@ -320,23 +320,43 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p done; \ /usr/bin/python3 -c 'import nvidia.dali' -# Upgrade PyNvVideoCodec past the release that stopped shipping a separate -# libavcodec. rc24 is the first TensorRT-LLM base image to ship this package at -# all, and it ships 2.1.0, which bundles libavcodec, libavdevice, libavfilter, -# libswresample and libswscale alongside the libavformat and libavutil it -# actually uses. 2.2.0 ships only the latter two -- but libavcodec is not gone, -# it is statically linked into libavformat.so.62. That distinction drives the -# content check below: after the upgrade no file is named libavcodec*, so a -# filename test cannot see a future release that re-enables decoders inside -# libavformat. +# Replace the base image's PyNvVideoCodec with one past the release that stopped +# shipping a separate libavcodec. rc24 is the first TensorRT-LLM base image to +# ship this package at all, and rc24 through the currently pinned rc26 all ship +# 2.1.0, which bundles libavcodec, libavdevice, libavfilter, libswresample and +# libswscale alongside the libavformat and libavutil it actually uses. 2.2.x +# ships only the latter two -- but libavcodec is not gone, it is statically +# linked into libavformat. That distinction drives the content check below: +# after the upgrade no file is named libavcodec*, so a filename test cannot see +# a future release that re-enables decoders inside libavformat. +# +# The base copy is uninstalled explicitly first, then whatever is left is removed, +# rather than letting the install's own upgrade path handle it. Order matters and +# the obvious shortcut is wrong: the wheel's RECORD is the only complete list of +# what it wrote -- it reaches a top-level `samples/` and `benchmarks/` tree and an +# FFmpeg source tarball outside dist-packages (see the whiteout note in +# pre_runtime) -- so deleting the dist-info first would destroy that list and +# orphan those trees. The rm stays, after the uninstall, because it does not +# depend on the installer honouring its own metadata. +# +# The presence test after it is find_spec, not an import: PyNvVideoCodec's +# __init__ dlopens libnvidia-encode.so.1, which this builder does not have, so an +# import raises whether or not the wheel is installed and could never fail the +# build. Same reason the smoke test is omitted further down. # # This is a surface reduction, not a codec removal, and the difference from the # DALI block above matters because the two look identical. 2.1.0's libavcodec # registers exactly one decoder -- vp9 -- and no encoders: none of h264, hevc, -# aac, aac_fixed or aac_latm. That is by construction, not by luck: the vendor's -# own configure line ships in the image at /usr/local/external/ffmpeg/readme.txt -# and reads --disable-encoders --disable-decoders --enable-decoder=vp9. Measured -# with enumerate_bundled_decoders.py on both arches, and 2.2.0 drops even vp9. +# aac, aac_fixed or aac_latm. That is by construction, not by luck: 2.1.0's +# /usr/local/external/ffmpeg/readme.txt carries the vendor's own configure line, +# reading --disable-encoders --disable-decoders --enable-decoder=vp9. 2.2.x drops +# even vp9 -- its libavformat registers 0 decoders and 0 encoders, which is what +# enumerate_bundled_decoders.py below reports. +# +# Do not expect that configure line to still be in readme.txt. 2.2.3 replaced it +# with a three-line note that says "demux/mux only" and names the FFmpeg version, +# so the file no longer states which decoders were enabled. Read the libraries, +# not the readme. # # So no prohibited *decoder implementation* ships in either version -- but that # is a narrower statement than "nothing prohibited ships". --disable-decoders @@ -356,9 +376,18 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p # 2.1.0's libavcodec.so.61.3.100 and libavformat.so.61.1.100 embed "FFmpeg # version 7.0.2", and they are on the runtime path rather than inert: readelf -d # on PyNvVideoCodec_130.cpython-312-x86_64-linux-gnu.so lists libavformat.so.61, -# libavcodec.so.61, libswresample.so.5 and libavutil.so.59 as NEEDED. 2.2.0's -# libavformat.so.62.12.102 embeds "FFmpeg version 8.1.2", which is the floor -# deny_components sets for ffmpeg in codec_policy.yaml. 2.1.0 sits below it. +# libavcodec.so.61, libswresample.so.5 and libavutil.so.59 as NEEDED. 2.2.0 +# moved to FFmpeg 8.1.2 (libavformat.so.62.12.102 / libavutil.so.60.26.102) and +# 2.2.3 to FFmpeg 9.0.1 (libavformat.so.63.1.101 / libavutil.so.61.1.101), both +# at or above the floor deny_components sets for ffmpeg in codec_policy.yaml. +# 2.1.0 sits below it. +# +# The 2.2.3 SONAME bump is worth stating because it removes a hazard rather than +# adding one. The in-tree FFmpeg is 8.1.2, so through 2.2.2 the vendored +# libavformat.so.62 / libavutil.so.60 collided with it by SONAME while being +# built with different configure flags -- whichever loaded first served the whole +# process. 9.0.1's .so.63 / .so.61 cannot collide with .so.62 / .so.60, so each +# consumer now resolves its own copy regardless of import order. # # Note that the gate did not tell us this. deny_components is evaluated against # SBOM components (scan_codecs.scan_sbom), and the SBOM does not enumerate @@ -382,17 +411,21 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p # future version whose libavcodec is not, disarming the check that just fired. # Removing the file keeps the narrow waivers, and the tripwire, intact. # -# A floor rather than an exact pin, unlike DALI above: requirements.trtllm.txt -# already installs PyNvVideoCodec>=2.2.0 into /opt/dynamo/venv, and pinning the +# A range rather than an exact pin, unlike DALI above: requirements.trtllm.txt +# already installs PyNvVideoCodec>=2.2.3,<3 into /opt/dynamo/venv, and pinning the # system copy exactly would let the two diverge inside one image. Keep this -# specifier identical to the one there. Two assertions follow: a filename test +# specifier identical to the one there -- including the cap, which the shell +# guards check as a separate major-version test because `sort -V` can express a +# floor but not an upper bound; tests/dependencies/ +# test_pynvvideocodec_floor.py asserts that, and covers the vllm and sglang +# requirements files in the same pass. Two assertions follow: a filename test # that mirrors the codec gate's deny glob (so a reintroduced libavcodec fails # here, with a clear message, rather than later in the scan), and a content test # that enumerates what the shipped FFmpeg libraries actually register. # -# Reading the content test's output: for 2.2.0 it reports "0 decoders, 0 +# Reading the content test's output: for 2.2.x it reports "0 decoders, 0 # encoders" and prints every entry of its "expected present (sanity check)" -# block as ABSENT. That is the correct answer here, not a broken probe -- 2.2.0 +# block as ABSENT. That is the correct answer here, not a broken probe -- 2.2.x # registers no codecs at all, vp9 included. The probe is proved live a different # way: enumerate_bundled_decoders.py resolves av_codec_iterate through ctypes, # so a library that does not export it raises AttributeError and fails the @@ -404,16 +437,21 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p # This deliberately overrides a dependency of TensorRT-LLM's, which is why the # build log carries a pip resolver complaint here: # -# tensorrt-llm 1.3.0rc24 requires PyNvVideoCodec~=2.1.0, -# but you have pynvvideocodec 2.2.0 which is incompatible +# tensorrt-llm 1.3.0rc26 requires PyNvVideoCodec~=2.1.0, +# but you have pynvvideocodec 2.2.3 which is incompatible # # The complaint is expected and the override is deliberate. `~=2.1.0` excludes -# 2.2.0 by construction, and 2.1.0 is the version that bundles the libavcodec. -# tensorrt_llm/media/decoding.py is the only consumer; it uses CreateDemuxer, -# CreateDecoder, PyNvVCException and OutputColorType, all of which 2.2.0 still -# exports, and it imports PyNvVideoCodec function-locally so `import +# the whole 2.2 line by construction, and 2.1.0 is the version that bundles the +# libavcodec. tensorrt_llm/media/decoding.py is the only consumer; it uses +# CreateDemuxer, CreateDecoder, PyNvVCException and OutputColorType, all of which +# 2.2.3 still exports, and it imports PyNvVideoCodec function-locally so `import # tensorrt_llm` does not touch it. Re-check that list when this base image moves: -# if upstream relaxes the pin to allow 2.2.0, this note is the thing to delete. +# if upstream relaxes the pin to allow 2.2.x, this note is the thing to delete. +# +# Dynamo's own use is narrower still and unaffected by the one API 2.2.3 drops. +# common/multimodal/nvdec_decoder.py and the SGLang decoder construct +# SimpleDecoder and read frames; 2.2.3 removed SimpleDecoder.stop(), which +# neither calls. # # System interpreter for the same reason as the opencv removal and the DALI # upgrade above: with VIRTUAL_ENV set, plain pip targets the venv and leaves the @@ -433,20 +471,45 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p echo " requirements.trtllm.txt is the one that matters." >&2; \ exit 1; \ fi; \ - newest=$(printf '%s\n2.2.0\n' "$before" | sort -V | tail -1); \ - if [ "$newest" != "2.2.0" ]; then \ + newest=$(printf '%s\n2.2.3\n' "$before" | sort -V | tail -1); \ + if [ "$newest" != "2.2.3" ]; then \ echo "ERROR: base image already carries PyNvVideoCodec $before, so this block" >&2; \ echo " is obsolete -- delete it and let the base version stand. Keep the" >&2; \ echo " libavcodec assertion below and the post-overlay one in" >&2; \ echo " pre_runtime, which are what stop this from regressing." >&2; \ exit 1; \ fi; \ + /usr/bin/python3 -m pip uninstall --break-system-packages --yes pynvvideocodec; \ + rm -rf /usr/local/lib/python3.12/dist-packages/PyNvVideoCodec \ + /usr/local/lib/python3.12/dist-packages/pynvvideocodec* \ + /usr/local/external/ffmpeg; \ + if /usr/bin/python3 -c 'import importlib.util, sys; sys.exit(0 if importlib.util.find_spec("PyNvVideoCodec") else 1)'; then \ + echo "ERROR: PyNvVideoCodec is still importable after the removal above; the" >&2; \ + echo " base ships it somewhere these paths do not reach." >&2; \ + exit 1; \ + fi; \ /usr/bin/python3 -m pip install --break-system-packages --no-cache-dir \ - 'PyNvVideoCodec>=2.2.0'; \ + 'PyNvVideoCodec>=2.2.3,<3'; \ v=$(/usr/bin/python3 -c 'import importlib.metadata as m; print(m.version("pynvvideocodec"))'); \ echo "PyNvVideoCodec version: $v"; \ - [ "$(printf '%s\n2.2.0\n' "$v" | sort -V | head -1)" = "2.2.0" ] \ - || { echo "ERROR: wanted PyNvVideoCodec >= 2.2.0, got $v" >&2; exit 1; }; \ + [ "$(printf '%s\n2.2.3\n' "$v" | sort -V | head -1)" = "2.2.3" ] \ + || { echo "ERROR: wanted PyNvVideoCodec >=2.2.3,<3, got $v" >&2; exit 1; }; \ + case "$v" in 2.*) ;; *) \ + echo "ERROR: PyNvVideoCodec $v is outside the >=2.2.3,<3 range that" >&2; \ + echo " requirements.trtllm.txt declares. A sort -V test can express" >&2; \ + echo " the floor but not the cap, so the major is checked separately." >&2; \ + exit 1;; esac; \ + dists=$(find /usr/local/lib/python3.12/dist-packages -maxdepth 1 \ + -name 'pynvvideocodec-*.dist-info' | wc -l); \ + [ "$dists" -eq 1 ] \ + || { echo "ERROR: expected exactly one PyNvVideoCodec in system site, found $dists:" >&2; \ + find /usr/local/lib/python3.12/dist-packages -maxdepth 1 \ + -name 'pynvvideocodec-*.dist-info' >&2; exit 1; }; \ + tarballs=$(find /usr/local/external/ffmpeg -name 'ffmpeg-*.tar.*' 2>/dev/null | wc -l); \ + [ "$tarballs" -eq 1 ] \ + || { echo "ERROR: expected exactly one bundled FFmpeg source tarball after the" >&2; \ + echo " removal and reinstall, found $tarballs:" >&2; \ + find /usr/local/external/ffmpeg -name 'ffmpeg-*.tar.*' >&2; exit 1; }; \ if find /usr/local/lib/python3.12/dist-packages/PyNvVideoCodec -name 'libavcodec*' | grep -q .; then \ echo "ERROR: PyNvVideoCodec $v still bundles a libavcodec:" >&2; \ find /usr/local/lib/python3.12/dist-packages/PyNvVideoCodec -name 'libavcodec*' >&2; \ @@ -473,10 +536,13 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p echo " --system-site-packages, so the interpreter would resolve the" >&2; \ echo " system copy this stage just upgraded and always look correct." >&2; \ exit 1; }; \ - [ "$(printf '%s\n2.2.0\n' "$vv" | sort -V | head -1)" = "2.2.0" ] \ - || { echo "ERROR: venv PyNvVideoCodec $vv is below the 2.2.0 floor while the" >&2; \ + [ "$(printf '%s\n2.2.3\n' "$vv" | sort -V | head -1)" = "2.2.3" ] \ + || { echo "ERROR: venv PyNvVideoCodec $vv is below the 2.2.3 floor while the" >&2; \ echo " system copy is $v -- the two specifiers have drifted apart." >&2; \ echo " requirements.trtllm.txt and this stage must stay in step." >&2; exit 1; }; \ + case "$vv" in 2.*) ;; *) \ + echo "ERROR: venv PyNvVideoCodec $vv is outside >=2.2.3,<3 while the system" >&2; \ + echo " copy is $v -- the two specifiers have drifted apart." >&2; exit 1;; esac; \ else \ echo "NOTE: no /opt/dynamo/venv in this stage yet, so the venv/system"; \ echo " cross-check is skipped. Expected for the dev and local-dev"; \ @@ -561,28 +627,35 @@ FROM ${RUNTIME_IMAGE}:${RUNTIME_IMAGE_TAG} AS pre_runtime # # PyNvVideoCodec is here for the DALI reason too, and it is the case the codec # scan actually catches: the scan runs on this stage (compliance_base_stage is -# pre_runtime), not on runtime_full. runtime_full UPGRADES the package, and an -# upgrade is a deletion plus an install -- 2.1.0's libavcodec/libavdevice/ -# libavfilter/libswresample/libswscale have no counterpart in 2.2.0, so the +# pre_runtime), not on runtime_full. runtime_full REPLACES the package, and a +# replacement is a deletion plus an install -- 2.1.0's libavcodec/libavdevice/ +# libavfilter/libswresample/libswscale have no counterpart in 2.2.x, so the # overlay COPY has nothing to write over them and the base image's copies would # come straight back. Without this entry the upgrade buys nothing and the scan # fails exactly as it did before. The version-stamped dist-info is renamed by the -# upgrade (pynvvideocodec-2.1.0.dist-info -> -2.2.0.dist-info), so the glob takes +# upgrade (pynvvideocodec-2.1.0.dist-info -> -2.2.3.dist-info), so the glob takes # that and the stray `pynvvideocodec.` entry beside it. # +# The SONAMEs move too, which is why the deletion cannot be narrowed to a version +# glob: 2.1.0 ships libavformat.so.61 / libavutil.so.59 (FFmpeg 7.0.2) and 2.2.3 +# ships libavformat.so.63 / libavutil.so.61 (FFmpeg 9.0.1). The overlay writes +# over the two bare `libav*.so` symlinks and nothing else -- every versioned file +# the base carries, and the whole libavcodec/libavdevice/libavfilter/libsw* +# families, would survive it. +# # /usr/local/external/ffmpeg is the same package, in a place that is easy to # miss: PyNvVideoCodec's wheel installs an FFmpeg source tarball through a # `../../../external/ffmpeg/src/ffmpeg-.tar.xz` RECORD entry, so it # lands OUTSIDE dist-packages and the two globs above do not reach it. It is # version-stamped, so this is the aiohttp rename problem again rather than the -# opencv one: runtime_full's pip upgrade removes ffmpeg-7.0.2.tar.xz and writes -# ffmpeg-8.1.2.tar.xz, the overlay COPY cannot express that deletion, and the +# opencv one: runtime_full removes ffmpeg-7.0.2.tar.xz and writes +# ffmpeg-9.0.1.tar.xz, the overlay COPY cannot express that deletion, and the # image would ship BOTH. # # Keep the scope of that honest: a source tarball is inert, nothing on the # runtime path reads it, and the exposure discussed above is in the shipped .so # files, not here. This entry buys ~10.8 MB and a tree that matches what -# actually ships -- a stale 7.0.2 source sitting beside 8.1.2 binaries invites a +# actually ships -- a stale 7.0.2 source sitting beside 9.0.1 binaries invites a # future reader to conclude the wrong thing about either. The codec scan cannot # arbitrate: its deny globs match libav*.so*/libsw*.so*, never a .tar.xz, and # allow_paths lists /usr/local/src/ffmpeg (our in-tree build), not this path. diff --git a/container/templates/vllm_runtime.Dockerfile b/container/templates/vllm_runtime.Dockerfile index 51d923226ef9..910e272d8245 100644 --- a/container/templates/vllm_runtime.Dockerfile +++ b/container/templates/vllm_runtime.Dockerfile @@ -395,6 +395,63 @@ RUN set -eu; \ fi {% endif %} +{% if device == "cuda" %} +# Delete the base image's PyNvVideoCodec before the install below replaces it. +# vllm/vllm-openai:v0.28.0-ubuntu2404 ships 2.0.4, which bundles a complete +# FFmpeg 8.1.1 -- libavcodec.so.62 plus libavdevice/libavfilter/libswscale/ +# libswresample -- and the codec gate denies a wheel-vendored libavcodec on +# presence. +# +# Uninstall FIRST, then rm what is left. The order matters and the obvious +# shortcut is wrong: the wheel's RECORD is the only complete list of what it +# installed, and it reaches further than the package directory -- top-level +# `samples/` and `benchmarks/` trees (41 entries in 2.0.4) and an FFmpeg source +# tarball written outside site-packages through a +# `../../../external/ffmpeg/src/ffmpeg-.tar.xz` entry. Deleting the +# dist-info first destroys that list, so a bare `rm -rf` of the package +# directory would leave those trees behind as orphans no later install owns. +# The rm is still here, after the uninstall, because it is the part that does +# not depend on the installer honouring its own metadata, and because it is what +# makes the intent legible at the point of use. +# +# `pynvvideocodec*` takes both the version-stamped dist-info and the stray +# `pynvvideocodec.` directory the wheel installs beside it (it holds a vendored +# libcudart, not a codec). +# +# Fails when the base stops shipping the package rather than quietly removing +# nothing: a no-op rm is indistinguishable from one whose paths went stale, and +# either way this block is what has to be revisited. +# +# The presence test is find_spec, not an import: PyNvVideoCodec's __init__ dlopens +# libnvidia-encode.so.1, which no builder has, so `import PyNvVideoCodec` raises +# whether or not the wheel is installed and could never fail this build -- the +# same reason the non-CUDA branch below uses find_spec. +RUN set -eu; \ + purelib="$(python3 -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"; \ + data="$(python3 -c 'import sysconfig; print(sysconfig.get_paths()["data"])')"; \ + before="$(python3 -c 'import importlib.metadata as m; print(m.version("pynvvideocodec"))' 2>/dev/null || echo none)"; \ + echo "PyNvVideoCodec in base image: ${before}"; \ + if [ "${before}" = "none" ]; then \ + echo "ERROR: the vllm-openai base no longer ships PyNvVideoCodec, so this" >&2; \ + echo " removal has nothing to act on -- delete this RUN and keep the" >&2; \ + echo " requirements install below, which is then the only copy." >&2; \ + exit 1; \ + fi; \ + python3 -m pip uninstall --yes pynvvideocodec; \ + rm -rf "${purelib}/PyNvVideoCodec" "${purelib}"/pynvvideocodec* "${data}/external/ffmpeg"; \ + if python3 -c 'import importlib.util, sys; sys.exit(0 if importlib.util.find_spec("PyNvVideoCodec") else 1)'; then \ + echo "ERROR: PyNvVideoCodec is still importable after the removal above; the" >&2; \ + echo " base ships it somewhere these paths do not reach." >&2; \ + exit 1; \ + fi; \ + leftover="$(find "${data}/external" -name 'ffmpeg-*.tar.*' 2>/dev/null || true)"; \ + if [ -n "${leftover}" ]; then \ + echo "ERROR: a bundled FFmpeg source tarball survived the removal:" >&2; \ + echo "${leftover}" >&2; \ + exit 1; \ + fi +{% endif %} + # Replace the upstream vllm/vllm-openai 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. On cuda, IMAGEIO_FFMPEG_EXE @@ -418,6 +475,110 @@ RUN --mount=type=bind,source=./container/deps/requirements.vllm.txt,target=/tmp/ --reinstall-package imageio-ffmpeg --reinstall-package PyNvVideoCodec \ --no-deps --requirement /tmp/requirements.vllm.txt && \ rm -rf /opt/uv/cache + +# Assert what the removal and the install together left on disk. The floor in +# requirements.vllm.txt is a lower bound, so it cannot state "exactly one copy" +# or "no libavcodec" -- and those are the two properties the codec gate depends +# on. Checked here, beside the install, so a regression names its cause instead +# of surfacing as an unexplained scan violation several stages later. +# +# Reads the dist-info directories rather than importlib.metadata: a surviving +# base copy beside the new one is exactly the failure being looked for, and the +# metadata API answers with whichever one it resolves first. FLOOR is duplicated +# from requirements.vllm.txt on purpose -- this stage must not parse the file it +# is checking; tests/dependencies/test_pynvvideocodec_floor.py asserts the two +# agree, and covers the sglang and trtllm copies in the same way. +RUN python3 - <<'PYEOF' +import csv +import glob +import os +import re +import sys +from importlib.metadata import distributions + +FLOOR = "2.2.3" +NAME = "pynvvideocodec" +# Mirrors the deny globs in container/compliance/policy/codec_policy.yaml. Kept as +# families rather than the four this package happens to have shed, so a future +# release vendoring libpostproc or libx264 is caught here -- beside the install +# that introduced it -- instead of as an unattributed scan violation later. +DENIED = ( + "libavcodec", + "libavdevice", + "libavfilter", + "libswscale", + "libswresample", + "libpostproc", + "libx264", + "libx265", + "libfdk-aac", +) + + +def canonical(name): + return re.sub(r"[-_.]+", "-", name or "").lower() + + +def parse(version): + """Compare on the numeric release only, and never raise. + + A version this cannot split (a release candidate, a dev build) must not kill + the build with a traceback where this block has its own message to print. + """ + parts = [int(x) for x in re.findall(r"\d+", version)[:3]] + return tuple(parts + [0] * (3 - len(parts))) + + +# Enumerated over sys.path rather than one scheme directory: these images carry +# both /usr/local/lib/python3.12/dist-packages and /usr/lib/python3/dist-packages, +# and the wheel declares Root-Is-Purelib: false, so neither purelib nor platlib +# alone is guaranteed to be the install target or the only place a copy can hide. +# A surviving base copy beside the new one is the failure being looked for, which +# is also why this counts distributions instead of asking for one version. +installed = [d for d in distributions() if canonical(d.metadata["Name"]) == NAME] +versions = sorted(d.version for d in installed) +print("PyNvVideoCodec distributions on sys.path:", versions) +if len(installed) != 1: + sys.exit(f"ERROR: expected exactly one PyNvVideoCodec, found {versions}") +if parse(versions[0]) < parse(FLOOR): + sys.exit(f"ERROR: PyNvVideoCodec {versions[0]} is below the {FLOOR} floor") + +site = os.path.normpath(str(installed[0].locate_file(""))) +pkg = os.path.join(site, "PyNvVideoCodec") +bundled = sorted( + os.path.relpath(p, pkg) + for p in glob.glob(os.path.join(pkg, "**", "lib*.so*"), recursive=True) +) +print("PyNvVideoCodec bundles:", bundled) +# Positive first, and on both libraries: an empty package directory satisfies +# every negative check below while shipping no demuxer at all. +for required in ("libavformat", "libavutil"): + if not any(os.path.basename(n).startswith(required) for n in bundled): + sys.exit( + f"ERROR: PyNvVideoCodec bundles no {required}, so the checks below " + f"would pass vacuously; found {bundled}" + ) +denied = [n for n in bundled if os.path.basename(n).startswith(DENIED)] +if denied: + sys.exit(f"ERROR: PyNvVideoCodec bundles libraries the codec gate denies: {denied}") + +# The FFmpeg source tarball lands outside site-packages, so its directory is read +# from the wheel's own RECORD rather than guessed from a sysconfig path -- the +# RECORD is what the installer actually wrote, and it moves if the layout does. +record = installed[0].read_text("RECORD") or "" +declared = [ + row[0] + for row in csv.reader(record.splitlines()) + if row and row[0].endswith((".tar.xz", ".tar.gz", ".tar.bz2")) +] +if len(declared) != 1: + sys.exit(f"ERROR: expected one source tarball in the RECORD, found {declared}") +external = os.path.dirname(os.path.normpath(os.path.join(site, declared[0]))) +tarballs = sorted(os.path.basename(p) for p in glob.glob(os.path.join(external, "ffmpeg-*.tar.*"))) +print("bundled FFmpeg source tarballs in", external, "->", tarballs) +if len(tarballs) != 1: + sys.exit(f"ERROR: expected exactly one bundled FFmpeg source tarball, found {tarballs}") +PYEOF {% else %} # PyNvVideoCodec decodes on NVDEC through libnvcuvid, so it is inert on a # non-NVIDIA device. Drop it from the shared requirements rather than ship an @@ -507,14 +668,31 @@ RUN set -eux; \ ! ls -d "${SITE_PACKAGES}"/opencv_python*.libs 2>/dev/null; \ python3 -c "import cv2,re,sys; enabled=[name for name,value in re.findall(r'^\s*(FFMPEG|GSTREAMER):\s*(\S+)', cv2.getBuildInformation(), re.M|re.I) if value.upper()=='YES']; sys.exit('ERROR: cv2 was built with video backends: '+', '.join(enabled) if enabled else 0)" -# PyNvVideoCodec is KEPT (removed from the purge above) but UPGRADED to >=2.2.0 by -# the requirements install: the base image's 2.0.4 bundles a full FFmpeg (incl. -# libavcodec) that the codec gate rejects, while 2.2.0 bundles only libavutil + -# libavformat (container demux, no software codec). It provides the built-in -# H.264/H.265 video-input path (NVDEC hardware decode via libnvcuvid; +# PyNvVideoCodec is KEPT (removed from the purge above) but REPLACED at >=2.2.3 by +# the removal and requirements install above: the base image's 2.0.4 bundles a +# full FFmpeg 8.1.1 (incl. libavcodec) that the codec gate rejects, while 2.2.3 +# bundles only libavutil.so.61 + libavformat.so.63 (FFmpeg 9.0.1, container +# demux, no software codec). It provides the built-in H.264/H.265 video-input +# path (NVDEC hardware decode via libnvcuvid; # common/multimodal/nvdec_decoder.py) and requires the driver "video" capability # at runtime or it cannot import; set it in the image and ensure the K8s # pod/runtimeClass does not drop it. +# +# This deliberately overrides a dependency of vLLM's: `importlib.metadata.requires +# ("vllm")` lists `PyNvVideoCodec==2.0.4`, an exact pin, and the image ships 2.2.3 +# against it. Unlike the equivalent override in trtllm_runtime.Dockerfile, do not +# expect a resolver complaint in the build log to confirm it -- that stage runs +# real `pip`, which prints one; this one runs `uv pip install --no-deps`, which +# does not. `uv pip check` on the finished image is what surfaces it. +# +# The override is deliberate: 2.0.4 is the version whose bundled libavcodec the +# codec gate rejects, so the override is the point. vLLM's own consumer is +# vllm/multimodal/video.py (PyNvVideoCodecVideoBackendMixin), and everything it +# touches -- SimpleDecoder, OutputColorType, get_batch_frames_by_index, +# get_stream_metadata, reconfigure_decoder -- still exists in 2.2.3. The one API +# 2.2.3 drops is SimpleDecoder.stop(), which neither vLLM nor Dynamo calls; +# `get_frames_at` in that file belongs to the torchcodec backend, not this +# package. Re-check that list when the base image moves. ENV NVIDIA_DRIVER_CAPABILITIES=video,compute,utility {% endif %} From a18618ff231b2deecb57b107c85779cecb410050 Mon Sep 17 00:00:00 2001 From: Dmitry Tokarev Date: Wed, 16 Sep 2026 14:39:06 -0400 Subject: [PATCH 2/8] test(container): pin the PyNvVideoCodec spec across every place it is written The spec is written in three requirements files; the TRT-LLM template installs a system-site copy with its own inline specifier, and all three templates repeat the lower bound as a guard constant. The templates cannot parse the requirements file they are checking, since the point of those guards is to fail when the installed version and the declared one disagree, so the duplication is deliberate and needs a test rather than a comment. Three things are pinned. The whole specifier in each requirements file; the inline specifier in any template that installs the package, which is what catches a template left at a floor while the requirements file gained a cap; and every version literal the shell guards compare against. The last of those is mutation-checked -- each of the ten floor-bearing lines individually fails the suite. Two only became reachable after the scan was widened: a shell guard's two sides sit on different lines (`printf '%s\n2.2.3\n'` and `[ "$newest" != "2.2.3" ]`), and pinning only the side carrying the package name let a half-finished bump invert the comparison. Diagnostic lines are excluded, because that is where versions the guard is deliberately not comparing against appear. `test_the_spec_is_bounded_above` is kept deliberately, though it looks like a weaker subset of the exact-spec cases. It is the only one that survives an edit which drops the cap and updates EXPECTED_SPEC to match: mutating all three requirements files and every constant self-consistently leaves six cases green and fails only that one. .dockerignore keeps `container/templates/*.Dockerfile` out of every build context, so the component images that run this suite have the requirements files but not the templates; those cases skip there, as `test_aisimulate_consistency` already does for the planner Dockerfile. The `pynvvideocodec-spec` pre-commit hook runs the module as a script against a real checkout, so the template half stays enforced in CI rather than only on a developer's machine. The codec-gate waiver fixture moves to the SONAMEs the shipped floor bundles (.so.61/.so.63); it had been written against .so.60/.so.62, which no version at or above the floor ships. One series is enough: the waiver globs are SONAME-agnostic, and pinning either of them to any single series unwaives the bare `.so` symlinks and fails the fixture whichever series it names. Co-Authored-By: Claude Opus 5 Signed-off-by: Dmitry Tokarev --- .pre-commit-config.yaml | 7 + .../tests/multimodal/test_nvdec_decoder.py | 2 +- .../compliance/tests/test_scan_codecs.py | 13 +- .../dependencies/test_pynvvideocodec_floor.py | 303 ++++++++++++++++++ 4 files changed, 320 insertions(+), 5 deletions(-) create mode 100644 tests/dependencies/test_pynvvideocodec_floor.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86ab70bbea98..c76c0f1ab20d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -204,6 +204,13 @@ repos: language: system files: ^scripts/check_action_pins\.py$ pass_filenames: false + - id: pynvvideocodec-spec + name: Check the PyNvVideoCodec spec is consistent + entry: python3 tests/dependencies/test_pynvvideocodec_floor.py + language: python + additional_dependencies: [packaging, pytest] + pass_filenames: false + always_run: true - id: validate-agent-instructions name: Validate AGENTS.md and CLAUDE.md pairs entry: python3 scripts/validate_agent_instructions.py diff --git a/components/src/dynamo/common/tests/multimodal/test_nvdec_decoder.py b/components/src/dynamo/common/tests/multimodal/test_nvdec_decoder.py index 04e0700ef34f..4a8e4dcccb20 100644 --- a/components/src/dynamo/common/tests/multimodal/test_nvdec_decoder.py +++ b/components/src/dynamo/common/tests/multimodal/test_nvdec_decoder.py @@ -302,7 +302,7 @@ def __init__(self, meta=None, **attrs): def test_source_fps_prefers_stream_metadata(): - """PyNvVideoCodec 2.2.0 reports the rate via get_stream_metadata().""" + """PyNvVideoCodec reports the rate via get_stream_metadata().""" dec = _Decoder(meta=_Meta(average_fps=10.0), get_fps=lambda: 30.0) assert nd._source_fps(dec) == 10.0 diff --git a/container/compliance/tests/test_scan_codecs.py b/container/compliance/tests/test_scan_codecs.py index eda99393d1d2..3c137f9f76ee 100644 --- a/container/compliance/tests/test_scan_codecs.py +++ b/container/compliance/tests/test_scan_codecs.py @@ -68,17 +68,22 @@ def test_pynvvideocodec_demux_libs_waived_but_codec_denied(tmp_path: Path): # PyNvVideoCodec vendors libavutil + libavformat (container demux, no codec) # for its NVDEC path — waived. The exception is scoped to those two libs, so a # libavcodec bundled by a future version must STILL fail the gate. + # + # One internally consistent SONAME series is enough to keep the globs honest. + # They are SONAME-agnostic (`libav*.so*`) and must also cover the bare `.so` + # symlinks, so pinning either glob to any single series unwaives those and + # fails here regardless of which series this fixture names. pkg = "usr/local/lib/python3.12/dist-packages/PyNvVideoCodec" for rel in ( - f"{pkg}/libavutil.so.60.26.102", + f"{pkg}/libavutil.so.61.1.101", f"{pkg}/libavutil.so", - f"{pkg}/libavformat.so.62", + f"{pkg}/libavformat.so.63", f"{pkg}/libavformat.so", ): _touch(tmp_path, rel) - _touch(tmp_path, f"{pkg}/libavcodec.so.62") # a real codec must NOT be waived + _touch(tmp_path, f"{pkg}/libavcodec.so.63") # a real codec must NOT be waived violations, exceptions, _allowed = scan_filesystem(tmp_path, _POLICY) - assert [v["path"] for v in violations] == [f"/{pkg}/libavcodec.so.62"] + assert [v["path"] for v in violations] == [f"/{pkg}/libavcodec.so.63"] assert len(exceptions) == 4 assert all("PyNvVideoCodec" in (e["detail"] or "") for e in exceptions) diff --git a/tests/dependencies/test_pynvvideocodec_floor.py b/tests/dependencies/test_pynvvideocodec_floor.py new file mode 100644 index 000000000000..ed7fe069a75d --- /dev/null +++ b/tests/dependencies/test_pynvvideocodec_floor.py @@ -0,0 +1,303 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Keep the PyNvVideoCodec spec identical everywhere it is written down. + +The spec is written in three requirements files, and its lower bound is repeated +as a guard constant in each of the three runtime templates. The templates cannot +parse the requirements file they are checking, since the point of those guards is +to fail when the installed version and the declared one disagree, so the +duplication is deliberate and needs a test rather than a comment. + +A lower bound resolves to whatever is newest at build time, so on its own it +records nothing: the spec read `>=2.2.0` while the images had already moved to +2.2.3, and no check could tell. The declared version is a claim about what was +validated, and this is what keeps it one. + +The template half only runs where the templates are on disk. `.dockerignore` +excludes `container/**/*.Dockerfile` from every build context except +`wheel_builder.Dockerfile`, so the component images that run this suite ship +`container/deps/` but not `container/templates/`, and those cases skip there -- +same situation, and the same resolution, as `test_aisimulate_consistency.py`'s +planner-Dockerfile check. The `pynvvideocodec-spec` pre-commit hook runs this +module as a script against a real checkout, which is what keeps the template half +enforced in CI rather than only on a developer's machine. +""" + +import re +from pathlib import Path + +import pytest +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.utils import canonicalize_name + +pytestmark = [ + pytest.mark.pre_merge, + pytest.mark.gpu_0, + pytest.mark.unit, + pytest.mark.parallel, +] + +ROOT = Path(__file__).resolve().parents[2] + +# The spec every image installs. The lower bound is the version the multimodal +# suite was validated against; the upper bound is what stops the next major being +# taken silently at build time. +EXPECTED_SPEC = ">=2.2.3,<3" +# The lower bound on its own -- this is what the template guards compare against, +# since a shell `sort -V` test cannot express a range. +EXPECTED_FLOOR = "2.2.3" + +DISTRIBUTION = "pynvvideocodec" + +REQUIREMENTS = ( + "container/deps/requirements.vllm.txt", + "container/deps/requirements.sglang.txt", + "container/deps/requirements.trtllm.txt", +) + +TEMPLATES = ( + "container/templates/vllm_runtime.Dockerfile", + "container/templates/sglang_runtime.Dockerfile", + "container/templates/trtllm_runtime.Dockerfile", +) + +_SEMVER = re.compile(r"\b\d+\.\d+\.\d+\b") +_HEREDOC = re.compile(r"<<-?'?([A-Za-z_][A-Za-z0-9_]*)'?") +# A real Dockerfile instruction, used to bound an unterminated heredoc. Spelled out +# rather than matched as "an uppercase word": the heredoc bodies assign uppercase +# Python constants (`FLOOR = "2.2.3"`), and a pattern that treats those as a new +# instruction cuts the body off exactly where the constant being checked lives. +_INSTRUCTION = re.compile( + r"^(FROM|RUN|CMD|LABEL|MAINTAINER|EXPOSE|ENV|ADD|COPY|ENTRYPOINT|VOLUME" + r"|USER|WORKDIR|ARG|ONBUILD|STOPSIGNAL|HEALTHCHECK|SHELL)\s" +) +# Diagnostic output does not constrain a guard -- it describes one, and it is where +# historical versions legitimately appear ("the base image's 2.1.0 copy survived the +# overlay"). Only the lines that compare or install are held to the floor. +_DIAGNOSTIC = re.compile(r'^\s*(\|\|\s*\{\s*)?(echo\b|print\(|sys\.exit\(|f?")') +# A PyNvVideoCodec requirement specifier written inline in a template, e.g. the +# `pip install 'PyNvVideoCodec>=2.2.3,<3'` in trtllm_runtime.Dockerfile. +_INLINE_SPEC = re.compile( + r"(?i)pynvvideocodec\s*((?:[<>=!~]=?\s*[0-9][^\'\"\s;]*)(?:\s*,\s*[<>=!~]=?\s*[0-9][^\'\"\s;]*)*)" +) + + +def _instruction_blocks(text: str) -> list[list[str]]: + """Split a Dockerfile into instructions, keeping continuations together. + + Handles both shapes the templates use: backslash-continued commands and + BuildKit heredocs. Matching has to be per instruction, not per file, because + the ``sort -V`` comparison idiom the TRT-LLM guard uses is also how the DALI + pin in the same file is written -- a file-wide scan reads DALI's version as a + PyNvVideoCodec floor and fails for the wrong reason. + + Comment lines are excluded from both the continuation and the heredoc tests: a + comment that quotes ``<<'PYEOF'`` or ends in a backslash would otherwise swallow + the instructions after it into one block and bring that cross-contamination + straight back. An unterminated heredoc stops at the next line that starts a new + instruction, for the same reason. + """ + lines = text.splitlines() + blocks: list[list[str]] = [] + i = 0 + while i < len(lines): + start = i + + def is_comment(n: int) -> bool: + return lines[n].lstrip().startswith("#") + + heredoc = None if is_comment(i) else _HEREDOC.search(lines[i]) + while ( + not is_comment(i) + and lines[i].rstrip().endswith("\\") + and i + 1 < len(lines) + ): + i += 1 + if not is_comment(i): + heredoc = heredoc or _HEREDOC.search(lines[i]) + if heredoc: + tag = heredoc.group(1) + while i + 1 < len(lines) and lines[i].strip() != tag: + # An unterminated heredoc must not run to the end of the file. + if i > start and _INSTRUCTION.match(lines[i + 1]): + break + i += 1 + blocks.append(lines[start : i + 1]) + i += 1 + return blocks + + +def _requirement(path: str) -> Requirement: + """Parse the one PyNvVideoCodec line, tolerating an inline `#` comment. + + Inline comments are the prevailing style in these files (`pillow>=12.3.0,<13 + # bounded: ...` sits three lines away), and `Requirement()` rejects them, so + stripping first is what keeps a future annotation from turning this into an + opaque parse error. Selection is by canonical distribution name rather than + by prefix, so a differently-named sibling cannot be mistaken for this one. + """ + found: list[Requirement] = [] + for raw in (ROOT / path).read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].strip() + if not line or line.startswith("-"): + continue + try: + requirement = Requirement(line) + except Exception: # noqa: BLE001 - not a requirement line + continue + if canonicalize_name(requirement.name) == DISTRIBUTION: + found.append(requirement) + assert len(found) == 1, f"expected one PyNvVideoCodec line in {path}, got {found}" + return found[0] + + +def _template_floor_versions(path: str) -> set[str]: + """Every version literal the PyNvVideoCodec instructions in `path` compare against. + + Every semver on a comparing or installing line inside an instruction that names + PyNvVideoCodec is collected, not just the ones adjacent to the package name. The + guards are shell tests whose two sides sit on different lines -- + `newest=$(printf '%s\\n2.2.3\\n' ...)` and `if [ "$newest" != "2.2.3" ]` -- and + pinning only the side that happens to carry the package name lets a half-finished + bump invert the comparison: the guard then accepts the stale version and rejects + the new one, with the test still green. + + Diagnostic lines are excluded, because that is where versions the guard is *not* + comparing against legitimately appear -- the post-overlay check names the base + image's 2.1.0 in its error text, and holding that to the floor would force an + unrelated rewrite of the message on every bump. + """ + versions: set[str] = set() + for block in _instruction_blocks((ROOT / path).read_text(encoding="utf-8")): + code = [line for line in block if not line.lstrip().startswith("#")] + if not any(DISTRIBUTION in line.lower() for line in code): + continue + for line in code: + if _DIAGNOSTIC.match(line): + continue + # `printf '%s\n2.2.3\n'` is the shape of the shell comparisons, and the + # literal backslash-n leaves no word boundary in front of the version. + # Without flattening the escape first, that constant matches nothing here + # and is silently unpinned. + versions.update(_SEMVER.findall(line.replace("\\n", " "))) + return versions + + +def _template_install_specs(path: str) -> list[str]: + """Requirement specifiers a template installs PyNvVideoCodec with.""" + specs: list[str] = [] + for line in (ROOT / path).read_text(encoding="utf-8").splitlines(): + if line.lstrip().startswith("#") or _DIAGNOSTIC.match(line): + continue + specs += _INLINE_SPEC.findall(line) + return specs + + +@pytest.mark.parametrize("path", REQUIREMENTS) +def test_requirements_declare_the_expected_spec(path: str) -> None: + assert _requirement(path).specifier == SpecifierSet(EXPECTED_SPEC) + + +def test_the_spec_is_bounded_above() -> None: + """`container/deps/README.md`: "Never use `>=`" -- it stops pinning anything. + + Distinct from the exact-spec cases above, which move with EXPECTED_SPEC: this + one survives an edit that drops the cap and updates that constant to match. + """ + specifier = _requirement(REQUIREMENTS[0]).specifier + assert any( + s.operator in ("<", "<=", "==", "~=") for s in specifier + ), f"PyNvVideoCodec is specified as {specifier}, which has no upper bound" + + +def test_templates_install_the_same_spec() -> None: + """A template that installs PyNvVideoCodec must use the declared specifier. + + The TRT-LLM image installs a system-site copy of its own, beside the venv copy + `requirements.trtllm.txt` provides, and its comment says the two specifiers + must stay identical. Nothing enforced that: the floor scan below reads lower + bounds only, so a template left at `>=2.2.3` while the requirements file moved + to `>=2.2.3,<3` would pass, and a 3.x release would then land in system site + while the venv stayed on 2.x. + + One test over all templates rather than one per template: only TRT-LLM carries + an inline specifier today, so a per-template parametrization would report two + cases that assert nothing. Scanning all three still matters -- a specifier + added to another template has to be caught -- so the scan is asserted to find + at least one, which is what stops it going quietly blind. + """ + staged = [path for path in TEMPLATES if (ROOT / path).is_file()] + if not staged: + pytest.skip("no runtime templates are staged in this component image") + found = {path: _template_install_specs(path) for path in staged} + present = {path: specs for path, specs in found.items() if specs} + assert present, f"no template installs PyNvVideoCodec any more: {found}" + for path, specs in present.items(): + for spec in specs: + assert SpecifierSet(spec) == SpecifierSet( + EXPECTED_SPEC + ), f"{path} installs {spec}" + + +@pytest.mark.parametrize("path", TEMPLATES) +def test_templates_guard_on_the_same_floor(path: str) -> None: + """Every floor constant in a template must equal the declared lower bound. + + Deliberately an assertion over the whole set, not "the floor appears + somewhere": a guard still comparing against a superseded version passes that + weaker check while letting through exactly the drift this file exists to catch. + """ + template = ROOT / path + if not template.is_file(): + pytest.skip(f"{path} is not staged in this component image (.dockerignore)") + versions = _template_floor_versions(path) + assert versions, f"no PyNvVideoCodec floor constant found in {path}" + assert versions == {EXPECTED_FLOOR}, f"{path} guards on {sorted(versions)}" + + +def main() -> int: + """Run every check outside pytest, for the `pynvvideocodec-spec` hook. + + The pre-commit environment has no repo conftest and no framework images, so it + is both the only place the template half is guaranteed to run and the place + that must not depend on the suite around it. A missing template is a hard error + here, not the skip it is inside an image: in a checkout the file is always there. + """ + failures: list[str] = [] + + def check(label: str, fn, *args) -> None: + try: + fn(*args) + except BaseException as exc: # noqa: BLE001 - report, never abort the sweep + failures.append(f"{label}: {exc}") + + check("spec is bounded above", test_the_spec_is_bounded_above) + check("template install specs", test_templates_install_the_same_spec) + for path in REQUIREMENTS: + check( + f"requirements spec {path}", + test_requirements_declare_the_expected_spec, + path, + ) + for path in TEMPLATES: + if not (ROOT / path).is_file(): + failures.append(f"template {path}: file not found") + continue + check(f"template floor {path}", test_templates_guard_on_the_same_floor, path) + + for failure in failures: + print(f"ERROR: {failure}") + if failures: + print(f"\n{len(failures)} PyNvVideoCodec spec check(s) failed.") + return 1 + print( + f"PyNvVideoCodec spec {EXPECTED_SPEC} is consistent across " + f"{len(REQUIREMENTS)} requirements files and {len(TEMPLATES)} templates." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From bf3f3f778191ba8542b695c2ef3e1b67c0550950 Mon Sep 17 00:00:00 2001 From: Dmitry Tokarev Date: Thu, 17 Sep 2026 20:45:00 -0400 Subject: [PATCH 3/8] build(container): pin PyNvVideoCodec exactly at 2.2.3 (DYN-4044) `>=2.2.3,<3` still resolves whatever is newest inside the major at build time, so the declared version stayed a floor rather than a record of what was validated -- the same shape of failure that let the images run 2.2.3 while the spec read `>=2.2.0` and nothing noticed. container/deps/README.md asks that every installed version be explicitly tested, and here the wheel's bundled FFmpeg is what the codec gate inspects, so an unaudited bundle must not be able to arrive on its own. The template guards follow the specifier. TRT-LLM's `sort -V` floor test and its separate `case "$v" in 2.*)` cap collapse into `[ "$v" = "2.2.3" ]`, and the venv cross-check becomes `[ "$vv" = "$v" ]`, which compares the image's two copies to each other rather than each to a bound. The vLLM and SGLang heredocs replace `parse(...) < parse(FLOOR)` with an equality against PINNED, so the now-unused `parse()` helper goes. The obsolete-base tripwire in trtllm_runtime.Dockerfile now also fires when the base ships exactly 2.2.3, not only something newer: with an exact pin, a base that already carries the pinned version makes the removal-and-reinstall block a no-op that should be deleted. Renames tests/dependencies/test_pynvvideocodec_floor.py to `..._spec.py`. Nothing here is a floor any more, and a stale name is precisely the drift that file exists to catch. Validation: the renamed module passes 8/8 under pytest and exits 0 as the pre-commit hook script, printing "PyNvVideoCodec spec ==2.2.3 is consistent across 3 requirements files and 3 templates." `pre-commit run` over the changed files passes every hook. Co-Authored-By: Claude Opus 5 Signed-off-by: Dmitry Tokarev --- .pre-commit-config.yaml | 2 +- container/deps/requirements.sglang.txt | 25 ++----- container/deps/requirements.trtllm.txt | 25 +++---- container/deps/requirements.vllm.txt | 25 +++---- container/templates/sglang_runtime.Dockerfile | 38 +++------- container/templates/trtllm_runtime.Dockerfile | 37 ++++------ container/templates/vllm_runtime.Dockerfile | 73 ++++++------------- ...c_floor.py => test_pynvvideocodec_spec.py} | 45 ++++++------ 8 files changed, 95 insertions(+), 175 deletions(-) rename tests/dependencies/{test_pynvvideocodec_floor.py => test_pynvvideocodec_spec.py} (89%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c76c0f1ab20d..38fcd9c53083 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -206,7 +206,7 @@ repos: pass_filenames: false - id: pynvvideocodec-spec name: Check the PyNvVideoCodec spec is consistent - entry: python3 tests/dependencies/test_pynvvideocodec_floor.py + entry: python3 tests/dependencies/test_pynvvideocodec_spec.py language: python additional_dependencies: [packaging, pytest] pass_filenames: false diff --git a/container/deps/requirements.sglang.txt b/container/deps/requirements.sglang.txt index a941756d144d..05e559c261ae 100644 --- a/container/deps/requirements.sglang.txt +++ b/container/deps/requirements.sglang.txt @@ -30,22 +30,11 @@ mooncake-transfer-engine-cuda13>=0.3.13.post1,<0.4 pillow>=12.3.0,<13 # bounded: this file installs with --force-reinstall # NVDEC hardware decode for H.264/H.265 video input (dynamo common.multimodal. # nvdec_decoder), restoring those codecs after the video-DECODE purge in -# sglang_runtime.Dockerfile. Pin >=2.2.3: it bundles only libavutil + libavformat -# (FFmpeg 9.0.1, container demux, no software codec); older 2.0.x/2.1.x bundle a -# full FFmpeg incl. libavcodec that the codec gate rejects. Links libnvcuvid at -# runtime; requires NVIDIA_DRIVER_CAPABILITIES to include "video" (set in -# sglang_runtime.Dockerfile). -# -# No pre-install removal here, unlike the vLLM and TRT-LLM images: the -# lmsysorg/sglang base ships no PyNvVideoCodec today, so this install is the only -# copy in the image. That is a property of the current base rather than a -# guarantee, so sglang_runtime.Dockerfile asserts the result -- exactly one -# dist-info, at or above this floor, and exactly one bundled FFmpeg source -# tarball -- instead of trusting the claim. -# -# Bounded above -- see the note in requirements.vllm.txt. Keep the whole -# specifier identical to the ones in requirements.{vllm,trtllm}.txt, and its lower -# bound identical to the guard constants in the three runtime templates; a test -# asserts they all agree. -PyNvVideoCodec>=2.2.3,<3 +# sglang_runtime.Dockerfile. The upstream SGLang image ships no PyNvVideoCodec, +# so nothing has to be removed first and this is the only copy. Requires +# NVIDIA_DRIVER_CAPABILITIES to include "video" (set in sglang_runtime.Dockerfile). +# Pinned exactly -- see the note in requirements.vllm.txt. Keep the specifier in +# step with requirements.{vllm,trtllm}.txt and the runtime templates; +# tests/dependencies/test_pynvvideocodec_spec.py enforces that. +PyNvVideoCodec==2.2.3 zstandard==0.23.0 diff --git a/container/deps/requirements.trtllm.txt b/container/deps/requirements.trtllm.txt index 0891a933ce20..8eaa0512b11d 100644 --- a/container/deps/requirements.trtllm.txt +++ b/container/deps/requirements.trtllm.txt @@ -36,22 +36,15 @@ msgspec>=0.19.0 # the current patch line. pillow>=12.3.0 # NVDEC hardware decode for H.264/H.265 video input (dynamo common.multimodal. -# nvdec_decoder). Pin >=2.2.3: it bundles only libavutil + libavformat (FFmpeg -# 9.0.1, container demux, no software codec); everything below 2.2.0 -- 2.1.0 -# included, not just the 2.0.x line -- bundles a full FFmpeg whose libavcodec -# the codec gate rejects on presence. Links libnvcuvid at runtime. Requires -# NVIDIA_DRIVER_CAPABILITIES to include "video" (set in -# trtllm_runtime.Dockerfile) or it cannot import. -# -# This image carries two copies: the /opt/dynamo/venv one this file installs, and -# a system-site one trtllm_runtime.Dockerfile installs over the base image's. -# Their specifiers must stay identical, or the two diverge inside one image. -# -# Bounded above -- see the note in requirements.vllm.txt. Keep the whole -# specifier identical to the ones in requirements.{vllm,sglang}.txt and to the -# system-site install, and its lower bound identical to the guard constants in -# the three runtime templates; a test asserts they all agree. -PyNvVideoCodec>=2.2.3,<3 +# nvdec_decoder). The TRT-LLM base ships 2.1.0, which bundles a full FFmpeg +# including a libavcodec the codec gate rejects. This image carries two copies -- +# the /opt/dynamo/venv one this file installs and the system-site one +# trtllm_runtime.Dockerfile installs over the base image's -- and their +# specifiers must stay identical. Requires NVIDIA_DRIVER_CAPABILITIES to include +# "video". Pinned exactly -- see the note in requirements.vllm.txt. Keep the +# specifier in step with requirements.{vllm,sglang}.txt; +# tests/dependencies/test_pynvvideocodec_spec.py enforces that. +PyNvVideoCodec==2.2.3 soupsieve>=2.8.4 # JupyterLab stack; see note above jupyter-server tornado>=6.5.6 # JupyterLab stack; see note above jupyter-server # Required by ai_dynamo_runtime (companion to msgspec above). diff --git a/container/deps/requirements.vllm.txt b/container/deps/requirements.vllm.txt index 3d5878fcdc5c..f25767a56a2d 100644 --- a/container/deps/requirements.vllm.txt +++ b/container/deps/requirements.vllm.txt @@ -29,18 +29,13 @@ mooncake-transfer-engine-cuda13>=0.3.13.post1,<0.4 # floor it so the --no-deps install moves it to the current patch line. pillow>=12.3.0 # NVDEC hardware decode for H.264/H.265 video input (common/multimodal/ -# nvdec_decoder). Pin >=2.2.3, and remove the base copy before installing (see -# the vllm_runtime.Dockerfile install): the vllm-openai base ships -# PyNvVideoCodec 2.0.4, which bundles a full FFmpeg 8.1.1 (libavcodec + -# libavdevice/libavfilter/libsw*) that the codec gate rejects; 2.2.3 bundles -# only libavutil + libavformat (FFmpeg 9.0.1, container demux, no codec). -# Requires NVIDIA_DRIVER_CAPABILITIES to include "video" or it cannot import. -# -# Bounded above, per the pinning strategy in container/deps/README.md: the lower -# bound records the version the multimodal suite was validated against, and -# without an upper bound it records nothing, because the build resolves whatever -# is newest. Keep the whole specifier identical to the ones in -# requirements.{sglang,trtllm}.txt and to any inline install in a runtime -# template, and its lower bound identical to those templates' guard constants; a -# test asserts they all agree. -PyNvVideoCodec>=2.2.3,<3 +# nvdec_decoder). The vllm-openai base ships 2.0.4, which bundles a full FFmpeg +# including a libavcodec the codec gate rejects, so vllm_runtime.Dockerfile +# removes it before this installs. Requires NVIDIA_DRIVER_CAPABILITIES to include +# "video". Pinned exactly: container/deps/README.md asks that every installed +# version be explicitly tested, and here the wheel's bundled FFmpeg is what the +# codec gate inspects, so a range would let an unaudited bundle land at build +# time. Keep the specifier in step with requirements.{sglang,trtllm}.txt +# and the runtime templates; tests/dependencies/test_pynvvideocodec_spec.py +# enforces that. +PyNvVideoCodec==2.2.3 diff --git a/container/templates/sglang_runtime.Dockerfile b/container/templates/sglang_runtime.Dockerfile index a0052ecd1981..f1ddf3ee9699 100644 --- a/container/templates/sglang_runtime.Dockerfile +++ b/container/templates/sglang_runtime.Dockerfile @@ -191,22 +191,11 @@ RUN --mount=type=bind,source=./container/deps/requirements.sglang.txt,target=/tm pip install --break-system-packages --force-reinstall --no-deps \ --requirement /tmp/requirements.sglang.txt -# Assert that exactly one PyNvVideoCodec ended up in the image, at or above the -# floor, carrying none of the libraries the codec gate denies. -# -# The lmsysorg/sglang base ships no PyNvVideoCodec today, so unlike the vLLM and -# TRT-LLM images there is no base copy to delete first -- but that is a property -# of the current base, not a guarantee, and --force-reinstall above would replace -# a base copy in place rather than leave two. What a base copy could still leave -# behind is a second version-stamped FFmpeg source tarball under -# /external/ffmpeg, which the wheel installs outside site-packages through -# a `../../../external/ffmpeg/src/ffmpeg-.tar.xz` RECORD entry. Checking -# the result is what keeps that honest; a comment claiming the base is clean -# would not. -# -# FLOOR is duplicated from requirements.sglang.txt on purpose -- this stage must -# not parse the file it is checking; tests/dependencies/test_pynvvideocodec_floor.py -# asserts the two agree. +# Assert what the install left. The upstream SGLang image ships no +# PyNvVideoCodec, so nothing is removed first -- but that is a property of the +# current base rather than a guarantee, and checking the result is what keeps it +# honest. PINNED is duplicated from the requirements file deliberately: this stage +# must not parse the file it is checking; a test asserts the two agree. RUN python3 - <<'PYEOF' import csv import glob @@ -215,7 +204,7 @@ import re import sys from importlib.metadata import distributions -FLOOR = "2.2.3" +PINNED = "2.2.3" NAME = "pynvvideocodec" # Mirrors the deny globs in container/compliance/policy/codec_policy.yaml. Kept as # families rather than the four this package happens to have shed, so a future @@ -238,16 +227,6 @@ def canonical(name): return re.sub(r"[-_.]+", "-", name or "").lower() -def parse(version): - """Compare on the numeric release only, and never raise. - - A version this cannot split (a release candidate, a dev build) must not kill - the build with a traceback where this block has its own message to print. - """ - parts = [int(x) for x in re.findall(r"\d+", version)[:3]] - return tuple(parts + [0] * (3 - len(parts))) - - # Enumerated over sys.path rather than one scheme directory: these images carry # both /usr/local/lib/python3.12/dist-packages and /usr/lib/python3/dist-packages, # and the wheel declares Root-Is-Purelib: false, so neither purelib nor platlib @@ -259,8 +238,9 @@ versions = sorted(d.version for d in installed) print("PyNvVideoCodec distributions on sys.path:", versions) if len(installed) != 1: sys.exit(f"ERROR: expected exactly one PyNvVideoCodec, found {versions}") -if parse(versions[0]) < parse(FLOOR): - sys.exit(f"ERROR: PyNvVideoCodec {versions[0]} is below the {FLOOR} floor") +if versions[0] != PINNED: + sys.exit(f"ERROR: PyNvVideoCodec is {versions[0]}, but the requirements file " + f"pins {PINNED}") site = os.path.normpath(str(installed[0].locate_file(""))) pkg = os.path.join(site, "PyNvVideoCodec") diff --git a/container/templates/trtllm_runtime.Dockerfile b/container/templates/trtllm_runtime.Dockerfile index 3ebf91e481c2..16f60ef8cfb1 100644 --- a/container/templates/trtllm_runtime.Dockerfile +++ b/container/templates/trtllm_runtime.Dockerfile @@ -411,13 +411,11 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p # future version whose libavcodec is not, disarming the check that just fired. # Removing the file keeps the narrow waivers, and the tripwire, intact. # -# A range rather than an exact pin, unlike DALI above: requirements.trtllm.txt -# already installs PyNvVideoCodec>=2.2.3,<3 into /opt/dynamo/venv, and pinning the -# system copy exactly would let the two diverge inside one image. Keep this -# specifier identical to the one there -- including the cap, which the shell -# guards check as a separate major-version test because `sort -V` can express a -# floor but not an upper bound; tests/dependencies/ -# test_pynvvideocodec_floor.py asserts that, and covers the vllm and sglang +# Pinned exactly, like DALI above: requirements.trtllm.txt installs +# PyNvVideoCodec==2.2.3 into /opt/dynamo/venv and this stage pins the system copy +# to the same version, so the guard below is a plain equality and the venv +# cross-check compares the two copies to each other. tests/dependencies/ +# test_pynvvideocodec_spec.py asserts that, and covers the vllm and sglang # requirements files in the same pass. Two assertions follow: a filename test # that mirrors the codec gate's deny glob (so a reintroduced libavcodec fails # here, with a clear message, rather than later in the scan), and a content test @@ -472,7 +470,7 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p exit 1; \ fi; \ newest=$(printf '%s\n2.2.3\n' "$before" | sort -V | tail -1); \ - if [ "$newest" != "2.2.3" ]; then \ + if [ "$before" = "2.2.3" ] || [ "$newest" != "2.2.3" ]; then \ echo "ERROR: base image already carries PyNvVideoCodec $before, so this block" >&2; \ echo " is obsolete -- delete it and let the base version stand. Keep the" >&2; \ echo " libavcodec assertion below and the post-overlay one in" >&2; \ @@ -489,16 +487,12 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p exit 1; \ fi; \ /usr/bin/python3 -m pip install --break-system-packages --no-cache-dir \ - 'PyNvVideoCodec>=2.2.3,<3'; \ + 'PyNvVideoCodec==2.2.3'; \ v=$(/usr/bin/python3 -c 'import importlib.metadata as m; print(m.version("pynvvideocodec"))'); \ echo "PyNvVideoCodec version: $v"; \ - [ "$(printf '%s\n2.2.3\n' "$v" | sort -V | head -1)" = "2.2.3" ] \ - || { echo "ERROR: wanted PyNvVideoCodec >=2.2.3,<3, got $v" >&2; exit 1; }; \ - case "$v" in 2.*) ;; *) \ - echo "ERROR: PyNvVideoCodec $v is outside the >=2.2.3,<3 range that" >&2; \ - echo " requirements.trtllm.txt declares. A sort -V test can express" >&2; \ - echo " the floor but not the cap, so the major is checked separately." >&2; \ - exit 1;; esac; \ + [ "$v" = "2.2.3" ] \ + || { echo "ERROR: wanted PyNvVideoCodec 2.2.3, got $v -- this stage and" >&2; \ + echo " requirements.trtllm.txt must pin the same version." >&2; exit 1; }; \ dists=$(find /usr/local/lib/python3.12/dist-packages -maxdepth 1 \ -name 'pynvvideocodec-*.dist-info' | wc -l); \ [ "$dists" -eq 1 ] \ @@ -536,13 +530,10 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p echo " --system-site-packages, so the interpreter would resolve the" >&2; \ echo " system copy this stage just upgraded and always look correct." >&2; \ exit 1; }; \ - [ "$(printf '%s\n2.2.3\n' "$vv" | sort -V | head -1)" = "2.2.3" ] \ - || { echo "ERROR: venv PyNvVideoCodec $vv is below the 2.2.3 floor while the" >&2; \ - echo " system copy is $v -- the two specifiers have drifted apart." >&2; \ - echo " requirements.trtllm.txt and this stage must stay in step." >&2; exit 1; }; \ - case "$vv" in 2.*) ;; *) \ - echo "ERROR: venv PyNvVideoCodec $vv is outside >=2.2.3,<3 while the system" >&2; \ - echo " copy is $v -- the two specifiers have drifted apart." >&2; exit 1;; esac; \ + [ "$vv" = "$v" ] \ + || { echo "ERROR: venv PyNvVideoCodec is $vv but the system copy is $v --" >&2; \ + echo " the two have drifted apart. requirements.trtllm.txt and" >&2; \ + echo " this stage must pin the same version." >&2; exit 1; }; \ else \ echo "NOTE: no /opt/dynamo/venv in this stage yet, so the venv/system"; \ echo " cross-check is skipped. Expected for the dev and local-dev"; \ diff --git a/container/templates/vllm_runtime.Dockerfile b/container/templates/vllm_runtime.Dockerfile index 910e272d8245..0dcf17cc1004 100644 --- a/container/templates/vllm_runtime.Dockerfile +++ b/container/templates/vllm_runtime.Dockerfile @@ -397,35 +397,20 @@ RUN set -eu; \ {% if device == "cuda" %} # Delete the base image's PyNvVideoCodec before the install below replaces it. -# vllm/vllm-openai:v0.28.0-ubuntu2404 ships 2.0.4, which bundles a complete -# FFmpeg 8.1.1 -- libavcodec.so.62 plus libavdevice/libavfilter/libswscale/ -# libswresample -- and the codec gate denies a wheel-vendored libavcodec on -# presence. +# The vllm-openai base ships 2.0.4, which bundles a complete FFmpeg including a +# libavcodec the codec gate denies on presence. # -# Uninstall FIRST, then rm what is left. The order matters and the obvious -# shortcut is wrong: the wheel's RECORD is the only complete list of what it -# installed, and it reaches further than the package directory -- top-level -# `samples/` and `benchmarks/` trees (41 entries in 2.0.4) and an FFmpeg source -# tarball written outside site-packages through a -# `../../../external/ffmpeg/src/ffmpeg-.tar.xz` entry. Deleting the -# dist-info first destroys that list, so a bare `rm -rf` of the package -# directory would leave those trees behind as orphans no later install owns. -# The rm is still here, after the uninstall, because it is the part that does -# not depend on the installer honouring its own metadata, and because it is what -# makes the intent legible at the point of use. +# Uninstall first, then rm. The wheel's RECORD is the only complete list of what +# it wrote -- it reaches top-level `samples/` and `benchmarks/` trees and an +# FFmpeg source tarball outside site-packages -- so deleting the dist-info first +# would orphan those. The rm covers what does not depend on the installer +# honouring its own metadata; `pynvvideocodec*` takes the dist-info and the stray +# `pynvvideocodec.` directory beside it. # -# `pynvvideocodec*` takes both the version-stamped dist-info and the stray -# `pynvvideocodec.` directory the wheel installs beside it (it holds a vendored -# libcudart, not a codec). +# find_spec, not an import: PyNvVideoCodec's __init__ dlopens libnvidia-encode, +# which no builder has, so an import raises whether or not the wheel is there. # -# Fails when the base stops shipping the package rather than quietly removing -# nothing: a no-op rm is indistinguishable from one whose paths went stale, and -# either way this block is what has to be revisited. -# -# The presence test is find_spec, not an import: PyNvVideoCodec's __init__ dlopens -# libnvidia-encode.so.1, which no builder has, so `import PyNvVideoCodec` raises -# whether or not the wheel is installed and could never fail this build -- the -# same reason the non-CUDA branch below uses find_spec. +# Fails if the base stops shipping it, rather than quietly removing nothing. RUN set -eu; \ purelib="$(python3 -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"; \ data="$(python3 -c 'import sysconfig; print(sysconfig.get_paths()["data"])')"; \ @@ -476,18 +461,13 @@ RUN --mount=type=bind,source=./container/deps/requirements.vllm.txt,target=/tmp/ --no-deps --requirement /tmp/requirements.vllm.txt && \ rm -rf /opt/uv/cache -# Assert what the removal and the install together left on disk. The floor in -# requirements.vllm.txt is a lower bound, so it cannot state "exactly one copy" -# or "no libavcodec" -- and those are the two properties the codec gate depends -# on. Checked here, beside the install, so a regression names its cause instead -# of surfacing as an unexplained scan violation several stages later. -# -# Reads the dist-info directories rather than importlib.metadata: a surviving -# base copy beside the new one is exactly the failure being looked for, and the -# metadata API answers with whichever one it resolves first. FLOOR is duplicated -# from requirements.vllm.txt on purpose -- this stage must not parse the file it -# is checking; tests/dependencies/test_pynvvideocodec_floor.py asserts the two -# agree, and covers the sglang and trtllm copies in the same way. +# Assert what the removal and install left. A requirements specifier constrains +# what pip installs; it cannot say "exactly one copy on disk" or "no libavcodec", +# and those are the properties the codec gate depends on. Checked beside the +# install so a regression names its cause instead of surfacing as a scan +# violation later. +# PINNED is duplicated from the requirements file deliberately -- this stage +# must not parse the file it is checking; a test asserts the two agree. RUN python3 - <<'PYEOF' import csv import glob @@ -496,7 +476,7 @@ import re import sys from importlib.metadata import distributions -FLOOR = "2.2.3" +PINNED = "2.2.3" NAME = "pynvvideocodec" # Mirrors the deny globs in container/compliance/policy/codec_policy.yaml. Kept as # families rather than the four this package happens to have shed, so a future @@ -519,16 +499,6 @@ def canonical(name): return re.sub(r"[-_.]+", "-", name or "").lower() -def parse(version): - """Compare on the numeric release only, and never raise. - - A version this cannot split (a release candidate, a dev build) must not kill - the build with a traceback where this block has its own message to print. - """ - parts = [int(x) for x in re.findall(r"\d+", version)[:3]] - return tuple(parts + [0] * (3 - len(parts))) - - # Enumerated over sys.path rather than one scheme directory: these images carry # both /usr/local/lib/python3.12/dist-packages and /usr/lib/python3/dist-packages, # and the wheel declares Root-Is-Purelib: false, so neither purelib nor platlib @@ -540,8 +510,9 @@ versions = sorted(d.version for d in installed) print("PyNvVideoCodec distributions on sys.path:", versions) if len(installed) != 1: sys.exit(f"ERROR: expected exactly one PyNvVideoCodec, found {versions}") -if parse(versions[0]) < parse(FLOOR): - sys.exit(f"ERROR: PyNvVideoCodec {versions[0]} is below the {FLOOR} floor") +if versions[0] != PINNED: + sys.exit(f"ERROR: PyNvVideoCodec is {versions[0]}, but the requirements file " + f"pins {PINNED}") site = os.path.normpath(str(installed[0].locate_file(""))) pkg = os.path.join(site, "PyNvVideoCodec") diff --git a/tests/dependencies/test_pynvvideocodec_floor.py b/tests/dependencies/test_pynvvideocodec_spec.py similarity index 89% rename from tests/dependencies/test_pynvvideocodec_floor.py rename to tests/dependencies/test_pynvvideocodec_spec.py index ed7fe069a75d..24de85cddc04 100644 --- a/tests/dependencies/test_pynvvideocodec_floor.py +++ b/tests/dependencies/test_pynvvideocodec_spec.py @@ -3,8 +3,8 @@ """Keep the PyNvVideoCodec spec identical everywhere it is written down. -The spec is written in three requirements files, and its lower bound is repeated -as a guard constant in each of the three runtime templates. The templates cannot +The spec is written in three requirements files, and the pinned version is +repeated as a guard constant in each of the three runtime templates. The templates cannot parse the requirements file they are checking, since the point of those guards is to fail when the installed version and the declared one disagree, so the duplication is deliberate and needs a test rather than a comment. @@ -41,13 +41,13 @@ ROOT = Path(__file__).resolve().parents[2] -# The spec every image installs. The lower bound is the version the multimodal -# suite was validated against; the upper bound is what stops the next major being -# taken silently at build time. -EXPECTED_SPEC = ">=2.2.3,<3" -# The lower bound on its own -- this is what the template guards compare against, -# since a shell `sort -V` test cannot express a range. -EXPECTED_FLOOR = "2.2.3" +# The spec every image installs: pinned exactly, so the declared version is the +# version, and `container/deps/README.md`'s "every installed version should be +# explicitly tested" holds without an audit. +EXPECTED_SPEC = "==2.2.3" +# The bare version, which is what the shell guards in the templates compare +# against -- they check an installed version string, not a specifier. +EXPECTED_VERSION = "2.2.3" DISTRIBUTION = "pynvvideocodec" @@ -153,7 +153,7 @@ def _requirement(path: str) -> Requirement: return found[0] -def _template_floor_versions(path: str) -> set[str]: +def _template_pinned_versions(path: str) -> set[str]: """Every version literal the PyNvVideoCodec instructions in `path` compare against. Every semver on a comparing or installing line inside an instruction that names @@ -204,7 +204,7 @@ def test_the_spec_is_bounded_above() -> None: """`container/deps/README.md`: "Never use `>=`" -- it stops pinning anything. Distinct from the exact-spec cases above, which move with EXPECTED_SPEC: this - one survives an edit that drops the cap and updates that constant to match. + one survives an edit that loosens the pin and updates that constant to match. """ specifier = _requirement(REQUIREMENTS[0]).specifier assert any( @@ -217,10 +217,9 @@ def test_templates_install_the_same_spec() -> None: The TRT-LLM image installs a system-site copy of its own, beside the venv copy `requirements.trtllm.txt` provides, and its comment says the two specifiers - must stay identical. Nothing enforced that: the floor scan below reads lower - bounds only, so a template left at `>=2.2.3` while the requirements file moved - to `>=2.2.3,<3` would pass, and a 3.x release would then land in system site - while the venv stayed on 2.x. + must stay identical. Nothing enforced that, and the two did drift once: the + requirements file was capped while the template's inline install was left + unbounded, so one image would have carried two different versions. One test over all templates rather than one per template: only TRT-LLM carries an inline specifier today, so a per-template parametrization would report two @@ -242,19 +241,19 @@ def test_templates_install_the_same_spec() -> None: @pytest.mark.parametrize("path", TEMPLATES) -def test_templates_guard_on_the_same_floor(path: str) -> None: - """Every floor constant in a template must equal the declared lower bound. +def test_templates_guard_on_the_same_version(path: str) -> None: + """Every version constant in a template must equal the declared pin. - Deliberately an assertion over the whole set, not "the floor appears + Deliberately an assertion over the whole set, not "the version appears somewhere": a guard still comparing against a superseded version passes that weaker check while letting through exactly the drift this file exists to catch. """ template = ROOT / path if not template.is_file(): pytest.skip(f"{path} is not staged in this component image (.dockerignore)") - versions = _template_floor_versions(path) - assert versions, f"no PyNvVideoCodec floor constant found in {path}" - assert versions == {EXPECTED_FLOOR}, f"{path} guards on {sorted(versions)}" + versions = _template_pinned_versions(path) + assert versions, f"no PyNvVideoCodec version constant found in {path}" + assert versions == {EXPECTED_VERSION}, f"{path} guards on {sorted(versions)}" def main() -> int: @@ -285,7 +284,9 @@ def check(label: str, fn, *args) -> None: if not (ROOT / path).is_file(): failures.append(f"template {path}: file not found") continue - check(f"template floor {path}", test_templates_guard_on_the_same_floor, path) + check( + f"template version {path}", test_templates_guard_on_the_same_version, path + ) for failure in failures: print(f"ERROR: {failure}") From 3bae0582836020379721c61b6724f6f9a4c880d0 Mon Sep 17 00:00:00 2001 From: Dmitry Tokarev Date: Thu, 17 Sep 2026 20:45:50 -0400 Subject: [PATCH 4/8] build(container): build the in-tree FFmpeg at 9.0.1 with --build-suffix=_dynamo PyNvVideoCodec 2.2.3 vendors FFmpeg 9.0.1, so moving the in-tree build to the same release gives two independently configured builds the same DT_SONAMEs -- libavformat.so.63, libavutil.so.61, libavcodec.so.63. The loader resolves a needed name to exactly one library per process, so whichever is loaded first serves both, and the two builds enable disjoint feature sets. Reproduced on a GPU box with both trees present: PyNvVideoCodec's own capability collapses from 359 demuxers to 3, 50 bitstream filters to 6, and 183 muxers to 4. `--build-suffix=_dynamo` renames the libraries and their SONAMEs, so ours ship as libavformat_dynamo.so.63 and cannot be mistaken for a vendored copy. Three things follow from the rename and each needed fixing: - pkg-config. The .pc files are renamed too, so `pkg-config libavformat` stops resolving and ffmpeg-sys-next's probe fails the Rust build outright. Canonical-name symlinks restore it; each still reports -lavformat_dynamo, so consumers link the suffixed library. - The codec gate. `**/libav*.so*` does not match a suffixed name. Measured by rescanning one tree with and without the three new deny globs: violations 1 -> 0 and allowed 6 -> 0, i.e. the scanner went blind to every suffixed library, ours and a planted third-party one alike. allow_paths re-permits ours by path prefix, so a third-party suffixed libavcodec still fails. - auditwheel. fnmatch('libavcodec_dynamo.so.63', 'libavcodec.so.*') is False, so leaving the excludes unsuffixed silently re-enables the library grafting they exist to prevent. video-rs 0.12 requires ffmpeg-next =9.0.0 -- cargo reports `dependency ffmpeg-next = "=9.0.0" of package video-rs v0.12.0` when asked to resolve it against 8.1.0 -- so lib/llm moves both together, with the lockfiles re-resolved. Separately, the codec absence guard was checking nothing. It ran /usr/local/bin/ffmpeg with stderr discarded before ldconfig, and the binary carries no RPATH, so against a stale cache it could not start, produced empty output, matched no disallowed codec and passed. ldconfig now runs first and a positive VP9-encoder check precedes the absence tests, so an unstartable binary fails loudly instead of quietly. tests/dependencies/test_ffmpeg_version_consistency.py is new: context.yaml decides what is compiled, native_packages.yaml is what the SBOM claims, and the codec policy's deny_components floor is what that SBOM is measured against. Nothing compared them, and only one direction failed loudly. All three read 8.1.2 before this change, so it pins an invariant the repo already held. Validation on a GPU box (RTX 6000 Ada): - The ffmpeg RUN block extracted verbatim from the rendered vLLM runtime Dockerfile builds clean. pkg-config resolves all five canonical names to -l*_dynamo; the positive VP9 check fires; encoders, decoders and parsers carry no denied codec; no unsuffixed libav* is installed. - cargo test -p dynamo-llm --no-default-features --features media-ffmpeg: 65/65 media tests pass, including test_sampled_decode_matches_video_rs and the decode paths. The test binary's DT_NEEDED entries are libavutil_dynamo.so.61, libavformat_dynamo.so.63, libavcodec_dynamo.so.63 and libswscale_dynamo.so.10. Injecting a call to a nonexistent ffmpeg-next API into video.rs fails the build, so the check is not vacuous. - container/compliance/tests: 93 passed. Deleting the three new deny globs fails exactly the two tests that pin them. All 11 Dockerfile flavours render, and `pre-commit run` over the changed files passes every hook. Co-Authored-By: Claude Opus 5 Signed-off-by: Dmitry Tokarev --- .pre-commit-config.yaml | 7 ++ Cargo.lock | 92 +++++++------- container/compliance/native_packages.yaml | 2 +- container/compliance/policy/codec_policy.yaml | 12 +- .../compliance/tests/test_scan_codecs.py | 29 +++-- container/context.yaml | 10 +- container/templates/wheel_builder.Dockerfile | 43 +++++-- lib/bindings/python/Cargo.lock | 20 +-- lib/llm/Cargo.toml | 6 +- .../test_ffmpeg_version_consistency.py | 115 ++++++++++++++++++ 10 files changed, 257 insertions(+), 79 deletions(-) create mode 100644 tests/dependencies/test_ffmpeg_version_consistency.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38fcd9c53083..f97dc50aa3ee 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -211,6 +211,13 @@ repos: additional_dependencies: [packaging, pytest] pass_filenames: false always_run: true + - id: ffmpeg-version-consistency + name: Check the in-tree FFmpeg version is declared consistently + entry: python3 tests/dependencies/test_ffmpeg_version_consistency.py + language: python + additional_dependencies: [pytest, pyyaml] + pass_filenames: false + always_run: true - id: validate-agent-instructions name: Validate AGENTS.md and CLAUDE.md pairs entry: python3 scripts/validate_agent_instructions.py diff --git a/Cargo.lock b/Cargo.lock index c2b9fcef4801..4305e6ed55ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1124,7 +1124,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1144,7 +1144,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1200,9 +1200,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -1380,9 +1380,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", "jobserver", @@ -2345,7 +2345,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "objc2", ] @@ -2675,7 +2675,7 @@ dependencies = [ "axum-server", "base64 0.22.1", "bincode 2.0.1", - "bitflags 2.11.1", + "bitflags 2.13.2", "blake3", "bs62", "bytemuck", @@ -3545,20 +3545,20 @@ dependencies = [ [[package]] name = "ffmpeg-next" -version = "8.1.0" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d" +checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "ffmpeg-sys-next", "libc", ] [[package]] name = "ffmpeg-sys-next" -version = "8.1.0" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547" +checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b" dependencies = [ "bindgen 0.72.1", "cc", @@ -3603,9 +3603,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fixedbitset" @@ -5209,7 +5209,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "libc", ] @@ -6228,7 +6228,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "byteorder", "derive_builder", "getset", @@ -6276,7 +6276,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -6289,7 +6289,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -6375,7 +6375,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "filetime", "inotify", "kqueue", @@ -6550,7 +6550,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -6571,7 +6571,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "dispatch2", "objc2", ] @@ -6582,7 +6582,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "dispatch2", "objc2", "objc2-core-foundation", @@ -6615,7 +6615,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -6633,7 +6633,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -6646,7 +6646,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", ] @@ -6657,7 +6657,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -6669,7 +6669,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "block2", "objc2", "objc2-cloud-kit", @@ -6766,7 +6766,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "libc", "once_cell", "onig_sys", @@ -7332,7 +7332,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", @@ -7522,7 +7522,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.11.1", + "bitflags 2.13.2", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -7703,7 +7703,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "memchr", "unicase", ] @@ -8088,7 +8088,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -8343,7 +8343,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "once_cell", "serde", "serde_derive", @@ -8535,7 +8535,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -8548,7 +8548,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -8648,7 +8648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f021ff72cabf5e2cd6d8ec8813d376a8445a228dc610ab56c27bd9054cda70d4" dependencies = [ "aho-corasick", - "bitflags 2.11.1", + "bitflags 2.13.2", "compact_str", "get-size2", "is-macro", @@ -8666,7 +8666,7 @@ version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01e6ee78bd9671fb5766664b2695fe1f2a92a961f4d9101646c570d8acdb1e0b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "bstr", "compact_str", "get-size2", @@ -8826,7 +8826,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -9516,7 +9516,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "bytemuck", "lazy_static", "log", @@ -9661,7 +9661,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -10439,7 +10439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.2", "bytes", "futures-util", "http 1.4.1", @@ -11057,9 +11057,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "video-rs" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2633ec4c2a8aeb7c0e970f75ba99122a75841e9f7b34d5225366d0e61a870a8c" +checksum = "aa25096b140b3015e39eb6db66a9dfd0d326a6e3cf952d848f5997c0e9546e53" dependencies = [ "ffmpeg-next", "tracing", @@ -11253,7 +11253,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -11760,7 +11760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.2", "indexmap 2.14.0", "log", "serde", diff --git a/container/compliance/native_packages.yaml b/container/compliance/native_packages.yaml index 52cf593258d5..fa63f243dd7a 100644 --- a/container/compliance/native_packages.yaml +++ b/container/compliance/native_packages.yaml @@ -64,7 +64,7 @@ packages: # no H.264 capability, then copied into the vLLM, SGLang, and TensorRT-LLM # runtimes for the VP9 video-encode path. - name: ffmpeg - version: "8.1.2" + version: "9.0.1" license: LGPL-2.1-or-later source: https://ffmpeg.org/ images: diff --git a/container/compliance/policy/codec_policy.yaml b/container/compliance/policy/codec_policy.yaml index fe2193967f2f..65c23cd3f22e 100644 --- a/container/compliance/policy/codec_policy.yaml +++ b/container/compliance/policy/codec_policy.yaml @@ -47,6 +47,16 @@ deny_globs: - "**/libswresample-*.so*" - "**/libpostproc.so*" - "**/libpostproc-*.so*" + # Our in-tree build carries --build-suffix=_dynamo so its SONAMEs cannot collide + # with the FFmpeg a wheel vendors (see wheel_builder.Dockerfile). The suffix is + # part of the library name, so none of the globs above reach it. Measured by + # deleting these three lines and rescanning one tree: violations 1 -> 0 and + # allowed 6 -> 0, i.e. the gate goes blind to every suffixed library, ours and a + # planted third-party one alike. allow_paths re-permits ours by path prefix, so + # a third-party suffixed libavcodec still fails. + - "**/libav*_dynamo.so*" + - "**/libsw*_dynamo.so*" + - "**/libpostproc_dynamo.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" @@ -59,7 +69,7 @@ deny_globs: # ffmpeg maintenance releases. deny_components: - name: ffmpeg - min_fixed_version: "8.1.2" + min_fixed_version: "9.0.1" - name: x264 - name: x265 - name: fdk-aac diff --git a/container/compliance/tests/test_scan_codecs.py b/container/compliance/tests/test_scan_codecs.py index 3c137f9f76ee..c8e50bb285f1 100644 --- a/container/compliance/tests/test_scan_codecs.py +++ b/container/compliance/tests/test_scan_codecs.py @@ -37,21 +37,36 @@ def _touch(root: Path, rel: str) -> None: def test_in_tree_ffmpeg_is_allowed(tmp_path: Path): # Our own /usr/local ffmpeg libs — must classify as allowed, never violate. + # The names carry the --build-suffix=_dynamo the wheel_builder configure sets, + # so this also pins the matching deny_globs: drop them and these libraries + # match nothing, `allowed` comes back empty, and the gate has gone blind. for rel in ( - "usr/local/lib/libavcodec.so.62", - "usr/local/lib/libswscale.so.9", + "usr/local/lib/libavcodec_dynamo.so.63", + "usr/local/lib/libswscale_dynamo.so.10", "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/lib/libavcodec_dynamo.so.63", + "/usr/local/lib/libswscale_dynamo.so.10", "/usr/local/bin/ffmpeg", } +def test_third_party_suffixed_codec_lib_is_denied(tmp_path: Path): + # allow_paths re-permits the suffixed names by /usr/local path prefix only. + # The same soname vendored by a wheel is still a violation — otherwise the + # suffix would hand anyone a way past the gate just by copying our naming. + rel = ( + "usr/local/lib/python3.12/dist-packages/somewheel/.libs/libavcodec_dynamo.so.63" + ) + _touch(tmp_path, rel) + violations, _, _ = scan_filesystem(tmp_path, _POLICY) + assert [v["path"] for v in violations] == ["/" + rel] + + 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). @@ -117,15 +132,15 @@ def test_sbom_flags_ffmpeg_below_cve_floor(tmp_path: Path): json.dumps( { "components": [ - {"name": "ffmpeg", "version": "8.0.1"}, # < 8.1.2 -> flagged - {"name": "ffmpeg", "version": "8.1.2"}, # == floor -> ok + {"name": "ffmpeg", "version": "8.1.2"}, # < 9.0.1 -> flagged + {"name": "ffmpeg", "version": "9.0.1"}, # == 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"] + assert [h["path"] for h in hits] == ["sbom:ffmpeg@8.1.2"] def test_codec_under_tmp_is_scanned(tmp_path: Path): diff --git a/container/context.yaml b/container/context.yaml index 991a2e4b438a..4d5e76681752 100644 --- a/container/context.yaml +++ b/container/context.yaml @@ -62,10 +62,12 @@ dynamo: enable_kvbm: "true" enable_media_ffmpeg: "false" enable_gpu_memory_service: "true" - # 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" + # Built from source in wheel_builder with a narrow codec allowlist and + # --build-suffix=_dynamo, so the SONAMEs cannot collide with the FFmpeg another + # wheel vendors. Keep in sync with native_packages.yaml's ffmpeg entry and with + # the ffmpeg-next/video-rs pins in lib/llm/Cargo.toml, which track the FFmpeg + # major. + ffmpeg_version: "9.0.1" # ffmpeg build inputs (only consumed when ENABLE_MEDIA_FFMPEG=true). nv_codec_headers_ref: "n13.0.19.0" libvpx_ref: "v1.14.1" diff --git a/container/templates/wheel_builder.Dockerfile b/container/templates/wheel_builder.Dockerfile index c81a67297659..be317e123d59 100644 --- a/container/templates/wheel_builder.Dockerfile +++ b/container/templates/wheel_builder.Dockerfile @@ -376,6 +376,7 @@ RUN --mount=type=secret,id=aws-web-identity-token,target=/run/secrets/aws-token cd ffmpeg-${FFMPEG_VERSION} && \ ./configure \ --prefix=/usr/local \ + --build-suffix=_dynamo \ --disable-gpl \ --disable-nonfree \ --disable-doc \ @@ -402,6 +403,28 @@ RUN --mount=type=secret,id=aws-web-identity-token,target=/run/secrets/aws-token --enable-protocol=file,pipe,fd && \ make -j$(nproc) && \ make install && \ + # ldconfig BEFORE the guard below, not after. The binary carries no RPATH, so + # until the cache knows about these libraries it cannot start -- and the guard + # discards stderr, so an unstartable binary produces empty output, matches no + # disallowed codec, and passes having checked nothing. Measured on a shipped + # image: with the ffmpeg libraries missing from the cache the run fails with + # "error while loading shared libraries" and the guard still passes. + ldconfig && \ + # --build-suffix renames the pkg-config files too, so `pkg-config libavformat` + # stops resolving and ffmpeg-sys-next's probe fails the Rust build outright. + # Canonical-name symlinks keep that working; each .pc still reports + # -lavformat_dynamo, so consumers link the suffixed library. + for pc in /usr/local/lib/pkgconfig/*_dynamo.pc; do \ + ln -sf "$(basename "$pc")" "${pc%_dynamo.pc}.pc"; \ + done && \ + # Positive check first: everything below is an absence test, and an absence + # test over empty output proves nothing. VP9 is present here by construction. + { /usr/local/bin/ffmpeg -hide_banner -encoders 2>/dev/null \ + | grep -qiE 'libvpx[-_]vp9' \ + || { echo "ERROR: the in-tree ffmpeg does not list its VP9 encoder; it is" >&2; \ + echo " either broken or unable to start, and the codec guard" >&2; \ + echo " below would then pass without checking anything." >&2; \ + exit 1; }; } && \ # Compliance guard: fail the build if any royalty-bearing / HW codec surface # leaked into the in-tree ffmpeg. By construction this build is VP9-only, so a # match here means a config regression. Check the implementation-carrying @@ -419,7 +442,6 @@ RUN --mount=type=secret,id=aws-web-identity-token,target=/run/secrets/aws-token fi; \ done && \ /tmp/use-sccache.sh show-stats "FFMPEG" && \ - ldconfig && \ 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/ @@ -599,6 +621,11 @@ RUN --mount=type=secret,id=aws-web-identity-token,target=/run/secrets/aws-token # wheel, which the codec gate rejects. Repair with those sonames excluded so # they stay external and resolve to the image's /usr/local/lib copies. This # media-enabled wheel is intentionally image-only and non-self-contained. + # + # The exclusions name the _dynamo-suffixed sonames, because that is what the + # wheel's DT_NEEDED entries carry. auditwheel globs these against the soname + # and 'libavcodec.so.*' does not match 'libavcodec_dynamo.so.62', so leaving + # them unsuffixed silently re-enables the grafting this exists to prevent. {% if device == "xpu" %} ARCH_ALT=x86_64 && \ MANYLINUX_POLICY=manylinux_2_39_x86_64 && \ {% else %} @@ -611,13 +638,13 @@ RUN --mount=type=secret,id=aws-web-identity-token,target=/run/secrets/aws-token {% endif %} maturin build --release --features "media-ffmpeg,kv-indexer,slot-tracker,select-service,mm-routing,aic-forward-pass,request-trace-s3" --auditwheel skip --out target/wheels && \ auditwheel repair \ - --exclude 'libavcodec.so.*' \ - --exclude 'libavdevice.so.*' \ - --exclude 'libavfilter.so.*' \ - --exclude 'libavformat.so.*' \ - --exclude 'libavutil.so.*' \ - --exclude 'libswresample.so.*' \ - --exclude 'libswscale.so.*' \ + --exclude 'libavcodec_dynamo.so.*' \ + --exclude 'libavdevice_dynamo.so.*' \ + --exclude 'libavfilter_dynamo.so.*' \ + --exclude 'libavformat_dynamo.so.*' \ + --exclude 'libavutil_dynamo.so.*' \ + --exclude 'libswresample_dynamo.so.*' \ + --exclude 'libswscale_dynamo.so.*' \ --plat ${MANYLINUX_POLICY} \ --wheel-dir /opt/dynamo/dist \ target/wheels/ai_dynamo_runtime-*.whl; \ diff --git a/lib/bindings/python/Cargo.lock b/lib/bindings/python/Cargo.lock index 585e22a2c3aa..b96557954fdb 100644 --- a/lib/bindings/python/Cargo.lock +++ b/lib/bindings/python/Cargo.lock @@ -1232,9 +1232,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.64" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", "jobserver", @@ -2963,9 +2963,9 @@ dependencies = [ [[package]] name = "ffmpeg-next" -version = "8.1.0" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d" +checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a" dependencies = [ "bitflags 2.13.0", "ffmpeg-sys-next", @@ -2974,9 +2974,9 @@ dependencies = [ [[package]] name = "ffmpeg-sys-next" -version = "8.1.0" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547" +checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b" dependencies = [ "bindgen 0.72.1", "cc", @@ -3021,9 +3021,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fixedbitset" @@ -9579,9 +9579,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "video-rs" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2633ec4c2a8aeb7c0e970f75ba99122a75841e9f7b34d5225366d0e61a870a8c" +checksum = "aa25096b140b3015e39eb6db66a9dfd0d326a6e3cf952d848f5997c0e9546e53" dependencies = [ "ffmpeg-next", "tracing", diff --git a/lib/llm/Cargo.toml b/lib/llm/Cargo.toml index aad78af22bdd..3750bcda96dc 100644 --- a/lib/llm/Cargo.toml +++ b/lib/llm/Cargo.toml @@ -206,8 +206,10 @@ reqwest = { workspace = true } base64 = { version = "0.22" } image = { version = "0.25", features = ["serde"] } libloading = "0.8" -video-rs = { version = "0.11.0", optional = true } -ffmpeg-next = { version = "8.1.0", optional = true } +# Track the in-tree FFmpeg major (container/context.yaml ffmpeg_version): these +# bind the system libraries, and video-rs 0.12 requires ffmpeg-next =9.0.0. +video-rs = { version = "0.12.0", optional = true } +ffmpeg-next = { version = "9.0.0", optional = true } memfile = { version = "0.3.2", optional = true } tokio-rayon = {version = "2" } ndarray = { version = "0.16" } diff --git a/tests/dependencies/test_ffmpeg_version_consistency.py b/tests/dependencies/test_ffmpeg_version_consistency.py new file mode 100644 index 000000000000..476934e60801 --- /dev/null +++ b/tests/dependencies/test_ffmpeg_version_consistency.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Keep the in-tree FFmpeg version identical in the three files that declare it. + +`container/context.yaml` decides what `wheel_builder.Dockerfile` actually +compiles. `container/compliance/native_packages.yaml` is what the generated SBOM +claims the image contains. The `deny_components` floor in +`container/compliance/policy/codec_policy.yaml` is what that SBOM is then +measured against. Nothing compared them, and only one direction fails loudly: an +SBOM *below* the floor is flagged, while an SBOM left above the version really +built simply makes the gate agree with a document that is wrong. + +All three read "8.1.2" before the 9.0.1 bump, so this pins an invariant the repo +already held rather than imposing a new one. + +Run as a script by the `ffmpeg-version-consistency` pre-commit hook, because +`container/context.yaml` is excluded by `.dockerignore` and so is absent from the +component images that run this suite -- the pytest cases skip there, and the hook +is what keeps the check enforced in CI. Same situation and same resolution as +`test_pynvvideocodec_spec.py`. +""" + +import sys +from pathlib import Path + +import pytest +import yaml + +pytestmark = [ + pytest.mark.pre_merge, + pytest.mark.post_merge, + pytest.mark.gpu_0, + pytest.mark.unit, + pytest.mark.parallel, +] + +ROOT = Path(__file__).resolve().parents[2] + +CONTEXT = "container/context.yaml" +NATIVE_PACKAGES = "container/compliance/native_packages.yaml" +CODEC_POLICY = "container/compliance/policy/codec_policy.yaml" + + +def _load(rel: str) -> dict: + path = ROOT / rel + if not path.is_file(): + pytest.skip(f"{rel} is not staged in this component image (.dockerignore)") + return yaml.safe_load(path.read_text()) + + +def _built_version() -> str: + return _load(CONTEXT)["dynamo"]["ffmpeg_version"] + + +def _declared_versions() -> list[str]: + packages = _load(NATIVE_PACKAGES)["packages"] + return [p["version"] for p in packages if p["name"] == "ffmpeg"] + + +def _policy_floors() -> list[str]: + components = _load(CODEC_POLICY).get("deny_components") or [] + return [ + c["min_fixed_version"] + for c in components + if c.get("name") == "ffmpeg" and "min_fixed_version" in c + ] + + +def test_sbom_declares_the_version_that_is_built() -> None: + built = _built_version() + assert _declared_versions() == [built], ( + f"{NATIVE_PACKAGES} declares ffmpeg {_declared_versions()} but {CONTEXT} " + f"builds {built}; the SBOM would describe an image that was never built" + ) + + +def test_policy_floor_matches_the_version_that_is_built() -> None: + built = _built_version() + assert _policy_floors() == [built], ( + f"{CODEC_POLICY} floors ffmpeg at {_policy_floors()} but {CONTEXT} builds " + f"{built}; the SBOM gate would accept a stale bundled ffmpeg" + ) + + +def main() -> int: + """Script entry point for the pre-commit hook.""" + for rel in (CONTEXT, NATIVE_PACKAGES, CODEC_POLICY): + if not (ROOT / rel).is_file(): + print(f"ERROR: {rel} not found") + return 1 + + built = _built_version() + failures = [] + if _declared_versions() != [built]: + failures.append( + f"{NATIVE_PACKAGES} declares ffmpeg {_declared_versions()}, " + f"{CONTEXT} builds {built}" + ) + if _policy_floors() != [built]: + failures.append( + f"{CODEC_POLICY} floors ffmpeg at {_policy_floors()}, " + f"{CONTEXT} builds {built}" + ) + + for failure in failures: + print(f"ERROR: {failure}") + if failures: + return 1 + print(f"In-tree FFmpeg {built} is declared consistently across 3 files.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From f9ff88f6bf8d2e0fc52866e588aaebc49110a60d Mon Sep 17 00:00:00 2001 From: Dmitry Tokarev Date: Wed, 30 Sep 2026 01:05:42 -0400 Subject: [PATCH 5/8] test(container): ignore error text when finding PyNvVideoCodec guards main added a DALI guard to trtllm_runtime.Dockerfile whose error message says "the way PyNvVideoCodec 2.2.0 links it into libavformat". The spec test decided which instructions guard PyNvVideoCodec by looking at every code line, including echo lines. So it counted the whole DALI instruction, read DALI's own 2.2.0 pin as a PyNvVideoCodec version, and failed the template for the wrong reason. The test now leaves diagnostic lines out of that decision too, as it already did when it collects versions. A new test feeds it a DALI-shaped guard and a PyNvVideoCodec guard in one template and expects only 2.2.3. Checks: - With the fix, the module passes 9 of 9. With the previous logic restored, the new test and the real trtllm_runtime.Dockerfile case both fail. - Each non-comment template line that carries 2.2.3 was changed to 2.2.2, one at a time. All 6 code lines fail the check. The one line that passes is the "wanted PyNvVideoCodec 2.2.3" error text, which the test leaves out on purpose. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Dmitry Tokarev --- .../dependencies/test_pynvvideocodec_spec.py | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/tests/dependencies/test_pynvvideocodec_spec.py b/tests/dependencies/test_pynvvideocodec_spec.py index 24de85cddc04..cf23105edd7a 100644 --- a/tests/dependencies/test_pynvvideocodec_spec.py +++ b/tests/dependencies/test_pynvvideocodec_spec.py @@ -169,14 +169,28 @@ def _template_pinned_versions(path: str) -> set[str]: image's 2.1.0 in its error text, and holding that to the floor would force an unrelated rewrite of the message on every bump. """ + return _pinned_versions_in((ROOT / path).read_text(encoding="utf-8")) + + +def _pinned_versions_in(text: str) -> set[str]: + """`_template_pinned_versions` over Dockerfile text rather than a path. + + Diagnostic lines are also left out when deciding which instructions guard this + package at all. The DALI guard in trtllm_runtime.Dockerfile explains itself with + "the way PyNvVideoCodec 2.2.0 links it into libavformat"; counting that message + made the whole DALI instruction a PyNvVideoCodec one and read DALI's own 2.2.0 + pin as a second PyNvVideoCodec version. + """ versions: set[str] = set() - for block in _instruction_blocks((ROOT / path).read_text(encoding="utf-8")): - code = [line for line in block if not line.lstrip().startswith("#")] + for block in _instruction_blocks(text): + code = [ + line + for line in block + if not line.lstrip().startswith("#") and not _DIAGNOSTIC.match(line) + ] if not any(DISTRIBUTION in line.lower() for line in code): continue for line in code: - if _DIAGNOSTIC.match(line): - continue # `printf '%s\n2.2.3\n'` is the shape of the shell comparisons, and the # literal backslash-n leaves no word boundary in front of the version. # Without flattening the escape first, that constant matches nothing here @@ -256,6 +270,27 @@ def test_templates_guard_on_the_same_version(path: str) -> None: assert versions == {EXPECTED_VERSION}, f"{path} guards on {sorted(versions)}" +def test_error_text_naming_the_package_does_not_claim_another_pin() -> None: + """An error message that mentions PyNvVideoCodec is not a PyNvVideoCodec guard. + + Shaped like the DALI guard in trtllm_runtime.Dockerfile, which compares DALI + against 2.2.0 with the same `sort -V` idiom and names PyNvVideoCodec only in + the text it prints on failure. + """ + template = r"""RUN set -eu; \ + newest=$(printf '%s\n2.2.0\n' "$before" | sort -V | tail -1); \ + if [ "$newest" != "2.2.0" ]; then \ + echo "ERROR: the way PyNvVideoCodec 2.2.0 links it into libavformat" >&2; \ + exit 1; \ + fi; \ + pip install 'nvidia-dali-cuda130==2.2.0' +RUN set -eu; \ + v=$(python3 -c 'import importlib.metadata as m; print(m.version("pynvvideocodec"))'); \ + [ "$v" = "2.2.3" ] || { echo "ERROR: wanted 2.2.3, got $v" >&2; exit 1; } +""" + assert _pinned_versions_in(template) == {"2.2.3"} + + def main() -> int: """Run every check outside pytest, for the `pynvvideocodec-spec` hook. From 06c2ff404e0c9d598dfc4f89582a5e06bce59143 Mon Sep 17 00:00:00 2001 From: Dmitry Tokarev Date: Wed, 30 Sep 2026 01:10:17 -0400 Subject: [PATCH 6/8] docs(container): correct PyNvVideoCodec notes for the merged bases Some comments on this branch were false after the merge, and some were already false before it. This branch made them false: - trtllm_runtime.Dockerfile said "The in-tree FFmpeg is 8.1.2" and that the SONAME change alone keeps the two FFmpeg copies apart. The in-tree build is 9.0.1 now. The --build-suffix=_dynamo rename in wheel_builder.Dockerfile keeps them apart, and the note now says so. - The same note put both 2.2.0 and 2.2.3 "at or above the floor". The floor is 9.0.1, so 2.2.0 (FFmpeg 8.1.2) sits below it. - vllm_runtime.Dockerfile and the spec test still described the old range: "REPLACED at >=2.2.3", "held to the floor", FLOOR, and a >=2.2.3,<3 example. - wheel_builder.Dockerfile kept a remark about an 8.1 -> 8.1.2 bump. The merge made them false: - TensorRT-LLM: the note named rc26 as the pinned base. main now pins rc28. Both rc27 and rc28 still ship PyNvVideoCodec 2.1.0. - vLLM: 0.30.0 moved its PyNvVideoCodec consumer to vllm/multimodal/video_decoders/pynvvideocodec.py. It uses the same five names as before. Checks: vLLM 0.30.0 decodes the H.264 and H.265 test clips through PyNvVideoCodecVideoBackend.load_bytes with 2.2.3 installed. The result matches the base's own 2.0.4: shape (8, 240, 320, 3), uint8, fps 10.0, and the same frame indices. FFmpeg 9.0.1 still opens fd: for "-i -". Co-Authored-By: Claude Opus 5.5 Signed-off-by: Dmitry Tokarev --- container/templates/trtllm_runtime.Dockerfile | 20 +++++++++---------- container/templates/vllm_runtime.Dockerfile | 14 ++++++------- container/templates/wheel_builder.Dockerfile | 8 +++----- .../dependencies/test_pynvvideocodec_spec.py | 10 +++++----- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/container/templates/trtllm_runtime.Dockerfile b/container/templates/trtllm_runtime.Dockerfile index 023406eeb0be..94dd0eebb52e 100644 --- a/container/templates/trtllm_runtime.Dockerfile +++ b/container/templates/trtllm_runtime.Dockerfile @@ -464,7 +464,7 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p # Replace the base image's PyNvVideoCodec with one past the release that stopped # shipping a separate libavcodec. rc24 is the first TensorRT-LLM base image to -# ship this package at all, and rc24 through the currently pinned rc26 all ship +# ship this package at all, and rc24 through the currently pinned rc28 all ship # 2.1.0, which bundles libavcodec, libavdevice, libavfilter, libswresample and # libswscale alongside the libavformat and libavutil it actually uses. 2.2.x # ships only the latter two -- but libavcodec is not gone, it is statically @@ -520,16 +520,14 @@ RUN --mount=type=bind,source=./container/compliance/enumerate_bundled_decoders.p # on PyNvVideoCodec_130.cpython-312-x86_64-linux-gnu.so lists libavformat.so.61, # libavcodec.so.61, libswresample.so.5 and libavutil.so.59 as NEEDED. 2.2.0 # moved to FFmpeg 8.1.2 (libavformat.so.62.12.102 / libavutil.so.60.26.102) and -# 2.2.3 to FFmpeg 9.0.1 (libavformat.so.63.1.101 / libavutil.so.61.1.101), both -# at or above the floor deny_components sets for ffmpeg in codec_policy.yaml. -# 2.1.0 sits below it. -# -# The 2.2.3 SONAME bump is worth stating because it removes a hazard rather than -# adding one. The in-tree FFmpeg is 8.1.2, so through 2.2.2 the vendored -# libavformat.so.62 / libavutil.so.60 collided with it by SONAME while being -# built with different configure flags -- whichever loaded first served the whole -# process. 9.0.1's .so.63 / .so.61 cannot collide with .so.62 / .so.60, so each -# consumer now resolves its own copy regardless of import order. +# 2.2.3 to FFmpeg 9.0.1 (libavformat.so.63.1.101 / libavutil.so.61.1.101). +# 9.0.1 is the floor deny_components sets for ffmpeg in codec_policy.yaml, so +# 2.1.0 and 2.2.0 both sit below it. +# +# The in-tree FFmpeg is 9.0.1 too, so without more the two copies would share +# SONAMEs and whichever loaded first would serve both. wheel_builder.Dockerfile +# builds ours with --build-suffix=_dynamo, which renames its SONAMEs, so each +# consumer resolves its own copy. # # Note that the gate did not tell us this. deny_components is evaluated against # SBOM components (scan_codecs.scan_sbom), and the SBOM does not enumerate diff --git a/container/templates/vllm_runtime.Dockerfile b/container/templates/vllm_runtime.Dockerfile index 15429219354e..5fca6e925456 100644 --- a/container/templates/vllm_runtime.Dockerfile +++ b/container/templates/vllm_runtime.Dockerfile @@ -634,7 +634,7 @@ RUN set -eux; \ ! ls -d "${SITE_PACKAGES}"/opencv_python*.libs 2>/dev/null; \ python3 -c "import cv2,re,sys; enabled=[name for name,value in re.findall(r'^\s*(FFMPEG|GSTREAMER):\s*(\S+)', cv2.getBuildInformation(), re.M|re.I) if value.upper()=='YES']; sys.exit('ERROR: cv2 was built with video backends: '+', '.join(enabled) if enabled else 0)" -# PyNvVideoCodec is KEPT (removed from the purge above) but REPLACED at >=2.2.3 by +# PyNvVideoCodec is KEPT (removed from the purge above) but REPLACED with 2.2.3 by # the removal and requirements install above: the base image's 2.0.4 bundles a # full FFmpeg 8.1.1 (incl. libavcodec) that the codec gate rejects, while 2.2.3 # bundles only libavutil.so.61 + libavformat.so.63 (FFmpeg 9.0.1, container @@ -653,12 +653,12 @@ RUN set -eux; \ # # The override is deliberate: 2.0.4 is the version whose bundled libavcodec the # codec gate rejects, so the override is the point. vLLM's own consumer is -# vllm/multimodal/video.py (PyNvVideoCodecVideoBackendMixin), and everything it -# touches -- SimpleDecoder, OutputColorType, get_batch_frames_by_index, -# get_stream_metadata, reconfigure_decoder -- still exists in 2.2.3. The one API -# 2.2.3 drops is SimpleDecoder.stop(), which neither vLLM nor Dynamo calls; -# `get_frames_at` in that file belongs to the torchcodec backend, not this -# package. Re-check that list when the base image moves. +# vllm/multimodal/video_decoders/pynvvideocodec.py +# (PyNvVideoCodecVideoBackendMixin), and everything it touches -- SimpleDecoder, +# OutputColorType, get_batch_frames_by_index, get_stream_metadata, +# reconfigure_decoder -- still exists in 2.2.3. The one API 2.2.3 drops is +# SimpleDecoder.stop(), which neither vLLM nor Dynamo calls. Re-check that list +# when the base image moves. ENV NVIDIA_DRIVER_CAPABILITIES=video,compute,utility {% endif %} diff --git a/container/templates/wheel_builder.Dockerfile b/container/templates/wheel_builder.Dockerfile index 7ba2d351496e..d3b2d05d054b 100644 --- a/container/templates/wheel_builder.Dockerfile +++ b/container/templates/wheel_builder.Dockerfile @@ -324,12 +324,10 @@ ENV SCCACHE_BUCKET=${USE_SCCACHE:+${SCCACHE_BUCKET}} \ # 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. +# the `fd:` protocol on ffmpeg 8.x and 9.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 LIBVPX_REF diff --git a/tests/dependencies/test_pynvvideocodec_spec.py b/tests/dependencies/test_pynvvideocodec_spec.py index cf23105edd7a..acfe9ea24f76 100644 --- a/tests/dependencies/test_pynvvideocodec_spec.py +++ b/tests/dependencies/test_pynvvideocodec_spec.py @@ -67,7 +67,7 @@ _HEREDOC = re.compile(r"<<-?'?([A-Za-z_][A-Za-z0-9_]*)'?") # A real Dockerfile instruction, used to bound an unterminated heredoc. Spelled out # rather than matched as "an uppercase word": the heredoc bodies assign uppercase -# Python constants (`FLOOR = "2.2.3"`), and a pattern that treats those as a new +# Python constants (`PINNED = "2.2.3"`), and a pattern that treats those as a new # instruction cuts the body off exactly where the constant being checked lives. _INSTRUCTION = re.compile( r"^(FROM|RUN|CMD|LABEL|MAINTAINER|EXPOSE|ENV|ADD|COPY|ENTRYPOINT|VOLUME" @@ -75,10 +75,10 @@ ) # Diagnostic output does not constrain a guard -- it describes one, and it is where # historical versions legitimately appear ("the base image's 2.1.0 copy survived the -# overlay"). Only the lines that compare or install are held to the floor. +# overlay"). Only the lines that compare or install are held to the pin. _DIAGNOSTIC = re.compile(r'^\s*(\|\|\s*\{\s*)?(echo\b|print\(|sys\.exit\(|f?")') # A PyNvVideoCodec requirement specifier written inline in a template, e.g. the -# `pip install 'PyNvVideoCodec>=2.2.3,<3'` in trtllm_runtime.Dockerfile. +# `pip install 'PyNvVideoCodec==2.2.3'` in trtllm_runtime.Dockerfile. _INLINE_SPEC = re.compile( r"(?i)pynvvideocodec\s*((?:[<>=!~]=?\s*[0-9][^\'\"\s;]*)(?:\s*,\s*[<>=!~]=?\s*[0-9][^\'\"\s;]*)*)" ) @@ -91,7 +91,7 @@ def _instruction_blocks(text: str) -> list[list[str]]: BuildKit heredocs. Matching has to be per instruction, not per file, because the ``sort -V`` comparison idiom the TRT-LLM guard uses is also how the DALI pin in the same file is written -- a file-wide scan reads DALI's version as a - PyNvVideoCodec floor and fails for the wrong reason. + PyNvVideoCodec version and fails for the wrong reason. Comment lines are excluded from both the continuation and the heredoc tests: a comment that quotes ``<<'PYEOF'`` or ends in a backslash would otherwise swallow @@ -166,7 +166,7 @@ def _template_pinned_versions(path: str) -> set[str]: Diagnostic lines are excluded, because that is where versions the guard is *not* comparing against legitimately appear -- the post-overlay check names the base - image's 2.1.0 in its error text, and holding that to the floor would force an + image's 2.1.0 in its error text, and holding that to the pin would force an unrelated rewrite of the message on every bump. """ return _pinned_versions_in((ROOT / path).read_text(encoding="utf-8")) From 3f080e6c091cf13dc8063d7ad3279fd816724b03 Mon Sep 17 00:00:00 2001 From: Dmitry Tokarev Date: Wed, 30 Sep 2026 11:43:11 -0400 Subject: [PATCH 7/8] test(container): skip requirements checks when an image has no container/ The vllm, sglang and trtllm runtime-test images have no /workspace/container directory. Run by path inside the vLLM image for 06c2ff404e, test_pynvvideocodec_spec.py failed 4 checks with FileNotFoundError on container/deps/requirements.*.txt. The template checks already skip when their file is absent, and the requirements checks now do the same. CI did not see this, because CI runs the module only in the dynamo-runtime image, which ships container/deps. Script mode still reports a missing requirements file as an error. The pynvvideocodec-spec pre-commit hook runs that mode in a checkout. Checks: - Inside the vLLM runtime-test image, with the fixed file mounted over the image's copy: 4 failed before, 3 passed and 8 skipped after. - Locally the module passes 11 of 11, and pre-commit passes. - Removing the skip fails the new skip test. Making main() ignore skipped checks fails the new script-mode test. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Dmitry Tokarev --- .../dependencies/test_pynvvideocodec_spec.py | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/tests/dependencies/test_pynvvideocodec_spec.py b/tests/dependencies/test_pynvvideocodec_spec.py index acfe9ea24f76..ea04f84e5cef 100644 --- a/tests/dependencies/test_pynvvideocodec_spec.py +++ b/tests/dependencies/test_pynvvideocodec_spec.py @@ -16,15 +16,18 @@ The template half only runs where the templates are on disk. `.dockerignore` excludes `container/**/*.Dockerfile` from every build context except -`wheel_builder.Dockerfile`, so the component images that run this suite ship -`container/deps/` but not `container/templates/`, and those cases skip there -- +`wheel_builder.Dockerfile`, so the dynamo-runtime image that runs this suite in CI +ships `container/deps/` but not `container/templates/`, and those cases skip there -- same situation, and the same resolution, as `test_aisimulate_consistency.py`'s -planner-Dockerfile check. The `pynvvideocodec-spec` pre-commit hook runs this -module as a script against a real checkout, which is what keeps the template half -enforced in CI rather than only on a developer's machine. +planner-Dockerfile check. The vllm, sglang and trtllm runtime-test images ship no +`container/` at all, so run by hand there, the requirements cases skip too. The +`pynvvideocodec-spec` pre-commit hook runs this module as a script against a real +checkout, which is what keeps both halves enforced in CI rather than only on a +developer's machine. """ import re +import sys from pathlib import Path import pytest @@ -138,8 +141,13 @@ def _requirement(path: str) -> Requirement: opaque parse error. Selection is by canonical distribution name rather than by prefix, so a differently-named sibling cannot be mistaken for this one. """ + requirements = ROOT / path + if not requirements.is_file(): + # The framework runtime-test images ship no container/ directory at all. + # main() still reports this as a failure, where a checkout always has it. + pytest.skip(f"{path} is not present in this image") found: list[Requirement] = [] - for raw in (ROOT / path).read_text(encoding="utf-8").splitlines(): + for raw in requirements.read_text(encoding="utf-8").splitlines(): line = raw.split("#", 1)[0].strip() if not line or line.startswith("-"): continue @@ -291,6 +299,42 @@ def test_error_text_naming_the_package_does_not_claim_another_pin() -> None: assert _pinned_versions_in(template) == {"2.2.3"} +def test_requirements_missing_from_the_image_skip(tmp_path, monkeypatch) -> None: + """The vllm, sglang and trtllm runtime-test images have no container/ at all. + + Run by path inside one of them, the requirements checks used to fail with + FileNotFoundError instead of skipping the way the template checks do. + """ + monkeypatch.setattr(sys.modules[__name__], "ROOT", tmp_path) + with pytest.raises(pytest.skip.Exception): + test_requirements_declare_the_expected_spec(REQUIREMENTS[0]) + with pytest.raises(pytest.skip.Exception): + test_the_spec_is_bounded_above() + + +def test_script_mode_still_fails_on_missing_requirements(tmp_path, monkeypatch) -> None: + """The pre-commit hook runs in a checkout, where a missing file is an error. + + The templates here are minimal but valid, so the requirements files are the + only thing missing, and main() must report them rather than skip them. + """ + guard = ( + "RUN set -eu; \\\n" + " v=$(python3 -c 'import importlib.metadata as m; " + 'print(m.version("pynvvideocodec"))\'); \\\n' + f" pip install 'PyNvVideoCodec{EXPECTED_SPEC}'; \\\n" + f' [ "$v" = "{EXPECTED_VERSION}" ]\n' + ) + for path in TEMPLATES: + (tmp_path / path).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / path).write_text(guard, encoding="utf-8") + monkeypatch.setattr(sys.modules[__name__], "ROOT", tmp_path) + test_templates_install_the_same_spec() + for path in TEMPLATES: + test_templates_guard_on_the_same_version(path) + assert main() == 1 + + def main() -> int: """Run every check outside pytest, for the `pynvvideocodec-spec` hook. From 88ab323da043d6dea88792e8e9dce35062061bfe Mon Sep 17 00:00:00 2001 From: Dmitry Tokarev Date: Fri, 2 Oct 2026 16:46:13 -0400 Subject: [PATCH 8/8] fix(container): deny hash-renamed _dynamo libraries in the codec gate auditwheel renames each library that it grafts into a wheel to -.so.N (copylib in auditwheel 6.8.2 repair.py). A grafted libavcodec_dynamo.so.63 therefore lands as libavcodec_dynamo-.so.63. The suffixed deny globs matched only the plain name, so scan_filesystem gave no verdict for the hashed form. Each suffixed family now has the same plain and hash-renamed pair as the families above it. The policy comment no longer carries the measured counts from the first review round. The PR description keeps them. Checks: - The extended test plants both names for libavcodec, libswscale and libpostproc under the .libs directory of a wheel. On the old policy it fails, because the 3 hashed paths are not violations. - Deleting any one of the 6 suffixed globs fails the test. The old test still passed with the plain libpostproc_dynamo glob deleted. - The vLLM, SGLang and TensorRT-LLM runtime-test images for 06c2ff404e give the same scan result with both policies: 0 violations, and the same exceptions and allowed files. - The compliance suite passes 99 tests on macOS. test_scan_codecs.py passes 23 tests in a Linux container with no network. pre-commit passes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Dmitry Tokarev --- container/compliance/policy/codec_policy.yaml | 12 ++++++----- .../compliance/tests/test_scan_codecs.py | 20 +++++++++++++++---- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/container/compliance/policy/codec_policy.yaml b/container/compliance/policy/codec_policy.yaml index 320a0a41616c..0227aaabb40d 100644 --- a/container/compliance/policy/codec_policy.yaml +++ b/container/compliance/policy/codec_policy.yaml @@ -49,14 +49,16 @@ deny_globs: - "**/libpostproc-*.so*" # Our in-tree build carries --build-suffix=_dynamo so its SONAMEs cannot collide # with the FFmpeg a wheel vendors (see wheel_builder.Dockerfile). The suffix is - # part of the library name, so none of the globs above reach it. Measured by - # deleting these three lines and rescanning one tree: violations 1 -> 0 and - # allowed 6 -> 0, i.e. the gate goes blind to every suffixed library, ours and a - # planted third-party one alike. allow_paths re-permits ours by path prefix, so - # a third-party suffixed libavcodec still fails. + # part of the library name, so none of the globs above reach it, and each + # suffixed family needs the same plain and auditwheel hash-renamed pair. + # allow_paths re-permits ours by path prefix, so a third-party suffixed library + # still fails. - "**/libav*_dynamo.so*" + - "**/libav*_dynamo-*.so*" - "**/libsw*_dynamo.so*" + - "**/libsw*_dynamo-*.so*" - "**/libpostproc_dynamo.so*" + - "**/libpostproc_dynamo-*.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" diff --git a/container/compliance/tests/test_scan_codecs.py b/container/compliance/tests/test_scan_codecs.py index c8e50bb285f1..f4c87df318e0 100644 --- a/container/compliance/tests/test_scan_codecs.py +++ b/container/compliance/tests/test_scan_codecs.py @@ -59,12 +59,24 @@ def test_third_party_suffixed_codec_lib_is_denied(tmp_path: Path): # allow_paths re-permits the suffixed names by /usr/local path prefix only. # The same soname vendored by a wheel is still a violation — otherwise the # suffix would hand anyone a way past the gate just by copying our naming. - rel = ( - "usr/local/lib/python3.12/dist-packages/somewheel/.libs/libavcodec_dynamo.so.63" + # auditwheel grafts a library as -.so.N, so each suffixed family + # is planted under both names. + libs = "usr/local/lib/python3.12/dist-packages/somewheel/.libs" + rels = sorted( + f"{libs}/{name}" + for name in ( + "libavcodec_dynamo.so.63", + "libavcodec_dynamo-1a2b3c4d.so.63", + "libswscale_dynamo.so.10", + "libswscale_dynamo-1a2b3c4d.so.10", + "libpostproc_dynamo.so.58", + "libpostproc_dynamo-1a2b3c4d.so.58", + ) ) - _touch(tmp_path, rel) + for rel in rels: + _touch(tmp_path, rel) violations, _, _ = scan_filesystem(tmp_path, _POLICY) - assert [v["path"] for v in violations] == ["/" + rel] + assert sorted(v["path"] for v in violations) == ["/" + rel for rel in rels] def test_dali_bundled_ffmpeg_is_a_logged_exception(tmp_path: Path):