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

nvidia-holoscan / holoscan-cli / 13458548404

21 Feb 2025 02:10PM UTC coverage: 85.772% (+16.8%) from 68.934%
13458548404

Pull #24

github

web-flow
Add unit tests (#27)
Pull Request #24: HSDK 3.0.0 support

16 of 16 new or added lines in 7 files covered. (100.0%)

13 existing lines in 3 files now uncovered.

1688 of 1968 relevant lines covered (85.77%)

0.86 hits per line

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

78.44
/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
        self.print_build_info(platform_parameters)
1✔
92
        builder = create_and_get_builder(Constants.LOCAL_BUILDX_BUILDER_NAME)
1✔
93

94
        build_result = PlatformBuildResults(platform_parameters)
1✔
95

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

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

120
        export_to_tar_ball = False
1✔
121
        if self._build_parameters.tarball_output is not None:
1✔
122
            build_result.tarball_filenaem = str(
1✔
123
                self._build_parameters.tarball_output
124
                / f"{platform_parameters.tag}{Constants.TARBALL_FILE_EXTENSION}"
125
            ).replace(":", "-")
126

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

147
        builds["build_args"] = {
1✔
148
            "UID": self._build_parameters.uid,
149
            "GID": self._build_parameters.gid,
150
            "UNAME": self._build_parameters.username,
151
            "GPU_TYPE": platform_parameters.platform_config.value,
152
        }
153

154
        self._logger.debug(
1✔
155
            f"Building Holoscan Application Package: tag={platform_parameters.tag}"
156
        )
157

158
        try:
1✔
159
            build_docker_image(**builds)
1✔
160
            build_result.succeeded = True
1✔
161
            if export_to_tar_ball:
1✔
162
                try:
1✔
163
                    self._logger.info(
1✔
164
                        f"Saving {platform_parameters.tag} to {build_result.tarball_filenaem}..."
165
                    )
166
                    docker_export_tarball(
1✔
167
                        build_result.tarball_filenaem, platform_parameters.tag
168
                    )
UNCOV
169
                except Exception as ex:
×
UNCOV
170
                    build_result.error = f"Error saving tarball: {ex}"
×
UNCOV
171
                    build_result.succeeded = False
×
172
        except Exception:
×
173
            build_result.succeeded = False
×
174
            build_result.error = (
×
175
                "Error building image: see Docker output for additional details."
176
            )
177

178
        return build_result
1✔
179

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

201
    def _write_dockerignore(self):
1✔
202
        """Copy .dockerignore file to temporary location."""
203
        # Write out .dockerignore file
204
        dockerignore_source_file_path = (
1✔
205
            Path(__file__).parent / "templates" / "dockerignore"
206
        )
207
        dockerignore_dest_file_path = os.path.join(self._temp_dir, ".dockerignore")
1✔
208
        shutil.copyfile(dockerignore_source_file_path, dockerignore_dest_file_path)
1✔
209
        return dockerignore_dest_file_path
1✔
210

211
    def _copy_script(self):
1✔
212
        """Copy HAP/MAP tools.sh script to temporary directory"""
213
        # Copy the tools script
214
        tools_script_file_path = Path(__file__).parent / "templates" / "tools.sh"
1✔
215
        tools_script_dest_file_path = os.path.join(self._temp_dir, "tools")
1✔
216
        shutil.copyfile(tools_script_file_path, tools_script_dest_file_path)
1✔
217
        return tools_script_dest_file_path
1✔
218

219
    def _write_dockerfile(self, platform_parameters: PlatformParameters):
1✔
220
        """Write Dockerfile temporary location"""
221
        docker_template_string = self._get_template(platform_parameters)
1✔
222
        self._logger.debug(
1✔
223
            f"""
224
========== Begin Dockerfile ==========
225
{docker_template_string}
226
=========== End Dockerfile ===========
227
"""
228
        )
229

230
        docker_file_path = os.path.join(self._temp_dir, DefaultValues.DOCKER_FILE_NAME)
1✔
231
        with open(docker_file_path, "w") as docker_file:
1✔
232
            docker_file.write(docker_template_string)
1✔
233

234
        return os.path.abspath(docker_file_path)
1✔
235

236
    def _copy_application(self):
1✔
237
        """Copy application to temporary location"""
238
        # Copy application files to temp directory (under 'app' folder)
239
        target_application_path = Path(os.path.join(self._temp_dir, "app"))
1✔
240
        if os.path.exists(target_application_path):
1✔
241
            shutil.rmtree(target_application_path)
1✔
242

243
        if not os.path.exists(self._build_parameters.application):
1✔
244
            raise WrongApplicationPathError(
×
245
                f'Directory "{self._build_parameters.application}" not found.'
246
            )
247

248
        if os.path.isfile(self._build_parameters.application):
1✔
249
            shutil.copytree(
1✔
250
                self._build_parameters.application.parent, target_application_path
251
            )
252
        else:
253
            shutil.copytree(self._build_parameters.application, target_application_path)
1✔
254

255
        target_config_file_path = Path(os.path.join(self._temp_dir, "app.config"))
1✔
256
        shutil.copyfile(
1✔
257
            self._build_parameters.app_config_file_path, target_config_file_path
258
        )
259

260
    def _copy_libs(self):
1✔
261
        """
262
        - Copy additional libraries to the temporary application directory.
263
        - Stores all subdirectories from the copied libraries to the 'additional_lib_paths'
264
          parameter that will be used to set the LD_LIBRARY_PATH or PYTHONPATH environment variable
265
          in the Dockerfile.
266
        """
267
        if self._build_parameters.additional_libs is None:
1✔
268
            return
×
269
        target_libs_path = Path(os.path.join(self._temp_dir, "lib"))
1✔
270
        if os.path.exists(target_libs_path):
1✔
271
            shutil.rmtree(target_libs_path)
1✔
272

273
        for lib_path in self._build_parameters.additional_libs:
1✔
274
            self._logger.debug(
1✔
275
                f"Copying additional libraries from {lib_path} to {target_libs_path}"
276
            )
277
            shutil.copytree(lib_path, target_libs_path, dirs_exist_ok=True)
1✔
278

279
        subdirectories = [
1✔
280
            os.path.join(
281
                DefaultValues.HOLOSCAN_LIB_DIR,
282
                os.path.join(root, subdir)
283
                .replace(str(target_libs_path), "")
284
                .lstrip("/"),
285
            )
286
            for root, dirs, _ in os.walk(target_libs_path)
287
            for subdir in dirs
288
        ]
289
        self._build_parameters.additional_lib_paths = ":".join(subdirectories)
1✔
290

291
    def _copy_model_files(self):
1✔
292
        """Copy models to temporary location"""
293
        if self._build_parameters.models:
1✔
294
            target_models_root_path = os.path.join(self._temp_dir, "models")
×
295
            os.makedirs(target_models_root_path, exist_ok=True)
×
296

297
            for model in self._build_parameters.models:
×
298
                target_model_path = os.path.join(target_models_root_path, model)
×
299
                if self._build_parameters.models[model].is_dir():
×
300
                    shutil.copytree(
×
301
                        self._build_parameters.models[model], target_model_path
302
                    )
303
                elif self._build_parameters.models[model].is_file():
×
304
                    os.makedirs(target_model_path, exist_ok=True)
×
305
                    target_model_path = os.path.join(
×
306
                        target_model_path, self._build_parameters.models[model].name
307
                    )
308
                    shutil.copy(self._build_parameters.models[model], target_model_path)
×
309

310
    def _copy_docs(self):
1✔
311
        """Copy user documentations to temporary location"""
312
        if self._build_parameters.docs is not None:
1✔
313
            target_path = os.path.join(self._temp_dir, "docs")
×
314
            shutil.copytree(self._build_parameters.docs, target_path)
×
315

316
    def _get_template(self, platform_parameters: PlatformParameters):
1✔
317
        """Generate Dockerfile using Jinja2 engine"""
UNCOV
318
        jinja_env = Environment(
×
319
            loader=FileSystemLoader(Path(__file__).parent / "templates"),
320
            undefined=StrictUndefined,
321
            trim_blocks=True,
322
            lstrip_blocks=True,
323
            autoescape=True,
324
        )
UNCOV
325
        self._logger.debug(
×
326
            f"""
327
========== Begin Build Parameters ==========
328
{pprint.pformat(self._build_parameters.to_jinja)}
329
=========== End Build Parameters ===========
330
"""
331
        )
UNCOV
332
        self._logger.debug(
×
333
            f"""
334
========== Begin Platform Parameters ==========
335
{pprint.pformat(platform_parameters.to_jinja)}
336
=========== End Platform Parameters ===========
337
"""
338
        )
339

UNCOV
340
        jinja_template = jinja_env.get_template("Dockerfile.jinja2")
×
UNCOV
341
        return jinja_template.render(
×
342
            {
343
                **self._build_parameters.to_jinja,
344
                **platform_parameters.to_jinja,
345
            }
346
        )
347

348
    def _copy_supporting_files(self, platform_parameters: PlatformParameters):
1✔
349
        """Abstract base function to copy supporting files"""
UNCOV
350
        return NotImplemented
×
351

352
    def __init_subclass__(cls):
1✔
353
        if cls._copy_supporting_files is BuilderBase._copy_supporting_files:
1✔
354
            raise NotImplementedError(
×
355
                "{cls} has not overwritten method {_copy_supporting_files}!"
356
            )
357

358

359
class PythonAppBuilder(BuilderBase):
1✔
360
    """A subclass of BuilderBase for Python-based applications.
361
    Copioes PyPI package and requirement.txt file
362
    """
363

364
    def __init__(
1✔
365
        self,
366
        build_parameters: PackageBuildParameters,
367
        temp_dir: str,
368
    ) -> None:
369
        BuilderBase.__init__(self, build_parameters, temp_dir)
1✔
370

371
    def _copy_supporting_files(self, platform_parameters: PlatformParameters):
1✔
372
        self._copy_sdk_file(platform_parameters.holoscan_sdk_file)
1✔
373
        self._copy_sdk_file(platform_parameters.monai_deploy_sdk_file)
1✔
374
        self._copy_pip_requirements()
1✔
375

376
    def _copy_pip_requirements(self):
1✔
377
        pip_folder = os.path.join(self._temp_dir, "pip")
1✔
378
        os.makedirs(pip_folder, exist_ok=True)
1✔
379
        pip_requirements_path = os.path.join(pip_folder, "requirements.txt")
1✔
380

381
        with open(pip_requirements_path, "w") as requirements_file:
1✔
382
            # Use local requirements.txt packages if provided, otherwise use sdk provided packages
383
            if self._build_parameters.requirements_file_path is not None:
1✔
384
                with open(self._build_parameters.requirements_file_path) as lr:
1✔
385
                    for line in lr:
1✔
386
                        requirements_file.write(line)
×
387
                requirements_file.writelines("\n")
1✔
388

389
            if self._build_parameters.pip_packages:
1✔
390
                requirements_file.writelines(
×
391
                    "\n".join(self._build_parameters.pip_packages)
392
                )
393

394
    def _copy_sdk_file(self, sdk_file: Optional[Path]):
1✔
395
        if sdk_file is not None and os.path.isfile(sdk_file):
1✔
396
            dest = os.path.join(self._temp_dir, sdk_file.name)
1✔
397
            if os.path.exists(dest):
1✔
398
                os.remove(dest)
1✔
399
            shutil.copyfile(sdk_file, dest)
1✔
400

401

402
class CppAppBuilder(BuilderBase):
1✔
403
    """A subclass of BuilderBase for C++ applications.
404
    Copies Debian.
405
    """
406

407
    def __init__(
1✔
408
        self,
409
        build_parameters: PackageBuildParameters,
410
        temp_dir: str,
411
    ) -> None:
412
        BuilderBase.__init__(self, build_parameters, temp_dir)
1✔
413

414
    def _copy_supporting_files(self, platform_parameters: PlatformParameters):
1✔
415
        """Copies the SDK file to the temporary directory"""
416
        if platform_parameters.holoscan_sdk_file is not None and os.path.isfile(
1✔
417
            platform_parameters.holoscan_sdk_file
418
        ):
419
            dest = os.path.join(
1✔
420
                self._temp_dir, platform_parameters.holoscan_sdk_file.name
421
            )
422
            if os.path.exists(dest):
1✔
423
                os.remove(dest)
1✔
424
            shutil.copyfile(
1✔
425
                platform_parameters.holoscan_sdk_file,
426
                dest,
427
            )
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