]> scripts.mit.edu Git - wizard.git/commitdiff
Revamp database infrastructure.
authorEdward Z. Yang <ezyang@mit.edu>
Mon, 7 Dec 2009 06:34:24 +0000 (01:34 -0500)
committerEdward Z. Yang <ezyang@mit.edu>
Mon, 7 Dec 2009 08:02:12 +0000 (03:02 -0500)
* We now use DSN URLs using the sqlalchemy.engine.url.URL
  class to pass these values around.  Parameters are now
  database agnostic; applications are tied to specific databases.
* Remove need for WIZARD_MYSQL_DB in test scripts
* Implement `wizard database` and `wizard remove`
* Prevent database exhaustion
* Added dsn and dsn_file property to deploy.Deployment
* Make the remove/backup/restore scripts agnostic
* Added database property to app.Application
* Added callback support to ArgSchema
* Golfed the Scripts specific code into wizard.sql

Signed-off-by: Edward Z. Yang <ezyang@mit.edu>
16 files changed:
TODO
doc/conf.py
doc/create.rst
doc/testing.rst
tests/mediawiki-backup-restore-test.sh
tests/setup
wizard/app/__init__.py
wizard/app/mediawiki.py
wizard/app/wordpress.py
wizard/command/database.py [new file with mode: 0644]
wizard/command/install.py
wizard/command/remove.py [new file with mode: 0644]
wizard/deploy.py
wizard/install/__init__.py
wizard/scripts.py
wizard/sql.py

diff --git a/TODO b/TODO
index a4e6562c80e1be8f4c6cbd8220e778edaa6c2468..2162d0fefa1995231d080f4ef7a9710e08999831 100644 (file)
--- a/TODO
+++ b/TODO
@@ -2,22 +2,25 @@ The Git Autoinstaller
 
 TODO NOW:
 
+- ImportError is too broad a catch for make() -- see patch from afarrell
+
 - The calling web code invocations are a mess, with stubs living
   in the install, deploy modules and the real deal living in util.  Furthermore,
   we use the scripts-specific heuristic to determine where the app
   lives, and the only reason my test scripts work is because they
   get manually fed the domain and path by my environment variables.
 
-  We will record the URL used for the initial installation, and save it in
-  .scripts/url.  If autodetection in either direction is
-  available, we verify this value against the actual file path the installation
-  lives in (for the scripts case, we can do a file-level comparison because we
-  know the web root of any given file).  If they mismatch, we error out
-  and have someone manually resolve the problem.  If autodetection is not
-  available, we use the saved .scripts/url for operations.
+  Use system similar to database, with option for explicit override,
+  but otherwise attempting to determine from ambient code
+
+- Setting PATH and WIZARD_SRV_PATH from a test script: mention in docs
+- If you try to do an install on scripts w/o sql, it will sign you up but fail to write
+  the sql.cnf file. This sucks.
 
-- wizard install wordpress should ask for password
-- Test code should auto-nuke the database using `wizard remove` before doing a new install
+- wizard install wordpress should ask for password.  One problem with this is that
+  Wordpress will still send mail with the wrong username and password, so Wordpress
+  will need to be patched to not do that.  Alternatively we can initally set the admin
+  email to a null address and then fix it manually.
 - git diff :1:$file :2:$file to find out what the user did, or is it :3:?
 - php.ini needs to get substituted!
 - --raw parameter for install which means an arbitrary commit can be installed
