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