chiark / gitweb /
build: clear timeout flag before every build
[fdroidserver.git] / fdroidserver / build.py
index 9497747b120ca46a8971da72687902116b3ed6e1..4ece75d8e9564a2fa3fbb5076f712b479d484ea0 100644 (file)
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-import sys
 import os
 import shutil
 import glob
 import subprocess
 import re
 import resource
+import sys
 import tarfile
+import threading
 import traceback
 import time
 import requests
@@ -72,13 +73,15 @@ def build_server(app, build, vcs, build_dir, output_dir, log_dir, force):
     else:
         logging.getLogger("paramiko").setLevel(logging.WARN)
 
-    sshinfo = vmtools.get_clean_builder('builder')
+    sshinfo = vmtools.get_clean_builder('builder', options.reset_server)
 
     try:
         if not buildserverid:
             buildserverid = subprocess.check_output(['vagrant', 'ssh', '-c',
                                                      'cat /home/vagrant/buildserverid'],
-                                                    cwd='builder').rstrip()
+                                                    cwd='builder').rstrip().decode()
+            logging.debug(_('Fetched buildserverid from VM: {buildserverid}')
+                          .format(buildserverid=buildserverid))
 
         # Open SSH connection...
         logging.info("Connecting to virtual machine...")
@@ -130,9 +133,9 @@ def build_server(app, build, vcs, build_dir, output_dir, log_dir, force):
         ftp.chmod('config.py', 0o600)
 
         # Copy over the ID (head commit hash) of the fdroidserver in use...
