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