index b6d57fa847fdf07efcb934841e2c8bc81c55851a..ff0a50206bc104306728668183a484b117e554ef 100644 (file)
@@ -208,6 +208,9 @@ latex_documents = [
 # If false, no module index is generated.
 #latex_use_modindex = True
 
-intersphinx_mapping = {'http://docs.python.org/dev': None}
+intersphinx_mapping = {
+    'http://docs.python.org/dev': None,
+    'http://www.sqlalchemy.org/docs/05/index.html': None,
+    }
 
 todo_include_todos = True
index fc554966dbcf4c8cfa27e3b6450227f83aae18de..38d40edda79f42c882e0e17582cea1e0abac4119 100644 (file)
@@ -250,6 +250,11 @@ be able to get away with pre-canned attributes.  You can access
 these arguments inside :meth:`~wizard.app.Application.install` via
 the ``options`` value.
 
+In particular, ``options.dsn`` is a :class:`sqlalchemy.engine.url.URL`
+which contains member variables such as :meth:`~sqlalchemy.engine.url.URL.username`,
+:meth:`~sqlalchemy.engine.url.URL.password`, :meth:`~sqlalchemy.engine.url.URL.host` and
+:meth:`~sqlalchemy.engine.url.URL.database` which you can use to pass in POST.
+
 Some tips and tricks for writing :meth:`wizard.app.Application.install`:
 
     * Some configuration file generators will get unhappy if the
@@ -266,7 +271,7 @@ Some tips and tricks for writing :meth:`wizard.app.Application.install`:
     * You should log any web page output using :func:`logging.debug`.
 
     * If you need to manually manipulate the database afterwards, you
-      can use :func:`wizard.sql.mysql_connect` (passing it ``options``)
+      can use :func:`wizard.sql.connect` (passing it ``options.dsn``)
       to get a `SQLAlchemy metadata object
       <http://www.sqlalchemy.org/docs/05/sqlexpression.html>`_, which can
       consequently be queried.  For convenience, we've bound metadata
@@ -298,6 +303,17 @@ verbose debugging information by using::
 
     env WIZARD_DEBUG=1 ./test-install-wordpress.sh
 
+The test scripts will try to conserve databases by running ``wizard remove`` on the
+old directory, but this requires :meth:`~wizard.app.remove` be implemented.
+Most of the time (namely, for single database setups), this simple template will suffice:
+
+.. code-block:: python
+
+    class Application(app.Application):
+        # ...
+        def remove(self, deployment)
+            app.remove_database(deployment)
+
 Versioning config
 -----------------
 
index 31b4265d3ef14d2c7ea65a341b1a02535943ea27..cabe3a989523de28dfaef6b85ec0edbd4eb9eb24 100644 (file)
@@ -41,28 +41,29 @@ environment variables that Wizard will use during installation.
 
 Here is a sample file::
 
+    MYSQL_ARGS="-uroot -ppassword"
+
     export WIZARD_WEB_HOST="localhost"
     export WIZARD_WEB_PATH="/wizard/tests/$TESTDIR"
-    export WIZARD_MYSQL_HOST="localhost"
-    export WIZARD_MYSQL_USER="root"
-    export WIZARD_MYSQL_PASSWORD="password"
-    export WIZARD_MYSQL_DB="wizard_test_$TESTID"
+    export WIZARD_DSN="mysql://root:password@localhost/wizard_test_$TESTID"
     export WIZARD_EMAIL="bob@example.com"
 
-You will need to specify all of these environment variables.  :envvar:`WIZARD_WEB_HOST`
-and :envvar:`WIZARD_WEB_PATH`  indicate Wizard's configuration with respect to
-your web server.  ``http://$WIZARD_WEB_HOST/$WIZARD_WEB_PATH`` will be the location
-that a newly installed application will be accessible.  You will notice that
-we used :envvar:`TESTDIR`; this is the directory that will be created in
-:file:`tests` for the application. :envvar:`WIZARD_MYSQL_HOST`,
-:envvar:`WIZARD_MYSQL_USER`, :envvar:`WIZARD_MYSQL_PASSWORD` and :envvar:`WIZARD_MYSQL_DB`
-are standard configuration variables for accessing a local MySQL database.  You can use
-:envvar:`TESTID` to uniquely identify any particular test.  Finally, :envvar:`WIZARD_EMAIL`
-is any email address you own that will be configured as an administrative email.
+You will need to specify all of these environment variables.  Those prefixed
+with ``WIZARD`` are directly used by Wizard, while the ``MYSQL`` environment
+variables are used if a test script wants to interactive directly with a
+MYSQL database.  We don't quite have a good story for alternative databases
+in test scripts.
 
-It may be useful to include a little bit of code to handle dropping and creating
-databases.  Here is a sample::
+* :envvar:`WIZARD_WEB_HOST` and :envvar:`WIZARD_WEB_PATH`  indicate Wizard's
+  configuration with respect to your web server.
+  ``http://$WIZARD_WEB_HOST/$WIZARD_WEB_PATH`` will be the location that a newly
+  installed application will be accessible.  You will notice that we used
+  :envvar:`TESTDIR`; this is the directory that will be created in :file:`tests`
+  for the application.
+* :envvar:`WIZARD_DSN` is the database source name, which
+  should be used to access a MySQL database (as Wizard does not contain support
+  for any other database system yet.) You can use :envvar:`TESTID` to uniquely
+  identify any particular test.
+* :envvar:`WIZARD_EMAIL` is any email address you
+  own that will be configured as an administrative email.
 
-    MYSQL_ARGS="-uroot -prootpassword"
-    mysql $MYSQL_ARGS -e "DROP DATABASE \`$WIZARD_MYSQL_DB\`;" || true
-    mysql $MYSQL_ARGS -e "CREATE DATABASE \`$WIZARD_MYSQL_DB\`;"
index bb7b34515d3acf41a9e28c3f915b4296e8810978..41da2352f3895319755cc5c833cd120719d9e8b9 100755 (executable)
@@ -13,8 +13,8 @@ mv "$FROB" "$FROB.bak"
 echo "BOOM" > "$FROB"
 
 # destroy the database
-mysql $MYSQL_ARGS -e "DROP DATABASE \`$WIZARD_MYSQL_DB\`;"
-mysql $MYSQL_ARGS -e "CREATE DATABASE \`$WIZARD_MYSQL_DB\`;"
+mysql $MYSQL_ARGS -e "DROP DATABASE \``wizard database .`\`;"
+mysql $MYSQL_ARGS -e "CREATE DATABASE \``wizard database .`\`;"
 
 BACKUP=`wizard restore | head -n1`
 wizard restore "$BACKUP"
index bac28b43c24be0ad39af0fc8fbc43bdd2ef1a741..9e3d535f4c06b78d0e2e5bf979e7b6718a571524 100644 (file)
@@ -31,6 +31,6 @@ fi
 
 if [ -e "$TESTDIR" ]; then
     echo "Removing previous $TESTDIR folder..."
-    rm -Rf "$TESTDIR"
+    wizard remove "$TESTDIR" || rm -Rf "$TESTDIR"
 fi
 
index 65f5ecd6911d03d7b1ef1df5983cf2d9a215568c..28c7d8fd0d1d84c4794c97a973d420a34e1b87c9 100644 (file)
@@ -27,6 +27,7 @@ import decorator
 import shlex
 import logging
 import shutil
+import sqlalchemy
 
 import wizard
 from wizard import resolve, scripts, shell, util
@@ -84,6 +85,12 @@ class Application(object):
     #: 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
     def __init__(self, name):
         self.name = name
         self.versions = {}
@@ -118,6 +125,52 @@ class Application(object):
         for k,extractor in self.extractors.items():
             result[k] = extractor(deployment)
         return result
+    def dsn(self, deployment):
+        """
+        Returns the deployment specific database URL.  Uses the override file
+        in :file:`.scripts` 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 parametrize(self, deployment, ref_deployment):
         """
         Takes a generic source checkout and parametrizes it according to the
@@ -223,6 +276,13 @@ class Application(object):
         should provide an implementation.
         """
         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
@@ -496,17 +556,21 @@ 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):
     """
-    Generic database backup function for MySQL.  Assumes that ``WIZARD_DBNAME``
-    is extractable, and that :func:`wizard.scripts.get_sql_credentials`
-    works.
+    Generic database backup function for MySQL.
     """
+    # XXX: Change this once deployments support multiple dbs
+    if deployment.application.database == "mysql":
+        return backup_mysql_database(outdir, deployment)
+    else:
+        raise NotImplementedError
+
+def backup_mysql_database(outdir, deployment):
     sh = shell.Shell()
     outfile = os.path.join(outdir, "db.sql")
     try:
-        sh.call("mysqldump", "--compress", "-r", outfile, *get_mysql_args(deployment))
+        sh.call("mysqldump", "--compress", "-r", outfile, *get_mysql_args(deployment.dsn))
         sh.call("gzip", "--best", outfile)
     except shell.CallError as e:
         shutil.rmtree(outdir)
@@ -514,34 +578,44 @@ def backup_database(outdir, deployment):
 
 def restore_database(backup_dir, deployment):
     """
-    Generic database restoration function for MySQL.  See :func:`backup_database`
-    for the assumptions that we make.
+    Generic database restoration function for MySQL.
     """
+    # XXX: see backup_database
+    if deployment.application.database == "mysql":
+        return restore_mysql_database(backup_dir, deployment)
+    else:
+        raise NotImplementedError
+
+def restore_mysql_database(backup_dir, deployment):
     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)
+    sh.call("mysql", *get_mysql_args(deployment.dsn), stdin=sql)
     sql.close()
 
-def get_mysql_args(d):
+def remove_database(deployment):
+    """
+    Generic database removal function.
+    """
+    engine = sqlalchemy.create_engine(deployment.dsn)
+    engine.execute("DROP DATABASE `%s`" % deployment.dsn.database)
+
+def get_mysql_args(dsn):
     """
     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 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)
+    if dsn.host:
+        args += ["-h", dsn.host]
+    if dsn.username:
+        args += ["-u", dsn.username]
+    if dsn.password:
+        args += ["-p" + dsn.password]
+    args += [dsn.database]
     return args
 
 class Error(wizard.Error):
@@ -666,3 +740,16 @@ class RestoreFailure(Failure):
 ERROR: Restore script failed, details:
 
 %s""" % self.details
+
+class RemoveFailure(Failure):
+    """Remove script failed."""
+    #: String details of failure
+    details = None
+    def __init__(self, details):
+        self.details = details
+    def __str__(self):
+        return """
+
+ERROR: Remove script failed, details:
+
+%s""" % self.details
index adfdd42205d764a611093a29411270a6894ffdc9..b676b9fac457069b973b5b5c3e22457b3270d185 100644 (file)
@@ -25,13 +25,14 @@ seed = util.dictmap(make_filename_regex, {
         })
 
 class Application(app.Application):
+    database = "mysql"
     parametrized_files = ['LocalSettings.php'] + php.parametrized_files
     deprecated_keys = set(['WIZARD_IP']) | php.deprecated_keys
     extractors = app.make_extractors(seed)
     extractors.update(php.extractors)
     substitutions = app.make_substitutions(seed)
     substitutions.update(php.substitutions)
-    install_schema = install.ArgSchema("mysql", "admin", "email", "title")
+    install_schema = install.ArgSchema("db", "admin", "email", "title")
     def checkConfig(self, deployment):
         return os.path.isfile("LocalSettings.php")
     def detectVersion(self, deployment):
@@ -48,11 +49,11 @@ class Application(app.Application):
             'Sitename': options.title,
             'EmergencyContact': options.email,
             'LanguageCode': 'en',
-            'DBserver': options.mysql_host,
-            'DBname': options.mysql_db,
-            'DBuser': options.mysql_user,
-            'DBpassword': options.mysql_password,
-            'DBpassword2': options.mysql_password,
+            'DBserver': options.dsn.host,
+            'DBname': options.dsn.database,
+            'DBuser': options.dsn.username,
+            'DBpassword': options.dsn.password,
+            'DBpassword2': options.dsn.password,
             'defaultEmail': options.email,
             'SysopName': options.admin_name,
             'SysopPass': options.admin_password,
@@ -84,6 +85,8 @@ class Application(app.Application):
         app.backup_database(backup_dir, deployment)
     def restore(self, deployment, backup_dir, options):
         app.restore_database(backup_dir, deployment)
+    def remove(self, deployment, options):
+        app.remove_database(deployment)
 
 Application.resolutions = {
 'LocalSettings.php': [
index 742881b93a857d5846b690963a08eced1ca1ddd4..883f5de2177fa4301508e5ee0a07ddeadde8d61f 100644 (file)
@@ -24,12 +24,13 @@ seed = util.dictmap(make_filename_regex_define, {
     })
 
 class Application(app.Application):
+    database = "mysql"
     parametrized_files = ['wp-config.php'] + php.parametrized_files
     extractors = app.make_extractors(seed)
     extractors.update(php.extractors)
     substitutions = app.make_substitutions(seed)
     substitutions.update(php.substitutions)
-    install_schema = install.ArgSchema("mysql", "email", "title")
+    install_schema = install.ArgSchema("db", "email", "title")
     deprecated_keys = set(['WIZARD_SECRETKEY'])
     def download(self, version):
         return "http://wordpress.org/wordpress-%s.tar.gz" % version
@@ -49,10 +50,10 @@ class Application(app.Application):
         util.soft_unlink("wp-config.php")
 
         post_setup_config = {
-                'dbhost': options.mysql_host,
-                'uname': options.mysql_user,
-                'dbname': options.mysql_db,
-                'pwd': options.mysql_password,
+                'dbhost': options.dsn.host,
+                'uname': options.dsn.username,
+                'dbname': options.dsn.database,
+                'pwd': options.dsn.password,
                 'prefix': '',
                 'submit': 'Submit',
                 'step': '2',
@@ -74,7 +75,7 @@ class Application(app.Application):
             raise app.InstallFailure()
 
         # not sure what to do about this
-        meta = sql.mysql_connect(options)
+        meta = sql.connect(options.dsn)
         wp_options = meta.tables["wp_options"]
         wp_options.update().where(wp_options.c.option_name == 'siteurl').values(option_value=options.web_path).execute()
         wp_options.update().where(wp_options.c.option_name == 'home').values(option_value="http://%s%s" % (options.web_host, options.web_path)).execute() # XXX: what if missing leading slash; this should be put in a function
@@ -87,3 +88,5 @@ class Application(app.Application):
         app.backup_database(backup_dir, deployment)
     def restore(self, deployment, backup_dir, options):
         app.restore_database(backup_dir, deployment)
+    def remove(self, deployment, options):
+        app.remove_database(deployment)
diff --git a/wizard/command/database.py b/wizard/command/database.py
new file mode 100644 (file)
index 0000000..ee59321
--- /dev/null
@@ -0,0 +1,20 @@
+from wizard import deploy, command
+
+def main(argv, baton):
+    options, args = parse_args(argv, baton)
+    dir = args[0]
+    deployment = deploy.ProductionCopy(dir)
+    print deployment.dsn.database
+
+def parse_args(argv, baton):
+    usage = """usage: %prog database DIR
+
+Prints the name of the database an application is using.
+Maybe in the future this will print more information."""
+    parser = command.WizardOptionParser(usage)
+    options, args = parser.parse_all(argv)
+    if len(args) > 1:
+        parser.error("too many arguments")
+    if len(args) == 0:
+        parser.error("must specify directory")
+    return options, args
index 4600c8f36e651e538509922ac3edce4a92c35edc..5999c27ffa5661b200562ceb011548b3662275c2 100644 (file)
@@ -22,7 +22,7 @@ def main(argv, baton):
 
     # get configuration
     schema = application.install_schema
-    schema.commit(dir)
+    schema.commit(application, dir)
     options = None
     opthandler = installopt.Controller(dir, schema)
     parser = command.WizardOptionParser("""usage: %%prog install %s DIR [ -- SETUPARGS ]
