chiark / gitweb /
Merge branch 'skip-scan' 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         globpath = os.path.join(root_dir, build.output)
863         apks = glob.glob(globpath)
864         if len(apks) > 1:
865             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
866         if len(apks) < 1:
867             raise BuildException('No apks match %s' % globpath)
868         src = os.path.normpath(apks[0])
869
870     # Make sure it's not debuggable...
871     if common.isApkAndDebuggable(src, config):
872         raise BuildException("APK is debuggable")
873
874     # By way of a sanity check, make sure the version and version
875     # code in our new apk match what we expect...
876     logging.debug("Checking " + src)
877     if not os.path.exists(src):
878         raise BuildException("Unsigned apk is not at expected location of " + src)
879
880     if common.get_file_extension(src) == 'apk':
881         vercode, version = get_metadata_from_apk(app, build, src)
882         if (version != build.versionName or vercode != build.versionCode):
883             raise BuildException(("Unexpected version/version code in output;"
884                                   " APK: '%s' / '%s', "
885                                   " Expected: '%s' / '%s'")
886                                  % (version, str(vercode), build.versionName,
887                                     str(build.versionCode)))
888     else:
889         vercode = build.versionCode
890         version = build.versionName
891
892     # Add information for 'fdroid verify' to be able to reproduce the build
893     # environment.
894     if onserver:
895         metadir = os.path.join(tmp_dir, 'META-INF')
896         if not os.path.exists(metadir):
897             os.mkdir(metadir)
898         homedir = os.path.expanduser('~')
899         for fn in ['buildserverid', 'fdroidserverid']:
900             shutil.copyfile(os.path.join(homedir, fn),
901                             os.path.join(metadir, fn))
902             subprocess.call(['jar', 'uf', os.path.abspath(src),
903                              'META-INF/' + fn], cwd=tmp_dir)
904
905     # Copy the unsigned apk to our destination directory for further
906     # processing (by publish.py)...
907     dest = os.path.join(output_dir, common.get_release_filename(app, build))
908     shutil.copyfile(src, dest)
909
910     # Move the source tarball into the output directory...
911     if output_dir != tmp_dir and not options.notarball:
912         shutil.move(os.path.join(tmp_dir, tarname),
913                     os.path.join(output_dir, tarname))
914
915
916 def trybuild(app, build, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
917              tmp_dir, repo_dir, vcs, test, server, force, onserver, refresh):
918     """
919     Build a particular version of an application, if it needs building.
920
921     :param output_dir: The directory where the build output will go. Usually
922        this is the 'unsigned' directory.
923     :param repo_dir: The repo directory - used for checking if the build is
924        necessary.
925     :paaram also_check_dir: An additional location for checking if the build
926        is necessary (usually the archive repo)
927     :param test: True if building in test mode, in which case the build will
928        always happen, even if the output already exists. In test mode, the
929        output directory should be a temporary location, not any of the real
930        ones.
931
932     :returns: True if the build was done, False if it wasn't necessary.
933     """
934
935     dest_file = common.get_release_filename(app, build)
936
937     dest = os.path.join(output_dir, dest_file)
938     dest_repo = os.path.join(repo_dir, dest_file)
939
940     if not test:
941         if os.path.exists(dest) or os.path.exists(dest_repo):
942             return False
943
944         if also_check_dir:
945             dest_also = os.path.join(also_check_dir, dest_file)
946             if os.path.exists(dest_also):
947                 return False
948
949     if build.disable and not options.force:
950         return False
951
952     logging.info("Building version %s (%s) of %s" % (
953         build.versionName, build.versionCode, app.id))
954
955     if server:
956         # When using server mode, still keep a local cache of the repo, by
957         # grabbing the source now.
958         vcs.gotorevision(build.commit)
959
960         build_server(app, build, vcs, build_dir, output_dir, force)
961     else:
962         build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
963     return True
964
965
966 def get_android_tools_versions(ndk_path=None):
967     '''get a list of the versions of all installed Android SDK/NDK components'''
968
969     global config
970     sdk_path = config['sdk_path']
971     if sdk_path[-1] != '/':
972         sdk_path += '/'
973     components = []
974     if ndk_path:
975         ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
976         if os.path.isfile(ndk_release_txt):
977             with open(ndk_release_txt, 'r') as fp:
978                 components.append((os.path.basename(ndk_path), fp.read()[:-1]))
979
980     pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
981     for root, dirs, files in os.walk(sdk_path):
982         if 'source.properties' in files:
983             source_properties = os.path.join(root, 'source.properties')
984             with open(source_properties, 'r') as fp:
985                 m = pattern.search(fp.read())
986                 if m:
987                     components.append((root[len(sdk_path):], m.group(1)))
988
989     return components
990
991
992 def get_android_tools_version_log(ndk_path):
993     '''get a list of the versions of all installed Android SDK/NDK components'''
994     log = ''
995     components = get_android_tools_versions(ndk_path)
996     for name, version in sorted(components):
997         log += '* ' + name + ' (' + version + ')\n'
998
999     return log
1000
1001
1002 def parse_commandline():
1003     """Parse the command line. Returns options, parser."""
1004
1005     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
1006     common.setup_global_opts(parser)
1007     parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
1008     parser.add_argument("-l", "--latest", action="store_true", default=False,
1009                         help="Build only the latest version of each package")
1010     parser.add_argument("-s", "--stop", action="store_true", default=False,
1011                         help="Make the build stop on exceptions")
1012     parser.add_argument("-t", "--test", action="store_true", default=False,
1013                         help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
1014     parser.add_argument("--server", action="store_true", default=False,
1015                         help="Use build server")
1016     parser.add_argument("--resetserver", action="store_true", default=False,
1017                         help="Reset and create a brand new build server, even if the existing one appears to be ok.")
1018     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
1019                         help="Specify that we're running on the build server")
1020     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
1021                         help="Skip scanning the source code for binaries and other problems")
1022     parser.add_argument("--dscanner", action="store_true", default=False,
1023                         help="Setup an emulator, install the apk on it and perform a drozer scan")
1024     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
1025                         help="Don't create a source tarball, useful when testing a build")
1026     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
1027                         help="Don't refresh the repository, useful when testing a build with no internet connection")
1028     parser.add_argument("-f", "--force", action="store_true", default=False,
1029                         help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
1030     parser.add_argument("-a", "--all", action="store_true", default=False,
1031                         help="Build all applications available")
1032     parser.add_argument("-w", "--wiki", default=False, action="store_true",
1033                         help="Update the wiki")
1034     metadata.add_metadata_arguments(parser)
1035     options = parser.parse_args()
1036     metadata.warnings_action = options.W
1037
1038     # Force --stop with --on-server to get correct exit code
1039     if options.onserver:
1040         options.stop = True
1041
1042     if options.force and not options.test:
1043         parser.error("option %s: Force is only allowed in test mode" % "force")
1044
1045     return options, parser
1046
1047
1048 options = None
1049 config = None
1050 buildserverid = None
1051
1052
1053 def main():
1054
1055     global options, config, buildserverid
1056
1057     options, parser = parse_commandline()
1058
1059     # The defaults for .fdroid.* metadata that is included in a git repo are
1060     # different than for the standard metadata/ layout because expectations
1061     # are different.  In this case, the most common user will be the app
1062     # developer working on the latest update of the app on their own machine.
1063     local_metadata_files = common.get_local_metadata_files()
1064     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
1065         config = dict(common.default_config)
1066         # `fdroid build` should build only the latest version by default since
1067         # most of the time the user will be building the most recent update
1068         if not options.all:
1069             options.latest = True
1070     elif len(local_metadata_files) > 1:
1071         raise FDroidException("Only one local metadata file allowed! Found: "
1072                               + " ".join(local_metadata_files))
1073     else:
1074         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1075             raise FDroidException("No app metadata found, nothing to process!")
1076         if not options.appid and not options.all:
1077             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1078
1079     config = common.read_config(options)
1080
1081     if config['build_server_always']:
1082         options.server = True
1083     if options.resetserver and not options.server:
1084         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1085
1086     log_dir = 'logs'
1087     if not os.path.isdir(log_dir):
1088         logging.info("Creating log directory")
1089         os.makedirs(log_dir)
1090
1091     tmp_dir = 'tmp'
1092     if not os.path.isdir(tmp_dir):
1093         logging.info("Creating temporary directory")
1094         os.makedirs(tmp_dir)
1095
1096     if options.test:
1097         output_dir = tmp_dir
1098     else:
1099         output_dir = 'unsigned'
1100         if not os.path.isdir(output_dir):
1101             logging.info("Creating output directory")
1102             os.makedirs(output_dir)
1103
1104     if config['archive_older'] != 0:
1105         also_check_dir = 'archive'
1106     else:
1107         also_check_dir = None
1108
1109     repo_dir = 'repo'
1110
1111     build_dir = 'build'
1112     if not os.path.isdir(build_dir):
1113         logging.info("Creating build directory")
1114         os.makedirs(build_dir)
1115     srclib_dir = os.path.join(build_dir, 'srclib')
1116     extlib_dir = os.path.join(build_dir, 'extlib')
1117
1118     # Read all app and srclib metadata
1119     pkgs = common.read_pkg_args(options.appid, True)
1120     allapps = metadata.read_metadata(not options.onserver, pkgs)
1121     apps = common.read_app_args(options.appid, allapps, True)
1122
1123     for appid, app in list(apps.items()):
1124         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1125             del apps[appid]
1126
1127     if not apps:
1128         raise FDroidException("No apps to process.")
1129
1130     if options.latest:
1131         for app in apps.values():
1132             for build in reversed(app.builds):
1133                 if build.disable and not options.force:
1134                     continue
1135                 app.builds = [build]
1136                 break
1137
1138     if options.wiki:
1139         import mwclient
1140         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1141                              path=config['wiki_path'])
1142         site.login(config['wiki_user'], config['wiki_password'])
1143
1144     # Build applications...
1145     failed_apps = {}
1146     build_succeeded = []
1147     for appid, app in apps.items():
1148
1149         first = True
1150
1151         for build in app.builds:
1152             wikilog = None
1153             tools_version_log = '== Installed Android Tools ==\n\n'
1154             tools_version_log += get_android_tools_version_log(build.ndk_path())
1155             try:
1156
1157                 # For the first build of a particular app, we need to set up
1158                 # the source repo. We can reuse it on subsequent builds, if
1159                 # there are any.
1160                 if first:
1161                     vcs, build_dir = common.setup_vcs(app)
1162                     first = False
1163
1164                 logging.debug("Checking " + build.versionName)
1165                 if trybuild(app, build, build_dir, output_dir,
1166                             also_check_dir, srclib_dir, extlib_dir,
1167                             tmp_dir, repo_dir, vcs, options.test,
1168                             options.server, options.force,
1169                             options.onserver, options.refresh):
1170
1171                     if app.Binaries is not None:
1172                         # This is an app where we build from source, and
1173                         # verify the apk contents against a developer's
1174                         # binary. We get that binary now, and save it
1175                         # alongside our built one in the 'unsigend'
1176                         # directory.
1177                         url = app.Binaries
1178                         url = url.replace('%v', build.versionName)
1179                         url = url.replace('%c', str(build.versionCode))
1180                         logging.info("...retrieving " + url)
1181                         of = "{0}_{1}.apk.binary".format(app.id, build.versionCode)
1182                         of = os.path.join(output_dir, of)
1183                         net.download_file(url, local_filename=of)
1184
1185                     build_succeeded.append(app)
1186                     wikilog = "Build succeeded"
1187             except VCSException as vcse:
1188                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1189                 logging.error("VCS error while building app %s: %s" % (
1190                     appid, reason))
1191                 if options.stop:
1192                     sys.exit(1)
1193                 failed_apps[appid] = vcse
1194                 wikilog = str(vcse)
1195             except FDroidException as e:
1196                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1197                     f.write('\n\n============================================================\n')
1198                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1199                             (build.versionCode, build.versionName, build.commit))
1200                     f.write('Build completed at '
1201                             + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
1202                     f.write('\n' + tools_version_log + '\n')
1203                     f.write(str(e))
1204                 logging.error("Could not build app %s: %s" % (appid, e))
1205                 if options.stop:
1206                     sys.exit(1)
1207                 failed_apps[appid] = e
1208                 wikilog = e.get_wikitext()
1209             except Exception as e:
1210                 logging.error("Could not build app %s due to unknown error: %s" % (
1211                     appid, traceback.format_exc()))
1212                 if options.stop:
1213                     sys.exit(1)
1214                 failed_apps[appid] = e
1215                 wikilog = str(e)
1216
1217             if options.wiki and wikilog:
1218                 try:
1219                     # Write a page with the last build log for this version code
1220                     lastbuildpage = appid + '/lastbuild_' + build.versionCode
1221                     newpage = site.Pages[lastbuildpage]
1222                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1223                         fdroidserverid = fp.read().rstrip()
1224                     txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
1225                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1226                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1227                     if options.onserver:
1228                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1229                                + buildserverid + ' ' + buildserverid + ']\n\n'
1230                     txt += tools_version_log + '\n\n'
1231                     txt += '== Build Log ==\n\n' + wikilog
1232                     newpage.save(txt, summary='Build log')
1233                     # Redirect from /lastbuild to the most recent build log
1234                     newpage = site.Pages[appid + '/lastbuild']
1235                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1236                 except:
1237                     logging.error("Error while attempting to publish build log")
1238
1239     for app in build_succeeded:
1240         logging.info("success: %s" % (app.id))
1241
1242     if not options.verbose:
1243         for fa in failed_apps:
1244             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1245
1246     # perform a drozer scan of all successful builds
1247     if options.dscanner and build_succeeded:
1248         from .dscanner import DockerDriver
1249
1250         docker = DockerDriver()
1251
1252         try:
1253             for app in build_succeeded:
1254
1255                 logging.info("Need to sign the app before we can install it.")
1256                 subprocess.call("fdroid publish {0}".format(app.id), shell=True)
1257
1258                 apk_path = None
1259
1260                 for f in os.listdir(repo_dir):
1261                     if f.endswith('.apk') and f.startswith(app.id):
1262                         apk_path = os.path.join(repo_dir, f)
1263                         break
1264
1265                 if not apk_path:
1266                     raise Exception("No signed APK found at path: {0}".format(apk_path))
1267
1268                 if not os.path.isdir(repo_dir):
1269                     exit(1)
1270
1271                 logging.info("Performing Drozer scan on {0}.".format(app))
1272                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1273         except Exception as e:
1274             logging.error(str(e))
1275             logging.error("An exception happened. Making sure to clean up")
1276         else:
1277             logging.info("Scan succeeded.")
1278
1279         logging.info("Cleaning up after ourselves.")
1280         docker.clean()
1281
1282     logging.info("Finished.")
1283     if len(build_succeeded) > 0:
1284         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1285     if len(failed_apps) > 0:
1286         logging.info(str(len(failed_apps)) + ' builds failed')
1287
1288     sys.exit(0)
1289
1290
1291 if __name__ == "__main__":
1292     main()