chiark / gitweb /
fix PEP8 "E226 missing whitespace around arithmetic operator"
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # build.py - part of the FDroid server tools
5 # Copyright (C) 2010-2014, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU Affero General Public License for more details.
17 #
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 import sys
22 import os
23 import shutil
24 import subprocess
25 import re
26 import tarfile
27 import traceback
28 import time
29 import json
30 from ConfigParser import ConfigParser
31 from optparse import OptionParser, OptionError
32 import logging
33
34 import common
35 import metadata
36 from common import BuildException, VCSException, FDroidPopen, SilentPopen
37
38 try:
39     import paramiko
40 except ImportError:
41     pass
42
43
44 def get_builder_vm_id():
45     vd = os.path.join('builder', '.vagrant')
46     if os.path.isdir(vd):
47         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
48         with open(os.path.join(vd, 'machines', 'default',
49                   'virtualbox', 'id')) as vf:
50             id = vf.read()
51         return id
52     else:
53         # Vagrant 1.0 - it's a json file...
54         with open(os.path.join('builder', '.vagrant')) as vf:
55             v = json.load(vf)
56         return v['active']['default']
57
58
59 def got_valid_builder_vm():
60     """Returns True if we have a valid-looking builder vm
61     """
62     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
63         return False
64     vd = os.path.join('builder', '.vagrant')
65     if not os.path.exists(vd):
66         return False
67     if not os.path.isdir(vd):
68         # Vagrant 1.0 - if the directory is there, it's valid...
69         return True
70     # Vagrant 1.2 - the directory can exist, but the id can be missing...
71     if not os.path.exists(os.path.join(vd, 'machines', 'default',
72                           'virtualbox', 'id')):
73         return False
74     return True
75
76
77 def vagrant(params, cwd=None, printout=False):
78     """Run a vagrant command.
79
80     :param: list of parameters to pass to vagrant
81     :cwd: directory to run in, or None for current directory
82     :returns: (ret, out) where ret is the return code, and out
83                is the stdout (and stderr) from vagrant
84     """
85     p = FDroidPopen(['vagrant'] + params, cwd=cwd)
86     return (p.returncode, p.stdout)
87
88
89 def get_vagrant_sshinfo():
90     """Get ssh connection info for a vagrant VM
91
92     :returns: A dictionary containing 'hostname', 'port', 'user'
93         and 'idfile'
94     """
95     if subprocess.call('vagrant ssh-config >sshconfig',
96                        cwd='builder', shell=True) != 0:
97         raise BuildException("Error getting ssh config")
98     vagranthost = 'default'  # Host in ssh config file
99     sshconfig = paramiko.SSHConfig()
100     sshf = open('builder/sshconfig', 'r')
101     sshconfig.parse(sshf)
102     sshf.close()
103     sshconfig = sshconfig.lookup(vagranthost)
104     idfile = sshconfig['identityfile']
105     if isinstance(idfile, list):
106         idfile = idfile[0]
107     elif idfile.startswith('"') and idfile.endswith('"'):
108         idfile = idfile[1:-1]
109     return {'hostname': sshconfig['hostname'],
110             'port': int(sshconfig['port']),
111             'user': sshconfig['user'],
112             'idfile': idfile}
113
114
115 def get_clean_vm(reset=False):
116     """Get a clean VM ready to do a buildserver build.
117
118     This might involve creating and starting a new virtual machine from
119     scratch, or it might be as simple (unless overridden by the reset
120     parameter) as re-using a snapshot created previously.
121
122     A BuildException will be raised if anything goes wrong.
123
124     :reset: True to force creating from scratch.
125     :returns: A dictionary containing 'hostname', 'port', 'user'
126         and 'idfile'
127     """
128     # Reset existing builder machine to a clean state if possible.
129     vm_ok = False
130     if not reset:
131         logging.info("Checking for valid existing build server")
132
133         if got_valid_builder_vm():
134             logging.info("...VM is present")
135             p = FDroidPopen(['VBoxManage', 'snapshot',
136                              get_builder_vm_id(), 'list',
137                              '--details'], cwd='builder')
138             if 'fdroidclean' in p.stdout:
139                 logging.info("...snapshot exists - resetting build server to "
140                              "clean state")
141                 retcode, output = vagrant(['status'], cwd='builder')
142
143                 if 'running' in output:
144                     logging.info("...suspending")
145                     vagrant(['suspend'], cwd='builder')
146                     logging.info("...waiting a sec...")
147                     time.sleep(10)
148                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
149                                  'restore', 'fdroidclean'],
150                                 cwd='builder')
151
152                 if p.returncode == 0:
153                     logging.info("...reset to snapshot - server is valid")
154                     retcode, output = vagrant(['up'], cwd='builder')
155                     if retcode != 0:
156                         raise BuildException("Failed to start build server")
157                     logging.info("...waiting a sec...")
158                     time.sleep(10)
159                     sshinfo = get_vagrant_sshinfo()
160                     vm_ok = True
161                 else:
162                     logging.info("...failed to reset to snapshot")
163             else:
164                 logging.info("...snapshot doesn't exist - "
165                              "VBoxManage snapshot list:\n" + p.stdout)
166
167     # If we can't use the existing machine for any reason, make a
168     # new one from scratch.
169     if not vm_ok:
170         if os.path.exists('builder'):
171             logging.info("Removing broken/incomplete/unwanted build server")
172             vagrant(['destroy', '-f'], cwd='builder')
173             shutil.rmtree('builder')
174         os.mkdir('builder')
175
176         p = subprocess.Popen('vagrant --version', shell=True,
177                              stdout=subprocess.PIPE)
178         vver = p.communicate()[0]
179         if vver.startswith('Vagrant version 1.2'):
180             with open('builder/Vagrantfile', 'w') as vf:
181                 vf.write('Vagrant.configure("2") do |config|\n')
182                 vf.write('config.vm.box = "buildserver"\n')
183                 vf.write('end\n')
184         else:
185             with open('builder/Vagrantfile', 'w') as vf:
186                 vf.write('Vagrant::Config.run do |config|\n')
187                 vf.write('config.vm.box = "buildserver"\n')
188                 vf.write('end\n')
189
190         logging.info("Starting new build server")
191         retcode, _ = vagrant(['up'], cwd='builder')
192         if retcode != 0:
193             raise BuildException("Failed to start build server")
194
195         # Open SSH connection to make sure it's working and ready...
196         logging.info("Connecting to virtual machine...")
197         sshinfo = get_vagrant_sshinfo()
198         sshs = paramiko.SSHClient()
199         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
200         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
201                      port=sshinfo['port'], timeout=300,
202                      look_for_keys=False,
203                      key_filename=sshinfo['idfile'])
204         sshs.close()
205
206         logging.info("Saving clean state of new build server")
207         retcode, _ = vagrant(['suspend'], cwd='builder')
208         if retcode != 0:
209             raise BuildException("Failed to suspend build server")
210         logging.info("...waiting a sec...")
211         time.sleep(10)
212         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
213                          'take', 'fdroidclean'],
214                         cwd='builder')
215         if p.returncode != 0:
216             raise BuildException("Failed to take snapshot")
217         logging.info("...waiting a sec...")
218         time.sleep(10)
219         logging.info("Restarting new build server")
220         retcode, _ = vagrant(['up'], cwd='builder')
221         if retcode != 0:
222             raise BuildException("Failed to start build server")
223         logging.info("...waiting a sec...")
224         time.sleep(10)
225         # Make sure it worked...
226         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
227                          'list', '--details'],
228                         cwd='builder')
229         if 'fdroidclean' not in p.stdout:
230             raise BuildException("Failed to take snapshot.")
231
232     return sshinfo
233
234
235 def release_vm():
236     """Release the VM previously started with get_clean_vm().
237
238     This should always be called.
239     """
240     logging.info("Suspending build server")
241     subprocess.call(['vagrant', 'suspend'], cwd='builder')
242
243
244 # Note that 'force' here also implies test mode.
245 def build_server(app, thisbuild, vcs, build_dir, output_dir, force):
246     """Do a build on the build server."""
247
248     try:
249         paramiko
250     except NameError:
251         raise BuildException("Paramiko is required to use the buildserver")
252     if options.verbose:
253         logging.getLogger("paramiko").setLevel(logging.DEBUG)
254     else:
255         logging.getLogger("paramiko").setLevel(logging.WARN)
256
257     sshinfo = get_clean_vm()
258
259     try:
260
261         # Open SSH connection...
262         logging.info("Connecting to virtual machine...")
263         sshs = paramiko.SSHClient()
264         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
265         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
266                      port=sshinfo['port'], timeout=300,
267                      look_for_keys=False, key_filename=sshinfo['idfile'])
268
269         homedir = '/home/' + sshinfo['user']
270
271         # Get an SFTP connection...
272         ftp = sshs.open_sftp()
273         ftp.get_channel().settimeout(15)
274
275         # Put all the necessary files in place...
276         ftp.chdir(homedir)
277
278         # Helper to copy the contents of a directory to the server...
279         def send_dir(path):
280             root = os.path.dirname(path)
281             main = os.path.basename(path)
282             ftp.mkdir(main)
283             for r, d, f in os.walk(path):
284                 rr = os.path.relpath(r, root)
285                 ftp.chdir(rr)
286                 for dd in d:
287                     ftp.mkdir(dd)
288                 for ff in f:
289                     lfile = os.path.join(root, rr, ff)
290                     if not os.path.islink(lfile):
291                         ftp.put(lfile, ff)
292                         ftp.chmod(ff, os.stat(lfile).st_mode)
293                 for i in range(len(rr.split('/'))):
294                     ftp.chdir('..')
295             ftp.chdir('..')
296
297         logging.info("Preparing server for build...")
298         serverpath = os.path.abspath(os.path.dirname(__file__))
299         ftp.put(os.path.join(serverpath, 'build.py'), 'build.py')
300         ftp.put(os.path.join(serverpath, 'common.py'), 'common.py')
301         ftp.put(os.path.join(serverpath, 'metadata.py'), 'metadata.py')
302         ftp.put(os.path.join(serverpath, '..', 'buildserver',
303                 'config.buildserver.py'), 'config.py')
304         ftp.chmod('config.py', 0o600)
305
306         # Copy over the ID (head commit hash) of the fdroidserver in use...
307         subprocess.call('git rev-parse HEAD >' +
308                         os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
309                         shell=True, cwd=serverpath)
310         ftp.put('tmp/fdroidserverid', 'fdroidserverid')
311
312         # Copy the metadata - just the file for this app...
313         ftp.mkdir('metadata')
314         ftp.mkdir('srclibs')
315         ftp.chdir('metadata')
316         ftp.put(os.path.join('metadata', app['id'] + '.txt'),
317                 app['id'] + '.txt')
318         # And patches if there are any...
319         if os.path.exists(os.path.join('metadata', app['id'])):
320             send_dir(os.path.join('metadata', app['id']))
321
322         ftp.chdir(homedir)
323         # Create the build directory...
324         ftp.mkdir('build')
325         ftp.chdir('build')
326         ftp.mkdir('extlib')
327         ftp.mkdir('srclib')
328         # Copy any extlibs that are required...
329         if 'extlibs' in thisbuild:
330             ftp.chdir(homedir + '/build/extlib')
331             for lib in thisbuild['extlibs']:
332                 lib = lib.strip()
333                 libsrc = os.path.join('build/extlib', lib)
334                 if not os.path.exists(libsrc):
335                     raise BuildException("Missing extlib {0}".format(libsrc))
336                 lp = lib.split('/')
337                 for d in lp[:-1]:
338                     if d not in ftp.listdir():
339                         ftp.mkdir(d)
340                     ftp.chdir(d)
341                 ftp.put(libsrc, lp[-1])
342                 for _ in lp[:-1]:
343                     ftp.chdir('..')
344         # Copy any srclibs that are required...
345         srclibpaths = []
346         if 'srclibs' in thisbuild:
347             for lib in thisbuild['srclibs']:
348                 srclibpaths.append(
349                     common.getsrclib(lib, 'build/srclib', srclibpaths,
350                                      basepath=True, prepare=False))
351
352         # If one was used for the main source, add that too.
353         basesrclib = vcs.getsrclib()
354         if basesrclib:
355             srclibpaths.append(basesrclib)
356         for name, number, lib in srclibpaths:
357             logging.info("Sending srclib '%s'" % lib)
358             ftp.chdir(homedir + '/build/srclib')
359             if not os.path.exists(lib):
360                 raise BuildException("Missing srclib directory '" + lib + "'")
361             fv = '.fdroidvcs-' + name
362             ftp.put(os.path.join('build/srclib', fv), fv)
363             send_dir(lib)
364             # Copy the metadata file too...
365             ftp.chdir(homedir + '/srclibs')
366             ftp.put(os.path.join('srclibs', name + '.txt'),
367                     name + '.txt')
368         # Copy the main app source code
369         # (no need if it's a srclib)
370         if (not basesrclib) and os.path.exists(build_dir):
371             ftp.chdir(homedir + '/build')
372             fv = '.fdroidvcs-' + app['id']
373             ftp.put(os.path.join('build', fv), fv)
374             send_dir(build_dir)
375
376         # Execute the build script...
377         logging.info("Starting build...")
378         chan = sshs.get_transport().open_session()
379         chan.get_pty()
380         cmdline = 'python build.py --on-server'
381         if force:
382             cmdline += ' --force --test'
383         if options.verbose:
384             cmdline += ' --verbose'
385         cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
386         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
387         output = ''
388         while not chan.exit_status_ready():
389             while chan.recv_ready():
390                 output += chan.recv(1024)
391             time.sleep(0.1)
392         logging.info("...getting exit status")
393         returncode = chan.recv_exit_status()
394         while True:
395             get = chan.recv(1024)
396             if len(get) == 0:
397                 break
398             output += get
399         if returncode != 0:
400             raise BuildException(
401                 "Build.py failed on server for {0}:{1}".format(
402                     app['id'], thisbuild['version']), output)
403
404         # Retrieve the built files...
405         logging.info("Retrieving build output...")
406         if force:
407             ftp.chdir(homedir + '/tmp')
408         else:
409             ftp.chdir(homedir + '/unsigned')
410         apkfile = common.getapkname(app, thisbuild)
411         tarball = common.getsrcname(app, thisbuild)
412         try:
413             ftp.get(apkfile, os.path.join(output_dir, apkfile))
414             if not options.notarball:
415                 ftp.get(tarball, os.path.join(output_dir, tarball))
416         except:
417             raise BuildException(
418                 "Build failed for %s:%s - missing output files".format(
419                     app['id'], thisbuild['version']), output)
420         ftp.close()
421
422     finally:
423
424         # Suspend the build server.
425         release_vm()
426
427
428 def adapt_gradle(build_dir):
429     for root, dirs, files in os.walk(build_dir):
430         if 'build.gradle' in files:
431             path = os.path.join(root, 'build.gradle')
432             logging.info("Adapting build.gradle at %s" % path)
433
434             FDroidPopen(['sed', '-i',
435                          r's@buildToolsVersion\([ =]*\)["\'][0-9\.]*["\']@buildToolsVersion\1"'
436                          + config['build_tools'] + '"@g', path])
437
438
439 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver):
440     """Do a build locally."""
441
442     if thisbuild.get('buildjni') not in (None, ['no']):
443         if not config['ndk_path']:
444             logging.critical("$ANDROID_NDK is not set!")
445             sys.exit(3)
446         elif not os.path.isdir(config['sdk_path']):
447             logging.critical("$ANDROID_NDK points to a non-existing directory!")
448             sys.exit(3)
449
450     # Prepare the source code...
451     root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
452                                                   build_dir, srclib_dir,
453                                                   extlib_dir, onserver)
454
455     # We need to clean via the build tool in case the binary dirs are
456     # different from the default ones
457     p = None
458     if thisbuild['type'] == 'maven':
459         logging.info("Cleaning Maven project...")
460         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
461
462         if '@' in thisbuild['maven']:
463             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
464             maven_dir = os.path.normpath(maven_dir)
465         else:
466             maven_dir = root_dir
467
468         p = FDroidPopen(cmd, cwd=maven_dir)
469
470     elif thisbuild['type'] == 'gradle':
471
472         logging.info("Cleaning Gradle project...")
473         cmd = [config['gradle'], 'clean']
474
475         if '@' in thisbuild['gradle']:
476             gradle_dir = os.path.join(root_dir, thisbuild['gradle'].split('@', 1)[1])
477             gradle_dir = os.path.normpath(gradle_dir)
478         else:
479             gradle_dir = root_dir
480
481         adapt_gradle(build_dir)
482         for name, number, libpath in srclibpaths:
483             adapt_gradle(libpath)
484
485         p = FDroidPopen(cmd, cwd=gradle_dir)
486
487     elif thisbuild['type'] == 'kivy':
488         pass
489
490     elif thisbuild['type'] == 'ant':
491         logging.info("Cleaning Ant project...")
492         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
493
494     if p is not None and p.returncode != 0:
495         raise BuildException("Error cleaning %s:%s" %
496                              (app['id'], thisbuild['version']), p.stdout)
497
498     logging.info("Getting rid of Gradle wrapper binaries...")
499     for root, dirs, files in os.walk(build_dir):
500         # Don't remove possibly necessary 'gradle' dirs if 'gradlew' is not there
501         if 'gradlew' in files:
502             os.remove(os.path.join(root, 'gradlew'))
503             if 'gradlew.bat' in files:
504                 os.remove(os.path.join(root, 'gradlew.bat'))
505             if 'gradle' in dirs:
506                 shutil.rmtree(os.path.join(root, 'gradle'))
507
508     if not options.skipscan:
509         # Scan before building...
510         logging.info("Scanning source for common problems...")
511         count = common.scan_source(build_dir, root_dir, thisbuild)
512         if count > 0:
513             if force:
514                 logging.warn('Scanner found %d problems:' % count)
515             else:
516                 raise BuildException("Can't build due to %d errors while scanning" % count)
517
518     if not options.notarball:
519         # Build the source tarball right before we build the release...
520         logging.info("Creating source tarball...")
521         tarname = common.getsrcname(app, thisbuild)
522         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
523
524         def tarexc(f):
525             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
526         tarball.add(build_dir, tarname, exclude=tarexc)
527         tarball.close()
528
529     if onserver:
530         manifest = os.path.join(root_dir, 'AndroidManifest.xml')
531         if os.path.exists(manifest):
532             homedir = os.path.expanduser('~')
533             with open(os.path.join(homedir, 'buildserverid'), 'r') as f:
534                 buildserverid = f.read()
535             with open(os.path.join(homedir, 'fdroidserverid'), 'r') as f:
536                 fdroidserverid = f.read()
537             with open(manifest, 'r') as f:
538                 manifestcontent = f.read()
539             manifestcontent = manifestcontent.replace('</manifest>',
540                                                       '<fdroid buildserverid="'
541                                                       + buildserverid + '"'
542                                                       + ' fdroidserverid="'
543                                                       + fdroidserverid + '"'
544                                                       + '/></manifest>')
545             with open(manifest, 'w') as f:
546                 f.write(manifestcontent)
547
548     # Run a build command if one is required...
549     if 'build' in thisbuild:
550         cmd = common.replace_config_vars(thisbuild['build'])
551         # Substitute source library paths into commands...
552         for name, number, libpath in srclibpaths:
553             libpath = os.path.relpath(libpath, root_dir)
554             cmd = cmd.replace('$$' + name + '$$', libpath)
555         logging.info("Running 'build' commands in %s" % root_dir)
556
557         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
558
559         if p.returncode != 0:
560             raise BuildException("Error running build command for %s:%s" %
561                                  (app['id'], thisbuild['version']), p.stdout)
562
563     # Build native stuff if required...
564     if thisbuild.get('buildjni') not in (None, ['no']):
565         logging.info("Building native libraries...")
566         jni_components = thisbuild.get('buildjni')
567         if jni_components == ['yes']:
568             jni_components = ['']
569         cmd = [os.path.join(config['ndk_path'], "ndk-build"), "-j1"]
570         for d in jni_components:
571             if d:
572                 logging.info("Building native code in '%s'" % d)
573             else:
574                 logging.info("Building native code in the main project")
575             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
576             if os.path.exists(manifest):
577                 # Read and write the whole AM.xml to fix newlines and avoid
578                 # the ndk r8c or later 'wordlist' errors. The outcome of this
579                 # under gnu/linux is the same as when using tools like
580                 # dos2unix, but the native python way is faster and will
581                 # work in non-unix systems.
582                 manifest_text = open(manifest, 'U').read()
583                 open(manifest, 'w').write(manifest_text)
584                 # In case the AM.xml read was big, free the memory
585                 del manifest_text
586             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
587             if p.returncode != 0:
588                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
589
590     p = None
591     # Build the release...
592     if thisbuild['type'] == 'maven':
593         logging.info("Building Maven project...")
594
595         if '@' in thisbuild['maven']:
596             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
597         else:
598             maven_dir = root_dir
599
600         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
601                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
602                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
603                   'package']
604         if 'target' in thisbuild:
605             target = thisbuild["target"].split('-')[1]
606             FDroidPopen(['sed', '-i',
607                          's@<platform>[0-9]*</platform>@<platform>'
608                          + target + '</platform>@g',
609                          'pom.xml'],
610                         cwd=root_dir)
611             if '@' in thisbuild['maven']:
612                 FDroidPopen(['sed', '-i',
613                              's@<platform>[0-9]*</platform>@<platform>'
614                              + target + '</platform>@g',
615                              'pom.xml'],
616                             cwd=maven_dir)
617
618         if 'mvnflags' in thisbuild:
619             mvncmd += thisbuild['mvnflags']
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 'preassemble' in thisbuild:
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         p = FDroidPopen(commands, cwd=gradle_dir)
710
711     elif thisbuild['type'] == 'ant':
712         logging.info("Building Ant project...")
713         cmd = ['ant']
714         if 'antcommand' in thisbuild:
715             cmd += [thisbuild['antcommand']]
716         else:
717             cmd += ['release']
718         p = FDroidPopen(cmd, cwd=root_dir)
719
720         bindir = os.path.join(root_dir, 'bin')
721
722     if p is not None and p.returncode != 0:
723         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
724     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
725
726     if thisbuild['type'] == 'maven':
727         stdout_apk = '\n'.join([
728             line for line in p.stdout.splitlines() if any(a in line for a in ('.apk', '.ap_'))])
729         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
730                      stdout_apk, re.S | re.M)
731         if not m:
732             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
733                          stdout_apk, re.S | re.M)
734         if not m:
735             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
736                          stdout_apk, re.S | re.M)
737         if not m:
738             raise BuildException('Failed to find output')
739         src = m.group(1)
740         src = os.path.join(bindir, src) + '.apk'
741     elif thisbuild['type'] == 'kivy':
742         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
743             bconfig.get('app', 'title'), bconfig.get('app', 'version'))
744     elif thisbuild['type'] == 'gradle':
745         basename = app['id']
746         dd = build_dir
747         if 'subdir' in thisbuild:
748             dd = os.path.join(dd, thisbuild['subdir'])
749             basename = os.path.basename(thisbuild['subdir'])
750         if '@' in thisbuild['gradle']:
751             dd = os.path.join(dd, thisbuild['gradle'].split('@')[1])
752             basename = app['id']
753         if len(flavours) == 1 and flavours[0] == '':
754             name = '-'.join([basename, 'release', 'unsigned'])
755         else:
756             name = '-'.join([basename, '-'.join(flavours), 'release', 'unsigned'])
757         dd = os.path.normpath(dd)
758         src = os.path.join(dd, 'build', 'apk', name + '.apk')
759     elif thisbuild['type'] == 'ant':
760         stdout_apk = '\n'.join([
761             line for line in p.stdout.splitlines() if '.apk' in line])
762         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
763                        re.S | re.M).group(1)
764         src = os.path.join(bindir, src)
765     elif thisbuild['type'] == 'raw':
766         src = os.path.join(root_dir, thisbuild['output'])
767         src = os.path.normpath(src)
768
769     # Make sure it's not debuggable...
770     if common.isApkDebuggable(src, config):
771         raise BuildException("APK is debuggable")
772
773     # By way of a sanity check, make sure the version and version
774     # code in our new apk match what we expect...
775     logging.info("Checking " + src)
776     if not os.path.exists(src):
777         raise BuildException("Unsigned apk is not at expected location of " + src)
778
779     p = SilentPopen([os.path.join(config['sdk_path'], 'build-tools',
780                                   config['build_tools'], 'aapt'),
781                      'dump', 'badging', src])
782
783     vercode = None
784     version = None
785     foundid = None
786     nativecode = None
787     for line in p.stdout.splitlines():
788         if line.startswith("package:"):
789             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
790             m = pat.match(line)
791             if m:
792                 foundid = m.group(1)
793             pat = re.compile(".*versionCode='([0-9]*)'.*")
794             m = pat.match(line)
795             if m:
796                 vercode = m.group(1)
797             pat = re.compile(".*versionName='([^']*)'.*")
798             m = pat.match(line)
799             if m:
800                 version = m.group(1)
801         elif line.startswith("native-code:"):
802             nativecode = line[12:]
803
804     if thisbuild.get('buildjni') is not None:
805         if nativecode is None or "'" not in nativecode:
806             raise BuildException("Native code should have been built but none was packaged")
807     if thisbuild['novcheck']:
808         vercode = thisbuild['vercode']
809         version = thisbuild['version']
810     if not version or not vercode:
811         raise BuildException("Could not find version information in build in output")
812     if not foundid:
813         raise BuildException("Could not find package ID in output")
814     if foundid != app['id']:
815         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
816
817     # Some apps (e.g. Timeriffic) have had the bonkers idea of
818     # including the entire changelog in the version number. Remove
819     # it so we can compare. (TODO: might be better to remove it
820     # before we compile, in fact)
821     index = version.find(" //")
822     if index != -1:
823         version = version[:index]
824
825     if (version != thisbuild['version'] or
826             vercode != thisbuild['vercode']):
827         raise BuildException(("Unexpected version/version code in output;"
828                               " APK: '%s' / '%s', "
829                               " Expected: '%s' / '%s'")
830                              % (version, str(vercode), thisbuild['version'],
831                                 str(thisbuild['vercode']))
832                              )
833
834     # Copy the unsigned apk to our destination directory for further
835     # processing (by publish.py)...
836     dest = os.path.join(output_dir, common.getapkname(app, thisbuild))
837     shutil.copyfile(src, dest)
838
839     # Move the source tarball into the output directory...
840     if output_dir != tmp_dir and not options.notarball:
841         shutil.move(os.path.join(tmp_dir, tarname),
842                     os.path.join(output_dir, tarname))
843
844
845 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
846              tmp_dir, repo_dir, vcs, test, server, force, onserver):
847     """
848     Build a particular version of an application, if it needs building.
849
850     :param output_dir: The directory where the build output will go. Usually
851        this is the 'unsigned' directory.
852     :param repo_dir: The repo directory - used for checking if the build is
853        necessary.
854     :paaram also_check_dir: An additional location for checking if the build
855        is necessary (usually the archive repo)
856     :param test: True if building in test mode, in which case the build will
857        always happen, even if the output already exists. In test mode, the
858        output directory should be a temporary location, not any of the real
859        ones.
860
861     :returns: True if the build was done, False if it wasn't necessary.
862     """
863
864     dest_apk = common.getapkname(app, thisbuild)
865
866     dest = os.path.join(output_dir, dest_apk)
867     dest_repo = os.path.join(repo_dir, dest_apk)
868
869     if not test:
870         if os.path.exists(dest) or os.path.exists(dest_repo):
871             return False
872
873         if also_check_dir:
874             dest_also = os.path.join(also_check_dir, dest_apk)
875             if os.path.exists(dest_also):
876                 return False
877
878     if 'disable' in thisbuild:
879         return False
880
881     logging.info("Building version " + thisbuild['version'] + ' of ' + 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     # Get all apps...
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 Exception("No apps to process.")
995
996     if options.latest:
997         for app in apps:
998             for build in reversed(app['builds']):
999                 if 'disable' in build:
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                 print("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                 print("VCS error while building app %s: %s" % (app['id'], vcse))
1058                 if options.stop:
1059                     sys.exit(1)
1060                 failed_apps[app['id']] = vcse
1061                 wikilog = str(vcse)
1062             except Exception as e:
1063                 print("Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc()))
1064                 if options.stop:
1065                     sys.exit(1)
1066                 failed_apps[app['id']] = e
1067                 wikilog = str(e)
1068
1069             if options.wiki and wikilog:
1070                 try:
1071                     newpage = site.Pages[app['id'] + '/lastbuild']
1072                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1073                     newpage.save(txt, summary='Build log')
1074                 except:
1075                     logging.info("Error while attempting to publish build log")
1076
1077     for app in build_succeeded:
1078         logging.info("success: %s" % (app['id']))
1079
1080     if not options.verbose:
1081         for fa in failed_apps:
1082             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1083
1084     logging.info("Finished.")
1085     if len(build_succeeded) > 0:
1086         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1087     if len(failed_apps) > 0:
1088         logging.info(str(len(failed_apps)) + ' builds failed')
1089
1090     sys.exit(0)
1091
1092 if __name__ == "__main__":
1093     main()