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:
60 # we consider this a worse error
61 logging.warning("Error with '%s'" % line.rstrip())
64 if name + "-" + str(d.version) in show or name in show:
71 ## -- Model Objects --
74 def chdir_to_location(f, self, *args, **kwargs):
76 Decorator for making a function have working directory
77 :attr:`Deployment.location`.
79 with util.ChangeDirectory(self.location):
80 return f(self, *args, **kwargs)
82 class Deployment(object):
84 Represents a deployment of an autoinstall, e.g. directory
85 that has ``.scripts`` directory or ``.scripts-version``
86 file in it. Supply ``version`` with an :class:`ApplicationVersion` only if
87 you were reading from the :term:`versions store` and care about
88 speed (data from there can be stale).
90 The Deployment interface is somewhat neutered, so you may
91 want to use :class:`WorkingCopy` or :class:`ProductionCopy` for
92 more powerful operations.
94 #: Absolute path to the deployment
96 def __init__(self, location, version=None):
97 self.location = os.path.abspath(location)
98 self._app_version = version
99 # some cache variables
100 self._read_cache = {}
104 def invalidateCache(self):
106 Invalidates all cached variables. This currently applies to
107 :attr:`app_version`, :attr:`old_log` and :meth:`read`.
109 self._app_version = None
110 self._read_cache = {}
112 def read(self, file, force = False):
114 Reads a file's contents, possibly from cache unless ``force``
117 if force or file not in self._read_cache:
118 f = open(os.path.join(self.location, file))
119 self._read_cache[file] = f.read()
121 return self._read_cache[file]
124 Extracts all the values of all variables from deployment.
125 These variables may be used for parametrizing generic parent
126 commits and include things such as database access credentials
127 and local configuration.
129 return self.application.extract(self)
133 Checks if this is an autoinstall, throws an exception if there
136 with util.ChangeDirectory(self.location):
137 has_git = os.path.isdir(".git")
138 has_scripts = os.path.isdir(".scripts")
139 if not has_git and has_scripts:
140 raise CorruptedAutoinstallError(self.location)
141 elif has_git and not has_scripts:
142 raise AlreadyVersionedError(self.location)
143 elif not has_git and not has_scripts:
144 if os.path.isfile(".scripts-version"):
145 raise NotMigratedError(self.location)
147 raise NotAutoinstallError(self.location)
149 def verifyTag(self, srv_path):
151 Checks if the purported version has a corresponding tag
152 in the upstream repository.
154 repo = self.application.repository(srv_path)
156 shell.eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
157 except shell.CallError:
158 raise NoTagError(self.app_version.scripts_tag)
160 def verifyGit(self, srv_path):
162 Checks if the autoinstall's Git repository makes sense,
163 checking if the tag is parseable and corresponds to
164 a real application, and if the tag in this repository
165 corresponds to the one in the remote repository.
167 with util.ChangeDirectory(self.location):
168 repo = self.application.repository(srv_path)
169 def repo_rev_parse(tag):
170 return shell.eval("git", "--git-dir", repo, "rev-parse", tag)
171 def self_rev_parse(tag):
173 return shell.safeCall("git", "rev-parse", tag, strip=True)
174 except shell.CallError:
175 raise NoLocalTagError(tag)
176 def compare_tags(tag):
177 return repo_rev_parse(tag) == self_rev_parse(tag)
178 if not compare_tags(self.app_version.pristine_tag):
179 raise InconsistentPristineTagError(self.app_version.pristine_tag)
180 if not compare_tags(self.app_version.scripts_tag):
181 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
182 parent = repo_rev_parse(self.app_version.scripts_tag)
183 merge_base = shell.safeCall("git", "merge-base", parent, "HEAD", strip=True)
184 if merge_base != parent:
185 raise HeadNotDescendantError(self.app_version.scripts_tag)
187 def verifyConfigured(self):
189 Checks if the autoinstall is configured running.
191 if not self.configured:
192 raise NotConfiguredError(self.location)
195 def verifyVersion(self):
197 Checks if our version and the version number recorded in a file
200 real = self.detectVersion()
201 if not str(real) == self.app_version.pristine_tag.partition('-')[2]:
202 raise VersionMismatchError(real, self.version)
205 def detectVersion(self):
207 Returns the real version, based on filesystem, of install.
209 Throws a :class:`VersionDetectionError` if we couldn't figure out
210 what the real version was.
212 real = self.application.detectVersion(self)
214 raise VersionDetectionError
219 def configured(self):
220 """Whether or not an autoinstall has been configured/installed for use."""
221 return self.application.checkConfig(self)
224 """Whether or not the autoinstalls has been migrated."""
225 return os.path.isdir(self.scripts_dir)
227 def scripts_dir(self):
228 """The absolute path of the ``.scripts`` directory."""
229 return os.path.join(self.location, '.scripts')
231 def old_version_file(self):
233 The absolute path of either ``.scripts-version`` (for unmigrated
234 installs) or ``.scripts/version``.
238 Use of this is discouraged for migrated installs.
240 return os.path.join(self.location, '.scripts-version')
242 def version_file(self):
243 """The absolute path of the ``.scripts/version`` file."""
244 return os.path.join(self.scripts_dir, 'version')
247 """The absolute path of the :file:`.scripts/dsn` override file."""
248 return os.path.join(self.scripts_dir, 'dsn')
251 """The absolute path of the :file:`.scripts/url` override file."""
252 return os.path.join(self.scripts_dir, 'url')
254 def application(self):
255 """The :class:`app.Application` of this deployment."""
256 return self.app_version.application
260 The :class:`wizard.old_log.Log` of this deployment. This
261 is only applicable to un-migrated autoinstalls.
263 if not self._old_log:
264 self._old_log = old_log.DeployLog.load(self)
269 The :class:`distutils.version.LooseVersion` of this
272 return self.app_version.version
274 def app_version(self):
275 """The :class:`app.ApplicationVersion` of this deployment."""
276 if not self._app_version:
277 if os.path.isdir(os.path.join(self.location, ".git")):
279 with util.ChangeDirectory(self.location):
280 appname, _, version = git.describe().partition('-')
281 self._app_version = app.ApplicationVersion.make(appname, version)
282 except shell.CallError:
284 if not self._app_version:
286 self._app_version = self.old_log[-1].version
287 except old_log.ScriptsVersionNoSuchFile:
289 if not self._app_version:
290 appname = shell.eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
291 self._app_version = app.ApplicationVersion.make(appname, "unknown")
292 return self._app_version
295 """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
297 self._dsn = sql.fill_url(self.application.dsn(self))
301 """The :class:`urlparse.ParseResult` for this deployment."""
303 self._url = scripts.fill_url(self.location, self.application.url(self))
307 def enableOldStyleUrls(self):
309 Switches to using http://user.scripts.mit.edu/~user/app URLs.
310 No effect if they have an explicit .scripts/url override.
312 self._url = scripts.fill_url(self.location, self.application.url(self), old_style = True)
316 Parses a line from the :term:`versions store`.
320 Use this method only when speed is of the utmost
321 importance. You should prefer to directly create a deployment
322 with only a ``location`` when possible.
326 location, deploydir = line.split(":")
328 return ProductionCopy(line) # lazy loaded version
330 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
332 e.location = location
335 class ProductionCopy(Deployment):
337 Represents the production copy of a deployment. This copy
338 is canonical, and is the only one guaranteed to be accessible
339 via web, have a database, etc.
342 def upgrade(self, version, options):
344 Performs an upgrade of database schemas and other non-versioned data.
346 return self.application.upgrade(self, version, options)
348 def backup(self, options):
350 Performs a backup of database schemas and other non-versioned data.
352 # There are retarded amounts of race-safety in this function,
353 # because we do NOT want to claim to have made a backup, when
354 # actually something weird happened to it.
355 backupdir = os.path.join(self.scripts_dir, "backups")
356 if not os.path.exists(backupdir):
360 if e.errno == errno.EEXIST:
364 tmpdir = tempfile.mkdtemp() # actually will be kept around
366 self.application.backup(self, tmpdir, options)
367 except app.BackupFailure:
368 # the backup is bogus, don't let it show up
369 shutil.rmtree(tmpdir)
372 with util.LockDirectory(os.path.join(backupdir, "lock")):
374 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
375 outdir = os.path.join(backupdir, backup)
376 if os.path.exists(outdir):
377 logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
381 shutil.move(tmpdir, outdir)
383 # don't leave half-baked stuff lying around
385 shutil.rmtree(outdir)
392 def restore(self, backup, options):
394 Restores a backup. Destroys state, so be careful! Also, this does
395 NOT restore the file-level backup, which is what 'wizard restore'
396 does, so you probably do NOT want to call this elsewhere unless
397 you know what you're doing (call 'wizard restore' instead).
399 backup_dir = os.path.join(".scripts", "backups", backup)
400 return self.application.restore(self, backup_dir, options)
402 def remove(self, options):
404 Deletes all non-local or non-filesystem data (such as databases) that
405 this application uses.
407 self.application.remove(self, options)
408 def verifyDatabase(self):
410 Checks if the autoinstall has a properly configured database.
412 if not self.application.checkDatabase(self):
413 raise DatabaseVerificationError
416 Checks if the autoinstall is viewable from the web.
418 if not self.application.checkWeb(self):
419 raise WebVerificationError
420 def fetch(self, path, post=None):
422 Performs a HTTP request on the website.
424 return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
426 class WorkingCopy(Deployment):
428 Represents a temporary clone of a deployment that we can make
429 modifications to without fear of interfering with a production
430 deployment. More operations are permitted on these copies.
432 def setAppVersion(self, app_version):
434 Manually resets the application version; useful if the working
435 copy is off in space (i.e. not anchored to something we can
436 git describe off of.)
438 self._app_version = app_version
440 def parametrize(self, deployment):
442 Edits files in ``dir`` to replace WIZARD_* variables with literal
443 instances based on ``deployment``. This is used for constructing
444 virtual merge bases, and as such ``deployment`` will generally not
447 return self.application.parametrize(self, deployment)
449 def prepareConfig(self):
451 Edits files in the deployment such that any user-specific configuration
452 is replaced with generic WIZARD_* variables.
454 return self.application.prepareConfig(self)
456 def resolveConflicts(self):
458 Resolves conflicted files in this working copy. Returns whether or
459 not all conflicted files were resolved or not. Fully resolved
460 files are added to the index, but no commit is made.
462 return self.application.resolveConflicts(self)
464 def prepareMerge(self):
466 Performs various edits to files in the current working directory in
467 order to make a merge go more smoothly. This is usually
468 used to fix botched line-endings.
470 return self.application.prepareMerge(self)
474 class Error(wizard.Error):
475 """Base error class for this module"""
478 class NotMigratedError(Error):
480 The deployment contains a .scripts-version file, but no .git
481 or .scripts directory.
483 #: Directory of deployment
485 def __init__(self, dir):
488 return """This installation was not migrated"""
490 class AlreadyVersionedError(Error):
491 """The deployment contained a .git directory but no .scripts directory."""
492 #: Directory of deployment
494 def __init__(self, dir):
499 ERROR: Directory contains a .git directory, but not
500 a .scripts directory. If this is not a corrupt
501 migration, this means that the user was versioning their
502 install using Git."""
504 class NotConfiguredError(Error):
505 """The install was missing essential configuration."""
506 #: Directory of unconfigured install
508 def __init__(self, dir):
513 ERROR: The install was well-formed, but not configured
514 (essential configuration files were not found.)"""
516 class CorruptedAutoinstallError(Error):
517 """The install was missing a .git directory, but had a .scripts directory."""
518 #: Directory of the corrupted install
520 def __init__(self, dir):
525 ERROR: Directory contains a .scripts directory,
526 but not a .git directory."""
528 class NotAutoinstallError(Error):
529 """Application is not an autoinstall."""
530 #: Directory of the not autoinstall
532 def __init__(self, dir):
541 does not appear to be an autoinstall. If you are in a
542 subdirectory of an autoinstall, you need to use the root
543 directory for the autoinstall.""" % self.dir
545 class NoTagError(Error):
546 """Deployment has a tag that does not have an equivalent in upstream repository."""
549 def __init__(self, tag):
554 ERROR: Could not find tag %s in repository.""" % self.tag
556 class NoLocalTagError(Error):
557 """Could not find tag in local repository."""
560 def __init__(self, tag):
565 ERROR: Could not find tag %s in local repository.""" % self.tag
567 class InconsistentPristineTagError(Error):
568 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
571 def __init__(self, tag):
576 ERROR: Local pristine tag %s did not match repository's. This
577 probably means an upstream rebase occured.""" % self.tag
579 class InconsistentScriptsTagError(Error):
580 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
583 def __init__(self, tag):
588 ERROR: Local scripts tag %s did not match repository's. This
589 probably means an upstream rebase occurred.""" % self.tag
591 class HeadNotDescendantError(Error):
592 """HEAD is not connected to tag."""
593 #: Tag that HEAD should have been descendant of.
595 def __init__(self, tag):
600 ERROR: HEAD is not a descendant of %s. This probably
601 means that an upstream rebase occurred, and new tags were
602 pulled, but local user commits were never rebased.""" % self.tag
604 class VersionDetectionError(Error):
605 """Could not detect real version of application."""
609 ERROR: Could not detect the real version of the application."""
611 class VersionMismatchError(Error):
612 """Git version of application does not match detected version."""
617 def __init__(self, real_version, git_version):
618 self.real_version = real_version
619 self.git_version = git_version
623 ERROR: The detected version %s did not match the Git
624 version %s.""" % (self.real_version, self.git_version)
626 class WebVerificationError(Error):
627 """Could not access the application on the web"""
631 ERROR: We were not able to access the application on the
632 web. This may indicate that the website is behind
633 authentication on the htaccess level. You can find
634 the contents of the page from the debug backtraces."""
636 class DatabaseVerificationError(Error):
637 """Could not access the database"""
641 ERROR: We were not able to access the database for
642 this application; this probably means that your database
643 configuration is misconfigured."""
645 class UnknownWebPath(Error):
646 """Could not determine application's web path."""
650 ERROR: We were not able to determine what the application's
651 host and path were in order to perform a web request
652 on the application. You can specify this manually using
653 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment