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

nvidia-holoscan / holoscan-cli / 28579343741

02 Jul 2026 09:20AM UTC coverage: 77.077% (-0.2%) from 77.248%
28579343741

Pull #193

github

wyli
fix: harden setup elevation and Kitware repo flow (review feedback)

Address three CodeRabbit findings on the setup path:

- io.py: `--dryrun` no longer requires sudo to be installed. When sudo is
  missing on a non-root host, dry-run prints the would-be `sudo ...`
  command instead of exiting; real execution still fails with the clear
  actionable message. Adds unit tests for both paths and for the
  run-directly-as-root case.

- host_setup.py: fetch and dearmor the Kitware key as two separate
  subprocess steps instead of a `wget | gpg` shell pipeline, so a download
  failure cannot be masked by the pipeline's exit status and install an
  empty keyring (also removes shell=True).

- host_setup.py: force `apt-get update` after adding the Kitware apt
  source. `_apt_updated` may already be true from an earlier step, which
  made install_packages_if_missing skip the refresh and miss Kitware's
  cmake packages.

Signed-off-by: Wenqi Li <wenqil@nvidia.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pull Request #193: fix: install CLI in one env and elevate only per-operation

31 of 71 new or added lines in 3 files covered. (43.66%)

3 existing lines in 2 files now uncovered.

2969 of 3852 relevant lines covered (77.08%)

0.77 hits per line

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

47.06
/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")
×
210
    if cmake_ver and parse_semantic_version(cmake_ver) >= parse_semantic_version(min_version):
×
211
        return
×
212
    ubuntu_codename = get_ubuntu_codename()
×
213
    install_packages_if_missing(["gpg"], dry_run=dry_run)
×
214

NEW
215
    keyring_path = "/usr/share/keyrings/kitware-archive-keyring.gpg"
×
NEW
216
    source_line = (
×
217
        "deb [signed-by=/usr/share/keyrings/kitware-archive-keyring.gpg] "
218
        f"https://apt.kitware.com/ubuntu/ {ubuntu_codename} main\n"
219
    )
NEW
220
    if dry_run:
×
NEW
221
        info(f"[dryrun] Would fetch the Kitware archive key -> {keyring_path}")
×
NEW
222
        write_system_file("/etc/apt/sources.list.d/kitware.list", source_line, dry_run=True)
×
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 subprocess.CalledProcessError as e:
×
NEW
241
            fatal(
×
242
                "Failed to download the Kitware apt archive key "
243
                f"(is the network available?): {e.stderr.decode(errors='replace').strip()}"
244
            )
NEW
245
        write_system_file(keyring_path, dearmored)
×
NEW
246
        write_system_file("/etc/apt/sources.list.d/kitware.list", source_line)
×
247
    # The new source must be visible to apt even when an earlier step already
248
    # ran `apt-get update` (which would make install_packages_if_missing skip it).
NEW
249
    run_command(["apt-get", "update"], dry_run=dry_run, as_root=True)
×
NEW
250
    _apt_updated = True
×
UNCOV
251
    install_packages_if_missing(["cmake", "cmake-curses-gui"], dry_run=dry_run)
×
252

253

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

264

265
def setup_ngc_cli(dry_run: bool = False) -> None:
1✔
266
    """Setup NGC CLI if not present"""
267
    if shutil.which("ngc"):
×
268
        return
×
269

270
    arch_suffix = "arm64" if platform.machine() == "aarch64" else "linux"
×
271
    ngc_url = (
×
272
        "https://api.ngc.nvidia.com/v2/resources/nvidia/ngc-apps/ngc_cli"
273
        f"/versions/3.64.3/files/ngccli_{arch_suffix}.zip"
274
    )
275
    ngc_filename = f"ngccli_{arch_suffix}.zip"
×
276

277
    try:
×
278
        run_command(
×
279
            ["wget", "--quiet", "--content-disposition", ngc_url, "-O", ngc_filename],
280
            dry_run=dry_run,
281
        )
282
        run_command(["unzip", "-q", ngc_filename], dry_run=dry_run)
×
283
        run_command(["chmod", "u+x", "ngc-cli/ngc"], dry_run=dry_run)
×
284

285
        # Link into the user's ~/.local/bin (on PATH for most shells) — no root needed.
286
        abs_path = os.path.abspath("ngc-cli/ngc")
×
NEW
287
        local_bin = os.path.expanduser("~/.local/bin")
×
NEW
288
        if not dry_run:
×
NEW
289
            os.makedirs(local_bin, exist_ok=True)
×
NEW
290
        run_command(["ln", "-sf", abs_path, os.path.join(local_bin, "ngc")], dry_run=dry_run)
×
NEW
291
        if not dry_run and not shutil.which("ngc"):
×
NEW
292
            info(f"Installed ngc to {local_bin}; add it to PATH to use 'ngc' directly.")
×
293

294
    except Exception as e:
×
295
        fatal(f"Failed to install NGC CLI: {e}")
×
296

297

