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