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             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             logging.info("Building native code in '%s'" % d)
466             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
467             if os.path.exists(manifest):
468                 # Read and write the whole AM.xml to fix newlines and avoid
469                 # the ndk r8c or later 'wordlist' errors. The outcome of this
470                 # under gnu/linux is the same as when using tools like
471                 # dos2unix, but the native python way is faster and will
472                 # work in non-unix systems.
473                 manifest_text = open(manifest, 'U').read()
474                 open(manifest, 'w').write(manifest_text)
475                 # In case the AM.xml read was big, free the memory
476                 del manifest_text
477             p = FDroidPopen([ndkbuild], cwd=os.path.join(root_dir,d))
478             if p.returncode != 0:
479                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
480
481     p = None
482     # Build the release...
483     if thisbuild['type'] == 'maven':
484         logging.info("Building Maven project...")
485
486         if '@' in thisbuild['maven']:
487             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
488         else:
489             maven_dir = root_dir
490
491         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
492                 '-Dandroid.sign.debug=false', '-Dmaven.test.skip=true',
493                 '-Dandroid.release=true', 'package']
494         if 'target' in thisbuild:
495             target = thisbuild["target"].split('-')[1]
496             subprocess.call(['sed', '-i',
497                     's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
498                     'pom.xml'], cwd=root_dir)
499             if '@' in thisbuild['maven']:
500                 subprocess.call(['sed', '-i',
501                         's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
502                         'pom.xml'], cwd=maven_dir)
503
504         if 'mvnflags' in thisbuild:
505             mvncmd += thisbuild['mvnflags']
506
507         p = FDroidPopen(mvncmd, cwd=maven_dir)
508
509         bindir = os.path.join(root_dir, 'target')
510
511     elif thisbuild['type'] == 'kivy':
512         logging.info("Building Kivy project...")
513
514         spec = os.path.join(root_dir, 'buildozer.spec')
515         if not os.path.exists(spec):
516             raise BuildException("Expected to find buildozer-compatible spec at {0}"
517                     .format(spec))
518
519         defaults = {'orientation': 'landscape', 'icon': '',
520                 'permissions': '', 'android.api': "18"}
521         bconfig = ConfigParser(defaults, allow_no_value=True)
522         bconfig.read(spec)
523
524         distdir = 'python-for-android/dist/fdroid'
525         if os.path.exists(distdir):
526             shutil.rmtree(distdir)
527
528         modules = bconfig.get('app', 'requirements').split(',')
529
530         cmd = 'ANDROIDSDK=' + config['sdk_path']
531         cmd += ' ANDROIDNDK=' + config['ndk_path']
532         cmd += ' ANDROIDNDKVER=r9'
533         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
534         cmd += ' VIRTUALENV=virtualenv'
535         cmd += ' ./distribute.sh'
536         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
537         cmd += ' -d fdroid'
538         if subprocess.call(cmd, cwd='python-for-android', shell=True) != 0:
539             raise BuildException("Distribute build failed")
540
541         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
542         if cid != app['id']:
543             raise BuildException("Package ID mismatch between metadata and spec")
544
545         orientation = bconfig.get('app', 'orientation', 'landscape')
546         if orientation == 'all':
547             orientation = 'sensor'
548
549         cmd = ['./build.py'
550                 '--dir', root_dir,
551                 '--name', bconfig.get('app', 'title'),
552                 '--package', app['id'],
553                 '--version', bconfig.get('app', 'version'),
554                 '--orientation', orientation,
555                 ]
556
557         perms = bconfig.get('app', 'permissions')
558         for perm in perms.split(','):
559             cmd.extend(['--permission', perm])
560
561         if config.get('app', 'fullscreen') == 0:
562             cmd.append('--window')
563
564         icon = bconfig.get('app', 'icon.filename')
565         if icon:
566             cmd.extend(['--icon', os.path.join(root_dir, icon)])
567
568         cmd.append('release')
569         p = FDroidPopen(cmd, cwd=distdir)
570
571     elif thisbuild['type'] == 'gradle':
572         logging.info("Building Gradle project...")
573         if '@' in thisbuild['gradle']:
574             flavours = thisbuild['gradle'].split('@')[0].split(',')
575             gradle_dir = thisbuild['gradle'].split('@')[1]
576             gradle_dir = os.path.join(root_dir, gradle_dir)
577         else:
578             flavours = thisbuild['gradle'].split(',')
579             gradle_dir = root_dir
580
581         if len(flavours) == 1 and flavours[0] in ['main', 'yes', '']:
582             flavours[0] = ''
583
584         commands = [config['gradle']]
585         if 'preassemble' in thisbuild:
586             commands += thisbuild['preassemble'].split()
587         commands += ['assemble'+''.join(flavours)+'Release']
588
589         p = FDroidPopen(commands, cwd=gradle_dir)
590
591     else:
592         logging.info("Building Ant project...")
593         cmd = ['ant']
594         if 'antcommand' in thisbuild:
595             cmd += [thisbuild['antcommand']]
596         else:
597             cmd += ['release']
598         p = FDroidPopen(cmd, cwd=root_dir)
599
600         bindir = os.path.join(root_dir, 'bin')
601
602     if p.returncode != 0:
603         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
604     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
605
606     if thisbuild['type'] == 'maven':
607         stdout_apk = '\n'.join([
608             line for line in p.stdout.splitlines() if any(a in line for a in ('.apk','.ap_'))])
609         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
610                 stdout_apk, re.S|re.M)
611         if not m:
612             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
613                     stdout_apk, re.S|re.M)
614         if not m:
615             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
616                     stdout_apk, re.S|re.M)
617         if not m:
618             raise BuildException('Failed to find output')
619         src = m.group(1)
620         src = os.path.join(bindir, src) + '.apk'
621     elif thisbuild['type'] == 'kivy':
622         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
623                 bconfig.get('app', 'title'), bconfig.get('app', 'version'))
624     elif thisbuild['type'] == 'gradle':
625         dd = build_dir
626         if 'subdir' in thisbuild:
627             dd = os.path.join(dd, thisbuild['subdir'])
628         if len(flavours) == 1 and flavours[0] == '':
629             name = '-'.join([os.path.basename(dd), 'release', 'unsigned'])
630         else:
631             name = '-'.join([os.path.basename(dd), '-'.join(flavours), 'release', 'unsigned'])
632         src = os.path.join(dd, 'build', 'apk', name+'.apk')
633     elif thisbuild['type'] == 'ant':
634         stdout_apk = '\n'.join([
635             line for line in p.stdout.splitlines() if '.apk' in line])
636         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
637             re.S|re.M).group(1)
638         src = os.path.join(bindir, src)
639
640     # Make sure it's not debuggable...
641     if common.isApkDebuggable(src, config):
642         raise BuildException("APK is debuggable")
643
644     # By way of a sanity check, make sure the version and version
645     # code in our new apk match what we expect...
646     logging.info("Checking " + src)
647     if not os.path.exists(src):
648         raise BuildException("Unsigned apk is not at expected location of " + src)
649
650     p = FDroidPopen([os.path.join(config['sdk_path'],
651                         'build-tools', config['build_tools'], 'aapt'),
652                         'dump', 'badging', src], output=False)
653
654     vercode = None
655     version = None
656     foundid = None
657     for line in p.stdout.splitlines():
658         if line.startswith("package:"):
659             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
660             m = pat.match(line)
661             if m:
662                 foundid = m.group(1)
663             pat = re.compile(".*versionCode='([0-9]*)'.*")
664             m = pat.match(line)
665             if m:
666                 vercode = m.group(1)
667             pat = re.compile(".*versionName='([^']*)'.*")
668             m = pat.match(line)
669             if m:
670                 version = m.group(1)
671
672     if thisbuild['novcheck']:
673         vercode = thisbuild['vercode']
674         version = thisbuild['version']
675     if not version or not vercode:
676         raise BuildException("Could not find version information in build in output")
677     if not foundid:
678         raise BuildException("Could not find package ID in output")
679     if foundid != app['id']:
680         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
681
682     # Some apps (e.g. Timeriffic) have had the bonkers idea of
683     # including the entire changelog in the version number. Remove
684     # it so we can compare. (TODO: might be better to remove it
685     # before we compile, in fact)
686     index = version.find(" //")
687     if index != -1:
688         version = version[:index]
689
690     if (version != thisbuild['version'] or
691             vercode != thisbuild['vercode']):
692         raise BuildException(("Unexpected version/version code in output;"
693                              " APK: '%s' / '%s', "
694                              " Expected: '%s' / '%s'")
695                              % (version, str(vercode), thisbuild['version'], str(thisbuild['vercode']))
696                             )
697
698     # Copy the unsigned apk to our destination directory for further
699     # processing (by publish.py)...
700     dest = os.path.join(output_dir, common.getapkname(app,thisbuild))
701     shutil.copyfile(src, dest)
702
703     # Move the source tarball into the output directory...
704     if output_dir != tmp_dir:
705         shutil.move(os.path.join(tmp_dir, tarname),
706             os.path.join(output_dir, tarname))
707
708
709 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
710         tmp_dir, repo_dir, vcs, test, server, force, onserver):
711     """
712     Build a particular version of an application, if it needs building.
713
714     :param output_dir: The directory where the build output will go. Usually
715        this is the 'unsigned' directory.
716     :param repo_dir: The repo directory - used for checking if the build is
717        necessary.
718     :paaram also_check_dir: An additional location for checking if the build
719        is necessary (usually the archive repo)
720     :param test: True if building in test mode, in which case the build will
721        always happen, even if the output already exists. In test mode, the
722        output directory should be a temporary location, not any of the real
723        ones.
724
725     :returns: True if the build was done, False if it wasn't necessary.
726     """
727
728     dest_apk = common.getapkname(app, thisbuild)
729
730     dest = os.path.join(output_dir, dest_apk)
731     dest_repo = os.path.join(repo_dir, dest_apk)
732
733     if not test:
734         if os.path.exists(dest) or os.path.exists(dest_repo):
735             return False
736
737         if also_check_dir:
738             dest_also = os.path.join(also_check_dir, dest_apk)
739             if os.path.exists(dest_also):
740                 return False
741
742     if 'disable' in thisbuild:
743         return False
744
745     logging.info("Building version " + thisbuild['version'] + ' of ' + app['id'])
746
747     if server:
748         # When using server mode, still keep a local cache of the repo, by
749         # grabbing the source now.
750         vcs.gotorevision(thisbuild['commit'])
751
752         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
753     else:
754         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
755     return True
756
757
758 def parse_commandline():
759     """Parse the command line. Returns options, args."""
760
761     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
762     parser.add_option("-v", "--verbose", action="store_true", default=False,
763                       help="Spew out even more information than normal")
764     parser.add_option("-l", "--latest", action="store_true", default=False,
765                       help="Build only the latest version of each package")
766     parser.add_option("-s", "--stop", action="store_true", default=False,
767                       help="Make the build stop on exceptions")
768     parser.add_option("-t", "--test", action="store_true", default=False,
769                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
770     parser.add_option("--server", action="store_true", default=False,
771                       help="Use build server")
772     parser.add_option("--resetserver", action="store_true", default=False,
773                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
774     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
775                       help="Specify that we're running on the build server")
776     parser.add_option("-f", "--force", action="store_true", default=False,
777                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
778     parser.add_option("-a", "--all", action="store_true", default=False,
779                       help="Build all applications available")
780     parser.add_option("-w", "--wiki", default=False, action="store_true",
781                       help="Update the wiki")
782     options, args = parser.parse_args()
783
784     # Force --stop with --on-server to get cotrect exit code
785     if options.onserver:
786         options.stop = True
787
788     if options.force and not options.test:
789         raise OptionError("Force is only allowed in test mode", "force")
790
791     return options, args
792
793 options = None
794 config = None
795
796 def main():
797
798     global options, config
799
800     options, args = parse_commandline()
801     if not args and not options.all:
802         raise OptionError("If you really want to build all the apps, use --all", "all")
803
804     config = common.read_config(options)
805
806     if config['build_server_always']:
807         options.server = True
808     if options.resetserver and not options.server:
809         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
810
811     log_dir = 'logs'
812     if not os.path.isdir(log_dir):
813         logging.info("Creating log directory")
814         os.makedirs(log_dir)
815
816     tmp_dir = 'tmp'
817     if not os.path.isdir(tmp_dir):
818         logging.info("Creating temporary directory")
819         os.makedirs(tmp_dir)
820
821     if options.test:
822         output_dir = tmp_dir
823     else:
824         output_dir = 'unsigned'
825         if not os.path.isdir(output_dir):
826             logging.info("Creating output directory")
827             os.makedirs(output_dir)
828
829     if config['archive_older'] != 0:
830         also_check_dir = 'archive'
831     else:
832         also_check_dir = None
833
834     repo_dir = 'repo'
835
836     build_dir = 'build'
837     if not os.path.isdir(build_dir):
838         logging.info("Creating build directory")
839         os.makedirs(build_dir)
840     srclib_dir = os.path.join(build_dir, 'srclib')
841     extlib_dir = os.path.join(build_dir, 'extlib')
842
843     # Get all apps...
844     allapps = metadata.read_metadata(xref=not options.onserver)
845
846     apps = common.read_app_args(args, allapps, True)
847     apps = [app for app in apps if (options.force or not app['Disabled']) and
848             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
849
850     if len(apps) == 0:
851         raise Exception("No apps to process.")
852
853     if options.latest:
854         for app in apps:
855             for build in reversed(app['builds']):
856                 if 'disable' in build:
857                     continue
858                 app['builds'] = [ build ]
859                 break
860
861     if options.wiki:
862         import mwclient
863         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
864                 path=config['wiki_path'])
865         site.login(config['wiki_user'], config['wiki_password'])
866
867     # Build applications...
868     failed_apps = {}
869     build_succeeded = []
870     for app in apps:
871
872         first = True
873
874         for thisbuild in app['builds']:
875             wikilog = None
876             try:
877
878                 # For the first build of a particular app, we need to set up
879                 # the source repo. We can reuse it on subsequent builds, if
880                 # there are any.
881                 if first:
882                     if app['Repo Type'] == 'srclib':
883                         build_dir = os.path.join('build', 'srclib', app['Repo'])
884                     else:
885                         build_dir = os.path.join('build', app['id'])
886
887                     # Set up vcs interface and make sure we have the latest code...
888                     logging.info("Getting {0} vcs interface for {1}".format(
889                             app['Repo Type'], app['Repo']))
890                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
891
892                     first = False
893
894                 logging.info("Checking " + thisbuild['version'])
895                 if trybuild(app, thisbuild, build_dir, output_dir, also_check_dir,
896                         srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, options.test,
897                         options.server, options.force, options.onserver):
898                     build_succeeded.append(app)
899                     wikilog = "Build succeeded"
900             except BuildException as be:
901                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
902                 logfile.write(str(be))
903                 logfile.close()
904                 logging.info("Could not build app %s due to BuildException: %s" % (app['id'], be))
905                 if options.stop:
906                     sys.exit(1)
907                 failed_apps[app['id']] = be
908                 wikilog = be.get_wikitext()
909             except VCSException as vcse:
910                 logging.info("VCS error while building app %s: %s" % (app['id'], vcse))
911                 if options.stop:
912                     sys.exit(1)
913                 failed_apps[app['id']] = vcse
914                 wikilog = str(vcse)
915             except Exception as e:
916                 logging.info("Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc()))
917                 if options.stop:
918                     sys.exit(1)
919                 failed_apps[app['id']] = e
920                 wikilog = str(e)
921
922             if options.wiki and wikilog:
923                 try:
924                     newpage = site.Pages[app['id'] + '/lastbuild']
925                     txt = wikilog
926                     if len(txt) > 8192:
927                         txt = txt[-8192:]
928                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + txt
929                     newpage.save(txt, summary='Build log')
930                 except:
931                     logging.info("Error while attempting to publish build log")
932
933     for app in build_succeeded:
934         logging.info("success: %s" % (app['id']))
935
936     if not options.verbose:
937         for fa in failed_apps:
938             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
939
940     logging.info("Finished.")
941     if len(build_succeeded) > 0:
942         logging.info(str(len(build_succeeded)) + ' builds succeeded')
943     if len(failed_apps) > 0:
944         logging.info(str(len(failed_apps)) + ' builds failed')
945
946     sys.exit(0)
947
948 if __name__ == "__main__":
949     main()
950