]> scripts.mit.edu Git - wizard.git/blobdiff - wizard/app/__init__.py
Add human readable names to applications.
[wizard.git] / wizard / app / __init__.py
index 05fe281187c66fe5fde8b2b4af9989407569ff8f..b653f524074eb5a3038041f6baa8e9f1473be1b0 100644 (file)
@@ -6,6 +6,23 @@ You'll need to know how to overload the :class:`Application` class
 and use some of the functions in this module in order to specify
 new applications.
 
+To specify custom applications as plugins,  add the following ``entry_points``
+configuration::
+
+    [wizard.app]
+    yourappname = your.module:Application
+    otherappname = your.other.module:Application
+
+.. note::
+
+    Wizard will complain loudly if ``yourappname`` conflicts with an
+    application name defined by someone else.
+
+There are some submodules for programming languages that define common
+functions and data that may be used by applications in that language.  See:
+
+* :mod:`wizard.app.php`
+
 .. testsetup:: *
 
     import re
@@ -16,31 +33,57 @@ new applications.
 """
 
 import os.path
+import subprocess
 import re
 import distutils.version
 import decorator
 import shlex
 import logging
 import shutil
+import sqlalchemy
+import sqlalchemy.exc
+import string
+import urlparse
+import tempfile
+import pkg_resources
+import traceback
 
 import wizard
-from wizard import resolve, scripts, shell, util
-
-_application_list = [
-    "mediawiki", "wordpress", "joomla", "e107", "gallery2",
-    "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
-    # these are technically deprecated
-    "advancedpoll", "gallery",
-]
-_applications = None
+from wizard import plugin, resolve, shell, sql, util
 
+_applications = None
 def applications():
     """Hash table for looking up string application name to instance"""
     global _applications
     if not _applications:
-        _applications = dict([(n,Application.make(n)) for n in _application_list ])
+        _applications = dict()
+        for dist in pkg_resources.working_set:
+            for appname, entry in dist.get_entry_map("wizard.app").items():
+                if appname in _applications:
+                    newname = dist.key + ":" + appname
+                    if newname in _applications:
+                        raise Exception("Unrecoverable application name conflict for %s from %s", appname, dist.key)
+                    logging.warning("Could not overwrite %s, used %s instead", appname, newname)
+                    appname = newname
+                appclass = entry.load()
+                _applications[appname] = appclass(appname)
+        # setup dummy apps
+        for entry in pkg_resources.iter_entry_points("wizard.dummy_apps"):
+            appfun = entry.load()
+            dummy_apps = appfun()
+            for appname in dummy_apps:
+                # a dummy app that already exists is not a fatal error
+                if appname in _applications:
+                    continue
+                _applications[appname] = Application(appname)
     return _applications
 
+def getApplication(appname):
+    """Retrieves application instance given a name"""
+    try:
+        return applications()[appname]
+    except KeyError:
+        raise NoSuchApplication(appname)
 
 class Application(object):
     """
