2 Object model for querying information and manipulating deployments
3 of autoinstalls. Every :class:`Deployment` has an :class:`app.ApplicationVersion`
4 which in turn has an :class:`app.Application`.
21 from wizard import app, git, old_log, shell, sql, util
23 ## -- Global Functions --
25 def get_install_lines(versions_store, user=None):
27 Low level function that retrieves a list of lines from the
28 :term:`versions store` that can be passed to :meth:`Deployment.parse`.
30 if os.path.isfile(versions_store):
31 return fileinput.input([versions_store])
33 return fileinput.input([versions_store + "/" + user])
34 return fileinput.input([versions_store + "/" + f for f in sorted(os.listdir(versions_store))])
36 def parse_install_lines(show, versions_store, yield_errors = False, user = None):
38 Generator function for iterating through all autoinstalls.
39 Each item is an instance of :class:`Deployment`, or possibly
40 a :class:`wizard.deploy.Error` if ``yield_errors`` is ``True``. You can
41 filter out applications and versions by specifying ``app``
42 or ``app-1.2.3`` in ``show``. This function may generate
46 show = app.applications()
47 elif isinstance(show, str):
48 # otherwise, frozenset will treat string as an iterable
49 show = frozenset([show])
51 show = frozenset(show)
52 for line in get_install_lines(versions_store, user):
55 d = Deployment.parse(line)
56 name = d.application.name
57 except app.NoSuchApplication as e:
60 e.location = line.split(':')[0]
67 # we consider this a worse error
68 logging.warning("Error with '%s'" % line.rstrip())
71 if name + "-" + str(d.version) in show or name in show:
78 def web(dir, url=None):
80 Attempts to determine the URL a directory would be web-accessible at.
81 If ``url`` is specified, automatically use it. Returns a generator which
82 produces a list of candidate urls.
84 This function implements a plugin interface named :ref:`wizard.deploy.web`.
87 if isinstance(url, str):
88 url = urlparse.urlparse(url)
89 logging.info("wizard.deploy.web: Using URL %s", url)
93 for entry in pkg_resources.iter_entry_points("wizard.deploy.web"):
94 logging.debug("wizard.deploy.web: Processing %s", entry)
97 if isinstance(r, str):
98 r = urlparse.urlparse(r)
99 logging.info("wizard.deploy.web: Using URL %s", r)
102 # try the environment
103 host = os.getenv("WIZARD_WEB_HOST")
104 path = os.getenv("WIZARD_WEB_PATH")
105 if host is not None and path is not None:
106 r = urlparse.ParseResult(
111 logging.info("wizard.deploy.web: Using URL %s", r)
114 logging.info("wizard.deploy.web: Exhausted URLs")
116 ## -- Model Objects --
119 def chdir_to_location(f, self, *args, **kwargs):
121 Decorator for making a function have working directory
122 :attr:`Deployment.location`.
124 with util.ChangeDirectory(self.location):
125 return f(self, *args, **kwargs)
127 class Deployment(object):
129 Represents a deployment of an autoinstall, e.g. directory
130 that has ``.scripts`` directory or ``.scripts-version``
131 file in it. Supply ``version`` with an :class:`ApplicationVersion` only if
132 you were reading from the :term:`versions store` and care about
133 speed (data from there can be stale).
135 The Deployment interface is somewhat neutered, so you may
136 want to use :class:`WorkingCopy` or :class:`ProductionCopy` for
137 more powerful operations.
139 #: Absolute path to the deployment
141 def __init__(self, location, version=None):
142 self.location = os.path.abspath(location)
143 self._app_version = version
144 # some cache variables
145 self._read_cache = {}
150 def invalidateCache(self):
152 Invalidates all cached variables. This currently applies to
153 :attr:`app_version`, :attr:`old_log` and :meth:`read`.
155 self._app_version = None
156 self._read_cache = {}
158 def read(self, file, force = False):
160 Reads a file's contents, possibly from cache unless ``force``
163 if force or file not in self._read_cache:
164 f = open(os.path.join(self.location, file))
165 self._read_cache[file] = f.read()
167 return self._read_cache[file]
170 Extracts all the values of all variables from deployment.
171 These variables may be used for parametrizing generic parent
172 commits and include things such as database access credentials
173 and local configuration.
175 return self.application.extract(self)
179 Checks if this is an autoinstall, throws an exception if there
182 with util.ChangeDirectory(self.location):
183 has_git = os.path.isdir(".git")
184 has_scripts = os.path.isdir(".scripts")
185 if not has_git and has_scripts:
186 raise CorruptedAutoinstallError(self.location)
187 elif has_git and not has_scripts:
188 raise AlreadyVersionedError(self.location)
189 elif not has_git and not has_scripts:
190 if os.path.isfile(".scripts-version"):
191 raise NotMigratedError(self.location)
193 raise NotAutoinstallError(self.location)
195 def verifyTag(self, srv_path):
197 Checks if the purported version has a corresponding tag
198 in the upstream repository.
200 repo = self.application.repository(srv_path)
202 shell.eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
203 except shell.CallError:
204 raise NoTagError(self.app_version.scripts_tag)
206 def verifyGit(self, srv_path):
208 Checks if the autoinstall's Git repository makes sense,
209 checking if the tag is parseable and corresponds to
210 a real application, and if the tag in this repository
211 corresponds to the one in the remote repository.
213 with util.ChangeDirectory(self.location):
214 repo = self.application.repository(srv_path)
215 def repo_rev_parse(tag):
216 return shell.eval("git", "--git-dir", repo, "rev-parse", tag)
217 def self_rev_parse(tag):
219 return shell.safeCall("git", "rev-parse", tag, strip=True)
220 except shell.CallError:
221 raise NoLocalTagError(tag)
222 def compare_tags(tag):
223 return repo_rev_parse(tag) == self_rev_parse(tag)
224 if not compare_tags(self.app_version.pristine_tag):
225 raise InconsistentPristineTagError(self.app_version.pristine_tag)
226 if not compare_tags(self.app_version.scripts_tag):
227 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
228 parent = repo_rev_parse(self.app_version.scripts_tag)
229 merge_base = shell.safeCall("git", "merge-base", parent, "HEAD", strip=True)
230 if merge_base != parent:
231 raise HeadNotDescendantError(self.app_version.scripts_tag)
233 def verifyConfigured(self):
235 Checks if the autoinstall is configured running.
237 if not self.configured:
238 raise NotConfiguredError(self.location)
241 def verifyVersion(self):
243 Checks if our version and the version number recorded in a file
246 real = self.detectVersion()
247 if not str(real) == self.app_version.pristine_tag.partition('-')[2]:
248 raise VersionMismatchError(real, self.version)
251 def detectVersion(self):
253 Returns the real version, based on filesystem, of install.
255 Throws a :class:`VersionDetectionError` if we couldn't figure out
256 what the real version was.
258 real = self.application.detectVersion(self)
260 raise VersionDetectionError
265 def configured(self):
266 """Whether or not an autoinstall has been configured/installed for use."""
267 return self.application.checkConfig(self)
270 """Whether or not the autoinstalls has been migrated."""
271 return os.path.isdir(self.scripts_dir)
273 def scripts_dir(self):
274 """The absolute path of the ``.scripts`` directory."""
275 return os.path.join(self.location, '.scripts')
277 def old_version_file(self):
279 The absolute path of either ``.scripts-version`` (for unmigrated
280 installs) or ``.scripts/version``.
284 Use of this is discouraged for migrated installs.
286 return os.path.join(self.location, '.scripts-version')
288 def version_file(self):
289 """The absolute path of the ``.scripts/version`` file."""
290 return os.path.join(self.scripts_dir, 'version')
293 """The absolute path of the :file:`.scripts/dsn` override file."""
294 return os.path.join(self.scripts_dir, 'dsn')
297 """The absolute path of the :file:`.scripts/url` override file."""
298 return os.path.join(self.scripts_dir, 'url')
300 def application(self):
301 """The :class:`app.Application` of this deployment."""
302 return self.app_version.application
306 The :class:`wizard.old_log.Log` of this deployment. This
307 is only applicable to un-migrated autoinstalls.
309 if not self._old_log:
310 self._old_log = old_log.DeployLog.load(self)
315 The :class:`distutils.version.LooseVersion` of this
318 return self.app_version.version
320 def app_version(self):
321 """The :class:`app.ApplicationVersion` of this deployment."""
322 if not self._app_version:
323 if os.path.isdir(os.path.join(self.location, ".git")):
325 with util.ChangeDirectory(self.location):
326 appname, _, version = git.describe().partition('-')
327 self._app_version = app.ApplicationVersion.make(appname, version)
328 except shell.CallError:
330 if not self._app_version:
332 self._app_version = self.old_log[-1].version
333 except old_log.ScriptsVersionNoSuchFile:
335 if not self._app_version:
336 appname = shell.eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
337 self._app_version = app.ApplicationVersion.make(appname, "unknown")
338 return self._app_version
341 """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
343 self._dsn = sql.auth(self.application.dsn(self))
347 """The :class:`urlparse.ParseResult` for this deployment."""
353 Initializes :attr:`url` with a possible URL the web application may
354 be located at. It may be called again to switch to another possible
355 URL, usually in the event of a web access failure.
358 self._urlGen = web(self.location, self.application.url(self))
360 self._url = self._urlGen.next() # pylint: disable-msg=E1101
362 except StopIteration:
367 Parses a line from the :term:`versions store`.
371 Use this method only when speed is of the utmost
372 importance. You should prefer to directly create a deployment
373 with only a ``location`` when possible.
377 location, deploydir = line.split(":")
379 return ProductionCopy(line) # lazy loaded version
381 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
383 e.location = location
386 class ProductionCopy(Deployment):
388 Represents the production copy of a deployment. This copy
389 is canonical, and is the only one guaranteed to be accessible
390 via web, have a database, etc.
393 def upgrade(self, version, options):
395 Performs an upgrade of database schemas and other non-versioned data.
397 return self.application.upgrade(self, version, options)
399 def backup(self, options):
401 Performs a backup of database schemas and other non-versioned data.
403 # There are retarded amounts of race-safety in this function,
404 # because we do NOT want to claim to have made a backup, when
405 # actually something weird happened to it.
406 backupdir = os.path.join(self.scripts_dir, "backups")
407 if not os.path.exists(backupdir):
411 if e.errno == errno.EEXIST:
415 tmpdir = tempfile.mkdtemp() # actually will be kept around
417 self.application.backup(self, tmpdir, options)
418 except app.BackupFailure:
419 # the backup is bogus, don't let it show up
420 shutil.rmtree(tmpdir)
423 with util.LockDirectory(os.path.join(backupdir, "lock")):
425 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
426 outdir = os.path.join(backupdir, backup)
427 if os.path.exists(outdir):
428 logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
432 shutil.move(tmpdir, outdir)
434 # don't leave half-baked stuff lying around
436 shutil.rmtree(outdir)
443 def restore(self, backup, options):
445 Restores a backup. Destroys state, so be careful! Also, this does
446 NOT restore the file-level backup, which is what 'wizard restore'
447 does, so you probably do NOT want to call this elsewhere unless
448 you know what you're doing (call 'wizard restore' instead).
450 backup_dir = os.path.join(".scripts", "backups", backup)
451 return self.application.restore(self, backup_dir, options)
453 def remove(self, options):
455 Deletes all non-local or non-filesystem data (such as databases) that
456 this application uses.
458 self.application.remove(self, options)
459 def verifyDatabase(self):
461 Checks if the autoinstall has a properly configured database.
463 if not self.application.checkDatabase(self):
464 raise DatabaseVerificationError
467 Checks if the autoinstall is viewable from the web. If you do not run
468 this, there is no guarantee that the url returned by this application
472 if not self.application.checkWeb(self):
475 except UnknownWebPath:
476 raise WebVerificationError
479 def fetch(self, path, post=None):
481 Performs a HTTP request on the website.
483 return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
485 class WorkingCopy(Deployment):
487 Represents a temporary clone of a deployment that we can make
488 modifications to without fear of interfering with a production
489 deployment. More operations are permitted on these copies.
491 def setAppVersion(self, app_version):
493 Manually resets the application version; useful if the working
494 copy is off in space (i.e. not anchored to something we can
495 git describe off of.)
497 self._app_version = app_version
499 def parametrize(self, deployment):
501 Edits files in ``dir`` to replace WIZARD_* variables with literal
502 instances based on ``deployment``. This is used for constructing
503 virtual merge bases, and as such ``deployment`` will generally not
506 return self.application.parametrize(self, deployment)
508 def prepareConfig(self):
510 Edits files in the deployment such that any user-specific configuration
511 is replaced with generic WIZARD_* variables.
513 return self.application.prepareConfig(self)
515 def resolveConflicts(self):
517 Resolves conflicted files in this working copy. Returns whether or
518 not all conflicted files were resolved or not. Fully resolved
519 files are added to the index, but no commit is made.
521 return self.application.resolveConflicts(self)
523 def prepareMerge(self):
525 Performs various edits to files in the current working directory in
526 order to make a merge go more smoothly. This is usually
527 used to fix botched line-endings.
529 return self.application.prepareMerge(self)
533 class Error(wizard.Error):
534 """Base error class for this module"""
537 class NotMigratedError(Error):
539 The deployment contains a .scripts-version file, but no .git
540 or .scripts directory.
542 #: Directory of deployment
544 def __init__(self, dir):
547 return """This installation was not migrated"""
549 class AlreadyVersionedError(Error):
550 """The deployment contained a .git directory but no .scripts directory."""
551 #: Directory of deployment
553 def __init__(self, dir):
558 ERROR: Directory contains a .git directory, but not
559 a .scripts directory. If this is not a corrupt
560 migration, this means that the user was versioning their
561 install using Git."""
563 class NotConfiguredError(Error):
564 """The install was missing essential configuration."""
565 #: Directory of unconfigured install
567 def __init__(self, dir):
572 ERROR: The install was well-formed, but not configured
573 (essential configuration files were not found.)"""
575 class CorruptedAutoinstallError(Error):
576 """The install was missing a .git directory, but had a .scripts directory."""
577 #: Directory of the corrupted install
579 def __init__(self, dir):
584 ERROR: Directory contains a .scripts directory,
585 but not a .git directory."""
587 class NotAutoinstallError(Error):
588 """Application is not an autoinstall."""
589 #: Directory of the not autoinstall
591 def __init__(self, dir):
600 does not appear to be an autoinstall. If you are in a
601 subdirectory of an autoinstall, you need to use the root
602 directory for the autoinstall.""" % self.dir
604 class NoTagError(Error):
605 """Deployment has a tag that does not have an equivalent in upstream repository."""
608 def __init__(self, tag):
613 ERROR: Could not find tag %s in repository.""" % self.tag
615 class NoLocalTagError(Error):
616 """Could not find tag in local repository."""
619 def __init__(self, tag):
624 ERROR: Could not find tag %s in local repository.""" % self.tag
626 class InconsistentPristineTagError(Error):
627 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
630 def __init__(self, tag):
635 ERROR: Local pristine tag %s did not match repository's. This
636 probably means an upstream rebase occured.""" % self.tag
638 class InconsistentScriptsTagError(Error):
639 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
642 def __init__(self, tag):
647 ERROR: Local scripts tag %s did not match repository's. This
648 probably means an upstream rebase occurred.""" % self.tag
650 class HeadNotDescendantError(Error):
651 """HEAD is not connected to tag."""
652 #: Tag that HEAD should have been descendant of.
654 def __init__(self, tag):
659 ERROR: HEAD is not a descendant of %s. This probably
660 means that an upstream rebase occurred, and new tags were
661 pulled, but local user commits were never rebased.""" % self.tag
663 class VersionDetectionError(Error):
664 """Could not detect real version of application."""
668 ERROR: Could not detect the real version of the application."""
670 class VersionMismatchError(Error):
671 """Git version of application does not match detected version."""
676 def __init__(self, real_version, git_version):
677 self.real_version = real_version
678 self.git_version = git_version
682 ERROR: The detected version %s did not match the Git
683 version %s.""" % (self.real_version, self.git_version)
685 class WebVerificationError(Error):
686 """Could not access the application on the web"""
690 ERROR: We were not able to access the application on the
691 web. This may indicate that the website is behind
692 authentication on the htaccess level. You can find
693 the contents of the page from the debug backtraces."""
695 class DatabaseVerificationError(Error):
696 """Could not access the database"""
700 ERROR: We were not able to access the database for
701 this application; this probably means that your database
702 configuration is misconfigured."""
704 class UnknownWebPath(Error):
705 """Could not determine application's web path."""
709 ERROR: We were not able to determine what the application's
710 host and path were in order to perform a web request
711 on the application. You can specify this manually using
712 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment