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