]> scripts.mit.edu Git - wizard.git/commitdiff
Implement web verification for pre-upgrade and post-upgrade.
authorEdward Z. Yang <ezyang@mit.edu>
Sat, 3 Oct 2009 05:18:38 +0000 (01:18 -0400)
committerEdward Z. Yang <ezyang@mit.edu>
Sat, 3 Oct 2009 05:21:19 +0000 (01:21 -0400)
* checkWeb and verifyWeb for Application and Deployment
* Fix bug if MediaWiki update.php doesn't return any output
* Return backup name from d.backup(), and print it from 'wizard backup'
* Ignore keyboard interrupts when running upgrades
* Created get_web_host_and_path() function in wizard.scripts
* Created fetch() in wizard.util

Signed-off-by: Edward Z. Yang <ezyang@mit.edu>
13 files changed:
TODO
doc/module/wizard.deploy.rst
doc/module/wizard.util.rst
tests/test-upgrade-mediawiki-fail.sh [new file with mode: 0755]
tests/test-upgrade-mediawiki-webfail-php [new file with mode: 0644]
tests/test-upgrade-mediawiki-webfail.sh [new file with mode: 0755]
wizard/app/mediawiki.py
wizard/command/backup.py
wizard/command/upgrade.py
wizard/deploy.py
wizard/install.py
wizard/scripts.py
wizard/util.py

diff --git a/TODO b/TODO
index 800c532a77eca17caca2a5a6a4117d9a80c36832..d1b0f521beb20faae8539ada2521ae49d5240c0a 100644 (file)
--- a/TODO
+++ b/TODO
@@ -2,7 +2,6 @@ The Git Autoinstaller
 
 TODO NOW:
 
-- Check for extensions
 - Implement "group" filtering using blanche for limited rollouts.
 
 - Remove "already migrated" cruft that will accumulate if we do small
index 053ce6b7891448d30c926c557c525f573c70de4d..f06ca5a4e6fbaf518dcc67ecbcaaef95499fe112 100644 (file)
@@ -51,3 +51,7 @@ Exceptions
     :members:
 .. autoexception:: VersionMismatchError
     :members:
+.. autoexception:: WebVerificationError
+    :members:
+.. autoexception:: UnknownWebPath
+    :members:
index 762c0b2c3d0923779921eebd4422a9810cc6d782..99dbd8ad0c973bbf36365ec3e7e81b1dba1d824f 100644 (file)
@@ -7,6 +7,7 @@ Classes
 -------
 .. autoclass:: ChangeDirectory
 .. autoclass:: PipeToLess
+.. autoclass:: IgnoreKeyboardInterrupts
 .. autoclass:: Counter
     :members:
 
@@ -27,6 +28,7 @@ Functions
 .. autofunction:: set_git_env
 .. autofunction:: get_git_footer
 .. autofunction:: safe_unlink
+.. autofunction:: fetch
 
 Exceptions
 ----------
diff --git a/tests/test-upgrade-mediawiki-fail.sh b/tests/test-upgrade-mediawiki-fail.sh
new file mode 100755 (executable)
index 0000000..fc6795a
--- /dev/null
@@ -0,0 +1,10 @@
+#!/bin/bash -e
+
+TESTNAME="upgrade_mediawiki_fail"
+source ./setup
+
+wizard install mediawiki-$VERSION-scripts "$TESTDIR" -- --title="TestApp"
+echo "FAILURE" > "$TESTDIR/maintenance/update.php"
+wizard upgrade "$TESTDIR" || true
+cd "$TESTDIR"
+git describe --tags
diff --git a/tests/test-upgrade-mediawiki-webfail-php b/tests/test-upgrade-mediawiki-webfail-php
new file mode 100644 (file)
index 0000000..5280618
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+file_put_contents(dirname(__FILE__) . '/../index.php', "BOOM");
+echo "Done.";
diff --git a/tests/test-upgrade-mediawiki-webfail.sh b/tests/test-upgrade-mediawiki-webfail.sh
new file mode 100755 (executable)
index 0000000..b03adbd
--- /dev/null
@@ -0,0 +1,10 @@
+#!/bin/bash -e
+
+TESTNAME="upgrade_mediawiki_webfail"
+source ./setup
+
+wizard install mediawiki-$VERSION-scripts "$TESTDIR" -- --title="TestApp"
+cp test-upgrade-mediawiki-webfail-php "$TESTDIR/maintenance/update.php"
+wizard upgrade "$TESTDIR" || true
+cd "$TESTDIR"
+git describe --tags
index 65a8704cb40afc474ed5dc4dcec1b321da303ef8..bfac9c7585cf4fb2ccc5dad7ce916ef623b0cbdf 100644 (file)
@@ -54,6 +54,9 @@ class Application(deploy.Application):
         match = regex.search(contents)
         if not match: return None
         return distutils.version.LooseVersion(match.group(2)[1:-1])
