chiark / gitweb /
bad025a6d9a37258aedad1bada262e1a82301f6e
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python3
2 #
3 # build.py - part of the FDroid server tools
4 # Copyright (C) 2010-2014, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import sys
21 import os
22 import shutil
23 import glob
24 import subprocess
25 import re
26 import tarfile
27 import traceback
28 import time
29 import json
30 from configparser import ConfigParser
31 from argparse import ArgumentParser
32 import logging
33
34 from . import common
35 from . import net
36 from . import metadata
37 from . import scanner
38 from .common import FDroidException, BuildException, VCSException, FDroidPopen, SdkToolsPopen
39
40 try:
41     import paramiko
42 except ImportError:
43     pass
44
45
46 def get_builder_vm_id():
47     vd = os.path.join('builder', '.vagrant')
48     if os.path.isdir(vd):
49         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
50         with open(os.path.join(vd, 'machines', 'default',
51                                'virtualbox', 'id')) as vf:
52             id = vf.read()
53         return id
54     else:
55         # Vagrant 1.0 - it's a json file...
56         with open(os.path.join('builder', '.vagrant')) as vf:
57             v = json.load(vf)
58         return v['active']['default']
59
60
61 def got_valid_builder_vm():
62     """Returns True if we have a valid-looking builder vm
63     """
64     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
65         return False
66     vd = os.path.join('builder', '.vagrant')
67     if not os.path.exists(vd):
68         return False
69     if not os.path.isdir(vd):
70         # Vagrant 1.0 - if the directory is there, it's valid...
71         return True
72     # Vagrant 1.2 - the directory can exist, but the id can be missing...
73     if not os.path.exists(os.path.join(vd, 'machines', 'default',
74                                        'virtualbox', 'id')):
75         return False
76     return True
77
78
79 def vagrant(params, cwd=None, printout=False):
80     """Run a vagrant command.
81
82     :param: list of parameters to pass to vagrant
83     :cwd: directory to run in, or None for current directory
84     :returns: (ret, out) where ret is the return code, and out
85                is the stdout (and stderr) from vagrant
86     """
87     p = FDroidPopen(['vagrant'] + params, cwd=cwd)
88     return (p.returncode, p.output)
89
90
91 def get_vagrant_sshinfo():
92     """Get ssh connection info for a vagrant VM
93
94     :returns: A dictionary containing 'hostname', 'port', 'user'
95         and 'idfile'
96     """
97     if subprocess.call('vagrant ssh-config >sshconfig',
98                        cwd='builder', shell=True) != 0:
99         raise BuildException("Error getting ssh config")
100     vagranthost = 'default'  # Host in ssh config file
101     sshconfig = paramiko.SSHConfig()
102     sshf = open(os.path.join('builder', 'sshconfig'), 'r')
103     sshconfig.parse(sshf)
104     sshf.close()
105     sshconfig = sshconfig.lookup(vagranthost)
106     idfile = sshconfig['identityfile']
107     if isinstance(idfile, list):
108         idfile = idfile[0]
109     elif idfile.startswith('"') and idfile.endswith('"'):
110         idfile = idfile[1:-1]
111     return {'hostname': sshconfig['hostname'],
112             'port': int(sshconfig['port']),
113             'user': sshconfig['user'],
114             'idfile': idfile}
115
116
117 def get_clean_vm(reset=False):
118     """Get a clean VM ready to do a buildserver build.
119
120     This might involve creating and starting a new virtual machine from
121     scratch, or it might be as simple (unless overridden by the reset
122     parameter) as re-using a snapshot created previously.
123
124     A BuildException will be raised if anything goes wrong.
125
126     :reset: True to force creating from scratch.
127     :returns: A dictionary containing 'hostname', 'port', 'user'
128         and 'idfile'
129     """
130     # Reset existing builder machine to a clean state if possible.
131     vm_ok = False
132     if not reset:
133         logging.info("Checking for valid existing build server")
134
135         if got_valid_builder_vm():
136             logging.info("...VM is present")
137             p = FDroidPopen(['VBoxManage', 'snapshot',
138                              get_builder_vm_id(), 'list',
139                              '--details'], cwd='builder')
140             if 'fdroidclean' in p.output:
141                 logging.info("...snapshot exists - resetting build server to "
142                              "clean state")
143                 retcode, output = vagrant(['status'], cwd='builder')
144
145                 if 'running' in output:
146                     logging.info("...suspending")
147                     vagrant(['suspend'], cwd='builder')
148                     logging.info("...waiting a sec...")
149                     time.sleep(10)
150                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
151                                  'restore', 'fdroidclean'],
152                                 cwd='builder')
153
154                 if p.returncode == 0:
155                     logging.info("...reset to snapshot - server is valid")
156                     retcode, output = vagrant(['up'], cwd='builder')
157                     if retcode != 0:
158                         raise BuildException("Failed to start build server")
159                     logging.info("...waiting a sec...")
160                     time.sleep(10)
161                     sshinfo = get_vagrant_sshinfo()
162                     vm_ok = True
163                 else:
164                     logging.info("...failed to reset to snapshot")
165             else:
166                 logging.info("...snapshot doesn't exist - "
167                              "VBoxManage snapshot list:\n" + p.output)
168
169     # If we can't use the existing machine for any reason, make a
170     # new one from scratch.
171     if not vm_ok:
172         if os.path.exists('builder'):
173             logging.info("Removing broken/incomplete/unwanted build server")
174             vagrant(['destroy', '-f'], cwd='builder')
175             shutil.rmtree('builder')
176         os.mkdir('builder')
177
178         p = subprocess.Popen(['vagrant', '--version'],
179                              universal_newlines=True,
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         cmdline = '. /etc/profile && ' + cmdline
392         chan.exec_command('bash -c "' + cmdline + '"')
393         output = bytes()
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), str(output, 'utf-8'))
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 force_gradle_build_tools(build_dir, build_tools):
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("Forcing build-tools %s in %s" % (build_tools, path))
443             common.regsub_file(r"""(\s*)buildToolsVersion([\s=]+).*""",
444                                r"""\1buildToolsVersion\2'%s'""" % 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'].items():
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     common.set_FDroidPopen_env(build)
476
477     # Prepare the source code...
478     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
479                                                   build_dir, srclib_dir,
480                                                   extlib_dir, onserver, refresh)
481
482     # We need to clean via the build tool in case the binary dirs are
483     # different from the default ones
484     p = None
485     gradletasks = []
486     bmethod = build.build_method()
487     if bmethod == 'maven':
488         logging.info("Cleaning Maven project...")
489         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
490
491         if '@' in build.maven:
492             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
493             maven_dir = os.path.normpath(maven_dir)
494         else:
495             maven_dir = root_dir
496
497         p = FDroidPopen(cmd, cwd=maven_dir)
498
499     elif bmethod == 'gradle':
500
501         logging.info("Cleaning Gradle project...")
502
503         if build.preassemble:
504             gradletasks += build.preassemble
505
506         flavours = build.gradle
507         if flavours == ['yes']:
508             flavours = []
509
510         flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
511
512         gradletasks += ['assemble' + flavours_cmd + 'Release']
513
514         if config['force_build_tools']:
515             force_gradle_build_tools(build_dir, config['build_tools'])
516             for name, number, libpath in srclibpaths:
517                 force_gradle_build_tools(libpath, config['build_tools'])
518
519         cmd = [config['gradle']]
520         if build.gradleprops:
521             cmd += ['-P' + kv for kv in build.gradleprops]
522
523         cmd += ['clean']
524
525         p = FDroidPopen(cmd, cwd=root_dir)
526
527     elif bmethod == 'kivy':
528         pass
529
530     elif bmethod == 'ant':
531         logging.info("Cleaning Ant project...")
532         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
533
534     if p is not None and p.returncode != 0:
535         raise BuildException("Error cleaning %s:%s" %
536                              (app.id, build.version), p.output)
537
538     for root, dirs, files in os.walk(build_dir):
539
540         def del_dirs(dl):
541             for d in dl:
542                 if d in dirs:
543                     shutil.rmtree(os.path.join(root, d))
544
545         def del_files(fl):
546             for f in fl:
547                 if f in files:
548                     os.remove(os.path.join(root, f))
549
550         if 'build.gradle' in files:
551             # Even when running clean, gradle stores task/artifact caches in
552             # .gradle/ as binary files. To avoid overcomplicating the scanner,
553             # manually delete them, just like `gradle clean` should have removed
554             # the build/ dirs.
555             del_dirs(['build', '.gradle'])
556             del_files(['gradlew', 'gradlew.bat'])
557
558         if 'pom.xml' in files:
559             del_dirs(['target'])
560
561         if any(f in files for f in ['ant.properties', 'project.properties', 'build.xml']):
562             del_dirs(['bin', 'gen'])
563
564         if 'jni' in dirs:
565             del_dirs(['obj'])
566
567     if options.skipscan:
568         if build.scandelete:
569             raise BuildException("Refusing to skip source scan since scandelete is present")
570     else:
571         # Scan before building...
572         logging.info("Scanning source for common problems...")
573         count = scanner.scan_source(build_dir, root_dir, build)
574         if count > 0:
575             if force:
576                 logging.warn('Scanner found %d problems' % count)
577             else:
578                 raise BuildException("Can't build due to %d errors while scanning" % count)
579
580     if not options.notarball:
581         # Build the source tarball right before we build the release...
582         logging.info("Creating source tarball...")
583         tarname = common.getsrcname(app, build)
584         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
585
586         def tarexc(f):
587             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
588         tarball.add(build_dir, tarname, exclude=tarexc)
589         tarball.close()
590
591     # Run a build command if one is required...
592     if build.build:
593         logging.info("Running 'build' commands in %s" % root_dir)
594         cmd = common.replace_config_vars(build.build, build)
595
596         # Substitute source library paths into commands...
597         for name, number, libpath in srclibpaths:
598             libpath = os.path.relpath(libpath, root_dir)
599             cmd = cmd.replace('$$' + name + '$$', libpath)
600
601         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
602
603         if p.returncode != 0:
604             raise BuildException("Error running build command for %s:%s" %
605                                  (app.id, build.version), p.output)
606
607     # Build native stuff if required...
608     if build.buildjni and build.buildjni != ['no']:
609         logging.info("Building the native code")
610         jni_components = build.buildjni
611
612         if jni_components == ['yes']:
613             jni_components = ['']
614         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
615         for d in jni_components:
616             if d:
617                 logging.info("Building native code in '%s'" % d)
618             else:
619                 logging.info("Building native code in the main project")
620             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
621             if os.path.exists(manifest):
622                 # Read and write the whole AM.xml to fix newlines and avoid
623                 # the ndk r8c or later 'wordlist' errors. The outcome of this
624                 # under gnu/linux is the same as when using tools like
625                 # dos2unix, but the native python way is faster and will
626                 # work in non-unix systems.
627                 manifest_text = open(manifest, 'U').read()
628                 open(manifest, 'w').write(manifest_text)
629                 # In case the AM.xml read was big, free the memory
630                 del manifest_text
631             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
632             if p.returncode != 0:
633                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.version), p.output)
634
635     p = None
636     # Build the release...
637     if bmethod == 'maven':
638         logging.info("Building Maven project...")
639
640         if '@' in build.maven:
641             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
642         else:
643             maven_dir = root_dir
644
645         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
646                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
647                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
648                   'package']
649         if build.target:
650             target = build.target.split('-')[1]
651             common.regsub_file(r'<platform>[0-9]*</platform>',
652                                r'<platform>%s</platform>' % target,
653                                os.path.join(root_dir, 'pom.xml'))
654             if '@' in build.maven:
655                 common.regsub_file(r'<platform>[0-9]*</platform>',
656                                    r'<platform>%s</platform>' % target,
657                                    os.path.join(maven_dir, 'pom.xml'))
658
659         p = FDroidPopen(mvncmd, cwd=maven_dir)
660
661         bindir = os.path.join(root_dir, 'target')
662
663     elif bmethod == 'kivy':
664         logging.info("Building Kivy project...")
665
666         spec = os.path.join(root_dir, 'buildozer.spec')
667         if not os.path.exists(spec):
668             raise BuildException("Expected to find buildozer-compatible spec at {0}"
669                                  .format(spec))
670
671         defaults = {'orientation': 'landscape', 'icon': '',
672                     'permissions': '', 'android.api': "18"}
673         bconfig = ConfigParser(defaults, allow_no_value=True)
674         bconfig.read(spec)
675
676         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
677         if os.path.exists(distdir):
678             shutil.rmtree(distdir)
679
680         modules = bconfig.get('app', 'requirements').split(',')
681
682         cmd = 'ANDROIDSDK=' + config['sdk_path']
683         cmd += ' ANDROIDNDK=' + ndk_path
684         cmd += ' ANDROIDNDKVER=' + build.ndk
685         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
686         cmd += ' VIRTUALENV=virtualenv'
687         cmd += ' ./distribute.sh'
688         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
689         cmd += ' -d fdroid'
690         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
691         if p.returncode != 0:
692             raise BuildException("Distribute build failed")
693
694         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
695         if cid != app.id:
696             raise BuildException("Package ID mismatch between metadata and spec")
697
698         orientation = bconfig.get('app', 'orientation', 'landscape')
699         if orientation == 'all':
700             orientation = 'sensor'
701
702         cmd = ['./build.py'
703                '--dir', root_dir,
704                '--name', bconfig.get('app', 'title'),
705                '--package', app.id,
706                '--version', bconfig.get('app', 'version'),
707                '--orientation', orientation
708                ]
709
710         perms = bconfig.get('app', 'permissions')
711         for perm in perms.split(','):
712             cmd.extend(['--permission', perm])
713
714         if config.get('app', 'fullscreen') == 0:
715             cmd.append('--window')
716
717         icon = bconfig.get('app', 'icon.filename')
718         if icon:
719             cmd.extend(['--icon', os.path.join(root_dir, icon)])
720
721         cmd.append('release')
722         p = FDroidPopen(cmd, cwd=distdir)
723
724     elif bmethod == 'gradle':
725         logging.info("Building Gradle project...")
726
727         cmd = [config['gradle']]
728         if build.gradleprops:
729             cmd += ['-P' + kv for kv in build.gradleprops]
730
731         cmd += gradletasks
732
733         p = FDroidPopen(cmd, cwd=root_dir)
734
735     elif bmethod == 'ant':
736         logging.info("Building Ant project...")
737         cmd = ['ant']
738         if build.antcommands:
739             cmd += build.antcommands
740         else:
741             cmd += ['release']
742         p = FDroidPopen(cmd, cwd=root_dir)
743
744         bindir = os.path.join(root_dir, 'bin')
745
746     if p is not None and p.returncode != 0:
747         raise BuildException("Build failed for %s:%s" % (app.id, build.version), p.output)
748     logging.info("Successfully built version " + build.version + ' of ' + app.id)
749
750     omethod = build.output_method()
751     if omethod == 'maven':
752         stdout_apk = '\n'.join([
753             line for line in p.output.splitlines() if any(
754                 a in line for a in ('.apk', '.ap_', '.jar'))])
755         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
756                      stdout_apk, re.S | re.M)
757         if not m:
758             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
759                          stdout_apk, re.S | re.M)
760         if not m:
761             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
762                          stdout_apk, re.S | re.M)
763
764         if not m:
765             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
766                          stdout_apk, re.S | re.M)
767         if not m:
768             raise BuildException('Failed to find output')
769         src = m.group(1)
770         src = os.path.join(bindir, src) + '.apk'
771     elif omethod == 'kivy':
772         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
773                            '{0}-{1}-release.apk'.format(
774                                bconfig.get('app', 'title'),
775                                bconfig.get('app', 'version')))
776     elif omethod == 'gradle':
777         src = None
778         for apks_dir in [
779                 os.path.join(root_dir, 'build', 'outputs', 'apk'),
780                 os.path.join(root_dir, 'build', 'apk'),
781                 ]:
782             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
783                 apks = glob.glob(os.path.join(apks_dir, apkglob))
784
785                 if len(apks) > 1:
786                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
787                                          '\n'.join(apks))
788                 if len(apks) == 1:
789                     src = apks[0]
790                     break
791             if src is not None:
792                 break
793
794         if src is None:
795             raise BuildException('Failed to find any output apks')
796
797     elif omethod == 'ant':
798         stdout_apk = '\n'.join([
799             line for line in p.output.splitlines() if '.apk' in line])
800         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
801                        re.S | re.M).group(1)
802         src = os.path.join(bindir, src)
803     elif omethod == 'raw':
804         globpath = os.path.join(root_dir, build.output)
805         apks = glob.glob(globpath)
806         if len(apks) > 1:
807             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
808         if len(apks) < 1:
809             raise BuildException('No apks match %s' % globpath)
810         src = os.path.normpath(apks[0])
811
812     # Make sure it's not debuggable...
813     if common.isApkDebuggable(src, config):
814         raise BuildException("APK is debuggable")
815
816     # By way of a sanity check, make sure the version and version
817     # code in our new apk match what we expect...
818     logging.debug("Checking " + src)
819     if not os.path.exists(src):
820         raise BuildException("Unsigned apk is not at expected location of " + src)
821
822     p = SdkToolsPopen(['aapt', 'dump', 'badging', src], output=False)
823
824     vercode = None
825     version = None
826     foundid = None
827     nativecode = None
828     for line in p.output.splitlines():
829         if line.startswith("package:"):
830             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
831             m = pat.match(line)
832             if m:
833                 foundid = m.group(1)
834             pat = re.compile(".*versionCode='([0-9]*)'.*")
835             m = pat.match(line)
836             if m:
837                 vercode = m.group(1)
838             pat = re.compile(".*versionName='([^']*)'.*")
839             m = pat.match(line)
840             if m:
841                 version = m.group(1)
842         elif line.startswith("native-code:"):
843             nativecode = line[12:]
844
845     # Ignore empty strings or any kind of space/newline chars that we don't
846     # care about
847     if nativecode is not None:
848         nativecode = nativecode.strip()
849         nativecode = None if not nativecode else nativecode
850
851     if build.buildjni and build.buildjni != ['no']:
852         if nativecode is None:
853             raise BuildException("Native code should have been built but none was packaged")
854     if build.novcheck:
855         vercode = build.vercode
856         version = build.version
857     if not version or not vercode:
858         raise BuildException("Could not find version information in build in output")
859     if not foundid:
860         raise BuildException("Could not find package ID in output")
861     if foundid != app.id:
862         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app.id)
863
864     # Some apps (e.g. Timeriffic) have had the bonkers idea of
865     # including the entire changelog in the version number. Remove
866     # it so we can compare. (TODO: might be better to remove it
867     # before we compile, in fact)
868     index = version.find(" //")
869     if index != -1:
870         version = version[:index]
871
872     if (version != build.version or
873             vercode != build.vercode):
874         raise BuildException(("Unexpected version/version code in output;"
875                               " APK: '%s' / '%s', "
876                               " Expected: '%s' / '%s'")
877                              % (version, str(vercode), build.version,
878                                 str(build.vercode))
879                              )
880
881     # Add information for 'fdroid verify' to be able to reproduce the build
882     # environment.
883     if onserver:
884         metadir = os.path.join(tmp_dir, 'META-INF')
885         if not os.path.exists(metadir):
886             os.mkdir(metadir)
887         homedir = os.path.expanduser('~')
888         for fn in ['buildserverid', 'fdroidserverid']:
889             shutil.copyfile(os.path.join(homedir, fn),
890                             os.path.join(metadir, fn))
891             subprocess.call(['jar', 'uf', os.path.abspath(src),
892                              'META-INF/' + fn], cwd=tmp_dir)
893
894     # Copy the unsigned apk to our destination directory for further
895     # processing (by publish.py)...
896     dest = os.path.join(output_dir, common.getapkname(app, build))
897     shutil.copyfile(src, dest)
898
899     # Move the source tarball into the output directory...
900     if output_dir != tmp_dir and not options.notarball:
901         shutil.move(os.path.join(tmp_dir, tarname),
902                     os.path.join(output_dir, tarname))
903
904
905 def trybuild(app, build, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
906              tmp_dir, repo_dir, vcs, test, server, force, onserver, refresh):
907     """
908     Build a particular version of an application, if it needs building.
909
910     :param output_dir: The directory where the build output will go. Usually
911        this is the 'unsigned' directory.
912     :param repo_dir: The repo directory - used for checking if the build is
913        necessary.
914     :paaram also_check_dir: An additional location for checking if the build
915        is necessary (usually the archive repo)
916     :param test: True if building in test mode, in which case the build will
917        always happen, even if the output already exists. In test mode, the
918        output directory should be a temporary location, not any of the real
919        ones.
920
921     :returns: True if the build was done, False if it wasn't necessary.
922     """
923
924     dest_apk = common.getapkname(app, build)
925
926     dest = os.path.join(output_dir, dest_apk)
927     dest_repo = os.path.join(repo_dir, dest_apk)
928
929     if not test:
930         if os.path.exists(dest) or os.path.exists(dest_repo):
931             return False
932
933         if also_check_dir:
934             dest_also = os.path.join(also_check_dir, dest_apk)
935             if os.path.exists(dest_also):
936                 return False
937
938     if build.disable and not options.force:
939         return False
940
941     logging.info("Building version %s (%s) of %s" % (
942         build.version, build.vercode, app.id))
943
944     if server:
945         # When using server mode, still keep a local cache of the repo, by
946         # grabbing the source now.
947         vcs.gotorevision(build.commit)
948
949         build_server(app, build, vcs, build_dir, output_dir, force)
950     else:
951         build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
952     return True
953
954
955 def parse_commandline():
956     """Parse the command line. Returns options, parser."""
957
958     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
959     common.setup_global_opts(parser)
960     parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
961     parser.add_argument("-l", "--latest", action="store_true", default=False,
962                         help="Build only the latest version of each package")
963     parser.add_argument("-s", "--stop", action="store_true", default=False,
964                         help="Make the build stop on exceptions")
965     parser.add_argument("-t", "--test", action="store_true", default=False,
966                         help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
967     parser.add_argument("--server", action="store_true", default=False,
968                         help="Use build server")
969     parser.add_argument("--resetserver", action="store_true", default=False,
970                         help="Reset and create a brand new build server, even if the existing one appears to be ok.")
971     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
972                         help="Specify that we're running on the build server")
973     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
974                         help="Skip scanning the source code for binaries and other problems")
975     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
976                         help="Don't create a source tarball, useful when testing a build")
977     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
978                         help="Don't refresh the repository, useful when testing a build with no internet connection")
979     parser.add_argument("-f", "--force", action="store_true", default=False,
980                         help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
981     parser.add_argument("-a", "--all", action="store_true", default=False,
982                         help="Build all applications available")
983     parser.add_argument("-w", "--wiki", default=False, action="store_true",
984                         help="Update the wiki")
985     options = parser.parse_args()
986
987     # Force --stop with --on-server to get correct exit code
988     if options.onserver:
989         options.stop = True
990
991     if options.force and not options.test:
992         parser.error("option %s: Force is only allowed in test mode" % "force")
993
994     return options, parser
995
996 options = None
997 config = None
998
999
1000 def main():
1001
1002     global options, config
1003
1004     options, parser = parse_commandline()
1005
1006     # The defaults for .fdroid.* metadata that is included in a git repo are
1007     # different than for the standard metadata/ layout because expectations
1008     # are different.  In this case, the most common user will be the app
1009     # developer working on the latest update of the app on their own machine.
1010     local_metadata_files = common.get_local_metadata_files()
1011     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
1012         config = dict(common.default_config)
1013         # `fdroid build` should build only the latest version by default since
1014         # most of the time the user will be building the most recent update
1015         if not options.all:
1016             options.latest = True
1017     elif len(local_metadata_files) > 1:
1018         raise FDroidException("Only one local metadata file allowed! Found: "
1019                               + " ".join(local_metadata_files))
1020     else:
1021         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1022             raise FDroidException("No app metadata found, nothing to process!")
1023         if not options.appid and not options.all:
1024             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1025
1026     config = common.read_config(options)
1027
1028     if config['build_server_always']:
1029         options.server = True
1030     if options.resetserver and not options.server:
1031         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1032
1033     log_dir = 'logs'
1034     if not os.path.isdir(log_dir):
1035         logging.info("Creating log directory")
1036         os.makedirs(log_dir)
1037
1038     tmp_dir = 'tmp'
1039     if not os.path.isdir(tmp_dir):
1040         logging.info("Creating temporary directory")
1041         os.makedirs(tmp_dir)
1042
1043     if options.test:
1044         output_dir = tmp_dir
1045     else:
1046         output_dir = 'unsigned'
1047         if not os.path.isdir(output_dir):
1048             logging.info("Creating output directory")
1049             os.makedirs(output_dir)
1050
1051     if config['archive_older'] != 0:
1052         also_check_dir = 'archive'
1053     else:
1054         also_check_dir = None
1055
1056     repo_dir = 'repo'
1057
1058     build_dir = 'build'
1059     if not os.path.isdir(build_dir):
1060         logging.info("Creating build directory")
1061         os.makedirs(build_dir)
1062     srclib_dir = os.path.join(build_dir, 'srclib')
1063     extlib_dir = os.path.join(build_dir, 'extlib')
1064
1065     # Read all app and srclib metadata
1066     allapps = metadata.read_metadata(xref=not options.onserver)
1067
1068     apps = common.read_app_args(options.appid, allapps, True)
1069     for appid, app in list(apps.items()):
1070         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1071             del apps[appid]
1072
1073     if not apps:
1074         raise FDroidException("No apps to process.")
1075
1076     if options.latest:
1077         for app in apps.values():
1078             for build in reversed(app.builds):
1079                 if build.disable and not options.force:
1080                     continue
1081                 app.builds = [build]
1082                 break
1083
1084     if options.wiki:
1085         import mwclient
1086         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1087                              path=config['wiki_path'])
1088         site.login(config['wiki_user'], config['wiki_password'])
1089
1090     # Build applications...
1091     failed_apps = {}
1092     build_succeeded = []
1093     for appid, app in apps.items():
1094
1095         first = True
1096
1097         for build in app.builds:
1098             wikilog = None
1099             try:
1100
1101                 # For the first build of a particular app, we need to set up
1102                 # the source repo. We can reuse it on subsequent builds, if
1103                 # there are any.
1104                 if first:
1105                     if app.RepoType == 'srclib':
1106                         build_dir = os.path.join('build', 'srclib', app.Repo)
1107                     else:
1108                         build_dir = os.path.join('build', appid)
1109
1110                     # Set up vcs interface and make sure we have the latest code...
1111                     logging.debug("Getting {0} vcs interface for {1}"
1112                                   .format(app.RepoType, app.Repo))
1113                     vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
1114
1115                     first = False
1116
1117                 logging.debug("Checking " + build.version)
1118                 if trybuild(app, build, build_dir, output_dir,
1119                             also_check_dir, srclib_dir, extlib_dir,
1120                             tmp_dir, repo_dir, vcs, options.test,
1121                             options.server, options.force,
1122                             options.onserver, options.refresh):
1123
1124                     if app.Binaries is not None:
1125                         # This is an app where we build from source, and
1126                         # verify the apk contents against a developer's
1127                         # binary. We get that binary now, and save it
1128                         # alongside our built one in the 'unsigend'
1129                         # directory.
1130                         url = app.Binaries
1131                         url = url.replace('%v', build.version)
1132                         url = url.replace('%c', str(build.vercode))
1133                         logging.info("...retrieving " + url)
1134                         of = "{0}_{1}.apk.binary".format(app.id, build.vercode)
1135                         of = os.path.join(output_dir, of)
1136                         net.download_file(url, local_filename=of)
1137
1138                     build_succeeded.append(app)
1139                     wikilog = "Build succeeded"
1140             except VCSException as vcse:
1141                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1142                 logging.error("VCS error while building app %s: %s" % (
1143                     appid, reason))
1144                 if options.stop:
1145                     sys.exit(1)
1146                 failed_apps[appid] = vcse
1147                 wikilog = str(vcse)
1148             except FDroidException as e:
1149                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1150                     f.write(str(e))
1151                 logging.error("Could not build app %s: %s" % (appid, e))
1152                 if options.stop:
1153                     sys.exit(1)
1154                 failed_apps[appid] = e
1155                 wikilog = e.get_wikitext()
1156             except Exception as e:
1157                 logging.error("Could not build app %s due to unknown error: %s" % (
1158                     appid, traceback.format_exc()))
1159                 if options.stop:
1160                     sys.exit(1)
1161                 failed_apps[appid] = e
1162                 wikilog = str(e)
1163
1164             if options.wiki and wikilog:
1165                 try:
1166                     # Write a page with the last build log for this version code
1167                     lastbuildpage = appid + '/lastbuild_' + build.vercode
1168                     newpage = site.Pages[lastbuildpage]
1169                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1170                     newpage.save(txt, summary='Build log')
1171                     # Redirect from /lastbuild to the most recent build log
1172                     newpage = site.Pages[appid + '/lastbuild']
1173                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1174                 except:
1175                     logging.error("Error while attempting to publish build log")
1176
1177     for app in build_succeeded:
1178         logging.info("success: %s" % (app.id))
1179
1180     if not options.verbose:
1181         for fa in failed_apps:
1182             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1183
1184     logging.info("Finished.")
1185     if len(build_succeeded) > 0:
1186         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1187     if len(failed_apps) > 0:
1188         logging.info(str(len(failed_apps)) + ' builds failed')
1189
1190     sys.exit(0)
1191
1192 if __name__ == "__main__":
1193     main()