diff --git a/wizard/command/remove.py b/wizard/command/remove.py
new file mode 100644 (file)
index 0000000..fc75e35
--- /dev/null
@@ -0,0 +1,27 @@
+import shutil
+
+from wizard import command, deploy, shell
+
+def main(argv, baton):
+    options, args = parse_args(argv, baton)
+    dir = args[0]
+    shell.drop_priviledges(dir, options.log_file)
+    deployment = deploy.ProductionCopy(dir)
+    deployment.verify()
+    deployment.remove(options)
+    shutil.rmtree(dir)
+
+def parse_args(argv, baton):
+    usage = """usage: %prog remove DIR
+
+Removes an autoinstall directory, deleting any databases along
+with it.  Will refuse to remove a non-autoinstall directory.  Be
+careful: this will also destroy all backups!"""
+    parser = command.WizardOptionParser(usage)
+    options, args = parser.parse_all(argv)
+    if len(args) > 1:
+        parser.error("too many arguments")
+    if len(args) == 0:
+        parser.error("must specify directory")
+    return options, args
+
index 421ba360e2551cf511e34d005ef31d3b2c462411..cadf63341990604a1524dca4dd190db74d543e4f 100644 (file)
@@ -11,7 +11,7 @@ import decorator
 import datetime
 
 import wizard
