chiark / gitweb /
Merge branch 'build-FPE-update-zip' into 'master'
[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.get_release_filename(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 get_metadata_from_apk(app, build, apkfile):
458     """get the required metadata from the built APK"""
459
460     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
461
462     vercode = None
463     version = None
464     foundid = None
465     nativecode = None
466     for line in p.output.splitlines():
467         if line.startswith("package:"):
468             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
469             m = pat.match(line)
470             if m:
471                 foundid = m.group(1)
472             pat = re.compile(".*versionCode='([0-9]*)'.*")
473             m = pat.match(line)
474             if m:
475                 vercode = m.group(1)
476             pat = re.compile(".*versionName='([^']*)'.*")
477             m = pat.match(line)
478             if m:
479                 version = m.group(1)
480         elif line.startswith("native-code:"):
481             nativecode = line[12:]
482
483     # Ignore empty strings or any kind of space/newline chars that we don't
484     # care about
485     if nativecode is not None:
486         nativecode = nativecode.strip()
487         nativecode = None if not nativecode else nativecode
488
489     if build.buildjni and build.buildjni != ['no']:
490         if nativecode is None:
491             raise BuildException("Native code should have been built but none was packaged")
492     if build.novcheck:
493         vercode = build.vercode
494         version = build.version
495     if not version or not vercode:
496         raise BuildException("Could not find version information in build in output")
497     if not foundid:
498         raise BuildException("Could not find package ID in output")
499     if foundid != app.id:
500         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app.id)
501
502     return vercode, version
503
504
505 def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
506     """Do a build locally."""
507
508     ndk_path = build.ndk_path()
509     if build.ndk or (build.buildjni and build.buildjni != ['no']):
510         if not ndk_path:
511             logging.critical("Android NDK version '%s' could not be found!" % build.ndk or 'r12b')
512             logging.critical("Configured versions:")
513             for k, v in config['ndk_paths'].items():
514                 if k.endswith("_orig"):
515                     continue
516                 logging.critical("  %s: %s" % (k, v))
517             sys.exit(3)
518         elif not os.path.isdir(ndk_path):
519             logging.critical("Android NDK '%s' is not a directory!" % ndk_path)
520             sys.exit(3)
521
522     common.set_FDroidPopen_env(build)
523
524     # Prepare the source code...
525     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
526                                                   build_dir, srclib_dir,
527                                                   extlib_dir, onserver, refresh)
528
529     # We need to clean via the build tool in case the binary dirs are
530     # different from the default ones
531     p = None
532     gradletasks = []
533     bmethod = build.build_method()
534     if bmethod == 'maven':
535         logging.info("Cleaning Maven project...")
536         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
537
538         if '@' in build.maven:
539             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
540             maven_dir = os.path.normpath(maven_dir)
541         else:
542             maven_dir = root_dir
543
544         p = FDroidPopen(cmd, cwd=maven_dir)
545
546     elif bmethod == 'gradle':
547
548         logging.info("Cleaning Gradle project...")
549
550         if build.preassemble:
551             gradletasks += build.preassemble
552
553         flavours = build.gradle
554         if flavours == ['yes']:
555             flavours = []
556
557         flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
558
559         gradletasks += ['assemble' + flavours_cmd + 'Release']
560
561         if config['force_build_tools']:
562             force_gradle_build_tools(build_dir, config['build_tools'])
563             for name, number, libpath in srclibpaths:
564                 force_gradle_build_tools(libpath, config['build_tools'])
565
566         cmd = [config['gradle']]
567         if build.gradleprops:
568             cmd += ['-P' + kv for kv in build.gradleprops]
569
570         cmd += ['clean']
571
572         p = FDroidPopen(cmd, cwd=root_dir)
573
574     elif bmethod == 'kivy':
575         pass
576
577     elif bmethod == 'ant':
578         logging.info("Cleaning Ant project...")
579         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
580
581     if p is not None and p.returncode != 0:
582         raise BuildException("Error cleaning %s:%s" %
583                              (app.id, build.version), p.output)
584
585     for root, dirs, files in os.walk(build_dir):
586
587         def del_dirs(dl):
588             for d in dl:
589                 if d in dirs:
590                     shutil.rmtree(os.path.join(root, d))
591
592         def del_files(fl):
593             for f in fl:
594                 if f in files:
595                     os.remove(os.path.join(root, f))
596
597         if 'build.gradle' in files:
598             # Even when running clean, gradle stores task/artifact caches in
599             # .gradle/ as binary files. To avoid overcomplicating the scanner,
600             # manually delete them, just like `gradle clean` should have removed
601             # the build/ dirs.
602             del_dirs(['build', '.gradle'])
603             del_files(['gradlew', 'gradlew.bat'])
604
605         if 'pom.xml' in files:
606             del_dirs(['target'])
607
608         if any(f in files for f in ['ant.properties', 'project.properties', 'build.xml']):
609             del_dirs(['bin', 'gen'])
610
611         if 'jni' in dirs:
612             del_dirs(['obj'])
613
614     if options.skipscan:
615         if build.scandelete:
616             raise BuildException("Refusing to skip source scan since scandelete is present")
617     else:
618         # Scan before building...
619         logging.info("Scanning source for common problems...")
620         count = scanner.scan_source(build_dir, root_dir, build)
621         if count > 0:
622             if force:
623                 logging.warn('Scanner found %d problems' % count)
624             else:
625                 raise BuildException("Can't build due to %d errors while scanning" % count)
626
627     if not options.notarball:
628         # Build the source tarball right before we build the release...
629         logging.info("Creating source tarball...")
630         tarname = common.getsrcname(app, build)
631         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
632
633         def tarexc(f):
634             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
635         tarball.add(build_dir, tarname, exclude=tarexc)
636         tarball.close()
637
638     # Run a build command if one is required...
639     if build.build:
640         logging.info("Running 'build' commands in %s" % root_dir)
641         cmd = common.replace_config_vars(build.build, build)
642
643         # Substitute source library paths into commands...
644         for name, number, libpath in srclibpaths:
645             libpath = os.path.relpath(libpath, root_dir)
646             cmd = cmd.replace('$$' + name + '$$', libpath)
647
648         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
649
650         if p.returncode != 0:
651             raise BuildException("Error running build command for %s:%s" %
652                                  (app.id, build.version), p.output)
653
654     # Build native stuff if required...
655     if build.buildjni and build.buildjni != ['no']:
656         logging.info("Building the native code")
657         jni_components = build.buildjni
658
659         if jni_components == ['yes']:
660             jni_components = ['']
661         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
662         for d in jni_components:
663             if d:
664                 logging.info("Building native code in '%s'" % d)
665             else:
666                 logging.info("Building native code in the main project")
667             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
668             if os.path.exists(manifest):
669                 # Read and write the whole AM.xml to fix newlines and avoid
670                 # the ndk r8c or later 'wordlist' errors. The outcome of this
671                 # under gnu/linux is the same as when using tools like
672                 # dos2unix, but the native python way is faster and will
673                 # work in non-unix systems.
674                 manifest_text = open(manifest, 'U').read()
675                 open(manifest, 'w').write(manifest_text)
676                 # In case the AM.xml read was big, free the memory
677                 del manifest_text
678             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
679             if p.returncode != 0:
680                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.version), p.output)
681
682     p = None
683     # Build the release...
684     if bmethod == 'maven':
685         logging.info("Building Maven project...")
686
687         if '@' in build.maven:
688             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
689         else:
690             maven_dir = root_dir
691
692         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
693                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
694                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
695                   'package']
696         if build.target:
697             target = build.target.split('-')[1]
698             common.regsub_file(r'<platform>[0-9]*</platform>',
699                                r'<platform>%s</platform>' % target,
700                                os.path.join(root_dir, 'pom.xml'))
701             if '@' in build.maven:
702                 common.regsub_file(r'<platform>[0-9]*</platform>',
703                                    r'<platform>%s</platform>' % target,
704                                    os.path.join(maven_dir, 'pom.xml'))
705
706         p = FDroidPopen(mvncmd, cwd=maven_dir)
707
708         bindir = os.path.join(root_dir, 'target')
709
710     elif bmethod == 'kivy':
711         logging.info("Building Kivy project...")
712
713         spec = os.path.join(root_dir, 'buildozer.spec')
714         if not os.path.exists(spec):
715             raise BuildException("Expected to find buildozer-compatible spec at {0}"
716                                  .format(spec))
717
718         defaults = {'orientation': 'landscape', 'icon': '',
719                     'permissions': '', 'android.api': "18"}
720         bconfig = ConfigParser(defaults, allow_no_value=True)
721         bconfig.read(spec)
722
723         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
724         if os.path.exists(distdir):
725             shutil.rmtree(distdir)
726
727         modules = bconfig.get('app', 'requirements').split(',')
728
729         cmd = 'ANDROIDSDK=' + config['sdk_path']
730         cmd += ' ANDROIDNDK=' + ndk_path
731         cmd += ' ANDROIDNDKVER=' + build.ndk
732         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
733         cmd += ' VIRTUALENV=virtualenv'
734         cmd += ' ./distribute.sh'
735         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
736         cmd += ' -d fdroid'
737         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
738         if p.returncode != 0:
739             raise BuildException("Distribute build failed")
740
741         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
742         if cid != app.id:
743             raise BuildException("Package ID mismatch between metadata and spec")
744
745         orientation = bconfig.get('app', 'orientation', 'landscape')
746         if orientation == 'all':
747             orientation = 'sensor'
748
749         cmd = ['./build.py'
750                '--dir', root_dir,
751                '--name', bconfig.get('app', 'title'),
752                '--package', app.id,
753                '--version', bconfig.get('app', 'version'),
754                '--orientation', orientation
755                ]
756
757         perms = bconfig.get('app', 'permissions')
758         for perm in perms.split(','):
759             cmd.extend(['--permission', perm])
760
761         if config.get('app', 'fullscreen') == 0:
762             cmd.append('--window')
763
764         icon = bconfig.get('app', 'icon.filename')
765         if icon:
766             cmd.extend(['--icon', os.path.join(root_dir, icon)])
767
768         cmd.append('release')
769         p = FDroidPopen(cmd, cwd=distdir)
770
771     elif bmethod == 'gradle':
772         logging.info("Building Gradle project...")
773
774         cmd = [config['gradle']]
775         if build.gradleprops:
776             cmd += ['-P' + kv for kv in build.gradleprops]
777
778         cmd += gradletasks
779
780         p = FDroidPopen(cmd, cwd=root_dir)
781
782     elif bmethod == 'ant':
783         logging.info("Building Ant project...")
784         cmd = ['ant']
785         if build.antcommands:
786             cmd += build.antcommands
787         else:
788             cmd += ['release']
789         p = FDroidPopen(cmd, cwd=root_dir)
790
791         bindir = os.path.join(root_dir, 'bin')
792
793     if p is not None and p.returncode != 0:
794         raise BuildException("Build failed for %s:%s" % (app.id, build.version), p.output)
795     logging.info("Successfully built version " + build.version + ' of ' + app.id)
796
797     omethod = build.output_method()
798     if omethod == 'maven':
799         stdout_apk = '\n'.join([
800             line for line in p.output.splitlines() if any(
801                 a in line for a in ('.apk', '.ap_', '.jar'))])
802         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
803                      stdout_apk, re.S | re.M)
804         if not m:
805             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
806                          stdout_apk, re.S | re.M)
807         if not m:
808             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
809                          stdout_apk, re.S | re.M)
810
811         if not m:
812             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
813                          stdout_apk, re.S | re.M)
814         if not m:
815             raise BuildException('Failed to find output')
816         src = m.group(1)
817         src = os.path.join(bindir, src) + '.apk'
818     elif omethod == 'kivy':
819         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
820                            '{0}-{1}-release.apk'.format(
821                                bconfig.get('app', 'title'),
822                                bconfig.get('app', 'version')))
823     elif omethod == 'gradle':
824         src = None
825         for apks_dir in [
826                 os.path.join(root_dir, 'build', 'outputs', 'apk'),
827                 os.path.join(root_dir, 'build', 'apk'),
828                 ]:
829             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
830                 apks = glob.glob(os.path.join(apks_dir, apkglob))
831
832                 if len(apks) > 1:
833                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
834                                          '\n'.join(apks))
835                 if len(apks) == 1:
836                     src = apks[0]
837                     break
838             if src is not None:
839                 break
840
841         if src is None:
842             raise BuildException('Failed to find any output apks')
843
844     elif omethod == 'ant':
845         stdout_apk = '\n'.join([
846             line for line in p.output.splitlines() if '.apk' in line])
847         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
848                        re.S | re.M).group(1)
849         src = os.path.join(bindir, src)
850     elif omethod == 'raw':
851         globpath = os.path.join(root_dir, build.output)
852         apks = glob.glob(globpath)
853         if len(apks) > 1:
854             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
855         if len(apks) < 1:
856             raise BuildException('No apks match %s' % globpath)
857         src = os.path.normpath(apks[0])
858
859     # Make sure it's not debuggable...
860     if common.isApkAndDebuggable(src, config):
861         raise BuildException("APK is debuggable")
862
863     # By way of a sanity check, make sure the version and version
864     # code in our new apk match what we expect...
865     logging.debug("Checking " + src)
866     if not os.path.exists(src):
867         raise BuildException("Unsigned apk is not at expected location of " + src)
868
869     if common.get_file_extension(src) == 'apk':
870         vercode, version = get_metadata_from_apk(app, build, src)
871         if (version != build.version or vercode != build.vercode):
872             raise BuildException(("Unexpected version/version code in output;"
873                                   " APK: '%s' / '%s', "
874                                   " Expected: '%s' / '%s'")
875                                  % (version, str(vercode), build.version,
876                                     str(build.vercode)))
877     else:
878         vercode = build.vercode
879         version = build.version
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.get_release_filename(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_file = common.get_release_filename(app, build)
925
926     dest = os.path.join(output_dir, dest_file)
927     dest_repo = os.path.join(repo_dir, dest_file)
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_file)
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     metadata.add_metadata_arguments(parser)
986     options = parser.parse_args()
987     metadata.warnings_action = options.W
988
989     # Force --stop with --on-server to get correct exit code
990     if options.onserver:
991         options.stop = True
992
993     if options.force and not options.test:
994         parser.error("option %s: Force is only allowed in test mode" % "force")
995
996     return options, parser
997
998 options = None
999 config = None
1000
1001
1002 def main():
1003
1004     global options, config
1005
1006     options, parser = parse_commandline()
1007
1008     # The defaults for .fdroid.* metadata that is included in a git repo are
1009     # different than for the standard metadata/ layout because expectations
1010     # are different.  In this case, the most common user will be the app
1011     # developer working on the latest update of the app on their own machine.
1012     local_metadata_files = common.get_local_metadata_files()
1013     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
1014         config = dict(common.default_config)
1015         # `fdroid build` should build only the latest version by default since
1016         # most of the time the user will be building the most recent update
1017         if not options.all:
1018             options.latest = True
1019     elif len(local_metadata_files) > 1:
1020         raise FDroidException("Only one local metadata file allowed! Found: "
1021                               + " ".join(local_metadata_files))
1022     else:
1023         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1024             raise FDroidException("No app metadata found, nothing to process!")
1025         if not options.appid and not options.all:
1026             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1027
1028     config = common.read_config(options)
1029
1030     if config['build_server_always']:
1031         options.server = True
1032     if options.resetserver and not options.server:
1033         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1034
1035     log_dir = 'logs'
1036     if not os.path.isdir(log_dir):
1037         logging.info("Creating log directory")
1038         os.makedirs(log_dir)
1039
1040     tmp_dir = 'tmp'
1041     if not os.path.isdir(tmp_dir):
1042         logging.info("Creating temporary directory")
1043         os.makedirs(tmp_dir)
1044
1045     if options.test:
1046         output_dir = tmp_dir
1047     else:
1048         output_dir = 'unsigned'
1049         if not os.path.isdir(output_dir):
1050             logging.info("Creating output directory")
1051             os.makedirs(output_dir)
1052
1053     if config['archive_older'] != 0:
1054         also_check_dir = 'archive'
1055     else:
1056         also_check_dir = None
1057
1058     repo_dir = 'repo'
1059
1060     build_dir = 'build'
1061     if not os.path.isdir(build_dir):
1062         logging.info("Creating build directory")
1063         os.makedirs(build_dir)
1064     srclib_dir = os.path.join(build_dir, 'srclib')
1065     extlib_dir = os.path.join(build_dir, 'extlib')
1066
1067     # Read all app and srclib metadata
1068     allapps = metadata.read_metadata(xref=not options.onserver)
1069
1070     apps = common.read_app_args(options.appid, allapps, True)
1071     for appid, app in list(apps.items()):
1072         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1073             del apps[appid]
1074
1075     if not apps:
1076         raise FDroidException("No apps to process.")
1077
1078     if options.latest:
1079         for app in apps.values():
1080             for build in reversed(app.builds):
1081                 if build.disable and not options.force:
1082                     continue
1083                 app.builds = [build]
1084                 break
1085
1086     if options.wiki:
1087         import mwclient
1088         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1089                              path=config['wiki_path'])
1090         site.login(config['wiki_user'], config['wiki_password'])
1091
1092     # Build applications...
1093     failed_apps = {}
1094     build_succeeded = []
1095     for appid, app in apps.items():
1096
1097         first = True
1098
1099         for build in app.builds:
1100             wikilog = None
1101             try:
1102
1103                 # For the first build of a particular app, we need to set up
1104                 # the source repo. We can reuse it on subsequent builds, if
1105                 # there are any.
1106                 if first:
1107                     if app.RepoType == 'srclib':
1108                         build_dir = os.path.join('build', 'srclib', app.Repo)
1109                     else:
1110                         build_dir = os.path.join('build', appid)
1111
1112                     # Set up vcs interface and make sure we have the latest code...
1113                     logging.debug("Getting {0} vcs interface for {1}"
1114                                   .format(app.RepoType, app.Repo))
1115                     vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
1116
1117                     first = False
1118
1119                 logging.debug("Checking " + build.version)
1120                 if trybuild(app, build, build_dir, output_dir,
1121                             also_check_dir, srclib_dir, extlib_dir,
1122                             tmp_dir, repo_dir, vcs, options.test,
1123                             options.server, options.force,
1124                             options.onserver, options.refresh):
1125
1126                     if app.Binaries is not None:
1127                         # This is an app where we build from source, and
1128                         # verify the apk contents against a developer's
1129                         # binary. We get that binary now, and save it
1130                         # alongside our built one in the 'unsigend'
1131                         # directory.
1132                         url = app.Binaries
1133                         url = url.replace('%v', build.version)
1134                         url = url.replace('%c', str(build.vercode))
1135                         logging.info("...retrieving " + url)
1136                         of = "{0}_{1}.apk.binary".format(app.id, build.vercode)
1137                         of = os.path.join(output_dir, of)
1138                         net.download_file(url, local_filename=of)
1139
1140                     build_succeeded.append(app)
1141                     wikilog = "Build succeeded"
1142             except VCSException as vcse:
1143                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1144                 logging.error("VCS error while building app %s: %s" % (
1145                     appid, reason))
1146                 if options.stop:
1147                     sys.exit(1)
1148                 failed_apps[appid] = vcse
1149                 wikilog = str(vcse)
1150             except FDroidException as e:
1151                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1152                     f.write(str(e))
1153                 logging.error("Could not build app %s: %s" % (appid, e))
1154                 if options.stop:
1155                     sys.exit(1)
1156                 failed_apps[appid] = e
1157                 wikilog = e.get_wikitext()
1158             except Exception as e:
1159                 logging.error("Could not build app %s due to unknown error: %s" % (
1160                     appid, traceback.format_exc()))
1161                 if options.stop:
1162                     sys.exit(1)
1163                 failed_apps[appid] = e
1164                 wikilog = str(e)
1165
1166             if options.wiki and wikilog:
1167                 try:
1168                     # Write a page with the last build log for this version code
1169                     lastbuildpage = appid + '/lastbuild_' + build.vercode
1170                     newpage = site.Pages[lastbuildpage]
1171                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1172                     newpage.save(txt, summary='Build log')
1173                     # Redirect from /lastbuild to the most recent build log
1174                     newpage = site.Pages[appid + '/lastbuild']
1175                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1176                 except:
1177                     logging.error("Error while attempting to publish build log")
1178
1179     for app in build_succeeded:
1180         logging.info("success: %s" % (app.id))
1181
1182     if not options.verbose:
1183         for fa in failed_apps:
1184             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1185
1186     logging.info("Finished.")
1187     if len(build_succeeded) > 0:
1188         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1189     if len(failed_apps) > 0:
1190         logging.info(str(len(failed_apps)) + ' builds failed')
1191
1192     sys.exit(0)
1193
1194 if __name__ == "__main__":
1195     main()