]> scripts.mit.edu Git - wizard.git/blobdiff - wizard/deploy.py
Add support for a working dir in wizard.shell
[wizard.git] / wizard / deploy.py
index ffccf47f11726312a634eb35223abfb990f42ce2..6d3ec6702f6ec3bef6520483eb1d1134cb23b52c 100644 (file)
@@ -6,14 +6,17 @@ which in turn has an :class:`app.Application`.
 
 import os.path
 import fileinput
 
 import os.path
 import fileinput
-import dateutil.parser
-import tempfile
 import logging
 import logging
-import shutil
 import decorator
 import decorator
+import datetime
+import tempfile
+import time
+import traceback
+import shutil
+import errno
 
 import wizard
 
 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 --
 
 
 ## -- Global Functions --
 
@@ -50,6 +53,11 @@ def parse_install_lines(show, versions_store, yield_errors = False, user = None)
             d = Deployment.parse(line)
             name = d.application.name
         except app.NoSuchApplication as e:
             d = Deployment.parse(line)
             name = d.application.name
         except app.NoSuchApplication as e:
+            if not e.location:
+                try:
+                    e.location = line.split(':')[0]
+                except IndexError:
+                    e.location = line
             if yield_errors:
                 yield e
             continue
             if yield_errors:
                 yield e
             continue
@@ -96,6 +104,8 @@ class Deployment(object):
         # some cache variables
         self._read_cache = {}
         self._old_log = None
         # some cache variables
         self._read_cache = {}
         self._old_log = None
+        self._dsn = None
+        self._url = None
     def invalidateCache(self):
         """
         Invalidates all cached variables.  This currently applies to
     def invalidateCache(self):
         """
         Invalidates all cached variables.  This currently applies to
@@ -138,6 +148,8 @@ class Deployment(object):
             elif not has_git and not has_scripts:
                 if os.path.isfile(".scripts-version"):
                     raise NotMigratedError(self.location)
             elif not has_git and not has_scripts:
                 if os.path.isfile(".scripts-version"):
                     raise NotMigratedError(self.location)
+                else:
+                    raise NotAutoinstallError(self.location)
 
     def verifyTag(self, srv_path):
         """
 
     def verifyTag(self, srv_path):
         """
@@ -146,7 +158,7 @@ class Deployment(object):
         """
         repo = self.application.repository(srv_path)
         try:
         """
         repo = self.application.repository(srv_path)
         try:
-            shell.Shell().eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
+            shell.eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
         except shell.CallError:
             raise NoTagError(self.app_version.scripts_tag)
 
         except shell.CallError:
             raise NoTagError(self.app_version.scripts_tag)
 
@@ -158,13 +170,12 @@ class Deployment(object):
         corresponds to the one in the remote repository.
         """
         with util.ChangeDirectory(self.location):
         corresponds to the one in the remote repository.
         """
         with util.ChangeDirectory(self.location):
-            sh = shell.Shell()
             repo = self.application.repository(srv_path)
             def repo_rev_parse(tag):
             repo = self.application.repository(srv_path)
             def repo_rev_parse(tag):
-                return sh.eval("git", "--git-dir", repo, "rev-parse", tag)
+                return shell.eval("git", "--git-dir", repo, "rev-parse", tag)
             def self_rev_parse(tag):
                 try:
             def self_rev_parse(tag):
                 try:
-                    return sh.safeCall("git", "rev-parse", tag, strip=True)
+                    return shell.safeCall("git", "rev-parse", tag, strip=True)
                 except shell.CallError:
                     raise NoLocalTagError(tag)
             def compare_tags(tag):
                 except shell.CallError:
                     raise NoLocalTagError(tag)
             def compare_tags(tag):
@@ -174,7 +185,7 @@ class Deployment(object):
             if not compare_tags(self.app_version.scripts_tag):
                 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
             parent = repo_rev_parse(self.app_version.scripts_tag)
             if not compare_tags(self.app_version.scripts_tag):
                 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
             parent = repo_rev_parse(self.app_version.scripts_tag)
-            merge_base = sh.safeCall("git", "merge-base", parent, "HEAD", strip=True)
+            merge_base = shell.safeCall("git", "merge-base", parent, "HEAD", strip=True)
             if merge_base != parent:
                 raise HeadNotDescendantError(self.app_version.scripts_tag)
 
             if merge_base != parent:
                 raise HeadNotDescendantError(self.app_version.scripts_tag)
 
