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