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