chiark / gitweb /
Don't use generic Exception raises
[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 FDroidException, 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.debug("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         adapt_gradle(build_dir)
477         for name, number, libpath in srclibpaths:
478             adapt_gradle(libpath)
479
480         p = FDroidPopen(cmd, cwd=root_dir)
481
482     elif thisbuild['type'] == 'kivy':
483         pass
484
485     elif thisbuild['type'] == 'ant':
486         logging.info("Cleaning Ant project...")
487         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
488
489     if p is not None and p.returncode != 0:
490         raise BuildException("Error cleaning %s:%s" %
491                              (app['id'], thisbuild['version']), p.output)
492
493     for root, dirs, files in os.walk(build_dir):
494         # Don't remove possibly necessary 'gradle' dirs if 'gradlew' is not there
495         if 'gradlew' in files:
496             logging.debug("Getting rid of Gradle wrapper stuff in %s" % root)
497             os.remove(os.path.join(root, 'gradlew'))
498             if 'gradlew.bat' in files:
499                 os.remove(os.path.join(root, 'gradlew.bat'))
500             if 'gradle' in dirs:
501                 shutil.rmtree(os.path.join(root, 'gradle'))
502
503     if not options.skipscan:
504         # Scan before building...
505         logging.info("Scanning source for common problems...")
506         count = common.scan_source(build_dir, root_dir, thisbuild)
507         if count > 0:
508             if force:
509                 logging.warn('Scanner found %d problems:' % count)
510             else:
511                 raise BuildException("Can't build due to %d errors while scanning" % count)
512
513     if not options.notarball:
514         # Build the source tarball right before we build the release...
515         logging.info("Creating source tarball...")
516         tarname = common.getsrcname(app, thisbuild)
517         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
518
519         def tarexc(f):
520             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
521         tarball.add(build_dir, tarname, exclude=tarexc)
522         tarball.close()
523
524     if onserver:
525         manifest = os.path.join(root_dir, 'AndroidManifest.xml')
526         if os.path.exists(manifest):
527             homedir = os.path.expanduser('~')
528             with open(os.path.join(homedir, 'buildserverid'), 'r') as f:
529                 buildserverid = f.read()
530             with open(os.path.join(homedir, 'fdroidserverid'), 'r') as f:
531                 fdroidserverid = f.read()
532             with open(manifest, 'r') as f:
533                 manifestcontent = f.read()
534             manifestcontent = manifestcontent.replace('</manifest>',
535                                                       '<fdroid buildserverid="'
536                                                       + buildserverid + '"'
537                                                       + ' fdroidserverid="'
538                                                       + fdroidserverid + '"'
539                                                       + '/></manifest>')
540             with open(manifest, 'w') as f:
541                 f.write(manifestcontent)
542
543     # Run a build command if one is required...
544     if thisbuild['build']:
545         logging.info("Running 'build' commands in %s" % root_dir)
546         cmd = common.replace_config_vars(thisbuild['build'])
547
548         # Substitute source library paths into commands...
549         for name, number, libpath in srclibpaths:
550             libpath = os.path.relpath(libpath, root_dir)
551             cmd = cmd.replace('$$' + name + '$$', libpath)
552
553         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
554
555         if p.returncode != 0:
556             raise BuildException("Error running build command for %s:%s" %
557                                  (app['id'], thisbuild['version']), p.output)
558
559     # Build native stuff if required...
560     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
561         logging.info("Building the native code")
562         jni_components = thisbuild['buildjni']
563
564         if jni_components == ['yes']:
565             jni_components = ['']
566         cmd = [os.path.join(config['ndk_path'], "ndk-build"), "-j1"]
567         for d in jni_components:
568             if d:
569                 logging.info("Building native code in '%s'" % d)
570             else:
571                 logging.info("Building native code in the main project")
572             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
573             if os.path.exists(manifest):
574                 # Read and write the whole AM.xml to fix newlines and avoid
575                 # the ndk r8c or later 'wordlist' errors. The outcome of this
576                 # under gnu/linux is the same as when using tools like
577                 # dos2unix, but the native python way is faster and will
578                 # work in non-unix systems.
579                 manifest_text = open(manifest, 'U').read()
580                 open(manifest, 'w').write(manifest_text)
581                 # In case the AM.xml read was big, free the memory
582                 del manifest_text
583             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
584             if p.returncode != 0:
585                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.output)
586
587     p = None
588     # Build the release...
589     if thisbuild['type'] == 'maven':
590         logging.info("Building Maven project...")
591
592         if '@' in thisbuild['maven']:
593             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
594         else:
595             maven_dir = root_dir
596
597         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
598                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
599                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
600                   'package']
601         if thisbuild['target']:
602             target = thisbuild["target"].split('-')[1]
603             FDroidPopen(['sed', '-i',
604                          's@<platform>[0-9]*</platform>@<platform>'
605                          + target + '</platform>@g',
606                          'pom.xml'],
607                         cwd=root_dir)
608             if '@' in thisbuild['maven']:
609                 FDroidPopen(['sed', '-i',
610                              's@<platform>[0-9]*</platform>@<platform>'
611                              + target + '</platform>@g',
612                              'pom.xml'],
613                             cwd=maven_dir)
614
615         p = FDroidPopen(mvncmd, cwd=maven_dir)
616
617         bindir = os.path.join(root_dir, 'target')
618
619     elif thisbuild['type'] == 'kivy':
620         logging.info("Building Kivy project...")
621
622         spec = os.path.join(root_dir, 'buildozer.spec')
623         if not os.path.exists(spec):
624             raise BuildException("Expected to find buildozer-compatible spec at {0}"
625                                  .format(spec))
626
627         defaults = {'orientation': 'landscape', 'icon': '',
628                     'permissions': '', 'android.api': "18"}
629         bconfig = ConfigParser(defaults, allow_no_value=True)
630         bconfig.read(spec)
631
632         distdir = 'python-for-android/dist/fdroid'
633         if os.path.exists(distdir):
634             shutil.rmtree(distdir)
635
636         modules = bconfig.get('app', 'requirements').split(',')
637
638         cmd = 'ANDROIDSDK=' + config['sdk_path']
639         cmd += ' ANDROIDNDK=' + config['ndk_path']
640         cmd += ' ANDROIDNDKVER=r9'
641         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
642         cmd += ' VIRTUALENV=virtualenv'
643         cmd += ' ./distribute.sh'
644         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
645         cmd += ' -d fdroid'
646         p = FDroidPopen(cmd, cwd='python-for-android', shell=True)
647         if p.returncode != 0:
648             raise BuildException("Distribute build failed")
649
650         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
651         if cid != app['id']:
652             raise BuildException("Package ID mismatch between metadata and spec")
653
654         orientation = bconfig.get('app', 'orientation', 'landscape')
655         if orientation == 'all':
656             orientation = 'sensor'
657
658         cmd = ['./build.py'
659                '--dir', root_dir,
660                '--name', bconfig.get('app', 'title'),
661                '--package', app['id'],
662                '--version', bconfig.get('app', 'version'),
663                '--orientation', orientation
664                ]
665
666         perms = bconfig.get('app', 'permissions')
667         for perm in perms.split(','):
668             cmd.extend(['--permission', perm])
669
670         if config.get('app', 'fullscreen') == 0:
671             cmd.append('--window')
672
673         icon = bconfig.get('app', 'icon.filename')
674         if icon:
675             cmd.extend(['--icon', os.path.join(root_dir, icon)])
676
677         cmd.append('release')
678         p = FDroidPopen(cmd, cwd=distdir)
679
680     elif thisbuild['type'] == 'gradle':
681         logging.info("Building Gradle project...")
682         flavours = thisbuild['gradle'].split(',')
683
684         if len(flavours) == 1 and flavours[0] in ['main', 'yes', '']:
685             flavours[0] = ''
686
687         commands = [config['gradle']]
688         if thisbuild['preassemble']:
689             commands += thisbuild['preassemble'].split()
690
691         flavours_cmd = ''.join(flavours)
692         if flavours_cmd:
693             flavours_cmd = flavours_cmd[0].upper() + flavours_cmd[1:]
694
695         commands += ['assemble' + flavours_cmd + 'Release']
696
697         # Avoid having to use lintOptions.abortOnError false
698         if thisbuild['gradlepluginver'] >= LooseVersion('0.8'):
699             commands += ['-x', 'lintVital' + flavours_cmd + 'Release']
700
701         p = FDroidPopen(commands, cwd=root_dir)
702
703     elif thisbuild['type'] == 'ant':
704         logging.info("Building Ant project...")
705         cmd = ['ant']
706         if thisbuild['antcommand']:
707             cmd += [thisbuild['antcommand']]
708         else:
709             cmd += ['release']
710         p = FDroidPopen(cmd, cwd=root_dir)
711
712         bindir = os.path.join(root_dir, 'bin')
713
714     if p is not None and p.returncode != 0:
715         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.output)
716     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
717
718     if thisbuild['type'] == 'maven':
719         stdout_apk = '\n'.join([
720             line for line in p.output.splitlines() if any(a in line for a in ('.apk', '.ap_'))])
721         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
722                      stdout_apk, re.S | re.M)
723         if not m:
724             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
725                          stdout_apk, re.S | re.M)
726         if not m:
727             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
728                          stdout_apk, re.S | re.M)
729         if not m:
730             raise BuildException('Failed to find output')
731         src = m.group(1)
732         src = os.path.join(bindir, src) + '.apk'
733     elif thisbuild['type'] == 'kivy':
734         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
735             bconfig.get('app', 'title'), bconfig.get('app', 'version'))
736     elif thisbuild['type'] == 'gradle':
737         basename = app['id']
738         dd = build_dir
739         if thisbuild['subdir']:
740             dd = os.path.join(dd, thisbuild['subdir'])
741             basename = os.path.basename(thisbuild['subdir'])
742         if '@' in thisbuild['gradle']:
743             dd = os.path.join(dd, thisbuild['gradle'].split('@')[1])
744             basename = app['id']
745         if len(flavours) == 1 and flavours[0] == '':
746             name = '-'.join([basename, 'release', 'unsigned'])
747         else:
748             name = '-'.join([basename, '-'.join(flavours), 'release', 'unsigned'])
749         dd = os.path.normpath(dd)
750         if thisbuild['gradlepluginver'] >= LooseVersion('0.11'):
751             src = os.path.join(dd, 'build', 'outputs', 'apk', name + '.apk')
752         else:
753             src = os.path.join(dd, 'build', 'apk', name + '.apk')
754     elif thisbuild['type'] == 'ant':
755         stdout_apk = '\n'.join([
756             line for line in p.output.splitlines() if '.apk' in line])
757         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
758                        re.S | re.M).group(1)
759         src = os.path.join(bindir, src)
760     elif thisbuild['type'] == 'raw':
761         src = os.path.join(root_dir, thisbuild['output'])
762         src = os.path.normpath(src)
763
764     # Make sure it's not debuggable...
765     if common.isApkDebuggable(src, config):
766         raise BuildException("APK is debuggable")
767
768     # By way of a sanity check, make sure the version and version
769     # code in our new apk match what we expect...
770     logging.debug("Checking " + src)
771     if not os.path.exists(src):
772         raise BuildException("Unsigned apk is not at expected location of " + src)
773
774     p = SilentPopen([config['aapt'], 'dump', 'badging', src])
775
776     vercode = None
777     version = None
778     foundid = None
779     nativecode = None
780     for line in p.output.splitlines():
781         if line.startswith("package:"):
782             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
783             m = pat.match(line)
784             if m:
785                 foundid = m.group(1)
786             pat = re.compile(".*versionCode='([0-9]*)'.*")
787             m = pat.match(line)
788             if m:
789                 vercode = m.group(1)
790             pat = re.compile(".*versionName='([^']*)'.*")
791             m = pat.match(line)
792             if m:
793                 version = m.group(1)
794         elif line.startswith("native-code:"):
795             nativecode = line[12:]
796
797     # Ignore empty strings or any kind of space/newline chars that we don't
798     # care about
799     if nativecode is not None:
800         nativecode = nativecode.strip()
801         nativecode = None if not nativecode else nativecode
802
803     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
804         if nativecode is None:
805             raise BuildException("Native code should have been built but none was packaged")
806     if thisbuild['novcheck']:
807         vercode = thisbuild['vercode']
808         version = thisbuild['version']
809     if not version or not vercode:
810         raise BuildException("Could not find version information in build in output")
811     if not foundid:
812         raise BuildException("Could not find package ID in output")
813     if foundid != app['id']:
814         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
815
816     # Some apps (e.g. Timeriffic) have had the bonkers idea of
817     # including the entire changelog in the version number. Remove
818     # it so we can compare. (TODO: might be better to remove it
819     # before we compile, in fact)
820     index = version.find(" //")
821     if index != -1:
822         version = version[:index]
823
824     if (version != thisbuild['version'] or
825             vercode != thisbuild['vercode']):
826         raise BuildException(("Unexpected version/version code in output;"
827                               " APK: '%s' / '%s', "
828                               " Expected: '%s' / '%s'")
829                              % (version, str(vercode), thisbuild['version'],
830                                 str(thisbuild['vercode']))
831                              )
832
833     # Copy the unsigned apk to our destination directory for further
834     # processing (by publish.py)...
835     dest = os.path.join(output_dir, common.getapkname(app, thisbuild))
836     shutil.copyfile(src, dest)
837
838     # Move the source tarball into the output directory...
839     if output_dir != tmp_dir and not options.notarball:
840         shutil.move(os.path.join(tmp_dir, tarname),
841                     os.path.join(output_dir, tarname))
842
843
844 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
845              tmp_dir, repo_dir, vcs, test, server, force, onserver):
846     """
847     Build a particular version of an application, if it needs building.
848
849     :param output_dir: The directory where the build output will go. Usually
850        this is the 'unsigned' directory.
851     :param repo_dir: The repo directory - used for checking if the build is
852        necessary.
853     :paaram also_check_dir: An additional location for checking if the build
854        is necessary (usually the archive repo)
855     :param test: True if building in test mode, in which case the build will
856        always happen, even if the output already exists. In test mode, the
857        output directory should be a temporary location, not any of the real
858        ones.
859
860     :returns: True if the build was done, False if it wasn't necessary.
861     """
862
863     dest_apk = common.getapkname(app, thisbuild)
864
865     dest = os.path.join(output_dir, dest_apk)
866     dest_repo = os.path.join(repo_dir, dest_apk)
867
868     if not test:
869         if os.path.exists(dest) or os.path.exists(dest_repo):
870             return False
871
872         if also_check_dir:
873             dest_also = os.path.join(also_check_dir, dest_apk)
874             if os.path.exists(dest_also):
875                 return False
876
877     if thisbuild['disable']:
878         return False
879
880     logging.info("Building version %s (%s) of %s" % (
881         thisbuild['version'], thisbuild['vercode'], app['id']))
882
883     if server:
884         # When using server mode, still keep a local cache of the repo, by
885         # grabbing the source now.
886         vcs.gotorevision(thisbuild['commit'])
887
888         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
889     else:
890         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
891     return True
892
893
894 def parse_commandline():
895     """Parse the command line. Returns options, args."""
896
897     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
898     parser.add_option("-v", "--verbose", action="store_true", default=False,
899                       help="Spew out even more information than normal")
900     parser.add_option("-q", "--quiet", action="store_true", default=False,
901                       help="Restrict output to warnings and errors")
902     parser.add_option("-l", "--latest", action="store_true", default=False,
903                       help="Build only the latest version of each package")
904     parser.add_option("-s", "--stop", action="store_true", default=False,
905                       help="Make the build stop on exceptions")
906     parser.add_option("-t", "--test", action="store_true", default=False,
907                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
908     parser.add_option("--server", action="store_true", default=False,
909                       help="Use build server")
910     parser.add_option("--resetserver", action="store_true", default=False,
911                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
912     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
913                       help="Specify that we're running on the build server")
914     parser.add_option("--skip-scan", dest="skipscan", action="store_true", default=False,
915                       help="Skip scanning the source code for binaries and other problems")
916     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
917                       help="Don't create a source tarball, useful when testing a build")
918     parser.add_option("-f", "--force", action="store_true", default=False,
919                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
920     parser.add_option("-a", "--all", action="store_true", default=False,
921                       help="Build all applications available")
922     parser.add_option("-w", "--wiki", default=False, action="store_true",
923                       help="Update the wiki")
924     options, args = parser.parse_args()
925
926     # Force --stop with --on-server to get correct exit code
927     if options.onserver:
928         options.stop = True
929
930     if options.force and not options.test:
931         raise OptionError("Force is only allowed in test mode", "force")
932
933     return options, args
934
935 options = None
936 config = None
937
938
939 def main():
940
941     global options, config
942
943     options, args = parse_commandline()
944     if not args and not options.all:
945         raise OptionError("If you really want to build all the apps, use --all", "all")
946
947     config = common.read_config(options)
948
949     if config['build_server_always']:
950         options.server = True
951     if options.resetserver and not options.server:
952         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
953
954     log_dir = 'logs'
955     if not os.path.isdir(log_dir):
956         logging.info("Creating log directory")
957         os.makedirs(log_dir)
958
959     tmp_dir = 'tmp'
960     if not os.path.isdir(tmp_dir):
961         logging.info("Creating temporary directory")
962         os.makedirs(tmp_dir)
963
964     if options.test:
965         output_dir = tmp_dir
966     else:
967         output_dir = 'unsigned'
968         if not os.path.isdir(output_dir):
969             logging.info("Creating output directory")
970             os.makedirs(output_dir)
971
972     if config['archive_older'] != 0:
973         also_check_dir = 'archive'
974     else:
975         also_check_dir = None
976
977     repo_dir = 'repo'
978
979     build_dir = 'build'
980     if not os.path.isdir(build_dir):
981         logging.info("Creating build directory")
982         os.makedirs(build_dir)
983     srclib_dir = os.path.join(build_dir, 'srclib')
984     extlib_dir = os.path.join(build_dir, 'extlib')
985
986     # Read all app and srclib metadata
987     allapps = metadata.read_metadata(xref=not options.onserver)
988
989     apps = common.read_app_args(args, allapps, True)
990     apps = [app for app in apps if (options.force or not app['Disabled']) and
991             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
992
993     if len(apps) == 0:
994         raise FDroidException("No apps to process.")
995
996     if options.latest:
997         for app in apps:
998             for build in reversed(app['builds']):
999                 if build['disable']:
1000                     continue
1001                 app['builds'] = [build]
1002                 break
1003
1004     if options.wiki:
1005         import mwclient
1006         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1007                              path=config['wiki_path'])
1008         site.login(config['wiki_user'], config['wiki_password'])
1009
1010     # Build applications...
1011     failed_apps = {}
1012     build_succeeded = []
1013     for app in apps:
1014
1015         first = True
1016
1017         for thisbuild in app['builds']:
1018             wikilog = None
1019             try:
1020
1021                 # For the first build of a particular app, we need to set up
1022                 # the source repo. We can reuse it on subsequent builds, if
1023                 # there are any.
1024                 if first:
1025                     if app['Repo Type'] == 'srclib':
1026                         build_dir = os.path.join('build', 'srclib', app['Repo'])
1027                     else:
1028                         build_dir = os.path.join('build', app['id'])
1029
1030                     # Set up vcs interface and make sure we have the latest code...
1031                     logging.debug("Getting {0} vcs interface for {1}"
1032                                   .format(app['Repo Type'], app['Repo']))
1033                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
1034
1035                     first = False
1036
1037                 logging.debug("Checking " + thisbuild['version'])
1038                 if trybuild(app, thisbuild, build_dir, output_dir,
1039                             also_check_dir, srclib_dir, extlib_dir,
1040                             tmp_dir, repo_dir, vcs, options.test,
1041                             options.server, options.force,
1042                             options.onserver):
1043                     build_succeeded.append(app)
1044                     wikilog = "Build succeeded"
1045             except BuildException as be:
1046                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
1047                 logfile.write(str(be))
1048                 logfile.close()
1049                 reason = str(be).split('\n', 1)[0] if options.verbose else str(be)
1050                 logging.error("Could not build app %s due to BuildException: %s" % (
1051                     app['id'], reason))
1052                 if options.stop:
1053                     sys.exit(1)
1054                 failed_apps[app['id']] = be
1055                 wikilog = be.get_wikitext()
1056             except VCSException as vcse:
1057                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1058                 logging.error("VCS error while building app %s: %s" % (
1059                     app['id'], reason))
1060                 if options.stop:
1061                     sys.exit(1)
1062                 failed_apps[app['id']] = vcse
1063                 wikilog = str(vcse)
1064             except Exception as e:
1065                 logging.error("Could not build app %s due to unknown error: %s" % (
1066                     app['id'], traceback.format_exc()))
1067                 if options.stop:
1068                     sys.exit(1)
1069                 failed_apps[app['id']] = e
1070                 wikilog = str(e)
1071
1072             if options.wiki and wikilog:
1073                 try:
1074                     newpage = site.Pages[app['id'] + '/lastbuild']
1075                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1076                     newpage.save(txt, summary='Build log')
1077                 except:
1078                     logging.error("Error while attempting to publish build log")
1079
1080     for app in build_succeeded:
1081         logging.info("success: %s" % (app['id']))
1082
1083     if not options.verbose:
1084         for fa in failed_apps:
1085             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1086
1087     logging.info("Finished.")
1088     if len(build_succeeded) > 0:
1089         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1090     if len(failed_apps) > 0:
1091         logging.info(str(len(failed_apps)) + ' builds failed')
1092
1093     sys.exit(0)
1094
1095 if __name__ == "__main__":
1096     main()