• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nvidia-holoscan / holoscan-cli / 28717141056

04 Jul 2026 07:26PM UTC coverage: 79.34% (+2.1%) from 77.248%
28717141056

Pull #198

github

wyli
fix: add destructive-boundary guard to clear-cache

`clear-cache` fed shutil.rmtree() with directories derived from
env-overridable roots (HOLOSCAN_CLI_BUILD_PARENT_DIR / _DATA_DIR) and
repo-root globs, with no safety boundary. A hostile or fat-fingered
value such as `HOLOSCAN_CLI_BUILD_PARENT_DIR=/` made the command report
`Would remove: /` and, without --dryrun under root, would wipe the
filesystem.

Add `_is_safe_to_remove()`: canonicalize each candidate and refuse it
unless it is (1) not a critical anchor (`/`, $HOME, the repo root) or an
ancestor of one, and (2) at or under an approved cache root (the repo
tree, the build parent dir, or the data dir). Refused paths print a
`Refusing to remove:` notice and are skipped.

Also fix the policy regression where `test --clear-cache` forwarded the
test namespace directly: with build/data/install unset, clear-cache
treated it as "clear everything" including downloaded data. Select
build/install explicitly, matching historical HoloHub behavior.

Add destructive-boundary tests covering /, $HOME, the repo root, an
ancestor of the repo root, the happy path, and data preservation on
`test --clear-cache`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull Request #198: fix: destructive-boundary guard for clear-cache

175 of 200 new or added lines in 9 files covered. (87.5%)

1 existing line in 1 file now uncovered.

3149 of 3969 relevant lines covered (79.34%)

0.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.23
/src/holoscan_cli/utils/host_setup.py
1
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
# SPDX-License-Identifier: Apache-2.0
3
#
4
# Licensed under the Apache License, Version 2.0 (the "License");
5
# you may not use this file except in compliance with the License.
6
# You may obtain a copy of the License at
7
#
8
# http://www.apache.org/licenses/LICENSE-2.0
9
#
10
# Unless required by applicable law or agreed to in writing, software
11
# distributed under the License is distributed on an "AS IS" BASIS,
12
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
# See the License for the specific language governing permissions and
14
# limitations under the License.
15

16
"""Host environment setup helpers used by ``holoscan setup``.
17

18
This module bundles two concerns:
19

20
* Low-level ``apt`` package-management primitives (``install_packages_if_missing``,
21
  ``install_cuda_dependencies_package``, ``PackageInstallationError`` ...) used
22
  to install host packages with version constraints.
23
* High-level ``setup_*`` orchestrators (``setup_cmake``, ``setup_python_dev``,
24
  ``setup_ngc_cli``, ``setup_sccache``, ``setup_cuda_*``) that wrap apt / wget /
25
  vendor shell scripts into idempotent helpers callable individually or together
26
  via ``handle_setup`` in ``commands/setup_cmd.py``.
27
"""
28

29
import os
1✔
30
import platform
1✔
31
import re
1✔
32
import shutil
1✔
33
import subprocess
1✔
34
import sys
1✔
35
from typing import List, Optional
1✔
36

37
from holoscan_cli.utils.io import fatal, info, run_command, warn, write_system_file
1✔
38
from holoscan_cli.utils.sdk import get_cuda_runtime_version
1✔
39
from holoscan_cli.utils.text import parse_semantic_version
1✔
40

41
# ---- apt package management primitives --------------------------------------
42

43
_apt_updated = False  # track whether apt update has been called
1✔
44

45

46
class PackageInstallationError(Exception):
1✔
47
    """Raised when a package cannot be installed via apt"""
48

49
    def __init__(self, package_name: str, version_pattern: str, message: str = None):
1✔
50
        self.package_name = package_name
1✔
51
        self.version_pattern = version_pattern
1✔
52
        super().__init__(
1✔
53
            message or f"Failed to install package {package_name} matching {version_pattern}"
54
        )
55

56

57
def get_installed_package_version(package_name: str) -> Optional[str]:
1✔
58
    """Get the installed version of a package"""
59
    try:
×
60
        result = subprocess.run(
×
61
            ["dpkg-query", "-W", "-f=${Version}", package_name],
62
            capture_output=True,
63
            text=True,
64
            check=False,
65
        )
66
        return result.stdout.strip() if result.returncode == 0 else None
×
67
    except Exception:
×
68
        return None
×
69

70

71
def get_available_package_versions(package_name: str) -> List[str]:
1✔
72
    """Get available versions of a package from apt"""
73
    try:
×
74
        result = subprocess.run(
×
75
            ["apt-cache", "madison", package_name], capture_output=True, text=True, check=False
76
        )
77
        if result.returncode != 0:
×
78
            return []
×
79

80
        versions = []
×
81
        for line in result.stdout.strip().split("\n"):
×
82
            if line.strip():
×
83
                parts = line.split("|")
×
84
                if len(parts) >= 2:
×
85
                    version = parts[1].strip()
×
86
                    if version:
×
87
                        versions.append(version)
×
88
        return versions
×
89
    except Exception:
×
90
        return []
×
91

92

93
def ensure_apt_updated(dry_run: bool = False) -> None:
1✔
94
    """Ensure apt package list is updated, but only once per session"""
95
    global _apt_updated
96
    if not _apt_updated:
×
NEW
97
        run_command(["apt-get", "update"], dry_run=dry_run, as_root=True)
×
98
        _apt_updated = True
×
99

100

101
def install_packages_if_missing(
1✔
102
    packages: List[str], dry_run: bool = False, apt_options: List[str] = None
103
) -> List[str]:
104
    """Install packages only if they're not already installed
105

106
    Args:
107
        packages: List of package names to install (can include version specs like "pkg=1.0*")
108
            Note: If package has a version spec, it always runs sudo apt install to ensure version.
109
        dry_run: Whether to perform a dry run
110
        apt_options: Additional options for apt install
111

112
    Returns:
113
        List of packages that were actually installed (or would be installed in dry run)
114
    """
115
    if apt_options is None:
1✔
116
        apt_options = ["--no-install-recommends", "-y"]
×
117

118
    packages_to_install = []
1✔
119

120
    for package_spec in packages:
1✔
121
        package_name = package_spec.split("=")[0]
1✔
122

123
        if "=" in package_spec:
1✔
124
            packages_to_install.append(package_spec)
1✔
125
            info(f"Installing {package_spec}")
1✔
126
        else:
127
            if get_installed_package_version(package_name):
1✔
128
                info(f"Package {package_name} is already installed")
1✔
129
            else:
130
                packages_to_install.append(package_spec)
1✔
131

132
    if packages_to_install:
1✔
133
        ensure_apt_updated(dry_run=dry_run)
1✔
134
        install_cmd = ["apt", "install"] + apt_options + packages_to_install
1✔
135
        run_command(install_cmd, dry_run=dry_run, as_root=True)
1✔
136

137
    return packages_to_install
1✔
138

139

140
def install_cuda_dependencies_package(
1✔
141
    package_name: str,
142
    version_pattern: str = r"\d+\.\d+\.\d+",
143
    dry_run: bool = False,
144
) -> str:
145
    """Install CUDA dependencies package with version checking
146

147
    Args:
148
        package_name: Name of the package to install
149
        version_pattern: Regular expression for package version to install
150
        dry_run: Whether to perform a dry run
151

152
    Returns:
153
        str: Installed package version
154

155
    Raises:
156
        PackageInstallationError: If package cannot be installed
157
    """
158
    installed_version = get_installed_package_version(package_name)
1✔
159
    if installed_version:
1✔
160
        if re.search(version_pattern, installed_version):
1✔
161
            info(f"Package {package_name} version {installed_version} already installed")
1✔
162
            return installed_version
1✔
163
        else:
164
            info(f"{package_name} version {installed_version} not match pattern {version_pattern}")
×
165

166
    available_versions = get_available_package_versions(package_name)
1✔
167
    if not available_versions:
1✔
168
        raise PackageInstallationError(
×
169
            package_name, version_pattern, f"No versions available for {package_name}"
170
        )
171

172
    matching_versions = [v for v in available_versions if re.search(version_pattern, v)]
1✔
173
    if not matching_versions:
