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