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