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