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