-from wizard import app, git, old_log, scripts, shell, util
+from wizard import app, git, old_log, scripts, shell, sql, util
 
 ## -- Global Functions --
 
@@ -94,6 +94,7 @@ class Deployment(object):
         # some cache variables
         self._read_cache = {}
         self._old_log = None
+        self._dsn = None
     def invalidateCache(self):
         """
         Invalidates all cached variables.  This currently applies to
@@ -224,6 +225,10 @@ class Deployment(object):
         """The absolute path of the ``.scripts/version`` file."""
         return os.path.join(self.scripts_dir, 'version')
     @property
+    def dsn_file(self):
+        """The absolute path of the :file:`.scripts/dsn` override file."""
+        return os.path.join(self.scripts_dir, 'dsn')
+    @property
     def application(self):
         """The :class:`app.Application` of this deployment."""
         return self.app_version.application
@@ -263,6 +268,11 @@ class Deployment(object):
             appname = shell.Shell().eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
             self._app_version = app.ApplicationVersion.make(appname, "unknown")
         return self._app_version
+    @property
+    def dsn(self):
+        if not self._dsn:
+            self._dsn = sql.fill_url(self.application.dsn(self))
+        return self._dsn
     @staticmethod
     def parse(line):
         """
@@ -322,6 +332,13 @@ class ProductionCopy(Deployment):
         """
         backup_dir = os.path.join(".scripts", "backups", backup)
         return self.application.restore(self, backup_dir, options)
+    @chdir_to_location
+    def remove(self, options):
+        """
+        Deletes all non-local or non-filesystem data (such as databases) that
+        this application uses.
+        """
+        self.application.remove(self, options)
     def verifyWeb(self):
         """
         Checks if the autoinstall is viewable from the web.
index e7bf46bb7e92f2109fdb99f448e027d823e11302..666fcf9d3640548dbffbe79ef36ff42229435ecb 100644 (file)
@@ -22,10 +22,15 @@ allow applications to refer to them as a single name.
 
 import os
 import logging
+import sqlalchemy
 
 import wizard
 from wizard import scripts, shell, util
 
+def dsn_callback(options):
+    if not isinstance(options.dsn, sqlalchemy.engine.url.URL):
+        options.dsn = sqlalchemy.engine.url.make_url(options.dsn)
+
 # XXX: This is in the wrong place
 def fetch(options, path, post=None):
     """
@@ -43,7 +48,7 @@ def preloads():
     """
     return {
             'web': WebArgSet(),
-            'mysql': MysqlArgSet(),
+            'db': DbArgSet(),
             'admin': AdminArgSet(),
             'email': EmailArgSet(),
             'title': TitleArgSet(),
@@ -126,13 +131,17 @@ class ScriptsMysqlStrategy(Strategy):
     may create an appropriate database for the user.
     """
     side_effects = True
-    provides = frozenset(["mysql_host", "mysql_user", "mysql_password", "mysql_db"])
-    def __init__(self, dir):
+    provides = frozenset(["dsn"])
+    def __init__(self, application, dir):
+        self.application = application
         self.dir = dir
     def prepare(self):
         """Uses :func:`wizard.scripts.get_sql_credentials`"""
-        self._triplet = scripts.get_sql_credentials()
-        if not self._triplet:
+        if self.application.database != "mysql":
+            raise StrategyFailed
+        try:
+            self._triplet = shell.Shell().eval("/mit/scripts/sql/bin/get-password").split()
+        except shell.CallError:
             raise StrategyFailed
         self._username = os.getenv('USER')
         if self._username is None:
@@ -140,10 +149,11 @@ class ScriptsMysqlStrategy(Strategy):
     def execute(self, options):
         """Creates a new database for the user using ``get-next-database`` and ``create-database``."""
         sh = shell.Shell()
-        options.mysql_host, options.mysql_user, options.mysql_password = self._triplet
+        host, username, password = self._triplet
         # race condition
-        options.mysql_db = self._username + '+' + sh.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
-        sh.call("/mit/scripts/sql/bin/create-database", options.mysql_db)
+        database = self._username + '+' + sh.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
+        sh.call("/mit/scripts/sql/bin/create-database", name)
+        options.dsn = sqlalchemy.engine.url.URL("mysql", username=username, password=password, host=host, database=database)
 
 class ScriptsEmailStrategy(Strategy):
     """Performs script specific guess for email."""
@@ -173,6 +183,8 @@ class Arg(object):
     type = None
     #: If true, is a password
     password = False
+    #: Callback that this argument wants to get run on options after finished
+    callback = None
     @property
     def envname(self):
         """Name of the environment variable containing this arg."""
@@ -196,6 +208,7 @@ class ArgSet(object):
     """
     #: The :class:`Arg` objects that compose this argument set.
     args = None
+    # XXX: probably could also use a callback attribute
     def __init__(self):
         self.args = []
 
@@ -207,14 +220,11 @@ class WebArgSet(ArgSet):
                 Arg("web_path", type="PATH", help="Relative path to your application root"),
                 ]
 
