""" Object model for querying information and manipulating deployments of autoinstalls. Every :class:`Deployment` has an :class:`app.ApplicationVersion` which in turn has an :class:`app.Application`. """ import os.path import fileinput import logging import decorator import datetime import tempfile import time import traceback import shutil import errno import pkg_resources import urlparse import wizard from wizard import app, git, old_log, shell, sql, util ## -- Global Functions -- def get_install_lines(versions_store, user=None): """ Low level function that retrieves a list of lines from the :term:`versions store` that can be passed to :meth:`Deployment.parse`. """ if os.path.isfile(versions_store): return fileinput.input([versions_store]) if user: return fileinput.input([versions_store + "/" + user]) return fileinput.input([versions_store + "/" + f for f in sorted(os.listdir(versions_store))]) def parse_install_lines(show, versions_store, yield_errors = False, user = None): """ Generator function for iterating through all autoinstalls. Each item is an instance of :class:`Deployment`, or possibly a :class:`wizard.deploy.Error` if ``yield_errors`` is ``True``. You can filter out applications and versions by specifying ``app`` or ``app-1.2.3`` in ``show``. This function may generate log output. """ if not show: show = app.applications() elif isinstance(show, str): # otherwise, frozenset will treat string as an iterable show = frozenset([show]) else: show = frozenset(show) for line in get_install_lines(versions_store, user): # construction try: 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 except Error: # we consider this a worse error logging.warning("Error with '%s'" % line.rstrip()) continue # filter if name + "-" + util.truncate(str(d.version)) in show or name in show: pass else: continue # yield yield d def web(dir, url=None): """ Attempts to determine the URL a directory would be web-accessible at. If ``url`` is specified, automatically use it. Returns a generator which produces a list of candidate urls. This function implements a plugin interface named :ref:`wizard.deploy.web`. """ if url: if isinstance(url, str): url = urlparse.urlparse(url) logging.info("wizard.deploy.web: Using default URL %s", url) yield url return for entry in pkg_resources.iter_entry_points("wizard.deploy.web"): logging.debug("wizard.deploy.web: Processing %s", entry) f = entry.load() for r in f(dir): if isinstance(r, str): r = urlparse.urlparse(r) logging.info("wizard.deploy.web: Using plugin-supplied URL %s", r) yield r # try the environment host = os.getenv("WIZARD_WEB_HOST") path = os.getenv("WIZARD_WEB_PATH") if host is not None and path is not None: r = urlparse.ParseResult( "http", host, path.rstrip('/'), "", "", "") logging.info("wizard.deploy.web: Using environment URL %s", r) yield r logging.info("wizard.deploy.web: Exhausted URLs") ## -- Model Objects -- @decorator.decorator def chdir_to_location(f, self, *args, **kwargs): """ Decorator for making a function have working directory :attr:`Deployment.location`. """ with util.ChangeDirectory(self.location): return f(self, *args, **kwargs) class Deployment(object): """ Represents a deployment of an autoinstall, e.g. directory that has a ``.wizard`` directory in it. Supply ``version`` with an :class:`ApplicationVersion` only if you were reading from the :term:`versions store` and care about speed (data from there can be stale). The Deployment interface is somewhat neutered, so you may want to use :class:`WorkingCopy` or :class:`ProductionCopy` for more powerful operations. .. note:: For legacy purposes, deployments can also be marked by a ``.scripts`` directory or a ``.scripts-version`` file. """ #: Absolute path to the deployment location = None def __init__(self, location, version=None): self.location = os.path.abspath(location) self._app_version = version # some cache variables self._read_cache = {} self._old_log = None self._dsn = None self._url = None self._urlGen = None self._wizard_dir = None def invalidateCache(self): """ Invalidates all cached variables. This currently applies to :attr:`app_version`, :attr:`old_log` and :meth:`read`. """ self._app_version = None self._read_cache = {} self._old_log = None 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) or there is no metadata to be heard of. """ self._app_version = app_version def read(self, file, force = False): """ Reads a file's contents, possibly from cache unless ``force`` is ``True``. """ if force or file not in self._read_cache: f = open(os.path.join(self.location, file)) self._read_cache[file] = f.read() f.close() return self._read_cache[file] def extract(self): """ Extracts all the values of all variables from deployment. These variables may be used for parametrizing generic parent commits and include things such as database access credentials and local configuration. """ return self.application.extract(self) def verify(self, no_touch=False): """ Checks if this is an autoinstall, throws an exception if there are problems. If ``no_touch`` is ``True``, it will not attempt edit the installation. """ with util.ChangeDirectory(self.location): has_git = os.path.isdir(".git") has_wizard = os.path.isdir(".wizard") if not has_wizard and os.path.isdir(".scripts"): # LEGACY os.symlink(".scripts", ".wizard") has_wizard = True if not has_git and has_wizard: raise CorruptedAutoinstallError(self.location) elif has_git and not has_wizard: raise AlreadyVersionedError(self.location) # LEGACY elif not has_git and not has_wizard: if os.path.isfile(".scripts-version"): raise NotMigratedError(self.location) else: raise NotAutoinstallError(self.location) def verifyTag(self, srv_path): """ Checks if the purported version has a corresponding tag in the upstream repository. """ repo = self.application.repository(srv_path) try: shell.eval("git", "--git-dir", repo, "rev-parse", self.app_version.wizard_tag, '--') except shell.CallError: raise NoTagError(self.app_version.wizard_tag) def verifyGit(self, srv_path): """ Checks if the autoinstall's Git repository makes sense, checking if the tag is parseable and corresponds to a real application, and if the tag in this repository corresponds to the one in the remote repository. """ with util.ChangeDirectory(self.location): repo = self.application.repository(srv_path) def repo_rev_parse(tag): return shell.eval("git", "--git-dir", repo, "rev-parse", tag) def self_rev_parse(tag): try: return shell.safeCall("git", "rev-parse", tag, strip=True) except shell.CallError: raise NoLocalTagError(tag) def compare_tags(tag): return repo_rev_parse(tag) == self_rev_parse(tag) if not compare_tags(self.app_version.pristine_tag): raise InconsistentPristineTagError(self.app_version.pristine_tag) if not compare_tags(self.app_version.wizard_tag): # Causes remastering raise InconsistentWizardTagError(self.app_version.wizard_tag) parent = repo_rev_parse(self.app_version.wizard_tag) merge_base = shell.safeCall("git", "merge-base", parent, "HEAD", strip=True) if merge_base != parent: # Causes remastering raise HeadNotDescendantError(self.app_version.wizard_tag) def verifyConfigured(self): """ Checks if the autoinstall is configured running. """ if not self.configured: raise NotConfiguredError(self.location) @chdir_to_location def verifyVersion(self): """ 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 return real @property @chdir_to_location def configured(self): """Whether or not an autoinstall has been configured/installed for use.""" return self.application.checkConfig(self) @property def migrated(self): """Whether or not the autoinstalls has been migrated.""" return os.path.isdir(self.wizard_dir) @property def wizard_dir(self): """The absolute path of the Wizard directory.""" return os.path.join(self.location, ".wizard") @property def backup_dir(self): """The absolute path to ``.wizard/backups``.""" return os.path.join(self.wizard_dir, "backups") # LEGACY @property def old_version_file(self): """ The absolute path of either ``.scripts-version``. """ return os.path.join(self.location, '.scripts-version') @property def blacklisted_file(self): """The absolute path of the ``.wizard/blacklisted`` file.""" return os.path.join(self.wizard_dir, 'blacklisted') @property def pending_file(self): """The absolute path of the ``.wizard/pending`` file.""" return os.path.join(self.wizard_dir, 'pending') @property def version_file(self): """The absolute path of the ``.wizard/version`` file.""" return os.path.join(self.wizard_dir, 'version') @property def dsn_file(self): """The absolute path of the :file:`.wizard/dsn` override file.""" return os.path.join(self.wizard_dir, 'dsn') @property def url_file(self): """The absolute path of the :file:`.wizard/url` override file.""" return os.path.join(self.wizard_dir, 'url') @property def application(self): """The :class:`app.Application` of this deployment.""" return self.app_version.application @property def old_log(self): """ The :class:`wizard.old_log.Log` of this deployment. This is only applicable to un-migrated autoinstalls. """ if not self._old_log: self._old_log = old_log.DeployLog.load(self) return self._old_log @property def version(self): """ The :class:`distutils.version.LooseVersion` of this deployment. """ return self.app_version.version @property def app_version(self): """The :class:`app.ApplicationVersion` of this deployment.""" if not self._app_version: if os.path.isdir(os.path.join(self.location, ".git")): try: with util.ChangeDirectory(self.location): appname, _, version = git.describe().partition('-') self._app_version = app.ApplicationVersion.make(appname, version) except shell.CallError: pass if not self._app_version: # LEGACY 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 @property def dsn(self): """The :class:`sqlalchemy.engine.url.URL` for this deployment.""" if not self._dsn: self._dsn = sql.auth(self.application.dsn(self)) return self._dsn @property def url(self): """The :class:`urlparse.ParseResult` for this deployment.""" if not self._url: self.nextUrl() return self._url def nextUrl(self): """ Initializes :attr:`url` with a possible URL the web application may be located at. It may be called again to switch to another possible URL, usually in the event of a web access failure. """ if not self._urlGen: self._urlGen = web(self.location, self.application.url(self)) try: self._url = self._urlGen.next() # pylint: disable-msg=E1101 return self._url except StopIteration: raise UnknownWebPath @staticmethod def parse(line): """ Parses a line from the :term:`versions store`. .. note:: Use this method only when speed is of the utmost importance. You should prefer to directly create a deployment with only a ``location`` when possible. """ line = line.rstrip() try: location, deploydir = line.split(":") except ValueError: return ProductionCopy(line) # lazy loaded version try: return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir)) except Error as e: e.location = location raise e class ProductionCopy(Deployment): """ Represents the production copy of a deployment. This copy is canonical, and is the only one guaranteed to be accessible via web, have a database, etc. """ @chdir_to_location def upgrade(self, version, options): """ Performs an upgrade of database schemas and other non-versioned data. """ return self.application.upgrade(self, version, options) @chdir_to_location def backup(self, options): """ Performs a backup of database schemas and other non-versioned data. """ # 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. if not os.path.exists(self.backup_dir): try: os.mkdir(self.backup_dir) 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(self.backup_dir, "lock")): while 1: backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S") outdir = os.path.join(self.backup_dir, 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): """ 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 (call 'wizard restore' instead). """ return self.application.restore(self, os.path.join(self.backup_dir, backup), 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. If you do not run this, there is no guarantee that the url returned by this application is the correct one. """ while True: if not self.application.checkWeb(self): try: self.nextUrl() except UnknownWebPath: raise WebVerificationError else: break def fetch(self, path, post=None): """ Performs a HTTP request on the website. """ return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103 class WorkingCopy(Deployment): """ Represents a temporary clone of a deployment that we can make modifications to without fear of interfering with a production deployment. More operations are permitted on these copies. """ @chdir_to_location def parametrize(self, deployment): """ Edits files in ``dir`` to replace WIZARD_* variables with literal instances based on ``deployment``. This is used for constructing virtual merge bases, and as such ``deployment`` will generally not equal ``self``. """ return self.application.parametrize(self, deployment) @chdir_to_location def prepareConfig(self): """ Edits files in the deployment such that any user-specific configuration is replaced with generic WIZARD_* variables. """ return self.application.prepareConfig(self) @chdir_to_location def resolveConflicts(self): """ Resolves conflicted files in this working copy. Returns whether or not all conflicted files were resolved or not. Fully resolved files are added to the index, but no commit is made. """ return self.application.resolveConflicts(self) @chdir_to_location def prepareMerge(self): """ Performs various edits to files in the current working directory in order to make a merge go more smoothly. This is usually used to fix botched line-endings. """ return self.application.prepareMerge(self) ## -- Exceptions -- class Error(wizard.Error): """Base error class for this module""" pass # LEGACY class NotMigratedError(Error): """ The deployment contains a .scripts-version file, but no .git or .wizard directory. """ #: Directory of deployment dir = None def __init__(self, dir): self.dir = dir def __str__(self): return """This installation was not migrated""" class AlreadyVersionedError(Error): """The deployment contained a .git directory but no .wizard directory.""" #: Directory of deployment dir = None def __init__(self, dir): self.dir = dir def __str__(self): return """ ERROR: Directory contains a .git directory, but not a .wizard directory. If this is not a corrupt migration, this means that the user was versioning their install using Git.""" class NotConfiguredError(Error): """The install was missing essential configuration.""" #: Directory of unconfigured install dir = None def __init__(self, dir): self.dir = dir def __str__(self): return """ ERROR: The install was well-formed, but not configured (essential configuration files were not found.)""" class CorruptedAutoinstallError(Error): """The install was missing a .git directory, but had a .wizard directory.""" #: Directory of the corrupted install dir = None def __init__(self, dir): self.dir = dir def __str__(self): return """ ERROR: Directory contains a .wizard directory, but not a .git directory.""" class NotAutoinstallError(Error): """Application is not an autoinstall.""" #: Directory of the not autoinstall dir = None def __init__(self, dir): self.dir = dir def __str__(self): return """ 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.""" #: Missing tag tag = None def __init__(self, tag): self.tag = tag def __str__(self): return """ ERROR: Could not find tag %s in repository.""" % self.tag class NoLocalTagError(Error): """Could not find tag in local repository.""" #: Missing tag tag = None def __init__(self, tag): self.tag = tag def __str__(self): return """ ERROR: Could not find tag %s in local repository.""" % self.tag class InconsistentPristineTagError(Error): """Pristine tag commit ID does not match upstream pristine tag commit ID.""" #: Inconsistent tag tag = None def __init__(self, tag): self.tag = tag def __str__(self): return """ ERROR: Local pristine tag %s did not match repository's. This probably means an upstream rebase occured.""" % self.tag class InconsistentWizardTagError(Error): """Wizard tag commit ID does not match upstream wizard tag commit ID.""" #: Inconsistent tag tag = None def __init__(self, tag): self.tag = tag def __str__(self): return """ ERROR: Local wizard tag %s did not match repository's. This probably means an upstream rebase occurred. Try 'git fetch --tags && wizard remaster'.""" % self.tag class HeadNotDescendantError(Error): """HEAD is not connected to tag.""" #: Tag that HEAD should have been descendant of. tag = None def __init__(self, tag): self.tag = tag def __str__(self): return """ ERROR: HEAD is not a descendant of %s. This probably means that an upstream rebase occurred, and new tags were pulled, but local user commits were never rebased. Try running 'wizard remaster'.""" % self.tag class VersionDetectionError(Error): """Could not detect real version of application.""" def __str__(self): return """ ERROR: Could not detect the real version of the application.""" class VersionMismatchError(Error): """Git version of application does not match detected version.""" #: Detected version real_version = None #: Version from Git git_version = None def __init__(self, real_version, git_version): self.real_version = real_version self.git_version = git_version def __str__(self): return """ 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. You can find the contents of the page from the debug backtraces.""" 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.""" 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."""