chiark / gitweb /
58e4ccf42ba82eb400c48db1e98118669b6687d9
[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', options.reset_server)
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         with open(os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'), 'wb') as fp:
137             fp.write(subprocess.check_output(['git', 'rev-parse', 'HEAD'],
138                                              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 == '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 == 'buildozer':
638         logging.info("Building Kivy project using buildozer...")
639
640         # parse buildozer.spez
641         spec = os.path.join(root_dir, 'buildozer.spec')
642         if not os.path.exists(spec):
643             raise BuildException("Expected to find buildozer-compatible spec at {0}"
644                                  .format(spec))
645         defaults = {'orientation': 'landscape', 'icon': '',
646                     'permissions': '', 'android.api': "19"}
647         bconfig = ConfigParser(defaults, allow_no_value=True)
648         bconfig.read(spec)
649
650         # update spec with sdk and ndk locations to prevent buildozer from
651         # downloading.
652         loc_ndk = common.env['ANDROID_NDK']
653         loc_sdk = common.env['ANDROID_SDK']
654         if loc_ndk == '$ANDROID_NDK':
655             loc_ndk = loc_sdk + '/ndk-bundle'
656
657         bc_ndk = None
658         bc_sdk = None
659         try:
660             bc_ndk = bconfig.get('app', 'android.sdk_path')
661         except Exception:
662             pass
663         try:
664             bc_sdk = bconfig.get('app', 'android.ndk_path')
665         except Exception:
666             pass
667
668         if bc_sdk is None:
669             bconfig.set('app', 'android.sdk_path', loc_sdk)
670         if bc_ndk is None:
671             bconfig.set('app', 'android.ndk_path', loc_ndk)
672
673         fspec = open(spec, 'w')
674         bconfig.write(fspec)
675         fspec.close()
676
677         logging.info("sdk_path = %s" % loc_sdk)
678         logging.info("ndk_path = %s" % loc_ndk)
679
680         p = None
681         # execute buildozer
682         cmd = ['buildozer', 'android', 'release']
683         try:
684             p = FDroidPopen(cmd, cwd=root_dir)
685         except Exception:
686             pass
687
688         # buidozer not installed ? clone repo and run
689         if (p is None or p.returncode != 0):
690             cmd = ['git', 'clone', 'https://github.com/kivy/buildozer.git']
691             p = subprocess.Popen(cmd, cwd=root_dir, shell=False)
692             p.wait()
693             if p.returncode != 0:
694                 raise BuildException("Distribute build failed")
695
696             cmd = ['python', 'buildozer/buildozer/scripts/client.py', 'android', 'release']
697             p = FDroidPopen(cmd, cwd=root_dir)
698
699         # expected to fail.
700         # Signing will fail if not set by environnment vars (cf. p4a docs).
701         # But the unsigned apk will be ok.
702         p.returncode = 0
703
704     elif bmethod == 'gradle':
705         logging.info("Building Gradle project...")
706
707         cmd = [config['gradle']]
708         if build.gradleprops:
709             cmd += ['-P' + kv for kv in build.gradleprops]
710
711         cmd += gradletasks
712
713         p = FDroidPopen(cmd, cwd=root_dir)
714
715     elif bmethod == 'ant':
716         logging.info("Building Ant project...")
717         cmd = ['ant']
718         if build.antcommands:
719             cmd += build.antcommands
720         else:
721             cmd += ['release']
722         p = FDroidPopen(cmd, cwd=root_dir)
723
724         bindir = os.path.join(root_dir, 'bin')
725
726     if p is not None and p.returncode != 0:
727         raise BuildException("Build failed for %s:%s" % (app.id, build.versionName), p.output)
728     logging.info("Successfully built version " + build.versionName + ' of ' + app.id)
729
730     omethod = build.output_method()
731     if omethod == 'maven':
732         stdout_apk = '\n'.join([
733             line for line in p.output.splitlines() if any(
734                 a in line for a in ('.apk', '.ap_', '.jar'))])
735         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
736                      stdout_apk, re.S | re.M)
737         if not m:
738             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
739                          stdout_apk, re.S | re.M)
740         if not m:
741             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
742                          stdout_apk, re.S | re.M)
743
744         if not m:
745             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
746                          stdout_apk, re.S | re.M)
747         if not m:
748             raise BuildException('Failed to find output')
749         src = m.group(1)
750         src = os.path.join(bindir, src) + '.apk'
751
752     elif omethod == 'buildozer':
753         src = None
754         for apks_dir in [
755                 os.path.join(root_dir, '.buildozer', 'android', 'platform', 'build', 'dists', bconfig.get('app', 'title'), 'bin'),
756                 ]:
757             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
758                 apks = glob.glob(os.path.join(apks_dir, apkglob))
759
760                 if len(apks) > 1:
761                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
762                                          '\n'.join(apks))
763                 if len(apks) == 1:
764                     src = apks[0]
765                     break
766             if src is not None:
767                 break
768
769         if src is None:
770             raise BuildException('Failed to find any output apks')
771
772     elif omethod == 'gradle':
773         src = None
774         apk_dirs = [
775             # gradle plugin >= 3.0
776             os.path.join(root_dir, 'build', 'outputs', 'apk', 'release'),
777             # gradle plugin < 3.0 and >= 0.11
778             os.path.join(root_dir, 'build', 'outputs', 'apk'),
779             # really old path
780             os.path.join(root_dir, 'build', 'apk'),
781             ]
782         # If we build with gradle flavours with gradle plugin >= 3.0 the apk will be in
783         # a subdirectory corresponding to the flavour command used, but with different
784         # capitalization.
785         if flavours_cmd:
786             apk_dirs.append(os.path.join(root_dir, 'build', 'outputs', 'apk', transform_first_char(flavours_cmd, str.lower), 'release'))
787         for apks_dir in apk_dirs:
788             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
789                 apks = glob.glob(os.path.join(apks_dir, apkglob))
790
791                 if len(apks) > 1:
792                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
793                                          '\n'.join(apks))
794                 if len(apks) == 1:
795                     src = apks[0]
796                     break
797             if src is not None:
798                 break
799
800         if src is None:
801             raise BuildException('Failed to find any output apks')
802
803     elif omethod == 'ant':
804         stdout_apk = '\n'.join([
805             line for line in p.output.splitlines() if '.apk' in line])
806         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
807                        re.S | re.M).group(1)
808         src = os.path.join(bindir, src)
809     elif omethod == 'raw':
810         output_path = common.replace_build_vars(build.output, build)
811         globpath = os.path.join(root_dir, output_path)
812         apks = glob.glob(globpath)
813         if len(apks) > 1:
814             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
815         if len(apks) < 1:
816             raise BuildException('No apks match %s' % globpath)
817         src = os.path.normpath(apks[0])
818
819     # Make sure it's not debuggable...
820     if common.is_apk_and_debuggable(src):
821         raise BuildException("APK is debuggable")
822
823     # By way of a sanity check, make sure the version and version
824     # code in our new apk match what we expect...
825     logging.debug("Checking " + src)
826     if not os.path.exists(src):
827         raise BuildException("Unsigned apk is not at expected location of " + src)
828
829     if common.get_file_extension(src) == 'apk':
830         vercode, version = get_metadata_from_apk(app, build, src)
831         if (version != build.versionName or vercode != build.versionCode):
832             raise BuildException(("Unexpected version/version code in output;"
833                                   " APK: '%s' / '%s', "
834                                   " Expected: '%s' / '%s'")
835                                  % (version, str(vercode), build.versionName,
836                                     str(build.versionCode)))
837     else:
838         vercode = build.versionCode
839         version = build.versionName
840
841     # Add information for 'fdroid verify' to be able to reproduce the build
842     # environment.
843     if onserver:
844         metadir = os.path.join(tmp_dir, 'META-INF')
845         if not os.path.exists(metadir):
846             os.mkdir(metadir)
847         homedir = os.path.expanduser('~')
848         for fn in ['buildserverid', 'fdroidserverid']:
849             shutil.copyfile(os.path.join(homedir, fn),
850                             os.path.join(metadir, fn))
851             subprocess.call(['jar', 'uf', os.path.abspath(src),
852                              'META-INF/' + fn], cwd=tmp_dir)
853
854     # Copy the unsigned apk to our destination directory for further
855     # processing (by publish.py)...
856     dest = os.path.join(output_dir, common.get_release_filename(app, build))
857     shutil.copyfile(src, dest)
858
859     # Move the source tarball into the output directory...
860     if output_dir != tmp_dir and not options.notarball:
861         shutil.move(os.path.join(tmp_dir, tarname),
862                     os.path.join(output_dir, tarname))
863
864
865 def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
866              srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, test,
867              server, force, onserver, refresh):
868     """
869     Build a particular version of an application, if it needs building.
870
871     :param output_dir: The directory where the build output will go. Usually
872        this is the 'unsigned' directory.
873     :param repo_dir: The repo directory - used for checking if the build is
874        necessary.
875     :param also_check_dir: An additional location for checking if the build
876        is necessary (usually the archive repo)
877     :param test: True if building in test mode, in which case the build will
878        always happen, even if the output already exists. In test mode, the
879        output directory should be a temporary location, not any of the real
880        ones.
881
882     :returns: True if the build was done, False if it wasn't necessary.
883     """
884
885     dest_file = common.get_release_filename(app, build)
886
887     dest = os.path.join(output_dir, dest_file)
888     dest_repo = os.path.join(repo_dir, dest_file)
889
890     if not test:
891         if os.path.exists(dest) or os.path.exists(dest_repo):
892             return False
893
894         if also_check_dir:
895             dest_also = os.path.join(also_check_dir, dest_file)
896             if os.path.exists(dest_also):
897                 return False
898
899     if build.disable and not options.force:
900         return False
901
902     logging.info("Building version %s (%s) of %s" % (
903         build.versionName, build.versionCode, app.id))
904
905     if server:
906         # When using server mode, still keep a local cache of the repo, by
907         # grabbing the source now.
908         vcs.gotorevision(build.commit, refresh)
909
910         build_server(app, build, vcs, build_dir, output_dir, log_dir, force)
911     else:
912         build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
913     return True
914
915
916 def force_halt_build(timeout):
917     """Halt the currently running Vagrant VM, to be called from a Timer"""
918     logging.error(_('Force halting build after {0} sec timeout!').format(timeout))
919     timeout_event.set()
920     vm = vmtools.get_build_vm('builder')
921     vm.halt()
922
923
924 def parse_commandline():
925     """Parse the command line. Returns options, parser."""
926
927     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
928     common.setup_global_opts(parser)
929     parser.add_argument("appid", nargs='*', help=_("applicationId with optional versionCode in the form APPID[:VERCODE]"))
930     parser.add_argument("-l", "--latest", action="store_true", default=False,
931                         help=_("Build only the latest version of each package"))
932     parser.add_argument("-s", "--stop", action="store_true", default=False,
933                         help=_("Make the build stop on exceptions"))
934     parser.add_argument("-t", "--test", action="store_true", default=False,
935                         help=_("Test mode - put output in the tmp directory only, and always build, even if the output already exists."))
936     parser.add_argument("--server", action="store_true", default=False,
937                         help=_("Use build server"))
938     parser.add_argument("--reset-server", action="store_true", default=False,
939                         help=_("Reset and create a brand new build server, even if the existing one appears to be ok."))
940     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
941                         help=_("Specify that we're running on the build server"))
942     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
943                         help=_("Skip scanning the source code for binaries and other problems"))
944     parser.add_argument("--dscanner", action="store_true", default=False,
945                         help=_("Setup an emulator, install the APK on it and perform a Drozer scan"))
946     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
947                         help=_("Don't create a source tarball, useful when testing a build"))
948     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
949                         help=_("Don't refresh the repository, useful when testing a build with no internet connection"))
950     parser.add_argument("-f", "--force", action="store_true", default=False,
951                         help=_("Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode."))
952     parser.add_argument("-a", "--all", action="store_true", default=False,
953                         help=_("Build all applications available"))
954     parser.add_argument("-w", "--wiki", default=False, action="store_true",
955                         help=_("Update the wiki"))
956     metadata.add_metadata_arguments(parser)
957     options = parser.parse_args()
958     metadata.warnings_action = options.W
959
960     # Force --stop with --on-server to get correct exit code
961     if options.onserver:
962         options.stop = True
963
964     if options.force and not options.test:
965         parser.error("option %s: Force is only allowed in test mode" % "force")
966
967     return options, parser
968
969
970 options = None
971 config = None
972 buildserverid = None
973 fdroidserverid = None
974 start_timestamp = time.gmtime()
975 timeout_event = threading.Event()
976
977
978 def main():
979
980     global options, config, buildserverid, fdroidserverid
981
982     options, parser = parse_commandline()
983
984     # The defaults for .fdroid.* metadata that is included in a git repo are
985     # different than for the standard metadata/ layout because expectations
986     # are different.  In this case, the most common user will be the app
987     # developer working on the latest update of the app on their own machine.
988     local_metadata_files = common.get_local_metadata_files()
989     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
990         config = dict(common.default_config)
991         # `fdroid build` should build only the latest version by default since
992         # most of the time the user will be building the most recent update
993         if not options.all:
994             options.latest = True
995     elif len(local_metadata_files) > 1:
996         raise FDroidException("Only one local metadata file allowed! Found: "
997                               + " ".join(local_metadata_files))
998     else:
999         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1000             raise FDroidException("No app metadata found, nothing to process!")
1001         if not options.appid and not options.all:
1002             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1003
1004     config = common.read_config(options)
1005
1006     if config['build_server_always']:
1007         options.server = True
1008     if options.reset_server and not options.server:
1009         parser.error("option %s: Using --reset-server without --server makes no sense" % "reset-server")
1010
1011     log_dir = 'logs'
1012     if not os.path.isdir(log_dir):
1013         logging.info("Creating log directory")
1014         os.makedirs(log_dir)
1015
1016     tmp_dir = 'tmp'
1017     if not os.path.isdir(tmp_dir):
1018         logging.info("Creating temporary directory")
1019         os.makedirs(tmp_dir)
1020
1021     if options.test:
1022         output_dir = tmp_dir
1023     else:
1024         output_dir = 'unsigned'
1025         if not os.path.isdir(output_dir):
1026             logging.info("Creating output directory")
1027             os.makedirs(output_dir)
1028
1029     if config['archive_older'] != 0:
1030         also_check_dir = 'archive'
1031     else:
1032         also_check_dir = None
1033
1034     repo_dir = 'repo'
1035
1036     build_dir = 'build'
1037     if not os.path.isdir(build_dir):
1038         logging.info("Creating build directory")
1039         os.makedirs(build_dir)
1040     srclib_dir = os.path.join(build_dir, 'srclib')
1041     extlib_dir = os.path.join(build_dir, 'extlib')
1042
1043     # Read all app and srclib metadata
1044     pkgs = common.read_pkg_args(options.appid, True)
1045     allapps = metadata.read_metadata(not options.onserver, pkgs, options.refresh, sort_by_time=True)
1046     apps = common.read_app_args(options.appid, allapps, True)
1047
1048     for appid, app in list(apps.items()):
1049         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1050             del apps[appid]
1051
1052     if not apps:
1053         raise FDroidException("No apps to process.")
1054
1055     # make sure enough open files are allowed to process everything
1056     soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
1057     if len(apps) > soft:
1058         try:
1059             soft = len(apps) * 2
1060             if soft > hard:
1061                 soft = hard
1062             resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
1063             logging.debug(_('Set open file limit to {integer}')
1064                           .format(integer=soft))
1065         except (OSError, ValueError) as e:
1066             logging.warning(_('Setting open file limit failed: ') + str(e))
1067
1068     if options.latest:
1069         for app in apps.values():
1070             for build in reversed(app.builds):
1071                 if build.disable and not options.force:
1072                     continue
1073                 app.builds = [build]
1074                 break
1075
1076     if options.wiki:
1077         import mwclient
1078         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1079                              path=config['wiki_path'])
1080         site.login(config['wiki_user'], config['wiki_password'])
1081
1082     # Build applications...
1083     failed_apps = {}
1084     build_succeeded = []
1085     # Only build for 36 hours, then stop gracefully.
1086     endtime = time.time() + 36 * 60 * 60
1087     max_build_time_reached = False
1088     for appid, app in apps.items():
1089
1090         first = True
1091
1092         for build in app.builds:
1093             if time.time() > endtime:
1094                 max_build_time_reached = True
1095                 break
1096
1097             # Enable watchdog timer (2 hours by default).
1098             if build.timeout is None:
1099                 timeout = 7200
1100             else:
1101                 timeout = int(build.timeout)
1102             if options.server and timeout > 0:
1103                 logging.debug(_('Setting {0} sec timeout for this build').format(timeout))
1104                 timer = threading.Timer(timeout, force_halt_build, [timeout])
1105                 timeout_event.clear()
1106                 timer.start()
1107             else:
1108                 timer = None
1109
1110             wikilog = None
1111             build_starttime = common.get_wiki_timestamp()
1112             tools_version_log = ''
1113             if not options.onserver:
1114                 tools_version_log = common.get_android_tools_version_log(build.ndk_path())
1115             try:
1116
1117                 # For the first build of a particular app, we need to set up
1118                 # the source repo. We can reuse it on subsequent builds, if
1119                 # there are any.
1120                 if first:
1121                     vcs, build_dir = common.setup_vcs(app)
1122                     first = False
1123
1124                 logging.info("Using %s" % vcs.clientversion())
1125                 logging.debug("Checking " + build.versionName)
1126                 if trybuild(app, build, build_dir, output_dir, log_dir,
1127                             also_check_dir, srclib_dir, extlib_dir,
1128                             tmp_dir, repo_dir, vcs, options.test,
1129                             options.server, options.force,
1130                             options.onserver, options.refresh):
1131                     toolslog = os.path.join(log_dir,
1132                                             common.get_toolsversion_logname(app, build))
1133                     if not options.onserver and os.path.exists(toolslog):
1134                         with open(toolslog, 'r') as f:
1135                             tools_version_log = ''.join(f.readlines())
1136                         os.remove(toolslog)
1137
1138                     if app.Binaries is not None:
1139                         # This is an app where we build from source, and
1140                         # verify the apk contents against a developer's
1141                         # binary. We get that binary now, and save it
1142                         # alongside our built one in the 'unsigend'
1143                         # directory.
1144                         url = app.Binaries
1145                         url = url.replace('%v', build.versionName)
1146                         url = url.replace('%c', str(build.versionCode))
1147                         logging.info("...retrieving " + url)
1148                         of = re.sub(r'.apk$', '.binary.apk', common.get_release_filename(app, build))
1149                         of = os.path.join(output_dir, of)
1150                         try:
1151                             net.download_file(url, local_filename=of)
1152                         except requests.exceptions.HTTPError as e:
1153                             raise FDroidException(
1154                                 'Downloading Binaries from %s failed. %s' % (url, e))
1155
1156                         # Now we check weather the build can be verified to
1157                         # match the supplied binary or not. Should the
1158                         # comparison fail, we mark this build as a failure
1159                         # and remove everything from the unsigend folder.
1160                         with tempfile.TemporaryDirectory() as tmpdir:
1161                             unsigned_apk = \
1162                                 common.get_release_filename(app, build)
1163                             unsigned_apk = \
1164                                 os.path.join(output_dir, unsigned_apk)
1165                             compare_result = \
1166                                 common.verify_apks(of, unsigned_apk, tmpdir)
1167                             if compare_result:
1168                                 logging.debug('removing %s', unsigned_apk)
1169                                 os.remove(unsigned_apk)
1170                                 logging.debug('removing %s', of)
1171                                 os.remove(of)
1172                                 compare_result = compare_result.split('\n')
1173                                 line_count = len(compare_result)
1174                                 compare_result = compare_result[:299]
1175                                 if line_count > len(compare_result):
1176                                     line_difference = \
1177                                         line_count - len(compare_result)
1178                                     compare_result.append('%d more lines ...' %
1179                                                           line_difference)
1180                                 compare_result = '\n'.join(compare_result)
1181                                 raise FDroidException('compared built binary '
1182                                                       'to supplied reference '
1183                                                       'binary but failed',
1184                                                       compare_result)
1185                             else:
1186                                 logging.info('compared built binary to '
1187                                              'supplied reference binary '
1188                                              'successfully')
1189
1190                     build_succeeded.append(app)
1191                     wikilog = "Build succeeded"
1192
1193             except VCSException as vcse:
1194                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1195                 logging.error("VCS error while building app %s: %s" % (
1196                     appid, reason))
1197                 if options.stop:
1198                     logging.debug("Error encoutered, stopping by user request.")
1199                     sys.exit(1)
1200                 failed_apps[appid] = vcse
1201                 wikilog = str(vcse)
1202             except FDroidException as e:
1203                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1204                     f.write('\n\n============================================================\n')
1205                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1206                             (build.versionCode, build.versionName, build.commit))
1207                     f.write('Build completed at '
1208                             + common.get_wiki_timestamp() + '\n')
1209                     f.write('\n' + tools_version_log + '\n')
1210                     f.write(str(e))
1211                 logging.error("Could not build app %s: %s" % (appid, e))
1212                 if options.stop:
1213                     logging.debug("Error encoutered, stopping by user request.")
1214                     sys.exit(1)
1215                 failed_apps[appid] = e
1216                 wikilog = e.get_wikitext()
1217             except Exception as e:
1218                 logging.error("Could not build app %s due to unknown error: %s" % (
1219                     appid, traceback.format_exc()))
1220                 if options.stop:
1221                     logging.debug("Error encoutered, stopping by user request.")
1222                     sys.exit(1)
1223                 failed_apps[appid] = e
1224                 wikilog = str(e)
1225
1226             if options.wiki and wikilog:
1227                 try:
1228                     # Write a page with the last build log for this version code
1229                     lastbuildpage = appid + '/lastbuild_' + build.versionCode
1230                     newpage = site.Pages[lastbuildpage]
1231                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1232                         fdroidserverid = fp.read().rstrip()
1233                     txt = "* build session started at " + common.get_wiki_timestamp(start_timestamp) + '\n' \
1234                           + "* this build started at " + build_starttime + '\n' \
1235                           + "* this build completed at " + common.get_wiki_timestamp() + '\n' \
1236                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1237                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1238                     if buildserverid:
1239                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1240                                + buildserverid + ' ' + buildserverid + ']\n\n'
1241                     txt += tools_version_log + '\n\n'
1242                     txt += '== Build Log ==\n\n' + wikilog
1243                     newpage.save(txt, summary='Build log')
1244                     # Redirect from /lastbuild to the most recent build log
1245                     newpage = site.Pages[appid + '/lastbuild']
1246                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1247                 except Exception as e:
1248                     logging.error("Error while attempting to publish build log: %s" % e)
1249
1250             if timer:
1251                 timer.cancel()  # kill the watchdog timer
1252
1253         if max_build_time_reached:
1254             logging.info("Stopping after global build timeout...")
1255             break
1256
1257     for app in build_succeeded:
1258         logging.info("success: %s" % (app.id))
1259
1260     if not options.verbose:
1261         for fa in failed_apps:
1262             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1263
1264     # perform a drozer scan of all successful builds
1265     if options.dscanner and build_succeeded:
1266         from .dscanner import DockerDriver
1267
1268         docker = DockerDriver()
1269
1270         try:
1271             for app in build_succeeded:
1272
1273                 logging.info("Need to sign the app before we can install it.")
1274                 subprocess.call("fdroid publish {0}".format(app.id))
1275
1276                 apk_path = None
1277
1278                 for f in os.listdir(repo_dir):
1279                     if f.endswith('.apk') and f.startswith(app.id):
1280                         apk_path = os.path.join(repo_dir, f)
1281                         break
1282
1283                 if not apk_path:
1284                     raise Exception("No signed APK found at path: {path}".format(path=apk_path))
1285
1286                 if not os.path.isdir(repo_dir):
1287                     logging.critical("directory does not exists '{path}'".format(path=repo_dir))
1288                     sys.exit(1)
1289
1290                 logging.info("Performing Drozer scan on {0}.".format(app))
1291                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1292         except Exception as e:
1293             logging.error(str(e))
1294             logging.error("An exception happened. Making sure to clean up")
1295         else:
1296             logging.info("Scan succeeded.")
1297
1298         logging.info("Cleaning up after ourselves.")
1299         docker.clean()
1300
1301     logging.info(_("Finished"))
1302     if len(build_succeeded) > 0:
1303         logging.info(ngettext("{} build succeeded",
1304                               "{} builds succeeded", len(build_succeeded)).format(len(build_succeeded)))
1305     if len(failed_apps) > 0:
1306         logging.info(ngettext("{} build failed",
1307                               "{} builds failed", len(failed_apps)).format(len(failed_apps)))
1308
1309     if options.wiki:
1310         wiki_page_path = 'build_' + time.strftime('%s', start_timestamp)
1311         newpage = site.Pages[wiki_page_path]
1312         txt = ''
1313         txt += "* command line: <code>%s</code>\n" % ' '.join(sys.argv)
1314         txt += "* started at %s\n" % common.get_wiki_timestamp(start_timestamp)
1315         txt += "* completed at %s\n" % common.get_wiki_timestamp()
1316         if buildserverid:
1317             txt += ('* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/{id} {id}]\n'
1318                     .format(id=buildserverid))
1319         if fdroidserverid:
1320             txt += ('* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/{id} {id}]\n'
1321                     .format(id=fdroidserverid))
1322         if os.cpu_count():
1323             txt += "* host processors: %d\n" % os.cpu_count()
1324         if os.path.isfile('/proc/meminfo') and os.access('/proc/meminfo', os.R_OK):
1325             with open('/proc/meminfo') as fp:
1326                 for line in fp:
1327                     m = re.search(r'MemTotal:\s*([0-9].*)', line)
1328                     if m:
1329                         txt += "* host RAM: %s\n" % m.group(1)
1330                         break
1331         txt += "* successful builds: %d\n" % len(build_succeeded)
1332         txt += "* failed builds: %d\n" % len(failed_apps)
1333         txt += "\n\n"
1334         newpage.save(txt, summary='Run log')
1335         newpage = site.Pages['build']
1336         newpage.save('#REDIRECT [[' + wiki_page_path + ']]', summary='Update redirect')
1337
1338     # hack to ensure this exits, even is some threads are still running
1339     sys.stdout.flush()
1340     sys.stderr.flush()
1341     os._exit(0)
1342
1343
1344 if __name__ == "__main__":
1345     main()