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