@@ -53,6 +96,8 @@ class Application(object):
     """
     #: String name of the application
     name = None
+    #: Human-readable name of the application
+    fullname = None
     #: Dictionary of version strings to :class:`ApplicationVersion`.
     #: See also :meth:`makeVersion`.
     versions = None
@@ -61,7 +106,12 @@ class Application(object):
     parametrized_files = []
     #: Keys that are used in older versions of the application, but
     #: not for the most recent version.
-    deprecated_keys = []
+    deprecated_keys = set()
+    #: Keys that we can simply generate random strings for if they're missing
+    random_keys = set()
+    #: Values that are not sufficiently random for a random key.  This can
+    #: include default values for a random configuration option,
+    random_blacklist = set()
     #: Dictionary of variable names to extractor functions.  These functions
     #: take a :class:`wizard.deploy.Deployment` as an argument and return the value of
     #: the variable, or ``None`` if it could not be found.
@@ -76,6 +126,17 @@ class Application(object):
     #: a conflict marker string and a result list.  See :mod:`wizard.resolve`
     #: for more information.
     resolutions = {}
+    #: Instance of :class:`wizard.install.ArgSchema` that defines the arguments
+    #: this application requires.
+    install_schema = None
+    #: Name of the database that this application uses, i.e. ``mysql`` or
+    #: ``postgres``.  If we end up supporting multiple databases for a single
+    #: application, there should also be a value for this in
+    #: :class:`wizard.deploy.Deployment`; the value here is merely the preferred
+    #: value.
+    database = None
+    #: Indicates whether or not a web stub is necessary.
+    needs_web_stub = False
     def __init__(self, name):
         self.name = name
         self.versions = {}
@@ -109,21 +170,111 @@ class Application(object):
         result = {}
         for k,extractor in self.extractors.items():
             result[k] = extractor(deployment)
+        # XXX: ugh... we have to do quoting
+        for k in self.random_keys:
+            if result[k] is None or result[k] in self.random_blacklist:
+                result[k] = "'%s'" % util.random_key()
         return result
+    def dsn(self, deployment):
+        """
+        Returns the deployment specific database URL.  Uses the override file
+        in :file:`.wizard` if it exists, and otherwise attempt to extract the
+        variables from the source files.
+
+        Under some cases, the database URL will contain only the database
+        property, and no other values.  This indicates that the actual DSN
+        should be determined from the environment.
+
+        This function might return ``None``.
+
+        .. note::
+
+            We are allowed to batch these two together, because the full precedence
+            chain for determining the database of an application combines these
+            two together.  If this was not the case, we would have to call
+            :meth:`databaseUrlFromOverride` and :meth:`databaseUrlFromExtract` manually.
+        """
+        url = self.dsnFromOverride(deployment)
+        if url:
+            return url
+        return self.dsnFromExtract(deployment)
+    def dsnFromOverride(self, deployment):
+        """
+        Extracts database URL from an explicit dsn override file.
+        """
+        try:
+            return sqlalchemy.engine.url.make_url(open(deployment.dsn_file).read().strip())
+        except IOError:
+            return None
+    def dsnFromExtract(self, deployment):
+        """
+        Extracts database URL from a deployment, and returns them as
+        a :class:`sqlalchemy.engine.url.URL`.  Returns ``None`` if we
+        can't figure it out: i.e. the conventional variables are not defined
+        for this application.
+        """
+        if not self.database:
+            return None
+        vars = self.extract(deployment)
+        names = ("WIZARD_DBSERVER", "WIZARD_DBUSER", "WIZARD_DBPASSWORD", "WIZARD_DBNAME")
+        host, user, password, database = (shlex.split(vars[x])[0] if vars[x] is not None else None for x in names)
+        # XXX: You'd have to put support for an explicit different database
+        # type here
+        return sqlalchemy.engine.url.URL(self.database, username=user, password=password, host=host, database=database)
+    def url(self, deployment):
+        """
+        Returns the deployment specific web URL.  Uses the override file
+        in :file:`.wizard` if it exists, and otherwise attempt to extract
+        the variables from the source files.
+
+        This function might return ``None``, which indicates we couldn't figure
+        it out.
+        """
+        url = self.urlFromOverride(deployment)
+        if url:
+            return url
+        return self.urlFromExtract(deployment)
+    def urlFromOverride(self, deployment):
+        """
+        Extracts URL from explicit url override file.
+        """
+        try:
+            return urlparse.urlparse(open(deployment.url_file).read().strip())
+        except IOError:
+            return None
+    def urlFromExtract(self, deployment):
+        """
+        Extracts URL from a deployment, and returns ``None`` if we can't
+        figure it out.  Default implementation is to fail; we might
+        do something clever with extractable variables in the future.
+        """
+        return None
     def parametrize(self, deployment, ref_deployment):
         """
         Takes a generic source checkout and parametrizes it according to the
         values of ``deployment``.  This function operates on the current
         working directory.  ``deployment`` should **not** be the same as the
