infr: fix pylint's warnings by adjusting the code, suppressing non-important issues...
[debocker.git] / debocker
1 #!/usr/bin/env python3
2 # coding: utf-8
3
4 import click
5 import re
6 import os
7 import hashlib
8 from tempfile import TemporaryDirectory
9 from os.path import isdir, join, isfile, abspath, dirname, \
10     realpath, splitext, relpath
11 from subprocess import check_call, check_output, CalledProcessError, DEVNULL
12 from shutil import which, copytree, copyfile
13 from shlex import quote as shell_quote
14 from collections import OrderedDict
15 from string import Template
16 from datetime import datetime
17
18 __version__ = "0.2.1"
19
20 NONE, LOW = 0, 1
21 VERBOSITY = NONE
22
23 # USEFUL ROUTINES
24
25 def fail(msg, *params):
26     raise click.ClickException(msg.format(*params))
27
28 def cached_constant(f):
29     cache = []
30     def _f():
31         if len(cache) == 0:
32             cache.append(f())
33         return cache[0]
34     return _f
35
36 def cached_property(f):
37     key = '_cached_' + f.__name__
38     def _f(self):
39         if not hasattr(self, key):
40             setattr(self, key, f(self))
41         return getattr(self, key)
42     return property(_f)
43
44 def Counter(v = 0):
45     v = [ v ]
46     def _f():
47         v[0] += 1
48         return v[0]
49     return _f
50
51 tmpdir = cached_constant(lambda: TemporaryDirectory()) # pylint: disable=unnecessary-lambda
52 tmpunique = Counter()
53 CURRENT_TIME = datetime.utcnow().isoformat()
54
55 def tmppath(name = None, directory = False):
56     '''get a temp. path'''
57     count = tmpunique()
58     tmp_directory = tmpdir().name
59     if name is None:
60         name = 'tmp'
61     tmp_path = join(tmp_directory, '{:03d}-{}'.format(count, name))
62     if directory:
63         os.mkdir(tmp_path)
64     return tmp_path
65
66 def log(msg, v = 0):
67     if VERBOSITY == NONE:
68         if v == 0:
69             click.secho('LOG {}'.format(msg), fg = 'green')
70     else:
71         if v <= VERBOSITY:
72             click.secho('LOG[{}] {}'.format(v, msg), fg = 'green')
73
74 def log_check_call(cmd, **kwds):
75     log('Run (check): {}'.format(cmd), LOW)
76     return check_call(cmd, **kwds)
77
78 def log_check_output(cmd, **kwds):
79     log('Run (output): {}'.format(cmd), LOW)
80     return check_output(cmd, **kwds)
81
82 @cached_constant
83 def find_bundle_files():
84     '''finds the location of bundle files'''
85     debocker_dir = dirname(abspath(realpath(__file__)))
86     locations = [ debocker_dir, '/usr/share/debocker' ]
87     for loc in locations:
88         bundle_files = join(loc, 'bundle-files')
89         if isdir(bundle_files):
90             log("Bundle files found in '{}'.".format(bundle_files), LOW)
91             return bundle_files
92     fail('could not find bundle files')
93
94 def assert_command(cmd):
95     if which(cmd) is None:
96         fail("command '{}' is not available", cmd)
97
98 @cached_constant
99 def assert_docker():
100     if which('docker') is None:
101         fail('docker is not available')
102
103 def extract_pristine_tar(path, candidates):
104     if which('pristine-tar') is None:
105         log('No pristine-tar available.', LOW)
106         return None
107     try:
108         tars = log_check_output([ 'pristine-tar', 'list' ], cwd = path)
109     except CalledProcessError:
110         log('Error in pristine-tar - giving up.', LOW)
111         return None
112     # let's hope that no non-utf8 files are stored
113     thelist = tars.decode('utf-8').split()
114     log('Pristine tarballs: {}'.format(thelist), LOW)
115     matches = list(set(thelist) & set(candidates))
116     if len(matches) > 0:
117         m = matches[0]
118         log("Found a match: '{}'.".format(m), LOW)
119         log_check_call([ 'pristine-tar', 'checkout', m ],
120                        cwd = path, stderr = DEVNULL)
121         try:
122             src = join(path, m)
123             dst = tmppath(m)
124             copyfile(src, dst)
125             log("Tarball extracted to '{}'.".format(dst), LOW)
126         finally:
127             os.unlink(src)
128         return dst
129     else:
130         log('No matching pristine tar found.', LOW)
131         return None
132
133 class Package:
134
135     def __init__(self, path):
136         self.path = path
137         self.debian = join(self.path, 'debian')
138         self.control = join(self.debian, 'control')
139         self.changelog = join(self.debian, 'changelog')
140         self.source_format = join(self.debian, 'source', 'format')
141
142     def is_valid(self):
143         '''verifies that the current directory is a debian package'''
144         return isdir(self.debian) and isfile(self.control) and \
145             isfile(self.source_format) and self.format in ['native', 'quilt']
146
147     def assert_is_valid(self):
148         if not self.is_valid():
149             fail('not in debian package directory')
150
151     @cached_property
152     def format(self):
153         with open(self.source_format) as f:
154             line = f.readline().strip()
155         m = re.match(r'^3\.0 \((native|quilt)\)', line)
156         if not m:
157             fail('unsupported format ({})', line)
158         fmt = m.group(1)
159         log("Detected format '{}'.".format(fmt), LOW)
160         return fmt
161
162     @cached_property
163     def native(self):
164         return self.format == 'native'
165
166     @cached_property
167     def name(self):
168         with open(self.control) as f:
169             for line in f:
170                 m = re.match(r'^Source: (\S+)$', line)
171                 if m:
172                     return m.group(1)
173         fail('could not find the name of the package')
174
175     @cached_property
176     def long_version(self):
177         '''long version'''
178         with open(self.changelog) as f:
179             line = f.readline()
180         m = re.match(r'^(\S+) \((\S+)\)', line)
181         if not m:
182             fail("could not parse package version (from '{}')", line.strip())
183         return m.group(2)
184
185     @cached_property
186     def version(self):
187         '''upstream version'''
188         if self.native:
189             return self.long_version
190         else:
191             m = re.match(r'^(.+)-(\d+)$', self.long_version)
192             if not m:
193                 fail('could not parse version ({})', self.long_version)
194             return m.group(1)
195
196     @cached_property
197     def orig_tarball_candidates(self):
198         '''possible original upstream tarballs'''
199         formats = [ 'xz', 'gz', 'bz2' ]
200         names = [ '{}_{}.orig.tar.{}'.format(self.name, self.version, fmt)
201                   for fmt in formats ]
202         return names
203
204     @cached_property
205     def orig_tarball(self):
206         '''finds the original tarball'''
207         for name in self.orig_tarball_candidates:
208             tarball = join(self.path, '..', name)
209             log("Trying tarball candidate '{}'.".format(tarball), LOW)
210             if isfile(tarball):
211                 log("Original tarball found at '{}'.".format(tarball), LOW)
212                 return tarball
213         result = extract_pristine_tar(self.path, self.orig_tarball_candidates)
214         if result is not None:
215             return result
216         fail('could not find original tarball')
217
218     def assert_orig_tarball(self):
219         if self.native:
220             # for now, we just tar the current directory
221             path = tmppath('{}_{}.tar.xz'.format(
222                 self.name, self.version))
223             with open(path, 'wb') as output:
224                 tar = [ 'tar', 'c', '--xz', '--exclude=.git',
225                         '-C', self.path, '.' ]
226                 log_check_call(tar, stdout = output)
227             return path
228         else:
229             return self.orig_tarball  # simple alias
230
231     def tar_package_debian(self, output, comp = None):
232         # TODO: make it reproducible # pylint: disable=fixme
233         compressions = {
234             'xz': '--xz'
235         }
236         tar = [ 'tar', 'c' ]
237         if comp:
238             tar += [ compressions[comp] ]
239         debian = join(self.path, 'debian')
240         filelist = get_reproducible_filelist(debian, self.path)
241         tar += [ '--no-recursion', '-C', self.path ]
242         tar += filelist
243         log_check_call(tar, stdout = output)
244
245     def build_docker_tarball_to_fd(self, output, buildinfo):
246         '''builds the docker tarball that builds the package'''
247         controlfile = join(self.path, 'debian', 'control')
248         controlfile = abspath(controlfile)
249         debianfile = tmppath('debian.tar.xz')
250         with open(debianfile, 'wb') as debian:
251             self.tar_package_debian(debian, comp = 'xz')
252         originalfile = self.assert_orig_tarball()
253         originalfile = abspath(originalfile)
254         if self.native:
255             make_native_bundle(self.name, self.version,
256                                controlfile, originalfile,
257                                buildinfo, output)
258         else:
259             make_quilt_bundle(self.name, self.long_version,
260                               controlfile, originalfile, debianfile,
261                               buildinfo, output)
262
263     def build_docker_tarball(self, filename, buildinfo):
264         with open(filename, 'wb') as output:
265             self.build_docker_tarball_to_fd(output, buildinfo)
266
267 def calculate_md5_and_size(path):
268     md5 = hashlib.md5()
269     count = 0
270     with open(path, 'rb') as f:
271         while True:
272             buff = f.read(8192)
273             if len(buff) == 0:
274                 break
275             count += len(buff)
276             md5.update(buff)
277     return md5.hexdigest(), count
278
279 def make_native_bundle(name, version, control,
280                        source, buildinfo, output):
281     dsc_name = '{}_{}.dsc'.format(name, version)
282     bundler = Bundler(name, version, control, dsc_name,
283                       buildinfo, native = True)
284     _, ext = splitext(source)
285     source_name = '{}_{}.tar{}'.format(name, version, ext)
286     bundler.add_source_file(source_name, source, 'source_tarball')
287     bundler.write_bundle(output = output)
288
289 def make_quilt_bundle(name, version, control,
290                       original, debian, buildinfo, output):
291     dsc_name = '{}_{}.dsc'.format(name, version)
292     bundler = Bundler(name, version, control, dsc_name,
293                       buildinfo, native = False)
294     _, oext = splitext(original)
295     _, dext = splitext(debian)
296     # TODO: improve # pylint: disable=fixme
297     uversion = version.split('-')[0]
298     original_name = '{}_{}.orig.tar{}'.format(name, uversion, oext)
299     debian_name = '{}_{}.debian.tar{}'.format(name, version, dext)
300     bundler.add_source_file(original_name, original, 'original_tarball')
301     bundler.add_source_file(debian_name, debian, 'debian_tarball')
302     bundler.write_bundle(output = output)
303
304 def get_reproducible_filelist(path, base = None):
305     if base is None:
306         base = path
307     elements = []
308     for p, ds, fs in os.walk(path):
309         p = relpath(p, base)
310         if p != '.':
311             p = join('.', p)
312         elements += [ join(p, x) for x in ds ]
313         elements += [ join(p, x) for x in fs ]
314     return sorted(elements)
315
316
317 # STEPS
318
319 STEPS = OrderedDict([
320     ('end', None),
321     ('build', 'args_05'),
322     ('extract-source', 'args_04'),
323     ('install-deps', 'args_03'),
324     ('install-utils', 'args_02'),
325     ('upgrade', 'args_01')
326 ])
327
328 class Bundler:
329
330     def __init__(self, name, version, control, dsc_name, buildinfo, native):
331         self.name = name
332         self.version = version
333         self.native = native
334         self.control = control
335         self.sources = []
336         self.dsc_name = dsc_name
337         self.step_name = buildinfo['step']
338         self.step = STEPS[self.step_name]
339         self.image = buildinfo['image']
340         self.buildinfo = buildinfo
341         self.wdir = tmppath('bundle', directory = True)
342
343     @property
344     def format_string(self):
345         return ('3.0 (native)' if self.native else '3.0 (quilt)')
346
347     def add_source_file(self, name, path, tag):
348         md5, size = calculate_md5_and_size(path)
349         self.sources.append({
350             'name': name,
351             'path': path,
352             'md5': md5,
353             'size': size,
354             'tag': tag
355         })
356
357     def write_dsc_file(self):
358         '''makes minimal, yet functional .dsc file'''
359         path = join(self.wdir, 'source', self.dsc_name)
360         with open(path, 'w') as f:
361             f.write('Format: {}\n'.format(self.format_string))
362             f.write('Source: {}\n'.format(self.name))
363             f.write('Version: {}\n'.format(self.version))
364             f.write('Files:\n')
365             for s in self.sources:
366                 f.write(' {} {} {}\n'.format(s['md5'], s['size'], s['name']))
367
368     def write_info_file(self, info):
369         path = join(self.wdir, 'info')
370         with open(path, 'w') as f:
371             for k, v in info.items():
372                 f.write("{}={}\n".format(k, v))
373
374     def write_buildinfo_file(self):
375         path = join(self.wdir, 'buildinfo')
376         with open(path, 'w') as f:
377             f.write("flags='{}'\n".format(self.buildinfo['flags']))
378
379     def write_bundle(self, output):
380         '''writes bundle to a given file'''
381
382         os.makedirs(join(self.wdir, 'source'))
383
384         info = OrderedDict()
385         info['bundle_version'] = __version__
386         info['name'] = self.name
387         info['version'] = self.version
388         info['format'] = ('native' if self.native else 'quilt')
389
390         def make_link(target, parts):
391             return os.symlink(target, join(self.wdir, *parts))
392
393         # control file
394         make_link(self.control, [ 'control' ])
395
396         # dsc file
397         self.write_dsc_file()
398         info['dsc_name'] = self.dsc_name
399
400         # sources
401         for s in self.sources:
402             name = s['name']
403             tag = s['tag']
404             make_link(s['path'], [ 'source', name ])
405             info[tag] = name
406
407         # info & buildinfo
408         self.write_info_file(info)
409         self.write_buildinfo_file()
410
411         # bundle files
412         bundle_files = find_bundle_files()
413
414         copytree(join(bundle_files, 'steps'),
415                  join(self.wdir, 'steps'))
416         dockertmpl = join(bundle_files, 'Dockerfile')
417         with open(dockertmpl, 'r') as df:
418             t = Template(df.read())
419             ctx = {
420                 'image': self.image,
421                 'args_01': 'none',
422                 'args_02': 'none',
423                 'args_03': 'none',
424                 'args_04': 'none',
425                 'args_05': 'none'
426             }
427             if self.step:
428                 log("Replacing '{}' with '{}'.".format(
429                     self.step, CURRENT_TIME))
430                 if self.step not in ctx:
431                     fail('internal error in dockerfile template')
432                 ctx[self.step] = CURRENT_TIME
433             rendered = t.substitute(ctx)
434             dockerfile = join(self.wdir, 'Dockerfile')
435             with open(dockerfile, 'w') as f:
436                 f.write(rendered)
437
438         file_list = get_reproducible_filelist(self.wdir)
439         # tar everything
440         tar = [ 'tar', 'c', '-h', '--numeric-owner' ]
441         tar += [ '--no-recursion' ]
442         tar += [ '--owner=0', '--group=0' ]
443         tar += [ '--mtime=1970-01-01' ]
444         tar += [ '-C', self.wdir ]
445         tar += file_list
446         log_check_call(tar, stdout = output)
447
448 def docker_build_bundle(bundle_name, no_cache, pull):
449     '''builds the given image and returns the final image'''
450     # TODO: quite ugly, cannot be done cleaner? # pylint: disable=fixme
451     build_log = tmppath()
452     bundle_esc = shell_quote(bundle_name)
453     build_log_esc = shell_quote(build_log)
454     docker_opts = []
455     if no_cache:
456         docker_opts.append('--no-cache')
457     if pull:
458         docker_opts.append('--pull')
459     docker_opts = ' '.join(docker_opts)
460     log_check_call('docker build {} - < {} | tee {}'.format(
461         docker_opts, bundle_esc, build_log_esc), shell = True)
462     with open(build_log) as f:
463         s = f.read().strip()
464         ms = re.findall(r'Successfully built (\S+)', s)
465         if len(ms) != 1:
466             fail('cannot parse logs (build failed?)')
467         image = ms[0]
468     return image
469
470 # CLI INTERFACE
471
472 @click.group()
473 @click.option('-v', '--verbose', count=True,
474               help = 'be verbose, repeat for more effect')
475 def cli(verbose):
476     global VERBOSITY # pylint: disable=global-statement
477     VERBOSITY = verbose
478
479 @cli.command(help = 'Write tar bundle')
480 @click.argument('path', default = '.')
481 @click.option('-o', '--output', default = None, metavar = 'FILE',
482               help = 'output file')
483 @click.option('-f', '--flags', default = '', metavar = 'FLAGS',
484               help = 'build flags')
485 @click.option('step', '--from', default = 'end',
486               help = 'start from the given step',
487               type = click.Choice(STEPS.keys()))
488 @click.option('--image', default = 'debian:unstable', metavar = 'IMAGE',
489               help = 'base docker image')
490 def bundle(path, output, flags, step, image):
491     pkg = Package(path)
492     pkg.assert_is_valid()
493     if output is None:
494         name = '{}_{}_bundle.tar'.format(pkg.name, pkg.long_version)
495         output = join('..', name)
496     log('Preparing bundle for {} ({})...'.format(pkg.name, pkg.version))
497     if not pkg.native:
498         pkg.assert_orig_tarball()
499     pkg.build_docker_tarball(output, {
500         'flags': flags, 'step': step, 'image': image })
501     log("Bundle created in '{}'.".format(output))
502     if VERBOSITY > NONE:
503         md5, size = calculate_md5_and_size(output)
504         log('Bundle hash and size: {}, {}.'.format(md5, size), LOW)
505
506 @cli.command('build-bundle', help = 'Build bundle')
507 @click.argument('bundle')
508 @click.option('-o', '--output', default = '.', metavar = 'DIRECTORY',
509               help = 'output directory')
510 @click.option('--sign', '-s', default = False,
511               is_flag = True, help = 'sign built package with debsign')
512 @click.option('no_cache', '--no-cache', default = False,
513               is_flag = True, help = 'do not use docker image cache')
514 @click.option('--pull', default = False,
515               is_flag = True, help = 'pull the newest base image')
516 def build_bundle(bundle_name, output, sign, no_cache, pull):
517     assert_docker()
518     if sign:
519         assert_command('debsign')
520     image = docker_build_bundle(bundle_name,
521                                 no_cache = no_cache, pull = pull)
522     log('Build successful (in {})'.format(image))
523     # extract the build
524     build_tar = tmppath('build.tar')
525     with open(build_tar, 'wb') as f:
526         log_check_call([ 'docker', 'run', '--rm=true',
527                          image, '/root/steps/build-tar' ], stdout = f)
528     log("Build tar stored in '{}'".format(build_tar))
529     tar_list = log_check_output([ 'tar', 'tf', build_tar ])
530     tar_files = tar_list.decode('utf-8').split()
531     log("Build files: {}".format(' '.join(tar_files)), LOW)
532     log_check_call([ 'tar', 'xf', build_tar, '-C', output ])
533     log("Build files stored in '{}'.".format(output))
534     if sign:
535         # TODO: needs devscripts, and hence will not work outside Debian # pylint: disable=fixme
536         # we probably have to copy/fork debsign, cause signing within
537         # the container is not a good idea security-wise
538         changes = [ fn for fn in tar_files if fn.endswith('.changes') ]
539         if len(changes) != 1:
540             fail('could not find the changes files')
541         changes_path = join(output, changes[0])
542         log("Trying to sign '{}'.".format(changes_path), LOW)
543         log_check_call([ 'debsign', changes_path ])
544
545 @cli.command(help = 'Build package')
546 @click.argument('path', default = '.')
547 @click.option('-o', '--output', default = '..', metavar = 'DIRECTORY',
548               help = 'output directory')
549 @click.option('--sign', '-s', default = False,
550               is_flag = True, help = 'sign built package with debsign')
551 @click.option('-f', '--flags', default = '', metavar = 'FLAGS',
552               help = 'build flags')
553 @click.option('no_cache', '--no-cache', default = False,
554               is_flag = True, help = 'do not use docker image cache')
555 @click.option('step', '--from', default = 'end',
556               help = 'start from the given step',
557               type = click.Choice(STEPS.keys()))
558 @click.option('--image', default = 'debian:unstable', metavar = 'IMAGE',
559               help = 'base docker image')
560 @click.option('--pull', default = False,
561               is_flag = True, help = 'pull the newest base image')
562 @click.pass_context
563 def build(ctx, path, output, sign, flags, no_cache, step, image, pull):
564     assert_docker()
565     if sign:
566         assert_command('debsign')
567     tarball_path = tmppath('bundle.tar')
568     ctx.invoke(bundle, path = path, output = tarball_path,
569                flags = flags, step = step, image = image)
570     ctx.invoke(build_bundle,
571                bundle = tarball_path, sign = sign, output = output,
572                no_cache = no_cache, pull = pull)
573
574 if __name__ == '__main__':
575     cli.main()