-class MysqlArgSet(ArgSet):
-    """Common arguments for applications that use a MySQL database."""
+class DbArgSet(ArgSet):
+    """Common arguments for applications that use a database."""
     def __init__(self):
         self.args = [
-                Arg("mysql_host", type="HOST", help="Host that your MySQL server lives on"),
-                Arg("mysql_db", type="DB", help="Name of the database to populate"),
-                Arg("mysql_user", type="USER", help="Name of user to access database with"),
-                Arg("mysql_password", type="PWD", password=True, help="Password of the database user"),
+                Arg("dsn", type="DSN", help="Database Source Name, i.e. mysql://user:pass@host/dbname", callback=dsn_callback),
                 ]
 
 class AdminArgSet(ArgSet):
@@ -282,7 +292,7 @@ class ArgSchema(object):
     def add(self, arg):
         """Adds an argument to our schema."""
         self.args[arg.name] = arg
-    def commit(self, dir):
+    def commit(self, application, dir):
         """Populates :attr:`strategies` and :attr:`provides`"""
         self.strategies = []
         self.provides = set()
@@ -290,7 +300,7 @@ class ArgSchema(object):
         raw_strategies = [
                 EnvironmentStrategy(self),
                 ScriptsWebStrategy(dir),
-                ScriptsMysqlStrategy(dir),
+                ScriptsMysqlStrategy(application, dir),
                 ScriptsEmailStrategy(),
                 ]
         for arg in self.args.values():
