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

nvidia-holoscan / holoscan-cli / 14182007055

31 Mar 2025 09:39PM UTC coverage: 89.45% (+3.7%) from 85.772%
14182007055

Pull #33

github

mocsharp
Add unit tests

Signed-off-by: Victor Chang <vicchang@nvidia.com>
Pull Request #33: Enable test-app with nv-gha-runner

46 of 51 new or added lines in 8 files covered. (90.2%)

1 existing line in 1 file now uncovered.

1789 of 2000 relevant lines covered (89.45%)

0.89 hits per line

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

79.78
/src/holoscan_cli/packager/container_builder.py
1
# SPDX-FileCopyrightText: Copyright (c) 2023-2025 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
import logging
1✔
16
import os
1✔
17
import pprint
1✔
18
import shutil
1✔
19
from pathlib import Path
1✔
20
from typing import Optional
1✔
21

22
from jinja2 import Environment, FileSystemLoader, StrictUndefined
1✔
23

24
from ..common.constants import Constants, DefaultValues
1✔
25
from ..common.dockerutils import (
1✔
26
    build_docker_image,
27
    create_and_get_builder,
28
    docker_export_tarball,
29
)
30
from ..common.exceptions import WrongApplicationPathError
1✔
31
from .parameters import PackageBuildParameters, PlatformBuildResults, PlatformParameters
1✔
32

33

34
class BuilderBase:
1✔
35
    """
36
    Docker container image builder base class.
37
    Prepares files for building the docker image and calls Docker API to build the container image.
38
    """
39

40
    def __init__(
1✔
41
        self,
42
        build_parameters: PackageBuildParameters,
43
        temp_dir: str,
44
    ) -> None:
45
        """
46
        Copy the application, model files, and user documentations here in __init__ since they
47
        won't change when building different platforms.
48

49
        Args:
50
            build_parameters (PackageBuildParameters): general build parameters
51
            temp_dir (str): temporary directory to store files required for build
52

53
        """
54
        self._logger = logging.getLogger("packager.builder")
1✔
55
        self._build_parameters = build_parameters
1✔
56
        self._temp_dir = temp_dir
1✔
57
        self._copy_application()
1✔
58
        self._copy_model_files()
1✔
59
        self._copy_docs()
1✔
60
        self._copy_libs()
1✔
61
        _ = self._write_dockerignore()
1✔
62
        _ = self._copy_script()
1✔
63

64
    def build(self, platform_parameters: PlatformParameters) -> PlatformBuildResults:
1✔
65
        """Build a new container image for a specific platform.
66
        Copy supporting files, such as redistributables and generate Dockerfile for the build.
67

68
        Args:
69
            platform_parameters (PlatformParameters): platform parameters
70

71
        Returns:
72
            PlatformBuildResults: build results
73
        """
74
        self._copy_supporting_files(platform_parameters)
1✔
75
        docker_file_path = self._write_dockerfile(platform_parameters)
1✔
76

77
        return self._build_internal(docker_file_path, platform_parameters)
1✔
78

79
    def _build_internal(
1✔
80
        self, dockerfile: str, platform_parameters: PlatformParameters
81
    ) -> PlatformBuildResults:
82
        """Prepare parameters for Docker buildx build
83

84
        Args:
85
            dockerfile (str): Path to Dockerfile to be built
86
            platform_parameters (PlatformParameters): platform parameters
87

88
        Returns:
89
            PlatformBuildResults: build results
90
        """
91
        builder = create_and_get_builder(Constants.LOCAL_BUILDX_BUILDER_NAME)
1✔
92

93
        build_result = PlatformBuildResults(platform_parameters)
1✔
94

95
        cache_to = {"type": "local", "dest": self._build_parameters.build_cache}
1✔
96
        cache_from = [{"type": "local", "src": self._build_parameters.build_cache}]
1✔
97
        if platform_parameters.base_image is not None:
1✔
98
            cache_from.append(
×
99
                {"type": "registry", "ref": platform_parameters.base_image}
100
            )
101
        if platform_parameters.build_image is not None:
1✔
102
            cache_from.append(
×
103
                {"type": "registry", "ref": platform_parameters.build_image}
104
            )
105

106
        builds = {
1✔
107
            "builder": builder,
108
            "cache": not self._build_parameters.no_cache,
109
            "cache_from": None if self._build_parameters.no_cache else cache_from,
110
            "cache_to": None if self._build_parameters.no_cache else cache_to,
111
            "context_path": self._temp_dir,
112
            "file": dockerfile,
113
            "platforms": [platform_parameters.docker_arch],
114
            "progress": "plain" if self._logger.root.level == logging.DEBUG else "auto",
115
            "pull": True,
116
            "tags": [platform_parameters.tag],
117
        }
118

119
        if self._build_parameters.add_hosts:
1✔
120
            builds["add_hosts"] = {}
1✔
121
            for host in self._build_parameters.add_hosts:
1✔
122
                host_name, host_ip = host.split(":")
1✔
123
                builds["add_hosts"][host_name] = host_ip
1✔
124

125
        export_to_tar_ball = False
1✔
126
        if self._build_parameters.tarball_output is not None:
1✔
127
            build_result.tarball_filename = str(
1✔
128
                self._build_parameters.tarball_output
129
                / f"{platform_parameters.tag}{Constants.TARBALL_FILE_EXTENSION}"
130
            ).replace(":", "-")
131

132
        # Make result image available on 'docker image' only if arch matches
133
        if platform_parameters.same_arch_as_system:
1✔
134
            builds["load"] = True
1✔
135
            build_result.docker_tag = platform_parameters.tag
1✔
136
            export_to_tar_ball = self._build_parameters.tarball_output is not None
1✔
137
        else:
138
            if self._build_parameters.tarball_output is not None:
×
139
                builds["output"] = {
×
140
                    # type=oci cannot be loaded by docker: https://github.com/docker/buildx/issues/59
141
                    "type": "docker",
142
                    "dest": build_result.tarball_filename,
143
                }
144
            else:
145
                build_result.succeeded = False
×
146
                build_result.error = (
×
147
                    "Skipped due to incompatible system architecture. "
148
                    "Use '--output' to write image to disk."
149
                )
150
                return build_result
×
151

152
        builds["build_args"] = {
1✔
153
            "UID": self._build_parameters.uid,
154
            "GID": self._build_parameters.gid,
155
            "UNAME": self._build_parameters.username,
156
            "GPU_TYPE": platform_parameters.platform_config.value,
157
        }
158

159
        self._logger.debug(
1✔
160
            f"Building Holoscan Application Package: tag={platform_parameters.tag}"
161
        )
162

163
        self.print_build_info(platform_parameters)
1✔
164

165
        try:
1✔
166
            build_docker_image(**builds)
1✔
167
            build_result.succeeded = True
1✔
168
            if export_to_tar_ball:
1✔
169
                try:
1✔
170
                    self._logger.info(
1✔
171
                        f"Saving {platform_parameters.tag} to {build_result.tarball_filename}..."
172
                    )
173
                    docker_export_tarball(
1✔
174
                        build_result.tarball_filename, platform_parameters.tag
175
                    )
176
                except Exception as ex:
×
177
                    build_result.error = f"Error saving tarball: {ex}"
×
178
                    build_result.succeeded = False
×
NEW
179
        except Exception as e:
×
NEW
180
            print(e)
×
181
            build_result.succeeded = False
×
182
            build_result.error = (
×
183
                "Error building image: see Docker output for additional details."
184
            )
185

186
        return build_result
1✔
187

188
    def print_build_info(self, platform_parameters):
1✔
189
        """Print build information for the platform."""
