chiark / gitweb /
Tidy up/fix some vagrant issues
[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 import logging
34
35 import common
36 import net
37 import metadata
38 import scanner
39 from common import FDroidException, BuildException, VCSException, FDroidPopen, SdkToolsPopen
40
41 try:
42     import paramiko
43 except ImportError:
44     pass
45
46
47 def get_builder_vm_id():
48     vd = os.path.join('builder', '.vagrant')
49     if os.path.isdir(vd):
50         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
51         with open(os.path.join(vd, 'machines', 'default',
52                                'virtualbox', 'id')) as vf:
53             id = vf.read()
54         return id
55     else:
56         # Vagrant 1.0 - it's a json file...
57         with open(os.path.join('builder', '.vagrant')) as vf:
58             v = json.load(vf)
59         return v['active']['default']
60
61
62 def got_valid_builder_vm():
63     """Returns True if we have a valid-looking builder vm
64     """
65     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
66         return False
67     vd = os.path.join('builder', '.vagrant')
68     if not os.path.exists(vd):
69         return False
70     if not os.path.isdir(vd):
71         # Vagrant 1.0 - if the directory is there, it's valid...
72         return True
73     # Vagrant 1.2 - the directory can exist, but the id can be missing...
74     if not os.path.exists(os.path.join(vd, 'machines', 'default',
75                                        'virtualbox', 'id')):
76         return False
77     return True
78
79
80 def vagrant(params, cwd=None, printout=False):
81     """Run a vagrant command.
82
83     :param: list of parameters to pass to vagrant
84     :cwd: directory to run in, or None for current directory
85     :returns: (ret, out) where ret is the return code, and out
86                is the stdout (and stderr) from vagrant
87     """
88     p = FDroidPopen(['vagrant'] + params, cwd=cwd)
89     return (p.returncode, p.output)
90
91
92 def get_vagrant_sshinfo():
93     """Get ssh connection info for a vagrant VM
94
95     :returns: A dictionary containing 'hostname', 'port', 'user'
96         and 'idfile'
97     """
98     if subprocess.call('vagrant ssh-config >sshconfig',
99                        cwd='builder', shell=True) != 0:
100         raise BuildException("Error getting ssh config")
101     vagranthost = 'default'  # Host in ssh config file
102     sshconfig = paramiko.SSHConfig()
103     sshf = open(os.path.join('builder', 'sshconfig'), 'r')
104     sshconfig.parse(sshf)
105     sshf.close()
106     sshconfig = sshconfig.lookup(vagranthost)
107     idfile = sshconfig['identityfile']
108     if isinstance(idfile, list):
109         idfile = idfile[0]
110     elif idfile.startswith('"') and idfile.endswith('"'):
111         idfile = idfile[1:-1]
112     return {'hostname': sshconfig['hostname'],
113             'port': int(sshconfig['port']),
114             'user': sshconfig['user'],
115             'idfile': idfile}
116
117
118 def get_clean_vm(reset=False):
119     """Get a clean VM ready to do a buildserver build.
120
121     This might involve creating and starting a new virtual machine from
122     scratch, or it might be as simple (unless overridden by the reset
123     parameter) as re-using a snapshot created previously.
124
125     A BuildException will be raised if anything goes wrong.
126
127     :reset: True to force creating from scratch.
128     :returns: A dictionary containing 'hostname', 'port', 'user'
129         and 'idfile'
130     """
131     # Reset existing builder machine to a clean state if possible.
132     vm_ok = False
133     if not reset:
134         logging.info("Checking for valid existing build server")
135
136         if got_valid_builder_vm():
137             logging.info("...VM is present")
138             p = FDroidPopen(['VBoxManage', 'snapshot',
139                              get_builder_vm_id(), 'list',
140                              '--details'], cwd='builder')
141             if 'fdroidclean' in p.output:
142                 logging.info("...snapshot exists - resetting build server to "
143                              "clean state")
144                 retcode, output = vagrant(['status'], cwd='builder')
145
146                 if 'running' in output:
147                     logging.info("...suspending")
148                     vagrant(['suspend'], cwd='builder')
149                     logging.info("...waiting a sec...")
150                     time.sleep(10)
151                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
152                                  'restore', 'fdroidclean'],
153                                 cwd='builder')
154
155                 if p.returncode == 0:
156                     logging.info("...reset to snapshot - server is valid")
157                     retcode, output = vagrant(['up'], cwd='builder')
158                     if retcode != 0:
159                         raise BuildException("Failed to start build server")
160                     logging.info("...waiting a sec...")
161                     time.sleep(10)
162                     sshinfo = get_vagrant_sshinfo()
163                     vm_ok = True
164                 else:
165                     logging.info("...failed to reset to snapshot")
166             else:
167                 logging.info("...snapshot doesn't exist - "
168                              "VBoxManage snapshot list:\n" + p.output)
169
170     # If we can't use the existing machine for any reason, make a
171     # new one from scratch.
172     if not vm_ok:
173         if os.path.exists('builder'):
174             logging.info("Removing broken/incomplete/unwanted build server")
175             vagrant(['destroy', '-f'], cwd='builder')
176             shutil.rmtree('builder')
177         os.mkdir('builder')
178
179         p = subprocess.Popen(['vagrant', '--version'],
180                              stdout=subprocess.PIPE)
181         vver = p.communicate()[0].strip().split(' ')[1]
182         if vver.split('.')[0] != '1' or int(vver.split('.')[1]) < 4:
183             raise BuildException("Unsupported vagrant version {0}".format(vver))
184
185         with open(os.path.join('builder', 'Vagrantfile'), 'w') as vf:
186             vf.write('Vagrant.configure("2") do |config|\n')
187             vf.write('config.vm.box = "buildserver"\n')
188             vf.write('config.vm.synced_folder ".", "/vagrant", disabled: true\n')
189             vf.write('end\n')
190
191         logging.info("Starting new build server")
192         retcode, _ = vagrant(['up'], cwd='builder')
193         if retcode != 0:
194             raise BuildException("Failed to start build server")
195
196         # Open SSH connection to make sure it's working and ready...
197         logging.info("Connecting to virtual machine...")
198         sshinfo = get_vagrant_sshinfo()
199         sshs = paramiko.SSHClient()
200         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
201         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
202                      port=sshinfo['port'], timeout=300,
203                      look_for_keys=False,
204                      key_filename=sshinfo['idfile'])
205         sshs.close()
206
207         logging.info("Saving clean state of new build server")
208         retcode, _ = vagrant(['suspend'], cwd='builder')
209         if retcode != 0:
210             raise BuildException("Failed to suspend build server")
211         logging.info("...waiting a sec...")
212         time.sleep(10)
213         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
214                          'take', 'fdroidclean'],
215                         cwd='builder')
216         if p.returncode != 0:
217             raise BuildException("Failed to take snapshot")
218         logging.info("...waiting a sec...")
219         time.sleep(10)
220         logging.info("Restarting new build server")
221         retcode, _ = vagrant(['up'], cwd='builder')
222         if retcode != 0:
223             raise BuildException("Failed to start build server")
224         logging.info("...waiting a sec...")
225         time.sleep(10)
226         # Make sure it worked...
227         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
228                          'list', '--details'],
229                         cwd='builder')
230         if 'fdroidclean' not in p.output:
231             raise BuildException("Failed to take snapshot.")
232
233     return sshinfo
234
235
236 def release_vm():
237     """Release the VM previously started with get_clean_vm().
238
239     This should always be called.
240     """
241     logging.info("Suspending build server")
242     subprocess.call(['vagrant', 'suspend'], cwd='builder')
243
244
245 # Note that 'force' here also implies test mode.
246 def build_server(app, build, vcs, build_dir, output_dir, force):
247     """Do a build on the build server."""
248
249     try:
250         paramiko
251     except NameError:
252         raise BuildException("Paramiko is required to use the buildserver")
253     if options.verbose:
254         logging.getLogger("paramiko").setLevel(logging.INFO)
255     else:
256         logging.getLogger("paramiko").setLevel(logging.WARN)
257
258     sshinfo = get_clean_vm()
259
260     try:
261
262         # Open SSH connection...
263         logging.info("Connecting to virtual machine...")
264         sshs = paramiko.SSHClient()
265         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
266         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
267                      port=sshinfo['port'], timeout=300,
268                      look_for_keys=False, key_filename=sshinfo['idfile'])
269
270         homedir = '/home/' + sshinfo['user']
271
272         # Get an SFTP connection...
273         ftp = sshs.open_sftp()
274         ftp.get_channel().settimeout(15)
275
276         # Put all the necessary files in place...
277         ftp.chdir(homedir)
278
279         # Helper to copy the contents of a directory to the server...
280         def send_dir(path):
281             root = os.path.dirname(path)
282             main = os.path.basename(path)
283             ftp.mkdir(main)
284             for r, d, f in os.walk(path):
285                 rr = os.path.relpath(r, root)
286                 ftp.chdir(rr)
287                 for dd in d:
288                     ftp.mkdir(dd)
289                 for ff in f:
290                     lfile = os.path.join(root, rr, ff)
291                     if not os.path.islink(lfile):
292                         ftp.put(lfile, ff)
293                         ftp.chmod(ff, os.stat(lfile).st_mode)
294                 for i in range(len(rr.split('/'))):
295                     ftp.chdir('..')
296             ftp.chdir('..')
297
298         logging.info("Preparing server for build...")
299         serverpath = os.path.abspath(os.path.dirname(__file__))
300         ftp.mkdir('fdroidserver')
301         ftp.chdir('fdroidserver')
302         ftp.put(os.path.join(serverpath, '..', 'fdroid'), 'fdroid')
303         ftp.chmod('fdroid', 0o755)
304         send_dir(os.path.join(serverpath))
305         ftp.chdir(homedir)
306
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 build.extlibs:
335             ftp.chdir(homedir + '/build/extlib')
336             for lib in build.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 build.srclibs:
352             for lib in build.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 = os.path.join(homedir, 'fdroidserver', 'fdroid')
385         cmdline += ' build --on-server'
386         if force:
387             cmdline += ' --force --test'
388         if options.verbose:
389             cmdline += ' --verbose'
390         cmdline += " %s:%s" % (app.id, build.vercode)
391         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
392         output = ''
393         while not chan.exit_status_ready():
394             while chan.recv_ready():
395                 output += chan.recv(1024)
396             time.sleep(0.1)
397         logging.info("...getting exit status")
398         returncode = chan.recv_exit_status()
399         while True:
400             get = chan.recv(1024)
401             if len(get) == 0:
402                 break
403             output += get
404         if returncode != 0:
405             raise BuildException(
406                 "Build.py failed on server for {0}:{1}".format(
407                     app.id, build.version), output)
408
409         # Retrieve the built files...
410         logging.info("Retrieving build output...")
411         if force:
412             ftp.chdir(homedir + '/tmp')
413         else:
414             ftp.chdir(homedir + '/unsigned')
415         apkfile = common.getapkname(app, build)
416         tarball = common.getsrcname(app, build)
417         try:
418             ftp.get(apkfile, os.path.join(output_dir, apkfile))
419             if not options.notarball:
420                 ftp.get(tarball, os.path.join(output_dir, tarball))
421         except:
422             raise BuildException(
423                 "Build failed for %s:%s - missing output files".format(
424                     app.id, build.version), output)
425         ftp.close()
426
427     finally:
428
429         # Suspend the build server.
430         release_vm()
431
432
433 def adapt_gradle(build_dir):
434     filename = 'build.gradle'
435     for root, dirs, files in os.walk(build_dir):
436         for filename in files:
437             if not filename.endswith('.gradle'):
438                 continue
439             path = os.path.join(root, filename)
440             if not os.path.isfile(path):
441                 continue
442             logging.debug("Adapting %s at %s" % (filename, path))
443             common.regsub_file(r"""(\s*)buildToolsVersion([\s=]+).*""",
444                                r"""\1buildToolsVersion\2'%s'""" % config['build_tools'],
445                                path)
446
447
448 def capitalize_intact(string):
449     """Like str.capitalize(), but leave the rest of the string intact without
450     switching it to lowercase."""
451     if len(string) == 0:
452         return string
453     if len(string) == 1:
454         return string.upper()
455     return string[0].upper() + string[1:]
456
457
458 def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
459     """Do a build locally."""
460
461     ndk_path = build.ndk_path()
462     if build.buildjni and build.buildjni != ['no']:
463         if not ndk_path:
464             logging.critical("Android NDK version '%s' could not be found!" % build.ndk or 'r10e')
465             logging.critical("Configured versions:")
466             for k, v in config['ndk_paths'].iteritems():
467                 if k.endswith("_orig"):
468                     continue
469                 logging.critical("  %s: %s" % (k, v))
470             sys.exit(3)
471         elif not os.path.isdir(ndk_path):
472             logging.critical("Android NDK '%s' is not a directory!" % ndk_path)
473             sys.exit(3)
474
475     # Set up environment vars that depend on each build
476     for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
477         common.env[n] = ndk_path
478
479     common.reset_env_path()
480     # Set up the current NDK to the PATH
481     common.add_to_env_path(ndk_path)
482
483     # Prepare the source code...
484     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
485                                                   build_dir, srclib_dir,
486                                                   extlib_dir, onserver, refresh)
487
488     # We need to clean via the build tool in case the binary dirs are
489     # different from the default ones
490     p = None
491     gradletasks = []
492     method = build.method()
493     if method == 'maven':
494         logging.info("Cleaning Maven project...")
495         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
496
497         if '@' in build.maven:
498             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
499             maven_dir = os.path.normpath(maven_dir)
500         else:
501             maven_dir = root_dir
502
503         p = FDroidPopen(cmd, cwd=maven_dir)
504
505     elif method == 'gradle':
506
507         logging.info("Cleaning Gradle project...")
508
509         if build.preassemble:
510             gradletasks += build.preassemble
511
512         flavours = build.gradle
513         if flavours == ['yes']:
514             flavours = []
515
516         flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
517
518         gradletasks += ['assemble' + flavours_cmd + 'Release']
519
520         adapt_gradle(build_dir)
521         for name, number, libpath in srclibpaths:
522             adapt_gradle(libpath)
523
524         cmd = [config['gradle']]
525         if build.gradleprops:
526             cmd += ['-P'+kv for kv in build.gradleprops]
527
528         cmd += ['clean']
529
530         p = FDroidPopen(cmd, cwd=root_dir)
531
532     elif method == 'kivy':
533         pass
534
535     elif method == 'ant':
536         logging.info("Cleaning Ant project...")
537         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
538
539     if p is not None and p.returncode != 0:
540         raise BuildException("Error cleaning %s:%s" %
541                              (app.id, build.version), p.output)
542
543     for root, dirs, files in os.walk(build_dir):
544
545         def del_dirs(dl):
546             for d in dl:
547                 if d in dirs:
548                     shutil.rmtree(os.path.join(root, d))
549
550         def del_files(fl):
551             for f in fl:
552                 if f in files:
553                     os.remove(os.path.join(root, f))
554
555         if 'build.gradle' in files:
556             # Even when running clean, gradle stores task/artifact caches in
557             # .gradle/ as binary files. To avoid overcomplicating the scanner,
558             # manually delete them, just like `gradle clean` should have removed
559             # the build/ dirs.
560             del_dirs(['build', '.gradle', 'gradle'])
561             del_files(['gradlew', 'gradlew.bat'])
562
563         if 'pom.xml' in files:
564             del_dirs(['target'])
565
566         if any(f in files for f in ['ant.properties', 'project.properties', 'build.xml']):
567             del_dirs(['bin', 'gen'])
568
569         if 'jni' in dirs:
570             del_dirs(['obj'])
571
572     if options.skipscan:
573         if build.scandelete:
574             raise BuildException("Refusing to skip source scan since scandelete is present")
575     else:
576         # Scan before building...
577         logging.info("Scanning source for common problems...")
578         count = scanner.scan_source(build_dir, root_dir, build)
579         if count > 0:
580             if force:
581                 logging.warn('Scanner found %d problems' % count)
582             else:
583                 raise BuildException("Can't build due to %d errors while scanning" % count)
584
585     if not options.notarball:
586         # Build the source tarball right before we build the release...
587         logging.info("Creating source tarball...")
588         tarname = common.getsrcname(app, build)
589         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
590
591         def tarexc(f):
592             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
593         tarball.add(build_dir, tarname, exclude=tarexc)
594         tarball.close()
595
596     # Run a build command if one is required...
597     if build.build:
598         logging.info("Running 'build' commands in %s" % root_dir)
599         cmd = common.replace_config_vars(build.build, build)
600
601         # Substitute source library paths into commands...
602         for name, number, libpath in srclibpaths:
603             libpath = os.path.relpath(libpath, root_dir)
604             cmd = cmd.replace('$$' + name + '$$', libpath)
605
606         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
607
608         if p.returncode != 0:
609             raise BuildException("Error running build command for %s:%s" %
610                                  (app.id, build.version), p.output)
611
612     # Build native stuff if required...
613     if build.buildjni and build.buildjni != ['no']:
614         logging.info("Building the native code")
615         jni_components = build.buildjni
616
617         if jni_components == ['yes']:
618             jni_components = ['']
619         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
620         for d in jni_components:
621             if d:
622                 logging.info("Building native code in '%s'" % d)
623             else:
624                 logging.info("Building native code in the main project")
625             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
626             if os.path.exists(manifest):
627                 # Read and write the whole AM.xml to fix newlines and avoid
628                 # the ndk r8c or later 'wordlist' errors. The outcome of this
629                 # under gnu/linux is the same as when using tools like
630                 # dos2unix, but the native python way is faster and will
631                 # work in non-unix systems.
632                 manifest_text = open(manifest, 'U').read()
633                 open(manifest, 'w').write(manifest_text)
634                 # In case the AM.xml read was big, free the memory
635                 del manifest_text
636             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
637             if p.returncode != 0:
638                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.version), p.output)
639
640     p = None
641     # Build the release...
642     if method == 'maven':
643         logging.info("Building Maven project...")
644
645         if '@' in build.maven:
646             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
647         else:
648             maven_dir = root_dir
649
650         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
651                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
652                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
653                   'package']
654         if build.target:
655             target = build.target.split('-')[1]
656             common.regsub_file(r'<platform>[0-9]*</platform>',
657                                r'<platform>%s</platform>' % target,
658                                os.path.join(root_dir, 'pom.xml'))
659             if '@' in build.maven:
660                 common.regsub_file(r'<platform>[0-9]*</platform>',
661                                    r'<platform>%s</platform>' % target,
662                                    os.path.join(maven_dir, 'pom.xml'))
663
664         p = FDroidPopen(mvncmd, cwd=maven_dir)
665
666         bindir = os.path.join(root_dir, 'target')
667
668     elif method == 'kivy':
669         logging.info("Building Kivy project...")
670
671         spec = os.path.join(root_dir, 'buildozer.spec')
672         if not os.path.exists(spec):
673             raise BuildException("Expected to find buildozer-compatible spec at {0}"
674                                  .format(spec))
675
676         defaults = {'orientation': 'landscape', 'icon': '',
677                     'permissions': '', 'android.api': "18"}
678         bconfig = ConfigParser(defaults, allow_no_value=True)
679         bconfig.read(spec)
680
681         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
682         if os.path.exists(distdir):
683             shutil.rmtree(distdir)
684
685         modules = bconfig.get('app', 'requirements').split(',')
686
687         cmd = 'ANDROIDSDK=' + config['sdk_path']
688         cmd += ' ANDROIDNDK=' + ndk_path
689         cmd += ' ANDROIDNDKVER=' + build.ndk
690         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
691         cmd += ' VIRTUALENV=virtualenv'
692         cmd += ' ./distribute.sh'
693         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
694         cmd += ' -d fdroid'
695         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
696         if p.returncode != 0:
697             raise BuildException("Distribute build failed")
698
699         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
700         if cid != app.id:
701             raise BuildException("Package ID mismatch between metadata and spec")
702
703         orientation = bconfig.get('app', 'orientation', 'landscape')
704         if orientation == 'all':
705             orientation = 'sensor'
706
707         cmd = ['./build.py'
708                '--dir', root_dir,
709                '--name', bconfig.get('app', 'title'),
710                '--package', app.id,
711                '--version', bconfig.get('app', 'version'),
712                '--orientation', orientation
713                ]
714
715         perms = bconfig.get('app', 'permissions')
716         for perm in perms.split(','):
717             cmd.extend(['--permission', perm])
718
719         if config.get('app', 'fullscreen') == 0:
720             cmd.append('--window')
721
722         icon = bconfig.get('app', 'icon.filename')
723         if icon:
724             cmd.extend(['--icon', os.path.join(root_dir, icon)])
725
726         cmd.append('release')
727         p = FDroidPopen(cmd, cwd=distdir)
728
729     elif method == 'gradle':
730         logging.info("Building Gradle project...")
731
732         cmd = [config['gradle']]
733         if build.gradleprops:
734             cmd += ['-P'+kv for kv in build.gradleprops]
735
736         cmd += gradletasks
737
738         p = FDroidPopen(cmd, cwd=root_dir)
739
740     elif method == 'ant':
741         logging.info("Building Ant project...")
742         cmd = ['ant']
743         if build.antcommands:
744             cmd += build.antcommands
745         else:
746             cmd += ['release']
747         p = FDroidPopen(cmd, cwd=root_dir)
748
749         bindir = os.path.join(root_dir, 'bin')
750
751     if p is not None and p.returncode != 0:
752         raise BuildException("Build failed for %s:%s" % (app.id, build.version), p.output)
753     logging.info("Successfully built version " + build.version + ' of ' + app.id)
754
755     if method == 'maven':
756         stdout_apk = '\n'.join([
757             line for line in p.output.splitlines() if any(
758                 a in line for a in ('.apk', '.ap_', '.jar'))])
759         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
760                      stdout_apk, re.S | re.M)
761         if not m:
762             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
763                          stdout_apk, re.S | re.M)
764         if not m:
765             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
766                          stdout_apk, re.S | re.M)
767
768         if not m:
769             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
770                          stdout_apk, re.S | re.M)
771         if not m:
772             raise BuildException('Failed to find output')
773         src = m.group(1)
774         src = os.path.join(bindir, src) + '.apk'
775     elif method == 'kivy':
776         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
777                            '{0}-{1}-release.apk'.format(
778                                bconfig.get('app', 'title'),
779                                bconfig.get('app', 'version')))
780     elif method == 'gradle':
781         src = None
782         for apks_dir in [
783                 os.path.join(root_dir, 'build', 'outputs', 'apk'),
784                 os.path.join(root_dir, 'build', 'apk'),
785                 ]:
786             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
787                 apks = glob.glob(os.path.join(apks_dir, apkglob))
788
789                 if len(apks) > 1:
790                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
791                                          '\n'.join(apks))
792                 if len(apks) == 1:
793                     src = apks[0]
794                     break
795             if src is not None:
796                 break
797
798         if src is None:
799             raise BuildException('Failed to find any output apks')
800
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()