chiark / gitweb /
build: enable watchdog timer for each build that kills in 2 hours
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python3
2 #
3 # build.py - part of the FDroid server tools
4 # Copyright (C) 2010-2014, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import os
21 import shutil
22 import glob
23 import subprocess
24 import re
25 import resource
26 import sys
27 import tarfile
28 import threading
29 import traceback
30 import time
31 import requests
32 import tempfile
33 from configparser import ConfigParser
34 from argparse import ArgumentParser
35 import logging
36 from gettext import ngettext
37
38 from . import _
39 from . import common
40 from . import net
41 from . import metadata
42 from . import scanner
43 from . import vmtools
44 from .common import FDroidPopen, SdkToolsPopen
45 from .exception import FDroidException, BuildException, VCSException
46
47 try:
48     import paramiko
49 except ImportError:
50     pass
51
52
53 # Note that 'force' here also implies test mode.
54 def build_server(app, build, vcs, build_dir, output_dir, log_dir, force):
55     """Do a build on the builder vm.
56
57     :param app: app metadata dict
58     :param build:
59     :param vcs: version control system controller object
60     :param build_dir: local source-code checkout of app
61     :param output_dir: target folder for the build result
62     :param force:
63     """
64
65     global buildserverid
66
67     try:
68         paramiko
69     except NameError:
70         raise BuildException("Paramiko is required to use the buildserver")
71     if options.verbose:
72         logging.getLogger("paramiko").setLevel(logging.INFO)
73     else:
74         logging.getLogger("paramiko").setLevel(logging.WARN)
75
76     sshinfo = vmtools.get_clean_builder('builder')
77
78     try:
79         if not buildserverid:
80             buildserverid = subprocess.check_output(['vagrant', 'ssh', '-c',
81                                                      'cat /home/vagrant/buildserverid'],
82                                                     cwd='builder').rstrip().decode()
83             logging.debug(_('Fetched buildserverid from VM: {buildserverid}')
84                           .format(buildserverid=buildserverid))
85
86         # Open SSH connection...
87         logging.info("Connecting to virtual machine...")
88         sshs = paramiko.SSHClient()
89         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
90         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
91                      port=sshinfo['port'], timeout=300,
92                      look_for_keys=False, key_filename=sshinfo['idfile'])
93
94         homedir = '/home/' + sshinfo['user']
95
96         # Get an SFTP connection...
97         ftp = sshs.open_sftp()
98         ftp.get_channel().settimeout(60)
99
100         # Put all the necessary files in place...
101         ftp.chdir(homedir)
102
103         # Helper to copy the contents of a directory to the server...
104         def send_dir(path):
105             logging.debug("rsyncing " + path + " to " + ftp.getcwd())
106             # TODO this should move to `vagrant rsync` from >= v1.5
107             try:
108                 subprocess.check_output(['rsync', '--recursive', '--perms', '--links', '--quiet', '--rsh=' +
109                                          'ssh -o StrictHostKeyChecking=no' +
110                                          ' -o UserKnownHostsFile=/dev/null' +
111                                          ' -o LogLevel=FATAL' +
112                                          ' -o IdentitiesOnly=yes' +
113                                          ' -o PasswordAuthentication=no' +
114                                          ' -p ' + str(sshinfo['port']) +
115                                          ' -i ' + sshinfo['idfile'],
116                                          path,
117                                          sshinfo['user'] + "@" + sshinfo['hostname'] + ":" + ftp.getcwd()],
118                                         stderr=subprocess.STDOUT)
119             except subprocess.CalledProcessError as e:
120                 raise FDroidException(str(e), e.output.decode())
121
122         logging.info("Preparing server for build...")
123         serverpath = os.path.abspath(os.path.dirname(__file__))
124         ftp.mkdir('fdroidserver')
125         ftp.chdir('fdroidserver')
126         ftp.put(os.path.join(serverpath, '..', 'fdroid'), 'fdroid')
127         ftp.chmod('fdroid', 0o755)
128         send_dir(os.path.join(serverpath))
129         ftp.chdir(homedir)
130
131         ftp.put(os.path.join(serverpath, '..', 'buildserver',
132                              'config.buildserver.py'), 'config.py')
133         ftp.chmod('config.py', 0o600)
134
135         # Copy over the ID (head commit hash) of the fdroidserver in use...
136         subprocess.call('git rev-parse HEAD >' +
137                         os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
138                         shell=True, cwd=serverpath)
139         ftp.put('tmp/fdroidserverid', 'fdroidserverid')
140
141         # Copy the metadata - just the file for this app...
142         ftp.mkdir('metadata')
143         ftp.mkdir('srclibs')
144         ftp.chdir('metadata')
145         ftp.put(app.metadatapath, os.path.basename(app.metadatapath))
146
147         # And patches if there are any...
148         if os.path.exists(os.path.join('metadata', app.id)):
149             send_dir(os.path.join('metadata', app.id))
150
151         ftp.chdir(homedir)
152         # Create the build directory...
153         ftp.mkdir('build')
154         ftp.chdir('build')
155         ftp.mkdir('extlib')
156         ftp.mkdir('srclib')
157         # Copy any extlibs that are required...
158         if build.extlibs:
159             ftp.chdir(homedir + '/build/extlib')
160             for lib in build.extlibs:
161                 lib = lib.strip()
162                 libsrc = os.path.join('build/extlib', lib)
163                 if not os.path.exists(libsrc):
164                     raise BuildException("Missing extlib {0}".format(libsrc))
165                 lp = lib.split('/')
166                 for d in lp[:-1]:
167                     if d not in ftp.listdir():
168                         ftp.mkdir(d)
169                     ftp.chdir(d)
170                 ftp.put(libsrc, lp[-1])
171                 for _ignored in lp[:-1]:
172                     ftp.chdir('..')
173         # Copy any srclibs that are required...
174         srclibpaths = []
175         if build.srclibs:
176             for lib in build.srclibs:
177                 srclibpaths.append(
178                     common.getsrclib(lib, 'build/srclib', basepath=True, prepare=False))
179
180         # If one was used for the main source, add that too.
181         basesrclib = vcs.getsrclib()
182         if basesrclib:
183             srclibpaths.append(basesrclib)
184         for name, number, lib in srclibpaths:
185             logging.info("Sending srclib '%s'" % lib)
186             ftp.chdir(homedir + '/build/srclib')
187             if not os.path.exists(lib):
188                 raise BuildException("Missing srclib directory '" + lib + "'")
189             fv = '.fdroidvcs-' + name
190             ftp.put(os.path.join('build/srclib', fv), fv)
191             send_dir(lib)
192             # Copy the metadata file too...
193             ftp.chdir(homedir + '/srclibs')
194             ftp.put(os.path.join('srclibs', name + '.txt'),
195                     name + '.txt')
196         # Copy the main app source code
197         # (no need if it's a srclib)
198         if (not basesrclib) and os.path.exists(build_dir):
199             ftp.chdir(homedir + '/build')
200             fv = '.fdroidvcs-' + app.id
201             ftp.put(os.path.join('build', fv), fv)
202             send_dir(build_dir)
203
204         # Execute the build script...
205         logging.info("Starting build...")
206         chan = sshs.get_transport().open_session()
207         chan.get_pty()
208         cmdline = os.path.join(homedir, 'fdroidserver', 'fdroid')
209         cmdline += ' build --on-server'
210         if force:
211             cmdline += ' --force --test'
212         if options.verbose:
213             cmdline += ' --verbose'
214         if options.skipscan:
215             cmdline += ' --skip-scan'
216         cmdline += " %s:%s" % (app.id, build.versionCode)
217         chan.exec_command('bash --login -c "' + cmdline + '"')
218
219         # Fetch build process output ...
220         try:
221             cmd_stdout = chan.makefile('rb', 1024)
222             output = bytes()
223             output += common.get_android_tools_version_log(build.ndk_path()).encode()
224             while not chan.exit_status_ready():
225                 line = cmd_stdout.readline()
226                 if line:
227                     if options.verbose:
228                         logging.debug("buildserver > " + str(line, 'utf-8').rstrip())
229                     output += line
230                 else:
231                     time.sleep(0.05)
232             for line in cmd_stdout.readlines():
233                 if options.verbose:
234                     logging.debug("buildserver > " + str(line, 'utf-8').rstrip())
235                 output += line
236         finally:
237             cmd_stdout.close()
238
239         # Check build process exit status ...
240         logging.info("...getting exit status")
241         returncode = chan.recv_exit_status()
242         if returncode != 0:
243             raise BuildException(
244                 "Build.py failed on server for {0}:{1}".format(
245                     app.id, build.versionName), None if options.verbose else str(output, 'utf-8'))
246
247         # Retreive logs...
248         toolsversion_log = common.get_toolsversion_logname(app, build)
249         try:
250             ftp.chdir(os.path.join(homedir, log_dir))
251             ftp.get(toolsversion_log, os.path.join(log_dir, toolsversion_log))
252             logging.debug('retrieved %s', toolsversion_log)
253         except Exception as e:
254             logging.warn('could not get %s from builder vm: %s' % (toolsversion_log, e))
255
256         # Retrieve the built files...
257         logging.info("Retrieving build output...")
258         if force:
259             ftp.chdir(homedir + '/tmp')
260         else:
261             ftp.chdir(homedir + '/unsigned')
262         apkfile = common.get_release_filename(app, build)
263         tarball = common.getsrcname(app, build)
264         try:
265             ftp.get(apkfile, os.path.join(output_dir, apkfile))
266             if not options.notarball:
267                 ftp.get(tarball, os.path.join(output_dir, tarball))
268         except Exception:
269             raise BuildException(
270                 "Build failed for {0}:{1} - missing output files".format(
271                     app.id, build.versionName), None if options.verbose else str(output, 'utf-8'))
272         ftp.close()
273
274     finally:
275         # Suspend the build server.
276         vm = vmtools.get_build_vm('builder')
277         vm.suspend()
278
279
280 def force_gradle_build_tools(build_dir, build_tools):
281     for root, dirs, files in os.walk(build_dir):
282         for filename in files:
283             if not filename.endswith('.gradle'):
284                 continue
285             path = os.path.join(root, filename)
286             if not os.path.isfile(path):
287                 continue
288             logging.debug("Forcing build-tools %s in %s" % (build_tools, path))
289             common.regsub_file(r"""(\s*)buildToolsVersion([\s=]+).*""",
290                                r"""\1buildToolsVersion\2'%s'""" % build_tools,
291                                path)
292
293
294 def transform_first_char(string, method):
295     """Uses method() on the first character of string."""
296     if len(string) == 0:
297         return string
298     if len(string) == 1:
299         return method(string)
300     return method(string[0]) + string[1:]
301
302
303 def has_native_code(apkobj):
304     """aapt checks if there are architecture folders under the lib/ folder
305     so we are simulating the same behaviour"""
306     arch_re = re.compile("^lib/(.*)/.*$")
307     arch = [file for file in apkobj.get_files() if arch_re.match(file)]
308     return False if not arch else True
309
310
311 def get_apk_metadata_aapt(apkfile):
312     """aapt function to extract versionCode, versionName, packageName and nativecode"""
313     vercode = None
314     version = None
315     foundid = None
316     nativecode = None
317
318     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
319
320     for line in p.output.splitlines():
321         if line.startswith("package:"):
322             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
323             m = pat.match(line)
324             if m:
325                 foundid = m.group(1)
326             pat = re.compile(".*versionCode='([0-9]*)'.*")
327             m = pat.match(line)
328             if m:
329                 vercode = m.group(1)
330             pat = re.compile(".*versionName='([^']*)'.*")
331             m = pat.match(line)
332             if m:
333                 version = m.group(1)
334         elif line.startswith("native-code:"):
335             nativecode = line[12:]
336
337     return vercode, version, foundid, nativecode
338
339
340 def get_apk_metadata_androguard(apkfile):
341     """androguard function to extract versionCode, versionName, packageName and nativecode"""
342     try:
343         from androguard.core.bytecodes.apk import APK
344         apkobject = APK(apkfile)
345     except ImportError:
346         raise BuildException("androguard library is not installed and aapt binary not found")
347     except FileNotFoundError:
348         raise BuildException("Could not open apk file for metadata analysis")
349
350     if not apkobject.is_valid_APK():
351         raise BuildException("Invalid APK provided")
352
353     foundid = apkobject.get_package()
354     vercode = apkobject.get_androidversion_code()
355     version = apkobject.get_androidversion_name()
356     nativecode = has_native_code(apkobject)
357
358     return vercode, version, foundid, nativecode
359
360
361 def get_metadata_from_apk(app, build, apkfile):
362     """get the required metadata from the built APK"""
363
364     if common.SdkToolsPopen(['aapt', 'version'], output=False):
365         vercode, version, foundid, nativecode = get_apk_metadata_aapt(apkfile)
366     else:
367         vercode, version, foundid, nativecode = get_apk_metadata_androguard(apkfile)
368
369     # Ignore empty strings or any kind of space/newline chars that we don't
370     # care about
371     if nativecode is not None:
372         nativecode = nativecode.strip()
373         nativecode = None if not nativecode else nativecode
374
375     if build.buildjni and build.buildjni != ['no']:
376         if nativecode is None:
377             raise BuildException("Native code should have been built but none was packaged")
378     if build.novcheck:
379         vercode = build.versionCode
380         version = build.versionName
381     if not version or not vercode:
382         raise BuildException("Could not find version information in build in output")
383     if not foundid:
384         raise BuildException("Could not find package ID in output")
385     if foundid != app.id:
386         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app.id)
387
388     return vercode, version
389
390
391 def build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
392     """Do a build locally."""
393     ndk_path = build.ndk_path()
394     if build.ndk or (build.buildjni and build.buildjni != ['no']):
395         if not ndk_path:
396             logging.critical("Android NDK version '%s' could not be found!" % build.ndk or 'r12b')
397             logging.critical("Configured versions:")
398             for k, v in config['ndk_paths'].items():
399                 if k.endswith("_orig"):
400                     continue
401                 logging.critical("  %s: %s" % (k, v))
402             raise FDroidException()
403         elif not os.path.isdir(ndk_path):
404             logging.critical("Android NDK '%s' is not a directory!" % ndk_path)
405             raise FDroidException()
406
407     common.set_FDroidPopen_env(build)
408
409     # create ..._toolsversion.log when running in builder vm
410     if onserver:
411         # before doing anything, run the sudo commands to setup the VM
412         if build.sudo:
413             logging.info("Running 'sudo' commands in %s" % os.getcwd())
414
415             p = FDroidPopen(['sudo', 'bash', '-x', '-c', build.sudo])
416             if p.returncode != 0:
417                 raise BuildException("Error running sudo command for %s:%s" %
418                                      (app.id, build.versionName), p.output)
419
420         p = FDroidPopen(['sudo', 'passwd', '--lock', 'root'])
421         if p.returncode != 0:
422             raise BuildException("Error locking root account for %s:%s" %
423                                  (app.id, build.versionName), p.output)
424
425         p = FDroidPopen(['sudo', 'SUDO_FORCE_REMOVE=yes', 'dpkg', '--purge', 'sudo'])
426         if p.returncode != 0:
427             raise BuildException("Error removing sudo for %s:%s" %
428                                  (app.id, build.versionName), p.output)
429
430         log_path = os.path.join(log_dir,
431                                 common.get_toolsversion_logname(app, build))
432         with open(log_path, 'w') as f:
433             f.write(common.get_android_tools_version_log(build.ndk_path()))
434     else:
435         if build.sudo:
436             logging.warning('%s:%s runs this on the buildserver with sudo:\n\t%s'
437                             % (app.id, build.versionName, build.sudo))
438
439     # Prepare the source code...
440     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
441                                                   build_dir, srclib_dir,
442                                                   extlib_dir, onserver, refresh)
443
444     # We need to clean via the build tool in case the binary dirs are
445     # different from the default ones
446     p = None
447     gradletasks = []
448     bmethod = build.build_method()
449     if bmethod == 'maven':
450         logging.info("Cleaning Maven project...")
451         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
452
453         if '@' in build.maven:
454             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
455             maven_dir = os.path.normpath(maven_dir)
456         else:
457             maven_dir = root_dir
458
459         p = FDroidPopen(cmd, cwd=maven_dir)
460
461     elif bmethod == 'gradle':
462
463         logging.info("Cleaning Gradle project...")
464
465         if build.preassemble:
466             gradletasks += build.preassemble
467
468         flavours = build.gradle
469         if flavours == ['yes']:
470             flavours = []
471
472         flavours_cmd = ''.join([transform_first_char(flav, str.upper) for flav in flavours])
473
474         gradletasks += ['assemble' + flavours_cmd + 'Release']
475
476         if config['force_build_tools']:
477             force_gradle_build_tools(build_dir, config['build_tools'])
478             for name, number, libpath in srclibpaths:
479                 force_gradle_build_tools(libpath, config['build_tools'])
480
481         cmd = [config['gradle']]
482         if build.gradleprops:
483             cmd += ['-P' + kv for kv in build.gradleprops]
484
485         cmd += ['clean']
486
487         p = FDroidPopen(cmd, cwd=root_dir)
488
489     elif bmethod == 'kivy':
490         pass
491
492     elif bmethod == 'buildozer':
493         pass
494
495     elif bmethod == 'ant':
496         logging.info("Cleaning Ant project...")
497         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
498
499     if p is not None and p.returncode != 0:
500         raise BuildException("Error cleaning %s:%s" %
501                              (app.id, build.versionName), p.output)
502
503     for root, dirs, files in os.walk(build_dir):
504
505         def del_dirs(dl):
506             for d in dl:
507                 if d in dirs:
508                     shutil.rmtree(os.path.join(root, d))
509
510         def del_files(fl):
511             for f in fl:
512                 if f in files:
513                     os.remove(os.path.join(root, f))
514
515         if any(f in files for f in ['build.gradle', 'settings.gradle']):
516             # Even when running clean, gradle stores task/artifact caches in
517             # .gradle/ as binary files. To avoid overcomplicating the scanner,
518             # manually delete them, just like `gradle clean` should have removed
519             # the build/* dirs.
520             del_dirs([os.path.join('build', 'android-profile'),
521                       os.path.join('build', 'generated'),
522                       os.path.join('build', 'intermediates'),
523                       os.path.join('build', 'outputs'),
524                       os.path.join('build', 'reports'),
525                       os.path.join('build', 'tmp'),
526                       '.gradle'])
527             del_files(['gradlew', 'gradlew.bat'])
528
529         if 'pom.xml' in files:
530             del_dirs(['target'])
531
532         if any(f in files for f in ['ant.properties', 'project.properties', 'build.xml']):
533             del_dirs(['bin', 'gen'])
534
535         if 'jni' in dirs:
536             del_dirs(['obj'])
537
538     if options.skipscan:
539         if build.scandelete:
540             raise BuildException("Refusing to skip source scan since scandelete is present")
541     else:
542         # Scan before building...
543         logging.info("Scanning source for common problems...")
544         count = scanner.scan_source(build_dir, build)
545         if count > 0:
546             if force:
547                 logging.warning(ngettext('Scanner found {} problem',
548                                          'Scanner found {} problems', count).format(count))
549             else:
550                 raise BuildException(ngettext(
551                     "Can't build due to {} error while scanning",
552                     "Can't build due to {} errors while scanning", count).format(count))
553
554     if not options.notarball:
555         # Build the source tarball right before we build the release...
556         logging.info("Creating source tarball...")
557         tarname = common.getsrcname(app, build)
558         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
559
560         def tarexc(f):
561             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
562         tarball.add(build_dir, tarname, exclude=tarexc)
563         tarball.close()
564
565     # Run a build command if one is required...
566     if build.build:
567         logging.info("Running 'build' commands in %s" % root_dir)
568         cmd = common.replace_config_vars(build.build, build)
569
570         # Substitute source library paths into commands...
571         for name, number, libpath in srclibpaths:
572             libpath = os.path.relpath(libpath, root_dir)
573             cmd = cmd.replace('$$' + name + '$$', libpath)
574
575         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
576
577         if p.returncode != 0:
578             raise BuildException("Error running build command for %s:%s" %
579                                  (app.id, build.versionName), p.output)
580
581     # Build native stuff if required...
582     if build.buildjni and build.buildjni != ['no']:
583         logging.info("Building the native code")
584         jni_components = build.buildjni
585
586         if jni_components == ['yes']:
587             jni_components = ['']
588         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
589         for d in jni_components:
590             if d:
591                 logging.info("Building native code in '%s'" % d)
592             else:
593                 logging.info("Building native code in the main project")
594             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
595             if os.path.exists(manifest):
596                 # Read and write the whole AM.xml to fix newlines and avoid
597                 # the ndk r8c or later 'wordlist' errors. The outcome of this
598                 # under gnu/linux is the same as when using tools like
599                 # dos2unix, but the native python way is faster and will
600                 # work in non-unix systems.
601                 manifest_text = open(manifest, 'U').read()
602                 open(manifest, 'w').write(manifest_text)
603                 # In case the AM.xml read was big, free the memory
604                 del manifest_text
605             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
606             if p.returncode != 0:
607                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.versionName), p.output)
608
609     p = None
610     # Build the release...
611     if bmethod == 'maven':
612         logging.info("Building Maven project...")
613
614         if '@' in build.maven:
615             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
616         else:
617             maven_dir = root_dir
618
619         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
620                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
621                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
622                   'package']
623         if build.target:
624             target = build.target.split('-')[1]
625             common.regsub_file(r'<platform>[0-9]*</platform>',
626                                r'<platform>%s</platform>' % target,
627                                os.path.join(root_dir, 'pom.xml'))
628             if '@' in build.maven:
629                 common.regsub_file(r'<platform>[0-9]*</platform>',
630                                    r'<platform>%s</platform>' % target,
631                                    os.path.join(maven_dir, 'pom.xml'))
632
633         p = FDroidPopen(mvncmd, cwd=maven_dir)
634
635         bindir = os.path.join(root_dir, 'target')
636
637     elif bmethod == 'kivy':
638         logging.info("Building Kivy project...")
639
640         spec = os.path.join(root_dir, 'buildozer.spec')
641         if not os.path.exists(spec):
642             raise BuildException("Expected to find buildozer-compatible spec at {0}"
643                                  .format(spec))
644
645         defaults = {'orientation': 'landscape', 'icon': '',
646                     'permissions': '', 'android.api': "18"}
647         bconfig = ConfigParser(defaults, allow_no_value=True)
648         bconfig.read(spec)
649
650         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
651         if os.path.exists(distdir):
652             shutil.rmtree(distdir)
653
654         modules = bconfig.get('app', 'requirements').split(',')
655
656         cmd = 'ANDROIDSDK=' + config['sdk_path']
657         cmd += ' ANDROIDNDK=' + ndk_path
658         cmd += ' ANDROIDNDKVER=' + build.ndk
659         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
660         cmd += ' VIRTUALENV=virtualenv'
661         cmd += ' ./distribute.sh'
662         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
663         cmd += ' -d fdroid'
664         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
665         if p.returncode != 0:
666             raise BuildException("Distribute build failed")
667
668         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
669         if cid != app.id:
670             raise BuildException("Package ID mismatch between metadata and spec")
671
672         orientation = bconfig.get('app', 'orientation', 'landscape')
673         if orientation == 'all':
674             orientation = 'sensor'
675
676         cmd = ['./build.py'
677                '--dir', root_dir,
678                '--name', bconfig.get('app', 'title'),
679                '--package', app.id,
680                '--version', bconfig.get('app', 'version'),
681                '--orientation', orientation
682                ]
683
684         perms = bconfig.get('app', 'permissions')
685         for perm in perms.split(','):
686             cmd.extend(['--permission', perm])
687
688         if config.get('app', 'fullscreen') == 0:
689             cmd.append('--window')
690
691         icon = bconfig.get('app', 'icon.filename')
692         if icon:
693             cmd.extend(['--icon', os.path.join(root_dir, icon)])
694
695         cmd.append('release')
696         p = FDroidPopen(cmd, cwd=distdir)
697
698     elif bmethod == 'buildozer':
699         logging.info("Building Kivy project using buildozer...")
700
701         # parse buildozer.spez
702         spec = os.path.join(root_dir, 'buildozer.spec')
703         if not os.path.exists(spec):
704             raise BuildException("Expected to find buildozer-compatible spec at {0}"
705                                  .format(spec))
706         defaults = {'orientation': 'landscape', 'icon': '',
707                     'permissions': '', 'android.api': "19"}
708         bconfig = ConfigParser(defaults, allow_no_value=True)
709         bconfig.read(spec)
710
711         # update spec with sdk and ndk locations to prevent buildozer from
712         # downloading.
713         loc_ndk = common.env['ANDROID_NDK']
714         loc_sdk = common.env['ANDROID_SDK']
715         if loc_ndk == '$ANDROID_NDK':
716             loc_ndk = loc_sdk + '/ndk-bundle'
717
718         bc_ndk = None
719         bc_sdk = None
720         try:
721             bc_ndk = bconfig.get('app', 'android.sdk_path')
722         except Exception:
723             pass
724         try:
725             bc_sdk = bconfig.get('app', 'android.ndk_path')
726         except Exception:
727             pass
728
729         if bc_sdk is None:
730             bconfig.set('app', 'android.sdk_path', loc_sdk)
731         if bc_ndk is None:
732             bconfig.set('app', 'android.ndk_path', loc_ndk)
733
734         fspec = open(spec, 'w')
735         bconfig.write(fspec)
736         fspec.close()
737
738         logging.info("sdk_path = %s" % loc_sdk)
739         logging.info("ndk_path = %s" % loc_ndk)
740
741         p = None
742         # execute buildozer
743         cmd = ['buildozer', 'android', 'release']
744         try:
745             p = FDroidPopen(cmd, cwd=root_dir)
746         except Exception:
747             pass
748
749         # buidozer not installed ? clone repo and run
750         if (p is None or p.returncode != 0):
751             cmd = ['git', 'clone', 'https://github.com/kivy/buildozer.git']
752             p = subprocess.Popen(cmd, cwd=root_dir, shell=False)
753             p.wait()
754             if p.returncode != 0:
755                 raise BuildException("Distribute build failed")
756
757             cmd = ['python', 'buildozer/buildozer/scripts/client.py', 'android', 'release']
758             p = FDroidPopen(cmd, cwd=root_dir)
759
760         # expected to fail.
761         # Signing will fail if not set by environnment vars (cf. p4a docs).
762         # But the unsigned apk will be ok.
763         p.returncode = 0
764
765     elif bmethod == 'gradle':
766         logging.info("Building Gradle project...")
767
768         cmd = [config['gradle']]
769         if build.gradleprops:
770             cmd += ['-P' + kv for kv in build.gradleprops]
771
772         cmd += gradletasks
773
774         p = FDroidPopen(cmd, cwd=root_dir)
775
776     elif bmethod == 'ant':
777         logging.info("Building Ant project...")
778         cmd = ['ant']
779         if build.antcommands:
780             cmd += build.antcommands
781         else:
782             cmd += ['release']
783         p = FDroidPopen(cmd, cwd=root_dir)
784
785         bindir = os.path.join(root_dir, 'bin')
786
787     if p is not None and p.returncode != 0:
788         raise BuildException("Build failed for %s:%s" % (app.id, build.versionName), p.output)
789     logging.info("Successfully built version " + build.versionName + ' of ' + app.id)
790
791     omethod = build.output_method()
792     if omethod == 'maven':
793         stdout_apk = '\n'.join([
794             line for line in p.output.splitlines() if any(
795                 a in line for a in ('.apk', '.ap_', '.jar'))])
796         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
797                      stdout_apk, re.S | re.M)
798         if not m:
799             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
800                          stdout_apk, re.S | re.M)
801         if not m:
802             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
803                          stdout_apk, re.S | re.M)
804
805         if not m:
806             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
807                          stdout_apk, re.S | re.M)
808         if not m:
809             raise BuildException('Failed to find output')
810         src = m.group(1)
811         src = os.path.join(bindir, src) + '.apk'
812     elif omethod == 'kivy':
813         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
814                            '{0}-{1}-release.apk'.format(
815                                bconfig.get('app', 'title'),
816                                bconfig.get('app', 'version')))
817
818     elif omethod == 'buildozer':
819         src = None
820         for apks_dir in [
821                 os.path.join(root_dir, '.buildozer', 'android', 'platform', 'build', 'dists', bconfig.get('app', 'title'), 'bin'),
822                 ]:
823             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
824                 apks = glob.glob(os.path.join(apks_dir, apkglob))
825
826                 if len(apks) > 1:
827                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
828                                          '\n'.join(apks))
829                 if len(apks) == 1:
830                     src = apks[0]
831                     break
832             if src is not None:
833                 break
834
835         if src is None:
836             raise BuildException('Failed to find any output apks')
837
838     elif omethod == 'gradle':
839         src = None
840         apk_dirs = [
841             # gradle plugin >= 3.0
842             os.path.join(root_dir, 'build', 'outputs', 'apk', 'release'),
843             # gradle plugin < 3.0 and >= 0.11
844             os.path.join(root_dir, 'build', 'outputs', 'apk'),
845             # really old path
846             os.path.join(root_dir, 'build', 'apk'),
847             ]
848         # If we build with gradle flavours with gradle plugin >= 3.0 the apk will be in
849         # a subdirectory corresponding to the flavour command used, but with different
850         # capitalization.
851         if flavours_cmd:
852             apk_dirs.append(os.path.join(root_dir, 'build', 'outputs', 'apk', transform_first_char(flavours_cmd, str.lower), 'release'))
853         for apks_dir in apk_dirs:
854             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
855                 apks = glob.glob(os.path.join(apks_dir, apkglob))
856
857                 if len(apks) > 1:
858                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
859                                          '\n'.join(apks))
860                 if len(apks) == 1:
861                     src = apks[0]
862                     break
863             if src is not None:
864                 break
865
866         if src is None:
867             raise BuildException('Failed to find any output apks')
868
869     elif omethod == 'ant':
870         stdout_apk = '\n'.join([
871             line for line in p.output.splitlines() if '.apk' in line])
872         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
873                        re.S | re.M).group(1)
874         src = os.path.join(bindir, src)
875     elif omethod == 'raw':
876         output_path = common.replace_build_vars(build.output, build)
877         globpath = os.path.join(root_dir, output_path)
878         apks = glob.glob(globpath)
879         if len(apks) > 1:
880             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
881         if len(apks) < 1:
882             raise BuildException('No apks match %s' % globpath)
883         src = os.path.normpath(apks[0])
884
885     # Make sure it's not debuggable...
886     if common.isApkAndDebuggable(src):
887         raise BuildException("APK is debuggable")
888
889     # By way of a sanity check, make sure the version and version
890     # code in our new apk match what we expect...
891     logging.debug("Checking " + src)
892     if not os.path.exists(src):
893         raise BuildException("Unsigned apk is not at expected location of " + src)
894
895     if common.get_file_extension(src) == 'apk':
896         vercode, version = get_metadata_from_apk(app, build, src)
897         if (version != build.versionName or vercode != build.versionCode):
898             raise BuildException(("Unexpected version/version code in output;"
899                                   " APK: '%s' / '%s', "
900                                   " Expected: '%s' / '%s'")
901                                  % (version, str(vercode), build.versionName,
902                                     str(build.versionCode)))
903     else:
904         vercode = build.versionCode
905         version = build.versionName
906
907     # Add information for 'fdroid verify' to be able to reproduce the build
908     # environment.
909     if onserver:
910         metadir = os.path.join(tmp_dir, 'META-INF')
911         if not os.path.exists(metadir):
912             os.mkdir(metadir)
913         homedir = os.path.expanduser('~')
914         for fn in ['buildserverid', 'fdroidserverid']:
915             shutil.copyfile(os.path.join(homedir, fn),
916                             os.path.join(metadir, fn))
917             subprocess.call(['jar', 'uf', os.path.abspath(src),
918                              'META-INF/' + fn], cwd=tmp_dir)
919
920     # Copy the unsigned apk to our destination directory for further
921     # processing (by publish.py)...
922     dest = os.path.join(output_dir, common.get_release_filename(app, build))
923     shutil.copyfile(src, dest)
924
925     # Move the source tarball into the output directory...
926     if output_dir != tmp_dir and not options.notarball:
927         shutil.move(os.path.join(tmp_dir, tarname),
928                     os.path.join(output_dir, tarname))
929
930
931 def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
932              srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, test,
933              server, force, onserver, refresh):
934     """
935     Build a particular version of an application, if it needs building.
936
937     :param output_dir: The directory where the build output will go. Usually
938        this is the 'unsigned' directory.
939     :param repo_dir: The repo directory - used for checking if the build is
940        necessary.
941     :param also_check_dir: An additional location for checking if the build
942        is necessary (usually the archive repo)
943     :param test: True if building in test mode, in which case the build will
944        always happen, even if the output already exists. In test mode, the
945        output directory should be a temporary location, not any of the real
946        ones.
947
948     :returns: True if the build was done, False if it wasn't necessary.
949     """
950
951     dest_file = common.get_release_filename(app, build)
952
953     dest = os.path.join(output_dir, dest_file)
954     dest_repo = os.path.join(repo_dir, dest_file)
955
956     if not test:
957         if os.path.exists(dest) or os.path.exists(dest_repo):
958             return False
959
960         if also_check_dir:
961             dest_also = os.path.join(also_check_dir, dest_file)
962             if os.path.exists(dest_also):
963                 return False
964
965     if build.disable and not options.force:
966         return False
967
968     logging.info("Building version %s (%s) of %s" % (
969         build.versionName, build.versionCode, app.id))
970
971     if server:
972         # When using server mode, still keep a local cache of the repo, by
973         # grabbing the source now.
974         vcs.gotorevision(build.commit, refresh)
975
976         build_server(app, build, vcs, build_dir, output_dir, log_dir, force)
977     else:
978         build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
979     return True
980
981
982 def force_halt_build():
983     """Halt the currently running Vagrant VM, to be called from a Timer"""
984     logging.error(_('Force halting build after timeout!'))
985     vm = vmtools.get_build_vm('builder')
986     vm.halt()
987
988
989 def parse_commandline():
990     """Parse the command line. Returns options, parser."""
991
992     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
993     common.setup_global_opts(parser)
994     parser.add_argument("appid", nargs='*', help=_("applicationId with optional versionCode in the form APPID[:VERCODE]"))
995     parser.add_argument("-l", "--latest", action="store_true", default=False,
996                         help=_("Build only the latest version of each package"))
997     parser.add_argument("-s", "--stop", action="store_true", default=False,
998                         help=_("Make the build stop on exceptions"))
999     parser.add_argument("-t", "--test", action="store_true", default=False,
1000                         help=_("Test mode - put output in the tmp directory only, and always build, even if the output already exists."))
1001     parser.add_argument("--server", action="store_true", default=False,
1002                         help=_("Use build server"))
1003     parser.add_argument("--resetserver", action="store_true", default=False,
1004                         help=_("Reset and create a brand new build server, even if the existing one appears to be ok."))
1005     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
1006                         help=_("Specify that we're running on the build server"))
1007     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
1008                         help=_("Skip scanning the source code for binaries and other problems"))
1009     parser.add_argument("--dscanner", action="store_true", default=False,
1010                         help=_("Setup an emulator, install the APK on it and perform a Drozer scan"))
1011     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
1012                         help=_("Don't create a source tarball, useful when testing a build"))
1013     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
1014                         help=_("Don't refresh the repository, useful when testing a build with no internet connection"))
1015     parser.add_argument("-f", "--force", action="store_true", default=False,
1016                         help=_("Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode."))
1017     parser.add_argument("-a", "--all", action="store_true", default=False,
1018                         help=_("Build all applications available"))
1019     parser.add_argument("-w", "--wiki", default=False, action="store_true",
1020                         help=_("Update the wiki"))
1021     metadata.add_metadata_arguments(parser)
1022     options = parser.parse_args()
1023     metadata.warnings_action = options.W
1024
1025     # Force --stop with --on-server to get correct exit code
1026     if options.onserver:
1027         options.stop = True
1028
1029     if options.force and not options.test:
1030         parser.error("option %s: Force is only allowed in test mode" % "force")
1031
1032     return options, parser
1033
1034
1035 options = None
1036 config = None
1037 buildserverid = None
1038 fdroidserverid = None
1039 start_timestamp = time.gmtime()
1040
1041
1042 def main():
1043
1044     global options, config, buildserverid, fdroidserverid
1045
1046     options, parser = parse_commandline()
1047
1048     # The defaults for .fdroid.* metadata that is included in a git repo are
1049     # different than for the standard metadata/ layout because expectations
1050     # are different.  In this case, the most common user will be the app
1051     # developer working on the latest update of the app on their own machine.
1052     local_metadata_files = common.get_local_metadata_files()
1053     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
1054         config = dict(common.default_config)
1055         # `fdroid build` should build only the latest version by default since
1056         # most of the time the user will be building the most recent update
1057         if not options.all:
1058             options.latest = True
1059     elif len(local_metadata_files) > 1:
1060         raise FDroidException("Only one local metadata file allowed! Found: "
1061                               + " ".join(local_metadata_files))
1062     else:
1063         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1064             raise FDroidException("No app metadata found, nothing to process!")
1065         if not options.appid and not options.all:
1066             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1067
1068     config = common.read_config(options)
1069
1070     if config['build_server_always']:
1071         options.server = True
1072     if options.resetserver and not options.server:
1073         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1074
1075     log_dir = 'logs'
1076     if not os.path.isdir(log_dir):
1077         logging.info("Creating log directory")
1078         os.makedirs(log_dir)
1079
1080     tmp_dir = 'tmp'
1081     if not os.path.isdir(tmp_dir):
1082         logging.info("Creating temporary directory")
1083         os.makedirs(tmp_dir)
1084
1085     if options.test:
1086         output_dir = tmp_dir
1087     else:
1088         output_dir = 'unsigned'
1089         if not os.path.isdir(output_dir):
1090             logging.info("Creating output directory")
1091             os.makedirs(output_dir)
1092
1093     if config['archive_older'] != 0:
1094         also_check_dir = 'archive'
1095     else:
1096         also_check_dir = None
1097
1098     repo_dir = 'repo'
1099
1100     build_dir = 'build'
1101     if not os.path.isdir(build_dir):
1102         logging.info("Creating build directory")
1103         os.makedirs(build_dir)
1104     srclib_dir = os.path.join(build_dir, 'srclib')
1105     extlib_dir = os.path.join(build_dir, 'extlib')
1106
1107     # Read all app and srclib metadata
1108     pkgs = common.read_pkg_args(options.appid, True)
1109     allapps = metadata.read_metadata(not options.onserver, pkgs, options.refresh, sort_by_time=True)
1110     apps = common.read_app_args(options.appid, allapps, True)
1111
1112     for appid, app in list(apps.items()):
1113         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1114             del apps[appid]
1115
1116     if not apps:
1117         raise FDroidException("No apps to process.")
1118
1119     # make sure enough open files are allowed to process everything
1120     soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
1121     if len(apps) > soft:
1122         try:
1123             soft = len(apps) * 2
1124             if soft > hard:
1125                 soft = hard
1126             resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
1127             logging.debug(_('Set open file limit to {integer}')
1128                           .format(integer=soft))
1129         except (OSError, ValueError) as e:
1130             logging.warning(_('Setting open file limit failed: ') + str(e))
1131
1132     if options.latest:
1133         for app in apps.values():
1134             for build in reversed(app.builds):
1135                 if build.disable and not options.force:
1136                     continue
1137                 app.builds = [build]
1138                 break
1139
1140     if options.wiki:
1141         import mwclient
1142         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1143                              path=config['wiki_path'])
1144         site.login(config['wiki_user'], config['wiki_password'])
1145
1146     # Build applications...
1147     failed_apps = {}
1148     build_succeeded = []
1149     for appid, app in apps.items():
1150
1151         first = True
1152
1153         for build in app.builds:
1154             if options.server:  # enable watchdog timer
1155                 timer = threading.Timer(7200, force_halt_build)
1156                 timer.start()
1157             else:
1158                 timer = None
1159
1160             wikilog = None
1161             build_starttime = common.get_wiki_timestamp()
1162             tools_version_log = ''
1163             if not options.onserver:
1164                 tools_version_log = common.get_android_tools_version_log(build.ndk_path())
1165             try:
1166
1167                 # For the first build of a particular app, we need to set up
1168                 # the source repo. We can reuse it on subsequent builds, if
1169                 # there are any.
1170                 if first:
1171                     vcs, build_dir = common.setup_vcs(app)
1172                     first = False
1173
1174                 logging.info("Using %s" % vcs.clientversion())
1175                 logging.debug("Checking " + build.versionName)
1176                 if trybuild(app, build, build_dir, output_dir, log_dir,
1177                             also_check_dir, srclib_dir, extlib_dir,
1178                             tmp_dir, repo_dir, vcs, options.test,
1179                             options.server, options.force,
1180                             options.onserver, options.refresh):
1181                     toolslog = os.path.join(log_dir,
1182                                             common.get_toolsversion_logname(app, build))
1183                     if not options.onserver and os.path.exists(toolslog):
1184                         with open(toolslog, 'r') as f:
1185                             tools_version_log = ''.join(f.readlines())
1186                         os.remove(toolslog)
1187
1188                     if app.Binaries is not None:
1189                         # This is an app where we build from source, and
1190                         # verify the apk contents against a developer's
1191                         # binary. We get that binary now, and save it
1192                         # alongside our built one in the 'unsigend'
1193                         # directory.
1194                         url = app.Binaries
1195                         url = url.replace('%v', build.versionName)
1196                         url = url.replace('%c', str(build.versionCode))
1197                         logging.info("...retrieving " + url)
1198                         of = re.sub(r'.apk$', '.binary.apk', common.get_release_filename(app, build))
1199                         of = os.path.join(output_dir, of)
1200                         try:
1201                             net.download_file(url, local_filename=of)
1202                         except requests.exceptions.HTTPError as e:
1203                             raise FDroidException(
1204                                 'Downloading Binaries from %s failed. %s' % (url, e))
1205
1206                         # Now we check weather the build can be verified to
1207                         # match the supplied binary or not. Should the
1208                         # comparison fail, we mark this build as a failure
1209                         # and remove everything from the unsigend folder.
1210                         with tempfile.TemporaryDirectory() as tmpdir:
1211                             unsigned_apk = \
1212                                 common.get_release_filename(app, build)
1213                             unsigned_apk = \
1214                                 os.path.join(output_dir, unsigned_apk)
1215                             compare_result = \
1216                                 common.verify_apks(of, unsigned_apk, tmpdir)
1217                             if compare_result:
1218                                 logging.debug('removing %s', unsigned_apk)
1219                                 os.remove(unsigned_apk)
1220                                 logging.debug('removing %s', of)
1221                                 os.remove(of)
1222                                 compare_result = compare_result.split('\n')
1223                                 line_count = len(compare_result)
1224                                 compare_result = compare_result[:299]
1225                                 if line_count > len(compare_result):
1226                                     line_difference = \
1227                                         line_count - len(compare_result)
1228                                     compare_result.append('%d more lines ...' %
1229                                                           line_difference)
1230                                 compare_result = '\n'.join(compare_result)
1231                                 raise FDroidException('compared built binary '
1232                                                       'to supplied reference '
1233                                                       'binary but failed',
1234                                                       compare_result)
1235                             else:
1236                                 logging.info('compared built binary to '
1237                                              'supplied reference binary '
1238                                              'successfully')
1239
1240                     build_succeeded.append(app)
1241                     wikilog = "Build succeeded"
1242
1243             except VCSException as vcse:
1244                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1245                 logging.error("VCS error while building app %s: %s" % (
1246                     appid, reason))
1247                 if options.stop:
1248                     logging.debug("Error encoutered, stopping by user request.")
1249                     sys.exit(1)
1250                 failed_apps[appid] = vcse
1251                 wikilog = str(vcse)
1252             except FDroidException as e:
1253                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1254                     f.write('\n\n============================================================\n')
1255                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1256                             (build.versionCode, build.versionName, build.commit))
1257                     f.write('Build completed at '
1258                             + common.get_wiki_timestamp() + '\n')
1259                     f.write('\n' + tools_version_log + '\n')
1260                     f.write(str(e))
1261                 logging.error("Could not build app %s: %s" % (appid, e))
1262                 if options.stop:
1263                     logging.debug("Error encoutered, stopping by user request.")
1264                     sys.exit(1)
1265                 failed_apps[appid] = e
1266                 wikilog = e.get_wikitext()
1267             except Exception as e:
1268                 logging.error("Could not build app %s due to unknown error: %s" % (
1269                     appid, traceback.format_exc()))
1270                 if options.stop:
1271                     logging.debug("Error encoutered, stopping by user request.")
1272                     sys.exit(1)
1273                 failed_apps[appid] = e
1274                 wikilog = str(e)
1275
1276             if options.wiki and wikilog:
1277                 try:
1278                     # Write a page with the last build log for this version code
1279                     lastbuildpage = appid + '/lastbuild_' + build.versionCode
1280                     newpage = site.Pages[lastbuildpage]
1281                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1282                         fdroidserverid = fp.read().rstrip()
1283                     txt = "* build session started at " + common.get_wiki_timestamp(start_timestamp) + '\n' \
1284                           + "* this build started at " + build_starttime + '\n' \
1285                           + "* this build completed at " + common.get_wiki_timestamp() + '\n' \
1286                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1287                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1288                     if buildserverid:
1289                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1290                                + buildserverid + ' ' + buildserverid + ']\n\n'
1291                     txt += tools_version_log + '\n\n'
1292                     txt += '== Build Log ==\n\n' + wikilog
1293                     newpage.save(txt, summary='Build log')
1294                     # Redirect from /lastbuild to the most recent build log
1295                     newpage = site.Pages[appid + '/lastbuild']
1296                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1297                 except Exception as e:
1298                     logging.error("Error while attempting to publish build log: %s" % e)
1299
1300             if timer:
1301                 timer.cancel()  # kill the watchdog timer
1302
1303     for app in build_succeeded:
1304         logging.info("success: %s" % (app.id))
1305
1306     if not options.verbose:
1307         for fa in failed_apps:
1308             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1309
1310     # perform a drozer scan of all successful builds
1311     if options.dscanner and build_succeeded:
1312         from .dscanner import DockerDriver
1313
1314         docker = DockerDriver()
1315
1316         try:
1317             for app in build_succeeded:
1318
1319                 logging.info("Need to sign the app before we can install it.")
1320                 subprocess.call("fdroid publish {0}".format(app.id), shell=True)
1321
1322                 apk_path = None
1323
1324                 for f in os.listdir(repo_dir):
1325                     if f.endswith('.apk') and f.startswith(app.id):
1326                         apk_path = os.path.join(repo_dir, f)
1327                         break
1328
1329                 if not apk_path:
1330                     raise Exception("No signed APK found at path: {path}".format(path=apk_path))
1331
1332                 if not os.path.isdir(repo_dir):
1333                     logging.critical("directory does not exists '{path}'".format(path=repo_dir))
1334                     sys.exit(1)
1335
1336                 logging.info("Performing Drozer scan on {0}.".format(app))
1337                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1338         except Exception as e:
1339             logging.error(str(e))
1340             logging.error("An exception happened. Making sure to clean up")
1341         else:
1342             logging.info("Scan succeeded.")
1343
1344         logging.info("Cleaning up after ourselves.")
1345         docker.clean()
1346
1347     logging.info(_("Finished"))
1348     if len(build_succeeded) > 0:
1349         logging.info(ngettext("{} build succeeded",
1350                               "{} builds succeeded", len(build_succeeded)).format(len(build_succeeded)))
1351     if len(failed_apps) > 0:
1352         logging.info(ngettext("{} build failed",
1353                               "{} builds failed", len(failed_apps)).format(len(failed_apps)))
1354
1355     if options.wiki:
1356         wiki_page_path = 'build_' + time.strftime('%s', start_timestamp)
1357         newpage = site.Pages[wiki_page_path]
1358         txt = ''
1359         txt += "* command line: <code>%s</code>\n" % ' '.join(sys.argv)
1360         txt += "* started at %s\n" % common.get_wiki_timestamp(start_timestamp)
1361         txt += "* completed at %s\n" % common.get_wiki_timestamp()
1362         if buildserverid:
1363             txt += ('* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/{id} {id}]\n'
1364                     .format(id=buildserverid))
1365         if fdroidserverid:
1366             txt += ('* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/{id} {id}]\n'
1367                     .format(id=fdroidserverid))
1368         if os.cpu_count():
1369             txt += "* host processors: %d\n" % os.cpu_count()
1370         if os.path.isfile('/proc/meminfo') and os.access('/proc/meminfo', os.R_OK):
1371             with open('/proc/meminfo') as fp:
1372                 for line in fp:
1373                     m = re.search(r'MemTotal:\s*([0-9].*)', line)
1374                     if m:
1375                         txt += "* host RAM: %s\n" % m.group(1)
1376                         break
1377         txt += "* successful builds: %d\n" % len(build_succeeded)
1378         txt += "* failed builds: %d\n" % len(failed_apps)
1379         txt += "\n\n"
1380         newpage.save(txt, summary='Run log')
1381         newpage = site.Pages['build']
1382         newpage.save('#REDIRECT [[' + wiki_page_path + ']]', summary='Update redirect')
1383
1384     # hack to ensure this exits, even is some threads are still running
1385     sys.stdout.flush()
1386     sys.stderr.flush()
1387     os._exit(0)
1388
1389
1390 if __name__ == "__main__":
1391     main()