+    def checkWeb(self, d):
+        page = d.fetch("index.php?title=Special:Version")
+        return page.find("MediaWiki is free software") != -1
     def install(self, version, options):
         try:
             os.unlink("LocalSettings.php")
@@ -85,14 +88,19 @@ class Application(deploy.Application):
         sh = shell.Shell()
         if not os.path.isfile("AdminSettings.php"):
             sh.call("git", "checkout", "mediawiki-" + str(version), "--", "AdminSettings.php")
-        result = sh.eval("php", "maintenance/update.php", "--quick", log=True)
-        if not result.rstrip().split()[-1] == "Done.":
+        try:
+            result = sh.eval("php", "maintenance/update.php", "--quick", log=True)
+        except shell.CallError as e:
+            raise app.UpgradeFailure("Update script returned non-zero exit code\nSTDOUT: %s\nSTDERR: %s" % (e.stdout, e.stderr))
+        results = result.rstrip().split()
+        if not results or not results[-1] == "Done.":
             raise app.UpgradeFailure(result)
     def backup(self, deployment, options):
         sh = shell.Shell()
         # XXX: duplicate code, refactor, also, race condition
         backupdir = os.path.join(".scripts", "backups")
-        outdir = os.path.join(backupdir, str(deployment.version) + "-" + datetime.date.today().isoformat())
+        backup = str(deployment.version) + "-" + datetime.date.today().isoformat()
+        outdir = os.path.join(backupdir, backup)
         if not os.path.exists(backupdir):
             os.mkdir(backupdir)
         if os.path.exists(outdir):
@@ -104,6 +112,7 @@ class Application(deploy.Application):
             sh.call("gzip", "--best", outfile)
         except shell.CallError as e:
             raise app.BackupFailure(e.stderr)
+        return backup
     def restore(self, deployment, backup, options):
         sh = shell.Shell()
         backup_dir = os.path.join(".scripts", "backups", backup)
index 8362da787c5ce1cb0a6931c006fb3cdf38880358..7d334aed7f157827aee3b33f223738ee1ce2cd7f 100644 (file)
@@ -16,7 +16,7 @@ def main(argv, baton):
     d = deploy.Deployment(".")
     d.verify()
     d.verifyConfigured()
