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