chiark / gitweb /
Fix vagrant popen error
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # build.py - part of the FDroid server tools
5 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU Affero General Public License for more details.
17 #
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 import sys
22 import os
23 import shutil
24 import subprocess
25 import re
26 import tarfile
27 import traceback
28 import time
29 import json
30 from ConfigParser import ConfigParser
31 from optparse import OptionParser, OptionError
32 import logging
33
34 import common, metadata
35 from common import BuildException, VCSException, FDroidPopen
36
37 def get_builder_vm_id():
38     vd = os.path.join('builder', '.vagrant')
39     if os.path.isdir(vd):
40         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
41         with open(os.path.join(vd, 'machines', 'default', 'virtualbox', 'id')) as vf:
42             id = vf.read()
43         return id
44     else:
45         # Vagrant 1.0 - it's a json file...
46         with open(os.path.join('builder', '.vagrant')) as vf:
47             v = json.load(vf)
48         return v['active']['default']
49
50 def got_valid_builder_vm():
51     """Returns True if we have a valid-looking builder vm
52     """
53     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
54         return False
55     vd = os.path.join('builder', '.vagrant')
56     if not os.path.exists(vd):
57         return False
58     if not os.path.isdir(vd):
59         # Vagrant 1.0 - if the directory is there, it's valid...
60         return True
61     # Vagrant 1.2 - the directory can exist, but the id can be missing...
62     if not os.path.exists(os.path.join(vd, 'machines', 'default', 'virtualbox', 'id')):
63         return False
64     return True
65
66
67 def vagrant(params, cwd=None, printout=False):
68     """Run vagrant.
69
70     :param: list of parameters to pass to vagrant
71     :cwd: directory to run in, or None for current directory
72     :returns: (ret, out) where ret is the return code, and out
73                is the stdout (and stderr) from vagrant
74     """
75     p = FDroidPopen(['vagrant'] + params, cwd=cwd)
76     return (p.returncode, p.stdout)
77
78
79 # Note that 'force' here also implies test mode.
80 def build_server(app, thisbuild, vcs, build_dir, output_dir, force):
81     """Do a build on the build server."""
82
83     import ssh
84
85     # Reset existing builder machine to a clean state if possible.
86     vm_ok = False
87     if not options.resetserver:
88         logging.info("Checking for valid existing build server")
89
90         if got_valid_builder_vm():
91             logging.info("...VM is present")
92             p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'list', '--details'], cwd='builder')
93             if 'fdroidclean' in p.stdout:
94                 logging.info("...snapshot exists - resetting build server to clean state")
95                 retcode, output = vagrant(['status'], cwd='builder')
96
97                 if 'running' in output:
98                     logging.info("...suspending")
99                     vagrant(['suspend'], cwd='builder')
100                     logging.info("...waiting a sec...")
101                     time.sleep(10)
102                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'restore', 'fdroidclean'],
103                     cwd='builder')
104
105                 if p.returncode == 0:
106                     logging.info("...reset to snapshot - server is valid")
107                     retcode, output = vagrant(['up'], cwd='builder')
108                     if retcode != 0:
109                         raise BuildException("Failed to start build server")
110                     logging.info("...waiting a sec...")
111                     time.sleep(10)
112                     vm_ok = True
113                 else:
114                     logging.info("...failed to reset to snapshot")
115             else:
116                 logging.info("...snapshot doesn't exist - VBoxManage snapshot list:\n" + output)
117
118     # If we can't use the existing machine for any reason, make a
119     # new one from scratch.
120     if not vm_ok:
121         if os.path.exists('builder'):
122             logging.info("Removing broken/incomplete/unwanted build server")
123             vagrant(['destroy', '-f'], cwd='builder')
124             shutil.rmtree('builder')
125         os.mkdir('builder')
126
127         p = subprocess.Popen('vagrant --version', shell=True, stdout=subprocess.PIPE)
128         vver = p.communicate()[0]
129         if vver.startswith('Vagrant version 1.2'):
130             with open('builder/Vagrantfile', 'w') as vf:
131                 vf.write('Vagrant.configure("2") do |config|\n')
132                 vf.write('config.vm.box = "buildserver"\n')
133                 vf.write('end\n')
134         else:
135             with open('builder/Vagrantfile', 'w') as vf:
136                 vf.write('Vagrant::Config.run do |config|\n')
137                 vf.write('config.vm.box = "buildserver"\n')
138                 vf.write('end\n')
139
140         logging.info("Starting new build server")
141         retcode, _ = vagrant(['up'], cwd='builder')
142         if retcode != 0:
143             raise BuildException("Failed to start build server")
144
145         # Open SSH connection to make sure it's working and ready...
146         logging.info("Connecting to virtual machine...")
147         if subprocess.call('vagrant ssh-config >sshconfig',
148                 cwd='builder', shell=True) != 0:
149             raise BuildException("Error getting ssh config")
150         vagranthost = 'default' # Host in ssh config file
151         sshconfig = ssh.SSHConfig()
152         sshf = open('builder/sshconfig', 'r')
153         sshconfig.parse(sshf)
154         sshf.close()
155         sshconfig = sshconfig.lookup(vagranthost)
156         sshs = ssh.SSHClient()
157         sshs.set_missing_host_key_policy(ssh.AutoAddPolicy())
158         idfile = sshconfig['identityfile']
159         if idfile.startswith('"') and idfile.endswith('"'):
160             idfile = idfile[1:-1]
161         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
162             port=int(sshconfig['port']), timeout=300, look_for_keys=False,
163             key_filename=idfile)
164         sshs.close()
165
166         logging.info("Saving clean state of new build server")
167         retcode, _ = vagrant(['suspend'], cwd='builder')
168         if retcode != 0:
169             raise BuildException("Failed to suspend build server")
170         logging.info("...waiting a sec...")
171         time.sleep(10)
172         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'take', 'fdroidclean'],
173                 cwd='builder')
174         if p.returncode != 0:
175             raise BuildException("Failed to take snapshot")
176         logging.info("...waiting a sec...")
177         time.sleep(10)
178         logging.info("Restarting new build server")
179         retcode, _ = vagrant(['up'], cwd='builder')
180         if retcode != 0:
181             raise BuildException("Failed to start build server")
182         logging.info("...waiting a sec...")
183         time.sleep(10)
184         # Make sure it worked...
185         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'list', '--details'],
186             cwd='builder')
187         if 'fdroidclean' not in p.stdout:
188             raise BuildException("Failed to take snapshot.")
189
190     try:
191
192         # Get SSH configuration settings for us to connect...
193         logging.info("Getting ssh configuration...")
194         subprocess.call('vagrant ssh-config >sshconfig',
195                 cwd='builder', shell=True)
196         vagranthost = 'default' # Host in ssh config file
197
198         # Load and parse the SSH config...
199         sshconfig = ssh.SSHConfig()
200         sshf = open('builder/sshconfig', 'r')
201         sshconfig.parse(sshf)
202         sshf.close()
203         sshconfig = sshconfig.lookup(vagranthost)
204
205         # Open SSH connection...
206         logging.info("Connecting to virtual machine...")
207         sshs = ssh.SSHClient()
208         sshs.set_missing_host_key_policy(ssh.AutoAddPolicy())
209         idfile = sshconfig['identityfile']
210         if idfile.startswith('"') and idfile.endswith('"'):
211             idfile = idfile[1:-1]
212         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
213             port=int(sshconfig['port']), timeout=300, look_for_keys=False,
214             key_filename=idfile)
215
216         # Get an SFTP connection...
217         ftp = sshs.open_sftp()
218         ftp.get_channel().settimeout(15)
219
220         # Put all the necessary files in place...
221         ftp.chdir('/home/vagrant')
222
223         # Helper to copy the contents of a directory to the server...
224         def send_dir(path):
225             root = os.path.dirname(path)
226             main = os.path.basename(path)
227             ftp.mkdir(main)
228             for r, d, f in os.walk(path):
229                 rr = os.path.relpath(r, root)
230                 ftp.chdir(rr)
231                 for dd in d:
232                     ftp.mkdir(dd)
233                 for ff in f:
234                     lfile = os.path.join(root, rr, ff)
235                     if not os.path.islink(lfile):
236                         ftp.put(lfile, ff)
237                         ftp.chmod(ff, os.stat(lfile).st_mode)
238                 for i in range(len(rr.split('/'))):
239                     ftp.chdir('..')
240             ftp.chdir('..')
241
242         logging.info("Preparing server for build...")
243         serverpath = os.path.abspath(os.path.dirname(__file__))
244         ftp.put(os.path.join(serverpath, 'build.py'), 'build.py')
245         ftp.put(os.path.join(serverpath, 'common.py'), 'common.py')
246         ftp.put(os.path.join(serverpath, 'metadata.py'), 'metadata.py')
247         ftp.put(os.path.join(serverpath, '..', 'config.buildserver.py'), 'config.py')
248         ftp.chmod('config.py', 0o600)
249
250         # Copy the metadata - just the file for this app...
251         ftp.mkdir('metadata')
252         ftp.mkdir('srclibs')
253         ftp.chdir('metadata')
254         ftp.put(os.path.join('metadata', app['id'] + '.txt'),
255                 app['id'] + '.txt')
256         # And patches if there are any...
257         if os.path.exists(os.path.join('metadata', app['id'])):
258             send_dir(os.path.join('metadata', app['id']))
259
260         ftp.chdir('/home/vagrant')
261         # Create the build directory...
262         ftp.mkdir('build')
263         ftp.chdir('build')
264         ftp.mkdir('extlib')
265         ftp.mkdir('srclib')
266         # Copy any extlibs that are required...
267         if 'extlibs' in thisbuild:
268             ftp.chdir('/home/vagrant/build/extlib')
269             for lib in thisbuild['extlibs'].split(';'):
270                 lib = lib.strip()
271                 libsrc = os.path.join('build/extlib', lib)
272                 if not os.path.exists(libsrc):
273                     raise BuildException("Missing extlib {0}".format(libsrc))
274                 lp = lib.split('/')
275                 for d in lp[:-1]:
276                     if d not in ftp.listdir():
277                         ftp.mkdir(d)
278                     ftp.chdir(d)
279                 ftp.put(libsrc, lp[-1])
280                 for _ in lp[:-1]:
281                     ftp.chdir('..')
282         # Copy any srclibs that are required...
283         srclibpaths = []
284         if 'srclibs' in thisbuild:
285             for lib in thisbuild['srclibs'].split(';'):
286                 srclibpaths.append(common.getsrclib(lib, 'build/srclib', srclibpaths,
287                     basepath=True, prepare=False))
288
289         # If one was used for the main source, add that too.
290         basesrclib = vcs.getsrclib()
291         if basesrclib:
292             srclibpaths.append(basesrclib)
293         for name, number, lib in srclibpaths:
294             logging.info("Sending srclib '%s'" % lib)
295             ftp.chdir('/home/vagrant/build/srclib')
296             if not os.path.exists(lib):
297                 raise BuildException("Missing srclib directory '" + lib + "'")
298             fv = '.fdroidvcs-' + name
299             ftp.put(os.path.join('build/srclib', fv), fv)
300             send_dir(lib)
301             # Copy the metadata file too...
302             ftp.chdir('/home/vagrant/srclibs')
303             ftp.put(os.path.join('srclibs', name + '.txt'),
304                     name + '.txt')
305         # Copy the main app source code
306         # (no need if it's a srclib)
307         if (not basesrclib) and os.path.exists(build_dir):
308             ftp.chdir('/home/vagrant/build')
309             fv = '.fdroidvcs-' + app['id']
310             ftp.put(os.path.join('build', fv), fv)
311             send_dir(build_dir)
312
313         # Execute the build script...
314         logging.info("Starting build...")
315         chan = sshs.get_transport().open_session()
316         chan.get_pty()
317         cmdline = 'python build.py --on-server'
318         if force:
319             cmdline += ' --force --test'
320         if options.verbose:
321             cmdline += ' --verbose'
322         cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
323         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
324         output = ''
325         while not chan.exit_status_ready():
326             while chan.recv_ready():
327                 output += chan.recv(1024)
328             time.sleep(0.1)
329         logging.info("...getting exit status")
330         returncode = chan.recv_exit_status()
331         while True:
332             get = chan.recv(1024)
333             if len(get) == 0:
334                 break
335             output += get
336         if returncode != 0:
337             raise BuildException("Build.py failed on server for %s:%s" % (app['id'], thisbuild['version']), output)
338
339         # Retrieve the built files...
340         logging.info("Retrieving build output...")
341         if force:
342             ftp.chdir('/home/vagrant/tmp')
343         else:
344             ftp.chdir('/home/vagrant/unsigned')
345         apkfile = common.getapkname(app,thisbuild)
346         tarball = common.getsrcname(app,thisbuild)
347         try:
348             ftp.get(apkfile, os.path.join(output_dir, apkfile))
349             if not options.notarball:
350                 ftp.get(tarball, os.path.join(output_dir, tarball))
351         except:
352             raise BuildException("Build failed for %s:%s - missing output files" % (app['id'], thisbuild['version']), output)
353         ftp.close()
354
355     finally:
356
357         # Suspend the build server.
358         logging.info("Suspending build server")
359         subprocess.call(['vagrant', 'suspend'], cwd='builder')
360
361 def adapt_gradle(build_dir):
362     for root, dirs, files in os.walk(build_dir):
363         if 'build.gradle' in files:
364             path = os.path.join(root, 'build.gradle')
365             logging.info("Adapting build.gradle at %s" % path)
366
367             subprocess.call(['sed', '-i',
368                     r's@buildToolsVersion\([ =]*\)["\'][0-9\.]*["\']@buildToolsVersion\1"'
369                     + config['build_tools'] + '"@g', path])
370
371
372 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver):
373     """Do a build locally."""
374
375     # Prepare the source code...
376     root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
377             build_dir, srclib_dir, extlib_dir, onserver)
378
379     # We need to clean via the build tool in case the binary dirs are
380     # different from the default ones
381     p = None
382     if thisbuild['type'] == 'maven':
383         logging.info("Cleaning Maven project...")
384         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
385
386         if '@' in thisbuild['maven']:
387             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
388             maven_dir = os.path.normpath(maven_dir)
389         else:
390             maven_dir = root_dir
391
392         p = FDroidPopen(cmd, cwd=maven_dir)
393
394     elif thisbuild['type'] == 'gradle':
395
396         logging.info("Cleaning Gradle project...")
397         cmd = [config['gradle'], 'clean']
398
399         if '@' in thisbuild['gradle']:
400             gradle_dir = os.path.join(root_dir, thisbuild['gradle'].split('@',1)[1])
401             gradle_dir = os.path.normpath(gradle_dir)
402         else:
403             gradle_dir = root_dir
404
405         adapt_gradle(build_dir)
406         for name, number, libpath in srclibpaths:
407             adapt_gradle(libpath)
408
409         p = FDroidPopen(cmd, cwd=gradle_dir)
410
411     elif thisbuild['type'] == 'kivy':
412         pass
413
414     elif thisbuild['type'] == 'ant':
415         logging.info("Cleaning Ant project...")
416         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
417
418     if p is not None and p.returncode != 0:
419         raise BuildException("Error cleaning %s:%s" %
420                 (app['id'], thisbuild['version']), p.stdout)
421
422     # Scan before building...
423     logging.info("Scanning source for common problems...")
424     buildprobs = common.scan_source(build_dir, root_dir, thisbuild)
425     if len(buildprobs) > 0:
426         logging.info('Scanner found %d problems:' % len(buildprobs))
427         for problem in buildprobs:
428             logging.info('    %s' % problem)
429         if not force:
430             raise BuildException("Can't build due to " +
431                 str(len(buildprobs)) + " scanned problems")
432
433     if not options.notarball:
434         # Build the source tarball right before we build the release...
435         logging.info("Creating source tarball...")
436         tarname = common.getsrcname(app,thisbuild)
437         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
438         def tarexc(f):
439             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
440         tarball.add(build_dir, tarname, exclude=tarexc)
441         tarball.close()
442
443     # Run a build command if one is required...
444     if 'build' in thisbuild:
445         cmd = common.replace_config_vars(thisbuild['build'])
446         # Substitute source library paths into commands...
447         for name, number, libpath in srclibpaths:
448             libpath = os.path.relpath(libpath, root_dir)
449             cmd = cmd.replace('$$' + name + '$$', libpath)
450         logging.info("Running 'build' commands in %s" % root_dir)
451
452         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
453
454         if p.returncode != 0:
455             raise BuildException("Error running build command for %s:%s" %
456                     (app['id'], thisbuild['version']), p.stdout)
457
458     # Build native stuff if required...
459     if thisbuild.get('buildjni') not in (None, 'no'):
460         logging.info("Building native libraries...")
461         jni_components = thisbuild.get('buildjni')
462         if jni_components == 'yes':
463             jni_components = ['']
464         else:
465             jni_components = [c.strip() for c in jni_components.split(';')]
466         ndkbuild = os.path.join(config['ndk_path'], "ndk-build")
467         for d in jni_components:
468             logging.info("Building native code in '%s'" % d)
469             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
470             if os.path.exists(manifest):
471                 # Read and write the whole AM.xml to fix newlines and avoid
472                 # the ndk r8c or later 'wordlist' errors. The outcome of this
473                 # under gnu/linux is the same as when using tools like
474                 # dos2unix, but the native python way is faster and will
475                 # work in non-unix systems.
476                 manifest_text = open(manifest, 'U').read()
477                 open(manifest, 'w').write(manifest_text)
478                 # In case the AM.xml read was big, free the memory
479                 del manifest_text
480             p = FDroidPopen([ndkbuild], cwd=os.path.join(root_dir,d))
481             if p.returncode != 0:
482                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
483
484     p = None
485     # Build the release...
486     if thisbuild['type'] == 'maven':
487         logging.info("Building Maven project...")
488
489         if '@' in thisbuild['maven']:
490             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
491         else:
492             maven_dir = root_dir
493
494         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
495                 '-Dandroid.sign.debug=false', '-Dmaven.test.skip=true',
496                 '-Dandroid.release=true', 'package']
497         if 'target' in thisbuild:
498             target = thisbuild["target"].split('-')[1]
499             subprocess.call(['sed', '-i',
500                     's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
501                     'pom.xml'], cwd=root_dir)
502             if '@' in thisbuild['maven']:
503                 subprocess.call(['sed', '-i',
504                         's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
505                         'pom.xml'], cwd=maven_dir)
506
507         if 'mvnflags' in thisbuild:
508             mvncmd += thisbuild['mvnflags']
509
510         p = FDroidPopen(mvncmd, cwd=maven_dir)
511
512         bindir = os.path.join(root_dir, 'target')
513
514     elif thisbuild['type'] == 'kivy':
515         logging.info("Building Kivy project...")
516
517         spec = os.path.join(root_dir, 'buildozer.spec')
518         if not os.path.exists(spec):
519             raise BuildException("Expected to find buildozer-compatible spec at {0}"
520                     .format(spec))
521
522         defaults = {'orientation': 'landscape', 'icon': '',
523                 'permissions': '', 'android.api': "18"}
524         bconfig = ConfigParser(defaults, allow_no_value=True)
525         bconfig.read(spec)
526
527         distdir = 'python-for-android/dist/fdroid'
528         if os.path.exists(distdir):
529             shutil.rmtree(distdir)
530
531         modules = bconfig.get('app', 'requirements').split(',')
532
533         cmd = 'ANDROIDSDK=' + config['sdk_path']
534         cmd += ' ANDROIDNDK=' + config['ndk_path']
535         cmd += ' ANDROIDNDKVER=r9'
536         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
537         cmd += ' VIRTUALENV=virtualenv'
538         cmd += ' ./distribute.sh'
539         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
540         cmd += ' -d fdroid'
541         if subprocess.call(cmd, cwd='python-for-android', shell=True) != 0:
542             raise BuildException("Distribute build failed")
543
544         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
545         if cid != app['id']:
546             raise BuildException("Package ID mismatch between metadata and spec")
547
548         orientation = bconfig.get('app', 'orientation', 'landscape')
549         if orientation == 'all':
550             orientation = 'sensor'
551
552         cmd = ['./build.py'
553                 '--dir', root_dir,
554                 '--name', bconfig.get('app', 'title'),
555                 '--package', app['id'],
556                 '--version', bconfig.get('app', 'version'),
557                 '--orientation', orientation,
558                 ]
559
560         perms = bconfig.get('app', 'permissions')
561         for perm in perms.split(','):
562             cmd.extend(['--permission', perm])
563
564         if config.get('app', 'fullscreen') == 0:
565             cmd.append('--window')
566
567         icon = bconfig.get('app', 'icon.filename')
568         if icon:
569             cmd.extend(['--icon', os.path.join(root_dir, icon)])
570
571         cmd.append('release')
572         p = FDroidPopen(cmd, cwd=distdir)
573
574     elif thisbuild['type'] == 'gradle':
575         logging.info("Building Gradle project...")
576         if '@' in thisbuild['gradle']:
577             flavours = thisbuild['gradle'].split('@')[0].split(',')
578             gradle_dir = thisbuild['gradle'].split('@')[1]
579             gradle_dir = os.path.join(root_dir, gradle_dir)
580         else:
581             flavours = thisbuild['gradle'].split(',')
582             gradle_dir = root_dir
583
584         if len(flavours) == 1 and flavours[0] in ['main', 'yes', '']:
585             flavours[0] = ''
586
587         commands = [config['gradle']]
588         if 'preassemble' in thisbuild:
589             commands += thisbuild['preassemble'].split()
590         commands += ['assemble'+''.join(flavours)+'Release']
591
592         p = FDroidPopen(commands, cwd=gradle_dir)
593
594     elif thisbuild['type'] == 'ant':
595         logging.info("Building Ant project...")
596         cmd = ['ant']
597         if 'antcommand' in thisbuild:
598             cmd += [thisbuild['antcommand']]
599         else:
600             cmd += ['release']
601         p = FDroidPopen(cmd, cwd=root_dir)
602
603         bindir = os.path.join(root_dir, 'bin')
604
605     if p is not None and p.returncode != 0:
606         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
607     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
608
609     if thisbuild['type'] == 'maven':
610         stdout_apk = '\n'.join([
611             line for line in p.stdout.splitlines() if any(a in line for a in ('.apk','.ap_'))])
612         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
613                 stdout_apk, re.S|re.M)
614         if not m:
615             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
616                     stdout_apk, re.S|re.M)
617         if not m:
618             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
619                     stdout_apk, re.S|re.M)
620         if not m:
621             raise BuildException('Failed to find output')
622         src = m.group(1)
623         src = os.path.join(bindir, src) + '.apk'
624     elif thisbuild['type'] == 'kivy':
625         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
626                 bconfig.get('app', 'title'), bconfig.get('app', 'version'))
627     elif thisbuild['type'] == 'gradle':
628         dd = build_dir
629         if 'subdir' in thisbuild:
630             dd = os.path.join(dd, thisbuild['subdir'])
631         if len(flavours) == 1 and flavours[0] == '':
632             name = '-'.join([os.path.basename(dd), 'release', 'unsigned'])
633         else:
634             name = '-'.join([os.path.basename(dd), '-'.join(flavours), 'release', 'unsigned'])
635         src = os.path.join(dd, 'build', 'apk', name+'.apk')
636     elif thisbuild['type'] == 'ant':
637         stdout_apk = '\n'.join([
638             line for line in p.stdout.splitlines() if '.apk' in line])
639         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
640             re.S|re.M).group(1)
641         src = os.path.join(bindir, src)
642     elif thisbuild['type'] == 'raw':
643         src = os.path.join(root_dir, thisbuild['output'])
644         src = os.path.normpath(src)
645
646     # Make sure it's not debuggable...
647     if common.isApkDebuggable(src, config):
648         raise BuildException("APK is debuggable")
649
650     # By way of a sanity check, make sure the version and version
651     # code in our new apk match what we expect...
652     logging.info("Checking " + src)
653     if not os.path.exists(src):
654         raise BuildException("Unsigned apk is not at expected location of " + src)
655
656     p = FDroidPopen([os.path.join(config['sdk_path'],
657                         'build-tools', config['build_tools'], 'aapt'),
658                         'dump', 'badging', src], output=False)
659
660     vercode = None
661     version = None
662     foundid = None
663     for line in p.stdout.splitlines():
664         if line.startswith("package:"):
665             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
666             m = pat.match(line)
667             if m:
668                 foundid = m.group(1)
669             pat = re.compile(".*versionCode='([0-9]*)'.*")
670             m = pat.match(line)
671             if m:
672                 vercode = m.group(1)
673             pat = re.compile(".*versionName='([^']*)'.*")
674             m = pat.match(line)
675             if m:
676                 version = m.group(1)
677
678     if thisbuild['novcheck']:
679         vercode = thisbuild['vercode']
680         version = thisbuild['version']
681     if not version or not vercode:
682         raise BuildException("Could not find version information in build in output")
683     if not foundid:
684         raise BuildException("Could not find package ID in output")
685     if foundid != app['id']:
686         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
687
688     # Some apps (e.g. Timeriffic) have had the bonkers idea of
689     # including the entire changelog in the version number. Remove
690     # it so we can compare. (TODO: might be better to remove it
691     # before we compile, in fact)
692     index = version.find(" //")
693     if index != -1:
694         version = version[:index]
695
696     if (version != thisbuild['version'] or
697             vercode != thisbuild['vercode']):
698         raise BuildException(("Unexpected version/version code in output;"
699                              " APK: '%s' / '%s', "
700                              " Expected: '%s' / '%s'")
701                              % (version, str(vercode), thisbuild['version'], str(thisbuild['vercode']))
702                             )
703
704     # Copy the unsigned apk to our destination directory for further
705     # processing (by publish.py)...
706     dest = os.path.join(output_dir, common.getapkname(app,thisbuild))
707     shutil.copyfile(src, dest)
708
709     # Move the source tarball into the output directory...
710     if output_dir != tmp_dir and not options.notarball:
711         shutil.move(os.path.join(tmp_dir, tarname),
712             os.path.join(output_dir, tarname))
713
714
715 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
716         tmp_dir, repo_dir, vcs, test, server, force, onserver):
717     """
718     Build a particular version of an application, if it needs building.
719
720     :param output_dir: The directory where the build output will go. Usually
721        this is the 'unsigned' directory.
722     :param repo_dir: The repo directory - used for checking if the build is
723        necessary.
724     :paaram also_check_dir: An additional location for checking if the build
725        is necessary (usually the archive repo)
726     :param test: True if building in test mode, in which case the build will
727        always happen, even if the output already exists. In test mode, the
728        output directory should be a temporary location, not any of the real
729        ones.
730
731     :returns: True if the build was done, False if it wasn't necessary.
732     """
733
734     dest_apk = common.getapkname(app, thisbuild)
735
736     dest = os.path.join(output_dir, dest_apk)
737     dest_repo = os.path.join(repo_dir, dest_apk)
738
739     if not test:
740         if os.path.exists(dest) or os.path.exists(dest_repo):
741             return False
742
743         if also_check_dir:
744             dest_also = os.path.join(also_check_dir, dest_apk)
745             if os.path.exists(dest_also):
746                 return False
747
748     if 'disable' in thisbuild:
749         return False
750
751     logging.info("Building version " + thisbuild['version'] + ' of ' + app['id'])
752
753     if server:
754         # When using server mode, still keep a local cache of the repo, by
755         # grabbing the source now.
756         vcs.gotorevision(thisbuild['commit'])
757
758         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
759     else:
760         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
761     return True
762
763
764 def parse_commandline():
765     """Parse the command line. Returns options, args."""
766
767     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
768     parser.add_option("-v", "--verbose", action="store_true", default=False,
769                       help="Spew out even more information than normal")
770     parser.add_option("-l", "--latest", action="store_true", default=False,
771                       help="Build only the latest version of each package")
772     parser.add_option("-s", "--stop", action="store_true", default=False,
773                       help="Make the build stop on exceptions")
774     parser.add_option("-t", "--test", action="store_true", default=False,
775                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
776     parser.add_option("--server", action="store_true", default=False,
777                       help="Use build server")
778     parser.add_option("--resetserver", action="store_true", default=False,
779                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
780     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
781                       help="Specify that we're running on the build server")
782     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
783                       help="Don't create a source tarball, useful when testing a build")
784     parser.add_option("-f", "--force", action="store_true", default=False,
785                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
786     parser.add_option("-a", "--all", action="store_true", default=False,
787                       help="Build all applications available")
788     parser.add_option("-w", "--wiki", default=False, action="store_true",
789                       help="Update the wiki")
790     options, args = parser.parse_args()
791
792     # Force --stop with --on-server to get cotrect exit code
793     if options.onserver:
794         options.stop = True
795
796     if options.force and not options.test:
797         raise OptionError("Force is only allowed in test mode", "force")
798
799     return options, args
800
801 options = None
802 config = None
803
804 def main():
805
806     global options, config
807
808     options, args = parse_commandline()
809     if not args and not options.all:
810         raise OptionError("If you really want to build all the apps, use --all", "all")
811
812     config = common.read_config(options)
813
814     if config['build_server_always']:
815         options.server = True
816     if options.resetserver and not options.server:
817         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
818
819     log_dir = 'logs'
820     if not os.path.isdir(log_dir):
821         logging.info("Creating log directory")
822         os.makedirs(log_dir)
823
824     tmp_dir = 'tmp'
825     if not os.path.isdir(tmp_dir):
826         logging.info("Creating temporary directory")
827         os.makedirs(tmp_dir)
828
829     if options.test:
830         output_dir = tmp_dir
831     else:
832         output_dir = 'unsigned'
833         if not os.path.isdir(output_dir):
834             logging.info("Creating output directory")
835             os.makedirs(output_dir)
836
837     if config['archive_older'] != 0:
838         also_check_dir = 'archive'
839     else:
840         also_check_dir = None
841
842     repo_dir = 'repo'
843
844     build_dir = 'build'
845     if not os.path.isdir(build_dir):
846         logging.info("Creating build directory")
847         os.makedirs(build_dir)
848     srclib_dir = os.path.join(build_dir, 'srclib')
849     extlib_dir = os.path.join(build_dir, 'extlib')
850
851     # Get all apps...
852     allapps = metadata.read_metadata(xref=not options.onserver)
853
854     apps = common.read_app_args(args, allapps, True)
855     apps = [app for app in apps if (options.force or not app['Disabled']) and
856             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
857
858     if len(apps) == 0:
859         raise Exception("No apps to process.")
860
861     if options.latest:
862         for app in apps:
863             for build in reversed(app['builds']):
864                 if 'disable' in build:
865                     continue
866                 app['builds'] = [ build ]
867                 break
868
869     if options.wiki:
870         import mwclient
871         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
872                 path=config['wiki_path'])
873         site.login(config['wiki_user'], config['wiki_password'])
874
875     # Build applications...
876     failed_apps = {}
877     build_succeeded = []
878     for app in apps:
879
880         first = True
881
882         for thisbuild in app['builds']:
883             wikilog = None
884             try:
885
886                 # For the first build of a particular app, we need to set up
887                 # the source repo. We can reuse it on subsequent builds, if
888                 # there are any.
889                 if first:
890                     if app['Repo Type'] == 'srclib':
891                         build_dir = os.path.join('build', 'srclib', app['Repo'])
892                     else:
893                         build_dir = os.path.join('build', app['id'])
894
895                     # Set up vcs interface and make sure we have the latest code...
896                     logging.info("Getting {0} vcs interface for {1}".format(
897                             app['Repo Type'], app['Repo']))
898                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
899
900                     first = False
901
902                 logging.info("Checking " + thisbuild['version'])
903                 if trybuild(app, thisbuild, build_dir, output_dir, also_check_dir,
904                         srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, options.test,
905                         options.server, options.force, options.onserver):
906                     build_succeeded.append(app)
907                     wikilog = "Build succeeded"
908             except BuildException as be:
909                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
910                 logfile.write(str(be))
911                 logfile.close()
912                 logging.info("Could not build app %s due to BuildException: %s" % (app['id'], be))
913                 if options.stop:
914                     sys.exit(1)
915                 failed_apps[app['id']] = be
916                 wikilog = be.get_wikitext()
917             except VCSException as vcse:
918                 logging.info("VCS error while building app %s: %s" % (app['id'], vcse))
919                 if options.stop:
920                     sys.exit(1)
921                 failed_apps[app['id']] = vcse
922                 wikilog = str(vcse)
923             except Exception as e:
924                 logging.info("Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc()))
925                 if options.stop:
926                     sys.exit(1)
927                 failed_apps[app['id']] = e
928                 wikilog = str(e)
929
930             if options.wiki and wikilog:
931                 try:
932                     newpage = site.Pages[app['id'] + '/lastbuild']
933                     txt = wikilog
934                     if len(txt) > 8192:
935                         txt = txt[-8192:]
936                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + txt
937                     newpage.save(txt, summary='Build log')
938                 except:
939                     logging.info("Error while attempting to publish build log")
940
941     for app in build_succeeded:
942         logging.info("success: %s" % (app['id']))
943
944     if not options.verbose:
945         for fa in failed_apps:
946             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
947
948     logging.info("Finished.")
949     if len(build_succeeded) > 0:
950         logging.info(str(len(build_succeeded)) + ' builds succeeded')
951     if len(failed_apps) > 0:
952         logging.info(str(len(failed_apps)) + ' builds failed')
953
954     sys.exit(0)
955
956 if __name__ == "__main__":
957     main()
958