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