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 def verifyTag(self, srv_path):
149 Checks if the purported version has a corresponding tag
150 in the upstream repository.
152 repo = self.application.repository(srv_path)
154 shell.eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
155 except shell.CallError:
156 raise NoTagError(self.app_version.scripts_tag)
158 def verifyGit(self, srv_path):
160 Checks if the autoinstall's Git repository makes sense,
161 checking if the tag is parseable and corresponds to
162 a real application, and if the tag in this repository
163 corresponds to the one in the remote repository.
165 with util.ChangeDirectory(self.location):
166 repo = self.application.repository(srv_path)
167 def repo_rev_parse(tag):
168 return shell.eval("git", "--git-dir", repo, "rev-parse", tag)
169 def self_rev_parse(tag):
171 return shell.safeCall("git", "rev-parse", tag, strip=True)
172 except shell.CallError:
173 raise NoLocalTagError(tag)
174 def compare_tags(tag):
175 return repo_rev_parse(tag) == self_rev_parse(tag)
176 if not compare_tags(self.app_version.pristine_tag):
177 raise InconsistentPristineTagError(self.app_version.pristine_tag)
178 if not compare_tags(self.app_version.scripts_tag):
179 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
180 parent = repo_rev_parse(self.app_version.scripts_tag)
181 merge_base = shell.safeCall("git", "merge-base", parent, "HEAD", strip=True)
182 if merge_base != parent:
183 raise HeadNotDescendantError(self.app_version.scripts_tag)
185 def verifyConfigured(self):
187 Checks if the autoinstall is configured running.
189 if not self.configured:
190 raise NotConfiguredError(self.location)
193 def verifyVersion(self):
195 Checks if our version and the version number recorded in a file
198 real = self.detectVersion()
199 if not str(real) == self.app_version.pristine_tag.partition('-')[2]:
200 raise VersionMismatchError(real, self.version)
203 def detectVersion(self):
205 Returns the real version, based on filesystem, of install.
207 Throws a :class:`VersionDetectionError` if we couldn't figure out
208 what the real version was.
210 real = self.application.detectVersion(self)
212 raise VersionDetectionError
217 def configured(self):
218 """Whether or not an autoinstall has been configured/installed for use."""
219 return self.application.checkConfig(self)
222 """Whether or not the autoinstalls has been migrated."""
223 return os.path.isdir(self.scripts_dir)
225 def scripts_dir(self):
226 """The absolute path of the ``.scripts`` directory."""
227 return os.path.join(self.location, '.scripts')
229 def old_version_file(self):
231 The absolute path of either ``.scripts-version`` (for unmigrated
232 installs) or ``.scripts/version``.
236 Use of this is discouraged for migrated installs.
238 return os.path.join(self.location, '.scripts-version')
240 def version_file(self):
241 """The absolute path of the ``.scripts/version`` file."""
242 return os.path.join(self.scripts_dir, 'version')
245 """The absolute path of the :file:`.scripts/dsn` override file."""
246 return os.path.join(self.scripts_dir, 'dsn')
249 """The absolute path of the :file:`.scripts/url` override file."""
250 return os.path.join(self.scripts_dir, 'url')
252 def application(self):
253 """The :class:`app.Application` of this deployment."""
254 return self.app_version.application
258 The :class:`wizard.old_log.Log` of this deployment. This
259 is only applicable to un-migrated autoinstalls.
261 if not self._old_log:
262 self._old_log = old_log.DeployLog.load(self)
267 The :class:`distutils.version.LooseVersion` of this
270 return self.app_version.version
272 def app_version(self):
273 """The :class:`app.ApplicationVersion` of this deployment."""
274 if not self._app_version:
275 if os.path.isdir(os.path.join(self.location, ".git")):
277 with util.ChangeDirectory(self.location):
278 appname, _, version = git.describe().partition('-')
279 self._app_version = app.ApplicationVersion.make(appname, version)
280 except shell.CallError:
282 if not self._app_version:
284 self._app_version = self.old_log[-1].version
285 except old_log.ScriptsVersionNoSuchFile:
287 if not self._app_version:
288 appname = shell.eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
289 self._app_version = app.ApplicationVersion.make(appname, "unknown")
290 return self._app_version
293 """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
295 self._dsn = sql.fill_url(self.application.dsn(self))
299 """The :class:`urlparse.ParseResult` for this deployment."""
301 self._url = scripts.fill_url(self.location, self.application.url(self))
305 def enableOldStyleUrls(self):
307 Switches to using http://user.scripts.mit.edu/~user/app URLs.
308 No effect if they have an explicit .scripts/url override.
310 self._url = scripts.fill_url(self.location, self.application.url(self), old_style = True)
314 Parses a line from the :term:`versions store`.
318 Use this method only when speed is of the utmost
319 importance. You should prefer to directly create a deployment
320 with only a ``location`` when possible.
324 location, deploydir = line.split(":")
326 return ProductionCopy(line) # lazy loaded version
328 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
330 e.location = location
333 class ProductionCopy(Deployment):
335 Represents the production copy of a deployment. This copy
336 is canonical, and is the only one guaranteed to be accessible
337 via web, have a database, etc.
340 def upgrade(self, version, options):
342 Performs an upgrade of database schemas and other non-versioned data.
344 return self.application.upgrade(self, version, options)
346 def backup(self, options):
348 Performs a backup of database schemas and other non-versioned data.
350 # There are retarded amounts of race-safety in this function,
351 # because we do NOT want to claim to have made a backup, when
352 # actually something weird happened to it.
353 backupdir = os.path.join(self.scripts_dir, "backups")
354 if not os.path.exists(backupdir):
358 if e.errno == errno.EEXIST:
362 tmpdir = tempfile.mkdtemp() # actually will be kept around
364 self.application.backup(self, tmpdir, options)
365 except app.BackupFailure:
366 # the backup is bogus, don't let it show up
367 shutil.rmtree(tmpdir)
370 with util.LockDirectory(os.path.join(backupdir, "lock")):
372 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
373 outdir = os.path.join(backupdir, backup)
374 if os.path.exists(outdir):
375 logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
379 shutil.move(tmpdir, outdir)
381 # don't leave half-baked stuff lying around
383 shutil.rmtree(outdir)
390 def restore(self, backup, options):
392 Restores a backup. Destroys state, so be careful! Also, this does
393 NOT restore the file-level backup, which is what 'wizard restore'
394 does, so you probably do NOT want to call this elsewhere unless
395 you know what you're doing (call 'wizard restore' instead).
397 backup_dir = os.path.join(".scripts", "backups", backup)
398 return self.application.restore(self, backup_dir, options)
400 def remove(self, options):
402 Deletes all non-local or non-filesystem data (such as databases) that
403 this application uses.
405 self.application.remove(self, options)
408 Checks if the autoinstall is viewable from the web.
410 if not self.application.checkWeb(self):
411 raise WebVerificationError
412 def fetch(self, path, post=None):
414 Performs a HTTP request on the website.
416 return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
418 class WorkingCopy(Deployment):
420 Represents a temporary clone of a deployment that we can make
421 modifications to without fear of interfering with a production
422 deployment. More operations are permitted on these copies.
424 def setAppVersion(self, app_version):
426 Manually resets the application version; useful if the working
427 copy is off in space (i.e. not anchored to something we can
428 git describe off of.)
430 self._app_version = app_version
432 def parametrize(self, deployment):
434 Edits files in ``dir`` to replace WIZARD_* variables with literal
435 instances based on ``deployment``. This is used for constructing
436 virtual merge bases, and as such ``deployment`` will generally not
439 return self.application.parametrize(self, deployment)
441 def prepareConfig(self):
443 Edits files in the deployment such that any user-specific configuration
444 is replaced with generic WIZARD_* variables.
446 return self.application.prepareConfig(self)
448 def resolveConflicts(self):
450 Resolves conflicted files in this working copy. Returns whether or
451 not all conflicted files were resolved or not. Fully resolved
452 files are added to the index, but no commit is made.
454 return self.application.resolveConflicts(self)
456 def prepareMerge(self):
458 Performs various edits to files in the current working directory in
459 order to make a merge go more smoothly. This is usually
460 used to fix botched line-endings.
462 return self.application.prepareMerge(self)
466 class Error(wizard.Error):
467 """Base error class for this module"""
470 class NotMigratedError(Error):
472 The deployment contains a .scripts-version file, but no .git
473 or .scripts directory.
475 #: Directory of deployment
477 def __init__(self, dir):
480 return """This installation was not migrated"""
482 class AlreadyVersionedError(Error):
483 """The deployment contained a .git directory but no .scripts directory."""
484 #: Directory of deployment
486 def __init__(self, dir):
491 ERROR: Directory contains a .git directory, but not
492 a .scripts directory. If this is not a corrupt
493 migration, this means that the user was versioning their
494 install using Git."""
496 class NotConfiguredError(Error):
497 """The install was missing essential configuration."""
498 #: Directory of unconfigured install
500 def __init__(self, dir):
505 ERROR: The install was well-formed, but not configured
506 (essential configuration files were not found.)"""
508 class CorruptedAutoinstallError(Error):
509 """The install was missing a .git directory, but had a .scripts directory."""
510 #: Directory of the corrupted install
512 def __init__(self, dir):
517 ERROR: Directory contains a .scripts directory,
518 but not a .git directory."""
520 class NotAutoinstallError(Error):
521 """The directory was not an autoinstall, due to missing .scripts-version file."""
522 #: Directory in question
524 def __init__(self, dir):
529 ERROR: Could not find .scripts-version file. Are you sure
530 this is an autoinstalled application?
533 class NoTagError(Error):
534 """Deployment has a tag that does not have an equivalent in upstream repository."""
537 def __init__(self, tag):
542 ERROR: Could not find tag %s in repository.""" % self.tag
544 class NoLocalTagError(Error):
545 """Could not find tag in local repository."""
548 def __init__(self, tag):
553 ERROR: Could not find tag %s in local repository.""" % self.tag
555 class InconsistentPristineTagError(Error):
556 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
559 def __init__(self, tag):
564 ERROR: Local pristine tag %s did not match repository's. This
565 probably means an upstream rebase occured.""" % self.tag
567 class InconsistentScriptsTagError(Error):
568 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
571 def __init__(self, tag):
576 ERROR: Local scripts tag %s did not match repository's. This
577 probably means an upstream rebase occurred.""" % self.tag
579 class HeadNotDescendantError(Error):
580 """HEAD is not connected to tag."""
581 #: Tag that HEAD should have been descendant of.
583 def __init__(self, tag):
588 ERROR: HEAD is not a descendant of %s. This probably
589 means that an upstream rebase occurred, and new tags were
590 pulled, but local user commits were never rebased.""" % self.tag
592 class VersionDetectionError(Error):
593 """Could not detect real version of application."""
597 ERROR: Could not detect the real version of the application."""
599 class VersionMismatchError(Error):
600 """Git version of application does not match detected version."""
605 def __init__(self, real_version, git_version):
606 self.real_version = real_version
607 self.git_version = git_version
611 ERROR: The detected version %s did not match the Git
612 version %s.""" % (self.real_version, self.git_version)
614 class WebVerificationError(Error):
615 """Could not access the application on the web"""
619 ERROR: We were not able to access the application on the
620 web. This may indicate that the website is behind
621 authentication on the htaccess level. You can find
622 the contents of the page from the debug backtraces."""
624 class UnknownWebPath(Error):
625 """Could not determine application's web path."""
629 ERROR: We were not able to determine what the application's
630 host and path were in order to perform a web request
631 on the application. You can specify this manually using
632 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment