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