1✔
174
        raise PackageInstallationError(
1✔
175
            package_name,
176
            version_pattern,
177
            f"{package_name} has no versions matching pattern {version_pattern}.\n"
178
            f"Available versions: {', '.join(available_versions[:5])}\n"
179
            f"You might need to install manually: sudo apt install {package_name}",
180
        )
181

182
    target_version = matching_versions[0]
1✔
183
    install_packages_if_missing(
1✔
184
        [f"{package_name}={target_version}"],
185
        apt_options=["--no-install-recommends", "-y", "--allow-downgrades"],
186
        dry_run=dry_run,
187
    )
188

189
    return target_version
1✔
190

191

192
def get_ubuntu_codename() -> str:
1✔
193
    """Get Ubuntu codename from os-release"""
194
    try:
×
195
        with open("/etc/os-release") as f:
×
196
            content = f.read()
×
197
        match = re.search(r"UBUNTU_CODENAME=(\w+)", content)
×
198
        return match.group(1) if match else "jammy"
×
199
    except (FileNotFoundError, AttributeError):
×
200
        return "jammy"
×
201

202

203
# ---- high-level setup_* orchestrators ---------------------------------------
204

205

206
def setup_cmake(min_version: str = "3.26.4", dry_run: bool = False) -> None:
1✔
207
    """Setup CMake from Kitware if needed"""
208
    global _apt_updated
209
    cmake_ver = get_installed_package_version("cmake")
1✔
210
    if cmake_ver and parse_semantic_version(cmake_ver) >= parse_semantic_version(min_version):
1✔
211
        return
×
212
    ubuntu_codename = get_ubuntu_codename()
1✔
213
    install_packages_if_missing(["gpg", "wget"], dry_run=dry_run)
1✔
214

215
    keyring_path = "/usr/share/keyrings/kitware-archive-keyring.gpg"
1✔
216
    source_line = (
1✔
217
        "deb [signed-by=/usr/share/keyrings/kitware-archive-keyring.gpg] "
218
        f"https://apt.kitware.com/ubuntu/ {ubuntu_codename} main\n"
219
    )
220
    if dry_run:
1✔
221
        info(f"[dryrun] Would fetch the Kitware archive key -> {keyring_path}")
1✔
222
        write_system_file("/etc/apt/sources.list.d/kitware.list", source_line, dry_run=True)
1✔
223
    else:
224
        # Fetch + dearmor the key as the invoking user; install the keyring as
225
        # root. Two separate steps (not a shell pipeline) so a download failure
226
        # cannot be masked by the pipeline's exit status and produce an empty
227
        # keyring.
NEW
228
        try:
×
NEW
229
            key = subprocess.run(
×
230
                ["wget", "-qO-", "https://apt.kitware.com/keys/kitware-archive-latest.asc"],
231
                check=True,
232
                capture_output=True,
233
            ).stdout
NEW
234
            dearmored = subprocess.run(
×
235
                ["gpg", "--dearmor"],
236
                input=key,
237
                check=True,
238
                capture_output=True,
239
            ).stdout
NEW
240
        except FileNotFoundError as e:
×
NEW
241
            fatal(f"Failed to download the Kitware apt archive key: {e}")
×
NEW
242
        except subprocess.CalledProcessError as e:
×
NEW
243
            fatal(
×
244
                "Failed to download the Kitware apt archive key "
245
                f"(is the network available?): {e.stderr.decode(errors='replace').strip()}"
246
            )
NEW
247
        write_system_file(keyring_path, dearmored)
×
NEW
248
        write_system_file("/etc/apt/sources.list.d/kitware.list", source_line)
×
249
    # The new source must be visible to apt even when an earlier step already
250
    # ran `apt-get update` (which would make install_packages_if_missing skip it).
251
    run_command(["apt-get", "update"], dry_run=dry_run, as_root=True)
1✔
252
    _apt_updated = True
1✔
253
    install_packages_if_missing(["cmake", "cmake-curses-gui"], dry_run=dry_run)
1✔
254

255

256
def setup_python_dev(min_version: str = "3.10.0", dry_run: bool = False) -> None:
1✔
257
    """Setup Python development packages"""
258
    python_version = sys.version_info
×
259
    python_dev_package = f"python3.{python_version.minor}-dev"
×
260
    pydev_ver = get_installed_package_version(python_dev_package)
