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