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

nvidia-holoscan / holoscan-cli / 28958333984

08 Jul 2026 04:21PM UTC coverage: 78.526% (+0.6%) from 77.878%
28958333984

Pull #193

github

wyli
Merge remote-tracking branch 'origin/main' into fix/privileged-setup-one-env

# Conflicts:
#	src/holoscan_cli/container/core.py
#	src/holoscan_cli/utils/io.py
Pull Request #193: fix: install CLI in one env and elevate only per-operation

140 of 182 new or added lines in 8 files covered. (76.92%)

4 existing lines in 2 files now uncovered.

3112 of 3963 relevant lines covered (78.53%)

0.79 hits per line

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

55.31
/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()
×
NEW
213
    install_packages_if_missing(["gpg", "wget"], 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. Resolve the binaries to absolute paths (apt just installed
228
        # them) instead of trusting the caller's PATH.
NEW
229
        wget = shutil.which("wget") or "/usr/bin/wget"
×
NEW
230
        gpg = shutil.which("gpg") or "/usr/bin/gpg"
×
NEW
231
        try:
×
NEW
232
            key = subprocess.run(
×
233
                [wget, "-qO-", "https://apt.kitware.com/keys/kitware-archive-latest.asc"],
234
                check=True,
235
                capture_output=True,
236
            ).stdout
NEW
237
            dearmored = subprocess.run(
×
238
                [gpg, "--dearmor"],
239
                input=key,
240
                check=True,
241
                capture_output=True,
242
            ).stdout
NEW
243
        except FileNotFoundError as e:
×
NEW
244
            fatal(f"Failed to download the Kitware apt archive key: {e}")
×
NEW
245
        except subprocess.CalledProcessError as e:
×
NEW
246
            fatal(
×
247
                "Failed to download the Kitware apt archive key "
248
                f"(is the network available?): {e.stderr.decode(errors='replace').strip()}"
249
            )
NEW
250
        write_system_file(keyring_path, dearmored)
×
NEW
251
        write_system_file("/etc/apt/sources.list.d/kitware.list", source_line)
×
252
    # The new source must be visible to apt even when an earlier step already
253
    # ran `apt-get update` (which would make install_packages_if_missing skip it).
NEW
254
    run_command(["apt-get", "update"], dry_run=dry_run, as_root=True)
×
NEW
255
    _apt_updated = True
×
UNCOV
256
    install_packages_if_missing(["cmake", "cmake-curses-gui"], dry_run=dry_run)
×
257

258

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

269

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

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

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

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

309
    except Exception as e:
×
310
        fatal(f"Failed to install NGC CLI: {e}")
×
311

312

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

317
    Requirements:
318
        - Only RAPIDS-formatted versions are supported, e.g. "0.12.0-rapids.20".
319

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

326
    script_path = get_holohub_setup_scripts_dir() / "sccache.sh"
×
327
    if not script_path.exists():
×
328
        warn(f"sccache setup script not found: {script_path}")
×
329
        return
×
330

331
    env = os.environ.copy()
×
332
    env["SCCACHE_MIN_VERSION"] = min_version
×
333
    run_command(["bash", str(script_path)], dry_run=dry_run, env=env)
×
334

335

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

345

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

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

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

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

404
        for trt_package_name in [
1✔
405
            "libnvinfer-headers-dev",
406
            "libnvinfer-safe-headers-dev",
407
            "libnvinfer-dev",
408
            "libnvinfer-headers-plugin-dev",
409
            "libnvinfer-plugin-dev",
410
            "libnvonnxparsers-dev",
411
        ]:
412
            install_cuda_dependencies_package(
1✔
413
                package_name=trt_package_name,
414
                version_pattern=libnvinfer_pattern,
415
                dry_run=dry_run,
416
            )
417
    except PackageInstallationError as e:
1✔
418
        info(f"TensorRT installation failed: {e}")
1✔
419
        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