-    d.backup(options)
+    print d.backup(options)
 
 def parse_args(argv, baton):
     usage = """usage: %prog backup [ARGS] [DIR]
index 66e1d6bd48909a2f1bfcd77f96ab4735eff43a59..235059269d3ab356257902bf45137e74d20fa4bb 100644 (file)
@@ -8,7 +8,7 @@ import errno
 import tempfile
 import itertools
 
-from wizard import command, deploy, shell, util
+from wizard import app, command, deploy, shell, util
 
 def main(argv, baton):
     options, args = parse_args(argv, baton)
@@ -39,6 +39,8 @@ def main(argv, baton):
         d.verifyGit(options.srv_path)
         d.verifyConfigured()
         d.verifyVersion()
+        if not options.dry_run:
+            d.verifyWeb()
         repo = d.application.repository(options.srv_path)
         version = calculate_newest_version(sh, repo)
         if version == d.app_version.scripts_tag and not options.force:
@@ -74,20 +76,40 @@ def main(argv, baton):
         logging.info("Dry run, bailing.  See results at %s" % temp_wc_dir)
         return
     # perform database backup
-    d.backup(options)
+    backup = d.backup(options)
     # XXX: frob .htaccess to make site inaccessible
     # XXX: need locking
     # git merge (which performs a fast forward)
     #   - merge could fail (race); that's /really/ dangerous.
-    sh.call("git", "pull", temp_wc_dir, "master")
-    # run update script
-    version_obj = distutils.version.LooseVersion(version.partition('-')[2])
-    d.application.upgrade(version_obj, options)
+    with util.IgnoreKeyboardInterrupts():
+        sh.call("git", "pull", temp_wc_dir, "master")
+        version_obj = distutils.version.LooseVersion(version.partition('-')[2])
+        try:
+            # run update script
+            d.application.upgrade(version_obj, options)
+            d.verifyWeb()
+        except app.UpgradeFailure:
+            logging.warning("Upgrade failed: rolling back")
+            perform_restore(d, backup)
+            raise
+        except deploy.WebVerificationError:
+            logging.warning("Web verification failed: rolling back")
+            perform_restore(d, backup)
+            raise app.UpgradeFailure("Upgrade caused website to become inaccessible; site was rolled back")
     # XXX: frob .htaccess to make site accessible
     # XXX:  - check if .htaccess changed, first.  Upgrade
     #       process might have frobbed it.  Don't be
     #       particularly worried if the segment dissappeared
 
+def perform_restore(d, backup):
+    # You don't want d.restore() because it doesn't perform
+    # the file level backup
+    shell.Shell().call("wizard", "restore", backup)
+    try:
+        d.verifyWeb()
+    except deploy.WebVerificationError:
+        logging.critical("Web verification failed after rollback")
+
 def make_commit_message(version):
     message = "Upgraded autoinstall in %s to %s.\n\n%s" % (util.get_dir_owner(), version, util.get_git_footer())
     try:
index d5ebab3fbd84493a53556ec22c91ddd1a5b7926a..0676454a90f0296e4e450b8e0dc0b87f82f56ce8 100644 (file)
@@ -12,7 +12,7 @@ import tempfile
 import logging
 
 import wizard
-from wizard import git, old_log, shell, util
+from wizard import git, old_log, scripts, shell, util
 
 ## -- Global Functions --
 
@@ -112,7 +112,12 @@ class Deployment(object):
         with util.ChangeDirectory(self.location):
             return self.application.backup(self, options)
     def restore(self, backup, options):
-        """Restores a backup. Destroys state, so be careful!"""
+        """
+        Restores a backup. Destroys state, so be careful! Also, this does
+        NOT restore the file-level backup, which is what 'wizard restore'
+        does, so you probably do NOT want to call this elsewhere unless
+        you know what you're doing.
+        """
         with util.ChangeDirectory(self.location):
             return self.application.restore(self, backup, options)
     def prepareConfig(self):
@@ -200,6 +205,23 @@ class Deployment(object):
         elif not str(real) == self.app_version.pristine_tag.partition('-')[2]:
             raise VersionMismatchError(real, self.version)
 
+    def verifyWeb(self):
+        """
+        Checks if the autoinstall is viewable from the web.
+        """
+        if not self.application.checkWeb(self):
+            raise WebVerificationError
+
+    def fetch(self, path, post=None):
+        """
+        Performs a HTTP request on the website.
+        """
+        try:
+            host, basepath = scripts.get_web_host_and_path(self.location)
+        except ValueError:
+            raise UnknownWebPath
+        return util.fetch(host, basepath, path, post)
+
     @property
     def configured(self):
         """Whether or not an autoinstall has been configured/installed for use."""
@@ -387,6 +409,11 @@ class Application(object):
         Checks source files to determine the version manually.
         """
         return None
+    def checkWeb(self, deployment):
+        """
+        Checks if the autoinstall is viewable from the web.
+        """
+        raise NotImplemented
     @property
     def extractors(self):
         """
@@ -680,6 +707,27 @@ class VersionMismatchError(Error):
 ERROR: The detected version %s did not match the Git
 version %s.""" % (self.real_version, self.git_version)
 