298
def setup_sccache(min_version: str = "0.12.0-rapids.20", dry_run: bool = False) -> None:
1✔
299
    """
300
    Install RAPIDS sccache if missing or older than min_version; link into /usr/local/bin.
301

302
    Requirements:
303
        - Only RAPIDS-formatted versions are supported, e.g. "0.12.0-rapids.20".
304

305
    Args:
306
        min_version: Minimum required RAPIDS version string ("[v]MAJOR.MINOR.PATCH-rapids.CUSTOM").
307
        dry_run: If True, print commands without executing them.
308
    """
309
    from holoscan_cli.utils.holohub import get_holohub_setup_scripts_dir
×
310

311
    script_path = get_holohub_setup_scripts_dir() / "sccache.sh"
×
312
    if not script_path.exists():
×
313
        warn(f"sccache setup script not found: {script_path}")
×
314
        return
×
315

316
    env = os.environ.copy()
×
317
    env["SCCACHE_MIN_VERSION"] = min_version
×
318
    run_command(["bash", str(script_path)], dry_run=dry_run, env=env)
×
319

320

321
def setup_cuda_dependencies(dry_run: bool = False) -> None:
1✔
322
    """Setup CUDA dependencies if CUDA runtime is available"""
323
    cuda_runtime_version = get_cuda_runtime_version()
1✔
324
    if cuda_runtime_version:
1✔
325
        cuda_major_version = cuda_runtime_version.split(".")[0]
1✔
326
        setup_cuda_packages(cuda_major_version, dry_run)
1✔
327
    else:
328
        info("CUDA Runtime package not found, skipping CUDA package installation")
×
329

330

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

334
    # Attempt to install cudnn9
335
    CUDNN_9_PATTERN = r"9\.[0-9]+\.[0-9]+\.[0-9]+\-[0-9]+"
1✔
336
    try:
1✔
337
        installed_cudnn9_version = install_cuda_dependencies_package(
1✔
338
            package_name=f"libcudnn9-cuda-{cuda_major_version}",
339
            version_pattern=CUDNN_9_PATTERN,
340
            dry_run=dry_run,
341
        )
342
        install_cuda_dependencies_package(
1✔
343
            package_name=f"libcudnn9-dev-cuda-{cuda_major_version}",
344
            version_pattern=re.escape(installed_cudnn9_version),
345
            dry_run=dry_run,
346
        )
347
    except PackageInstallationError as e:
1✔
348
        info(f"cuDNN 9.x installation failed, falling back to cuDNN 8.x: {e}")
1✔
349
        try:
1✔
350
            # Fall back to cudnn8
351
            CUDNN_8_PATTERN = rf"8\.[0-9]+\.[0-9]+\.[0-9]+\-[0-9]\+cuda{cuda_major_version}\.[0-9]+"
1✔
352
            installed_cudnn8_version = install_cuda_dependencies_package(
1✔
353
                package_name="libcudnn8",
354
                version_pattern=CUDNN_8_PATTERN,
355
                dry_run=dry_run,
356
            )
357
            install_cuda_dependencies_package(
1✔
358
                package_name="libcudnn8-dev",
359
                version_pattern=re.escape(installed_cudnn8_version),
360
                dry_run=dry_run,
361
            )
362
        except PackageInstallationError as e:
×
363
            info(f"cuDNN 8.x installation failed: {e}.")
×
364
            info("cuDNN packages may need to be installed manually.")
×
365

366
    # Install TensorRT dependencies
367
    NVINFER_PATTERN = rf"\d+\.[0-9]+\.[0-9]+\.[0-9]+-[0-9]\+cuda{cuda_major_version}\.[0-9]+"
1✔
368
    try:
1✔
369
        installed_libnvinferversion = install_cuda_dependencies_package(
1✔
370
            package_name="libnvinfer10",
371
            version_pattern=NVINFER_PATTERN,
372
            dry_run=dry_run,
373
        )
374
        libnvinfer_pattern = re.escape(installed_libnvinferversion)
1✔
375

376
        install_packages_if_missing(
1✔
377
            [
378
                f"libnvinfer-bin={installed_libnvinferversion}",
379
                f"libnvinfer-lean10={installed_libnvinferversion}",
380
                f"libnvinfer-plugin10={installed_libnvinferversion}",
381
                f"libnvinfer-vc-plugin10={installed_libnvinferversion}",
382
                f"libnvinfer-dispatch10={installed_libnvinferversion}",
383
                f"libnvonnxparsers10={installed_libnvinferversion}",
384
            ],
385
            apt_options=["--no-install-recommends", "-y", "--allow-downgrades"],
386
            dry_run=dry_run,
387
        )
388

389
        for trt_package_name in [
1✔
390
            "libnvinfer-headers-dev",
391
            "libnvinfer-safe-headers-dev",
392
            "libnvinfer-dev",
393
            "libnvinfer-headers-plugin-dev",
394
            "libnvinfer-plugin-dev",
395
            "libnvonnxparsers-dev",
396
        ]:
397
            install_cuda_dependencies_package(
1✔
398
                package_name=trt_package_name,
399
                version_pattern=libnvinfer_pattern,
400
                dry_run=dry_run,
401
            )
402
    except PackageInstallationError as e:
1✔
403
        info(f"TensorRT installation failed: {e}")
1✔
404
        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