@@ -191,11 +202,22 @@ class Deployment(object):
         Checks if our version and the version number recorded in a file
         are consistent.
         """
         Checks if our version and the version number recorded in a file
         are consistent.
         """
+        real = self.detectVersion()
+        if not str(real) == self.app_version.pristine_tag.partition('-')[2]:
+            raise VersionMismatchError(real, self.version)
+
+    @chdir_to_location
+    def detectVersion(self):
+        """
+        Returns the real version, based on filesystem, of install.
+
+        Throws a :class:`VersionDetectionError` if we couldn't figure out
+        what the real version was.
+        """
         real = self.application.detectVersion(self)
         if not real:
             raise VersionDetectionError
         real = self.application.detectVersion(self)
         if not real:
             raise VersionDetectionError
-        elif not str(real) == self.app_version.pristine_tag.partition('-')[2]:
-            raise VersionMismatchError(real, self.version)
+        return real
 
     @property
     @chdir_to_location
 
     @property
     @chdir_to_location
@@ -226,6 +248,14 @@ class Deployment(object):
         """The absolute path of the ``.scripts/version`` file."""
         return os.path.join(self.scripts_dir, 'version')
     @property
         """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 url_file(self):
+        """The absolute path of the :file:`.scripts/url` override file."""
+        return os.path.join(self.scripts_dir, 'url')
+    @property
     def application(self):
         """The :class:`app.Application` of this deployment."""
         return self.app_version.application
     def application(self):
         """The :class:`app.Application` of this deployment."""
         return self.app_version.application
@@ -257,8 +287,34 @@ class Deployment(object):
                 except shell.CallError:
                     pass
         if not self._app_version:
                 except shell.CallError:
                     pass
         if not self._app_version:
-            self._app_version = self.old_log[-1].version
+            try:
+                self._app_version = self.old_log[-1].version
+            except old_log.ScriptsVersionNoSuchFile:
+                pass
+        if not self._app_version:
+            appname = shell.eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
+            self._app_version = app.ApplicationVersion.make(appname, "unknown")
         return self._app_version
         return self._app_version
+    @property
+    def dsn(self):
+        """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
+        if not self._dsn:
+            self._dsn = sql.fill_url(self.application.dsn(self))
+        return self._dsn
+    @property
+    def url(self):
+        """The :class:`urlparse.ParseResult` for this deployment."""
+        if not self._url:
+            self._url = scripts.fill_url(self.location, self.application.url(self))
+        if not self._url:
+            raise UnknownWebPath
+        return self._url
+    def enableOldStyleUrls(self):
+        """
+        Switches to using http://user.scripts.mit.edu/~user/app URLs.
+        No effect if they have an explicit .scripts/url override.
+        """
+        self._url = scripts.fill_url(self.location, self.application.url(self), old_style = True)
     @staticmethod
     def parse(line):
         """
     @staticmethod
     def parse(line):
         """
@@ -298,7 +354,45 @@ class ProductionCopy(Deployment):
         """
         Performs a backup of database schemas and other non-versioned data.
         """
         """
         Performs a backup of database schemas and other non-versioned data.
         """
-        return self.application.backup(self, options)
+        # There are retarded amounts of race-safety in this function,
+        # because we do NOT want to claim to have made a backup, when
+        # actually something weird happened to it.
+        backupdir = os.path.join(self.scripts_dir, "backups")
+        if not os.path.exists(backupdir):
+            try:
+                os.mkdir(backupdir)
+            except OSError as e:
+                if e.errno == errno.EEXIST:
+                    pass
+                else:
+                    raise
+        tmpdir = tempfile.mkdtemp() # actually will be kept around
+        try:
+            self.application.backup(self, tmpdir, options)
+        except app.BackupFailure:
+            # the backup is bogus, don't let it show up
+            shutil.rmtree(tmpdir)
+            raise
+        backup = None
+        with util.LockDirectory(os.path.join(backupdir, "lock")):
+            while 1:
+                backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
+                outdir = os.path.join(backupdir, backup)
+                if os.path.exists(outdir):
+                    logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
+                    time.sleep(1)
+                    continue
+                try:
+                    shutil.move(tmpdir, outdir)
+                except:
+                    # don't leave half-baked stuff lying around
+                    try:
+                        shutil.rmtree(outdir)
+                    except OSError:
+                        pass
+                    raise
+                break
+        return backup
     @chdir_to_location
     def restore(self, backup, options):
         """
     @chdir_to_location
     def restore(self, backup, options):
         """
