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