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