190
        self._logger.info(
1✔
191
            f"""
192
===============================================================================
193
Building image for:                 {platform_parameters.platform.value}
194
    Architecture:                   {platform_parameters.platform_arch.value}
195
    Base Image:                     {platform_parameters.base_image}
196
    Build Image:                    {platform_parameters.build_image if platform_parameters.build_image is not None else "N/A"}
197
    Cache:                          {'Disabled' if self._build_parameters.no_cache else 'Enabled'}
198
    Configuration:                  {platform_parameters.platform_config.value}
199
    Holoscan SDK Package:           {platform_parameters.holoscan_sdk_file if platform_parameters.holoscan_sdk_file is not None else "N/A"}
200
    MONAI Deploy App SDK Package:   {platform_parameters.monai_deploy_sdk_file if platform_parameters.monai_deploy_sdk_file is not None else "N/A"}
201
    gRPC Health Probe:              {platform_parameters.health_probe if platform_parameters.health_probe is not None else "N/A"}
202
    SDK Version:                    {self._build_parameters.holoscan_sdk_version}
203
    SDK:                            {self._build_parameters.sdk.value}
204
    Tag:                            {platform_parameters.tag}
205
    Included features/dependencies: {", ".join(self._build_parameters.includes) if self._build_parameters.includes else "N/A"}
206
    """  # noqa: E501
207
        )
208

209
    def _write_dockerignore(self):
1✔
210
        """Copy .dockerignore file to temporary location."""
211
        # Write out .dockerignore file
212
        dockerignore_source_file_path = (
1✔
213
            Path(__file__).parent / "templates" / "dockerignore"
214
        )
215
        dockerignore_dest_file_path = os.path.join(self._temp_dir, ".dockerignore")
1✔
216
        shutil.copyfile(dockerignore_source_file_path, dockerignore_dest_file_path)
1✔
217
        return dockerignore_dest_file_path
1✔
218

219
    def _copy_script(self):
1✔
220
        """Copy HAP/MAP tools.sh script to temporary directory"""
221
        # Copy the tools script
222
        tools_script_file_path = Path(__file__).parent / "templates" / "tools.sh"
1✔
223
        tools_script_dest_file_path = os.path.join(self._temp_dir, "tools")
1✔
224
        shutil.copyfile(tools_script_file_path, tools_script_dest_file_path)
1✔
225
        return tools_script_dest_file_path
1✔
226

227
    def _write_dockerfile(self, platform_parameters: PlatformParameters):
1✔
228
        """Write Dockerfile temporary location"""
229
        docker_template_string = self._get_template(platform_parameters)
1✔
230
        self._logger.debug(
1✔
231
            f"""
232
========== Begin Dockerfile ==========
233
{docker_template_string}
234
=========== End Dockerfile ===========
235
"""
236
        )
237

238
        docker_file_path = os.path.join(self._temp_dir, DefaultValues.DOCKER_FILE_NAME)
1✔
239
        with open(docker_file_path, "w") as docker_file:
1✔
240
            docker_file.write(docker_template_string)
1✔
241

242
        return os.path.abspath(docker_file_path)
1✔
243

244
    def _copy_application(self):
1✔
245
        """Copy application to temporary location"""
246
        # Copy application files to temp directory (under 'app' folder)
247
        target_application_path = Path(os.path.join(self._temp_dir, "app"))
1✔
248
        if os.path.exists(target_application_path):
1✔
249
            shutil.rmtree(target_application_path)
1✔
250

251
        if not os.path.exists(self._build_parameters.application):
1✔
252
            raise WrongApplicationPathError(
×
253
                f'Directory "{self._build_parameters.application}" not found.'
254
            )
255

256
        if os.path.isfile(self._build_parameters.application):
1✔
257
            shutil.copytree(
1✔
258
                self._build_parameters.application.parent, target_application_path
259
            )
260
        else:
261
            shutil.copytree(self._build_parameters.application, target_application_path)
1✔
262

263
        target_config_file_path = Path(os.path.join(self._temp_dir, "app.config"))