+class WebVerificationError(Error):
+    """Could not access the application on the web"""
+
+    def __str__(self):
+        return """
+
+ERROR: We were not able to access the application on the
+web.  This may indicate that the website is behind
+authentication on the htaccess level."""
+
+class UnknownWebPath(Error):
+    """Could not determine application's web path."""
+    def __str__(self):
+        return """
+
+ERROR: We were not able to determine what the application's
+host and path were in order to perform a web request
+on the application.  You can specify this manually using
+the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment
+variables."""
+
 _application_list = [
     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
index 500680d0b806e530b78280f19c6ea7ec69934597..eab7ca4df951d1a4a2bc0e2e32a3391c663119fc 100644 (file)
@@ -75,17 +75,7 @@ def fetch(options, path, post=None):
     ``post`` is a dictionary to post.  ``options`` is the options
     object generated by :class:`OptionParser`.
     """
-    h = httplib.HTTPConnection(options.web_host)
-    fullpath = options.web_path + "/" + path
-    if post:
-        headers = {"Content-type": "application/x-www-form-urlencoded"}
-        h.request("POST", fullpath, urllib.urlencode(post), headers)
-    else:
-        h.request("GET", fullpath)
-    r = h.getresponse()
-    data = r.read()
-    h.close()
-    return data
+    return util.fetch(options.web_host, options.web_path, path, post)
 
 def attr_to_option(variable):
     """
@@ -124,14 +114,12 @@ class Strategy(object):
 
 class ScriptsWebStrategy(Strategy):
     """Performs scripts specific guesses for web variables."""
-    # XXX: THIS CODE SUCKS
     def execute(self, options):
         """Guesses web path by splitting on web_scripts."""
-        _, _, web_path = os.getcwd().partition("/web_scripts")
-        if not web_path:
+        tuple = scripts.get_web_host_and_path()
+        if not tuple:
             raise StrategyFailed
-        options.web_path = web_path
-        options.web_host = util.get_dir_owner() + ".scripts.mit.edu"
+        options.web_host, options.web_path = tuple
 
 class ScriptsMysqlStrategy(Strategy):
     """
index 9676b3482f8e57b3a0515aad69455c38a1ddb972..35b9ba942db0905aa5ab8e37ac03b9188cde4c97 100644 (file)
@@ -18,3 +18,21 @@ def get_sql_credentials():
     except CallError:
         return None
 
+def get_web_host_and_path(dir=None):
+    """
+    Attempts to determine webhost and path for the current directory
+    as it would be accessible from the web.  Works only for scripts
+    servers.  Returns a tuple web_host, web_path, or None if it failed.
+    """
+    # XXX: THIS CODE SUCKS
+    host = os.getenv("WIZARD_WEB_HOST")
+    path = os.getenv("WIZARD_WEB_PATH")
+    if host is not None and path is not None:
+        return (host, path)
+    if not dir:
+        dir = os.getcwd()
+    _, _, web_path = dir.partition("/web_scripts")
+    if not web_path:
+        return None
+    return (util.get_dir_owner(dir) + ".scripts.mit.edu", web_path)
+
index 93f2a34a49047a88e08e68d4740e5722a4d32ed0..621dea16d61a9d4b36292562c04c1b32d726c1d4 100644 (file)
@@ -14,6 +14,9 @@ import sys
 import socket
 import errno
 import itertools
+import signal
+import httplib
+import urllib
 
 import wizard
 
@@ -70,6 +73,16 @@ class PipeToLess(object):
             self.proc.wait()
             sys.stdout = self.old_stdout
 
+class IgnoreKeyboardInterrupts(object):
+    """
+    Context for temporarily ignoring keyboard interrupts.  Use this
+    if aborting would cause more harm than finishing the job.
+    """
+    def __enter__(self):
+        signal.signal(signal.SIGINT,signal.SIG_IGN)
+    def __exit__(self, *args):
+        signal.signal(signal.SIGINT, signal.default_int_handler)
+
 def chdir(dir):
     """
     Changes a directory, but has special exceptions for certain
@@ -258,6 +271,19 @@ def safe_unlink(file):
     os.rename(file, name)
     return name
 
+def fetch(host, path, subpath, post=None):
+    h = httplib.HTTPConnection(host)
+    fullpath = path + "/" + subpath
+    if post:
+        headers = {"Content-type": "application/x-www-form-urlencoded"}
+        h.request("POST", fullpath, urllib.urlencode(post), headers)
+    else:
+        h.request("GET", fullpath)
+    r = h.getresponse()
+    data = r.read()
+    h.close()
+    return data
+
 class NoOperatorInfo(wizard.Error):
     """No information could be found about the operator from Kerberos."""
     pass