-        current working directory.  Default implementation uses
-        :attr:`parametrized_files` and a simple search and replace on those
-        files.
+        current working directory.  See :meth:`parametrizeWithVars` for details
+        on the parametrization.
         """
+        # deployment is not used in this implementation, but note that
+        # we do have the invariant the current directory matches
+        # deployment's directory
         variables = ref_deployment.extract()
+        self.parametrizeWithVars(variables)
+    def parametrizeWithVars(self, variables):
+        """
+        Takes a generic source checkout and parametrizes it according to
+        the values of ``variables``.  Default implementation uses
+        :attr:`parametrized_files` and a simple search and replace on
+        those files.
+        """
         for file in self.parametrized_files:
+            logging.debug("Parametrizing file '%s'\n" % (file, ))
             try:
                 contents = open(file, "r").read()
             except IOError:
+                logging.debug("Failed to open file '%s'\n" % (file, ))
                 continue
             for key, value in variables.items():
                 if value is None: continue
@@ -138,9 +289,24 @@ class Application(object):
         default implementation uses :attr:`resolutions`.
         """
         resolved = True
-        sh = shell.Shell()
-        for status in sh.eval("git", "ls-files", "--unmerged").splitlines():
-            file = status.split()[-1]
+        files = set()
+        files = {}
+        for status in shell.eval("git", "ls-files", "--unmerged").splitlines():
+            mode, hash, role, name = status.split()
+            files.setdefault(name, set()).add(int(role))
+        for file, roles in files.items():
+            # some automatic resolutions
+            if 1 not in roles and 2 not in roles and 3 in roles:
+                # upstream added a file, but it conflicted for whatever reason
+                shell.call("git", "add", file)
+                continue
+            elif 1 in roles and 2 not in roles and 3 in roles:
+                # user deleted the file, but upstream changed it
+                shell.call("git", "rm", file)
+                continue
+            # manual resolutions
+            # XXX: this functionality is mostly subsumed by the rerere
+            # tricks we do
             if file in self.resolutions:
                 contents = open(file, "r").read()
                 for spec, result in self.resolutions[file]:
@@ -150,7 +316,7 @@ class Application(object):
                         logging.info("Did resolution with spec:\n" + spec)
                 open(file, "w").write(contents)
                 if not resolve.is_conflict(contents):
-                    sh.call("git", "add", file)
+                    shell.call("git", "add", file)
                 else:
                     resolved = False
             else:
@@ -175,7 +341,7 @@ class Application(object):
         """
         for key, subst in self.substitutions.items():
             subs = subst(deployment)
-            if not subs and key not in self.deprecated_keys:
+            if not subs and key not in self.deprecated_keys and key not in self.random_keys:
                 logging.warning("No substitutions for %s" % key)
     def install(self, version, options):
         """
@@ -185,7 +351,7 @@ class Application(object):
         take a :class:`wizard.deploy.Deployment` as a parameter.)  Subclasses should
         provide an implementation.
         """
-        raise NotImplemented
+        raise NotImplementedError
     def upgrade(self, deployment, version, options):
         """
         Run for 'wizard upgrade' to upgrade database schemas and other
@@ -193,7 +359,7 @@ class Application(object):
         upgraded.  This assumes that the current working directory is the
         deployment.  Subclasses should provide an implementation.
         """
-        raise NotImplemented
+        raise NotImplementedError
     def backup(self, deployment, outdir, options):
         """
         Run for 'wizard backup' and upgrades to backup database schemas
@@ -206,7 +372,7 @@ class Application(object):
             Static user files may not need to be backed up, since in
             many applications upgrades do not modify static files.
         """
-        raise NotImplemented
+        raise NotImplementedError
     def restore(self, deployment, backup_dir, options):
         """
         Run for 'wizard restore' and failed upgrades to restore database
@@ -214,21 +380,55 @@ class Application(object):
         that the current working directory is the deployment.  Subclasses
         should provide an implementation.
         """
-        raise NotImplemented
+        raise NotImplementedError
+    def remove(self, deployment, options):
+        """
+        Run for 'wizard remove' to delete all database and non-local
+        file data.  This assumes that the current working directory is
+        the deployment.  Subclasses should provide an implementation.
+        """
+        raise NotImplementedError
     def detectVersion(self, deployment):
         """
         Checks source files to determine the version manually.  This assumes
         that the current working directory is the deployment.  Subclasses
         should provide an implementation.
         """
-        raise NotImplemented
-    def checkWeb(self, deployment, output=None):
+        raise NotImplementedError
+    def detectVersionFromFile(self, filename, regex):
         """
