TODO NOW:
-- Check for extensions
- Implement "group" filtering using blanche for limited rollouts.
- Remove "already migrated" cruft that will accumulate if we do small
:members:
.. autoexception:: VersionMismatchError
:members:
+.. autoexception:: WebVerificationError
+ :members:
+.. autoexception:: UnknownWebPath
+ :members:
-------
.. autoclass:: ChangeDirectory
.. autoclass:: PipeToLess
+.. autoclass:: IgnoreKeyboardInterrupts
.. autoclass:: Counter
:members:
.. autofunction:: set_git_env
.. autofunction:: get_git_footer
.. autofunction:: safe_unlink
+.. autofunction:: fetch
Exceptions
----------
--- /dev/null
+#!/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
--- /dev/null
+<?php
+file_put_contents(dirname(__FILE__) . '/../index.php', "BOOM");
+echo "Done.";
--- /dev/null
+#!/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
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")
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):
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)
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]
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)
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:
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:
import logging
import wizard
-from wizard import git, old_log, shell, util
+from wizard import git, old_log, scripts, shell, util
## -- Global Functions --
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):
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."""
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):
"""
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",
``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):
"""
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):
"""
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)
+
import socket
import errno
import itertools
+import signal
+import httplib
+import urllib
import wizard
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
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