×
261
    if not pydev_ver:
×
262
        pydev_ver = get_installed_package_version("python3-dev")
×
263
    if not pydev_ver or parse_semantic_version(pydev_ver) < parse_semantic_version(min_version):
×
264
        install_packages_if_missing([python_dev_package], dry_run=dry_run)
×
265

266

267
def setup_ngc_cli(dry_run: bool = False) -> None:
1✔
268
    """Setup NGC CLI if not present"""
269
    # Container build (root) = system-wide, on PATH in the built image;
270
    # host = per-user under ~/.local/bin (no root needed).
271
    if os.geteuid() == 0:
1✔
272
        dest_dir = "/usr/local/bin"
1✔
273
    else:
274
        dest_dir = os.path.expanduser("~/.local/bin")
1✔
275
    dest = os.path.join(dest_dir, "ngc")
1✔
276
    # Also check the destination itself: ~/.local/bin may not be on PATH, and
277
    # `which` alone would then re-download NGC on every setup run. A dangling
278
    # symlink fails the check and is repaired by the `ln -sf` below.
279
    if shutil.which("ngc") or (os.path.isfile(dest) and os.access(dest, os.X_OK)):
1✔
280
        return
1✔
281
    if os.path.isdir(dest):
1✔
282
        fatal(f"Cannot install NGC CLI: destination is a directory: {dest}")
1✔
283

284
    arch_suffix = "arm64" if platform.machine() == "aarch64" else "linux"
1✔
285
    ngc_url = (
1✔
286
        "https://api.ngc.nvidia.com/v2/resources/nvidia/ngc-apps/ngc_cli"
287
        f"/versions/3.64.3/files/ngccli_{arch_suffix}.zip"
288
    )
289
    ngc_filename = f"ngccli_{arch_suffix}.zip"
1✔
290

291
    try:
1✔
292
        run_command(
1✔
293
            ["wget", "--quiet", "--content-disposition", ngc_url, "-O", ngc_filename],
294
            dry_run=dry_run,
295
        )
296
        run_command(["unzip", "-q", ngc_filename], dry_run=dry_run)
1✔
297
        run_command(["chmod", "u+x", "ngc-cli/ngc"], dry_run=dry_run)
1✔
298

299
        abs_path = os.path.abspath("ngc-cli/ngc")
1✔
300
        if not dry_run:
1✔
301
            os.makedirs(dest_dir, exist_ok=True)
1✔
302
        run_command(["ln", "-sf", abs_path, dest], dry_run=dry_run)
1✔
303
        if not dry_run and not shutil.which("ngc"):
1✔
304
            info(f"Installed ngc to {dest_dir}; add it to PATH to use 'ngc' directly.")
1✔
305

306
    except Exception as e:
×
307
        fatal(f"Failed to install NGC CLI: {e}")
×
308

309

310
def setup_sccache(min_version: str = "0.12.0-rapids.20", dry_run: bool = False) -> None:
1✔
311
    """
312
    Install RAPIDS sccache if missing or older than min_version.
313

314
    Requirements:
315
        - Only RAPIDS-formatted versions are supported, e.g. "0.12.0-rapids.20".
316

317
    Args:
318
        min_version: Minimum required RAPIDS version string ("[v]MAJOR.MINOR.PATCH-rapids.CUSTOM").
319
        dry_run: If True, print commands without executing them.
320
    """
321
    from holoscan_cli.utils.holohub import get_holohub_setup_scripts_dir
1✔
322

323
    script_path = get_holohub_setup_scripts_dir() / "sccache.sh"
1✔
324
    if not script_path.exists():
1✔
325
        warn(f"sccache setup script not found: {script_path}")
×
326
        return
×
327

328
    env = os.environ.copy()
1✔
329
    env["SCCACHE_MIN_VERSION"] = min_version
1✔
330
    run_command(["bash", str(script_path)], dry_run=dry_run, env=env)
1✔
331

332

333
def setup_cuda_dependencies(dry_run: bool = False) -> None:
1✔
334
    """Setup CUDA dependencies if CUDA runtime is available"""
335
    cuda_runtime_version = get_cuda_runtime_version()
1✔
336
    if cuda_runtime_version:
1✔
337
        cuda_major_version = cuda_runtime_version.split(".")[0]
1✔
338
        setup_cuda_packages(cuda_major_version, dry_run)
1✔
339
    else:
340
        info("CUDA Runtime package not found, skipping CUDA package installation")
×
341

342

343
def setup_cuda_packages(cuda_major_version: str, dry_run: bool = False) -> None:
1✔
344
    """Install CUDA packages for Holoscan SDK development"""
345

346
    # Attempt to install cudnn9
347
    CUDNN_9_PATTERN = r"9\.[0-9]+\.[0-9]+\.[0-9]+\-[0-9]+"
1✔
348
    try:
1✔
349
        installed_cudnn9_version = install_cuda_dependencies_package(
1✔
350
            package_name=f"libcudnn9-cuda-{cuda_major_version}",
351
            version_pattern=CUDNN_9_PATTERN,
352
            dry_run=dry_run,
353
        )
354
        install_cuda_dependencies_package(
1✔
355
            package_name=f"libcudnn9-dev-cuda-{cuda_major_version}",
356
            version_pattern=re.escape(installed_cudnn9_version),
357
            dry_run=dry_run,
358
        )
359
    except PackageInstallationError as e:
1✔
360
        info(f"cuDNN 9.x installation failed, falling back to cuDNN 8.x: {e}")
1✔
361
        try:
1✔
362
            # Fall back to cudnn8
363
            CUDNN_8_PATTERN = rf"8\.[0-9]+\.[0-9]+\.[0-9]+\-[0-9]\+cuda{cuda_major_version}\.[0-9]+"
1✔
364
            installed_cudnn8_version = install_cuda_dependencies_package(
1✔
365
                package_name="libcudnn8",
366
                version_pattern=CUDNN_8_PATTERN,
367
                dry_run=dry_run,
368
            )
369
            install_cuda_dependencies_package(
1✔
370
                package_name="libcudnn8-dev",
371
                version_pattern=re.escape(installed_cudnn8_version),
372
                dry_run=dry_run,
373
            )
374
        except PackageInstallationError as e:
×
375
            info(f"cuDNN 8.x installation failed: {e}.")
×
376
            info("cuDNN packages may need to be installed manually.")
×
377

378
    # Install TensorRT dependencies
379
    NVINFER_PATTERN = rf"\d+\.[0-9]+\.[0-9]+\.[0-9]+-[0-9]\+cuda{cuda_major_version}\.[0-9]+"
1✔
380
    try:
1✔
381
        installed_libnvinferversion = install_cuda_dependencies_package(
1✔
382
            package_name="libnvinfer10",
383
            version_pattern=NVINFER_PATTERN,
384
            dry_run=dry_run,
385
        )
386
        libnvinfer_pattern = re.escape(installed_libnvinferversion)
1✔
387

388
        install_packages_if_missing(
1✔
389
            [
390
                f"libnvinfer-bin={installed_libnvinferversion}",
391
                f"libnvinfer-lean10={installed_libnvinferversion}",
392
                f"libnvinfer-plugin10={installed_libnvinferversion}",
393
                f"libnvinfer-vc-plugin10={installed_libnvinferversion}",
394
                f"libnvinfer-dispatch10={installed_libnvinferversion}",
395
                f"libnvonnxparsers10={installed_libnvinferversion}",
396
            ],
397
            apt_options=["--no-install-recommends", "-y", "--allow-downgrades"],
398
            dry_run=dry_run,
399
        )
400

401
        for trt_package_name in [
1✔
402
            "libnvinfer-headers-dev",
403
            "libnvinfer-safe-headers-dev",
404
            "libnvinfer-dev",
405
            "libnvinfer-headers-plugin-dev",
406
            "libnvinfer-plugin-dev",
407
            "libnvonnxparsers-dev",
408
        ]:
409
            install_cuda_dependencies_package(
1✔
410
                package_name=trt_package_name,
411
                version_pattern=libnvinfer_pattern,
412
                dry_run=dry_run,
413
            )
414
    except PackageInstallationError as e:
1✔
415
        info(f"TensorRT installation failed: {e}")
1✔
416
        info("Continuing with setup - TensorRT packages may need to be installed manually")
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc