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