1✔
264
        shutil.copyfile(
1✔
265
            self._build_parameters.app_config_file_path, target_config_file_path
266
        )
267

268
    def _copy_libs(self):
1✔
269
        """
270
        - Copy additional libraries to the temporary application directory.
271
        - Stores all subdirectories from the copied libraries to the 'additional_lib_paths'
272
          parameter that will be used to set the LD_LIBRARY_PATH or PYTHONPATH environment variable
273
          in the Dockerfile.
274
        """
275
        if self._build_parameters.additional_libs is None:
1✔
276
            return
×
277
        target_libs_path = Path(os.path.join(self._temp_dir, "lib"))
1✔
278
        if os.path.exists(target_libs_path):
1✔
279
            shutil.rmtree(target_libs_path)
1✔
280

281
        for lib_path in self._build_parameters.additional_libs:
1✔
282
            self._logger.debug(
1✔
283
                f"Copying additional libraries from {lib_path} to {target_libs_path}"
284
            )
285
            shutil.copytree(lib_path, target_libs_path, dirs_exist_ok=True)
1✔
286

287
        subdirectories = [
1✔
288
            os.path.join(
289
                DefaultValues.HOLOSCAN_LIB_DIR,
290
                os.path.join(root, subdir)
291
                .replace(str(target_libs_path), "")
292
                .lstrip("/"),
293
            )
294
            for root, dirs, _ in os.walk(target_libs_path)
295
            for subdir in dirs
296
        ]
297
        self._build_parameters.additional_lib_paths = ":".join(subdirectories)
1✔
298

299
    def _copy_model_files(self):
1✔
300
        """Copy models to temporary location"""
301
        if self._build_parameters.models:
1✔
302
            target_models_root_path = os.path.join(self._temp_dir, "models")
×
303
            os.makedirs(target_models_root_path, exist_ok=True)
×
304

305
            for model in self._build_parameters.models:
×
306
                target_model_path = os.path.join(target_models_root_path, model)
×
307
                if self._build_parameters.models[model].is_dir():
×
308
                    shutil.copytree(
×
309
                        self._build_parameters.models[model], target_model_path
310
                    )
311
                elif self._build_parameters.models[model].is_file():
×
312
                    os.makedirs(target_model_path, exist_ok=True)
×
313
                    target_model_path = os.path.join(
×
314
                        target_model_path, self._build_parameters.models[model].name
315
                    )
316
                    shutil.copy(self._build_parameters.models[model], target_model_path)
×
317

318
    def _copy_docs(self):
1✔
319
        """Copy user documentations to temporary location"""
320
        if self._build_parameters.docs is not None:
1✔
321
            target_path = os.path.join(self._temp_dir, "docs")
×
322
            shutil.copytree(self._build_parameters.docs, target_path)
×
323

324
    def _get_template(self, platform_parameters: PlatformParameters):
1✔
325
        """Generate Dockerfile using Jinja2 engine"""
326
        jinja_env = Environment(
×
327
            loader=FileSystemLoader(Path(__file__).parent / "templates"),
328
            undefined=StrictUndefined,
329
            trim_blocks=True,
330
            lstrip_blocks=True,
331
            autoescape=True,
332
        )
333
        self._logger.debug(
×
334
            f"""
335
========== Begin Build Parameters ==========
336
{pprint.pformat(self._build_parameters.to_jinja)}
337
=========== End Build Parameters ===========
338
"""
339
        )
340
        self._logger.debug(
×
341
            f"""
342
========== Begin Platform Parameters ==========
343
{pprint.pformat(platform_parameters.to_jinja)}
344
=========== End Platform Parameters ===========
345
"""
346
        )
347

348
        jinja_template = jinja_env.get_template("Dockerfile.jinja2")
×
349
        return jinja_template.render(
×
350
            {
351
                **self._build_parameters.to_jinja,
352
                **platform_parameters.to_jinja,
353
            }
354
        )
355

