chiark / gitweb /
Remove shell option from FDroidPopen
[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', srclibpaths,
352                                      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     for root, dirs, files in os.walk(build_dir):
432         if 'build.gradle' in files:
433             path = os.path.join(root, 'build.gradle')
434             logging.debug("Adapting build.gradle at %s" % path)
435
436             FDroidPopen(['sed', '-i',
437                          r's@buildToolsVersion\([ =]*\)["\'][0-9\.]*["\']@buildToolsVersion\1"'
438                          + config['build_tools'] + '"@g', path])
439
440
441 def capitalize_intact(string):
442     """Like str.capitalize(), but leave the rest of the string intact without
443     switching it to lowercase."""
444     if len(string) == 0:
445         return string
446     if len(string) == 1:
447         return string.upper()
448     return string[0].upper() + string[1:]
449
450
451 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver):
452     """Do a build locally."""
453
454     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
455         if not thisbuild['ndk_path']:
456             logging.critical("Android NDK version '%s' could not be found!" % thisbuild['ndk'])
457             logging.critical("Configured versions:")
458             for k, v in config['ndk_paths'].iteritems():
459                 if k.endswith("_orig"):
460                     continue
461                 logging.critical("  %s: %s" % (k, v))
462             sys.exit(3)
463         elif not os.path.isdir(thisbuild['ndk_path']):
464             logging.critical("Android NDK '%s' is not a directory!" % thisbuild['ndk_path'])
465             sys.exit(3)
466
467     # Set up environment vars that depend on each build
468     for n in ['ANDROID_NDK', 'NDK']:
469         common.env[n] = thisbuild['ndk_path']
470
471     common.reset_env_path()
472     # Set up the current NDK to the PATH
473     common.add_to_env_path(thisbuild['ndk_path'])
474
475     # Prepare the source code...
476     root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
477                                                   build_dir, srclib_dir,
478                                                   extlib_dir, onserver)
479
480     # We need to clean via the build tool in case the binary dirs are
481     # different from the default ones
482     p = None
483     gradletasks = []
484     if thisbuild['type'] == 'maven':
485         logging.info("Cleaning Maven project...")
486         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
487
488         if '@' in thisbuild['maven']:
489             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
490             maven_dir = os.path.normpath(maven_dir)
491         else:
492             maven_dir = root_dir
493
494         p = FDroidPopen(cmd, cwd=maven_dir)
495
496     elif thisbuild['type'] == 'gradle':
497
498         logging.info("Cleaning Gradle project...")
499
500         if thisbuild['preassemble']:
501             gradletasks += thisbuild['preassemble']
502
503         flavours = thisbuild['gradle']
504         if flavours == ['yes']:
505             flavours = []
506
507         flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
508
509         gradletasks += ['assemble' + flavours_cmd + 'Release']
510
511         adapt_gradle(build_dir)
512         for name, number, libpath in srclibpaths:
513             adapt_gradle(libpath)
514
515         cmd = [config['gradle']]
516         cmd += ['clean' + capitalize_intact(task) for task in gradletasks]
517
518         p = FDroidPopen(cmd, cwd=root_dir)
519
520     elif thisbuild['type'] == 'kivy':
521         pass
522
523     elif thisbuild['type'] == 'ant':
524         logging.info("Cleaning Ant project...")
525         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
526
527     if p is not None and p.returncode != 0:
528         raise BuildException("Error cleaning %s:%s" %
529                              (app['id'], thisbuild['version']), p.output)
530
531     for root, dirs, files in os.walk(build_dir):
532         # Don't remove possibly necessary 'gradle' dirs if 'gradlew' is not there
533         if 'gradlew' in files:
534             logging.debug("Getting rid of Gradle wrapper stuff in %s" % root)
535             os.remove(os.path.join(root, 'gradlew'))
536             if 'gradlew.bat' in files:
537                 os.remove(os.path.join(root, 'gradlew.bat'))
538             if 'gradle' in dirs:
539                 shutil.rmtree(os.path.join(root, 'gradle'))
540
541     if options.skipscan:
542         if thisbuild['scandelete']:
543             raise BuildException("Refusing to skip source scan since scandelete is present")
544     else:
545         # Scan before building...
546         logging.info("Scanning source for common problems...")
547         count = common.scan_source(build_dir, root_dir, thisbuild)
548         if count > 0:
549             if force:
550                 logging.warn('Scanner found %d problems' % count)
551             else:
552                 raise BuildException("Can't build due to %d errors while scanning" % count)
553
554     if not options.notarball:
555         # Build the source tarball right before we build the release...
556         logging.info("Creating source tarball...")
557         tarname = common.getsrcname(app, thisbuild)
558         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
559
560         def tarexc(f):
561             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
562         tarball.add(build_dir, tarname, exclude=tarexc)
563         tarball.close()
564
565     # Run a build command if one is required...
566     if thisbuild['build']:
567         logging.info("Running 'build' commands in %s" % root_dir)
568         cmd = common.replace_config_vars(thisbuild['build'])
569
570         # Substitute source library paths into commands...
571         for name, number, libpath in srclibpaths:
572             libpath = os.path.relpath(libpath, root_dir)
573             cmd = cmd.replace('$$' + name + '$$', libpath)
574
575         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
576
577         if p.returncode != 0:
578             raise BuildException("Error running build command for %s:%s" %
579                                  (app['id'], thisbuild['version']), p.output)
580
581     # Build native stuff if required...
582     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
583         logging.info("Building the native code")
584         jni_components = thisbuild['buildjni']
585
586         if jni_components == ['yes']:
587             jni_components = ['']
588         cmd = [os.path.join(thisbuild['ndk_path'], "ndk-build"), "-j1"]
589         for d in jni_components:
590             if d:
591                 logging.info("Building native code in '%s'" % d)
592             else:
593                 logging.info("Building native code in the main project")
594             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
595             if os.path.exists(manifest):
596                 # Read and write the whole AM.xml to fix newlines and avoid
597                 # the ndk r8c or later 'wordlist' errors. The outcome of this
598                 # under gnu/linux is the same as when using tools like
599                 # dos2unix, but the native python way is faster and will
600                 # work in non-unix systems.
601                 manifest_text = open(manifest, 'U').read()
602                 open(manifest, 'w').write(manifest_text)
603                 # In case the AM.xml read was big, free the memory
604                 del manifest_text
605             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
606             if p.returncode != 0:
607                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.output)
608
609     p = None
610     # Build the release...
611     if thisbuild['type'] == 'maven':
612         logging.info("Building Maven project...")
613
614         if '@' in thisbuild['maven']:
615             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
616         else:
617             maven_dir = root_dir
618
619         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
620                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
621                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
622                   'package']
623         if thisbuild['target']:
624             target = thisbuild["target"].split('-')[1]
625             FDroidPopen(['sed', '-i',
626                          's@<platform>[0-9]*</platform>@<platform>'
627                          + target + '</platform>@g',
628                          'pom.xml'],
629                         cwd=root_dir)
630             if '@' in thisbuild['maven']:
631                 FDroidPopen(['sed', '-i',
632                              's@<platform>[0-9]*</platform>@<platform>'
633                              + target + '</platform>@g',
634                              'pom.xml'],
635                             cwd=maven_dir)
636
637         p = FDroidPopen(mvncmd, cwd=maven_dir)
638
639         bindir = os.path.join(root_dir, 'target')
640
641     elif thisbuild['type'] == 'kivy':
642         logging.info("Building Kivy project...")
643
644         spec = os.path.join(root_dir, 'buildozer.spec')
645         if not os.path.exists(spec):
646             raise BuildException("Expected to find buildozer-compatible spec at {0}"
647                                  .format(spec))
648
649         defaults = {'orientation': 'landscape', 'icon': '',
650                     'permissions': '', 'android.api': "18"}
651         bconfig = ConfigParser(defaults, allow_no_value=True)
652         bconfig.read(spec)
653
654         distdir = 'python-for-android/dist/fdroid'
655         if os.path.exists(distdir):
656             shutil.rmtree(distdir)
657
658         modules = bconfig.get('app', 'requirements').split(',')
659
660         cmd = 'ANDROIDSDK=' + config['sdk_path']
661         cmd += ' ANDROIDNDK=' + thisbuild['ndk_path']
662         cmd += ' ANDROIDNDKVER=' + thisbuild['ndk']
663         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
664         cmd += ' VIRTUALENV=virtualenv'
665         cmd += ' ./distribute.sh'
666         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
667         cmd += ' -d fdroid'
668         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
669         if p.returncode != 0:
670             raise BuildException("Distribute build failed")
671
672         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
673         if cid != app['id']:
674             raise BuildException("Package ID mismatch between metadata and spec")
675
676         orientation = bconfig.get('app', 'orientation', 'landscape')
677         if orientation == 'all':
678             orientation = 'sensor'
679
680         cmd = ['./build.py'
681                '--dir', root_dir,
682                '--name', bconfig.get('app', 'title'),
683                '--package', app['id'],
684                '--version', bconfig.get('app', 'version'),
685                '--orientation', orientation
686                ]
687
688         perms = bconfig.get('app', 'permissions')
689         for perm in perms.split(','):
690             cmd.extend(['--permission', perm])
691
692         if config.get('app', 'fullscreen') == 0:
693             cmd.append('--window')
694
695         icon = bconfig.get('app', 'icon.filename')
696         if icon:
697             cmd.extend(['--icon', os.path.join(root_dir, icon)])
698
699         cmd.append('release')
700         p = FDroidPopen(cmd, cwd=distdir)
701
702     elif thisbuild['type'] == 'gradle':
703         logging.info("Building Gradle project...")
704
705         # Avoid having to use lintOptions.abortOnError false
706         if thisbuild['gradlepluginver'] >= LooseVersion('0.7'):
707             with open(os.path.join(root_dir, 'build.gradle'), "a") as f:
708                 f.write("\nandroid { lintOptions { checkReleaseBuilds false } }\n")
709
710         commands = [config['gradle']] + gradletasks
711
712         p = FDroidPopen(commands, cwd=root_dir)
713
714     elif thisbuild['type'] == 'ant':
715         logging.info("Building Ant project...")
716         cmd = ['ant']
717         if thisbuild['antcommands']:
718             cmd += thisbuild['antcommands']
719         else:
720             cmd += ['release']
721         p = FDroidPopen(cmd, cwd=root_dir)
722
723         bindir = os.path.join(root_dir, 'bin')
724
725     if p is not None and p.returncode != 0:
726         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.output)
727     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
728
729     if thisbuild['type'] == 'maven':
730         stdout_apk = '\n'.join([
731             line for line in p.output.splitlines() if any(
732                 a in line for a in ('.apk', '.ap_', '.jar'))])
733         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
734                      stdout_apk, re.S | re.M)
735         if not m:
736             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
737                          stdout_apk, re.S | re.M)
738         if not m:
739             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
740                          stdout_apk, re.S | re.M)
741
742         if not m:
743             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
744                          stdout_apk, re.S | re.M)
745         if not m:
746             raise BuildException('Failed to find output')
747         src = m.group(1)
748         src = os.path.join(bindir, src) + '.apk'
749     elif thisbuild['type'] == 'kivy':
750         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
751             bconfig.get('app', 'title'), bconfig.get('app', 'version'))
752     elif thisbuild['type'] == 'gradle':
753
754         if thisbuild['gradlepluginver'] >= LooseVersion('0.11'):
755             apks_dir = os.path.join(root_dir, 'build', 'outputs', 'apk')
756         else:
757             apks_dir = os.path.join(root_dir, 'build', 'apk')
758
759         apks = glob.glob(os.path.join(apks_dir, '*-release-unsigned.apk'))
760         if len(apks) > 1:
761             raise BuildException('More than one resulting apks found in %s' % apks_dir,
762                                  '\n'.join(apks))
763         if len(apks) < 1:
764             raise BuildException('Failed to find gradle output in %s' % apks_dir)
765         src = apks[0]
766     elif thisbuild['type'] == 'ant':
767         stdout_apk = '\n'.join([
768             line for line in p.output.splitlines() if '.apk' in line])
769         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
770                        re.S | re.M).group(1)
771         src = os.path.join(bindir, src)
772     elif thisbuild['type'] == 'raw':
773         src = os.path.join(root_dir, thisbuild['output'])
774         src = os.path.normpath(src)
775
776     # Make sure it's not debuggable...
777     if common.isApkDebuggable(src, config):
778         raise BuildException("APK is debuggable")
779
780     # By way of a sanity check, make sure the version and version
781     # code in our new apk match what we expect...
782     logging.debug("Checking " + src)
783     if not os.path.exists(src):
784         raise BuildException("Unsigned apk is not at expected location of " + src)
785
786     p = SdkToolsPopen(['aapt', 'dump', 'badging', src], output=False)
787
788     vercode = None
789     version = None
790     foundid = None
791     nativecode = None
792     for line in p.output.splitlines():
793         if line.startswith("package:"):
794             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
795             m = pat.match(line)
796             if m:
797                 foundid = m.group(1)
798             pat = re.compile(".*versionCode='([0-9]*)'.*")
799             m = pat.match(line)
800             if m:
801                 vercode = m.group(1)
802             pat = re.compile(".*versionName='([^']*)'.*")
803             m = pat.match(line)
804             if m:
805                 version = m.group(1)
806         elif line.startswith("native-code:"):
807             nativecode = line[12:]
808
809     # Ignore empty strings or any kind of space/newline chars that we don't
810     # care about
811     if nativecode is not None:
812         nativecode = nativecode.strip()
813         nativecode = None if not nativecode else nativecode
814
815     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
816         if nativecode is None:
817             raise BuildException("Native code should have been built but none was packaged")
818     if thisbuild['novcheck']:
819         vercode = thisbuild['vercode']
820         version = thisbuild['version']
821     if not version or not vercode:
822         raise BuildException("Could not find version information in build in output")
823     if not foundid:
824         raise BuildException("Could not find package ID in output")
825     if foundid != app['id']:
826         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
827
828     # Some apps (e.g. Timeriffic) have had the bonkers idea of
829     # including the entire changelog in the version number. Remove
830     # it so we can compare. (TODO: might be better to remove it
831     # before we compile, in fact)
832     index = version.find(" //")
833     if index != -1:
834         version = version[:index]
835
836     if (version != thisbuild['version'] or
837             vercode != thisbuild['vercode']):
838         raise BuildException(("Unexpected version/version code in output;"
839                               " APK: '%s' / '%s', "
840                               " Expected: '%s' / '%s'")
841                              % (version, str(vercode), thisbuild['version'],
842                                 str(thisbuild['vercode']))
843                              )
844
845     # Add information for 'fdroid verify' to be able to reproduce the build
846     # environment.
847     if onserver:
848         metadir = os.path.join(tmp_dir, 'META-INF')
849         if not os.path.exists(metadir):
850             os.mkdir(metadir)
851         homedir = os.path.expanduser('~')
852         for fn in ['buildserverid', 'fdroidserverid']:
853             shutil.copyfile(os.path.join(homedir, fn),
854                             os.path.join(metadir, fn))
855             subprocess.call(['jar', 'uf', os.path.abspath(src),
856                              'META-INF/' + fn], cwd=tmp_dir)
857
858     # Copy the unsigned apk to our destination directory for further
859     # processing (by publish.py)...
860     dest = os.path.join(output_dir, common.getapkname(app, thisbuild))
861     shutil.copyfile(src, dest)
862
863     # Move the source tarball into the output directory...
864     if output_dir != tmp_dir and not options.notarball:
865         shutil.move(os.path.join(tmp_dir, tarname),
866                     os.path.join(output_dir, tarname))
867
868
869 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
870              tmp_dir, repo_dir, vcs, test, server, force, onserver):
871     """
872     Build a particular version of an application, if it needs building.
873
874     :param output_dir: The directory where the build output will go. Usually
875        this is the 'unsigned' directory.
876     :param repo_dir: The repo directory - used for checking if the build is
877        necessary.
878     :paaram also_check_dir: An additional location for checking if the build
879        is necessary (usually the archive repo)
880     :param test: True if building in test mode, in which case the build will
881        always happen, even if the output already exists. In test mode, the
882        output directory should be a temporary location, not any of the real
883        ones.
884
885     :returns: True if the build was done, False if it wasn't necessary.
886     """
887
888     dest_apk = common.getapkname(app, thisbuild)
889
890     dest = os.path.join(output_dir, dest_apk)
891     dest_repo = os.path.join(repo_dir, dest_apk)
892
893     if not test:
894         if os.path.exists(dest) or os.path.exists(dest_repo):
895             return False
896
897         if also_check_dir:
898             dest_also = os.path.join(also_check_dir, dest_apk)
899             if os.path.exists(dest_also):
900                 return False
901
902     if thisbuild['disable'] and not options.force:
903         return False
904
905     logging.info("Building version %s (%s) of %s" % (
906         thisbuild['version'], thisbuild['vercode'], app['id']))
907
908     if server:
909         # When using server mode, still keep a local cache of the repo, by
910         # grabbing the source now.
911         vcs.gotorevision(thisbuild['commit'])
912
913         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
914     else:
915         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
916     return True
917
918
919 def parse_commandline():
920     """Parse the command line. Returns options, args."""
921
922     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
923     parser.add_option("-v", "--verbose", action="store_true", default=False,
924                       help="Spew out even more information than normal")
925     parser.add_option("-q", "--quiet", action="store_true", default=False,
926                       help="Restrict output to warnings and errors")
927     parser.add_option("-l", "--latest", action="store_true", default=False,
928                       help="Build only the latest version of each package")
929     parser.add_option("-s", "--stop", action="store_true", default=False,
930                       help="Make the build stop on exceptions")
931     parser.add_option("-t", "--test", action="store_true", default=False,
932                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
933     parser.add_option("--server", action="store_true", default=False,
934                       help="Use build server")
935     parser.add_option("--resetserver", action="store_true", default=False,
936                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
937     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
938                       help="Specify that we're running on the build server")
939     parser.add_option("--skip-scan", dest="skipscan", action="store_true", default=False,
940                       help="Skip scanning the source code for binaries and other problems")
941     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
942                       help="Don't create a source tarball, useful when testing a build")
943     parser.add_option("-f", "--force", action="store_true", default=False,
944                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
945     parser.add_option("-a", "--all", action="store_true", default=False,
946                       help="Build all applications available")
947     parser.add_option("-w", "--wiki", default=False, action="store_true",
948                       help="Update the wiki")
949     options, args = parser.parse_args()
950
951     # Force --stop with --on-server to get correct exit code
952     if options.onserver:
953         options.stop = True
954
955     if options.force and not options.test:
956         raise OptionError("Force is only allowed in test mode", "force")
957
958     return options, args
959
960 options = None
961 config = None
962
963
964 def main():
965
966     global options, config
967
968     options, args = parse_commandline()
969     if not args and not options.all:
970         raise OptionError("If you really want to build all the apps, use --all", "all")
971
972     config = common.read_config(options)
973
974     if config['build_server_always']:
975         options.server = True
976     if options.resetserver and not options.server:
977         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
978
979     log_dir = 'logs'
980     if not os.path.isdir(log_dir):
981         logging.info("Creating log directory")
982         os.makedirs(log_dir)
983
984     tmp_dir = 'tmp'
985     if not os.path.isdir(tmp_dir):
986         logging.info("Creating temporary directory")
987         os.makedirs(tmp_dir)
988
989     if options.test:
990         output_dir = tmp_dir
991     else:
992         output_dir = 'unsigned'
993         if not os.path.isdir(output_dir):
994             logging.info("Creating output directory")
995             os.makedirs(output_dir)
996
997     if config['archive_older'] != 0:
998         also_check_dir = 'archive'
999     else:
1000         also_check_dir = None
1001
1002     repo_dir = 'repo'
1003
1004     build_dir = 'build'
1005     if not os.path.isdir(build_dir):
1006         logging.info("Creating build directory")
1007         os.makedirs(build_dir)
1008     srclib_dir = os.path.join(build_dir, 'srclib')
1009     extlib_dir = os.path.join(build_dir, 'extlib')
1010
1011     # Read all app and srclib metadata
1012     allapps = metadata.read_metadata(xref=not options.onserver)
1013
1014     apps = common.read_app_args(args, allapps, True)
1015     for appid, app in apps.items():
1016         if (app['Disabled'] and not options.force) or not app['Repo Type'] or not app['builds']:
1017             del apps[appid]
1018
1019     if not apps:
1020         raise FDroidException("No apps to process.")
1021
1022     if options.latest:
1023         for app in apps.itervalues():
1024             for build in reversed(app['builds']):
1025                 if build['disable'] and not options.force:
1026                     continue
1027                 app['builds'] = [build]
1028                 break
1029
1030     if options.wiki:
1031         import mwclient
1032         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1033                              path=config['wiki_path'])
1034         site.login(config['wiki_user'], config['wiki_password'])
1035
1036     # Build applications...
1037     failed_apps = {}
1038     build_succeeded = []
1039     for appid, app in apps.iteritems():
1040
1041         first = True
1042
1043         for thisbuild in app['builds']:
1044             wikilog = None
1045             try:
1046
1047                 # For the first build of a particular app, we need to set up
1048                 # the source repo. We can reuse it on subsequent builds, if
1049                 # there are any.
1050                 if first:
1051                     if app['Repo Type'] == 'srclib':
1052                         build_dir = os.path.join('build', 'srclib', app['Repo'])
1053                     else:
1054                         build_dir = os.path.join('build', appid)
1055
1056                     # Set up vcs interface and make sure we have the latest code...
1057                     logging.debug("Getting {0} vcs interface for {1}"
1058                                   .format(app['Repo Type'], app['Repo']))
1059                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
1060
1061                     first = False
1062
1063                 logging.debug("Checking " + thisbuild['version'])
1064                 if trybuild(app, thisbuild, build_dir, output_dir,
1065                             also_check_dir, srclib_dir, extlib_dir,
1066                             tmp_dir, repo_dir, vcs, options.test,
1067                             options.server, options.force,
1068                             options.onserver):
1069                     build_succeeded.append(app)
1070                     wikilog = "Build succeeded"
1071             except BuildException as be:
1072                 logfile = open(os.path.join(log_dir, appid + '.log'), 'a+')
1073                 logfile.write(str(be))
1074                 logfile.close()
1075                 print("Could not build app %s due to BuildException: %s" % (appid, be))
1076                 if options.stop:
1077                     sys.exit(1)
1078                 failed_apps[appid] = be
1079                 wikilog = be.get_wikitext()
1080             except VCSException as vcse:
1081                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1082                 logging.error("VCS error while building app %s: %s" % (
1083                     appid, reason))
1084                 if options.stop:
1085                     sys.exit(1)
1086                 failed_apps[appid] = vcse
1087                 wikilog = str(vcse)
1088             except Exception as e:
1089                 logging.error("Could not build app %s due to unknown error: %s" % (
1090                     appid, traceback.format_exc()))
1091                 if options.stop:
1092                     sys.exit(1)
1093                 failed_apps[appid] = e
1094                 wikilog = str(e)
1095
1096             if options.wiki and wikilog:
1097                 try:
1098                     # Write a page with the last build log for this version code
1099                     lastbuildpage = appid + '/lastbuild_' + thisbuild['vercode']
1100                     newpage = site.Pages[lastbuildpage]
1101                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1102                     newpage.save(txt, summary='Build log')
1103                     # Redirect from /lastbuild to the most recent build log
1104                     newpage = site.Pages[appid + '/lastbuild']
1105                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1106                 except:
1107                     logging.error("Error while attempting to publish build log")
1108
1109     for app in build_succeeded:
1110         logging.info("success: %s" % (app['id']))
1111
1112     if not options.verbose:
1113         for fa in failed_apps:
1114             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1115
1116     logging.info("Finished.")
1117     if len(build_succeeded) > 0:
1118         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1119     if len(failed_apps) > 0:
1120         logging.info(str(len(failed_apps)) + ' builds failed')
1121
1122     sys.exit(0)
1123
1124 if __name__ == "__main__":
1125     main()