@@ -307,23 +401,32 @@ class ProductionCopy(Deployment):
         does, so you probably do NOT want to call this elsewhere unless
         you know what you're doing (call 'wizard restore' instead).
         """
         does, so you probably do NOT want to call this elsewhere unless
         you know what you're doing (call 'wizard restore' instead).
         """
-        return self.application.restore(self, backup, options)
+        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 verifyDatabase(self):
+        """
+        Checks if the autoinstall has a properly configured database.
+        """
+        if not self.application.checkDatabase(self):
+            raise DatabaseVerificationError
     def verifyWeb(self):
         """
         Checks if the autoinstall is viewable from the web.
         """
     def verifyWeb(self):
         """
         Checks if the autoinstall is viewable from the web.
         """
-        out = []
-        if not self.application.checkWeb(self, out):
-            raise WebVerificationError(out[0])
+        if not self.application.checkWeb(self):
+            raise WebVerificationError
     def fetch(self, path, post=None):
         """
         Performs a HTTP request on the website.
         """
     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, TypeError):
-            raise UnknownWebPath
-        return util.fetch(host, basepath, path, post)
+        return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
 
 class WorkingCopy(Deployment):
     """
 
 class WorkingCopy(Deployment):
     """
@@ -331,6 +434,13 @@ class WorkingCopy(Deployment):
     modifications to without fear of interfering with a production
     deployment.  More operations are permitted on these copies.
     """
     modifications to without fear of interfering with a production
     deployment.  More operations are permitted on these copies.
     """
+    def setAppVersion(self, app_version):
+        """
+        Manually resets the application version; useful if the working
+        copy is off in space (i.e. not anchored to something we can
+        git describe off of.)
+        """
+        self._app_version = app_version
     @chdir_to_location
     def parametrize(self, deployment):
         """
     @chdir_to_location
     def parametrize(self, deployment):
         """
@@ -421,17 +531,21 @@ ERROR: Directory contains a .scripts directory,
 but not a .git directory."""
 
 class NotAutoinstallError(Error):
 but not a .git directory."""
 
 class NotAutoinstallError(Error):
-    """The directory was not an autoinstall, due to missing .scripts-version file."""
-    #: Directory in question
+    """Application is not an autoinstall."""
+    #: Directory of the not autoinstall
     dir = None
     def __init__(self, dir):
         self.dir = dir
     def __str__(self):
         return """
 
     dir = None
     def __init__(self, dir):
         self.dir = dir
     def __str__(self):
         return """
 
-ERROR: Could not find .scripts-version file. Are you sure
-this is an autoinstalled application?
-"""
+ERROR: The directory
+
+    %s
+
+does not appear to be an autoinstall.  If you are in a
+subdirectory of an autoinstall, you need to use the root
+directory for the autoinstall.""" % self.dir
 
 class NoTagError(Error):
     """Deployment has a tag that does not have an equivalent in upstream repository."""
 
 class NoTagError(Error):
     """Deployment has a tag that does not have an equivalent in upstream repository."""
@@ -516,19 +630,22 @@ version %s.""" % (self.real_version, self.git_version)
 
 class WebVerificationError(Error):
     """Could not access the application on the web"""
 
 class WebVerificationError(Error):
     """Could not access the application on the web"""
-    #: Contents of web page access
-    contents = None
-    def __init__(self, contents):
-        self.contents = contents
     def __str__(self):
         return """
 
 ERROR: We were not able to access the application on the
 web.  This may indicate that the website is behind
     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.  The contents
-of the page were:
+authentication on the htaccess level.  You can find
+the contents of the page from the debug backtraces."""
 
 
-%s""" % self.contents
+class DatabaseVerificationError(Error):
+    """Could not access the database"""
+    def __str__(self):
+        return """
+
+ERROR: We were not able to access the database for
+this application; this probably means that your database
+configuration is misconfigured."""
 
 class UnknownWebPath(Error):
     """Could not determine application's web path."""
 
 class UnknownWebPath(Error):
     """Could not determine application's web path."""
@@ -540,4 +657,3 @@ 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."""
 on the application.  You can specify this manually using
 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment
 variables."""
-