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

nvidia-holoscan / holoscan-cli / 28577104782

02 Jul 2026 08:41AM UTC coverage: 76.813%. First build
28577104782

Pull #193

github

web-flow
Merge branch 'main' into fix/privileged-setup-one-env
Pull Request #193: fix: install CLI in one env and elevate only per-operation

21 of 66 new or added lines in 3 files covered. (31.82%)

2955 of 3847 relevant lines covered (76.81%)

0.77 hits per line

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

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

NEW
214
    keyring_path = "/usr/share/keyrings/kitware-archive-keyring.gpg"
×
NEW
215
    source_line = (
×
216
        "deb [signed-by=/usr/share/keyrings/kitware-archive-keyring.gpg] "
217
        f"https://apt.kitware.com/ubuntu/ {ubuntu_codename} main\n"
218
    )
NEW
219
    if dry_run:
×
NEW
220
        info(f"[dryrun] Would fetch the Kitware archive key -> {keyring_path}")
×
NEW
221
        write_system_file("/etc/apt/sources.list.d/kitware.list", source_line, dry_run=True)
×
222
    else:
223
        # Fetch + dearmor the key as the invoking user; install the keyring as root.
NEW
224
        try:
×
NEW
225
            dearmored = subprocess.run(
×
226
                "wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc"
227
                " | gpg --dearmor",
228
                shell=True,
229
                check=True,
230
                capture_output=True,
231
            ).stdout
NEW
232
        except subprocess.CalledProcessError as e:
×
NEW
233
            fatal(
×
234
                "Failed to download the Kitware apt archive key "
235
                f"(is the network available?): {e.stderr.decode(errors='replace').strip()}"
236
            )
NEW
237
        write_system_file(keyring_path, dearmored)
×
NEW
238
        write_system_file("/etc/apt/sources.list.d/kitware.list", source_line)
×
239
    install_packages_if_missing(["cmake", "cmake-curses-gui"], dry_run=dry_run)
×
240

241

242
def setup_python_dev(min_version: str = "3.10.0", dry_run: bool = False) -> None:
1✔
243
    """Setup Python development packages"""
244
    python_version = sys.version_info
×
245
    python_dev_package = f"python3.{python_version.minor}-dev"
×
246
    pydev_ver = get_installed_package_version(python_dev_package)
×
247
    if not pydev_ver:
×
248
        pydev_ver = get_installed_package_version("python3-dev")
×
249
    if not pydev_ver or parse_semantic_version(pydev_ver) < parse_semantic_version(min_version):
×
250
        install_packages_if_missing([python_dev_package], dry_run=dry_run)
×
251

252

253
def setup_ngc_cli(dry_run: bool = False) -> None:
1✔
254
    """Setup NGC CLI if not present"""
255
    if shutil.which("ngc"):
×
256
        return
×
257

258
    arch_suffix = "arm64" if platform.machine() == "aarch64" else "linux"
×
259
    ngc_url = (
×
260
        "https://api.ngc.nvidia.com/v2/resources/nvidia/ngc-apps/ngc_cli"
261
        f"/versions/3.64.3/files/ngccli_{arch_suffix}.zip"
262
    )
263
    ngc_filename = f"ngccli_{arch_suffix}.zip"
×
264

265
    try:
×
266
        run_command(
×
267
            ["wget", "--quiet", "--content-disposition", ngc_url, "-O", ngc_filename],
268
            dry_run=dry_run,
269
        )
270
        run_command(["unzip", "-q", ngc_filename], dry_run=dry_run)
×
271
        run_command(["chmod", "u+x", "ngc-cli/ngc"], dry_run=dry_run)
×
272

273
        # Link into the user's ~/.local/bin (on PATH for most shells) — no root needed.
274
        abs_path = os.path.abspath("ngc-cli/ngc")
×
NEW
275
        local_bin = os.path.expanduser("~/.local/bin")
×
NEW
276
        if not dry_run:
×
NEW
277
            os.makedirs(local_bin, exist_ok=True)
×
NEW
278
        run_command(["ln", "-sf", abs_path, os.path.join(local_bin, "ngc")], dry_run=dry_run)
×
NEW
279
        if not dry_run and not shutil.which("ngc"):
×
NEW
280
            info(f"Installed ngc to {local_bin}; add it to PATH to use 'ngc' directly.")
×
281

282
    except Exception as e:
×
283
        fatal(f"Failed to install NGC CLI: {e}")
×
284

285

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

290
    Requirements:
291
        - Only RAPIDS-formatted versions are supported, e.g. "0.12.0-rapids.20".
292

293
    Args:
294
        min_version: Minimum required RAPIDS version string ("[v]MAJOR.MINOR.PATCH-rapids.CUSTOM").
295
        dry_run: If True, print commands without executing them.
