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
25 def fail(msg, *params):
26 raise click.ClickException(msg.format(*params))
28 def cached_constant(f):
36 def cached_property(f):
37 key = '_cached_' + f.__name__
39 if not hasattr(self, key):
40 setattr(self, key, f(self))
41 return getattr(self, key)
51 tmpdir = cached_constant(lambda: TemporaryDirectory()) # pylint: disable=unnecessary-lambda
53 CURRENT_TIME = datetime.utcnow().isoformat()
55 def tmppath(name = None, directory = False):
56 '''get a temp. path'''
58 tmp_directory = tmpdir().name
61 tmp_path = join(tmp_directory, '{:03d}-{}'.format(count, name))
69 click.secho('LOG {}'.format(msg), fg = 'green')
72 click.secho('LOG[{}] {}'.format(v, msg), fg = 'green')
74 def log_check_call(cmd, **kwds):
75 log('Run (check): {}'.format(cmd), LOW)
76 return check_call(cmd, **kwds)
78 def log_check_output(cmd, **kwds):
79 log('Run (output): {}'.format(cmd), LOW)
80 return check_output(cmd, **kwds)
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' ]
88 bundle_files = join(loc, 'bundle-files')
89 if isdir(bundle_files):
90 log("Bundle files found in '{}'.".format(bundle_files), LOW)
92 fail('could not find bundle files')
94 def assert_command(cmd):
95 if which(cmd) is None:
96 fail("command '{}' is not available", cmd)
100 if which('docker') is None:
101 fail('docker is not available')
103 def extract_pristine_tar(path, candidates):
104 if which('pristine-tar') is None:
105 log('No pristine-tar available.', LOW)
108 tars = log_check_output([ 'pristine-tar', 'list' ], cwd = path)
109 except CalledProcessError:
110 log('Error in pristine-tar - giving up.', LOW)
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))
118 log("Found a match: '{}'.".format(m), LOW)
119 log_check_call([ 'pristine-tar', 'checkout', m ],
120 cwd = path, stderr = DEVNULL)
125 log("Tarball extracted to '{}'.".format(dst), LOW)
130 log('No matching pristine tar found.', LOW)
135 def __init__(self, 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')
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']
147 def assert_is_valid(self):
148 if not self.is_valid():
149 fail('not in debian package directory')
153 with open(self.source_format) as f:
154 line = f.readline().strip()
155 m = re.match(r'^3\.0 \((native|quilt)\)', line)
157 fail('unsupported format ({})', line)
159 log("Detected format '{}'.".format(fmt), LOW)
164 return self.format == 'native'
168 with open(self.control) as f:
170 m = re.match(r'^Source: (\S+)$', line)
173 fail('could not find the name of the package')
176 def long_version(self):
178 with open(self.changelog) as f:
180 m = re.match(r'^(\S+) \((\S+)\)', line)
182 fail("could not parse package version (from '{}')", line.strip())
187 '''upstream version'''
189 return self.long_version
191 m = re.match(r'^(.+)-(\d+)$', self.long_version)
193 fail('could not parse version ({})', self.long_version)
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)
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)
211 log("Original tarball found at '{}'.".format(tarball), LOW)
213 result = extract_pristine_tar(self.path, self.orig_tarball_candidates)
214 if result is not None:
216 fail('could not find original tarball')
218 def assert_orig_tarball(self):
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)
229 return self.orig_tarball # simple alias
231 def tar_package_debian(self, output, comp = None):
232 # TODO: make it reproducible # pylint: disable=fixme
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 ]
243 log_check_call(tar, stdout = output)
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)
255 make_native_bundle(self.name, self.version,
256 controlfile, originalfile,
259 make_quilt_bundle(self.name, self.long_version,
260 controlfile, originalfile, debianfile,
263 def build_docker_tarball(self, filename, buildinfo):
264 with open(filename, 'wb') as output:
265 self.build_docker_tarball_to_fd(output, buildinfo)
267 def calculate_md5_and_size(path):
270 with open(path, 'rb') as f:
277 return md5.hexdigest(), count
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)
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)
304 def get_reproducible_filelist(path, base = None):
308 for p, ds, fs in os.walk(path):
312 elements += [ join(p, x) for x in ds ]
313 elements += [ join(p, x) for x in fs ]
314 return sorted(elements)
319 STEPS = OrderedDict([
321 ('build', 'args_05'),
322 ('extract-source', 'args_04'),
323 ('install-deps', 'args_03'),
324 ('install-utils', 'args_02'),
325 ('upgrade', 'args_01')
330 def __init__(self, name, version, control, dsc_name, buildinfo, native):
332 self.version = version
334 self.control = control
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)
344 def format_string(self):
345 return ('3.0 (native)' if self.native else '3.0 (quilt)')
347 def add_source_file(self, name, path, tag):
348 md5, size = calculate_md5_and_size(path)
349 self.sources.append({
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))
365 for s in self.sources:
366 f.write(' {} {} {}\n'.format(s['md5'], s['size'], s['name']))
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))
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']))
379 def write_bundle(self, output):
380 '''writes bundle to a given file'''
382 os.makedirs(join(self.wdir, 'source'))
385 info['bundle_version'] = __version__
386 info['name'] = self.name
387 info['version'] = self.version
388 info['format'] = ('native' if self.native else 'quilt')
390 def make_link(target, parts):
391 return os.symlink(target, join(self.wdir, *parts))
394 make_link(self.control, [ 'control' ])
397 self.write_dsc_file()
398 info['dsc_name'] = self.dsc_name
401 for s in self.sources:
404 make_link(s['path'], [ 'source', name ])
408 self.write_info_file(info)
409 self.write_buildinfo_file()
412 bundle_files = find_bundle_files()
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())
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:
438 file_list = get_reproducible_filelist(self.wdir)
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 ]
446 log_check_call(tar, stdout = output)
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)
456 docker_opts.append('--no-cache')
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:
464 ms = re.findall(r'Successfully built (\S+)', s)
466 fail('cannot parse logs (build failed?)')
473 @click.option('-v', '--verbose', count=True,
474 help = 'be verbose, repeat for more effect')
476 global VERBOSITY # pylint: disable=global-statement
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):
492 pkg.assert_is_valid()
494 name = '{}_{}_bundle.tar'.format(pkg.name, pkg.long_version)
495 output = join('..', name)
496 log('Preparing bundle for {} ({})...'.format(pkg.name, pkg.version))
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))
503 md5, size = calculate_md5_and_size(output)
504 log('Bundle hash and size: {}, {}.'.format(md5, size), LOW)
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):
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))
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))
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 ])
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')
563 def build(ctx, path, output, sign, flags, no_cache, step, image, pull):
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)
574 if __name__ == '__main__':