chiark / gitweb /
Merge branch 'buildozer-build' into 'master'
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python3
2 #
3 # build.py - part of the FDroid server tools
4 # Copyright (C) 2010-2014, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import sys
21 import os
22 import shutil
23 import glob
24 import subprocess
25 import re
26 import tarfile
27 import traceback
28 import time
29 import requests
30 import tempfile
31 from configparser import ConfigParser
32 from argparse import ArgumentParser
33 import logging
34
35 from . import common
36 from . import net
37 from . import metadata
38 from . import scanner
39 from . import vmtools
40 from .common import FDroidPopen, SdkToolsPopen
41 from .exception import FDroidException, BuildException, VCSException
42
43 try:
44     import paramiko
45 except ImportError:
46     pass
47
48
49 # Note that 'force' here also implies test mode.
50 def build_server(app, build, vcs, build_dir, output_dir, log_dir, force):
51     """Do a build on the builder vm.
52
53     :param app: app metadata dict
54     :param build:
55     :param vcs: version control system controller object
56     :param build_dir: local source-code checkout of app
57     :param output_dir: target folder for the build result
58     :param force:
59     """
60
61     global buildserverid
62
63     try:
64         paramiko
65     except NameError:
66         raise BuildException("Paramiko is required to use the buildserver")
67     if options.verbose:
68         logging.getLogger("paramiko").setLevel(logging.INFO)
69     else:
70         logging.getLogger("paramiko").setLevel(logging.WARN)
71
72     sshinfo = vmtools.get_clean_builder('builder')
73
74     try:
75         if not buildserverid:
76             buildserverid = subprocess.check_output(['vagrant', 'ssh', '-c',
77                                                      'cat /home/vagrant/buildserverid'],
78                                                     cwd='builder').rstrip()
79
80         # Open SSH connection...
81         logging.info("Connecting to virtual machine...")
82         sshs = paramiko.SSHClient()
83         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
84         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
85                      port=sshinfo['port'], timeout=300,
86                      look_for_keys=False, key_filename=sshinfo['idfile'])
87
88         homedir = '/home/' + sshinfo['user']
89
90         # Get an SFTP connection...
91         ftp = sshs.open_sftp()
92         ftp.get_channel().settimeout(60)
93
94         # Put all the necessary files in place...
95         ftp.chdir(homedir)
96
97         # Helper to copy the contents of a directory to the server...
98         def send_dir(path):
99             root = os.path.dirname(path)
100             main = os.path.basename(path)
101             ftp.mkdir(main)
102             for r, d, f in os.walk(path):
103                 rr = os.path.relpath(r, root)
104                 ftp.chdir(rr)
105                 for dd in d:
106                     ftp.mkdir(dd)
107                 for ff in f:
108                     lfile = os.path.join(root, rr, ff)
109                     if not os.path.islink(lfile):
110                         ftp.put(lfile, ff)
111                         ftp.chmod(ff, os.stat(lfile).st_mode)
112                 for i in range(len(rr.split('/'))):
113                     ftp.chdir('..')
114             ftp.chdir('..')
115
116         logging.info("Preparing server for build...")
117         serverpath = os.path.abspath(os.path.dirname(__file__))
118         ftp.mkdir('fdroidserver')
119         ftp.chdir('fdroidserver')
120         ftp.put(os.path.join(serverpath, '..', 'fdroid'), 'fdroid')
121         ftp.chmod('fdroid', 0o755)
122         send_dir(os.path.join(serverpath))
123         ftp.chdir(homedir)
124
125         ftp.put(os.path.join(serverpath, '..', 'buildserver',
126                              'config.buildserver.py'), 'config.py')
127         ftp.chmod('config.py', 0o600)
128
129         # Copy over the ID (head commit hash) of the fdroidserver in use...
130         subprocess.call('git rev-parse HEAD >' +
131                         os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
132                         shell=True, cwd=serverpath)
133         ftp.put('tmp/fdroidserverid', 'fdroidserverid')
134
135         # Copy the metadata - just the file for this app...
136         ftp.mkdir('metadata')
137         ftp.mkdir('srclibs')
138         ftp.chdir('metadata')
139         ftp.put(os.path.join('metadata', app.id + '.txt'),
140                 app.id + '.txt')
141         # And patches if there are any...
142         if os.path.exists(os.path.join('metadata', app.id)):
143             send_dir(os.path.join('metadata', app.id))
144
145         ftp.chdir(homedir)
146         # Create the build directory...
147         ftp.mkdir('build')
148         ftp.chdir('build')
149         ftp.mkdir('extlib')
150         ftp.mkdir('srclib')
151         # Copy any extlibs that are required...
152         if build.extlibs:
153             ftp.chdir(homedir + '/build/extlib')
154             for lib in build.extlibs:
155                 lib = lib.strip()
156                 libsrc = os.path.join('build/extlib', lib)
157                 if not os.path.exists(libsrc):
158                     raise BuildException("Missing extlib {0}".format(libsrc))
159                 lp = lib.split('/')
160                 for d in lp[:-1]:
161                     if d not in ftp.listdir():
162                         ftp.mkdir(d)
163                     ftp.chdir(d)
164                 ftp.put(libsrc, lp[-1])
165                 for _ in lp[:-1]:
166                     ftp.chdir('..')
167         # Copy any srclibs that are required...
168         srclibpaths = []
169         if build.srclibs:
170             for lib in build.srclibs:
171                 srclibpaths.append(
172                     common.getsrclib(lib, 'build/srclib', basepath=True, prepare=False))
173
174         # If one was used for the main source, add that too.
175         basesrclib = vcs.getsrclib()
176         if basesrclib:
177             srclibpaths.append(basesrclib)
178         for name, number, lib in srclibpaths:
179             logging.info("Sending srclib '%s'" % lib)
180             ftp.chdir(homedir + '/build/srclib')
181             if not os.path.exists(lib):
182                 raise BuildException("Missing srclib directory '" + lib + "'")
183             fv = '.fdroidvcs-' + name
184             ftp.put(os.path.join('build/srclib', fv), fv)
185             send_dir(lib)
186             # Copy the metadata file too...
187             ftp.chdir(homedir + '/srclibs')
188             ftp.put(os.path.join('srclibs', name + '.txt'),
189                     name + '.txt')
190         # Copy the main app source code
191         # (no need if it's a srclib)
192         if (not basesrclib) and os.path.exists(build_dir):
193             ftp.chdir(homedir + '/build')
194             fv = '.fdroidvcs-' + app.id
195             ftp.put(os.path.join('build', fv), fv)
196             send_dir(build_dir)
197
198         # Execute the build script...
199         logging.info("Starting build...")
200         chan = sshs.get_transport().open_session()
201         chan.get_pty()
202         cmdline = os.path.join(homedir, 'fdroidserver', 'fdroid')
203         cmdline += ' build --on-server'
204         if force:
205             cmdline += ' --force --test'
206         if options.verbose:
207             cmdline += ' --verbose'
208         if options.skipscan:
209             cmdline += ' --skip-scan'
210         cmdline += " %s:%s" % (app.id, build.versionCode)
211         chan.exec_command('bash --login -c "' + cmdline + '"')
212
213         output = bytes()
214         output += get_android_tools_version_log(build.ndk_path()).encode()
215         while not chan.exit_status_ready():
216             while chan.recv_ready():
217                 output += chan.recv(1024)
218             time.sleep(0.1)
219         logging.info("...getting exit status")
220         returncode = chan.recv_exit_status()
221         while True:
222             get = chan.recv(1024)
223             if len(get) == 0:
224                 break
225             output += get
226         if returncode != 0:
227             raise BuildException(
228                 "Build.py failed on server for {0}:{1}".format(
229                     app.id, build.versionName), str(output, 'utf-8'))
230
231         # Retreive logs...
232         toolsversion_log = common.get_toolsversion_logname(app, build)
233         try:
234             ftp.chdir(os.path.join(homedir, log_dir))
235             ftp.get(toolsversion_log, os.path.join(log_dir, toolsversion_log))
236             logging.debug('retrieved %s', toolsversion_log)
237         except Exception as e:
238             logging.warn('could not get %s from builder vm: %s' % (toolsversion_log, e))
239
240         # Retrieve the built files...
241         logging.info("Retrieving build output...")
242         if force:
243             ftp.chdir(homedir + '/tmp')
244         else:
245             ftp.chdir(homedir + '/unsigned')
246         apkfile = common.get_release_filename(app, build)
247         tarball = common.getsrcname(app, build)
248         try:
249             ftp.get(apkfile, os.path.join(output_dir, apkfile))
250             if not options.notarball:
251                 ftp.get(tarball, os.path.join(output_dir, tarball))
252         except Exception:
253             raise BuildException(
254                 "Build failed for %s:%s - missing output files".format(
255                     app.id, build.versionName), output)
256         ftp.close()
257
258     finally:
259         # Suspend the build server.
260         vm = vmtools.get_build_vm('builder')
261         vm.suspend()
262
263
264 def force_gradle_build_tools(build_dir, build_tools):
265     for root, dirs, files in os.walk(build_dir):
266         for filename in files:
267             if not filename.endswith('.gradle'):
268                 continue
269             path = os.path.join(root, filename)
270             if not os.path.isfile(path):
271                 continue
272             logging.debug("Forcing build-tools %s in %s" % (build_tools, path))
273             common.regsub_file(r"""(\s*)buildToolsVersion([\s=]+).*""",
274                                r"""\1buildToolsVersion\2'%s'""" % build_tools,
275                                path)
276
277
278 def capitalize_intact(string):
279     """Like str.capitalize(), but leave the rest of the string intact without
280     switching it to lowercase."""
281     if len(string) == 0:
282         return string
283     if len(string) == 1:
284         return string.upper()
285     return string[0].upper() + string[1:]
286
287
288 def has_native_code(apkobj):
289     """aapt checks if there are architecture folders under the lib/ folder
290     so we are simulating the same behaviour"""
291     arch_re = re.compile("^lib/(.*)/.*$")
292     arch = [file for file in apkobj.get_files() if arch_re.match(file)]
293     return False if not arch else True
294
295
296 def get_apk_metadata_aapt(apkfile):
297     """aapt function to extract versionCode, versionName, packageName and nativecode"""
298     vercode = None
299     version = None
300     foundid = None
301     nativecode = None
302
303     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
304
305     for line in p.output.splitlines():
306         if line.startswith("package:"):
307             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
308             m = pat.match(line)
309             if m:
310                 foundid = m.group(1)
311             pat = re.compile(".*versionCode='([0-9]*)'.*")
312             m = pat.match(line)
313             if m:
314                 vercode = m.group(1)
315             pat = re.compile(".*versionName='([^']*)'.*")
316             m = pat.match(line)
317             if m:
318                 version = m.group(1)
319         elif line.startswith("native-code:"):
320             nativecode = line[12:]
321
322     return vercode, version, foundid, nativecode
323
324
325 def get_apk_metadata_androguard(apkfile):
326     """androguard function to extract versionCode, versionName, packageName and nativecode"""
327     try:
328         from androguard.core.bytecodes.apk import APK
329         apkobject = APK(apkfile)
330     except ImportError:
331         raise BuildException("androguard library is not installed and aapt binary not found")
332     except FileNotFoundError:
333         raise BuildException("Could not open apk file for metadata analysis")
334
335     if not apkobject.is_valid_APK():
336         raise BuildException("Invalid APK provided")
337
338     foundid = apkobject.get_package()
339     vercode = apkobject.get_androidversion_code()
340     version = apkobject.get_androidversion_name()
341     nativecode = has_native_code(apkobject)
342
343     return vercode, version, foundid, nativecode
344
345
346 def get_metadata_from_apk(app, build, apkfile):
347     """get the required metadata from the built APK"""
348
349     if common.SdkToolsPopen(['aapt', 'version'], output=False):
350         vercode, version, foundid, nativecode = get_apk_metadata_aapt(apkfile)
351     else:
352         vercode, version, foundid, nativecode = get_apk_metadata_androguard(apkfile)
353
354     # Ignore empty strings or any kind of space/newline chars that we don't
355     # care about
356     if nativecode is not None:
357         nativecode = nativecode.strip()
358         nativecode = None if not nativecode else nativecode
359
360     if build.buildjni and build.buildjni != ['no']:
361         if nativecode is None:
362             raise BuildException("Native code should have been built but none was packaged")
363     if build.novcheck:
364         vercode = build.versionCode
365         version = build.versionName
366     if not version or not vercode:
367         raise BuildException("Could not find version information in build in output")
368     if not foundid:
369         raise BuildException("Could not find package ID in output")
370     if foundid != app.id:
371         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app.id)
372
373     return vercode, version
374
375
376 def build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
377     """Do a build locally."""
378     ndk_path = build.ndk_path()
379     if build.ndk or (build.buildjni and build.buildjni != ['no']):
380         if not ndk_path:
381             logging.critical("Android NDK version '%s' could not be found!" % build.ndk or 'r12b')
382             logging.critical("Configured versions:")
383             for k, v in config['ndk_paths'].items():
384                 if k.endswith("_orig"):
385                     continue
386                 logging.critical("  %s: %s" % (k, v))
387             raise FDroidException()
388         elif not os.path.isdir(ndk_path):
389             logging.critical("Android NDK '%s' is not a directory!" % ndk_path)
390             raise FDroidException()
391
392     common.set_FDroidPopen_env(build)
393
394     # create ..._toolsversion.log when running in builder vm
395     if onserver:
396         log_path = os.path.join(log_dir,
397                                 common.get_toolsversion_logname(app, build))
398         with open(log_path, 'w') as f:
399             f.write(get_android_tools_version_log(build.ndk_path()))
400
401     # Prepare the source code...
402     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
403                                                   build_dir, srclib_dir,
404                                                   extlib_dir, onserver, refresh)
405
406     # We need to clean via the build tool in case the binary dirs are
407     # different from the default ones
408     p = None
409     gradletasks = []
410     bmethod = build.build_method()
411     if bmethod == 'maven':
412         logging.info("Cleaning Maven project...")
413         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
414
415         if '@' in build.maven:
416             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
417             maven_dir = os.path.normpath(maven_dir)
418         else:
419             maven_dir = root_dir
420
421         p = FDroidPopen(cmd, cwd=maven_dir)
422
423     elif bmethod == 'gradle':
424
425         logging.info("Cleaning Gradle project...")
426
427         if build.preassemble:
428             gradletasks += build.preassemble
429
430         flavours = build.gradle
431         if flavours == ['yes']:
432             flavours = []
433
434         flavours_cmd = ''.join([capitalize_intact(flav) for flav in flavours])
435
436         gradletasks += ['assemble' + flavours_cmd + 'Release']
437
438         if config['force_build_tools']:
439             force_gradle_build_tools(build_dir, config['build_tools'])
440             for name, number, libpath in srclibpaths:
441                 force_gradle_build_tools(libpath, config['build_tools'])
442
443         cmd = [config['gradle']]
444         if build.gradleprops:
445             cmd += ['-P' + kv for kv in build.gradleprops]
446
447         cmd += ['clean']
448
449         p = FDroidPopen(cmd, cwd=root_dir)
450
451     elif bmethod == 'kivy':
452         pass
453
454     elif bmethod == 'buildozer':
455         pass
456
457     elif bmethod == 'ant':
458         logging.info("Cleaning Ant project...")
459         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
460
461     if p is not None and p.returncode != 0:
462         raise BuildException("Error cleaning %s:%s" %
463                              (app.id, build.versionName), p.output)
464
465     for root, dirs, files in os.walk(build_dir):
466
467         def del_dirs(dl):
468             for d in dl:
469                 if d in dirs:
470                     shutil.rmtree(os.path.join(root, d))
471
472         def del_files(fl):
473             for f in fl:
474                 if f in files:
475                     os.remove(os.path.join(root, f))
476
477         if 'build.gradle' in files:
478             # Even when running clean, gradle stores task/artifact caches in
479             # .gradle/ as binary files. To avoid overcomplicating the scanner,
480             # manually delete them, just like `gradle clean` should have removed
481             # the build/ dirs.
482             del_dirs(['build', '.gradle'])
483             del_files(['gradlew', 'gradlew.bat'])
484
485         if 'pom.xml' in files:
486             del_dirs(['target'])
487
488         if any(f in files for f in ['ant.properties', 'project.properties', 'build.xml']):
489             del_dirs(['bin', 'gen'])
490
491         if 'jni' in dirs:
492             del_dirs(['obj'])
493
494     if options.skipscan:
495         if build.scandelete:
496             raise BuildException("Refusing to skip source scan since scandelete is present")
497     else:
498         # Scan before building...
499         logging.info("Scanning source for common problems...")
500         count = scanner.scan_source(build_dir, build)
501         if count > 0:
502             if force:
503                 logging.warn('Scanner found %d problems' % count)
504             else:
505                 raise BuildException("Can't build due to %d errors while scanning" % count)
506
507     if not options.notarball:
508         # Build the source tarball right before we build the release...
509         logging.info("Creating source tarball...")
510         tarname = common.getsrcname(app, build)
511         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
512
513         def tarexc(f):
514             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
515         tarball.add(build_dir, tarname, exclude=tarexc)
516         tarball.close()
517
518     # Run a build command if one is required...
519     if build.build:
520         logging.info("Running 'build' commands in %s" % root_dir)
521         cmd = common.replace_config_vars(build.build, build)
522
523         # Substitute source library paths into commands...
524         for name, number, libpath in srclibpaths:
525             libpath = os.path.relpath(libpath, root_dir)
526             cmd = cmd.replace('$$' + name + '$$', libpath)
527
528         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
529
530         if p.returncode != 0:
531             raise BuildException("Error running build command for %s:%s" %
532                                  (app.id, build.versionName), p.output)
533
534     # Build native stuff if required...
535     if build.buildjni and build.buildjni != ['no']:
536         logging.info("Building the native code")
537         jni_components = build.buildjni
538
539         if jni_components == ['yes']:
540             jni_components = ['']
541         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
542         for d in jni_components:
543             if d:
544                 logging.info("Building native code in '%s'" % d)
545             else:
546                 logging.info("Building native code in the main project")
547             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
548             if os.path.exists(manifest):
549                 # Read and write the whole AM.xml to fix newlines and avoid
550                 # the ndk r8c or later 'wordlist' errors. The outcome of this
551                 # under gnu/linux is the same as when using tools like
552                 # dos2unix, but the native python way is faster and will
553                 # work in non-unix systems.
554                 manifest_text = open(manifest, 'U').read()
555                 open(manifest, 'w').write(manifest_text)
556                 # In case the AM.xml read was big, free the memory
557                 del manifest_text
558             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
559             if p.returncode != 0:
560                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.versionName), p.output)
561
562     p = None
563     # Build the release...
564     if bmethod == 'maven':
565         logging.info("Building Maven project...")
566
567         if '@' in build.maven:
568             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
569         else:
570             maven_dir = root_dir
571
572         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
573                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
574                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
575                   'package']
576         if build.target:
577             target = build.target.split('-')[1]
578             common.regsub_file(r'<platform>[0-9]*</platform>',
579                                r'<platform>%s</platform>' % target,
580                                os.path.join(root_dir, 'pom.xml'))
581             if '@' in build.maven:
582                 common.regsub_file(r'<platform>[0-9]*</platform>',
583                                    r'<platform>%s</platform>' % target,
584                                    os.path.join(maven_dir, 'pom.xml'))
585
586         p = FDroidPopen(mvncmd, cwd=maven_dir)
587
588         bindir = os.path.join(root_dir, 'target')
589
590     elif bmethod == 'kivy':
591         logging.info("Building Kivy project...")
592
593         spec = os.path.join(root_dir, 'buildozer.spec')
594         if not os.path.exists(spec):
595             raise BuildException("Expected to find buildozer-compatible spec at {0}"
596                                  .format(spec))
597
598         defaults = {'orientation': 'landscape', 'icon': '',
599                     'permissions': '', 'android.api': "18"}
600         bconfig = ConfigParser(defaults, allow_no_value=True)
601         bconfig.read(spec)
602
603         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
604         if os.path.exists(distdir):
605             shutil.rmtree(distdir)
606
607         modules = bconfig.get('app', 'requirements').split(',')
608
609         cmd = 'ANDROIDSDK=' + config['sdk_path']
610         cmd += ' ANDROIDNDK=' + ndk_path
611         cmd += ' ANDROIDNDKVER=' + build.ndk
612         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
613         cmd += ' VIRTUALENV=virtualenv'
614         cmd += ' ./distribute.sh'
615         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
616         cmd += ' -d fdroid'
617         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
618         if p.returncode != 0:
619             raise BuildException("Distribute build failed")
620
621         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
622         if cid != app.id:
623             raise BuildException("Package ID mismatch between metadata and spec")
624
625         orientation = bconfig.get('app', 'orientation', 'landscape')
626         if orientation == 'all':
627             orientation = 'sensor'
628
629         cmd = ['./build.py'
630                '--dir', root_dir,
631                '--name', bconfig.get('app', 'title'),
632                '--package', app.id,
633                '--version', bconfig.get('app', 'version'),
634                '--orientation', orientation
635                ]
636
637         perms = bconfig.get('app', 'permissions')
638         for perm in perms.split(','):
639             cmd.extend(['--permission', perm])
640
641         if config.get('app', 'fullscreen') == 0:
642             cmd.append('--window')
643
644         icon = bconfig.get('app', 'icon.filename')
645         if icon:
646             cmd.extend(['--icon', os.path.join(root_dir, icon)])
647
648         cmd.append('release')
649         p = FDroidPopen(cmd, cwd=distdir)
650
651     elif bmethod == 'buildozer':
652         logging.info("Building Kivy project using buildozer...")
653
654         # parse buildozer.spez
655         spec = os.path.join(root_dir, 'buildozer.spec')
656         if not os.path.exists(spec):
657             raise BuildException("Expected to find buildozer-compatible spec at {0}"
658                                  .format(spec))
659         defaults = {'orientation': 'landscape', 'icon': '',
660                     'permissions': '', 'android.api': "19"}
661         bconfig = ConfigParser(defaults, allow_no_value=True)
662         bconfig.read(spec)
663
664         # update spec with sdk and ndk locations to prevent buildozer from
665         # downloading.
666         loc_ndk = common.env['ANDROID_NDK']
667         loc_sdk = common.env['ANDROID_SDK']
668         if loc_ndk == '$ANDROID_NDK':
669             loc_ndk = loc_sdk + '/ndk-bundle'
670
671         bc_ndk = None
672         bc_sdk = None
673         try:
674             bc_ndk = bconfig.get('app', 'android.sdk_path')
675         except Exception:
676             pass
677         try:
678             bc_sdk = bconfig.get('app', 'android.ndk_path')
679         except Exception:
680             pass
681
682         if bc_sdk is None:
683             bconfig.set('app', 'android.sdk_path', loc_sdk)
684         if bc_ndk is None:
685             bconfig.set('app', 'android.ndk_path', loc_ndk)
686
687         fspec = open(spec, 'w')
688         bconfig.write(fspec)
689         fspec.close()
690
691         logging.info("sdk_path = %s" % loc_sdk)
692         logging.info("ndk_path = %s" % loc_ndk)
693
694         p = None
695         # execute buildozer
696         cmd = ['buildozer', 'android', 'release']
697         try:
698             p = FDroidPopen(cmd, cwd=root_dir)
699         except Exception:
700             pass
701
702         # buidozer not installed ? clone repo and run
703         if (p is None or p.returncode != 0):
704             cmd = ['git', 'clone', 'https://github.com/kivy/buildozer.git']
705             p = subprocess.Popen(cmd, cwd=root_dir, shell=False)
706             p.wait()
707             if p.returncode != 0:
708                 raise BuildException("Distribute build failed")
709
710             cmd = ['python', 'buildozer/buildozer/scripts/client.py', 'android', 'release']
711             p = FDroidPopen(cmd, cwd=root_dir)
712
713         # expected to fail.
714         # Signing will fail if not set by environnment vars (cf. p4a docs).
715         # But the unsigned apk will be ok.
716         p.returncode = 0
717
718     elif bmethod == 'gradle':
719         logging.info("Building Gradle project...")
720
721         cmd = [config['gradle']]
722         if build.gradleprops:
723             cmd += ['-P' + kv for kv in build.gradleprops]
724
725         cmd += gradletasks
726
727         p = FDroidPopen(cmd, cwd=root_dir)
728
729     elif bmethod == 'ant':
730         logging.info("Building Ant project...")
731         cmd = ['ant']
732         if build.antcommands:
733             cmd += build.antcommands
734         else:
735             cmd += ['release']
736         p = FDroidPopen(cmd, cwd=root_dir)
737
738         bindir = os.path.join(root_dir, 'bin')
739
740     if p is not None and p.returncode != 0:
741         raise BuildException("Build failed for %s:%s" % (app.id, build.versionName), p.output)
742     logging.info("Successfully built version " + build.versionName + ' of ' + app.id)
743
744     omethod = build.output_method()
745     if omethod == 'maven':
746         stdout_apk = '\n'.join([
747             line for line in p.output.splitlines() if any(
748                 a in line for a in ('.apk', '.ap_', '.jar'))])
749         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
750                      stdout_apk, re.S | re.M)
751         if not m:
752             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
753                          stdout_apk, re.S | re.M)
754         if not m:
755             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
756                          stdout_apk, re.S | re.M)
757
758         if not m:
759             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
760                          stdout_apk, re.S | re.M)
761         if not m:
762             raise BuildException('Failed to find output')
763         src = m.group(1)
764         src = os.path.join(bindir, src) + '.apk'
765     elif omethod == 'kivy':
766         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
767                            '{0}-{1}-release.apk'.format(
768                                bconfig.get('app', 'title'),
769                                bconfig.get('app', 'version')))
770
771     elif omethod == 'buildozer':
772         src = None
773         for apks_dir in [
774                 os.path.join(root_dir, '.buildozer', 'android', 'platform', 'build', 'dists', bconfig.get('app', 'title'), 'bin'),
775                 ]:
776             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
777                 apks = glob.glob(os.path.join(apks_dir, apkglob))
778
779                 if len(apks) > 1:
780                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
781                                          '\n'.join(apks))
782                 if len(apks) == 1:
783                     src = apks[0]
784                     break
785             if src is not None:
786                 break
787
788         if src is None:
789             raise BuildException('Failed to find any output apks')
790
791     elif omethod == 'gradle':
792         src = None
793         for apks_dir in [
794                 os.path.join(root_dir, 'build', 'outputs', 'apk'),
795                 os.path.join(root_dir, 'build', 'apk'),
796                 ]:
797             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
798                 apks = glob.glob(os.path.join(apks_dir, apkglob))
799
800                 if len(apks) > 1:
801                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
802                                          '\n'.join(apks))
803                 if len(apks) == 1:
804                     src = apks[0]
805                     break
806             if src is not None:
807                 break
808
809         if src is None:
810             raise BuildException('Failed to find any output apks')
811
812     elif omethod == 'ant':
813         stdout_apk = '\n'.join([
814             line for line in p.output.splitlines() if '.apk' in line])
815         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
816                        re.S | re.M).group(1)
817         src = os.path.join(bindir, src)
818     elif omethod == 'raw':
819         output_path = common.replace_build_vars(build.output, build)
820         globpath = os.path.join(root_dir, output_path)
821         apks = glob.glob(globpath)
822         if len(apks) > 1:
823             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
824         if len(apks) < 1:
825             raise BuildException('No apks match %s' % globpath)
826         src = os.path.normpath(apks[0])
827
828     # Make sure it's not debuggable...
829     if common.isApkAndDebuggable(src):
830         raise BuildException("APK is debuggable")
831
832     # By way of a sanity check, make sure the version and version
833     # code in our new apk match what we expect...
834     logging.debug("Checking " + src)
835     if not os.path.exists(src):
836         raise BuildException("Unsigned apk is not at expected location of " + src)
837
838     if common.get_file_extension(src) == 'apk':
839         vercode, version = get_metadata_from_apk(app, build, src)
840         if (version != build.versionName or vercode != build.versionCode):
841             raise BuildException(("Unexpected version/version code in output;"
842                                   " APK: '%s' / '%s', "
843                                   " Expected: '%s' / '%s'")
844                                  % (version, str(vercode), build.versionName,
845                                     str(build.versionCode)))
846     else:
847         vercode = build.versionCode
848         version = build.versionName
849
850     # Add information for 'fdroid verify' to be able to reproduce the build
851     # environment.
852     if onserver:
853         metadir = os.path.join(tmp_dir, 'META-INF')
854         if not os.path.exists(metadir):
855             os.mkdir(metadir)
856         homedir = os.path.expanduser('~')
857         for fn in ['buildserverid', 'fdroidserverid']:
858             shutil.copyfile(os.path.join(homedir, fn),
859                             os.path.join(metadir, fn))
860             subprocess.call(['jar', 'uf', os.path.abspath(src),
861                              'META-INF/' + fn], cwd=tmp_dir)
862
863     # Copy the unsigned apk to our destination directory for further
864     # processing (by publish.py)...
865     dest = os.path.join(output_dir, common.get_release_filename(app, build))
866     shutil.copyfile(src, dest)
867
868     # Move the source tarball into the output directory...
869     if output_dir != tmp_dir and not options.notarball:
870         shutil.move(os.path.join(tmp_dir, tarname),
871                     os.path.join(output_dir, tarname))
872
873
874 def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
875              srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, test,
876              server, force, onserver, refresh):
877     """
878     Build a particular version of an application, if it needs building.
879
880     :param output_dir: The directory where the build output will go. Usually
881        this is the 'unsigned' directory.
882     :param repo_dir: The repo directory - used for checking if the build is
883        necessary.
884     :param also_check_dir: An additional location for checking if the build
885        is necessary (usually the archive repo)
886     :param test: True if building in test mode, in which case the build will
887        always happen, even if the output already exists. In test mode, the
888        output directory should be a temporary location, not any of the real
889        ones.
890
891     :returns: True if the build was done, False if it wasn't necessary.
892     """
893
894     dest_file = common.get_release_filename(app, build)
895
896     dest = os.path.join(output_dir, dest_file)
897     dest_repo = os.path.join(repo_dir, dest_file)
898
899     if not test:
900         if os.path.exists(dest) or os.path.exists(dest_repo):
901             return False
902
903         if also_check_dir:
904             dest_also = os.path.join(also_check_dir, dest_file)
905             if os.path.exists(dest_also):
906                 return False
907
908     if build.disable and not options.force:
909         return False
910
911     logging.info("Building version %s (%s) of %s" % (
912         build.versionName, build.versionCode, app.id))
913
914     if server:
915         # When using server mode, still keep a local cache of the repo, by
916         # grabbing the source now.
917         vcs.gotorevision(build.commit)
918
919         build_server(app, build, vcs, build_dir, output_dir, log_dir, force)
920     else:
921         build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
922     return True
923
924
925 def get_android_tools_versions(ndk_path=None):
926     '''get a list of the versions of all installed Android SDK/NDK components'''
927
928     global config
929     sdk_path = config['sdk_path']
930     if sdk_path[-1] != '/':
931         sdk_path += '/'
932     components = []
933     if ndk_path:
934         ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
935         if os.path.isfile(ndk_release_txt):
936             with open(ndk_release_txt, 'r') as fp:
937                 components.append((os.path.basename(ndk_path), fp.read()[:-1]))
938
939     pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
940     for root, dirs, files in os.walk(sdk_path):
941         if 'source.properties' in files:
942             source_properties = os.path.join(root, 'source.properties')
943             with open(source_properties, 'r') as fp:
944                 m = pattern.search(fp.read())
945                 if m:
946                     components.append((root[len(sdk_path):], m.group(1)))
947
948     return components
949
950
951 def get_android_tools_version_log(ndk_path):
952     '''get a list of the versions of all installed Android SDK/NDK components'''
953     log = '== Installed Android Tools ==\n\n'
954     components = get_android_tools_versions(ndk_path)
955     for name, version in sorted(components):
956         log += '* ' + name + ' (' + version + ')\n'
957
958     return log
959
960
961 def parse_commandline():
962     """Parse the command line. Returns options, parser."""
963
964     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
965     common.setup_global_opts(parser)
966     parser.add_argument("appid", nargs='*', help="app-id with optional versionCode in the form APPID[:VERCODE]")
967     parser.add_argument("-l", "--latest", action="store_true", default=False,
968                         help="Build only the latest version of each package")
969     parser.add_argument("-s", "--stop", action="store_true", default=False,
970                         help="Make the build stop on exceptions")
971     parser.add_argument("-t", "--test", action="store_true", default=False,
972                         help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
973     parser.add_argument("--server", action="store_true", default=False,
974                         help="Use build server")
975     parser.add_argument("--resetserver", action="store_true", default=False,
976                         help="Reset and create a brand new build server, even if the existing one appears to be ok.")
977     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
978                         help="Specify that we're running on the build server")
979     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
980                         help="Skip scanning the source code for binaries and other problems")
981     parser.add_argument("--dscanner", action="store_true", default=False,
982                         help="Setup an emulator, install the apk on it and perform a drozer scan")
983     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
984                         help="Don't create a source tarball, useful when testing a build")
985     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
986                         help="Don't refresh the repository, useful when testing a build with no internet connection")
987     parser.add_argument("-f", "--force", action="store_true", default=False,
988                         help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
989     parser.add_argument("-a", "--all", action="store_true", default=False,
990                         help="Build all applications available")
991     parser.add_argument("-w", "--wiki", default=False, action="store_true",
992                         help="Update the wiki")
993     metadata.add_metadata_arguments(parser)
994     options = parser.parse_args()
995     metadata.warnings_action = options.W
996
997     # Force --stop with --on-server to get correct exit code
998     if options.onserver:
999         options.stop = True
1000
1001     if options.force and not options.test:
1002         parser.error("option %s: Force is only allowed in test mode" % "force")
1003
1004     return options, parser
1005
1006
1007 options = None
1008 config = None
1009 buildserverid = None
1010
1011
1012 def main():
1013
1014     global options, config, buildserverid
1015
1016     options, parser = parse_commandline()
1017
1018     # The defaults for .fdroid.* metadata that is included in a git repo are
1019     # different than for the standard metadata/ layout because expectations
1020     # are different.  In this case, the most common user will be the app
1021     # developer working on the latest update of the app on their own machine.
1022     local_metadata_files = common.get_local_metadata_files()
1023     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
1024         config = dict(common.default_config)
1025         # `fdroid build` should build only the latest version by default since
1026         # most of the time the user will be building the most recent update
1027         if not options.all:
1028             options.latest = True
1029     elif len(local_metadata_files) > 1:
1030         raise FDroidException("Only one local metadata file allowed! Found: "
1031                               + " ".join(local_metadata_files))
1032     else:
1033         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1034             raise FDroidException("No app metadata found, nothing to process!")
1035         if not options.appid and not options.all:
1036             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1037
1038     config = common.read_config(options)
1039
1040     if config['build_server_always']:
1041         options.server = True
1042     if options.resetserver and not options.server:
1043         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1044
1045     log_dir = 'logs'
1046     if not os.path.isdir(log_dir):
1047         logging.info("Creating log directory")
1048         os.makedirs(log_dir)
1049
1050     tmp_dir = 'tmp'
1051     if not os.path.isdir(tmp_dir):
1052         logging.info("Creating temporary directory")
1053         os.makedirs(tmp_dir)
1054
1055     if options.test:
1056         output_dir = tmp_dir
1057     else:
1058         output_dir = 'unsigned'
1059         if not os.path.isdir(output_dir):
1060             logging.info("Creating output directory")
1061             os.makedirs(output_dir)
1062
1063     if config['archive_older'] != 0:
1064         also_check_dir = 'archive'
1065     else:
1066         also_check_dir = None
1067
1068     repo_dir = 'repo'
1069
1070     build_dir = 'build'
1071     if not os.path.isdir(build_dir):
1072         logging.info("Creating build directory")
1073         os.makedirs(build_dir)
1074     srclib_dir = os.path.join(build_dir, 'srclib')
1075     extlib_dir = os.path.join(build_dir, 'extlib')
1076
1077     # Read all app and srclib metadata
1078     pkgs = common.read_pkg_args(options.appid, True)
1079     allapps = metadata.read_metadata(not options.onserver, pkgs)
1080     apps = common.read_app_args(options.appid, allapps, True)
1081
1082     for appid, app in list(apps.items()):
1083         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1084             del apps[appid]
1085
1086     if not apps:
1087         raise FDroidException("No apps to process.")
1088
1089     if options.latest:
1090         for app in apps.values():
1091             for build in reversed(app.builds):
1092                 if build.disable and not options.force:
1093                     continue
1094                 app.builds = [build]
1095                 break
1096
1097     if options.wiki:
1098         import mwclient
1099         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1100                              path=config['wiki_path'])
1101         site.login(config['wiki_user'], config['wiki_password'])
1102
1103     # Build applications...
1104     failed_apps = {}
1105     build_succeeded = []
1106     for appid, app in apps.items():
1107
1108         first = True
1109
1110         for build in app.builds:
1111             wikilog = None
1112             tools_version_log = ''
1113             if not options.onserver:
1114                 tools_version_log = get_android_tools_version_log(build.ndk_path())
1115             try:
1116
1117                 # For the first build of a particular app, we need to set up
1118                 # the source repo. We can reuse it on subsequent builds, if
1119                 # there are any.
1120                 if first:
1121                     vcs, build_dir = common.setup_vcs(app)
1122                     first = False
1123
1124                 logging.debug("Checking " + build.versionName)
1125                 if trybuild(app, build, build_dir, output_dir, log_dir,
1126                             also_check_dir, srclib_dir, extlib_dir,
1127                             tmp_dir, repo_dir, vcs, options.test,
1128                             options.server, options.force,
1129                             options.onserver, options.refresh):
1130                     toolslog = os.path.join(log_dir,
1131                                             common.get_toolsversion_logname(app, build))
1132                     if not options.onserver and os.path.exists(toolslog):
1133                         with open(toolslog, 'r') as f:
1134                             tools_version_log = ''.join(f.readlines())
1135                         os.remove(toolslog)
1136
1137                     if app.Binaries is not None:
1138                         # This is an app where we build from source, and
1139                         # verify the apk contents against a developer's
1140                         # binary. We get that binary now, and save it
1141                         # alongside our built one in the 'unsigend'
1142                         # directory.
1143                         url = app.Binaries
1144                         url = url.replace('%v', build.versionName)
1145                         url = url.replace('%c', str(build.versionCode))
1146                         logging.info("...retrieving " + url)
1147                         of = common.get_release_filename(app, build) + '.binary'
1148                         of = os.path.join(output_dir, of)
1149                         try:
1150                             net.download_file(url, local_filename=of)
1151                         except requests.exceptions.HTTPError as e:
1152                             raise FDroidException(
1153                                 'Downloading Binaries from %s failed. %s' % (url, e))
1154
1155                         # Now we check weather the build can be verified to
1156                         # match the supplied binary or not. Should the
1157                         # comparison fail, we mark this build as a failure
1158                         # and remove everything from the unsigend folder.
1159                         with tempfile.TemporaryDirectory() as tmpdir:
1160                             unsigned_apk = \
1161                                 common.get_release_filename(app, build)
1162                             unsigned_apk = \
1163                                 os.path.join(output_dir, unsigned_apk)
1164                             compare_result = \
1165                                 common.verify_apks(of, unsigned_apk, tmpdir)
1166                             if compare_result:
1167                                 logging.debug('removing %s', unsigned_apk)
1168                                 os.remove(unsigned_apk)
1169                                 logging.debug('removing %s', of)
1170                                 os.remove(of)
1171                                 compare_result = compare_result.split('\n')
1172                                 line_count = len(compare_result)
1173                                 compare_result = compare_result[:299]
1174                                 if line_count > len(compare_result):
1175                                     line_difference = \
1176                                         line_count - len(compare_result)
1177                                     compare_result.append('%d more lines ...' %
1178                                                           line_difference)
1179                                 compare_result = '\n'.join(compare_result)
1180                                 raise FDroidException('compared built binary '
1181                                                       'to supplied reference '
1182                                                       'binary but failed',
1183                                                       compare_result)
1184                             else:
1185                                 logging.info('compared built binary to '
1186                                              'supplied reference binary '
1187                                              'successfully')
1188
1189                     build_succeeded.append(app)
1190                     wikilog = "Build succeeded"
1191
1192             except VCSException as vcse:
1193                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1194                 logging.error("VCS error while building app %s: %s" % (
1195                     appid, reason))
1196                 if options.stop:
1197                     sys.exit(1)
1198                 failed_apps[appid] = vcse
1199                 wikilog = str(vcse)
1200             except FDroidException as e:
1201                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1202                     f.write('\n\n============================================================\n')
1203                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1204                             (build.versionCode, build.versionName, build.commit))
1205                     f.write('Build completed at '
1206                             + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
1207                     f.write('\n' + tools_version_log + '\n')
1208                     f.write(str(e))
1209                 logging.error("Could not build app %s: %s" % (appid, e))
1210                 if options.stop:
1211                     sys.exit(1)
1212                 failed_apps[appid] = e
1213                 wikilog = e.get_wikitext()
1214             except Exception as e:
1215                 logging.error("Could not build app %s due to unknown error: %s" % (
1216                     appid, traceback.format_exc()))
1217                 if options.stop:
1218                     sys.exit(1)
1219                 failed_apps[appid] = e
1220                 wikilog = str(e)
1221
1222             if options.wiki and wikilog:
1223                 try:
1224                     # Write a page with the last build log for this version code
1225                     lastbuildpage = appid + '/lastbuild_' + build.versionCode
1226                     newpage = site.Pages[lastbuildpage]
1227                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1228                         fdroidserverid = fp.read().rstrip()
1229                     txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
1230                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1231                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1232                     if options.onserver:
1233                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1234                                + buildserverid + ' ' + buildserverid + ']\n\n'
1235                     txt += tools_version_log + '\n\n'
1236                     txt += '== Build Log ==\n\n' + wikilog
1237                     newpage.save(txt, summary='Build log')
1238                     # Redirect from /lastbuild to the most recent build log
1239                     newpage = site.Pages[appid + '/lastbuild']
1240                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1241                 except Exception as e:
1242                     logging.error("Error while attempting to publish build log: %s" % e)
1243
1244     for app in build_succeeded:
1245         logging.info("success: %s" % (app.id))
1246
1247     if not options.verbose:
1248         for fa in failed_apps:
1249             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1250
1251     # perform a drozer scan of all successful builds
1252     if options.dscanner and build_succeeded:
1253         from .dscanner import DockerDriver
1254
1255         docker = DockerDriver()
1256
1257         try:
1258             for app in build_succeeded:
1259
1260                 logging.info("Need to sign the app before we can install it.")
1261                 subprocess.call("fdroid publish {0}".format(app.id), shell=True)
1262
1263                 apk_path = None
1264
1265                 for f in os.listdir(repo_dir):
1266                     if f.endswith('.apk') and f.startswith(app.id):
1267                         apk_path = os.path.join(repo_dir, f)
1268                         break
1269
1270                 if not apk_path:
1271                     raise Exception("No signed APK found at path: {0}".format(apk_path))
1272
1273                 if not os.path.isdir(repo_dir):
1274                     exit(1)
1275
1276                 logging.info("Performing Drozer scan on {0}.".format(app))
1277                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1278         except Exception as e:
1279             logging.error(str(e))
1280             logging.error("An exception happened. Making sure to clean up")
1281         else:
1282             logging.info("Scan succeeded.")
1283
1284         logging.info("Cleaning up after ourselves.")
1285         docker.clean()
1286
1287     logging.info("Finished.")
1288     if len(build_succeeded) > 0:
1289         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1290     if len(failed_apps) > 0:
1291         logging.info(str(len(failed_apps)) + ' builds failed')
1292
1293     sys.exit(0)
1294
1295
1296 if __name__ == "__main__":
1297     main()