-        subprocess.call('git rev-parse HEAD >' +
-                        os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
-                        shell=True, cwd=serverpath)
+        with open(os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'), 'wb') as fp:
+            fp.write(subprocess.check_output(['git', 'rev-parse', 'HEAD'],
+                                             cwd=serverpath))
         ftp.put('tmp/fdroidserverid', 'fdroidserverid')
 
         # Copy the metadata - just the file for this app...
@@ -217,7 +220,7 @@ def build_server(app, build, vcs, build_dir, output_dir, log_dir, force):
         try:
             cmd_stdout = chan.makefile('rb', 1024)
             output = bytes()
-            output += get_android_tools_version_log(build.ndk_path()).encode()
+            output += common.get_android_tools_version_log(build.ndk_path()).encode()
             while not chan.exit_status_ready():
                 line = cmd_stdout.readline()
                 if line:
@@ -237,9 +240,12 @@ def build_server(app, build, vcs, build_dir, output_dir, log_dir, force):
         logging.info("...getting exit status")
         returncode = chan.recv_exit_status()
         if returncode != 0:
-            raise BuildException(
-                "Build.py failed on server for {0}:{1}".format(
-                    app.id, build.versionName), None if options.verbose else str(output, 'utf-8'))
+            if timeout_event.is_set():
+                message = "Timeout exceeded! Build VM force-stopped for {0}:{1}"
+            else:
+                message = "Build.py failed on server for {0}:{1}"
+            raise BuildException(message.format(app.id, build.versionName),
+                                 None if options.verbose else str(output, 'utf-8'))
 
         # Retreive logs...
         toolsversion_log = common.get_toolsversion_logname(app, build)
@@ -427,7 +433,7 @@ def build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, ext
         log_path = os.path.join(log_dir,
                                 common.get_toolsversion_logname(app, build))
         with open(log_path, 'w') as f:
-            f.write(get_android_tools_version_log(build.ndk_path()))
+            f.write(common.get_android_tools_version_log(build.ndk_path()))
     else:
         if build.sudo:
             logging.warning('%s:%s runs this on the buildserver with sudo:\n\t%s'
@@ -483,9 +489,6 @@ def build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, ext
 
         p = FDroidPopen(cmd, cwd=root_dir)
 
-    elif bmethod == 'kivy':
-        pass
-
     elif bmethod == 'buildozer':
         pass
 
@@ -513,8 +516,14 @@ def build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, ext
             # Even when running clean, gradle stores task/artifact caches in
             # .gradle/ as binary files. To avoid overcomplicating the scanner,
             # manually delete them, just like `gradle clean` should have removed
-            # the build/ dirs.
-            del_dirs(['build', '.gradle'])
+            # the build/* dirs.
+            del_dirs([os.path.join('build', 'android-profile'),
+                      os.path.join('build', 'generated'),
+                      os.path.join('build', 'intermediates'),
+                      os.path.join('build', 'outputs'),
+                      os.path.join('build', 'reports'),
+                      os.path.join('build', 'tmp'),
+                      '.gradle'])
             del_files(['gradlew', 'gradlew.bat'])
 
         if 'pom.xml' in files:
@@ -625,67 +634,6 @@ def build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, ext
 
         bindir = os.path.join(root_dir, 'target')
 
-    elif bmethod == 'kivy':
-        logging.info("Building Kivy project...")
-
-        spec = os.path.join(root_dir, 'buildozer.spec')
-        if not os.path.exists(spec):
-            raise BuildException("Expected to find buildozer-compatible spec at {0}"
-                                 .format(spec))
-
-        defaults = {'orientation': 'landscape', 'icon': '',
-                    'permissions': '', 'android.api': "18"}
-        bconfig = ConfigParser(defaults, allow_no_value=True)
-        bconfig.read(spec)
-
-        distdir = os.path.join('python-for-android', 'dist', 'fdroid')
-        if os.path.exists(distdir):
-            shutil.rmtree(distdir)
-
-        modules = bconfig.get('app', 'requirements').split(',')
-
-        cmd = 'ANDROIDSDK=' + config['sdk_path']
-        cmd += ' ANDROIDNDK=' + ndk_path
-        cmd += ' ANDROIDNDKVER=' + build.ndk
-        cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
-        cmd += ' VIRTUALENV=virtualenv'
-        cmd += ' ./distribute.sh'
-        cmd += ' -m ' + "'" + ' '.join(modules) + "'"
-        cmd += ' -d fdroid'
-        p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
-        if p.returncode != 0:
-            raise BuildException("Distribute build failed")
-
-        cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
-        if cid != app.id:
-            raise BuildException("Package ID mismatch between metadata and spec")
-
-        orientation = bconfig.get('app', 'orientation', 'landscape')
-        if orientation == 'all':
-            orientation = 'sensor'
-
-        cmd = ['./build.py'
-               '--dir', root_dir,
-               '--name', bconfig.get('app', 'title'),
-               '--package', app.id,
-               '--version', bconfig.get('app', 'version'),
-               '--orientation', orientation
-               ]
-
-        perms = bconfig.get('app', 'permissions')
-        for perm in perms.split(','):
-            cmd.extend(['--permission', perm])
-
-        if config.get('app', 'fullscreen') == 0:
-            cmd.append('--window')
-
-        icon = bconfig.get('app', 'icon.filename')
-        if icon:
-            cmd.extend(['--icon', os.path.join(root_dir, icon)])
-
-        cmd.append('release')
-        p = FDroidPopen(cmd, cwd=distdir)
-
     elif bmethod == 'buildozer':
         logging.info("Building Kivy project using buildozer...")
 
@@ -800,11 +748,6 @@ def build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, ext
             raise BuildException('Failed to find output')
         src = m.group(1)
         src = os.path.join(bindir, src) + '.apk'
-    elif omethod == 'kivy':
-        src = os.path.join('python-for-android', 'dist', 'default', 'bin',
-                           '{0}-{1}-release.apk'.format(
-                               bconfig.get('app', 'title'),
-                               bconfig.get('app', 'version')))
 
     elif omethod == 'buildozer':
         src = None
@@ -962,7 +905,7 @@ def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
     if server:
         # When using server mode, still keep a local cache of the repo, by
         # grabbing the source now.
-        vcs.gotorevision(build.commit)
+        vcs.gotorevision(build.commit, refresh)
 
         build_server(app, build, vcs, build_dir, output_dir, log_dir, force)
     else:
@@ -970,40 +913,12 @@ def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
     return True
 
 
-def get_android_tools_versions(ndk_path=None):
-    '''get a list of the versions of all installed Android SDK/NDK components'''
-
-    global config
-    sdk_path = config['sdk_path']
-    if sdk_path[-1] != '/':
-        sdk_path += '/'
-    components = []
-    if ndk_path:
-        ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
-        if os.path.isfile(ndk_release_txt):
-            with open(ndk_release_txt, 'r') as fp:
-                components.append((os.path.basename(ndk_path), fp.read()[:-1]))
-
-    pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
-    for root, dirs, files in os.walk(sdk_path):
-        if 'source.properties' in files:
-            source_properties = os.path.join(root, 'source.properties')
-            with open(source_properties, 'r') as fp:
-                m = pattern.search(fp.read())
-                if m:
-                    components.append((root[len(sdk_path):], m.group(1)))
-
-    return components
-
-
-def get_android_tools_version_log(ndk_path):
-    '''get a list of the versions of all installed Android SDK/NDK components'''
-    log = '== Installed Android Tools ==\n\n'
-    components = get_android_tools_versions(ndk_path)
-    for name, version in sorted(components):
-        log += '* ' + name + ' (' + version + ')\n'
-
-    return log
+def force_halt_build(timeout):
+    """Halt the currently running Vagrant VM, to be called from a Timer"""
+    logging.error(_('Force halting build after {0} sec timeout!').format(timeout))
+    timeout_event.set()
+    vm = vmtools.get_build_vm('builder')
+    vm.halt()
 
 
 def parse_commandline():
@@ -1020,7 +935,7 @@ def parse_commandline():
                         help=_("Test mode - put output in the tmp directory only, and always build, even if the output already exists."))
     parser.add_argument("--server", action="store_true", default=False,
                         help=_("Use build server"))
-    parser.add_argument("--resetserver", action="store_true", default=False,
+    parser.add_argument("--reset-server", action="store_true", default=False,
                         help=_("Reset and create a brand new build server, even if the existing one appears to be ok."))
     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
                         help=_("Specify that we're running on the build server"))
@@ -1055,11 +970,14 @@ def parse_commandline():
 options = None
 config = None
 buildserverid = None
+fdroidserverid = None
+start_timestamp = time.gmtime()
+timeout_event = threading.Event()
 
 
 def main():
 
-    global options, config, buildserverid
+    global options, config, buildserverid, fdroidserverid
 
     options, parser = parse_commandline()
 
@@ -1087,8 +1005,8 @@ def main():
 
     if config['build_server_always']:
         options.server = True
-    if options.resetserver and not options.server:
-        parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
+    if options.reset_server and not options.server:
+        parser.error("option %s: Using --reset-server without --server makes no sense" % "reset-server")
 
     log_dir = 'logs'
     if not os.path.isdir(log_dir):
@@ -1124,7 +1042,7 @@ def main():
 
     # Read all app and srclib metadata
     pkgs = common.read_pkg_args(options.appid, True)
-    allapps = metadata.read_metadata(not options.onserver, pkgs, sort_by_time=True)
+    allapps = metadata.read_metadata(not options.onserver, pkgs, options.refresh, sort_by_time=True)
     apps = common.read_app_args(options.appid, allapps, True)
 
     for appid, app in list(apps.items()):
@@ -1164,19 +1082,36 @@ def main():
     # Build applications...
     failed_apps = {}
     build_succeeded = []
-    max_apps_per_run = 10
+    # Only build for 12 hours, then stop gracefully
+    endtime = time.time() + 12 * 60 * 60
+    max_build_time_reached = False
     for appid, app in apps.items():
-        max_apps_per_run -= 1
-        if max_apps_per_run < 1:
-            break
 
         first = True
 
         for build in app.builds:
+            if time.time() > endtime:
+                max_build_time_reached = True
+                break
+
+            # Enable watchdog timer (2 hours by default).
+            if build.timeout is None:
+                timeout = 7200
+            else:
+                timeout = int(build.timeout)
+            if options.server and timeout > 0:
+                logging.debug(_('Setting {0} sec timeout for this build').format(timeout))
+                timer = threading.Timer(timeout, force_halt_build, [timeout])
+                timeout_event.clear()
+                timer.start()
+            else:
+                timer = None
+
             wikilog = None
+            build_starttime = common.get_wiki_timestamp()
             tools_version_log = ''
             if not options.onserver:
-                tools_version_log = get_android_tools_version_log(build.ndk_path())
+                tools_version_log = common.get_android_tools_version_log(build.ndk_path())
             try:
 
                 # For the first build of a particular app, we need to set up
@@ -1270,7 +1205,7 @@ def main():
                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
                             (build.versionCode, build.versionName, build.commit))
                     f.write('Build completed at '
-                            + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
+                            + common.get_wiki_timestamp() + '\n')
                     f.write('\n' + tools_version_log + '\n')
                     f.write(str(e))
                 logging.error("Could not build app %s: %s" % (appid, e))