-        Checks if the autoinstall is viewable from the web.  To get
-        the HTML source that was retrieved, pass a variable containing
-        an empty list to ``output``; it will be mutated to have its
-        first element be the output.  Subclasses should provide an
-        implementation.
+        Helper method that detects a version by using a regular expression
+        from a file.  The regexed value is passed through :mod:`shlex`.
+        This assumes that the current working directory is the deployment.
+        """
+        contents = open(filename).read()
+        match = regex.search(contents)
+        if not match: return None
+        return distutils.version.LooseVersion(shlex.split(match.group(2))[0])
+    # XXX: This signature doesn't really make too much sense...
+    def detectVersionFromGit(self, tagPattern, preStrip = ''):
+        """
+        Helper method that detects a version by using the most recent tag
+        in git that matches the specified pattern.
+        This assumes that the current working directory is the deployment.
+        """
+        sh = wizard.shell.Shell()
+        cmd = ['git', 'describe', '--tags', '--match', tagPattern, ]
+        tag = sh.call(*cmd, strip=True)
+        if tag and len(tag) > len(preStrip) and tag[:len(preStrip)] == preStrip:
+            tag = tag[len(preStrip):]
+        if not tag: return None
+        return distutils.version.LooseVersion(tag)
+    def download(self, version):
+        """
+        Returns a URL that can be used to download a tarball of ``version`` of
+        this application.
+        """
+        raise NotImplementedError
+    def checkWeb(self, deployment):
+        """
+        Checks if the autoinstall is viewable from the web.  Subclasses should
+        provide an implementation.
 
         .. note::
             Finding a reasonable heuristic that works across skinning
@@ -237,22 +437,61 @@ class Application(object):
             page does not contain the features you search for.  Try
             not to depend on pages that are not the main page.
         """
-        raise NotImplemented
+        raise NotImplementedError
+    def checkDatabase(self, deployment):
+        """
+        Checks if the database is accessible.
+        """
+        try:
+            sql.connect(deployment.dsn)
+            return True
+        except sqlalchemy.exc.DBAPIError:
+            return False
+    def checkWebPage(self, deployment, page, outputs=[], exclude=[]):
+        """
+        Checks if a given page of an autoinstall contains a particular string.
+        """
+        page = deployment.fetch(page)
+        for x in exclude:
+            if page.find(x) != -1:
+                logging.info("checkWebPage (failed due to %s):\n\n%s", x, page)
+                return False
+        votes = 0
+        for output in outputs:
+            votes += page.find(output) != -1
+        if votes > len(outputs) / 2:
+            logging.debug("checkWebPage (passed):\n\n" + page)
+            return True
+        else:
+            logging.info("checkWebPage (failed):\n\n" + page)
+            return False
     def checkConfig(self, deployment):
         """
         Checks whether or not an autoinstall has been configured/installed
         for use.  Assumes that the current working directory is the deployment.
         Subclasses should provide an implementation.
         """
-        raise NotImplemented
-    @staticmethod
-    def make(name):
-        """Makes an application, but uses the correct subtype if available."""
-        try:
-            __import__("wizard.app." + name)
-            return getattr(wizard.app, name).Application(name)
-        except ImportError:
-            return Application(name)
+        # XXX: Unfortunately, this doesn't quite work because we package
+        # bogus config files.  Maybe we should check a hash or
+        # something?
+        raise NotImplementedError
+    def researchFilter(self, filename, added, deleted):
+        """
+        Allows an application to selectively ignore certain diffstat signatures
+        during research; for example, configuration files will have a very
+        specific set of changes, so ignore them; certain installation files
+        may be removed, etc.  Return ``True`` if a diffstat signature should be
+        ignored,
+        """
+        return False
+    def researchVerbose(self, filename):
+        """
+        Allows an application to exclude certain dirty files from the output
+        report; usually this will just be parametrized files, since those are
+        guaranteed to have changes.  Return ``True`` if a file should only
+        be displayed in verbose mode.
+        """
+        return filename in self.parametrized_files
 
 class ApplicationVersion(object):
     """Represents an abstract notion of a version for an application, where