296
    """
297
    from holoscan_cli.utils.holohub import get_holohub_setup_scripts_dir
×
298

299
    script_path = get_holohub_setup_scripts_dir() / "sccache.sh"
×
300
    if not script_path.exists():
×
301
        warn(f"sccache setup script not found: {script_path}")
×
302
        return
×
303

304
    env = os.environ.copy()
×
305
    env["SCCACHE_MIN_VERSION"] = min_version
×
306
    run_command(["bash", str(script_path)], dry_run=dry_run, env=env)
×
307

308

309
def setup_cuda_dependencies(dry_run: bool = False) -> None:
1✔
310
    """Setup CUDA dependencies if CUDA runtime is available"""
311
    cuda_runtime_version = get_cuda_runtime_version()
1✔
312
    if cuda_runtime_version:
1✔
313
        cuda_major_version = cuda_runtime_version.split(".")[0]
1✔
314
        setup_cuda_packages(cuda_major_version, dry_run)
1✔
315
    else:
316
        info("CUDA Runtime package not found, skipping CUDA package installation")
×
317

318

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

322
    # Attempt to install cudnn9
323
    CUDNN_9_PATTERN = r"9\.[0-9]+\.[0-9]+\.[0-9]+\-[0-9]+"
1✔
324
    try:
1✔
325
        installed_cudnn9_version = install_cuda_dependencies_package(
1✔
326
            package_name=f"libcudnn9-cuda-{cuda_major_version}",
327
            version_pattern=CUDNN_9_PATTERN,
328
            dry_run=dry_run,
329
        )
330
        install_cuda_dependencies_package(
1✔
331
            package_name=f"libcudnn9-dev-cuda-{cuda_major_version}",
332
            version_pattern=re.escape(installed_cudnn9_version),
333
            dry_run=dry_run,
334
        )
335
    except PackageInstallationError as e:
1✔
336
        info(f"cuDNN 9.x installation failed, falling back to cuDNN 8.x: {e}")
1✔
337
        try:
1✔
338
            # Fall back to cudnn8
339
            CUDNN_8_PATTERN = rf"8\.[0-9]+\.[0-9]+\.[0-9]+\-[0-9]\+cuda{cuda_major_version}\.[0-9]+"
1✔
340
            installed_cudnn8_version = install_cuda_dependencies_package(
1✔
341
                package_name="libcudnn8",
342
                version_pattern=CUDNN_8_PATTERN,
343
                dry_run=dry_run,
344
            )
345
            install_cuda_dependencies_package(
1✔
346
                package_name="libcudnn8-dev",
347
                version_pattern=re.escape(installed_cudnn8_version),
348
                dry_run=dry_run,
349
            )
350
        except PackageInstallationError as e:
×
351
            info(f"cuDNN 8.x installation failed: {e}.")
×
352
            info("cuDNN packages may need to be installed manually.")
×
353

354
    # Install TensorRT dependencies
355
    NVINFER_PATTERN = rf"\d+\.[0-9]+\.[0-9]+\.[0-9]+-[0-9]\+cuda{cuda_major_version}\.[0-9]+"
1✔
356
    try:
1✔
357
        installed_libnvinferversion = install_cuda_dependencies_package(
1✔
358
            package_name="libnvinfer10",
359
            version_pattern=NVINFER_PATTERN,
360
            dry_run=dry_run,
361
        )
362
        libnvinfer_pattern = re.escape(installed_libnvinferversion)
1✔
363

364
        install_packages_if_missing(
1✔
365
            [
366
                f"libnvinfer-bin={installed_libnvinferversion}",
367
                f"libnvinfer-lean10={installed_libnvinferversion}",
368
                f"libnvinfer-plugin10={installed_libnvinferversion}",
369
                f"libnvinfer-vc-plugin10={installed_libnvinferversion}",
370
                f"libnvinfer-dispatch10={installed_libnvinferversion}",
371
                f"libnvonnxparsers10={installed_libnvinferversion}",
372
            ],
373
            apt_options=["--no-install-recommends", "-y", "--allow-downgrades"],
374
            dry_run=dry_run,
375
        )
376

377
        for trt_package_name in [
1✔
378
            "libnvinfer-headers-dev",
379
            "libnvinfer-safe-headers-dev",
380
            "libnvinfer-dev",
381
            "libnvinfer-headers-plugin-dev",
382
            "libnvinfer-plugin-dev",
383
            "libnvonnxparsers-dev",
384
        ]:
385
            install_cuda_dependencies_package(
1✔
386
                package_name=trt_package_name,
387
                version_pattern=libnvinfer_pattern,
388
                dry_run=dry_run,
389
            )
390
    except PackageInstallationError as e:
1✔
391
        info(f"TensorRT installation failed: {e}")
1✔
392
        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