356
    def _copy_supporting_files(self, platform_parameters: PlatformParameters):
1✔
357
        """Abstract base function to copy supporting files"""
358
        return NotImplemented
×
359

360
    def __init_subclass__(cls):
1✔
361
        if cls._copy_supporting_files is BuilderBase._copy_supporting_files:
1✔
362
            raise NotImplementedError(
×
363
                "{cls} has not overwritten method {_copy_supporting_files}!"
364
            )
365

366

367
class PythonAppBuilder(BuilderBase):
1✔
368
    """A subclass of BuilderBase for Python-based applications.
369
    Copioes PyPI package and requirement.txt file
370
    """
371

372
    def __init__(
1✔
373
        self,
374
        build_parameters: PackageBuildParameters,
375
        temp_dir: str,
376
    ) -> None:
377
        BuilderBase.__init__(self, build_parameters, temp_dir)
1✔
378

379
    def _copy_supporting_files(self, platform_parameters: PlatformParameters):
1✔
380
        self._copy_sdk_file(platform_parameters.holoscan_sdk_file)
1✔
381
        self._copy_sdk_file(platform_parameters.monai_deploy_sdk_file)
1✔
382
        self._copy_pip_requirements()
1✔
383

384
    def _copy_pip_requirements(self):
1✔
385
        pip_folder = os.path.join(self._temp_dir, "pip")
1✔
386
        os.makedirs(pip_folder, exist_ok=True)
1✔
387
        pip_requirements_path = os.path.join(pip_folder, "requirements.txt")
1✔
388

389
        # Build requirements content first
390
        requirements_content = []
1✔
391
        if self._build_parameters.requirements_file_path is not None:
1✔
392
            with open(self._build_parameters.requirements_file_path) as lr:
1✔
393
                requirements_content.extend(lr)
1✔
394
            requirements_content.append("")
1✔
395

396
        if self._build_parameters.pip_packages:
1✔
NEW
397
            requirements_content.extend(self._build_parameters.pip_packages)
×
398

399
        # Write all content at once
400
        with open(pip_requirements_path, "w") as requirements_file:
1✔
401
            requirements_file.writelines(requirements_content)
1✔
402
            self._logger.debug(
1✔
403
                "================ Begin requirements.txt ================"
404
            )
405
            for req in requirements_content:
1✔
406
                self._logger.debug(f"  {req.strip()}")
1✔
407
            self._logger.debug(
1✔
408
                "================ End requirements.txt =================="
409
            )
410

411
    def _copy_sdk_file(self, sdk_file: Optional[Path]):
1✔
412
        if sdk_file is not None and os.path.isfile(sdk_file):
1✔
413
            dest = os.path.join(self._temp_dir, sdk_file.name)
1✔
414
            if os.path.exists(dest):
1✔
415
                os.remove(dest)
1✔
416
            shutil.copyfile(sdk_file, dest)
1✔
417

418

419
class CppAppBuilder(BuilderBase):
1✔
420
    """A subclass of BuilderBase for C++ applications.
421
    Copies Debian.
422
    """
423

424
    def __init__(
1✔
425
        self,
426
        build_parameters: PackageBuildParameters,
427
        temp_dir: str,
428
    ) -> None:
429
        BuilderBase.__init__(self, build_parameters, temp_dir)
1✔
430

431
    def _copy_supporting_files(self, platform_parameters: PlatformParameters):
1✔
432
        """Copies the SDK file to the temporary directory"""
433
        if platform_parameters.holoscan_sdk_file is not None and os.path.isfile(
1✔
434
            platform_parameters.holoscan_sdk_file
435
        ):
436
            dest = os.path.join(
1✔
437
                self._temp_dir, platform_parameters.holoscan_sdk_file.name
438
            )
439
            if os.path.exists(dest):
1✔
440
                os.remove(dest)
1✔
441
            shutil.copyfile(
1✔
442
                platform_parameters.holoscan_sdk_file,
443
                dest,
444
            )
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