@@ -273,10 +512,11 @@ class ApplicationVersion(object):
         """
         return "%s-%s" % (self.application, self.version)
     @property
-    def scripts_tag(self):
+    def wizard_tag(self):
         """
         Returns the name of the Git tag for this version.
         """
+        # XXX: Scripts specific
         end = str(self.version).partition('-scripts')[2].partition('-')[0]
         return "%s-scripts%s" % (self.pristine_tag, end)
     @property
@@ -326,12 +566,9 @@ class ApplicationVersion(object):
         Makes/retrieves a singleton :class:`ApplicationVersion` from
         a``app`` and ``version`` string.
         """
-        try:
-            # defer to the application for version creation to enforce
-            # singletons
-            return applications()[app].makeVersion(version)
-        except KeyError:
-            raise NoSuchApplication(app)
+        # defer to the application for version creation to enforce
+        # singletons
+        return getApplication(app).makeVersion(version)
 
 def expand_re(val):
     """
@@ -352,7 +589,7 @@ def expand_re(val):
 
 def make_extractors(seed):
     """
-    Take a dictionary of ``key``s to ``(file, regex)`` tuples and convert them into
+    Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into
     extractor functions (which take a :class:`wizard.deploy.Deployment`
     and return the value of the second subpattern of ``regex`` when matched
     with the contents of ``file``).
@@ -361,7 +598,7 @@ def make_extractors(seed):
 
 def make_substitutions(seed):
     """
-    Take a dictionary of ``key``s to ``(file, regex)`` tuples and convert them into substitution
+    Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into substitution
     functions (which take a :class:`wizard.deploy.Deployment`, replace the second subpattern
     of ``regex`` with ``key`` in ``file``, and returns the number of substitutions made.)
     """
@@ -461,53 +698,24 @@ def filename_regex_substitution(key, files, regex):
         return subs
     return h
 
-# XXX: rename to show that it's mysql specific
-def backup_database(outdir, deployment):
+@decorator.decorator
+def throws_database_errors(f, self, *args, **kwargs):
     """
-    Generic database backup function for MySQL.  Assumes that ``WIZARD_DBNAME``
-    is extractable, and that :func:`wizard.scripts.get_sql_credentials`
-    works.
+    Decorator that takes database errors from :mod:`wizard.sql` and
+    converts them into application script failures from
+    :mod:`wizard.app`.  We can't throw application errors directly from
+    :mod:`wizard.sql` because that would result in a cyclic import;
+    also, it's cleaner to distinguish between a database error and an
+    application script failure.
     """
-    sh = shell.Shell()
-    outfile = os.path.join(outdir, "db.sql")
     try:
-        sh.call("mysqldump", "--compress", "-r", outfile, *get_mysql_args(deployment))
-        sh.call("gzip", "--best", outfile)
-    except shell.CallError as e:
-        shutil.rmtree(outdir)
-        raise BackupFailure(e.stderr)
-
-def restore_database(backup_dir, deployment):
-    """
-    Generic database restoration function for MySQL.  See :func:`backup_database`
-    for the assumptions that we make.
-    """
-    sh = shell.Shell()
-    if not os.path.exists(backup_dir):
-        raise RestoreFailure("Backup %s doesn't exist", backup_dir.rpartition("/")[2])
-    sql = open(os.path.join(backup_dir, "db.sql"), 'w+')
-    sh.call("gunzip", "-c", os.path.join(backup_dir, "db.sql.gz"), stdout=sql)
-    sql.seek(0)
-    sh.call("mysql", *get_mysql_args(deployment), stdin=sql)
-    sql.close()
-
-def get_mysql_args(d):
-    """
-    Extracts arguments that would be passed to the command line mysql utility
-    from a deployment.
-    """
-    # XXX: add support for getting these out of options
-    vars = d.extract()
-    if 'WIZARD_DBNAME' not in vars:
-        raise app.BackupFailure("Could not determine database name")
-    triplet = scripts.get_sql_credentials(vars)
-    args = []
-    if triplet is not None:
-        server, user, password = triplet
-        args += ["-h", server, "-u", user, "-p" + password]
-    name = shlex.split(vars['WIZARD_DBNAME'])[0]
-    args.append(name)
-    return args
+        return f(self, *args, **kwargs)
+    except sql.BackupDatabaseError:
+        raise BackupFailure(traceback.format_exc())
+    except sql.RestoreDatabaseError:
+        raise RestoreFailure(traceback.format_exc())
+    except sql.RemoveDatabaseError:
+        raise RemoveFailure(traceback.format_exc())
 
 class Error(wizard.Error):
     """Generic error class for this module."""
