chiark / gitweb /
Move scan_source into scanner.py
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # build.py - part of the FDroid server tools
5 # Copyright (C) 2010-2014, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU Affero General Public License for more details.
17 #
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 import sys
22 import os
23 import shutil
24 import glob
25 import subprocess
26 import re
27 import tarfile
28 import traceback
29 import time
30 import json
31 from ConfigParser import ConfigParser
32 from optparse import OptionParser, OptionError
33 from distutils.version import LooseVersion
34 import logging
35
36 import common
37 import metadata
38 import scanner
39 from common import FDroidException, BuildException, VCSException, FDroidPopen, SdkToolsPopen
40
41 try:
42     import paramiko
43 except ImportError:
44     pass
45
46
47 def get_builder_vm_id():
48     vd = os.path.join('builder', '.vagrant')
49     if os.path.isdir(vd):
50         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
51         with open(os.path.join(vd, 'machines', 'default',
52                                'virtualbox', 'id')) as vf:
53             id = vf.read()
54         return id
55     else:
56         # Vagrant 1.0 - it's a json file...
57         with open(os.path.join('builder', '.vagrant')) as vf:
58             v = json.load(vf)
59         return v['active']['default']
60
61
62 def got_valid_builder_vm():
63     """Returns True if we have a valid-looking builder vm
64     """
65     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
66         return False
67     vd = os.path.join('builder', '.vagrant')
68     if not os.path.exists(vd):
69         return False
70     if not os.path.isdir(vd):
71         # Vagrant 1.0 - if the directory is there, it's valid...
72         return True
73     # Vagrant 1.2 - the directory can exist, but the id can be missing...
74     if not os.path.exists(os.path.join(vd, 'machines', 'default',
75                                        'virtualbox', 'id')):
76         return False
77     return True
78
79
80 def vagrant(params, cwd=None, printout=False):
81     """Run a vagrant command.
82
83     :param: list of parameters to pass to vagrant
84     :cwd: directory to run in, or None for current directory
85     :returns: (ret, out) where ret is the return code, and out
86                is the stdout (and stderr) from vagrant
87     """
88     p = FDroidPopen(['vagrant'] + params, cwd=cwd)
89     return (p.returncode, p.output)
90
91
92 def get_vagrant_sshinfo():
93     """Get ssh connection info for a vagrant VM
94
95     :returns: A dictionary containing 'hostname', 'port', 'user'
96         and 'idfile'
97     """
98     if subprocess.call('vagrant ssh-config >sshconfig',
99                        cwd='builder', shell=True) != 0:
100         raise BuildException("Error getting ssh config")
101     vagranthost = 'default'  # Host in ssh config file
102     sshconfig = paramiko.SSHConfig()
103     sshf = open('builder/sshconfig', 'r')
104     sshconfig.parse(sshf)
105     sshf.close()
106     sshconfig = sshconfig.lookup(vagranthost)
107     idfile = sshconfig['identityfile']
108     if isinstance(idfile, list):
109         idfile = idfile[0]
110     elif idfile.startswith('"') and idfile.endswith('"'):
111         idfile = idfile[1:-1]
112     return {'hostname': sshconfig['hostname'],
113             'port': int(sshconfig['port']),
114             'user': sshconfig['user'],
115             'idfile': idfile}
116
117
118 def get_clean_vm(reset=False):
119     """Get a clean VM ready to do a buildserver build.
120
121     This might involve creating and starting a new virtual machine from
122     scratch, or it might be as simple (unless overridden by the reset
123     parameter) as re-using a snapshot created previously.
124
125     A BuildException will be raised if anything goes wrong.
126
127     :reset: True to force creating from scratch.
128     :returns: A dictionary containing 'hostname', 'port', 'user'
129         and 'idfile'
130     """
131     # Reset existing builder machine to a clean state if possible.
132     vm_ok = False
133     if not reset:
134         logging.info("Checking for valid existing build server")
135
136         if got_valid_builder_vm():
137             logging.info("...VM is present")
138             p = FDroidPopen(['VBoxManage', 'snapshot',
139                              get_builder_vm_id(), 'list',
140                              '--details'], cwd='builder')
141             if 'fdroidclean' in p.output:
142                 logging.info("...snapshot exists - resetting build server to "
143                              "clean state")
144                 retcode, output = vagrant(['status'], cwd='builder')
145
146                 if 'running' in output:
147                     logging.info("...suspending")
148                     vagrant(['suspend'], cwd='builder')
149                     logging.info("...waiting a sec...")
150                     time.sleep(10)
151                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
152                                  'restore', 'fdroidclean'],
153                                 cwd='builder')
154
155                 if p.returncode == 0:
156                     logging.info("...reset to snapshot - server is valid")
157                     retcode, output = vagrant(['up'], cwd='builder')
158                     if retcode != 0:
159                         raise BuildException("Failed to start build server")
160                     logging.info("...waiting a sec...")
161                     time.sleep(10)
162                     sshinfo = get_vagrant_sshinfo()
163                     vm_ok = True
164                 else:
165                     logging.info("...failed to reset to snapshot")
166             else:
167                 logging.info("...snapshot doesn't exist - "
168                              "VBoxManage snapshot list:\n" + p.output)
169
170     # If we can't use the existing machine for any reason, make a
171     # new one from scratch.
172     if not vm_ok:
173         if os.path.exists('builder'):
174             logging.info("Removing broken/incomplete/unwanted build server")
175             vagrant(['destroy', '-f'], cwd='builder')
176             shutil.rmtree('builder')
177         os.mkdir('builder')
178
179         p = subprocess.Popen(['vagrant', '--version'],
180                              stdout=subprocess.PIPE)
181         vver = p.communicate()[0]
182         if vver.startswith('Vagrant version 1.2'):
183             with open('builder/Vagrantfile', 'w') as vf:
184                 vf.write('Vagrant.configure("2") do |config|\n')
185                 vf.write('config.vm.box = "buildserver"\n')
186                 vf.write('end\n')
187         else:
188             with open('builder/Vagrantfile', 'w') as vf:
189                 vf.write('Vagrant::Config.run do |config|\n')
190                 vf.write('config.vm.box = "buildserver"\n')
191                 vf.write('end\n')
192
193         logging.info("Starting new build server")
194         retcode, _ = vagrant(['up'], cwd='builder')
195         if retcode != 0:
196             raise BuildException("Failed to start build server")
197
198         # Open SSH connection to make sure it's working and ready...
199         logging.info("Connecting to virtual machine...")
200         sshinfo = get_vagrant_sshinfo()
201         sshs = paramiko.SSHClient()
202         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
203         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
204                      port=sshinfo['port'], timeout=300,
205                      look_for_keys=False,
206                      key_filename=sshinfo['idfile'])
207         sshs.close()
208
209         logging.info("Saving clean state of new build server")
210         retcode, _ = vagrant(['suspend'], cwd='builder')
211         if retcode != 0:
212             raise BuildException("Failed to suspend build server")
213         logging.info("...waiting a sec...")
214         time.sleep(10)
215         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
216                          'take', 'fdroidclean'],
217                         cwd='builder')
218         if p.returncode != 0:
219             raise BuildException("Failed to take snapshot")
220         logging.info("...waiting a sec...")
221         time.sleep(10)
222         logging.info("Restarting new build server")
223         retcode, _ = vagrant(['up'], cwd='builder')
224         if retcode != 0:
225             raise BuildException("Failed to start build server")
226         logging.info("...waiting a sec...")
227         time.sleep(10)
228         # Make sure it worked...
229         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
230                          'list', '--details'],
231                         cwd='builder')
232         if 'fdroidclean' not in p.output:
233             raise BuildException("Failed to take snapshot.")
234
235     return sshinfo
236
237
238 def release_vm():
239     """Release the VM previously started with get_clean_vm().
240
241     This should always be called.
242     """
243     logging.info("Suspending build server")
244     subprocess.call(['vagrant', 'suspend'], cwd='builder')
245
246
247 # Note that 'force' here also implies test mode.
248 def build_server(app, thisbuild, vcs, build_dir, output_dir, force):
249     """Do a build on the build server."""
250
251     try:
252         paramiko
253     except NameError:
254         raise BuildException("Paramiko is required to use the buildserver")
255     if options.verbose:
256         logging.getLogger("paramiko").setLevel(logging.DEBUG)
257     else:
258         logging.getLogger("paramiko").setLevel(logging.WARN)
259
260     sshinfo = get_clean_vm()
261
262     try:
263
264         # Open SSH connection...
265         logging.info("Connecting to virtual machine...")
266         sshs = paramiko.SSHClient()
267         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
268         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
269                      port=sshinfo['port'], timeout=300,
270                      look_for_keys=False, key_filename=sshinfo['idfile'])
271
272         homedir = '/home/' + sshinfo['user']
273
274         # Get an SFTP connection...
275         ftp = sshs.open_sftp()
276         ftp.get_channel().settimeout(15)
277
278         # Put all the necessary files in place...
279         ftp.chdir(homedir)
280
281         # Helper to copy the contents of a directory to the server...
282         def send_dir(path):
283             root = os.path.dirname(path)
284             main = os.path.basename(path)
285             ftp.mkdir(main)
286             for r, d, f in os.walk(path):
287                 rr = os.path.relpath(r, root)
288                 ftp.chdir(rr)
289                 for dd in d:
290                     ftp.mkdir(dd)
291                 for ff in f:
292                     lfile = os.path.join(root, rr, ff)
293                     if not os.path.islink(lfile):
294                         ftp.put(lfile, ff)
295                         ftp.chmod(ff, os.stat(lfile).st_mode)
296                 for i in range(len(rr.split('/'))):
297                     ftp.chdir('..')
298             ftp.chdir('..')
299
300         logging.info("Preparing server for build...")
301         serverpath = os.path.abspath(os.path.dirname(__file__))
302         ftp.put(os.path.join(serverpath, 'build.py'), 'build.py')
303         ftp.put(os.path.join(serverpath, 'common.py'), 'common.py')
304         ftp.put(os.path.join(serverpath, 'metadata.py'), 'metadata.py')
305         ftp.put(os.path.join(serverpath, '..', 'buildserver',
306                              'config.buildserver.py'), 'config.py')
307         ftp.chmod('config.py', 0o600)
308
309         # Copy over the ID (head commit hash) of the fdroidserver in use...
310         subprocess.call('git rev-parse HEAD >' +
311                         os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
312                         shell=True, cwd=serverpath)
313         ftp.put('tmp/fdroidserverid', 'fdroidserverid')
314
315         # Copy the metadata - just the file for this app...
316         ftp.mkdir('metadata')
317         ftp.mkdir('srclibs')
318         ftp.chdir('metadata')
319         ftp.put(os.path.join('metadata', app['id'] + '.txt'),
320                 app['id'] + '.txt')
321         # And patches if there are any...
322         if os.path.exists(os.path.join('metadata', app['id'])):
323             send_dir(os.path.join('metadata', app['id']))
324
325         ftp.chdir(homedir)
326         # Create the build directory...
327         ftp.mkdir('build')
328         ftp.chdir('build')
329         ftp.mkdir('extlib')
330         ftp.mkdir('srclib')
331         # Copy any extlibs that are required...
332         if thisbuild['extlibs']:
333             ftp.chdir(homedir + '/build/extlib')
334             for lib in thisbuild['extlibs']:
335                 lib = lib.strip()
336                 libsrc = os.path.join('build/extlib', lib)
337                 if not os.path.exists(libsrc):
338                     raise BuildException("Missing extlib {0}".format(libsrc))
339                 lp = lib.split('/')
340                 for d in lp[:-1]:
341                     if d not in ftp.listdir():
342                         ftp.mkdir(d)
343                     ftp.chdir(d)
344                 ftp.put(libsrc, lp[-1])
345                 for _ in lp[:-1]:
346                     ftp.chdir('..')
347         # Copy any srclibs that are required...
348         srclibpaths = []
349         if thisbuild['srclibs']:
350             for lib in thisbuild['srclibs']:
351                 srclibpaths.append(
352                     common.getsrclib(lib, 'build/srclib', basepath=True, prepare=False))
353
354         # If one was used for the main source, add that too.
355         basesrclib = vcs.getsrclib()
356         if basesrclib:
357             srclibpaths.append(basesrclib)
358         for name, number, lib in srclibpaths:
359             logging.info("Sending srclib '%s'" % lib)
360             ftp.chdir(homedir + '/build/srclib')
361             if not os.path.exists(lib):
362                 raise BuildException("Missing srclib directory '" + lib + "'")
363             fv = '.fdroidvcs-' + name
364             ftp.put(os.path.join('build/srclib', fv), fv)
365             send_dir(lib)
366             # Copy the metadata file too...
367             ftp.chdir(homedir + '/srclibs')
368             ftp.put(os.path.join('srclibs', name + '.txt'),
369                     name + '.txt')
370         # Copy the main app source code
371         # (no need if it's a srclib)
372         if (not basesrclib) and os.path.exists(build_dir):
373             ftp.chdir(homedir + '/build')
374             fv = '.fdroidvcs-' + app['id']
375             ftp.put(os.path.join('build', fv), fv)
376             send_dir(build_dir)
377
378         # Execute the build script...
379         logging.info("Starting build...")
380         chan = sshs.get_transport().open_session()
381         chan.get_pty()
382         cmdline = 'python build.py --on-server'
383         if force:
384             cmdline += ' --force --test'
385         if options.verbose:
386             cmdline += ' --verbose'
387         cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
388         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
389         output = ''
390         while not chan.exit_status_ready():
391             while chan.recv_ready():
392                 output += chan.recv(1024)
393             time.sleep(0.1)
394         logging.info("...getting exit status")
395         returncode = chan.recv_exit_status()
396         while True:
397             get = chan.recv(1024)
398             if len(get) == 0:
399                 break
400             output += get
401         if returncode != 0:
402             raise BuildException(
403                 "Build.py failed on server for {0}:{1}".format(
404                     app['id'], thisbuild['version']), output)
405
406         # Retrieve the built files...
407         logging.info("Retrieving build output...")
408         if force:
409             ftp.chdir(homedir + '/tmp')
410         else:
411             ftp.chdir(homedir + '/unsigned')
412         apkfile = common.getapkname(app, thisbuild)
413         tarball = common.getsrcname(app, thisbuild)
414         try:
415             ftp.get(apkfile, os.path.join(output_dir, apkfile))
416             if not options.notarball:
417                 ftp.get(tarball, os.path.join(output_dir, tarball))
418         except:
419             raise BuildException(
420                 "Build failed for %s:%s - missing output files".format(
421                     app['id'], thisbuild['version']), output)
422         ftp.close()
423
424     finally:
425
426         # Suspend the build server.
427         release_vm()
428
429
430 def adapt_gradle(build_dir):
431     filename = 'build.gradle'
432     for root, dirs, files in os.walk(build_dir):
433         for filename in files:
434             if not filename.endswith('.gradle'):
435                 continue
436             path = os.path.join(root, filename)
437             if not os.path.isfile(path):
438                 continue
439             logging.debug("Adapting %s at %s" % (filename, path))
440             common.regsub_file(r"""(\s*)buildToolsVersion([\s=]+)['"].*""",
441                                r"""\1buildToolsVersion\2'%s'""" % config['build_tools'],
442                                path)
443
444
445 def capitalize_intact(string):
446     """Like str.capitalize(), but leave the rest of the string intact without
447     switching it to lowercase."""
448     if len(string) == 0:
449         return string
450     if len(string) == 1:
451         return string.upper()
452     return string[0].upper() + string[1:]
453
454
455 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
456     """Do a build locally."""
457
458     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
459         if not thisbuild['ndk_path']:
460             logging.critical("Android NDK version '%s' could not be found!" % thisbuild['ndk'])
461             logging.critical("Configured versions:")
462             for k, v in config['ndk_paths'].iteritems():
463                 if k.endswith("_orig"):
464                     continue
465                 logging.critical("  %s: %s" % (k, v))
466             sys.exit(3)
467         elif not os.path.isdir(thisbuild['ndk_path']):
468             logging.critical("Android NDK '%s' is not a directory!" % thisbuild['ndk_path'])
469             sys.exit(3)
470
471     # Set up environment vars that depend on each build
472     for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
473         common.env[n] = thisbuild['ndk_path']
474
475     common.reset_env_path()
476     # Set up the current NDK to the PATH
477     common.add_to_env_path(thisbuild['ndk_path'])
478
479     # Prepare the source code...
480     root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
481                                                   build_dir, srclib_dir,
482                                                   extlib_dir, onserver, refresh)
483
484     # We need to clean via the build tool in case the binary dirs are
485     # different from the default ones
486     p = None
487     gradletasks = []
488     if thisbuild['type'] == 'maven':
489         logging.info("Cleaning Maven project...")
490         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
491
492         if '@' in thisbuild['maven']:
493             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
494             maven_dir = os.path.normpath(maven_dir)
495         else:
496             maven_dir = root_dir
497
498         p = FDroidPopen(cmd, cwd=maven_dir)
499
500     elif thisbuild['type'] == 'gradle':
501
502         logging.info("Cleaning Gradle project...")
503
504         if thisbuild['preassemble']:
505             gradletasks += thisbuild['preassemble']
506
507         flavours = thisbuild['gradle']
508         if flavours == ['yes']:
509             flavours = []
510
511         flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
512
513         gradletasks += ['assemble' + flavours_cmd + 'Release']
514
515         adapt_gradle(build_dir)
516         for name, number, libpath in srclibpaths:
517             adapt_gradle(libpath)
518
519         cmd = [config['gradle']]
520         if thisbuild['gradleprops']:
521             cmd += ['-P'+kv for kv in thisbuild['gradleprops']]
522
523         for task in gradletasks:
524             parts = task.split(':')
525             parts[-1] = 'clean' + capitalize_intact(parts[-1])
526             cmd += [':'.join(parts)]
527
528         cmd += ['clean']
529
530         p = FDroidPopen(cmd, cwd=root_dir)
531
532     elif thisbuild['type'] == 'kivy':
533         pass
534
535     elif thisbuild['type'] == 'ant':
536         logging.info("Cleaning Ant project...")
537         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
538
539     if p is not None and p.returncode != 0:
540         raise BuildException("Error cleaning %s:%s" %
541                              (app['id'], thisbuild['version']), p.output)
542
543     for root, dirs, files in os.walk(build_dir):
544         # Don't remove possibly necessary 'gradle' dirs if 'gradlew' is not there
545         if 'gradlew' in files:
546             logging.debug("Getting rid of Gradle wrapper stuff in %s" % root)
547             os.remove(os.path.join(root, 'gradlew'))
548             if 'gradlew.bat' in files:
549                 os.remove(os.path.join(root, 'gradlew.bat'))
550             if 'gradle' in dirs:
551                 shutil.rmtree(os.path.join(root, 'gradle'))
552
553     if options.skipscan:
554         if thisbuild['scandelete']:
555             raise BuildException("Refusing to skip source scan since scandelete is present")
556     else:
557         # Scan before building...
558         logging.info("Scanning source for common problems...")
559         count = scanner.scan_source(build_dir, root_dir, thisbuild)
560         if count > 0:
561             if force:
562                 logging.warn('Scanner found %d problems' % count)
563             else:
564                 raise BuildException("Can't build due to %d errors while scanning" % count)
565
566     if not options.notarball:
567         # Build the source tarball right before we build the release...
568         logging.info("Creating source tarball...")
569         tarname = common.getsrcname(app, thisbuild)
570         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
571
572         def tarexc(f):
573             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
574         tarball.add(build_dir, tarname, exclude=tarexc)
575         tarball.close()
576
577     # Run a build command if one is required...
578     if thisbuild['build']:
579         logging.info("Running 'build' commands in %s" % root_dir)
580         cmd = common.replace_config_vars(thisbuild['build'], thisbuild)
581
582         # Substitute source library paths into commands...
583         for name, number, libpath in srclibpaths:
584             libpath = os.path.relpath(libpath, root_dir)
585             cmd = cmd.replace('$$' + name + '$$', libpath)
586
587         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
588
589         if p.returncode != 0:
590             raise BuildException("Error running build command for %s:%s" %
591                                  (app['id'], thisbuild['version']), p.output)
592
593     # Build native stuff if required...
594     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
595         logging.info("Building the native code")
596         jni_components = thisbuild['buildjni']
597
598         if jni_components == ['yes']:
599             jni_components = ['']
600         cmd = [os.path.join(thisbuild['ndk_path'], "ndk-build"), "-j1"]
601         for d in jni_components:
602             if d:
603                 logging.info("Building native code in '%s'" % d)
604             else:
605                 logging.info("Building native code in the main project")
606             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
607             if os.path.exists(manifest):
608                 # Read and write the whole AM.xml to fix newlines and avoid
609                 # the ndk r8c or later 'wordlist' errors. The outcome of this
610                 # under gnu/linux is the same as when using tools like
611                 # dos2unix, but the native python way is faster and will
612                 # work in non-unix systems.
613                 manifest_text = open(manifest, 'U').read()
614                 open(manifest, 'w').write(manifest_text)
615                 # In case the AM.xml read was big, free the memory
616                 del manifest_text
617             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
618             if p.returncode != 0:
619                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.output)
620
621     p = None
622     # Build the release...
623     if thisbuild['type'] == 'maven':
624         logging.info("Building Maven project...")
625
626         if '@' in thisbuild['maven']:
627             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
628         else:
629             maven_dir = root_dir
630
631         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
632                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
633                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
634                   'package']
635         if thisbuild['target']:
636             target = thisbuild["target"].split('-')[1]
637             common.regsub_file(r'<platform>[0-9]*</platform>',
638                                r'<platform>%s</platform>' % target,
639                                os.path.join(root_dir, 'pom.xml'))
640             if '@' in thisbuild['maven']:
641                 common.regsub_file(r'<platform>[0-9]*</platform>',
642                                    r'<platform>%s</platform>' % target,
643                                    os.path.join(maven_dir, 'pom.xml'))
644
645         p = FDroidPopen(mvncmd, cwd=maven_dir)
646
647         bindir = os.path.join(root_dir, 'target')
648
649     elif thisbuild['type'] == 'kivy':
650         logging.info("Building Kivy project...")
651
652         spec = os.path.join(root_dir, 'buildozer.spec')
653         if not os.path.exists(spec):
654             raise BuildException("Expected to find buildozer-compatible spec at {0}"
655                                  .format(spec))
656
657         defaults = {'orientation': 'landscape', 'icon': '',
658                     'permissions': '', 'android.api': "18"}
659         bconfig = ConfigParser(defaults, allow_no_value=True)
660         bconfig.read(spec)
661
662         distdir = 'python-for-android/dist/fdroid'
663         if os.path.exists(distdir):
664             shutil.rmtree(distdir)
665
666         modules = bconfig.get('app', 'requirements').split(',')
667
668         cmd = 'ANDROIDSDK=' + config['sdk_path']
669         cmd += ' ANDROIDNDK=' + thisbuild['ndk_path']
670         cmd += ' ANDROIDNDKVER=' + thisbuild['ndk']
671         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
672         cmd += ' VIRTUALENV=virtualenv'
673         cmd += ' ./distribute.sh'
674         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
675         cmd += ' -d fdroid'
676         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
677         if p.returncode != 0:
678             raise BuildException("Distribute build failed")
679
680         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
681         if cid != app['id']:
682             raise BuildException("Package ID mismatch between metadata and spec")
683
684         orientation = bconfig.get('app', 'orientation', 'landscape')
685         if orientation == 'all':
686             orientation = 'sensor'
687
688         cmd = ['./build.py'
689                '--dir', root_dir,
690                '--name', bconfig.get('app', 'title'),
691                '--package', app['id'],
692                '--version', bconfig.get('app', 'version'),
693                '--orientation', orientation
694                ]
695
696         perms = bconfig.get('app', 'permissions')
697         for perm in perms.split(','):
698             cmd.extend(['--permission', perm])
699
700         if config.get('app', 'fullscreen') == 0:
701             cmd.append('--window')
702
703         icon = bconfig.get('app', 'icon.filename')
704         if icon:
705             cmd.extend(['--icon', os.path.join(root_dir, icon)])
706
707         cmd.append('release')
708         p = FDroidPopen(cmd, cwd=distdir)
709
710     elif thisbuild['type'] == 'gradle':
711         logging.info("Building Gradle project...")
712
713         # Avoid having to use lintOptions.abortOnError false
714         if thisbuild['gradlepluginver'] >= LooseVersion('0.7'):
715             with open(os.path.join(root_dir, 'build.gradle'), "a") as f:
716                 f.write("\nandroid { lintOptions { checkReleaseBuilds false } }\n")
717
718         cmd = [config['gradle']]
719         if thisbuild['gradleprops']:
720             cmd += ['-P'+kv for kv in thisbuild['gradleprops']]
721
722         cmd += gradletasks
723
724         p = FDroidPopen(cmd, cwd=root_dir)
725
726     elif thisbuild['type'] == 'ant':
727         logging.info("Building Ant project...")
728         cmd = ['ant']
729         if thisbuild['antcommands']:
730             cmd += thisbuild['antcommands']
731         else:
732             cmd += ['release']
733         p = FDroidPopen(cmd, cwd=root_dir)
734
735         bindir = os.path.join(root_dir, 'bin')
736
737     if p is not None and p.returncode != 0:
738         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.output)
739     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
740
741     if thisbuild['type'] == 'maven':
742         stdout_apk = '\n'.join([
743             line for line in p.output.splitlines() if any(
744                 a in line for a in ('.apk', '.ap_', '.jar'))])
745         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
746                      stdout_apk, re.S | re.M)
747         if not m:
748             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
749                          stdout_apk, re.S | re.M)
750         if not m:
751             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
752                          stdout_apk, re.S | re.M)
753
754         if not m:
755             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
756                          stdout_apk, re.S | re.M)
757         if not m:
758             raise BuildException('Failed to find output')
759         src = m.group(1)
760         src = os.path.join(bindir, src) + '.apk'
761     elif thisbuild['type'] == 'kivy':
762         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
763             bconfig.get('app', 'title'), bconfig.get('app', 'version'))
764     elif thisbuild['type'] == 'gradle':
765
766         if thisbuild['gradlepluginver'] >= LooseVersion('0.11'):
767             apks_dir = os.path.join(root_dir, 'build', 'outputs', 'apk')
768         else:
769             apks_dir = os.path.join(root_dir, 'build', 'apk')
770
771         apks = glob.glob(os.path.join(apks_dir, '*-release-unsigned.apk'))
772         if len(apks) > 1:
773             raise BuildException('More than one resulting apks found in %s' % apks_dir,
774                                  '\n'.join(apks))
775         if len(apks) < 1:
776             raise BuildException('Failed to find gradle output in %s' % apks_dir)
777         src = apks[0]
778     elif thisbuild['type'] == 'ant':
779         stdout_apk = '\n'.join([
780             line for line in p.output.splitlines() if '.apk' in line])
781         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
782                        re.S | re.M).group(1)
783         src = os.path.join(bindir, src)
784     elif thisbuild['type'] == 'raw':
785         src = os.path.join(root_dir, thisbuild['output'])
786         src = os.path.normpath(src)
787
788     # Make sure it's not debuggable...
789     if common.isApkDebuggable(src, config):
790         raise BuildException("APK is debuggable")
791
792     # By way of a sanity check, make sure the version and version
793     # code in our new apk match what we expect...
794     logging.debug("Checking " + src)
795     if not os.path.exists(src):
796         raise BuildException("Unsigned apk is not at expected location of " + src)
797
798     p = SdkToolsPopen(['aapt', 'dump', 'badging', src], output=False)
799
800     vercode = None
801     version = None
802     foundid = None
803     nativecode = None
804     for line in p.output.splitlines():
805         if line.startswith("package:"):
806             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
807             m = pat.match(line)
808             if m:
809                 foundid = m.group(1)
810             pat = re.compile(".*versionCode='([0-9]*)'.*")
811             m = pat.match(line)
812             if m:
813                 vercode = m.group(1)
814             pat = re.compile(".*versionName='([^']*)'.*")
815             m = pat.match(line)
816             if m:
817                 version = m.group(1)
818         elif line.startswith("native-code:"):
819             nativecode = line[12:]
820
821     # Ignore empty strings or any kind of space/newline chars that we don't
822     # care about
823     if nativecode is not None:
824         nativecode = nativecode.strip()
825         nativecode = None if not nativecode else nativecode
826
827     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
828         if nativecode is None:
829             raise BuildException("Native code should have been built but none was packaged")
830     if thisbuild['novcheck']:
831         vercode = thisbuild['vercode']
832         version = thisbuild['version']
833     if not version or not vercode:
834         raise BuildException("Could not find version information in build in output")
835     if not foundid:
836         raise BuildException("Could not find package ID in output")
837     if foundid != app['id']:
838         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
839
840     # Some apps (e.g. Timeriffic) have had the bonkers idea of
841     # including the entire changelog in the version number. Remove
842     # it so we can compare. (TODO: might be better to remove it
843     # before we compile, in fact)
844     index = version.find(" //")
845     if index != -1:
846         version = version[:index]
847
848     if (version != thisbuild['version'] or
849             vercode != thisbuild['vercode']):
850         raise BuildException(("Unexpected version/version code in output;"
851                               " APK: '%s' / '%s', "
852                               " Expected: '%s' / '%s'")
853                              % (version, str(vercode), thisbuild['version'],
854                                 str(thisbuild['vercode']))
855                              )
856
857     # Add information for 'fdroid verify' to be able to reproduce the build
858     # environment.
859     if onserver:
860         metadir = os.path.join(tmp_dir, 'META-INF')
861         if not os.path.exists(metadir):
862             os.mkdir(metadir)
863         homedir = os.path.expanduser('~')
864         for fn in ['buildserverid', 'fdroidserverid']:
865             shutil.copyfile(os.path.join(homedir, fn),
866                             os.path.join(metadir, fn))
867             subprocess.call(['jar', 'uf', os.path.abspath(src),
868                              'META-INF/' + fn], cwd=tmp_dir)
869
870     # Copy the unsigned apk to our destination directory for further
871     # processing (by publish.py)...
872     dest = os.path.join(output_dir, common.getapkname(app, thisbuild))
873     shutil.copyfile(src, dest)
874
875     # Move the source tarball into the output directory...
876     if output_dir != tmp_dir and not options.notarball:
877         shutil.move(os.path.join(tmp_dir, tarname),
878                     os.path.join(output_dir, tarname))
879
880
881 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
882              tmp_dir, repo_dir, vcs, test, server, force, onserver, refresh):
883     """
884     Build a particular version of an application, if it needs building.
885
886     :param output_dir: The directory where the build output will go. Usually
887        this is the 'unsigned' directory.
888     :param repo_dir: The repo directory - used for checking if the build is
889        necessary.
890     :paaram also_check_dir: An additional location for checking if the build
891        is necessary (usually the archive repo)
892     :param test: True if building in test mode, in which case the build will
893        always happen, even if the output already exists. In test mode, the
894        output directory should be a temporary location, not any of the real
895        ones.
896
897     :returns: True if the build was done, False if it wasn't necessary.
898     """
899
900     dest_apk = common.getapkname(app, thisbuild)
901
902     dest = os.path.join(output_dir, dest_apk)
903     dest_repo = os.path.join(repo_dir, dest_apk)
904
905     if not test:
906         if os.path.exists(dest) or os.path.exists(dest_repo):
907             return False
908
909         if also_check_dir:
910             dest_also = os.path.join(also_check_dir, dest_apk)
911             if os.path.exists(dest_also):
912                 return False
913
914     if thisbuild['disable'] and not options.force:
915         return False
916
917     logging.info("Building version %s (%s) of %s" % (
918         thisbuild['version'], thisbuild['vercode'], app['id']))
919
920     if server:
921         # When using server mode, still keep a local cache of the repo, by
922         # grabbing the source now.
923         vcs.gotorevision(thisbuild['commit'])
924
925         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
926     else:
927         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
928     return True
929
930
931 def parse_commandline():
932     """Parse the command line. Returns options, args."""
933
934     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
935     parser.add_option("-v", "--verbose", action="store_true", default=False,
936                       help="Spew out even more information than normal")
937     parser.add_option("-q", "--quiet", action="store_true", default=False,
938                       help="Restrict output to warnings and errors")
939     parser.add_option("-l", "--latest", action="store_true", default=False,
940                       help="Build only the latest version of each package")
941     parser.add_option("-s", "--stop", action="store_true", default=False,
942                       help="Make the build stop on exceptions")
943     parser.add_option("-t", "--test", action="store_true", default=False,
944                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
945     parser.add_option("--server", action="store_true", default=False,
946                       help="Use build server")
947     parser.add_option("--resetserver", action="store_true", default=False,
948                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
949     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
950                       help="Specify that we're running on the build server")
951     parser.add_option("--skip-scan", dest="skipscan", action="store_true", default=False,
952                       help="Skip scanning the source code for binaries and other problems")
953     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
954                       help="Don't create a source tarball, useful when testing a build")
955     parser.add_option("--no-refresh", dest="refresh", action="store_false", default=True,
956                       help="Don't refresh the repository, useful when testing a build with no internet connection")
957     parser.add_option("-f", "--force", action="store_true", default=False,
958                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
959     parser.add_option("-a", "--all", action="store_true", default=False,
960                       help="Build all applications available")
961     parser.add_option("-w", "--wiki", default=False, action="store_true",
962                       help="Update the wiki")
963     options, args = parser.parse_args()
964
965     # Force --stop with --on-server to get correct exit code
966     if options.onserver:
967         options.stop = True
968
969     if options.force and not options.test:
970         raise OptionError("Force is only allowed in test mode", "force")
971
972     return options, args
973
974 options = None
975 config = None
976
977
978 def main():
979
980     global options, config
981
982     options, args = parse_commandline()
983     if not args and not options.all:
984         raise OptionError("If you really want to build all the apps, use --all", "all")
985
986     config = common.read_config(options)
987
988     if config['build_server_always']:
989         options.server = True
990     if options.resetserver and not options.server:
991         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
992
993     log_dir = 'logs'
994     if not os.path.isdir(log_dir):
995         logging.info("Creating log directory")
996         os.makedirs(log_dir)
997
998     tmp_dir = 'tmp'
999     if not os.path.isdir(tmp_dir):
1000         logging.info("Creating temporary directory")
1001         os.makedirs(tmp_dir)
1002
1003     if options.test:
1004         output_dir = tmp_dir
1005     else:
1006         output_dir = 'unsigned'
1007         if not os.path.isdir(output_dir):
1008             logging.info("Creating output directory")
1009             os.makedirs(output_dir)
1010
1011     if config['archive_older'] != 0:
1012         also_check_dir = 'archive'
1013     else:
1014         also_check_dir = None
1015
1016     repo_dir = 'repo'
1017
1018     build_dir = 'build'
1019     if not os.path.isdir(build_dir):
1020         logging.info("Creating build directory")
1021         os.makedirs(build_dir)
1022     srclib_dir = os.path.join(build_dir, 'srclib')
1023     extlib_dir = os.path.join(build_dir, 'extlib')
1024
1025     # Read all app and srclib metadata
1026     allapps = metadata.read_metadata(xref=not options.onserver)
1027
1028     apps = common.read_app_args(args, allapps, True)
1029     for appid, app in apps.items():
1030         if (app['Disabled'] and not options.force) or not app['Repo Type'] or not app['builds']:
1031             del apps[appid]
1032
1033     if not apps:
1034         raise FDroidException("No apps to process.")
1035
1036     if options.latest:
1037         for app in apps.itervalues():
1038             for build in reversed(app['builds']):
1039                 if build['disable'] and not options.force:
1040                     continue
1041                 app['builds'] = [build]
1042                 break
1043
1044     if options.wiki:
1045         import mwclient
1046         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1047                              path=config['wiki_path'])
1048         site.login(config['wiki_user'], config['wiki_password'])
1049
1050     # Build applications...
1051     failed_apps = {}
1052     build_succeeded = []
1053     for appid, app in apps.iteritems():
1054
1055         first = True
1056
1057         for thisbuild in app['builds']:
1058             wikilog = None
1059             try:
1060
1061                 # For the first build of a particular app, we need to set up
1062                 # the source repo. We can reuse it on subsequent builds, if
1063                 # there are any.
1064                 if first:
1065                     if app['Repo Type'] == 'srclib':
1066                         build_dir = os.path.join('build', 'srclib', app['Repo'])
1067                     else:
1068                         build_dir = os.path.join('build', appid)
1069
1070                     # Set up vcs interface and make sure we have the latest code...
1071                     logging.debug("Getting {0} vcs interface for {1}"
1072                                   .format(app['Repo Type'], app['Repo']))
1073                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
1074
1075                     first = False
1076
1077                 logging.debug("Checking " + thisbuild['version'])
1078                 if trybuild(app, thisbuild, build_dir, output_dir,
1079                             also_check_dir, srclib_dir, extlib_dir,
1080                             tmp_dir, repo_dir, vcs, options.test,
1081                             options.server, options.force,
1082                             options.onserver, options.refresh):
1083
1084                     if app.get('Binaries', None):
1085                         # This is an app where we build from source, and
1086                         # verify the apk contents against a developer's
1087                         # binary. We get that binary now, and save it
1088                         # alongside our built one in the 'unsigend'
1089                         # directory.
1090                         url = app['Binaries']
1091                         url = url.replace('%v', thisbuild['version'])
1092                         url = url.replace('%c', str(thisbuild['vercode']))
1093                         logging.info("...retrieving " + url)
1094                         of = "{0}_{1}.apk.binary".format(app['id'], thisbuild['vercode'])
1095                         of = os.path.join(output_dir, of)
1096                         common.download_file(url, local_filename=of)
1097
1098                     build_succeeded.append(app)
1099                     wikilog = "Build succeeded"
1100             except BuildException as be:
1101                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1102                     f.write(str(be))
1103                 print("Could not build app %s due to BuildException: %s" % (appid, be))
1104                 if options.stop:
1105                     sys.exit(1)
1106                 failed_apps[appid] = be
1107                 wikilog = be.get_wikitext()
1108             except VCSException as vcse:
1109                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1110                 logging.error("VCS error while building app %s: %s" % (
1111                     appid, reason))
1112                 if options.stop:
1113                     sys.exit(1)
1114                 failed_apps[appid] = vcse
1115                 wikilog = str(vcse)
1116             except Exception as e:
1117                 logging.error("Could not build app %s due to unknown error: %s" % (
1118                     appid, traceback.format_exc()))
1119                 if options.stop:
1120                     sys.exit(1)
1121                 failed_apps[appid] = e
1122                 wikilog = str(e)
1123
1124             if options.wiki and wikilog:
1125                 try:
1126                     # Write a page with the last build log for this version code
1127                     lastbuildpage = appid + '/lastbuild_' + thisbuild['vercode']
1128                     newpage = site.Pages[lastbuildpage]
1129                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1130                     newpage.save(txt, summary='Build log')
1131                     # Redirect from /lastbuild to the most recent build log
1132                     newpage = site.Pages[appid + '/lastbuild']
1133                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1134                 except:
1135                     logging.error("Error while attempting to publish build log")
1136
1137     for app in build_succeeded:
1138         logging.info("success: %s" % (app['id']))
1139
1140     if not options.verbose:
1141         for fa in failed_apps:
1142             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1143
1144     logging.info("Finished.")
1145     if len(build_succeeded) > 0:
1146         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1147     if len(failed_apps) > 0:
1148         logging.info(str(len(failed_apps)) + ' builds failed')
1149
1150     sys.exit(0)
1151
1152 if __name__ == "__main__":
1153     main()