@@ -1295,10 +1230,12 @@ def main():
                     newpage = site.Pages[lastbuildpage]
                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
                         fdroidserverid = fp.read().rstrip()
-                    txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
+                    txt = "* build session started at " + common.get_wiki_timestamp(start_timestamp) + '\n' \
+                          + "* this build started at " + build_starttime + '\n' \
+                          + "* this build completed at " + common.get_wiki_timestamp() + '\n' \
                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
-                    if options.onserver:
+                    if buildserverid:
                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
                                + buildserverid + ' ' + buildserverid + ']\n\n'
                     txt += tools_version_log + '\n\n'
@@ -1310,6 +1247,13 @@ def main():
                 except Exception as e:
                     logging.error("Error while attempting to publish build log: %s" % e)
 
+            if timer:
+                timer.cancel()  # kill the watchdog timer
+
+        if max_build_time_reached:
+            logging.info("Stopping after global build timeout...")
+            break
+
     for app in build_succeeded:
         logging.info("success: %s" % (app.id))
 
@@ -1327,7 +1271,7 @@ def main():
             for app in build_succeeded:
 
                 logging.info("Need to sign the app before we can install it.")
-                subprocess.call("fdroid publish {0}".format(app.id), shell=True)
+                subprocess.call("fdroid publish {0}".format(app.id))
 
                 apk_path = None
 
