diff mbox series

[11/24] devtool: deploy-target: add --package/--file-glob filters

Message ID 20260830142922.17241-12-adrian.freihofer@siemens.com
State New
Headers show
Series devtool: ide-sdk: NFS/slirp support, deploy filtering, and robustness fixes | expand

Commit Message

AdrianF Aug. 30, 2026, 2:28 p.m. UTC
From: Adrian Freihofer <adrian.freihofer@siemens.com>

Allow deploying only a subset of a recipe's installed files instead of
always sending the whole do_install output. This can be crucial when a
recipe's PACKAGES include additional content (e.g. -doc, -staticdev, or
optional extras) that does not even fit into the target device's
storage/memory; it also speeds up deployment significantly by avoiding
the transfer of files that are not actually needed for testing on the
target.

- --package PACKAGE filters the deployed files down to those listed in
  that package's FILES variable.
- --file-glob GLOB filters by an arbitrary glob pattern on the installed
  path. --package and --file-glob combine as a union when both are given.

Examples:
  devtool deploy-target mdadm root@target
      deploy everything (minus -dbg/-src/-staticdev, see above)
  devtool deploy-target mdadm root@target --package mdadm-doc
      deploy only the mdadm-doc package's files
  devtool deploy-target mdadm root@target --package mdadm,mdadm-doc
      deploy the main and -doc packages together
  devtool deploy-target mdadm root@target --file-glob '/usr/bin/*'
      deploy only files under /usr/bin
  devtool ide-sdk mdadm cmake-example core-image-minimal \
      --package "mdadm:,-doc" --package "cmake-example:,-doc"
      scope each --package entry to its own recipe when several
      modified recipes are targeted at once

Signed-off-by: Adrian Freihofer <adrian.freihofer@siemens.com>
---
 scripts/lib/devtool/deploy.py | 158 ++++++++++++++++++++++++++++++++--
 1 file changed, 152 insertions(+), 6 deletions(-)

Comments

patchtest@automation.yoctoproject.org Aug. 30, 2026, 2:56 p.m. UTC | #1
Thank you for your submission. Patchtest identified one
or more issues with the patch. Please see the log below for
more information:

---
Testing patch /home/patchtest/share/mboxes/11-24-devtool-deploy-target-add---package---file-glob-filters.patch

FAIL: test max line length: Patch line too long (current length 247, maximum is 200) (test_metadata.TestMetadata.test_max_line_length)

PASS: pretest pylint (test_python_pylint.PyLint.pretest_pylint)
PASS: test Signed-off-by presence (test_mbox.TestMbox.test_signed_off_by_presence)
PASS: test auh changelog truncation notice (test_mbox.TestMbox.test_auh_changelog_truncation_notice)
PASS: test author valid (test_mbox.TestMbox.test_author_valid)
PASS: test commit message presence (test_mbox.TestMbox.test_commit_message_presence)
PASS: test commit message user tags (test_mbox.TestMbox.test_commit_message_user_tags)
PASS: test mbox format (test_mbox.TestMbox.test_mbox_format)
PASS: test non-AUH upgrade (test_mbox.TestMbox.test_non_auh_upgrade)
PASS: test pylint (test_python_pylint.PyLint.test_pylint)
PASS: test shortlog format (test_mbox.TestMbox.test_shortlog_format)
PASS: test shortlog length (test_mbox.TestMbox.test_shortlog_length)
PASS: test target mailing list (test_mbox.TestMbox.test_target_mailing_list)

SKIP: pretest src uri left files: No modified recipes, skipping pretest (test_metadata.TestMetadata.pretest_src_uri_left_files)
SKIP: test CVE check ignore: No modified recipes or older target branch, skipping test (test_metadata.TestMetadata.test_cve_check_ignore)
SKIP: test CVE tag format: No new source patches introduced (test_patch.TestPatch.test_cve_tag_format)
SKIP: test Signed-off-by presence: No new source patches introduced (test_patch.TestPatch.test_signed_off_by_presence)
SKIP: test Upstream-Status presence: No new source patches introduced (test_patch.TestPatch.test_upstream_status_presence_format)
SKIP: test bugzilla entry format: No bug ID found (test_mbox.TestMbox.test_bugzilla_entry_format)
SKIP: test lic files chksum modified not mentioned: No modified recipes, skipping test (test_metadata.TestMetadata.test_lic_files_chksum_modified_not_mentioned)
SKIP: test lic files chksum presence: No added recipes, skipping test (test_metadata.TestMetadata.test_lic_files_chksum_presence)
SKIP: test license presence: No added recipes, skipping test (test_metadata.TestMetadata.test_license_presence)
SKIP: test series merge on head: Merge test is disabled for now (test_mbox.TestMbox.test_series_merge_on_head)
SKIP: test src uri left files: No modified recipes, skipping test (test_metadata.TestMetadata.test_src_uri_left_files)
SKIP: test summary presence: No added recipes, skipping test (test_metadata.TestMetadata.test_summary_presence)

