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