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