@@ -318,7 +328,8 @@ class ArgSchema(object):
         Load values from strategy.  Must be called after :meth:`commit`.  We
         omit strategies whose provided variables are completely specified
         already.  Will raise :exc:`MissingRequiredParam` if strategies aren't
-        sufficient to fill all options.
+        sufficient to fill all options.  It will then run any callbacks on
+        arguments.
         """
         unfilled = set(name for name in self.args if getattr(options, name) is None)
         missing = unfilled - self.provides
@@ -331,6 +342,10 @@ class ArgSchema(object):
                 if getattr(options, name) is not None:
                     logging.warning("Overriding pre-specified value for %s", name)
             strategy.execute(options)
+        for arg in self.args.values():
+            if arg.callback is None:
+                continue
+            arg.callback(options)
 
 class Error(wizard.Error):
     """Base error class for this module."""
index 66c62d887782dd01f0feb973b1c0154931602487..4a0618ebcc305fda4c25f6a149679987388a2097 100644 (file)
@@ -6,28 +6,6 @@ import logging
 import wizard
 from wizard import shell, util
 
-def get_sql_credentials(vars=None):
-    """
-    Attempts to determine a user's MySQL credentials.  They are
-    returned as a three-tuple (host, user, password).
-    """
-    sh = shell.Shell()
-    host = os.getenv("WIZARD_MYSQL_HOST")
-    user = os.getenv("WIZARD_MYSQL_USER")
-    password = os.getenv("WIZARD_MYSQL_PASSWORD")
-    if host is not None and user is not None and password is not None:
-        return (host, user, password)
-    # XXX: this is very fragile
-    elif vars and "WIZARD_DBSERVER" in vars and "WIZARD_DBUSER" in vars and "WIZARD_DBPASSWORD" in vars:
-        return (shlex.split(vars[x])[0] for x in ("WIZARD_DBSERVER", "WIZARD_DBUSER", "WIZARD_DBPASSWORD"))
-    try:
-        tuple = sh.eval("/mit/scripts/sql/bin/get-password").split()
-        if len(tuple) == 3:
-            return tuple
-        return None
-    except shell.CallError:
-        return None
-
 def get_web_host_and_path(dir=None):
     """
     Attempts to determine webhost and path for the current directory
index a0a6387cf325e0b6173142a04e0ebdc7c0123b78..6e8754a1cb7c1e43a310add61499933e93017831 100644 (file)
@@ -1,15 +1,39 @@
 import sqlalchemy
 
-def mysql_connect(options):
+# We're going to use sqlalchemy.engine.url.URL as our database
+# info intermediate object
+
+def connect(url):
     """Convenience method for connecting to a MySQL database."""
-    engine = sqlalchemy.create_engine(sqlalchemy.engine.url.URL(
-        "mysql",
-        username=options.mysql_user,
-        password=options.mysql_password,
-        host=options.mysql_host,
-        database=options.mysql_db,
-        ))
+    engine = sqlalchemy.create_engine(url)
     meta = sqlalchemy.MetaData()
     meta.bind = engine
     meta.reflect()
     return meta
+
+def fill_url(url):
+    """
+    If the URL has a database name but no other values, it will
+    use the global configuration, and then try the database name.
+    """
+    if not url:
+        return None
+    if not url.database:
+        # it's hopeless
+        return url
+    # omitted port and query
+    if any((url.host, url.username, url.password)):
+        # don't try for defaults if a few of these were set
+        return url
+    # this is hook stuff
+    if url.driver == "mysql":
+        try:
+            url.host, url.username, url.password = sh.eval("/mit/scripts/sql/bin/get-password").split()
+            return url
+        except shell.CallError:
+            pass
+    dsn = os.getenv("WIZARD_DSN")
+    old_url = url
+    url = sqlalchemy.engine.url.make_url(dsn)
+    url.database = old_url.database
+    return url