@@ -536,6 +744,8 @@ class DeploymentParseError(Error):
     location = None
     def __init__(self, value):
         self.value = value
+    def __str__(self):
+        return "Could not parse '%s' from versions store in '%s'" % (self.value, self.location)
 
 class NoSuchApplication(Error):
     """
@@ -549,8 +759,45 @@ class NoSuchApplication(Error):
     location = None
     def __init__(self, app):
         self.app = app
+    def __str__(self):
+        return "Wizard doesn't know about an application named '%s'." % self.app
+
+class Failure(Error):
+    """
+    Represents a failure when performing some double-dispatched operation
+    such as an installation or an upgrade.  Failure classes are postfixed
+    with Failure, not Error.
+    """
+    pass
+
+class InstallFailure(Error):
+    """Installation failed for unknown reason."""
+    def __str__(self):
+        return """
 
-class UpgradeFailure(Error):
+ERROR: Installation failed for unknown reason.  You can
+retry the installation by appending --retry to the installation
+command."""
+
+class RecoverableInstallFailure(InstallFailure):
+    """
+    Installation failed, but we were able to determine what the
+    error was, and should give the user a second chance if we were
+    running interactively.
+    """
+    #: List of the errors that were found.
+    errors = None
+    def __init__(self, errors):
+        self.errors = errors
+    def __str__(self):
+        return """
+
+ERROR: Installation failed due to the following errors:  %s
+
+You can retry the installation by appending --retry to the
+installation command.""" % ", ".join(self.errors)
+
+class UpgradeFailure(Failure):
     """Upgrade script failed."""
     #: String details of failure (possibly stdout or stderr output)
     details = None
@@ -563,21 +810,29 @@ ERROR: Upgrade script failed, details:
 
 %s""" % self.details
 
-class UpgradeVerificationFailure(Error):
+class UpgradeVerificationFailure(Failure):
     """Upgrade script passed, but website wasn't accessible afterwards"""
-    #: String details of failure (possibly stdout or stderr output)
+    def __str__(self):
+        return """
+
+ERROR: Upgrade script passed, but website wasn't accessible afterwards.  Check
+the debug logs for the contents of the page."""
+
+class BackupFailure(Failure):
+    """Backup script failed."""
+    #: String details of failure
     details = None
     def __init__(self, details):
         self.details = details
     def __str__(self):
         return """
 
-ERROR: Upgrade script passed, but website wasn't accessible afterwards. Details:
+ERROR: Backup script failed, details:
 
 %s""" % self.details
 
-class BackupFailure(Error):
-    """Backup script failed."""
+class RestoreFailure(Failure):
+    """Restore script failed."""
     #: String details of failure
     details = None
     def __init__(self, details):
@@ -585,12 +840,12 @@ class BackupFailure(Error):
     def __str__(self):
         return """
 
-ERROR: Backup script failed, details:
+ERROR: Restore script failed, details:
 
 %s""" % self.details
 
-class RestoreFailure(Error):
-    """Restore script failed."""
+class RemoveFailure(Failure):
+    """Remove script failed."""
     #: String details of failure
     details = None
     def __init__(self, details):
@@ -598,6 +853,6 @@ class RestoreFailure(Error):
     def __str__(self):
         return """
 
-ERROR: Restore script failed, details:
+ERROR: Remove script failed, details:
 
 %s""" % self.details