@@ -1362,6 +1306,35 @@ def main():
         logging.info(ngettext("{} build failed",
                               "{} builds failed", len(failed_apps)).format(len(failed_apps)))
 
+    if options.wiki:
+        wiki_page_path = 'build_' + time.strftime('%s', start_timestamp)
+        newpage = site.Pages[wiki_page_path]
+        txt = ''
+        txt += "* command line: <code>%s</code>\n" % ' '.join(sys.argv)
+        txt += "* started at %s\n" % common.get_wiki_timestamp(start_timestamp)
+        txt += "* completed at %s\n" % common.get_wiki_timestamp()
+        if buildserverid:
+            txt += ('* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/{id} {id}]\n'
+                    .format(id=buildserverid))
+        if fdroidserverid:
+            txt += ('* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/{id} {id}]\n'
+                    .format(id=fdroidserverid))
+        if os.cpu_count():
+            txt += "* host processors: %d\n" % os.cpu_count()
+        if os.path.isfile('/proc/meminfo') and os.access('/proc/meminfo', os.R_OK):
+            with open('/proc/meminfo') as fp:
+                for line in fp:
+                    m = re.search(r'MemTotal:\s*([0-9].*)', line)
+                    if m:
+                        txt += "* host RAM: %s\n" % m.group(1)
+                        break
+        txt += "* successful builds: %d\n" % len(build_succeeded)
+        txt += "* failed builds: %d\n" % len(failed_apps)
+        txt += "\n\n"
+        newpage.save(txt, summary='Run log')
+        newpage = site.Pages['build']
+        newpage.save('#REDIRECT [[' + wiki_page_path + ']]', summary='Update redirect')
+
     # hack to ensure this exits, even is some threads are still running
     sys.stdout.flush()
     sys.stderr.flush()