blob: 1e0fc1ee856edd4e0c48961f7e0d6a4677159aca [file] [log] [blame]
Patrick Williamsc124f4f2015-09-15 14:41:29 -05001# Recipe creation tool - append plugin
2#
3# Copyright (C) 2015 Intel Corporation
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License version 2 as
7# published by the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License along
15# with this program; if not, write to the Free Software Foundation, Inc.,
16# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
18import sys
19import os
20import argparse
21import glob
22import fnmatch
23import re
24import subprocess
25import logging
26import stat
27import shutil
28import scriptutils
29import errno
30from collections import defaultdict
31
32logger = logging.getLogger('recipetool')
33
34tinfoil = None
35
36def tinfoil_init(instance):
37 global tinfoil
38 tinfoil = instance
39
40
41# FIXME guessing when we don't have pkgdata?
42# FIXME mode to create patch rather than directly substitute
43
44class InvalidTargetFileError(Exception):
45 pass
46
47def find_target_file(targetpath, d, pkglist=None):
48 """Find the recipe installing the specified target path, optionally limited to a select list of packages"""
49 import json
50
51 pkgdata_dir = d.getVar('PKGDATA_DIR', True)
52
53 # The mix between /etc and ${sysconfdir} here may look odd, but it is just
54 # being consistent with usage elsewhere
55 invalidtargets = {'${sysconfdir}/version': '${sysconfdir}/version is written out at image creation time',
56 '/etc/timestamp': '/etc/timestamp is written out at image creation time',
57 '/dev/*': '/dev is handled by udev (or equivalent) and the kernel (devtmpfs)',
58 '/etc/passwd': '/etc/passwd should be managed through the useradd and extrausers classes',
59 '/etc/group': '/etc/group should be managed through the useradd and extrausers classes',
60 '/etc/shadow': '/etc/shadow should be managed through the useradd and extrausers classes',
61 '/etc/gshadow': '/etc/gshadow should be managed through the useradd and extrausers classes',
62 '${sysconfdir}/hostname': '${sysconfdir}/hostname contents should be set by setting hostname_pn-base-files = "value" in configuration',}
63
Patrick Williamsc0f7c042017-02-23 20:41:17 -060064 for pthspec, message in invalidtargets.items():
Patrick Williamsc124f4f2015-09-15 14:41:29 -050065 if fnmatch.fnmatchcase(targetpath, d.expand(pthspec)):
66 raise InvalidTargetFileError(d.expand(message))
67
68 targetpath_re = re.compile(r'\s+(\$D)?%s(\s|$)' % targetpath)
69
70 recipes = defaultdict(list)
71 for root, dirs, files in os.walk(os.path.join(pkgdata_dir, 'runtime')):
72 if pkglist:
73 filelist = pkglist
74 else:
75 filelist = files
76 for fn in filelist:
77 pkgdatafile = os.path.join(root, fn)
78 if pkglist and not os.path.exists(pkgdatafile):
79 continue
80 with open(pkgdatafile, 'r') as f:
81 pn = ''
82 # This does assume that PN comes before other values, but that's a fairly safe assumption
83 for line in f:
84 if line.startswith('PN:'):
85 pn = line.split(':', 1)[1].strip()
86 elif line.startswith('FILES_INFO:'):
87 val = line.split(':', 1)[1].strip()
88 dictval = json.loads(val)
89 for fullpth in dictval.keys():
90 if fnmatch.fnmatchcase(fullpth, targetpath):
91 recipes[targetpath].append(pn)
92 elif line.startswith('pkg_preinst_') or line.startswith('pkg_postinst_'):
Patrick Williamsc0f7c042017-02-23 20:41:17 -060093 scriptval = line.split(':', 1)[1].strip().encode('utf-8').decode('unicode_escape')
Patrick Williamsc124f4f2015-09-15 14:41:29 -050094 if 'update-alternatives --install %s ' % targetpath in scriptval:
95 recipes[targetpath].append('?%s' % pn)
96 elif targetpath_re.search(scriptval):
97 recipes[targetpath].append('!%s' % pn)
98 return recipes
99
100def _get_recipe_file(cooker, pn):
101 import oe.recipeutils
102 recipefile = oe.recipeutils.pn_to_recipe(cooker, pn)
103 if not recipefile:
104 skipreasons = oe.recipeutils.get_unavailable_reasons(cooker, pn)
105 if skipreasons:
106 logger.error('\n'.join(skipreasons))
107 else:
108 logger.error("Unable to find any recipe file matching %s" % pn)
109 return recipefile
110
111def _parse_recipe(pn, tinfoil):
112 import oe.recipeutils
113 recipefile = _get_recipe_file(tinfoil.cooker, pn)
114 if not recipefile:
115 # Error already logged
116 return None
117 append_files = tinfoil.cooker.collection.get_file_appends(recipefile)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600118 rd = oe.recipeutils.parse_recipe(tinfoil.cooker, recipefile, append_files)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500119 return rd
120
121def determine_file_source(targetpath, rd):
122 """Assuming we know a file came from a specific recipe, figure out exactly where it came from"""
123 import oe.recipeutils
124
125 # See if it's in do_install for the recipe
126 workdir = rd.getVar('WORKDIR', True)
127 src_uri = rd.getVar('SRC_URI', True)
128 srcfile = ''
129 modpatches = []
130 elements = check_do_install(rd, targetpath)
131 if elements:
132 logger.debug('do_install line:\n%s' % ' '.join(elements))
133 srcpath = get_source_path(elements)
134 logger.debug('source path: %s' % srcpath)
135 if not srcpath.startswith('/'):
136 # Handle non-absolute path
137 srcpath = os.path.abspath(os.path.join(rd.getVarFlag('do_install', 'dirs', True).split()[-1], srcpath))
138 if srcpath.startswith(workdir):
139 # OK, now we have the source file name, look for it in SRC_URI
140 workdirfile = os.path.relpath(srcpath, workdir)
141 # FIXME this is where we ought to have some code in the fetcher, because this is naive
142 for item in src_uri.split():
143 localpath = bb.fetch2.localpath(item, rd)
144 # Source path specified in do_install might be a glob
145 if fnmatch.fnmatch(os.path.basename(localpath), workdirfile):
146 srcfile = 'file://%s' % localpath
147 elif '/' in workdirfile:
148 if item == 'file://%s' % workdirfile:
149 srcfile = 'file://%s' % localpath
150
151 # Check patches
152 srcpatches = []
153 patchedfiles = oe.recipeutils.get_recipe_patched_files(rd)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600154 for patch, filelist in patchedfiles.items():
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500155 for fileitem in filelist:
156 if fileitem[0] == srcpath:
157 srcpatches.append((patch, fileitem[1]))
158 if srcpatches:
159 addpatch = None
160 for patch in srcpatches:
161 if patch[1] == 'A':
162 addpatch = patch[0]
163 else:
164 modpatches.append(patch[0])
165 if addpatch:
166 srcfile = 'patch://%s' % addpatch
167
168 return (srcfile, elements, modpatches)
169
170def get_source_path(cmdelements):
171 """Find the source path specified within a command"""
172 command = cmdelements[0]
173 if command in ['install', 'cp']:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600174 helptext = subprocess.check_output('LC_ALL=C %s --help' % command, shell=True).decode('utf-8')
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500175 argopts = ''
176 argopt_line_re = re.compile('^-([a-zA-Z0-9]), --[a-z-]+=')
177 for line in helptext.splitlines():
178 line = line.lstrip()
179 res = argopt_line_re.search(line)
180 if res:
181 argopts += res.group(1)
182 if not argopts:
183 # Fallback
184 if command == 'install':
185 argopts = 'gmoSt'
186 elif command == 'cp':
187 argopts = 't'
188 else:
189 raise Exception('No fallback arguments for command %s' % command)
190
191 skipnext = False
192 for elem in cmdelements[1:-1]:
193 if elem.startswith('-'):
194 if len(elem) > 1 and elem[1] in argopts:
195 skipnext = True
196 continue
197 if skipnext:
198 skipnext = False
199 continue
200 return elem
201 else:
202 raise Exception('get_source_path: no handling for command "%s"')
203
204def get_func_deps(func, d):
205 """Find the function dependencies of a shell function"""
206 deps = bb.codeparser.ShellParser(func, logger).parse_shell(d.getVar(func, True))
207 deps |= set((d.getVarFlag(func, "vardeps", True) or "").split())
208 funcdeps = []
209 for dep in deps:
210 if d.getVarFlag(dep, 'func', True):
211 funcdeps.append(dep)
212 return funcdeps
213
214def check_do_install(rd, targetpath):
215 """Look at do_install for a command that installs/copies the specified target path"""
216 instpath = os.path.abspath(os.path.join(rd.getVar('D', True), targetpath.lstrip('/')))
217 do_install = rd.getVar('do_install', True)
218 # Handle where do_install calls other functions (somewhat crudely, but good enough for this purpose)
219 deps = get_func_deps('do_install', rd)
220 for dep in deps:
221 do_install = do_install.replace(dep, rd.getVar(dep, True))
222
223 # Look backwards through do_install as we want to catch where a later line (perhaps
224 # from a bbappend) is writing over the top
225 for line in reversed(do_install.splitlines()):
226 line = line.strip()
227 if (line.startswith('install ') and ' -m' in line) or line.startswith('cp '):
228 elements = line.split()
229 destpath = os.path.abspath(elements[-1])
230 if destpath == instpath:
231 return elements
232 elif destpath.rstrip('/') == os.path.dirname(instpath):
233 # FIXME this doesn't take recursive copy into account; unsure if it's practical to do so
234 srcpath = get_source_path(elements)
235 if fnmatch.fnmatchcase(os.path.basename(instpath), os.path.basename(srcpath)):
236 return elements
237 return None
238
239
240def appendfile(args):
241 import oe.recipeutils
242
243 stdout = ''
244 try:
245 (stdout, _) = bb.process.run('LANG=C file -b %s' % args.newfile, shell=True)
246 if 'cannot open' in stdout:
247 raise bb.process.ExecutionError(stdout)
248 except bb.process.ExecutionError as err:
249 logger.debug('file command returned error: %s' % err)
250 stdout = ''
251 if stdout:
252 logger.debug('file command output: %s' % stdout.rstrip())
253 if ('executable' in stdout and not 'shell script' in stdout) or 'shared object' in stdout:
254 logger.warn('This file looks like it is a binary or otherwise the output of compilation. If it is, you should consider building it properly instead of substituting a binary file directly.')
255
256 if args.recipe:
257 recipes = {args.targetpath: [args.recipe],}
258 else:
259 try:
260 recipes = find_target_file(args.targetpath, tinfoil.config_data)
261 except InvalidTargetFileError as e:
262 logger.error('%s cannot be handled by this tool: %s' % (args.targetpath, e))
263 return 1
264 if not recipes:
265 logger.error('Unable to find any package producing path %s - this may be because the recipe packaging it has not been built yet' % args.targetpath)
266 return 1
267
268 alternative_pns = []
269 postinst_pns = []
270
271 selectpn = None
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600272 for targetpath, pnlist in recipes.items():
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500273 for pn in pnlist:
274 if pn.startswith('?'):
275 alternative_pns.append(pn[1:])
276 elif pn.startswith('!'):
277 postinst_pns.append(pn[1:])
278 elif selectpn:
279 # hit here with multilibs
280 continue
281 else:
282 selectpn = pn
283
284 if not selectpn and len(alternative_pns) == 1:
285 selectpn = alternative_pns[0]
286 logger.error('File %s is an alternative possibly provided by recipe %s but seemingly no other, selecting it by default - you should double check other recipes' % (args.targetpath, selectpn))
287
288 if selectpn:
289 logger.debug('Selecting recipe %s for file %s' % (selectpn, args.targetpath))
290 if postinst_pns:
291 logger.warn('%s be modified by postinstall scripts for the following recipes:\n %s\nThis may or may not be an issue depending on what modifications these postinstall scripts make.' % (args.targetpath, '\n '.join(postinst_pns)))
292 rd = _parse_recipe(selectpn, tinfoil)
293 if not rd:
294 # Error message already shown
295 return 1
296 sourcefile, instelements, modpatches = determine_file_source(args.targetpath, rd)
297 sourcepath = None
298 if sourcefile:
299 sourcetype, sourcepath = sourcefile.split('://', 1)
300 logger.debug('Original source file is %s (%s)' % (sourcepath, sourcetype))
301 if sourcetype == 'patch':
302 logger.warn('File %s is added by the patch %s - you may need to remove or replace this patch in order to replace the file.' % (args.targetpath, sourcepath))
303 sourcepath = None
304 else:
305 logger.debug('Unable to determine source file, proceeding anyway')
306 if modpatches:
307 logger.warn('File %s is modified by the following patches:\n %s' % (args.targetpath, '\n '.join(modpatches)))
308
309 if instelements and sourcepath:
310 install = None
311 else:
312 # Auto-determine permissions
313 # Check destination
314 binpaths = '${bindir}:${sbindir}:${base_bindir}:${base_sbindir}:${libexecdir}:${sysconfdir}/init.d'
315 perms = '0644'
316 if os.path.abspath(os.path.dirname(args.targetpath)) in rd.expand(binpaths).split(':'):
317 # File is going into a directory normally reserved for executables, so it should be executable
318 perms = '0755'
319 else:
320 # Check source
321 st = os.stat(args.newfile)
322 if st.st_mode & stat.S_IXUSR:
323 perms = '0755'
324 install = {args.newfile: (args.targetpath, perms)}
325 oe.recipeutils.bbappend_recipe(rd, args.destlayer, {args.newfile: sourcepath}, install, wildcardver=args.wildcard_version, machine=args.machine)
326 return 0
327 else:
328 if alternative_pns:
329 logger.error('File %s is an alternative possibly provided by the following recipes:\n %s\nPlease select recipe with -r/--recipe' % (targetpath, '\n '.join(alternative_pns)))
330 elif postinst_pns:
331 logger.error('File %s may be written out in a pre/postinstall script of the following recipes:\n %s\nPlease select recipe with -r/--recipe' % (targetpath, '\n '.join(postinst_pns)))
332 return 3
333
334
335def appendsrc(args, files, rd, extralines=None):
336 import oe.recipeutils
337
338 srcdir = rd.getVar('S', True)
339 workdir = rd.getVar('WORKDIR', True)
340
341 import bb.fetch
342 simplified = {}
343 src_uri = rd.getVar('SRC_URI', True).split()
344 for uri in src_uri:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500345 if uri.endswith(';'):
346 uri = uri[:-1]
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500347 simple_uri = bb.fetch.URI(uri)
348 simple_uri.params = {}
349 simplified[str(simple_uri)] = uri
350
351 copyfiles = {}
352 extralines = extralines or []
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600353 for newfile, srcfile in files.items():
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500354 src_destdir = os.path.dirname(srcfile)
355 if not args.use_workdir:
356 if rd.getVar('S', True) == rd.getVar('STAGING_KERNEL_DIR', True):
357 srcdir = os.path.join(workdir, 'git')
358 if not bb.data.inherits_class('kernel-yocto', rd):
359 logger.warn('S == STAGING_KERNEL_DIR and non-kernel-yocto, unable to determine path to srcdir, defaulting to ${WORKDIR}/git')
360 src_destdir = os.path.join(os.path.relpath(srcdir, workdir), src_destdir)
361 src_destdir = os.path.normpath(src_destdir)
362
363 source_uri = 'file://{0}'.format(os.path.basename(srcfile))
364 if src_destdir and src_destdir != '.':
365 source_uri += ';subdir={0}'.format(src_destdir)
366
367 simple = bb.fetch.URI(source_uri)
368 simple.params = {}
369 simple_str = str(simple)
370 if simple_str in simplified:
371 existing = simplified[simple_str]
372 if source_uri != existing:
373 logger.warn('{0!r} is already in SRC_URI, with different parameters: {1!r}, not adding'.format(source_uri, existing))
374 else:
375 logger.warn('{0!r} is already in SRC_URI, not adding'.format(source_uri))
376 else:
377 extralines.append('SRC_URI += {0}'.format(source_uri))
378 copyfiles[newfile] = srcfile
379
380 oe.recipeutils.bbappend_recipe(rd, args.destlayer, copyfiles, None, wildcardver=args.wildcard_version, machine=args.machine, extralines=extralines)
381
382
383def appendsrcfiles(parser, args):
384 recipedata = _parse_recipe(args.recipe, tinfoil)
385 if not recipedata:
386 parser.error('RECIPE must be a valid recipe name')
387
388 files = dict((f, os.path.join(args.destdir, os.path.basename(f)))
389 for f in args.files)
390 return appendsrc(args, files, recipedata)
391
392
393def appendsrcfile(parser, args):
394 recipedata = _parse_recipe(args.recipe, tinfoil)
395 if not recipedata:
396 parser.error('RECIPE must be a valid recipe name')
397
398 if not args.destfile:
399 args.destfile = os.path.basename(args.file)
400 elif args.destfile.endswith('/'):
401 args.destfile = os.path.join(args.destfile, os.path.basename(args.file))
402
403 return appendsrc(args, {args.file: args.destfile}, recipedata)
404
405
406def layer(layerpath):
407 if not os.path.exists(os.path.join(layerpath, 'conf', 'layer.conf')):
408 raise argparse.ArgumentTypeError('{0!r} must be a path to a valid layer'.format(layerpath))
409 return layerpath
410
411
412def existing_path(filepath):
413 if not os.path.exists(filepath):
414 raise argparse.ArgumentTypeError('{0!r} must be an existing path'.format(filepath))
415 return filepath
416
417
418def existing_file(filepath):
419 filepath = existing_path(filepath)
420 if os.path.isdir(filepath):
421 raise argparse.ArgumentTypeError('{0!r} must be a file, not a directory'.format(filepath))
422 return filepath
423
424
425def destination_path(destpath):
426 if os.path.isabs(destpath):
427 raise argparse.ArgumentTypeError('{0!r} must be a relative path, not absolute'.format(destpath))
428 return destpath
429
430
431def target_path(targetpath):
432 if not os.path.isabs(targetpath):
433 raise argparse.ArgumentTypeError('{0!r} must be an absolute path, not relative'.format(targetpath))
434 return targetpath
435
436
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500437def register_commands(subparsers):
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500438 common = argparse.ArgumentParser(add_help=False)
439 common.add_argument('-m', '--machine', help='Make bbappend changes specific to a machine only', metavar='MACHINE')
440 common.add_argument('-w', '--wildcard-version', help='Use wildcard to make the bbappend apply to any recipe version', action='store_true')
441 common.add_argument('destlayer', metavar='DESTLAYER', help='Base directory of the destination layer to write the bbappend to', type=layer)
442
443 parser_appendfile = subparsers.add_parser('appendfile',
444 parents=[common],
445 help='Create/update a bbappend to replace a target file',
446 description='Creates a bbappend (or updates an existing one) to replace the specified file that appears in the target system, determining the recipe that packages the file and the required path and name for the bbappend automatically. Note that the ability to determine the recipe packaging a particular file depends upon the recipe\'s do_packagedata task having already run prior to running this command (which it will have when the recipe has been built successfully, which in turn will have happened if one or more of the recipe\'s packages is included in an image that has been built successfully).')
447 parser_appendfile.add_argument('targetpath', help='Path to the file to be replaced (as it would appear within the target image, e.g. /etc/motd)', type=target_path)
448 parser_appendfile.add_argument('newfile', help='Custom file to replace the target file with', type=existing_file)
449 parser_appendfile.add_argument('-r', '--recipe', help='Override recipe to apply to (default is to find which recipe already packages the file)')
450 parser_appendfile.set_defaults(func=appendfile, parserecipes=True)
451
452 common_src = argparse.ArgumentParser(add_help=False, parents=[common])
453 common_src.add_argument('-W', '--workdir', help='Unpack file into WORKDIR rather than S', dest='use_workdir', action='store_true')
454 common_src.add_argument('recipe', metavar='RECIPE', help='Override recipe to apply to')
455
456 parser = subparsers.add_parser('appendsrcfiles',
457 parents=[common_src],
458 help='Create/update a bbappend to add or replace source files',
459 description='Creates a bbappend (or updates an existing one) to add or replace the specified file in the recipe sources, either those in WORKDIR or those in the source tree. This command lets you specify multiple files with a destination directory, so cannot specify the destination filename. See the `appendsrcfile` command for the other behavior.')
460 parser.add_argument('-D', '--destdir', help='Destination directory (relative to S or WORKDIR, defaults to ".")', default='', type=destination_path)
461 parser.add_argument('files', nargs='+', metavar='FILE', help='File(s) to be added to the recipe sources (WORKDIR or S)', type=existing_path)
462 parser.set_defaults(func=lambda a: appendsrcfiles(parser, a), parserecipes=True)
463
464 parser = subparsers.add_parser('appendsrcfile',
465 parents=[common_src],
466 help='Create/update a bbappend to add or replace a source file',
467 description='Creates a bbappend (or updates an existing one) to add or replace the specified files in the recipe sources, either those in WORKDIR or those in the source tree. This command lets you specify the destination filename, not just destination directory, but only works for one file. See the `appendsrcfiles` command for the other behavior.')
468 parser.add_argument('file', metavar='FILE', help='File to be added to the recipe sources (WORKDIR or S)', type=existing_path)
469 parser.add_argument('destfile', metavar='DESTFILE', nargs='?', help='Destination path (relative to S or WORKDIR, optional)', type=destination_path)
470 parser.set_defaults(func=lambda a: appendsrcfile(parser, a), parserecipes=True)