chiark / gitweb /
Continue using FDroidPopen and SilentPopen more, support shell=
[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, SilentPopen
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             FDroidPopen(['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     if not options.skipscan:
423         # Scan before building...
424         logging.info("Scanning source for common problems...")
425         buildprobs = common.scan_source(build_dir, root_dir, thisbuild)
426         if len(buildprobs) > 0:
427             logging.info('Scanner found %d problems:' % len(buildprobs))
428             for problem in buildprobs:
429                 logging.info('    %s' % problem)
430             if not force:
431                 raise BuildException("Can't build due to " +
432                     str(len(buildprobs)) + " scanned problems")
433
434     if not options.notarball:
435         # Build the source tarball right before we build the release...
436         logging.info("Creating source tarball...")
437         tarname = common.getsrcname(app,thisbuild)
438         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
439         def tarexc(f):
440             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
441         tarball.add(build_dir, tarname, exclude=tarexc)
442         tarball.close()
443
444     # Run a build command if one is required...
445     if 'build' in thisbuild:
446         cmd = common.replace_config_vars(thisbuild['build'])
447         # Substitute source library paths into commands...
448         for name, number, libpath in srclibpaths:
449             libpath = os.path.relpath(libpath, root_dir)
450             cmd = cmd.replace('$$' + name + '$$', libpath)
451         logging.info("Running 'build' commands in %s" % root_dir)
452
453         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
454
455         if p.returncode != 0:
456             raise BuildException("Error running build command for %s:%s" %
457                     (app['id'], thisbuild['version']), p.stdout)
458
459     # Build native stuff if required...
460     if thisbuild.get('buildjni') not in (None, ['no']):
461         logging.info("Building native libraries...")
462         jni_components = thisbuild.get('buildjni')
463         if jni_components == ['yes']:
464             jni_components = ['']
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             FDroidPopen(['sed', '-i',
499                     's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
500                     'pom.xml'], cwd=root_dir)
501             if '@' in thisbuild['maven']:
502                 FDroidPopen(['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         p = FDroidPopen(cmd, cwd='python-for-android', shell=True)
541         if p.returncode != 0:
542             raise BuildException("Distribute build failed")
543
544         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
545         if cid != app['id']:
546             raise BuildException("Package ID mismatch between metadata and spec")
547
548         orientation = bconfig.get('app', 'orientation', 'landscape')
549         if orientation == 'all':
550             orientation = 'sensor'
551
552         cmd = ['./build.py'
553                 '--dir', root_dir,
554                 '--name', bconfig.get('app', 'title'),
555                 '--package', app['id'],
556                 '--version', bconfig.get('app', 'version'),
557                 '--orientation', orientation,
558                 ]
559
560         perms = bconfig.get('app', 'permissions')
561         for perm in perms.split(','):
562             cmd.extend(['--permission', perm])
563
564         if config.get('app', 'fullscreen') == 0:
565             cmd.append('--window')
566
567         icon = bconfig.get('app', 'icon.filename')
568         if icon:
569             cmd.extend(['--icon', os.path.join(root_dir, icon)])
570
571         cmd.append('release')
572         p = FDroidPopen(cmd, cwd=distdir)
573
574     elif thisbuild['type'] == 'gradle':
575         logging.info("Building Gradle project...")
576         if '@' in thisbuild['gradle']:
577             flavours = thisbuild['gradle'].split('@')[0].split(',')
578             gradle_dir = thisbuild['gradle'].split('@')[1]
579             gradle_dir = os.path.join(root_dir, gradle_dir)
580         else:
581             flavours = thisbuild['gradle'].split(',')
582             gradle_dir = root_dir
583
584         if len(flavours) == 1 and flavours[0] in ['main', 'yes', '']:
585             flavours[0] = ''
586
587         commands = [config['gradle']]
588         if 'preassemble' in thisbuild:
589             commands += thisbuild['preassemble'].split()
590         commands += ['assemble'+''.join(flavours)+'Release']
591
592         p = FDroidPopen(commands, cwd=gradle_dir)
593
594     elif thisbuild['type'] == 'ant':
595         logging.info("Building Ant project...")
596         cmd = ['ant']
597         if 'antcommand' in thisbuild:
598             cmd += [thisbuild['antcommand']]
599         else:
600             cmd += ['release']
601         p = FDroidPopen(cmd, cwd=root_dir)
602
603         bindir = os.path.join(root_dir, 'bin')
604
605     if p is not None and p.returncode != 0:
606         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
607     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
608
609     if thisbuild['type'] == 'maven':
610         stdout_apk = '\n'.join([
611             line for line in p.stdout.splitlines() if any(a in line for a in ('.apk','.ap_'))])
612         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
613                 stdout_apk, re.S|re.M)
614         if not m:
615             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
616                     stdout_apk, re.S|re.M)
617         if not m:
618             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
619                     stdout_apk, re.S|re.M)
620         if not m:
621             raise BuildException('Failed to find output')
622         src = m.group(1)
623         src = os.path.join(bindir, src) + '.apk'
624     elif thisbuild['type'] == 'kivy':
625         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
626                 bconfig.get('app', 'title'), bconfig.get('app', 'version'))
627     elif thisbuild['type'] == 'gradle':
628         dd = build_dir
629         if 'subdir' in thisbuild:
630             dd = os.path.join(dd, thisbuild['subdir'])
631         if len(flavours) == 1 and flavours[0] == '':
632             name = '-'.join([os.path.basename(dd), 'release', 'unsigned'])
633         else:
634             name = '-'.join([os.path.basename(dd), '-'.join(flavours), 'release', 'unsigned'])
635         src = os.path.join(dd, 'build', 'apk', name+'.apk')
636     elif thisbuild['type'] == 'ant':
637         stdout_apk = '\n'.join([
638             line for line in p.stdout.splitlines() if '.apk' in line])
639         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
640             re.S|re.M).group(1)
641         src = os.path.join(bindir, src)
642     elif thisbuild['type'] == 'raw':
643         src = os.path.join(root_dir, thisbuild['output'])
644         src = os.path.normpath(src)
645
646     # Make sure it's not debuggable...
647     if common.isApkDebuggable(src, config):
648         raise BuildException("APK is debuggable")
649
650     # By way of a sanity check, make sure the version and version
651     # code in our new apk match what we expect...
652     logging.info("Checking " + src)
653     if not os.path.exists(src):
654         raise BuildException("Unsigned apk is not at expected location of " + src)
655
656     p = SilentPopen([os.path.join(config['sdk_path'],
657         'build-tools', config['build_tools'], 'aapt'),
658         'dump', 'badging', src])
659
660     vercode = None
661     version = None
662     foundid = None
663     for line in p.stdout.splitlines():
664         if line.startswith("package:"):
665             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
666             m = pat.match(line)
667             if m:
668                 foundid = m.group(1)
669             pat = re.compile(".*versionCode='([0-9]*)'.*")
670             m = pat.match(line)
671             if m:
672                 vercode = m.group(1)
673             pat = re.compile(".*versionName='([^']*)'.*")
674             m = pat.match(line)
675             if m:
676                 version = m.group(1)
677
678     if thisbuild['novcheck']:
679         vercode = thisbuild['vercode']
680         version = thisbuild['version']
681     if not version or not vercode:
682         raise BuildException("Could not find version information in build in output")
683     if not foundid:
684         raise BuildException("Could not find package ID in output")
685     if foundid != app['id']:
686         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
687
688     # Some apps (e.g. Timeriffic) have had the bonkers idea of
689     # including the entire changelog in the version number. Remove
690     # it so we can compare. (TODO: might be better to remove it
691     # before we compile, in fact)
692     index = version.find(" //")
693     if index != -1:
694         version = version[:index]
695
696     if (version != thisbuild['version'] or
697             vercode != thisbuild['vercode']):
698         raise BuildException(("Unexpected version/version code in output;"
699                              " APK: '%s' / '%s', "
700                              " Expected: '%s' / '%s'")
701                              % (version, str(vercode), thisbuild['version'], str(thisbuild['vercode']))
702                             )
703
704     # Copy the unsigned apk to our destination directory for further
705     # processing (by publish.py)...
706     dest = os.path.join(output_dir, common.getapkname(app,thisbuild))
707     shutil.copyfile(src, dest)
708
709     # Move the source tarball into the output directory...
710     if output_dir != tmp_dir and not options.notarball:
711         shutil.move(os.path.join(tmp_dir, tarname),
712             os.path.join(output_dir, tarname))
713
714
715 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
716         tmp_dir, repo_dir, vcs, test, server, force, onserver):
717     """
718     Build a particular version of an application, if it needs building.
719
720     :param output_dir: The directory where the build output will go. Usually
721        this is the 'unsigned' directory.
722     :param repo_dir: The repo directory - used for checking if the build is
723        necessary.
724     :paaram also_check_dir: An additional location for checking if the build
725        is necessary (usually the archive repo)
726     :param test: True if building in test mode, in which case the build will
727        always happen, even if the output already exists. In test mode, the
728        output directory should be a temporary location, not any of the real
729        ones.
730
731     :returns: True if the build was done, False if it wasn't necessary.
732     """
733
734     dest_apk = common.getapkname(app, thisbuild)
735
736     dest = os.path.join(output_dir, dest_apk)
737     dest_repo = os.path.join(repo_dir, dest_apk)
738
739     if not test:
740         if os.path.exists(dest) or os.path.exists(dest_repo):
741             return False
742
743         if also_check_dir:
744             dest_also = os.path.join(also_check_dir, dest_apk)
745             if os.path.exists(dest_also):
746                 return False
747
748     if 'disable' in thisbuild:
749         return False
750
751     logging.info("Building version " + thisbuild['version'] + ' of ' + app['id'])
752
753     if server:
754         # When using server mode, still keep a local cache of the repo, by
755         # grabbing the source now.
756         vcs.gotorevision(thisbuild['commit'])
757
758         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
759     else:
760         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
761     return True
762
763
764 def parse_commandline():
765     """Parse the command line. Returns options, args."""
766
767     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
768     parser.add_option("-v", "--verbose", action="store_true", default=False,
769                       help="Spew out even more information than normal")
770     parser.add_option("-l", "--latest", action="store_true", default=False,
771                       help="Build only the latest version of each package")
772     parser.add_option("-s", "--stop", action="store_true", default=False,
773                       help="Make the build stop on exceptions")
774     parser.add_option("-t", "--test", action="store_true", default=False,
775                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
776     parser.add_option("--server", action="store_true", default=False,
777                       help="Use build server")
778     parser.add_option("--resetserver", action="store_true", default=False,
779                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
780     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
781                       help="Specify that we're running on the build server")
782     parser.add_option("--skip-scan", dest="skipscan", action="store_true", default=False,
783                       help="Skip scanning the source code for binaries and other problems")
784     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
785                       help="Don't create a source tarball, useful when testing a build")
786     parser.add_option("-f", "--force", action="store_true", default=False,
787                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
788     parser.add_option("-a", "--all", action="store_true", default=False,
789                       help="Build all applications available")
790     parser.add_option("-w", "--wiki", default=False, action="store_true",
791                       help="Update the wiki")
792     options, args = parser.parse_args()
793
794     # Force --stop with --on-server to get cotrect exit code
795     if options.onserver:
796         options.stop = True
797
798     if options.force and not options.test:
799         raise OptionError("Force is only allowed in test mode", "force")
800
801     return options, args
802
803 options = None
804 config = None
805
806 def main():
807
808     global options, config
809
810     options, args = parse_commandline()
811     if not args and not options.all:
812         raise OptionError("If you really want to build all the apps, use --all", "all")
813
814     config = common.read_config(options)
815
816     if config['build_server_always']:
817         options.server = True
818     if options.resetserver and not options.server:
819         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
820
821     log_dir = 'logs'
822     if not os.path.isdir(log_dir):
823         logging.info("Creating log directory")
824         os.makedirs(log_dir)
825
826     tmp_dir = 'tmp'
827     if not os.path.isdir(tmp_dir):
828         logging.info("Creating temporary directory")
829         os.makedirs(tmp_dir)
830
831     if options.test:
832         output_dir = tmp_dir
833     else:
834         output_dir = 'unsigned'
835         if not os.path.isdir(output_dir):
836             logging.info("Creating output directory")
837             os.makedirs(output_dir)
838
839     if config['archive_older'] != 0:
840         also_check_dir = 'archive'
841     else:
842         also_check_dir = None
843
844     repo_dir = 'repo'
845
846     build_dir = 'build'
847     if not os.path.isdir(build_dir):
848         logging.info("Creating build directory")
849         os.makedirs(build_dir)
850     srclib_dir = os.path.join(build_dir, 'srclib')
851     extlib_dir = os.path.join(build_dir, 'extlib')
852
853     # Get all apps...
854     allapps = metadata.read_metadata(xref=not options.onserver)
855
856     apps = common.read_app_args(args, allapps, True)
857     apps = [app for app in apps if (options.force or not app['Disabled']) and
858             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
859
860     if len(apps) == 0:
861         raise Exception("No apps to process.")
862
863     if options.latest:
864         for app in apps:
865             for build in reversed(app['builds']):
866                 if 'disable' in build:
867                     continue
868                 app['builds'] = [ build ]
869                 break
870
871     if options.wiki:
872         import mwclient
873         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
874                 path=config['wiki_path'])
875         site.login(config['wiki_user'], config['wiki_password'])
876
877     # Build applications...
878     failed_apps = {}
879     build_succeeded = []
880     for app in apps:
881
882         first = True
883
884         for thisbuild in app['builds']:
885             wikilog = None
886             try:
887
888                 # For the first build of a particular app, we need to set up
889                 # the source repo. We can reuse it on subsequent builds, if
890                 # there are any.
891                 if first:
892                     if app['Repo Type'] == 'srclib':
893                         build_dir = os.path.join('build', 'srclib', app['Repo'])
894                     else:
895                         build_dir = os.path.join('build', app['id'])
896
897                     # Set up vcs interface and make sure we have the latest code...
898                     logging.info("Getting {0} vcs interface for {1}".format(
899                             app['Repo Type'], app['Repo']))
900                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
901
902                     first = False
903
904                 logging.info("Checking " + thisbuild['version'])
905                 if trybuild(app, thisbuild, build_dir, output_dir, also_check_dir,
906                         srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, options.test,
907                         options.server, options.force, options.onserver):
908                     build_succeeded.append(app)
909                     wikilog = "Build succeeded"
910             except BuildException as be:
911                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
912                 logfile.write(str(be))
913                 logfile.close()
914                 print("Could not build app %s due to BuildException: %s" % (app['id'], be))
915                 if options.stop:
916                     sys.exit(1)
917                 failed_apps[app['id']] = be
918                 wikilog = be.get_wikitext()
919             except VCSException as vcse:
920                 print("VCS error while building app %s: %s" % (app['id'], vcse))
921                 if options.stop:
922                     sys.exit(1)
923                 failed_apps[app['id']] = vcse
924                 wikilog = str(vcse)
925             except Exception as e:
926                 print("Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc()))
927                 if options.stop:
928                     sys.exit(1)
929                 failed_apps[app['id']] = e
930                 wikilog = str(e)
931
932             if options.wiki and wikilog:
933                 try:
934                     newpage = site.Pages[app['id'] + '/lastbuild']
935                     txt = wikilog
936                     if len(txt) > 8192:
937                         txt = txt[-8192:]
938                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + txt
939                     newpage.save(txt, summary='Build log')
940                 except:
941                     logging.info("Error while attempting to publish build log")
942
943     for app in build_succeeded:
944         logging.info("success: %s" % (app['id']))
945
946     if not options.verbose:
947         for fa in failed_apps:
948             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
949
950     logging.info("Finished.")
951     if len(build_succeeded) > 0:
952         logging.info(str(len(build_succeeded)) + ' builds succeeded')
953     if len(failed_apps) > 0:
954         logging.info(str(len(failed_apps)) + ' builds failed')
955
956     sys.exit(0)
957
958 if __name__ == "__main__":
959     main()
960