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 whic
82 produces a list of candidate urls.
88 for entry in pkg_resources.iter_entry_points("wizard.deploy.web"):
91 if isinstance(r, str):
92 r = urlparse.urlparse(r)
96 host = os.getenv("WIZARD_WEB_HOST")
97 path = os.getenv("WIZARD_WEB_PATH")
98 if host is not None and path is not None:
99 yield urlparse.ParseResult(
105 ## -- Model Objects --
108 def chdir_to_location(f, self, *args, **kwargs):
110 Decorator for making a function have working directory
111 :attr:`Deployment.location`.
113 with util.ChangeDirectory(self.location):
114 return f(self, *args, **kwargs)
116 class Deployment(object):
118 Represents a deployment of an autoinstall, e.g. directory
119 that has ``.scripts`` directory or ``.scripts-version``
120 file in it. Supply ``version`` with an :class:`ApplicationVersion` only if
121 you were reading from the :term:`versions store` and care about
122 speed (data from there can be stale).
124 The Deployment interface is somewhat neutered, so you may
125 want to use :class:`WorkingCopy` or :class:`ProductionCopy` for
126 more powerful operations.
128 #: Absolute path to the deployment
130 def __init__(self, location, version=None):
131 self.location = os.path.abspath(location)
132 self._app_version = version
133 # some cache variables
134 self._read_cache = {}
139 def invalidateCache(self):
141 Invalidates all cached variables. This currently applies to
142 :attr:`app_version`, :attr:`old_log` and :meth:`read`.
144 self._app_version = None
145 self._read_cache = {}
147 def read(self, file, force = False):
149 Reads a file's contents, possibly from cache unless ``force``
152 if force or file not in self._read_cache:
153 f = open(os.path.join(self.location, file))
154 self._read_cache[file] = f.read()
156 return self._read_cache[file]
159 Extracts all the values of all variables from deployment.
160 These variables may be used for parametrizing generic parent
161 commits and include things such as database access credentials
162 and local configuration.
164 return self.application.extract(self)
168 Checks if this is an autoinstall, throws an exception if there
171 with util.ChangeDirectory(self.location):
172 has_git = os.path.isdir(".git")
173 has_scripts = os.path.isdir(".scripts")
174 if not has_git and has_scripts:
175 raise CorruptedAutoinstallError(self.location)
176 elif has_git and not has_scripts:
177 raise AlreadyVersionedError(self.location)
178 elif not has_git and not has_scripts:
179 if os.path.isfile(".scripts-version"):
180 raise NotMigratedError(self.location)
182 raise NotAutoinstallError(self.location)
184 def verifyTag(self, srv_path):
186 Checks if the purported version has a corresponding tag
187 in the upstream repository.
189 repo = self.application.repository(srv_path)
191 shell.eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
192 except shell.CallError:
193 raise NoTagError(self.app_version.scripts_tag)
195 def verifyGit(self, srv_path):
197 Checks if the autoinstall's Git repository makes sense,
198 checking if the tag is parseable and corresponds to
199 a real application, and if the tag in this repository
200 corresponds to the one in the remote repository.
202 with util.ChangeDirectory(self.location):
203 repo = self.application.repository(srv_path)
204 def repo_rev_parse(tag):
205 return shell.eval("git", "--git-dir", repo, "rev-parse", tag)
206 def self_rev_parse(tag):
208 return shell.safeCall("git", "rev-parse", tag, strip=True)
209 except shell.CallError:
210 raise NoLocalTagError(tag)
211 def compare_tags(tag):
212 return repo_rev_parse(tag) == self_rev_parse(tag)
213 if not compare_tags(self.app_version.pristine_tag):
214 raise InconsistentPristineTagError(self.app_version.pristine_tag)
215 if not compare_tags(self.app_version.scripts_tag):
216 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
217 parent = repo_rev_parse(self.app_version.scripts_tag)
218 merge_base = shell.safeCall("git", "merge-base", parent, "HEAD", strip=True)
219 if merge_base != parent:
220 raise HeadNotDescendantError(self.app_version.scripts_tag)
222 def verifyConfigured(self):
224 Checks if the autoinstall is configured running.
226 if not self.configured:
227 raise NotConfiguredError(self.location)
230 def verifyVersion(self):
232 Checks if our version and the version number recorded in a file
235 real = self.detectVersion()
236 if not str(real) == self.app_version.pristine_tag.partition('-')[2]:
237 raise VersionMismatchError(real, self.version)
240 def detectVersion(self):
242 Returns the real version, based on filesystem, of install.
244 Throws a :class:`VersionDetectionError` if we couldn't figure out
245 what the real version was.
247 real = self.application.detectVersion(self)
249 raise VersionDetectionError
254 def configured(self):
255 """Whether or not an autoinstall has been configured/installed for use."""
256 return self.application.checkConfig(self)
259 """Whether or not the autoinstalls has been migrated."""
260 return os.path.isdir(self.scripts_dir)
262 def scripts_dir(self):
263 """The absolute path of the ``.scripts`` directory."""
264 return os.path.join(self.location, '.scripts')
266 def old_version_file(self):
268 The absolute path of either ``.scripts-version`` (for unmigrated
269 installs) or ``.scripts/version``.
273 Use of this is discouraged for migrated installs.
275 return os.path.join(self.location, '.scripts-version')
277 def version_file(self):
278 """The absolute path of the ``.scripts/version`` file."""
279 return os.path.join(self.scripts_dir, 'version')
282 """The absolute path of the :file:`.scripts/dsn` override file."""
283 return os.path.join(self.scripts_dir, 'dsn')
286 """The absolute path of the :file:`.scripts/url` override file."""
287 return os.path.join(self.scripts_dir, 'url')
289 def application(self):
290 """The :class:`app.Application` of this deployment."""
291 return self.app_version.application
295 The :class:`wizard.old_log.Log` of this deployment. This
296 is only applicable to un-migrated autoinstalls.
298 if not self._old_log:
299 self._old_log = old_log.DeployLog.load(self)
304 The :class:`distutils.version.LooseVersion` of this
307 return self.app_version.version
309 def app_version(self):
310 """The :class:`app.ApplicationVersion` of this deployment."""
311 if not self._app_version:
312 if os.path.isdir(os.path.join(self.location, ".git")):
314 with util.ChangeDirectory(self.location):
315 appname, _, version = git.describe().partition('-')
316 self._app_version = app.ApplicationVersion.make(appname, version)
317 except shell.CallError:
319 if not self._app_version:
321 self._app_version = self.old_log[-1].version
322 except old_log.ScriptsVersionNoSuchFile:
324 if not self._app_version:
325 appname = shell.eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
326 self._app_version = app.ApplicationVersion.make(appname, "unknown")
327 return self._app_version
330 """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
332 self._dsn = sql.fill_url(self.application.dsn(self))
336 """The :class:`urlparse.ParseResult` for this deployment."""
338 self._urlGen = web(self.location, self.application.url(self))
344 self._url = self._urlGen.next() # pylint: disable-msg=E1101
346 except StopIteration:
348 def enableOldStyleUrls(self):
350 Switches to using http://user.scripts.mit.edu/~user/app URLs.
351 No effect if they have an explicit .scripts/url override.
353 # XXX: This is pretty scripts specific
354 self._url = scripts.fill_url(self.location, self.application.url(self), old_style = True)
358 Parses a line from the :term:`versions store`.
362 Use this method only when speed is of the utmost
363 importance. You should prefer to directly create a deployment
364 with only a ``location`` when possible.
368 location, deploydir = line.split(":")
370 return ProductionCopy(line) # lazy loaded version
372 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
374 e.location = location
377 class ProductionCopy(Deployment):
379 Represents the production copy of a deployment. This copy
380 is canonical, and is the only one guaranteed to be accessible
381 via web, have a database, etc.
384 def upgrade(self, version, options):
386 Performs an upgrade of database schemas and other non-versioned data.
388 return self.application.upgrade(self, version, options)
390 def backup(self, options):
392 Performs a backup of database schemas and other non-versioned data.
394 # There are retarded amounts of race-safety in this function,
395 # because we do NOT want to claim to have made a backup, when
396 # actually something weird happened to it.
397 backupdir = os.path.join(self.scripts_dir, "backups")
398 if not os.path.exists(backupdir):
402 if e.errno == errno.EEXIST:
406 tmpdir = tempfile.mkdtemp() # actually will be kept around
408 self.application.backup(self, tmpdir, options)
409 except app.BackupFailure:
410 # the backup is bogus, don't let it show up
411 shutil.rmtree(tmpdir)
414 with util.LockDirectory(os.path.join(backupdir, "lock")):
416 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
417 outdir = os.path.join(backupdir, backup)
418 if os.path.exists(outdir):
419 logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
423 shutil.move(tmpdir, outdir)
425 # don't leave half-baked stuff lying around
427 shutil.rmtree(outdir)
434 def restore(self, backup, options):
436 Restores a backup. Destroys state, so be careful! Also, this does
437 NOT restore the file-level backup, which is what 'wizard restore'
438 does, so you probably do NOT want to call this elsewhere unless
439 you know what you're doing (call 'wizard restore' instead).
441 backup_dir = os.path.join(".scripts", "backups", backup)
442 return self.application.restore(self, backup_dir, options)
444 def remove(self, options):
446 Deletes all non-local or non-filesystem data (such as databases) that
447 this application uses.
449 self.application.remove(self, options)
450 def verifyDatabase(self):
452 Checks if the autoinstall has a properly configured database.
454 if not self.application.checkDatabase(self):
455 raise DatabaseVerificationError
458 Checks if the autoinstall is viewable from the web. If you do not run
459 this, there is no guarantee that the url returned by this application
463 if not self.application.checkWeb(self):
466 except UnknownWebPath:
467 raise WebVerificationError
470 def fetch(self, path, post=None):
472 Performs a HTTP request on the website.
474 return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
476 class WorkingCopy(Deployment):
478 Represents a temporary clone of a deployment that we can make
479 modifications to without fear of interfering with a production
480 deployment. More operations are permitted on these copies.
482 def setAppVersion(self, app_version):
484 Manually resets the application version; useful if the working
485 copy is off in space (i.e. not anchored to something we can
486 git describe off of.)
488 self._app_version = app_version
490 def parametrize(self, deployment):
492 Edits files in ``dir`` to replace WIZARD_* variables with literal
493 instances based on ``deployment``. This is used for constructing
494 virtual merge bases, and as such ``deployment`` will generally not
497 return self.application.parametrize(self, deployment)
499 def prepareConfig(self):
501 Edits files in the deployment such that any user-specific configuration
502 is replaced with generic WIZARD_* variables.
504 return self.application.prepareConfig(self)
506 def resolveConflicts(self):
508 Resolves conflicted files in this working copy. Returns whether or
509 not all conflicted files were resolved or not. Fully resolved
510 files are added to the index, but no commit is made.
512 return self.application.resolveConflicts(self)
514 def prepareMerge(self):
516 Performs various edits to files in the current working directory in
517 order to make a merge go more smoothly. This is usually
518 used to fix botched line-endings.
520 return self.application.prepareMerge(self)
524 class Error(wizard.Error):
525 """Base error class for this module"""
528 class NotMigratedError(Error):
530 The deployment contains a .scripts-version file, but no .git
531 or .scripts directory.
533 #: Directory of deployment
535 def __init__(self, dir):
538 return """This installation was not migrated"""
540 class AlreadyVersionedError(Error):
541 """The deployment contained a .git directory but no .scripts directory."""
542 #: Directory of deployment
544 def __init__(self, dir):
549 ERROR: Directory contains a .git directory, but not
550 a .scripts directory. If this is not a corrupt
551 migration, this means that the user was versioning their
552 install using Git."""
554 class NotConfiguredError(Error):
555 """The install was missing essential configuration."""
556 #: Directory of unconfigured install
558 def __init__(self, dir):
563 ERROR: The install was well-formed, but not configured
564 (essential configuration files were not found.)"""
566 class CorruptedAutoinstallError(Error):
567 """The install was missing a .git directory, but had a .scripts directory."""
568 #: Directory of the corrupted install
570 def __init__(self, dir):
575 ERROR: Directory contains a .scripts directory,
576 but not a .git directory."""
578 class NotAutoinstallError(Error):
579 """Application is not an autoinstall."""
580 #: Directory of the not autoinstall
582 def __init__(self, dir):
591 does not appear to be an autoinstall. If you are in a
592 subdirectory of an autoinstall, you need to use the root
593 directory for the autoinstall.""" % self.dir
595 class NoTagError(Error):
596 """Deployment has a tag that does not have an equivalent in upstream repository."""
599 def __init__(self, tag):
604 ERROR: Could not find tag %s in repository.""" % self.tag
606 class NoLocalTagError(Error):
607 """Could not find tag in local repository."""
610 def __init__(self, tag):
615 ERROR: Could not find tag %s in local repository.""" % self.tag
617 class InconsistentPristineTagError(Error):
618 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
621 def __init__(self, tag):
626 ERROR: Local pristine tag %s did not match repository's. This
627 probably means an upstream rebase occured.""" % self.tag
629 class InconsistentScriptsTagError(Error):
630 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
633 def __init__(self, tag):
638 ERROR: Local scripts tag %s did not match repository's. This
639 probably means an upstream rebase occurred.""" % self.tag
641 class HeadNotDescendantError(Error):
642 """HEAD is not connected to tag."""
643 #: Tag that HEAD should have been descendant of.
645 def __init__(self, tag):
650 ERROR: HEAD is not a descendant of %s. This probably
651 means that an upstream rebase occurred, and new tags were
652 pulled, but local user commits were never rebased.""" % self.tag
654 class VersionDetectionError(Error):
655 """Could not detect real version of application."""
659 ERROR: Could not detect the real version of the application."""
661 class VersionMismatchError(Error):
662 """Git version of application does not match detected version."""
667 def __init__(self, real_version, git_version):
668 self.real_version = real_version
669 self.git_version = git_version
673 ERROR: The detected version %s did not match the Git
674 version %s.""" % (self.real_version, self.git_version)
676 class WebVerificationError(Error):
677 """Could not access the application on the web"""
681 ERROR: We were not able to access the application on the
682 web. This may indicate that the website is behind
683 authentication on the htaccess level. You can find
684 the contents of the page from the debug backtraces."""
686 class DatabaseVerificationError(Error):
687 """Could not access the database"""
691 ERROR: We were not able to access the database for
692 this application; this probably means that your database
693 configuration is misconfigured."""
695 class UnknownWebPath(Error):
696 """Could not determine application's web path."""
700 ERROR: We were not able to determine what the application's
701 host and path were in order to perform a web request
702 on the application. You can specify this manually using
703 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment