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