xref: /openbmc/openbmc/poky/scripts/lib/devtool/ide_sdk.py (revision 96e4b4e121e0e2da1535d7d537d6a982a6ff5bc0)
1# Development tool - ide-sdk command plugin
2#
3# Copyright (C) 2023-2024 Siemens AG
4#
5# SPDX-License-Identifier: GPL-2.0-only
6#
7"""Devtool ide-sdk plugin"""
8
9import json
10import logging
11import os
12import re
13import shutil
14import stat
15import subprocess
16import sys
17from argparse import RawTextHelpFormatter
18from enum import Enum
19
20import scriptutils
21import bb
22from devtool import exec_build_env_command, setup_tinfoil, check_workspace_recipe, DevtoolError, parse_recipe
23from devtool.standard import get_real_srctree
24from devtool.ide_plugins import BuildTool
25
26
27logger = logging.getLogger('devtool')
28
29# dict of classes derived from IdeBase
30ide_plugins = {}
31
32
33class DevtoolIdeMode(Enum):
34    """Different modes are supported by the ide-sdk plugin.
35
36    The enum might be extended by more advanced modes in the future. Some ideas:
37    - auto: modified if all recipes are modified, shared if none of the recipes is modified.
38    - mixed: modified mode for modified recipes, shared mode for all other recipes.
39    """
40
41    modified = 'modified'
42    shared = 'shared'
43
44
45class TargetDevice:
46    """SSH remote login parameters"""
47
48    def __init__(self, args):
49        self.extraoptions = ''
50        if args.no_host_check:
51            self.extraoptions += '-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no'
52        self.ssh_sshexec = 'ssh'
53        if args.ssh_exec:
54            self.ssh_sshexec = args.ssh_exec
55        self.ssh_port = ''
56        if args.port:
57            self.ssh_port = "-p %s" % args.port
58        if args.key:
59            self.extraoptions += ' -i %s' % args.key
60
61        self.target = args.target
62        target_sp = args.target.split('@')
63        if len(target_sp) == 1:
64            self.login = ""
65            self.host = target_sp[0]
66        elif len(target_sp) == 2:
67            self.login = target_sp[0]
68            self.host = target_sp[1]
69        else:
70            logger.error("Invalid target argument: %s" % args.target)
71
72
73class RecipeNative:
74    """Base class for calling bitbake to provide a -native recipe"""
75
76    def __init__(self, name, target_arch=None):
77        self.name = name
78        self.target_arch = target_arch
79        self.bootstrap_tasks = [self.name + ':do_addto_recipe_sysroot']
80        self.staging_bindir_native = None
81        self.target_sys = None
82        self.__native_bin = None
83
84    def _initialize(self, config, workspace, tinfoil):
85        """Get the parsed recipe"""
86        recipe_d = parse_recipe(
87            config, tinfoil, self.name, appends=True, filter_workspace=False)
88        if not recipe_d:
89            raise DevtoolError("Parsing %s recipe failed" % self.name)
90        self.staging_bindir_native = os.path.realpath(
91            recipe_d.getVar('STAGING_BINDIR_NATIVE'))
92        self.target_sys = recipe_d.getVar('TARGET_SYS')
93        return recipe_d
94
95    def initialize(self, config, workspace, tinfoil):
96        """Basic initialization that can be overridden by a derived class"""
97        self._initialize(config, workspace, tinfoil)
98
99    @property
100    def native_bin(self):
101        if not self.__native_bin:
102            raise DevtoolError("native binary name is not defined.")
103        return self.__native_bin
104
105
106class RecipeGdbCross(RecipeNative):
107    """Handle handle gdb-cross on the host and the gdbserver on the target device"""
108
109    def __init__(self, args, target_arch, target_device):
110        super().__init__('gdb-cross-' + target_arch, target_arch)
111        self.target_device = target_device
112        self.gdb = None
113        self.gdbserver_port_next = int(args.gdbserver_port_start)
114        self.config_db = {}
115
116    def __find_gdbserver(self, config, tinfoil):
117        """Absolute path of the gdbserver"""
118        recipe_d_gdb = parse_recipe(
119            config, tinfoil, 'gdb', appends=True, filter_workspace=False)
120        if not recipe_d_gdb:
121            raise DevtoolError("Parsing gdb recipe failed")
122        return os.path.join(recipe_d_gdb.getVar('bindir'), 'gdbserver')
123
124    def initialize(self, config, workspace, tinfoil):
125        super()._initialize(config, workspace, tinfoil)
126        gdb_bin = self.target_sys + '-gdb'
127        gdb_path = os.path.join(
128            self.staging_bindir_native, self.target_sys, gdb_bin)
129        self.gdb = gdb_path
130        self.gdbserver_path = self.__find_gdbserver(config, tinfoil)
131
132    @property
133    def host(self):
134        return self.target_device.host
135
136
137class RecipeImage:
138    """Handle some image recipe related properties
139
140    Most workflows require firmware that runs on the target device.
141    This firmware must be consistent with the setup of the host system.
142    In particular, the debug symbols must be compatible. For this, the
143    rootfs must be created as part of the SDK.
144    """
145
146    def __init__(self, name):
147        self.combine_dbg_image = False
148        self.gdbserver_missing = False
149        self.name = name
150        self.rootfs = None
151        self.__rootfs_dbg = None
152        self.bootstrap_tasks = [self.name + ':do_build']
153
154    def initialize(self, config, tinfoil):
155        image_d = parse_recipe(
156            config, tinfoil, self.name, appends=True, filter_workspace=False)
157        if not image_d:
158            raise DevtoolError(
159                "Parsing image recipe %s failed" % self.name)
160
161        self.combine_dbg_image = bb.data.inherits_class(
162            'image-combined-dbg', image_d)
163
164        workdir = image_d.getVar('WORKDIR')
165        self.rootfs = os.path.join(workdir, 'rootfs')
166        if image_d.getVar('IMAGE_GEN_DEBUGFS') == "1":
167            self.__rootfs_dbg = os.path.join(workdir, 'rootfs-dbg')
168
169        self.gdbserver_missing = 'gdbserver' not in image_d.getVar(
170            'IMAGE_INSTALL') and 'tools-debug' not in image_d.getVar('IMAGE_FEATURES')
171
172    @property
173    def debug_support(self):
174        return bool(self.rootfs_dbg)
175
176    @property
177    def rootfs_dbg(self):
178        if self.__rootfs_dbg and os.path.isdir(self.__rootfs_dbg):
179            return self.__rootfs_dbg
180        return None
181
182
183class RecipeMetaIdeSupport:
184    """For the shared sysroots mode meta-ide-support is needed
185
186    For use cases where just a cross tool-chain is required but
187    no recipe is used, devtool ide-sdk abstracts calling bitbake meta-ide-support
188    and bitbake build-sysroots. This also allows to expose the cross-toolchains
189    to IDEs. For example VSCode support different tool-chains with e.g. cmake-kits.
190    """
191
192    def __init__(self):
193        self.bootstrap_tasks = ['meta-ide-support:do_build']
194        self.topdir = None
195        self.datadir = None
196        self.deploy_dir_image = None
197        self.build_sys = None
198        # From toolchain-scripts
199        self.real_multimach_target_sys = None
200
201    def initialize(self, config, tinfoil):
202        meta_ide_support_d = parse_recipe(
203            config, tinfoil, 'meta-ide-support', appends=True, filter_workspace=False)
204        if not meta_ide_support_d:
205            raise DevtoolError("Parsing meta-ide-support recipe failed")
206
207        self.topdir = meta_ide_support_d.getVar('TOPDIR')
208        self.datadir = meta_ide_support_d.getVar('datadir')
209        self.deploy_dir_image = meta_ide_support_d.getVar(
210            'DEPLOY_DIR_IMAGE')
211        self.build_sys = meta_ide_support_d.getVar('BUILD_SYS')
212        self.real_multimach_target_sys = meta_ide_support_d.getVar(
213            'REAL_MULTIMACH_TARGET_SYS')
214
215
216class RecipeBuildSysroots:
217    """For the shared sysroots mode build-sysroots is needed"""
218
219    def __init__(self):
220        self.standalone_sysroot = None
221        self.standalone_sysroot_native = None
222        self.bootstrap_tasks = [
223            'build-sysroots:do_build_target_sysroot',
224            'build-sysroots:do_build_native_sysroot'
225        ]
226
227    def initialize(self, config, tinfoil):
228        build_sysroots_d = parse_recipe(
229            config, tinfoil, 'build-sysroots', appends=True, filter_workspace=False)
230        if not build_sysroots_d:
231            raise DevtoolError("Parsing build-sysroots recipe failed")
232        self.standalone_sysroot = build_sysroots_d.getVar(
233            'STANDALONE_SYSROOT')
234        self.standalone_sysroot_native = build_sysroots_d.getVar(
235            'STANDALONE_SYSROOT_NATIVE')
236
237
238class SharedSysrootsEnv:
239    """Handle the shared sysroots based workflow
240
241    Support the workflow with just a tool-chain without a recipe.
242    It's basically like:
243      bitbake some-dependencies
244      bitbake meta-ide-support
245      bitbake build-sysroots
246      Use the environment-* file found in the deploy folder
247    """
248
249    def __init__(self):
250        self.ide_support = None
251        self.build_sysroots = None
252
253    def initialize(self, ide_support, build_sysroots):
254        self.ide_support = ide_support
255        self.build_sysroots = build_sysroots
256
257    def setup_ide(self, ide):
258        ide.setup(self)
259
260
261class RecipeNotModified:
262    """Handling of recipes added to the Direct DSK shared sysroots."""
263
264    def __init__(self, name):
265        self.name = name
266        self.bootstrap_tasks = [name + ':do_populate_sysroot']
267
268
269class RecipeModified:
270    """Handling of recipes in the workspace created by devtool modify"""
271    OE_INIT_BUILD_ENV = 'oe-init-build-env'
272
273    VALID_BASH_ENV_NAME_CHARS = re.compile(r"^[a-zA-Z0-9_]*$")
274
275    def __init__(self, name):
276        self.name = name
277        self.bootstrap_tasks = [name + ':do_install']
278        self.gdb_cross = None
279        # workspace
280        self.real_srctree = None
281        self.srctree = None
282        self.ide_sdk_dir = None
283        self.ide_sdk_scripts_dir = None
284        self.bbappend = None
285        # recipe variables from d.getVar
286        self.b = None
287        self.base_libdir = None
288        self.bblayers = None
289        self.bpn = None
290        self.d = None
291        self.debug_build = None
292        self.fakerootcmd = None
293        self.fakerootenv = None
294        self.libdir = None
295        self.max_process = None
296        self.package_arch = None
297        self.package_debug_split_style = None
298        self.path = None
299        self.pn = None
300        self.recipe_sysroot = None
301        self.recipe_sysroot_native = None
302        self.staging_incdir = None
303        self.strip_cmd = None
304        self.target_arch = None
305        self.target_dbgsrc_dir = None
306        self.topdir = None
307        self.workdir = None
308        self.recipe_id = None
309        # replicate bitbake build environment
310        self.exported_vars = None
311        self.cmd_compile = None
312        self.__oe_init_dir = None
313        # main build tool used by this recipe
314        self.build_tool = BuildTool.UNDEFINED
315        # build_tool = cmake
316        self.oecmake_generator = None
317        self.cmake_cache_vars = None
318        # build_tool = meson
319        self.meson_buildtype = None
320        self.meson_wrapper = None
321        self.mesonopts = None
322        self.extra_oemeson = None
323        self.meson_cross_file = None
324
325    def initialize(self, config, workspace, tinfoil):
326        recipe_d = parse_recipe(
327            config, tinfoil, self.name, appends=True, filter_workspace=False)
328        if not recipe_d:
329            raise DevtoolError("Parsing %s recipe failed" % self.name)
330
331        # Verify this recipe is built as externalsrc setup by devtool modify
332        workspacepn = check_workspace_recipe(
333            workspace, self.name, bbclassextend=True)
334        self.srctree = workspace[workspacepn]['srctree']
335        # Need to grab this here in case the source is within a subdirectory
336        self.real_srctree = get_real_srctree(
337            self.srctree, recipe_d.getVar('S'), recipe_d.getVar('WORKDIR'))
338        self.bbappend = workspace[workspacepn]['bbappend']
339
340        self.ide_sdk_dir = os.path.join(
341            config.workspace_path, 'ide-sdk', self.name)
342        if os.path.exists(self.ide_sdk_dir):
343            shutil.rmtree(self.ide_sdk_dir)
344        self.ide_sdk_scripts_dir = os.path.join(self.ide_sdk_dir, 'scripts')
345
346        self.b = recipe_d.getVar('B')
347        self.base_libdir = recipe_d.getVar('base_libdir')
348        self.bblayers = recipe_d.getVar('BBLAYERS').split()
349        self.bpn = recipe_d.getVar('BPN')
350        self.cxx = recipe_d.getVar('CXX')
351        self.d = recipe_d.getVar('D')
352        self.debug_build = recipe_d.getVar('DEBUG_BUILD')
353        self.fakerootcmd = recipe_d.getVar('FAKEROOTCMD')
354        self.fakerootenv = recipe_d.getVar('FAKEROOTENV')
355        self.libdir = recipe_d.getVar('libdir')
356        self.max_process = int(recipe_d.getVar(
357            "BB_NUMBER_THREADS") or os.cpu_count() or 1)
358        self.package_arch = recipe_d.getVar('PACKAGE_ARCH')
359        self.package_debug_split_style = recipe_d.getVar(
360            'PACKAGE_DEBUG_SPLIT_STYLE')
361        self.path = recipe_d.getVar('PATH')
362        self.pn = recipe_d.getVar('PN')
363        self.recipe_sysroot = os.path.realpath(
364            recipe_d.getVar('RECIPE_SYSROOT'))
365        self.recipe_sysroot_native = os.path.realpath(
366            recipe_d.getVar('RECIPE_SYSROOT_NATIVE'))
367        self.staging_bindir_toolchain = os.path.realpath(
368            recipe_d.getVar('STAGING_BINDIR_TOOLCHAIN'))
369        self.staging_incdir = os.path.realpath(
370            recipe_d.getVar('STAGING_INCDIR'))
371        self.strip_cmd = recipe_d.getVar('STRIP')
372        self.target_arch = recipe_d.getVar('TARGET_ARCH')
373        self.target_dbgsrc_dir = recipe_d.getVar('TARGET_DBGSRC_DIR')
374        self.topdir = recipe_d.getVar('TOPDIR')
375        self.workdir = os.path.realpath(recipe_d.getVar('WORKDIR'))
376
377        self.__init_exported_variables(recipe_d)
378
379        if bb.data.inherits_class('cmake', recipe_d):
380            self.oecmake_generator = recipe_d.getVar('OECMAKE_GENERATOR')
381            self.__init_cmake_preset_cache(recipe_d)
382            self.build_tool = BuildTool.CMAKE
383        elif bb.data.inherits_class('meson', recipe_d):
384            self.meson_buildtype = recipe_d.getVar('MESON_BUILDTYPE')
385            self.mesonopts = recipe_d.getVar('MESONOPTS')
386            self.extra_oemeson = recipe_d.getVar('EXTRA_OEMESON')
387            self.meson_cross_file = recipe_d.getVar('MESON_CROSS_FILE')
388            self.build_tool = BuildTool.MESON
389
390        # Recipe ID is the identifier for IDE config sections
391        self.recipe_id = self.bpn + "-" + self.package_arch
392        self.recipe_id_pretty = self.bpn + ": " + self.package_arch
393
394    @staticmethod
395    def is_valid_shell_variable(var):
396        """Skip strange shell variables like systemd
397
398        prevent from strange bugs because of strange variables which
399        are not used in this context but break various tools.
400        """
401        if RecipeModified.VALID_BASH_ENV_NAME_CHARS.match(var):
402            bb.debug(1, "ignoring variable: %s" % var)
403            return True
404        return False
405
406    def solib_search_path(self, image):
407        """Search for debug symbols in the rootfs and rootfs-dbg
408
409        The debug symbols of shared libraries which are provided by other packages
410        are grabbed from the -dbg packages in the rootfs-dbg.
411
412        But most cross debugging tools like gdb, perf, and systemtap need to find
413        executable/library first and through it debuglink note find corresponding
414        symbols file. Therefore the library paths from the rootfs are added as well.
415
416        Note: For the devtool modified recipe compiled from the IDE, the debug
417        symbols are taken from the unstripped binaries in the image folder.
418        Also, devtool deploy-target takes the files from the image folder.
419        debug symbols in the image folder refer to the corresponding source files
420        with absolute paths of the build machine. Debug symbols found in the
421        rootfs-dbg are relocated and contain paths which refer to the source files
422        installed on the target device e.g. /usr/src/...
423        """
424        base_libdir = self.base_libdir.lstrip('/')
425        libdir = self.libdir.lstrip('/')
426        so_paths = [
427            # debug symbols for package_debug_split_style: debug-with-srcpkg or .debug
428            os.path.join(image.rootfs_dbg, base_libdir, ".debug"),
429            os.path.join(image.rootfs_dbg, libdir, ".debug"),
430            # debug symbols for package_debug_split_style: debug-file-directory
431            os.path.join(image.rootfs_dbg, "usr", "lib", "debug"),
432
433            # The binaries are required as well, the debug packages are not enough
434            # With image-combined-dbg.bbclass the binaries are copied into rootfs-dbg
435            os.path.join(image.rootfs_dbg, base_libdir),
436            os.path.join(image.rootfs_dbg, libdir),
437            # Without image-combined-dbg.bbclass the binaries are only in rootfs.
438            # Note: Stepping into source files located in rootfs-dbg does not
439            #       work without image-combined-dbg.bbclass yet.
440            os.path.join(image.rootfs, base_libdir),
441            os.path.join(image.rootfs, libdir)
442        ]
443        return so_paths
444
445    def solib_search_path_str(self, image):
446        """Return a : separated list of paths usable by GDB's set solib-search-path"""
447        return ':'.join(self.solib_search_path(image))
448
449    def __init_exported_variables(self, d):
450        """Find all variables with export flag set.
451
452        This allows to generate IDE configurations which compile with the same
453        environment as bitbake does. That's at least a reasonable default behavior.
454        """
455        exported_vars = {}
456
457        vars = (key for key in d.keys() if not key.startswith(
458            "__") and not d.getVarFlag(key, "func", False))
459        for var in sorted(vars):
460            func = d.getVarFlag(var, "func", False)
461            if d.getVarFlag(var, 'python', False) and func:
462                continue
463            export = d.getVarFlag(var, "export", False)
464            unexport = d.getVarFlag(var, "unexport", False)
465            if not export and not unexport and not func:
466                continue
467            if unexport:
468                continue
469
470            val = d.getVar(var)
471            if val is None:
472                continue
473            if set(var) & set("-.{}+"):
474                logger.warn(
475                    "Warning: Found invalid character in variable name %s", str(var))
476                continue
477            varExpanded = d.expand(var)
478            val = str(val)
479
480            if not RecipeModified.is_valid_shell_variable(varExpanded):
481                continue
482
483            if func:
484                code_line = "line: {0}, file: {1}\n".format(
485                    d.getVarFlag(var, "lineno", False),
486                    d.getVarFlag(var, "filename", False))
487                val = val.rstrip('\n')
488                logger.warn("Warning: exported shell function %s() is not exported (%s)" %
489                            (varExpanded, code_line))
490                continue
491
492            if export:
493                exported_vars[varExpanded] = val.strip()
494                continue
495
496        self.exported_vars = exported_vars
497
498    def __init_cmake_preset_cache(self, d):
499        """Get the arguments passed to cmake
500
501        Replicate the cmake configure arguments with all details to
502        share on build folder between bitbake and SDK.
503        """
504        site_file = os.path.join(self.workdir, 'site-file.cmake')
505        if os.path.exists(site_file):
506            print("Warning: site-file.cmake is not supported")
507
508        cache_vars = {}
509        oecmake_args = d.getVar('OECMAKE_ARGS').split()
510        extra_oecmake = d.getVar('EXTRA_OECMAKE').split()
511        for param in sorted(oecmake_args + extra_oecmake):
512            d_pref = "-D"
513            if param.startswith(d_pref):
514                param = param[len(d_pref):]
515            else:
516                print("Error: expected a -D")
517            param_s = param.split('=', 1)
518            param_nt = param_s[0].split(':', 1)
519
520            def handle_undefined_variable(var):
521                if var.startswith('${') and var.endswith('}'):
522                    return ''
523                else:
524                    return var
525            # Example: FOO=ON
526            if len(param_nt) == 1:
527                cache_vars[param_s[0]] = handle_undefined_variable(param_s[1])
528            # Example: FOO:PATH=/tmp
529            elif len(param_nt) == 2:
530                cache_vars[param_nt[0]] = {
531                    "type": param_nt[1],
532                    "value": handle_undefined_variable(param_s[1]),
533                }
534            else:
535                print("Error: cannot parse %s" % param)
536        self.cmake_cache_vars = cache_vars
537
538    def cmake_preset(self):
539        """Create a preset for cmake that mimics how bitbake calls cmake"""
540        toolchain_file = os.path.join(self.workdir, 'toolchain.cmake')
541        cmake_executable = os.path.join(
542            self.recipe_sysroot_native, 'usr', 'bin', 'cmake')
543        self.cmd_compile = cmake_executable + " --build --preset " + self.recipe_id
544
545        preset_dict_configure = {
546            "name": self.recipe_id,
547            "displayName": self.recipe_id_pretty,
548            "description": "Bitbake build environment for the recipe %s compiled for %s" % (self.bpn, self.package_arch),
549            "binaryDir": self.b,
550            "generator": self.oecmake_generator,
551            "toolchainFile": toolchain_file,
552            "cacheVariables": self.cmake_cache_vars,
553            "environment": self.exported_vars,
554            "cmakeExecutable": cmake_executable
555        }
556
557        preset_dict_build = {
558            "name": self.recipe_id,
559            "displayName": self.recipe_id_pretty,
560            "description": "Bitbake build environment for the recipe %s compiled for %s" % (self.bpn, self.package_arch),
561            "configurePreset": self.recipe_id,
562            "inheritConfigureEnvironment": True
563        }
564
565        preset_dict_test = {
566            "name": self.recipe_id,
567            "displayName": self.recipe_id_pretty,
568            "description": "Bitbake build environment for the recipe %s compiled for %s" % (self.bpn, self.package_arch),
569            "configurePreset": self.recipe_id,
570            "inheritConfigureEnvironment": True
571        }
572
573        preset_dict = {
574            "version": 3,  # cmake 3.21, backward compatible with kirkstone
575            "configurePresets": [preset_dict_configure],
576            "buildPresets": [preset_dict_build],
577            "testPresets": [preset_dict_test]
578        }
579
580        # Finally write the json file
581        json_file = 'CMakeUserPresets.json'
582        json_path = os.path.join(self.real_srctree, json_file)
583        logger.info("Updating CMake preset: %s (%s)" % (json_file, json_path))
584        if not os.path.exists(self.real_srctree):
585            os.makedirs(self.real_srctree)
586        try:
587            with open(json_path) as f:
588                orig_dict = json.load(f)
589        except json.decoder.JSONDecodeError:
590            logger.info(
591                "Decoding %s failed. Probably because of comments in the json file" % json_path)
592            orig_dict = {}
593        except FileNotFoundError:
594            orig_dict = {}
595
596        # Add or update the presets for the recipe and keep other presets
597        for k, v in preset_dict.items():
598            if isinstance(v, list):
599                update_preset = v[0]
600                preset_added = False
601                if k in orig_dict:
602                    for index, orig_preset in enumerate(orig_dict[k]):
603                        if 'name' in orig_preset:
604                            if orig_preset['name'] == update_preset['name']:
605                                logger.debug("Updating preset: %s" %
606                                             orig_preset['name'])
607                                orig_dict[k][index] = update_preset
608                                preset_added = True
609                                break
610                            else:
611                                logger.debug("keeping preset: %s" %
612                                             orig_preset['name'])
613                        else:
614                            logger.warn("preset without a name found")
615                if not preset_added:
616                    if not k in orig_dict:
617                        orig_dict[k] = []
618                    orig_dict[k].append(update_preset)
619                    logger.debug("Added preset: %s" %
620                                 update_preset['name'])
621            else:
622                orig_dict[k] = v
623
624        with open(json_path, 'w') as f:
625            json.dump(orig_dict, f, indent=4)
626
627    def gen_meson_wrapper(self):
628        """Generate a wrapper script to call meson with the cross environment"""
629        bb.utils.mkdirhier(self.ide_sdk_scripts_dir)
630        meson_wrapper = os.path.join(self.ide_sdk_scripts_dir, 'meson')
631        meson_real = os.path.join(
632            self.recipe_sysroot_native, 'usr', 'bin', 'meson.real')
633        with open(meson_wrapper, 'w') as mwrap:
634            mwrap.write("#!/bin/sh" + os.linesep)
635            for var, val in self.exported_vars.items():
636                mwrap.write('export %s="%s"' % (var, val) + os.linesep)
637            mwrap.write("unset CC CXX CPP LD AR NM STRIP" + os.linesep)
638            private_temp = os.path.join(self.b, "meson-private", "tmp")
639            mwrap.write('mkdir -p "%s"' % private_temp + os.linesep)
640            mwrap.write('export TMPDIR="%s"' % private_temp + os.linesep)
641            mwrap.write('exec "%s" "$@"' % meson_real + os.linesep)
642        st = os.stat(meson_wrapper)
643        os.chmod(meson_wrapper, st.st_mode | stat.S_IEXEC)
644        self.meson_wrapper = meson_wrapper
645        self.cmd_compile = meson_wrapper + " compile -C " + self.b
646
647    def which(self, executable):
648        bin_path = shutil.which(executable, path=self.path)
649        if not bin_path:
650            raise DevtoolError(
651                'Cannot find %s. Probably the recipe %s is not built yet.' % (executable, self.bpn))
652        return bin_path
653
654    @staticmethod
655    def is_elf_file(file_path):
656        with open(file_path, "rb") as f:
657            data = f.read(4)
658        if data == b'\x7fELF':
659            return True
660        return False
661
662    def find_installed_binaries(self):
663        """find all executable elf files in the image directory"""
664        binaries = []
665        d_len = len(self.d)
666        re_so = re.compile(r'.*\.so[.0-9]*$')
667        for root, _, files in os.walk(self.d, followlinks=False):
668            for file in files:
669                if os.path.islink(file):
670                    continue
671                if re_so.match(file):
672                    continue
673                abs_name = os.path.join(root, file)
674                if os.access(abs_name, os.X_OK) and RecipeModified.is_elf_file(abs_name):
675                    binaries.append(abs_name[d_len:])
676        return sorted(binaries)
677
678    def gen_deploy_target_script(self, args):
679        """Generate a script which does what devtool deploy-target does
680
681        This script is much quicker than devtool target-deploy. Because it
682        does not need to start a bitbake server. All information from tinfoil
683        is hard-coded in the generated script.
684        """
685        cmd_lines = ['#!%s' % str(sys.executable)]
686        cmd_lines.append('import sys')
687        cmd_lines.append('devtool_sys_path = %s' % str(sys.path))
688        cmd_lines.append('devtool_sys_path.reverse()')
689        cmd_lines.append('for p in devtool_sys_path:')
690        cmd_lines.append('    if p not in sys.path:')
691        cmd_lines.append('        sys.path.insert(0, p)')
692        cmd_lines.append('from devtool.deploy import deploy_no_d')
693        args_filter = ['debug', 'dry_run', 'key', 'no_check_space', 'no_host_check',
694                       'no_preserve', 'port', 'show_status', 'ssh_exec', 'strip', 'target']
695        filtered_args_dict = {key: value for key, value in vars(
696            args).items() if key in args_filter}
697        cmd_lines.append('filtered_args_dict = %s' % str(filtered_args_dict))
698        cmd_lines.append('class Dict2Class(object):')
699        cmd_lines.append('    def __init__(self, my_dict):')
700        cmd_lines.append('        for key in my_dict:')
701        cmd_lines.append('            setattr(self, key, my_dict[key])')
702        cmd_lines.append('filtered_args = Dict2Class(filtered_args_dict)')
703        cmd_lines.append(
704            'setattr(filtered_args, "recipename", "%s")' % self.bpn)
705        cmd_lines.append('deploy_no_d("%s", "%s", "%s", "%s", "%s", "%s", %d, "%s", "%s", filtered_args)' %
706                         (self.d, self.workdir, self.path, self.strip_cmd,
707                          self.libdir, self.base_libdir, self.max_process,
708                          self.fakerootcmd, self.fakerootenv))
709        return self.write_script(cmd_lines, 'deploy_target')
710
711    def gen_install_deploy_script(self, args):
712        """Generate a script which does install and deploy"""
713        cmd_lines = ['#!/bin/bash']
714
715        # . oe-init-build-env $BUILDDIR
716        # Note: Sourcing scripts with arguments requires bash
717        cmd_lines.append('cd "%s" || { echo "cd %s failed"; exit 1; }' % (
718            self.oe_init_dir, self.oe_init_dir))
719        cmd_lines.append('. "%s" "%s" || { echo ". %s %s failed"; exit 1; }' % (
720            self.oe_init_build_env, self.topdir, self.oe_init_build_env, self.topdir))
721
722        # bitbake -c install
723        cmd_lines.append(
724            'bitbake %s -c install --force || { echo "bitbake %s -c install --force failed"; exit 1; }' % (self.bpn, self.bpn))
725
726        # Self contained devtool deploy-target
727        cmd_lines.append(self.gen_deploy_target_script(args))
728
729        return self.write_script(cmd_lines, 'install_and_deploy')
730
731    def write_script(self, cmd_lines, script_name):
732        bb.utils.mkdirhier(self.ide_sdk_scripts_dir)
733        script_name_arch = script_name + '_' + self.recipe_id
734        script_file = os.path.join(self.ide_sdk_scripts_dir, script_name_arch)
735        with open(script_file, 'w') as script_f:
736            script_f.write(os.linesep.join(cmd_lines))
737        st = os.stat(script_file)
738        os.chmod(script_file, st.st_mode | stat.S_IEXEC)
739        return script_file
740
741    @property
742    def oe_init_build_env(self):
743        """Find the oe-init-build-env used for this setup"""
744        oe_init_dir = self.oe_init_dir
745        if oe_init_dir:
746            return os.path.join(oe_init_dir, RecipeModified.OE_INIT_BUILD_ENV)
747        return None
748
749    @property
750    def oe_init_dir(self):
751        """Find the directory where the oe-init-build-env is located
752
753        Assumption: There might be a layer with higher priority than poky
754        which provides to oe-init-build-env in the layer's toplevel folder.
755        """
756        if not self.__oe_init_dir:
757            for layer in reversed(self.bblayers):
758                result = subprocess.run(
759                    ['git', 'rev-parse', '--show-toplevel'], cwd=layer, capture_output=True)
760                if result.returncode == 0:
761                    oe_init_dir = result.stdout.decode('utf-8').strip()
762                    oe_init_path = os.path.join(
763                        oe_init_dir, RecipeModified.OE_INIT_BUILD_ENV)
764                    if os.path.exists(oe_init_path):
765                        logger.debug("Using %s from: %s" % (
766                            RecipeModified.OE_INIT_BUILD_ENV, oe_init_path))
767                        self.__oe_init_dir = oe_init_dir
768                        break
769            if not self.__oe_init_dir:
770                logger.error("Cannot find the bitbake top level folder")
771        return self.__oe_init_dir
772
773
774def ide_setup(args, config, basepath, workspace):
775    """Generate the IDE configuration for the workspace"""
776
777    # Explicitely passing some special recipes does not make sense
778    for recipe in args.recipenames:
779        if recipe in ['meta-ide-support', 'build-sysroots']:
780            raise DevtoolError("Invalid recipe: %s." % recipe)
781
782    # Collect information about tasks which need to be bitbaked
783    bootstrap_tasks = []
784    bootstrap_tasks_late = []
785    tinfoil = setup_tinfoil(config_only=False, basepath=basepath)
786    try:
787        # define mode depending on recipes which need to be processed
788        recipes_image_names = []
789        recipes_modified_names = []
790        recipes_other_names = []
791        for recipe in args.recipenames:
792            try:
793                check_workspace_recipe(
794                    workspace, recipe, bbclassextend=True)
795                recipes_modified_names.append(recipe)
796            except DevtoolError:
797                recipe_d = parse_recipe(
798                    config, tinfoil, recipe, appends=True, filter_workspace=False)
799                if not recipe_d:
800                    raise DevtoolError("Parsing recipe %s failed" % recipe)
801                if bb.data.inherits_class('image', recipe_d):
802                    recipes_image_names.append(recipe)
803                else:
804                    recipes_other_names.append(recipe)
805
806        invalid_params = False
807        if args.mode == DevtoolIdeMode.shared:
808            if len(recipes_modified_names):
809                logger.error("In shared sysroots mode modified recipes %s cannot be handled." % str(
810                    recipes_modified_names))
811                invalid_params = True
812        if args.mode == DevtoolIdeMode.modified:
813            if len(recipes_other_names):
814                logger.error("Only in shared sysroots mode not modified recipes %s can be handled." % str(
815                    recipes_other_names))
816                invalid_params = True
817            if len(recipes_image_names) != 1:
818                logger.error(
819                    "One image recipe is required as the rootfs for the remote development.")
820                invalid_params = True
821            for modified_recipe_name in recipes_modified_names:
822                if modified_recipe_name.startswith('nativesdk-') or modified_recipe_name.endswith('-native'):
823                    logger.error(
824                        "Only cross compiled recipes are support. %s is not cross." % modified_recipe_name)
825                    invalid_params = True
826
827        if invalid_params:
828            raise DevtoolError("Invalid parameters are passed.")
829
830        # For the shared sysroots mode, add all dependencies of all the images to the sysroots
831        # For the modified mode provide one rootfs and the corresponding debug symbols via rootfs-dbg
832        recipes_images = []
833        for recipes_image_name in recipes_image_names:
834            logger.info("Using image: %s" % recipes_image_name)
835            recipe_image = RecipeImage(recipes_image_name)
836            recipe_image.initialize(config, tinfoil)
837            bootstrap_tasks += recipe_image.bootstrap_tasks
838            recipes_images.append(recipe_image)
839
840        # Provide a Direct SDK with shared sysroots
841        recipes_not_modified = []
842        if args.mode == DevtoolIdeMode.shared:
843            ide_support = RecipeMetaIdeSupport()
844            ide_support.initialize(config, tinfoil)
845            bootstrap_tasks += ide_support.bootstrap_tasks
846
847            logger.info("Adding %s to the Direct SDK sysroots." %
848                        str(recipes_other_names))
849            for recipe_name in recipes_other_names:
850                recipe_not_modified = RecipeNotModified(recipe_name)
851                bootstrap_tasks += recipe_not_modified.bootstrap_tasks
852                recipes_not_modified.append(recipe_not_modified)
853
854            build_sysroots = RecipeBuildSysroots()
855            build_sysroots.initialize(config, tinfoil)
856            bootstrap_tasks_late += build_sysroots.bootstrap_tasks
857            shared_env = SharedSysrootsEnv()
858            shared_env.initialize(ide_support, build_sysroots)
859
860        recipes_modified = []
861        if args.mode == DevtoolIdeMode.modified:
862            logger.info("Setting up workspaces for modified recipe: %s" %
863                        str(recipes_modified_names))
864            gdbs_cross = {}
865            for recipe_name in recipes_modified_names:
866                recipe_modified = RecipeModified(recipe_name)
867                recipe_modified.initialize(config, workspace, tinfoil)
868                bootstrap_tasks += recipe_modified.bootstrap_tasks
869                recipes_modified.append(recipe_modified)
870
871                if recipe_modified.target_arch not in gdbs_cross:
872                    target_device = TargetDevice(args)
873                    gdb_cross = RecipeGdbCross(
874                        args, recipe_modified.target_arch, target_device)
875                    gdb_cross.initialize(config, workspace, tinfoil)
876                    bootstrap_tasks += gdb_cross.bootstrap_tasks
877                    gdbs_cross[recipe_modified.target_arch] = gdb_cross
878                recipe_modified.gdb_cross = gdbs_cross[recipe_modified.target_arch]
879
880    finally:
881        tinfoil.shutdown()
882
883    if not args.skip_bitbake:
884        bb_cmd = 'bitbake '
885        if args.bitbake_k:
886            bb_cmd += "-k "
887        bb_cmd_early = bb_cmd + ' '.join(bootstrap_tasks)
888        exec_build_env_command(
889            config.init_path, basepath, bb_cmd_early, watch=True)
890        if bootstrap_tasks_late:
891            bb_cmd_late = bb_cmd + ' '.join(bootstrap_tasks_late)
892            exec_build_env_command(
893                config.init_path, basepath, bb_cmd_late, watch=True)
894
895    for recipe_image in recipes_images:
896        if (recipe_image.gdbserver_missing):
897            logger.warning(
898                "gdbserver not installed in image %s. Remote debugging will not be available" % recipe_image)
899
900        if recipe_image.combine_dbg_image is False:
901            logger.warning(
902                'IMAGE_CLASSES += "image-combined-dbg" is missing for image %s. Remote debugging will not find debug symbols from rootfs-dbg.' % recipe_image)
903
904    # Instantiate the active IDE plugin
905    ide = ide_plugins[args.ide]()
906    if args.mode == DevtoolIdeMode.shared:
907        ide.setup_shared_sysroots(shared_env)
908    elif args.mode == DevtoolIdeMode.modified:
909        for recipe_modified in recipes_modified:
910            if recipe_modified.build_tool is BuildTool.CMAKE:
911                recipe_modified.cmake_preset()
912            if recipe_modified.build_tool is BuildTool.MESON:
913                recipe_modified.gen_meson_wrapper()
914            ide.setup_modified_recipe(
915                args, recipe_image, recipe_modified)
916
917            if recipe_modified.debug_build != '1':
918                logger.warn(
919                    'Recipe %s is compiled with release build configuration. '
920                    'You might want to add DEBUG_BUILD = "1" to %s. '
921                    'Note that devtool modify --debug-build can do this automatically.',
922                    recipe_modified.name, recipe_modified.bbappend)
923    else:
924        raise DevtoolError("Must not end up here.")
925
926
927def register_commands(subparsers, context):
928    """Register devtool subcommands from this plugin"""
929
930    # The ide-sdk command bootstraps the SDK from the bitbake environment before the IDE
931    # configuration is generated. In the case of the eSDK, the bootstrapping is performed
932    # during the installation of the eSDK installer. Running the ide-sdk plugin from an
933    # eSDK installer-based setup would require skipping the bootstrapping and probably
934    # taking some other differences into account when generating the IDE configurations.
935    # This would be possible. But it is not implemented.
936    if context.fixed_setup:
937        return
938
939    global ide_plugins
940
941    # Search for IDE plugins in all sub-folders named ide_plugins where devtool seraches for plugins.
942    pluginpaths = [os.path.join(path, 'ide_plugins')
943                   for path in context.pluginpaths]
944    ide_plugin_modules = []
945    for pluginpath in pluginpaths:
946        scriptutils.load_plugins(logger, ide_plugin_modules, pluginpath)
947
948    for ide_plugin_module in ide_plugin_modules:
949        if hasattr(ide_plugin_module, 'register_ide_plugin'):
950            ide_plugin_module.register_ide_plugin(ide_plugins)
951    # Sort plugins according to their priority. The first entry is the default IDE plugin.
952    ide_plugins = dict(sorted(ide_plugins.items(),
953                       key=lambda p: p[1].ide_plugin_priority(), reverse=True))
954
955    parser_ide_sdk = subparsers.add_parser('ide-sdk', group='working', order=50, formatter_class=RawTextHelpFormatter,
956                                           help='Setup the SDK and configure the IDE')
957    parser_ide_sdk.add_argument(
958        'recipenames', nargs='+', help='Generate an IDE configuration suitable to work on the given recipes.\n'
959        'Depending on the --mode parameter different types of SDKs and IDE configurations are generated.')
960    parser_ide_sdk.add_argument(
961        '-m', '--mode', type=DevtoolIdeMode, default=DevtoolIdeMode.modified,
962        help='Different SDK types are supported:\n'
963        '- "' + DevtoolIdeMode.modified.name + '" (default):\n'
964        '  devtool modify creates a workspace to work on the source code of a recipe.\n'
965        '  devtool ide-sdk builds the SDK and generates the IDE configuration(s) in the workspace directorie(s)\n'
966        '  Usage example:\n'
967        '    devtool modify cmake-example\n'
968        '    devtool ide-sdk cmake-example core-image-minimal\n'
969        '    Start the IDE in the workspace folder\n'
970        '  At least one devtool modified recipe plus one image recipe are required:\n'
971        '  The image recipe is used to generate the target image and the remote debug configuration.\n'
972        '- "' + DevtoolIdeMode.shared.name + '":\n'
973        '  Usage example:\n'
974        '    devtool ide-sdk -m ' + DevtoolIdeMode.shared.name + ' recipe(s)\n'
975        '  This command generates a cross-toolchain as well as the corresponding shared sysroot directories.\n'
976        '  To use this tool-chain the environment-* file found in the deploy..image folder needs to be sourced into a shell.\n'
977        '  In case of VSCode and cmake the tool-chain is also exposed as a cmake-kit')
978    default_ide = list(ide_plugins.keys())[0]
979    parser_ide_sdk.add_argument(
980        '-i', '--ide', choices=ide_plugins.keys(), default=default_ide,
981        help='Setup the configuration for this IDE (default: %s)' % default_ide)
982    parser_ide_sdk.add_argument(
983        '-t', '--target', default='root@192.168.7.2',
984        help='Live target machine running an ssh server: user@hostname.')
985    parser_ide_sdk.add_argument(
986        '-G', '--gdbserver-port-start', default="1234", help='port where gdbserver is listening.')
987    parser_ide_sdk.add_argument(
988        '-c', '--no-host-check', help='Disable ssh host key checking', action='store_true')
989    parser_ide_sdk.add_argument(
990        '-e', '--ssh-exec', help='Executable to use in place of ssh')
991    parser_ide_sdk.add_argument(
992        '-P', '--port', help='Specify ssh port to use for connection to the target')
993    parser_ide_sdk.add_argument(
994        '-I', '--key', help='Specify ssh private key for connection to the target')
995    parser_ide_sdk.add_argument(
996        '--skip-bitbake', help='Generate IDE configuration but skip calling bitbake to update the SDK', action='store_true')
997    parser_ide_sdk.add_argument(
998        '-k', '--bitbake-k', help='Pass -k parameter to bitbake', action='store_true')
999    parser_ide_sdk.add_argument(
1000        '--no-strip', help='Do not strip executables prior to deploy', dest='strip', action='store_false')
1001    parser_ide_sdk.add_argument(
1002        '-n', '--dry-run', help='List files to be undeployed only', action='store_true')
1003    parser_ide_sdk.add_argument(
1004        '-s', '--show-status', help='Show progress/status output', action='store_true')
1005    parser_ide_sdk.add_argument(
1006        '-p', '--no-preserve', help='Do not preserve existing files', action='store_true')
1007    parser_ide_sdk.add_argument(
1008        '--no-check-space', help='Do not check for available space before deploying', action='store_true')
1009    parser_ide_sdk.set_defaults(func=ide_setup)
1010