---

Please address the issues identified and
submit a new revision of the patch, or alternatively, reply to this
email with an explanation of why the patch should be accepted. If you
believe these results are due to an error in patchtest, please submit a
bug at https://bugzilla.yoctoproject.org/ (use the 'Patchtest' category
under 'Yocto Project Subprojects'). For more information on specific
failures, see: https://wiki.yoctoproject.org/wiki/Patchtest. Thank
you!
diff mbox series

Patch

diff --git a/scripts/lib/devtool/deploy.py b/scripts/lib/devtool/deploy.py
index 35ec0b1b4d..cf481d6b63 100644
--- a/scripts/lib/devtool/deploy.py
+++ b/scripts/lib/devtool/deploy.py
@@ -17,6 +17,7 @@  import tempfile
 import bb.utils
 import argparse_oe
 import oe.types
+import oe.package
 
 from devtool import exec_fakeroot_no_d, setup_tinfoil, check_workspace_recipe, DevtoolError
 
@@ -137,6 +138,92 @@  def _prepare_remote_script(deploy, destdir='/', verbose=False, dryrun=False, und
 
     return '\n'.join(lines)
 
+def parse_file_globs_arg(entries, recipename=None):
+    """Flatten --file-glob entries ("[RECIPE:]GLOB") into glob patterns for recipename.
+
+    E.g. ["other:/usr/bin/*", "/etc/*"] with recipename="myrecipe" -> ["/etc/*"].
+    """
+    globs = []
+    for entry in entries or []:
+        recipe, spec = entry.split(':', 1) if ':' in entry else (None, entry)
+        if recipe is not None and recipename is not None and recipe != recipename:
+            continue
+        if spec:
+            globs.append(spec)
+    return globs
+
+def _match_deploy_files(recipe_outdir, file_globs, recipename=None):
+    """Match file_globs (FILES-variable-style patterns) against recipe_outdir.
+
+    Returns the matched files (relative to recipe_outdir), or None if no glob
+    applies to recipename.
+    """
+    file_globs = parse_file_globs_arg(file_globs, recipename)
+    if not file_globs:
+        return None
+    cwd = os.getcwd()
+    os.chdir(recipe_outdir)
+    try:
+        matched, _ = oe.package.files_from_filevars(file_globs)
+    finally:
+        os.chdir(cwd)
+    return {os.path.normpath(f) for f in matched}
+
+def parse_packages_arg(entries, recipename=None):
+    """Flatten --package entries ("[RECIPE:]PKG[,PKG...]") into package names.
+
+    E.g. with recipename="myrecipe": "myrecipe:,-doc,-ptest" ->
+    ["myrecipe", "myrecipe-doc", "myrecipe-ptest"]; "other:foo" -> [].
+    """
+    packages = []
+    for entry in entries or []:
+        recipe, spec = entry.split(':', 1) if ':' in entry else (None, entry)
+        if recipe is not None and recipename is not None and recipe != recipename:
+            continue
+        for item in spec.split(','):
+            if not item:
+                if recipe is not None:
+                    packages.append(recipe)
+            elif item.startswith('-'):
+                if recipe is None:
+                    raise DevtoolError('Package suffix "%s" requires a "RECIPE:" prefix, '
+                                    'e.g. "RECIPE:%s"' % (item, item))
+                packages.append(recipe + item)
+            else:
+                packages.append(item)
+    return packages
+
+def is_default_excluded_package(pkg):
+    """True for packages left out of a deploy"""
+    return pkg.endswith(('-dbg', '-src', '-staticdev'))
+
+def _match_package_files(recipe_outdir, packages_files, packages, recipename=None):
+    """Resolve packages (see parse_packages_arg) into their files under recipe_outdir."""
+    packages = parse_packages_arg(packages, recipename)
+    all_package_names = [pkg for pkg, _ in packages_files]
+    for pkg in packages:
+        if pkg not in all_package_names:
+            raise DevtoolError('Package "%s" is not one of the packages produced '
+                            'by this recipe (PACKAGES: %s)' % (pkg, ' '.join(all_package_names)))
+    cwd = os.getcwd()
+    os.chdir(recipe_outdir)
+    try:
+        seen = set()
+        result = set() if packages else None
+        default_excluded = set()
+        for pkg, files_var in packages_files:
+            matched, _ = oe.package.files_from_filevars((files_var or '').split())
+            matched = {os.path.normpath(f) for f in matched} - seen
+            seen |= matched
+            if pkg in packages:
+                if result is not None:
+                    result |= matched
+            elif is_default_excluded_package(pkg):
+                default_excluded |= matched
+    finally:
+        os.chdir(cwd)
+    return result, default_excluded
+
 def deploy(args, config, basepath, workspace):
     """Entry point for the devtool 'deploy' subcommand"""
     import oe.utils
@@ -160,14 +247,15 @@  def deploy(args, config, basepath, workspace):
         max_process = oe.utils.get_bb_number_threads(rd)
         fakerootcmd = rd.getVar('FAKEROOTCMD')
         fakerootenv = rd.getVar('FAKEROOTENV')
+        packages_files = [(pkg, rd.getVar('FILES:' + pkg) or '')
+                        for pkg in (rd.getVar('PACKAGES') or '').split()]
     finally:
         tinfoil.shutdown()
 
-    return deploy_no_d(srcdir, workdir, path, strip_cmd, libdir, base_libdir, max_process, fakerootcmd, fakerootenv, args)
+    return deploy_no_d(srcdir, workdir, path, strip_cmd, libdir, base_libdir, max_process, fakerootcmd, fakerootenv, args, file_globs=args.file_globs, packages_files=packages_files)
 
-def deploy_no_d(srcdir, workdir, path, strip_cmd, libdir, base_libdir, max_process, fakerootcmd, fakerootenv, args):
+def deploy_no_d(srcdir, workdir, path, strip_cmd, libdir, base_libdir, max_process, fakerootcmd, fakerootenv, args, file_globs=None, packages_files=None):
     import math
-    import oe.package
 
     try:
         host, destdir = args.target.split(':')
@@ -208,11 +296,35 @@  def deploy_no_d(srcdir, workdir, path, strip_cmd, libdir, base_libdir, max_proce
         if ret != 0:
             raise DevtoolError('Failed to strip files for deployment')
 
+    allowed_files = None
+    default_excluded_files = set()
+    file_sets = []
+    if file_globs:
+        deploy_files = _match_deploy_files(recipe_outdir, file_globs, getattr(args, 'recipename', None))
+        if deploy_files is not None:
+            file_sets.append(deploy_files)
+    if packages_files:
+        package_files, default_excluded_files = _match_package_files(recipe_outdir, packages_files, getattr(args, 'package', None),
+                                            getattr(args, 'recipename', None))
+        if package_files is not None:
+            file_sets.append(package_files)
+    if file_sets:
+        allowed_files = set().union(*file_sets)
+
     filelist = []
+    tar_relpaths = []
     inodes = set({})
     ftotalsize = 0
     for root, _, files in os.walk(recipe_outdir):
         for fn in files:
+            relpath = os.path.normpath(os.path.join(os.path.relpath(root, recipe_outdir), fn))
+            if allowed_files is not None:
+                if relpath not in allowed_files:
+                    continue
+            elif relpath in default_excluded_files:
+                # No explicit --package/--file-glob filter was given: still leave
+                # out packages like -staticdev that aren't needed on a live target.
+                continue
             fstat = os.lstat(os.path.join(root, fn))
             # Get the size in kiB (since we'll be comparing it to the output of du -k)
             # MUST use lstat() here not stat() or getfilesize() since we don't want to
@@ -226,6 +338,12 @@  def deploy_no_d(srcdir, workdir, path, strip_cmd, libdir, base_libdir, max_proce
             # The path as it would appear on the target
             fpath = os.path.join(destdir, os.path.relpath(root, recipe_outdir), fn)
             filelist.append((fpath, fsize))
+            tar_relpaths.append(relpath)
+
+    if allowed_files is not None and not filelist:
+        raise DevtoolError('No files to deploy for %s - the --package/--file-glob '
+                        'filter(s) did not match any of the files installed by this '
+                        'recipe.' % args.recipename)
 
     if args.dry_run:
         print('Files to be deployed for %s on target %s:' % (args.recipename, args.target))
@@ -282,8 +400,25 @@  def deploy_no_d(srcdir, workdir, path, strip_cmd, libdir, base_libdir, max_proce
     finally:
         shutil.rmtree(tmpdir)
 
-    # Now run the script
-    ret = exec_fakeroot_no_d(fakerootcmd, fakerootenv, path, 'tar cf - . | %s  %s %s %s \'sh %s %s %s %s\'' % (ssh_sshexec, ssh_port, extraoptions, args.target, tmpscript, args.recipename, destdir, tmpfilelist), cwd=recipe_outdir, shell=True)
+    # Now run the script. When a package/glob filter narrowed down filelist,
+    # tar is given an explicit list of relative paths (-T) instead of packing
+    # the whole recipe_outdir tree.
+    tar_filelist_path = None
+    try:
+        if allowed_files is not None:
+            tar_fd, tar_filelist_path = tempfile.mkstemp(prefix='devtool-deploy-filelist-')
+            with os.fdopen(tar_fd, 'w') as f:
+                for relpath in tar_relpaths:
+                    # './' prefix matches what 'tar cf - .' itself would produce, which
+                    # the remote script's manifest handling (sed "s!^./!$2!") relies on.
+                    f.write('./' + relpath + '\n')
+            tar_cmd = 'tar cf - -T %s' % shlex.quote(tar_filelist_path)
+        else:
+            tar_cmd = 'tar cf - .'
+        ret = exec_fakeroot_no_d(fakerootcmd, fakerootenv, path, '%s | %s  %s %s %s \'sh %s %s %s %s\'' % (tar_cmd, ssh_sshexec, ssh_port, extraoptions, args.target, tmpscript, args.recipename, destdir, tmpfilelist), cwd=recipe_outdir, shell=True)
+    finally:
+        if tar_filelist_path:
+            os.remove(tar_filelist_path)
     if ret != 0:
         raise DevtoolError('Deploy failed - rerun with -s to get a complete '
                         'error message')
@@ -362,7 +497,7 @@  def register_commands(subparsers, context):
 
     parser_deploy = subparsers.add_parser('deploy-target',
                                           help='Deploy recipe output files to live target machine',
-                                          description='Deploys a recipe\'s build output (i.e. the output of the do_install task) to a live target machine over ssh. By default, any existing files will be preserved instead of being overwritten and will be restored if you run devtool undeploy-target. Note: this only deploys the recipe itself and not any runtime dependencies, so it is assumed that those have been installed on the target beforehand.',
+                                          description='Deploys a recipe\'s build output (i.e. the output of the do_install task) to a live target machine over ssh. By default, any existing files will be preserved instead of being overwritten and will be restored if you run devtool undeploy-target. Note: this only deploys the recipe itself and not any runtime dependencies, so it is assumed that those have been installed on the target beforehand. Use --package/--file-glob to deploy only a subset of the recipe\'s installed files.',
                                           group='testbuild')
     parser_deploy.add_argument('recipename', help='Recipe to deploy')
     parser_deploy.add_argument('target', help='Live target machine running an ssh server: user@hostname[:destdir]')
@@ -375,6 +510,17 @@  def register_commands(subparsers, context):
     parser_deploy.add_argument('-P', '--port', help='Specify port to use for connection to the target')
     parser_deploy.add_argument('-I', '--key',
                                help='Specify ssh private key for connection to the target')
+    parser_deploy.add_argument('--package', action='append', metavar='PACKAGE',
+                               help='Only deploy files belonging to PACKAGE, as defined by that '
+                                    'package\'s FILES variable in the recipe metadata. May be a '
+                                    'comma-separated list and/or specified multiple times to '
+                                    'include several packages. May be prefixed with "RECIPE:" (must '
+                                    'match RECIPENAME), e.g. "RECIPE:,-doc,-ptest" is short for '
+                                    '"RECIPE,RECIPE-doc,RECIPE-ptest".')
+    parser_deploy.add_argument('--file-glob', action='append', dest='file_globs', metavar='GLOB',
+                               help='Only deploy files whose installed path matches this glob '
+                                    'pattern (e.g. "/usr/bin/*" or "${bindir}/myprog"). May be '
+                                    'specified multiple times. Combined with --package if both are given.')
 
     strip_opts = parser_deploy.add_mutually_exclusive_group(required=False)
     strip_opts.add_argument('-S', '--strip',