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