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