chiark / gitweb /
d792132d570d42dbcbd2e87a36ad2c839c32b0ca
[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, build, 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.INFO)
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.mkdir('fdroidserver')
303         ftp.chdir('fdroidserver')
304         ftp.put(os.path.join(serverpath, '..', 'fdroid'), 'fdroid')
305         ftp.chmod('fdroid', 0o755)
306         send_dir(os.path.join(serverpath))
307         ftp.chdir(homedir)
308
309         ftp.put(os.path.join(serverpath, '..', 'buildserver',
310                              'config.buildserver.py'), 'config.py')
311         ftp.chmod('config.py', 0o600)
312
313         # Copy over the ID (head commit hash) of the fdroidserver in use...
314         subprocess.call('git rev-parse HEAD >' +
315                         os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
316                         shell=True, cwd=serverpath)
317         ftp.put('tmp/fdroidserverid', 'fdroidserverid')
318
319         # Copy the metadata - just the file for this app...
320         ftp.mkdir('metadata')
321         ftp.mkdir('srclibs')
322         ftp.chdir('metadata')
323         ftp.put(os.path.join('metadata', app.id + '.txt'),
324                 app.id + '.txt')
325         # And patches if there are any...
326         if os.path.exists(os.path.join('metadata', app.id)):
327             send_dir(os.path.join('metadata', app.id))
328
329         ftp.chdir(homedir)
330         # Create the build directory...
331         ftp.mkdir('build')
332         ftp.chdir('build')
333         ftp.mkdir('extlib')
334         ftp.mkdir('srclib')
335         # Copy any extlibs that are required...
336         if build.extlibs:
337             ftp.chdir(homedir + '/build/extlib')
338             for lib in build.extlibs:
339                 lib = lib.strip()
340                 libsrc = os.path.join('build/extlib', lib)
341                 if not os.path.exists(libsrc):
342                     raise BuildException("Missing extlib {0}".format(libsrc))
343                 lp = lib.split('/')
344                 for d in lp[:-1]:
345                     if d not in ftp.listdir():
346                         ftp.mkdir(d)
347                     ftp.chdir(d)
348                 ftp.put(libsrc, lp[-1])
349                 for _ in lp[:-1]:
350                     ftp.chdir('..')
351         # Copy any srclibs that are required...
352         srclibpaths = []
353         if build.srclibs:
354             for lib in build.srclibs:
355                 srclibpaths.append(
356                     common.getsrclib(lib, 'build/srclib', basepath=True, prepare=False))
357
358         # If one was used for the main source, add that too.
359         basesrclib = vcs.getsrclib()
360         if basesrclib:
361             srclibpaths.append(basesrclib)
362         for name, number, lib in srclibpaths:
363             logging.info("Sending srclib '%s'" % lib)
364             ftp.chdir(homedir + '/build/srclib')
365             if not os.path.exists(lib):
366                 raise BuildException("Missing srclib directory '" + lib + "'")
367             fv = '.fdroidvcs-' + name
368             ftp.put(os.path.join('build/srclib', fv), fv)
369             send_dir(lib)
370             # Copy the metadata file too...
371             ftp.chdir(homedir + '/srclibs')
372             ftp.put(os.path.join('srclibs', name + '.txt'),
373                     name + '.txt')
374         # Copy the main app source code
375         # (no need if it's a srclib)
376         if (not basesrclib) and os.path.exists(build_dir):
377             ftp.chdir(homedir + '/build')
378             fv = '.fdroidvcs-' + app.id
379             ftp.put(os.path.join('build', fv), fv)
380             send_dir(build_dir)
381
382         # Execute the build script...
383         logging.info("Starting build...")
384         chan = sshs.get_transport().open_session()
385         chan.get_pty()
386         cmdline = os.path.join(homedir, 'fdroidserver', 'fdroid')
387         cmdline += ' build --on-server'
388         if force:
389             cmdline += ' --force --test'
390         if options.verbose:
391             cmdline += ' --verbose'
392         cmdline += " %s:%s" % (app.id, build.vercode)
393         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
394         output = ''
395         while not chan.exit_status_ready():
396             while chan.recv_ready():
397                 output += chan.recv(1024)
398             time.sleep(0.1)
399         logging.info("...getting exit status")
400         returncode = chan.recv_exit_status()
401         while True:
402             get = chan.recv(1024)
403             if len(get) == 0:
404                 break
405             output += get
406         if returncode != 0:
407             raise BuildException(
408                 "Build.py failed on server for {0}:{1}".format(
409                     app.id, build.version), output)
410
411         # Retrieve the built files...
412         logging.info("Retrieving build output...")
413         if force:
414             ftp.chdir(homedir + '/tmp')
415         else:
416             ftp.chdir(homedir + '/unsigned')
417         apkfile = common.getapkname(app, build)
418         tarball = common.getsrcname(app, build)
419         try:
420             ftp.get(apkfile, os.path.join(output_dir, apkfile))
421             if not options.notarball:
422                 ftp.get(tarball, os.path.join(output_dir, tarball))
423         except:
424             raise BuildException(
425                 "Build failed for %s:%s - missing output files".format(
426                     app.id, build.version), output)
427         ftp.close()
428
429     finally:
430
431         # Suspend the build server.
432         release_vm()
433
434
435 def adapt_gradle(build_dir):
436     filename = 'build.gradle'
437     for root, dirs, files in os.walk(build_dir):
438         for filename in files:
439             if not filename.endswith('.gradle'):
440                 continue
441             path = os.path.join(root, filename)
442             if not os.path.isfile(path):
443                 continue
444             logging.debug("Adapting %s at %s" % (filename, path))
445             common.regsub_file(r"""(\s*)buildToolsVersion([\s=]+).*""",
446                                r"""\1buildToolsVersion\2'%s'""" % config['build_tools'],
447                                path)
448
449
450 def capitalize_intact(string):
451     """Like str.capitalize(), but leave the rest of the string intact without
452     switching it to lowercase."""
453     if len(string) == 0:
454         return string
455     if len(string) == 1:
456         return string.upper()
457     return string[0].upper() + string[1:]
458
459
460 def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
461     """Do a build locally."""
462
463     ndk_path = build.ndk_path()
464     if build.buildjni and build.buildjni != ['no']:
465         if not ndk_path:
466             logging.critical("Android NDK version '%s' could not be found!" % build.ndk or 'r10e')
467             logging.critical("Configured versions:")
468             for k, v in config['ndk_paths'].iteritems():
469                 if k.endswith("_orig"):
470                     continue
471                 logging.critical("  %s: %s" % (k, v))
472             sys.exit(3)
473         elif not os.path.isdir(ndk_path):
474             logging.critical("Android NDK '%s' is not a directory!" % ndk_path)
475             sys.exit(3)
476
477     # Set up environment vars that depend on each build
478     for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
479         common.env[n] = ndk_path
480
481     common.reset_env_path()
482     # Set up the current NDK to the PATH
483     common.add_to_env_path(ndk_path)
484
485     # Prepare the source code...
486     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
487                                                   build_dir, srclib_dir,
488                                                   extlib_dir, onserver, refresh)
489
490     # We need to clean via the build tool in case the binary dirs are
491     # different from the default ones
492     p = None
493     gradletasks = []
494     method = build.method()
495     if method == 'maven':
496         logging.info("Cleaning Maven project...")
497         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
498
499         if '@' in build.maven:
500             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
501             maven_dir = os.path.normpath(maven_dir)
502         else:
503             maven_dir = root_dir
504
505         p = FDroidPopen(cmd, cwd=maven_dir)
506
507     elif method == 'gradle':
508
509         logging.info("Cleaning Gradle project...")
510
511         if build.preassemble:
512             gradletasks += build.preassemble
513
514         flavours = build.gradle
515         if flavours == ['yes']:
516             flavours = []
517
518         flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
519
520         gradletasks += ['assemble' + flavours_cmd + 'Release']
521
522         adapt_gradle(build_dir)
523         for name, number, libpath in srclibpaths:
524             adapt_gradle(libpath)
525
526         cmd = [config['gradle']]
527         if build.gradleprops:
528             cmd += ['-P'+kv for kv in build.gradleprops]
529
530         for task in gradletasks:
531             parts = task.split(':')
532             parts[-1] = 'clean' + capitalize_intact(parts[-1])
533             cmd += [':'.join(parts)]
534
535         cmd += ['clean']
536
537         p = FDroidPopen(cmd, cwd=root_dir)
538
539     elif method == 'kivy':
540         pass
541
542     elif method == 'ant':
543         logging.info("Cleaning Ant project...")
544         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
545
546     if p is not None and p.returncode != 0:
547         raise BuildException("Error cleaning %s:%s" %
548                              (app.id, build.version), p.output)
549
550     for root, dirs, files in os.walk(build_dir):
551
552         def del_dirs(dl):
553             for d in dl:
554                 if d in dirs:
555                     shutil.rmtree(os.path.join(root, d))
556
557         def del_files(fl):
558             for f in fl:
559                 if f in files:
560                     os.remove(os.path.join(root, f))
561
562         if 'build.gradle' in files:
563             # Even when running clean, gradle stores task/artifact caches in
564             # .gradle/ as binary files. To avoid overcomplicating the scanner,
565             # manually delete them, just like `gradle clean` should have removed
566             # the build/ dirs.
567             del_dirs(['build', '.gradle', 'gradle'])
568             del_files(['gradlew', 'gradlew.bat'])
569
570         if 'pom.xml' in files:
571             del_dirs(['target'])
572
573         if any(f in files for f in ['ant.properties', 'project.properties', 'build.xml']):
574             del_dirs(['bin', 'gen'])
575
576         if 'jni' in dirs:
577             del_dirs(['obj'])
578
579     if options.skipscan:
580         if build.scandelete:
581             raise BuildException("Refusing to skip source scan since scandelete is present")
582     else:
583         # Scan before building...
584         logging.info("Scanning source for common problems...")
585         count = scanner.scan_source(build_dir, root_dir, build)
586         if count > 0:
587             if force:
588                 logging.warn('Scanner found %d problems' % count)
589             else:
590                 raise BuildException("Can't build due to %d errors while scanning" % count)
591
592     if not options.notarball:
593         # Build the source tarball right before we build the release...
594         logging.info("Creating source tarball...")
595         tarname = common.getsrcname(app, build)
596         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
597
598         def tarexc(f):
599             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
600         tarball.add(build_dir, tarname, exclude=tarexc)
601         tarball.close()
602
603     # Run a build command if one is required...
604     if build.build:
605         logging.info("Running 'build' commands in %s" % root_dir)
606         cmd = common.replace_config_vars(build.build, build)
607
608         # Substitute source library paths into commands...
609         for name, number, libpath in srclibpaths:
610             libpath = os.path.relpath(libpath, root_dir)
611             cmd = cmd.replace('$$' + name + '$$', libpath)
612
613         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
614
615         if p.returncode != 0:
616             raise BuildException("Error running build command for %s:%s" %
617                                  (app.id, build.version), p.output)
618
619     # Build native stuff if required...
620     if build.buildjni and build.buildjni != ['no']:
621         logging.info("Building the native code")
622         jni_components = build.buildjni
623
624         if jni_components == ['yes']:
625             jni_components = ['']
626         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
627         for d in jni_components:
628             if d:
629                 logging.info("Building native code in '%s'" % d)
630             else:
631                 logging.info("Building native code in the main project")
632             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
633             if os.path.exists(manifest):
634                 # Read and write the whole AM.xml to fix newlines and avoid
635                 # the ndk r8c or later 'wordlist' errors. The outcome of this
636                 # under gnu/linux is the same as when using tools like
637                 # dos2unix, but the native python way is faster and will
638                 # work in non-unix systems.
639                 manifest_text = open(manifest, 'U').read()
640                 open(manifest, 'w').write(manifest_text)
641                 # In case the AM.xml read was big, free the memory
642                 del manifest_text
643             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
644             if p.returncode != 0:
645                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.version), p.output)
646
647     p = None
648     # Build the release...
649     if method == 'maven':
650         logging.info("Building Maven project...")
651
652         if '@' in build.maven:
653             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
654         else:
655             maven_dir = root_dir
656
657         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
658                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
659                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
660                   'package']
661         if build.target:
662             target = build.target.split('-')[1]
663             common.regsub_file(r'<platform>[0-9]*</platform>',
664                                r'<platform>%s</platform>' % target,
665                                os.path.join(root_dir, 'pom.xml'))
666             if '@' in build.maven:
667                 common.regsub_file(r'<platform>[0-9]*</platform>',
668                                    r'<platform>%s</platform>' % target,
669                                    os.path.join(maven_dir, 'pom.xml'))
670
671         p = FDroidPopen(mvncmd, cwd=maven_dir)
672
673         bindir = os.path.join(root_dir, 'target')
674
675     elif method == 'kivy':
676         logging.info("Building Kivy project...")
677
678         spec = os.path.join(root_dir, 'buildozer.spec')
679         if not os.path.exists(spec):
680             raise BuildException("Expected to find buildozer-compatible spec at {0}"
681                                  .format(spec))
682
683         defaults = {'orientation': 'landscape', 'icon': '',
684                     'permissions': '', 'android.api': "18"}
685         bconfig = ConfigParser(defaults, allow_no_value=True)
686         bconfig.read(spec)
687
688         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
689         if os.path.exists(distdir):
690             shutil.rmtree(distdir)
691
692         modules = bconfig.get('app', 'requirements').split(',')
693
694         cmd = 'ANDROIDSDK=' + config['sdk_path']
695         cmd += ' ANDROIDNDK=' + ndk_path
696         cmd += ' ANDROIDNDKVER=' + build.ndk
697         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
698         cmd += ' VIRTUALENV=virtualenv'
699         cmd += ' ./distribute.sh'
700         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
701         cmd += ' -d fdroid'
702         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
703         if p.returncode != 0:
704             raise BuildException("Distribute build failed")
705
706         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
707         if cid != app.id:
708             raise BuildException("Package ID mismatch between metadata and spec")
709
710         orientation = bconfig.get('app', 'orientation', 'landscape')
711         if orientation == 'all':
712             orientation = 'sensor'
713
714         cmd = ['./build.py'
715                '--dir', root_dir,
716                '--name', bconfig.get('app', 'title'),
717                '--package', app.id,
718                '--version', bconfig.get('app', 'version'),
719                '--orientation', orientation
720                ]
721
722         perms = bconfig.get('app', 'permissions')
723         for perm in perms.split(','):
724             cmd.extend(['--permission', perm])
725
726         if config.get('app', 'fullscreen') == 0:
727             cmd.append('--window')
728
729         icon = bconfig.get('app', 'icon.filename')
730         if icon:
731             cmd.extend(['--icon', os.path.join(root_dir, icon)])
732
733         cmd.append('release')
734         p = FDroidPopen(cmd, cwd=distdir)
735
736     elif method == 'gradle':
737         logging.info("Building Gradle project...")
738
739         cmd = [config['gradle']]
740         if build.gradleprops:
741             cmd += ['-P'+kv for kv in build.gradleprops]
742
743         cmd += gradletasks
744
745         p = FDroidPopen(cmd, cwd=root_dir)
746
747     elif method == 'ant':
748         logging.info("Building Ant project...")
749         cmd = ['ant']
750         if build.antcommands:
751             cmd += build.antcommands
752         else:
753             cmd += ['release']
754         p = FDroidPopen(cmd, cwd=root_dir)
755
756         bindir = os.path.join(root_dir, 'bin')
757
758     if p is not None and p.returncode != 0:
759         raise BuildException("Build failed for %s:%s" % (app.id, build.version), p.output)
760     logging.info("Successfully built version " + build.version + ' of ' + app.id)
761
762     if method == 'maven':
763         stdout_apk = '\n'.join([
764             line for line in p.output.splitlines() if any(
765                 a in line for a in ('.apk', '.ap_', '.jar'))])
766         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
767                      stdout_apk, re.S | re.M)
768         if not m:
769             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
770                          stdout_apk, re.S | re.M)
771         if not m:
772             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
773                          stdout_apk, re.S | re.M)
774
775         if not m:
776             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
777                          stdout_apk, re.S | re.M)
778         if not m:
779             raise BuildException('Failed to find output')
780         src = m.group(1)
781         src = os.path.join(bindir, src) + '.apk'
782     elif method == 'kivy':
783         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
784                            '{0}-{1}-release.apk'.format(
785                                bconfig.get('app', 'title'),
786                                bconfig.get('app', 'version')))
787     elif method == 'gradle':
788
789         if build.gradlepluginver >= LooseVersion('0.11'):
790             apks_dir = os.path.join(root_dir, 'build', 'outputs', 'apk')
791         else:
792             apks_dir = os.path.join(root_dir, 'build', 'apk')
793
794         apks = glob.glob(os.path.join(apks_dir, '*-release-unsigned.apk'))
795         if len(apks) > 1:
796             raise BuildException('More than one resulting apks found in %s' % apks_dir,
797                                  '\n'.join(apks))
798         if len(apks) < 1:
799             raise BuildException('Failed to find gradle output in %s' % apks_dir)
800         src = apks[0]
801     elif method == 'ant':
802         stdout_apk = '\n'.join([
803             line for line in p.output.splitlines() if '.apk' in line])
804         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
805                        re.S | re.M).group(1)
806         src = os.path.join(bindir, src)
807     elif method == 'raw':
808         src = os.path.join(root_dir, build.output)
809         src = os.path.normpath(src)
810
811     # Make sure it's not debuggable...
812     if common.isApkDebuggable(src, config):
813         raise BuildException("APK is debuggable")
814
815     # By way of a sanity check, make sure the version and version
816     # code in our new apk match what we expect...
817     logging.debug("Checking " + src)
818     if not os.path.exists(src):
819         raise BuildException("Unsigned apk is not at expected location of " + src)
820
821     p = SdkToolsPopen(['aapt', 'dump', 'badging', src], output=False)
822
823     vercode = None
824     version = None
825     foundid = None
826     nativecode = None
827     for line in p.output.splitlines():
828         if line.startswith("package:"):
829             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
830             m = pat.match(line)
831             if m:
832                 foundid = m.group(1)
833             pat = re.compile(".*versionCode='([0-9]*)'.*")
834             m = pat.match(line)
835             if m:
836                 vercode = m.group(1)
837             pat = re.compile(".*versionName='([^']*)'.*")
838             m = pat.match(line)
839             if m:
840                 version = m.group(1)
841         elif line.startswith("native-code:"):
842             nativecode = line[12:]
843
844     # Ignore empty strings or any kind of space/newline chars that we don't
845     # care about
846     if nativecode is not None:
847         nativecode = nativecode.strip()
848         nativecode = None if not nativecode else nativecode
849
850     if build.buildjni and build.buildjni != ['no']:
851         if nativecode is None:
852             raise BuildException("Native code should have been built but none was packaged")
853     if build.novcheck:
854         vercode = build.vercode
855         version = build.version
856     if not version or not vercode:
857         raise BuildException("Could not find version information in build in output")
858     if not foundid:
859         raise BuildException("Could not find package ID in output")
860     if foundid != app.id:
861         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app.id)
862
863     # Some apps (e.g. Timeriffic) have had the bonkers idea of
864     # including the entire changelog in the version number. Remove
865     # it so we can compare. (TODO: might be better to remove it
866     # before we compile, in fact)
867     index = version.find(" //")
868     if index != -1:
869         version = version[:index]
870
871     if (version != build.version or
872             vercode != build.vercode):
873         raise BuildException(("Unexpected version/version code in output;"
874                               " APK: '%s' / '%s', "
875                               " Expected: '%s' / '%s'")
876                              % (version, str(vercode), build.version,
877                                 str(build.vercode))
878                              )
879
880     # Add information for 'fdroid verify' to be able to reproduce the build
881     # environment.
882     if onserver:
883         metadir = os.path.join(tmp_dir, 'META-INF')
884         if not os.path.exists(metadir):
885             os.mkdir(metadir)
886         homedir = os.path.expanduser('~')
887         for fn in ['buildserverid', 'fdroidserverid']:
888             shutil.copyfile(os.path.join(homedir, fn),
889                             os.path.join(metadir, fn))
890             subprocess.call(['jar', 'uf', os.path.abspath(src),
891                              'META-INF/' + fn], cwd=tmp_dir)
892
893     # Copy the unsigned apk to our destination directory for further
894     # processing (by publish.py)...
895     dest = os.path.join(output_dir, common.getapkname(app, build))
896     shutil.copyfile(src, dest)
897
898     # Move the source tarball into the output directory...
899     if output_dir != tmp_dir and not options.notarball:
900         shutil.move(os.path.join(tmp_dir, tarname),
901                     os.path.join(output_dir, tarname))
902
903
904 def trybuild(app, build, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
905              tmp_dir, repo_dir, vcs, test, server, force, onserver, refresh):
906     """
907     Build a particular version of an application, if it needs building.
908
909     :param output_dir: The directory where the build output will go. Usually
910        this is the 'unsigned' directory.
911     :param repo_dir: The repo directory - used for checking if the build is
912        necessary.
913     :paaram also_check_dir: An additional location for checking if the build
914        is necessary (usually the archive repo)
915     :param test: True if building in test mode, in which case the build will
916        always happen, even if the output already exists. In test mode, the
917        output directory should be a temporary location, not any of the real
918        ones.
919
920     :returns: True if the build was done, False if it wasn't necessary.
921     """
922
923     dest_apk = common.getapkname(app, build)
924
925     dest = os.path.join(output_dir, dest_apk)
926     dest_repo = os.path.join(repo_dir, dest_apk)
927
928     if not test:
929         if os.path.exists(dest) or os.path.exists(dest_repo):
930             return False
931
932         if also_check_dir:
933             dest_also = os.path.join(also_check_dir, dest_apk)
934             if os.path.exists(dest_also):
935                 return False
936
937     if build.disable and not options.force:
938         return False
939
940     logging.info("Building version %s (%s) of %s" % (
941         build.version, build.vercode, app.id))
942
943     if server:
944         # When using server mode, still keep a local cache of the repo, by
945         # grabbing the source now.
946         vcs.gotorevision(build.commit)
947
948         build_server(app, build, vcs, build_dir, output_dir, force)
949     else:
950         build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
951     return True
952
953
954 def parse_commandline():
955     """Parse the command line. Returns options, parser."""
956
957     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
958     common.setup_global_opts(parser)
959     parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
960     parser.add_argument("-l", "--latest", action="store_true", default=False,
961                         help="Build only the latest version of each package")
962     parser.add_argument("-s", "--stop", action="store_true", default=False,
963                         help="Make the build stop on exceptions")
964     parser.add_argument("-t", "--test", action="store_true", default=False,
965                         help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
966     parser.add_argument("--server", action="store_true", default=False,
967                         help="Use build server")
968     parser.add_argument("--resetserver", action="store_true", default=False,
969                         help="Reset and create a brand new build server, even if the existing one appears to be ok.")
970     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
971                         help="Specify that we're running on the build server")
972     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
973                         help="Skip scanning the source code for binaries and other problems")
974     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
975                         help="Don't create a source tarball, useful when testing a build")
976     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
977                         help="Don't refresh the repository, useful when testing a build with no internet connection")
978     parser.add_argument("-f", "--force", action="store_true", default=False,
979                         help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
980     parser.add_argument("-a", "--all", action="store_true", default=False,
981                         help="Build all applications available")
982     parser.add_argument("-w", "--wiki", default=False, action="store_true",
983                         help="Update the wiki")
984     options = parser.parse_args()
985
986     # Force --stop with --on-server to get correct exit code
987     if options.onserver:
988         options.stop = True
989
990     if options.force and not options.test:
991         parser.error("option %s: Force is only allowed in test mode" % "force")
992
993     return options, parser
994
995 options = None
996 config = None
997
998
999 def main():
1000
1001     global options, config
1002
1003     options, parser = parse_commandline()
1004     if not options.appid and not options.all:
1005         parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1006
1007     config = common.read_config(options)
1008
1009     if config['build_server_always']:
1010         options.server = True
1011     if options.resetserver and not options.server:
1012         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1013
1014     log_dir = 'logs'
1015     if not os.path.isdir(log_dir):
1016         logging.info("Creating log directory")
1017         os.makedirs(log_dir)
1018
1019     tmp_dir = 'tmp'
1020     if not os.path.isdir(tmp_dir):
1021         logging.info("Creating temporary directory")
1022         os.makedirs(tmp_dir)
1023
1024     if options.test:
1025         output_dir = tmp_dir
1026     else:
1027         output_dir = 'unsigned'
1028         if not os.path.isdir(output_dir):
1029             logging.info("Creating output directory")
1030             os.makedirs(output_dir)
1031
1032     if config['archive_older'] != 0:
1033         also_check_dir = 'archive'
1034     else:
1035         also_check_dir = None
1036
1037     repo_dir = 'repo'
1038
1039     build_dir = 'build'
1040     if not os.path.isdir(build_dir):
1041         logging.info("Creating build directory")
1042         os.makedirs(build_dir)
1043     srclib_dir = os.path.join(build_dir, 'srclib')
1044     extlib_dir = os.path.join(build_dir, 'extlib')
1045
1046     # Read all app and srclib metadata
1047     allapps = metadata.read_metadata(xref=not options.onserver)
1048
1049     apps = common.read_app_args(options.appid, allapps, True)
1050     for appid, app in apps.items():
1051         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1052             del apps[appid]
1053
1054     if not apps:
1055         raise FDroidException("No apps to process.")
1056
1057     if options.latest:
1058         for app in apps.itervalues():
1059             for build in reversed(app.builds):
1060                 if build.disable and not options.force:
1061                     continue
1062                 app.builds = [build]
1063                 break
1064
1065     if options.wiki:
1066         import mwclient
1067         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1068                              path=config['wiki_path'])
1069         site.login(config['wiki_user'], config['wiki_password'])
1070
1071     # Build applications...
1072     failed_apps = {}
1073     build_succeeded = []
1074     for appid, app in apps.iteritems():
1075
1076         first = True
1077
1078         for build in app.builds:
1079             wikilog = None
1080             try:
1081
1082                 # For the first build of a particular app, we need to set up
1083                 # the source repo. We can reuse it on subsequent builds, if
1084                 # there are any.
1085                 if first:
1086                     if app.RepoType == 'srclib':
1087                         build_dir = os.path.join('build', 'srclib', app.Repo)
1088                     else:
1089                         build_dir = os.path.join('build', appid)
1090
1091                     # Set up vcs interface and make sure we have the latest code...
1092                     logging.debug("Getting {0} vcs interface for {1}"
1093                                   .format(app.RepoType, app.Repo))
1094                     vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
1095
1096                     first = False
1097
1098                 logging.debug("Checking " + build.version)
1099                 if trybuild(app, build, build_dir, output_dir,
1100                             also_check_dir, srclib_dir, extlib_dir,
1101                             tmp_dir, repo_dir, vcs, options.test,
1102                             options.server, options.force,
1103                             options.onserver, options.refresh):
1104
1105                     if app.Binaries is not None:
1106                         # This is an app where we build from source, and
1107                         # verify the apk contents against a developer's
1108                         # binary. We get that binary now, and save it
1109                         # alongside our built one in the 'unsigend'
1110                         # directory.
1111                         url = app.Binaries
1112                         url = url.replace('%v', build.version)
1113                         url = url.replace('%c', str(build.vercode))
1114                         logging.info("...retrieving " + url)
1115                         of = "{0}_{1}.apk.binary".format(app.id, build.vercode)
1116                         of = os.path.join(output_dir, of)
1117                         net.download_file(url, local_filename=of)
1118
1119                     build_succeeded.append(app)
1120                     wikilog = "Build succeeded"
1121             except VCSException as vcse:
1122                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1123                 logging.error("VCS error while building app %s: %s" % (
1124                     appid, reason))
1125                 if options.stop:
1126                     sys.exit(1)
1127                 failed_apps[appid] = vcse
1128                 wikilog = str(vcse)
1129             except FDroidException as e:
1130                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1131                     f.write(str(e))
1132                 logging.error("Could not build app %s: %s" % (appid, e))
1133                 if options.stop:
1134                     sys.exit(1)
1135                 failed_apps[appid] = e
1136                 wikilog = e.get_wikitext()
1137             except Exception as e:
1138                 logging.error("Could not build app %s due to unknown error: %s" % (
1139                     appid, traceback.format_exc()))
1140                 if options.stop:
1141                     sys.exit(1)
1142                 failed_apps[appid] = e
1143                 wikilog = str(e)
1144
1145             if options.wiki and wikilog:
1146                 try:
1147                     # Write a page with the last build log for this version code
1148                     lastbuildpage = appid + '/lastbuild_' + build.vercode
1149                     newpage = site.Pages[lastbuildpage]
1150                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1151                     newpage.save(txt, summary='Build log')
1152                     # Redirect from /lastbuild to the most recent build log
1153                     newpage = site.Pages[appid + '/lastbuild']
1154                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1155                 except:
1156                     logging.error("Error while attempting to publish build log")
1157
1158     for app in build_succeeded:
1159         logging.info("success: %s" % (app.id))
1160
1161     if not options.verbose:
1162         for fa in failed_apps:
1163             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1164
1165     logging.info("Finished.")
1166     if len(build_succeeded) > 0:
1167         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1168     if len(failed_apps) > 0:
1169         logging.info(str(len(failed_apps)) + ' builds failed')
1170
1171     sys.exit(0)
1172
1173 if __name__ == "__main__":
1174     main()