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`.
19 from wizard import app, git, old_log, scripts, shell, sql, util
21 ## -- Global Functions --
23 def get_install_lines(versions_store, user=None):
25 Low level function that retrieves a list of lines from the
26 :term:`versions store` that can be passed to :meth:`Deployment.parse`.
28 if os.path.isfile(versions_store):
29 return fileinput.input([versions_store])
31 return fileinput.input([versions_store + "/" + user])
32 return fileinput.input([versions_store + "/" + f for f in sorted(os.listdir(versions_store))])
34 def parse_install_lines(show, versions_store, yield_errors = False, user = None):
36 Generator function for iterating through all autoinstalls.
37 Each item is an instance of :class:`Deployment`, or possibly
38 a :class:`wizard.deploy.Error` if ``yield_errors`` is ``True``. You can
39 filter out applications and versions by specifying ``app``
40 or ``app-1.2.3`` in ``show``. This function may generate
44 show = app.applications()
45 elif isinstance(show, str):
46 # otherwise, frozenset will treat string as an iterable
47 show = frozenset([show])
49 show = frozenset(show)
50 for line in get_install_lines(versions_store, user):
53 d = Deployment.parse(line)
54 name = d.application.name
55 except app.NoSuchApplication as e:
58 e.location = line.split(':')[0]
65 # we consider this a worse error
66 logging.warning("Error with '%s'" % line.rstrip())
69 if name + "-" + str(d.version) in show or name in show:
76 ## -- Model Objects --
79 def chdir_to_location(f, self, *args, **kwargs):
81 Decorator for making a function have working directory
82 :attr:`Deployment.location`.
84 with util.ChangeDirectory(self.location):
85 return f(self, *args, **kwargs)
87 class Deployment(object):
89 Represents a deployment of an autoinstall, e.g. directory
90 that has ``.scripts`` directory or ``.scripts-version``
91 file in it. Supply ``version`` with an :class:`ApplicationVersion` only if
92 you were reading from the :term:`versions store` and care about
93 speed (data from there can be stale).
95 The Deployment interface is somewhat neutered, so you may
96 want to use :class:`WorkingCopy` or :class:`ProductionCopy` for
97 more powerful operations.
99 #: Absolute path to the deployment
101 def __init__(self, location, version=None):
102 self.location = os.path.abspath(location)
103 self._app_version = version
104 # some cache variables
105 self._read_cache = {}
109 def invalidateCache(self):
111 Invalidates all cached variables. This currently applies to
112 :attr:`app_version`, :attr:`old_log` and :meth:`read`.
114 self._app_version = None
115 self._read_cache = {}
117 def read(self, file, force = False):
119 Reads a file's contents, possibly from cache unless ``force``
122 if force or file not in self._read_cache:
123 f = open(os.path.join(self.location, file))
124 self._read_cache[file] = f.read()
126 return self._read_cache[file]
129 Extracts all the values of all variables from deployment.
130 These variables may be used for parametrizing generic parent
131 commits and include things such as database access credentials
132 and local configuration.
134 return self.application.extract(self)
138 Checks if this is an autoinstall, throws an exception if there
141 with util.ChangeDirectory(self.location):
142 has_git = os.path.isdir(".git")
143 has_scripts = os.path.isdir(".scripts")
144 if not has_git and has_scripts:
145 raise CorruptedAutoinstallError(self.location)
146 elif has_git and not has_scripts:
147 raise AlreadyVersionedError(self.location)
148 elif not has_git and not has_scripts:
149 if os.path.isfile(".scripts-version"):
150 raise NotMigratedError(self.location)
152 raise NotAutoinstallError(self.location)
154 def verifyTag(self, srv_path):
156 Checks if the purported version has a corresponding tag
157 in the upstream repository.
159 repo = self.application.repository(srv_path)
161 shell.eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
162 except shell.CallError:
163 raise NoTagError(self.app_version.scripts_tag)
165 def verifyGit(self, srv_path):
167 Checks if the autoinstall's Git repository makes sense,
168 checking if the tag is parseable and corresponds to
169 a real application, and if the tag in this repository
170 corresponds to the one in the remote repository.
172 with util.ChangeDirectory(self.location):
173 repo = self.application.repository(srv_path)
174 def repo_rev_parse(tag):
175 return shell.eval("git", "--git-dir", repo, "rev-parse", tag)
176 def self_rev_parse(tag):
178 return shell.safeCall("git", "rev-parse", tag, strip=True)
179 except shell.CallError:
180 raise NoLocalTagError(tag)
181 def compare_tags(tag):
182 return repo_rev_parse(tag) == self_rev_parse(tag)
183 if not compare_tags(self.app_version.pristine_tag):
184 raise InconsistentPristineTagError(self.app_version.pristine_tag)
185 if not compare_tags(self.app_version.scripts_tag):
186 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
187 parent = repo_rev_parse(self.app_version.scripts_tag)
188 merge_base = shell.safeCall("git", "merge-base", parent, "HEAD", strip=True)
189 if merge_base != parent:
190 raise HeadNotDescendantError(self.app_version.scripts_tag)
192 def verifyConfigured(self):
194 Checks if the autoinstall is configured running.
196 if not self.configured:
197 raise NotConfiguredError(self.location)
200 def verifyVersion(self):
202 Checks if our version and the version number recorded in a file
205 real = self.detectVersion()
206 if not str(real) == self.app_version.pristine_tag.partition('-')[2]:
207 raise VersionMismatchError(real, self.version)
210 def detectVersion(self):
212 Returns the real version, based on filesystem, of install.
214 Throws a :class:`VersionDetectionError` if we couldn't figure out
215 what the real version was.
217 real = self.application.detectVersion(self)
219 raise VersionDetectionError
224 def configured(self):
225 """Whether or not an autoinstall has been configured/installed for use."""
226 return self.application.checkConfig(self)
229 """Whether or not the autoinstalls has been migrated."""
230 return os.path.isdir(self.scripts_dir)
232 def scripts_dir(self):
233 """The absolute path of the ``.scripts`` directory."""
234 return os.path.join(self.location, '.scripts')
236 def old_version_file(self):
238 The absolute path of either ``.scripts-version`` (for unmigrated
239 installs) or ``.scripts/version``.
243 Use of this is discouraged for migrated installs.
245 return os.path.join(self.location, '.scripts-version')
247 def version_file(self):
248 """The absolute path of the ``.scripts/version`` file."""
249 return os.path.join(self.scripts_dir, 'version')
252 """The absolute path of the :file:`.scripts/dsn` override file."""
253 return os.path.join(self.scripts_dir, 'dsn')
256 """The absolute path of the :file:`.scripts/url` override file."""
257 return os.path.join(self.scripts_dir, 'url')
259 def application(self):
260 """The :class:`app.Application` of this deployment."""
261 return self.app_version.application
265 The :class:`wizard.old_log.Log` of this deployment. This
266 is only applicable to un-migrated autoinstalls.
268 if not self._old_log:
269 self._old_log = old_log.DeployLog.load(self)
274 The :class:`distutils.version.LooseVersion` of this
277 return self.app_version.version
279 def app_version(self):
280 """The :class:`app.ApplicationVersion` of this deployment."""
281 if not self._app_version:
282 if os.path.isdir(os.path.join(self.location, ".git")):
284 with util.ChangeDirectory(self.location):
285 appname, _, version = git.describe().partition('-')
286 self._app_version = app.ApplicationVersion.make(appname, version)
287 except shell.CallError:
289 if not self._app_version:
291 self._app_version = self.old_log[-1].version
292 except old_log.ScriptsVersionNoSuchFile:
294 if not self._app_version:
295 appname = shell.eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
296 self._app_version = app.ApplicationVersion.make(appname, "unknown")
297 return self._app_version
300 """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
302 self._dsn = sql.fill_url(self.application.dsn(self))
306 """The :class:`urlparse.ParseResult` for this deployment."""
308 self._url = scripts.fill_url(self.location, self.application.url(self))
312 def enableOldStyleUrls(self):
314 Switches to using http://user.scripts.mit.edu/~user/app URLs.
315 No effect if they have an explicit .scripts/url override.
317 self._url = scripts.fill_url(self.location, self.application.url(self), old_style = True)
321 Parses a line from the :term:`versions store`.
325 Use this method only when speed is of the utmost
326 importance. You should prefer to directly create a deployment
327 with only a ``location`` when possible.
331 location, deploydir = line.split(":")
333 return ProductionCopy(line) # lazy loaded version
335 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
337 e.location = location
340 class ProductionCopy(Deployment):
342 Represents the production copy of a deployment. This copy
343 is canonical, and is the only one guaranteed to be accessible
344 via web, have a database, etc.
347 def upgrade(self, version, options):
349 Performs an upgrade of database schemas and other non-versioned data.
351 return self.application.upgrade(self, version, options)
353 def backup(self, options):
355 Performs a backup of database schemas and other non-versioned data.
357 # There are retarded amounts of race-safety in this function,
358 # because we do NOT want to claim to have made a backup, when
359 # actually something weird happened to it.
360 backupdir = os.path.join(self.scripts_dir, "backups")
361 if not os.path.exists(backupdir):
365 if e.errno == errno.EEXIST:
369 tmpdir = tempfile.mkdtemp() # actually will be kept around
371 self.application.backup(self, tmpdir, options)
372 except app.BackupFailure:
373 # the backup is bogus, don't let it show up
374 shutil.rmtree(tmpdir)
377 with util.LockDirectory(os.path.join(backupdir, "lock")):
379 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
380 outdir = os.path.join(backupdir, backup)
381 if os.path.exists(outdir):
382 logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
386 shutil.move(tmpdir, outdir)
388 # don't leave half-baked stuff lying around
390 shutil.rmtree(outdir)
397 def restore(self, backup, options):
399 Restores a backup. Destroys state, so be careful! Also, this does
400 NOT restore the file-level backup, which is what 'wizard restore'
401 does, so you probably do NOT want to call this elsewhere unless
402 you know what you're doing (call 'wizard restore' instead).
404 backup_dir = os.path.join(".scripts", "backups", backup)
405 return self.application.restore(self, backup_dir, options)
407 def remove(self, options):
409 Deletes all non-local or non-filesystem data (such as databases) that
410 this application uses.
412 self.application.remove(self, options)
413 def verifyDatabase(self):
415 Checks if the autoinstall has a properly configured database.
417 if not self.application.checkDatabase(self):
418 raise DatabaseVerificationError
421 Checks if the autoinstall is viewable from the web.
423 if not self.application.checkWeb(self):
424 raise WebVerificationError
425 def fetch(self, path, post=None):
427 Performs a HTTP request on the website.
429 return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
431 class WorkingCopy(Deployment):
433 Represents a temporary clone of a deployment that we can make
434 modifications to without fear of interfering with a production
435 deployment. More operations are permitted on these copies.
437 def setAppVersion(self, app_version):
439 Manually resets the application version; useful if the working
440 copy is off in space (i.e. not anchored to something we can
441 git describe off of.)
443 self._app_version = app_version
445 def parametrize(self, deployment):
447 Edits files in ``dir`` to replace WIZARD_* variables with literal
448 instances based on ``deployment``. This is used for constructing
449 virtual merge bases, and as such ``deployment`` will generally not
452 return self.application.parametrize(self, deployment)
454 def prepareConfig(self):
456 Edits files in the deployment such that any user-specific configuration
457 is replaced with generic WIZARD_* variables.
459 return self.application.prepareConfig(self)
461 def resolveConflicts(self):
463 Resolves conflicted files in this working copy. Returns whether or
464 not all conflicted files were resolved or not. Fully resolved
465 files are added to the index, but no commit is made.
467 return self.application.resolveConflicts(self)
469 def prepareMerge(self):
471 Performs various edits to files in the current working directory in
472 order to make a merge go more smoothly. This is usually
473 used to fix botched line-endings.
475 return self.application.prepareMerge(self)
479 class Error(wizard.Error):
480 """Base error class for this module"""
483 class NotMigratedError(Error):
485 The deployment contains a .scripts-version file, but no .git
486 or .scripts directory.
488 #: Directory of deployment
490 def __init__(self, dir):
493 return """This installation was not migrated"""
495 class AlreadyVersionedError(Error):
496 """The deployment contained a .git directory but no .scripts directory."""
497 #: Directory of deployment
499 def __init__(self, dir):
504 ERROR: Directory contains a .git directory, but not
505 a .scripts directory. If this is not a corrupt
506 migration, this means that the user was versioning their
507 install using Git."""
509 class NotConfiguredError(Error):
510 """The install was missing essential configuration."""
511 #: Directory of unconfigured install
513 def __init__(self, dir):
518 ERROR: The install was well-formed, but not configured
519 (essential configuration files were not found.)"""
521 class CorruptedAutoinstallError(Error):
522 """The install was missing a .git directory, but had a .scripts directory."""
523 #: Directory of the corrupted install
525 def __init__(self, dir):
530 ERROR: Directory contains a .scripts directory,
531 but not a .git directory."""
533 class NotAutoinstallError(Error):
534 """Application is not an autoinstall."""
535 #: Directory of the not autoinstall
537 def __init__(self, dir):
546 does not appear to be an autoinstall. If you are in a
547 subdirectory of an autoinstall, you need to use the root
548 directory for the autoinstall.""" % self.dir
550 class NoTagError(Error):
551 """Deployment has a tag that does not have an equivalent in upstream repository."""
554 def __init__(self, tag):
559 ERROR: Could not find tag %s in repository.""" % self.tag
561 class NoLocalTagError(Error):
562 """Could not find tag in local repository."""
565 def __init__(self, tag):
570 ERROR: Could not find tag %s in local repository.""" % self.tag
572 class InconsistentPristineTagError(Error):
573 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
576 def __init__(self, tag):
581 ERROR: Local pristine tag %s did not match repository's. This
582 probably means an upstream rebase occured.""" % self.tag
584 class InconsistentScriptsTagError(Error):
585 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
588 def __init__(self, tag):
593 ERROR: Local scripts tag %s did not match repository's. This
594 probably means an upstream rebase occurred.""" % self.tag
596 class HeadNotDescendantError(Error):
597 """HEAD is not connected to tag."""
598 #: Tag that HEAD should have been descendant of.
600 def __init__(self, tag):
605 ERROR: HEAD is not a descendant of %s. This probably
606 means that an upstream rebase occurred, and new tags were
607 pulled, but local user commits were never rebased.""" % self.tag
609 class VersionDetectionError(Error):
610 """Could not detect real version of application."""
614 ERROR: Could not detect the real version of the application."""
616 class VersionMismatchError(Error):
617 """Git version of application does not match detected version."""
622 def __init__(self, real_version, git_version):
623 self.real_version = real_version
624 self.git_version = git_version
628 ERROR: The detected version %s did not match the Git
629 version %s.""" % (self.real_version, self.git_version)
631 class WebVerificationError(Error):
632 """Could not access the application on the web"""
636 ERROR: We were not able to access the application on the
637 web. This may indicate that the website is behind
638 authentication on the htaccess level. You can find
639 the contents of the page from the debug backtraces."""
641 class DatabaseVerificationError(Error):
642 """Could not access the database"""
646 ERROR: We were not able to access the database for
647 this application; this probably means that your database
648 configuration is misconfigured."""
650 class UnknownWebPath(Error):
651 """Could not determine application's web path."""
655 ERROR: We were not able to determine what the application's
656 host and path were in order to perform a web request
657 on the application. You can specify this manually using
658 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment