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.application.detectVersion(self)
200 raise VersionDetectionError
201 elif not str(real) == self.app_version.pristine_tag.partition('-')[2]:
202 raise VersionMismatchError(real, self.version)
206 def configured(self):
207 """Whether or not an autoinstall has been configured/installed for use."""
208 return self.application.checkConfig(self)
211 """Whether or not the autoinstalls has been migrated."""
212 return os.path.isdir(self.scripts_dir)
214 def scripts_dir(self):
215 """The absolute path of the ``.scripts`` directory."""
216 return os.path.join(self.location, '.scripts')
218 def old_version_file(self):
220 The absolute path of either ``.scripts-version`` (for unmigrated
221 installs) or ``.scripts/version``.
225 Use of this is discouraged for migrated installs.
227 return os.path.join(self.location, '.scripts-version')
229 def version_file(self):
230 """The absolute path of the ``.scripts/version`` file."""
231 return os.path.join(self.scripts_dir, 'version')
234 """The absolute path of the :file:`.scripts/dsn` override file."""
235 return os.path.join(self.scripts_dir, 'dsn')
238 """The absolute path of the :file:`.scripts/url` override file."""
239 return os.path.join(self.scripts_dir, 'url')
241 def application(self):
242 """The :class:`app.Application` of this deployment."""
243 return self.app_version.application
247 The :class:`wizard.old_log.Log` of this deployment. This
248 is only applicable to un-migrated autoinstalls.
250 if not self._old_log:
251 self._old_log = old_log.DeployLog.load(self)
256 The :class:`distutils.version.LooseVersion` of this
259 return self.app_version.version
261 def app_version(self):
262 """The :class:`app.ApplicationVersion` of this deployment."""
263 if not self._app_version:
264 if os.path.isdir(os.path.join(self.location, ".git")):
266 with util.ChangeDirectory(self.location):
267 appname, _, version = git.describe().partition('-')
268 self._app_version = app.ApplicationVersion.make(appname, version)
269 except shell.CallError:
271 if not self._app_version:
273 self._app_version = self.old_log[-1].version
274 except old_log.ScriptsVersionNoSuchFile:
276 if not self._app_version:
277 appname = shell.eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
278 self._app_version = app.ApplicationVersion.make(appname, "unknown")
279 return self._app_version
282 """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
284 self._dsn = sql.fill_url(self.application.dsn(self))
288 """The :class:`urlparse.ParseResult` for this deployment."""
290 self._url = scripts.fill_url(self.location, self.application.url(self))
297 Parses a line from the :term:`versions store`.
301 Use this method only when speed is of the utmost
302 importance. You should prefer to directly create a deployment
303 with only a ``location`` when possible.
307 location, deploydir = line.split(":")
309 return ProductionCopy(line) # lazy loaded version
311 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
313 e.location = location
316 class ProductionCopy(Deployment):
318 Represents the production copy of a deployment. This copy
319 is canonical, and is the only one guaranteed to be accessible
320 via web, have a database, etc.
323 def upgrade(self, version, options):
325 Performs an upgrade of database schemas and other non-versioned data.
327 return self.application.upgrade(self, version, options)
329 def backup(self, options):
331 Performs a backup of database schemas and other non-versioned data.
333 # There are retarded amounts of race-safety in this function,
334 # because we do NOT want to claim to have made a backup, when
335 # actually something weird happened to it.
336 backupdir = os.path.join(self.scripts_dir, "backups")
337 if not os.path.exists(backupdir):
341 if e.errno == errno.EEXIST:
345 tmpdir = tempfile.mkdtemp() # actually will be kept around
347 self.application.backup(self, tmpdir, options)
348 except app.BackupFailure:
349 # the backup is bogus, don't let it show up
350 shutil.rmtree(tmpdir)
353 with util.LockDirectory(os.path.join(backupdir, "lock")):
355 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
356 outdir = os.path.join(backupdir, backup)
357 if os.path.exists(outdir):
358 logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
362 shutil.move(tmpdir, outdir)
364 # don't leave half-baked stuff lying around
366 shutil.rmtree(outdir)
373 def restore(self, backup, options):
375 Restores a backup. Destroys state, so be careful! Also, this does
376 NOT restore the file-level backup, which is what 'wizard restore'
377 does, so you probably do NOT want to call this elsewhere unless
378 you know what you're doing (call 'wizard restore' instead).
380 backup_dir = os.path.join(".scripts", "backups", backup)
381 return self.application.restore(self, backup_dir, options)
383 def remove(self, options):
385 Deletes all non-local or non-filesystem data (such as databases) that
386 this application uses.
388 self.application.remove(self, options)
391 Checks if the autoinstall is viewable from the web.
393 if not self.application.checkWeb(self):
394 raise WebVerificationError
395 def fetch(self, path, post=None):
397 Performs a HTTP request on the website.
399 return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
401 class WorkingCopy(Deployment):
403 Represents a temporary clone of a deployment that we can make
404 modifications to without fear of interfering with a production
405 deployment. More operations are permitted on these copies.
408 def parametrize(self, deployment):
410 Edits files in ``dir`` to replace WIZARD_* variables with literal
411 instances based on ``deployment``. This is used for constructing
412 virtual merge bases, and as such ``deployment`` will generally not
415 return self.application.parametrize(self, deployment)
417 def prepareConfig(self):
419 Edits files in the deployment such that any user-specific configuration
420 is replaced with generic WIZARD_* variables.
422 return self.application.prepareConfig(self)
424 def resolveConflicts(self):
426 Resolves conflicted files in this working copy. Returns whether or
427 not all conflicted files were resolved or not. Fully resolved
428 files are added to the index, but no commit is made.
430 return self.application.resolveConflicts(self)
432 def prepareMerge(self):
434 Performs various edits to files in the current working directory in
435 order to make a merge go more smoothly. This is usually
436 used to fix botched line-endings.
438 return self.application.prepareMerge(self)
442 class Error(wizard.Error):
443 """Base error class for this module"""
446 class NotMigratedError(Error):
448 The deployment contains a .scripts-version file, but no .git
449 or .scripts directory.
451 #: Directory of deployment
453 def __init__(self, dir):
456 return """This installation was not migrated"""
458 class AlreadyVersionedError(Error):
459 """The deployment contained a .git directory but no .scripts directory."""
460 #: Directory of deployment
462 def __init__(self, dir):
467 ERROR: Directory contains a .git directory, but not
468 a .scripts directory. If this is not a corrupt
469 migration, this means that the user was versioning their
470 install using Git."""
472 class NotConfiguredError(Error):
473 """The install was missing essential configuration."""
474 #: Directory of unconfigured install
476 def __init__(self, dir):
481 ERROR: The install was well-formed, but not configured
482 (essential configuration files were not found.)"""
484 class CorruptedAutoinstallError(Error):
485 """The install was missing a .git directory, but had a .scripts directory."""
486 #: Directory of the corrupted install
488 def __init__(self, dir):
493 ERROR: Directory contains a .scripts directory,
494 but not a .git directory."""
496 class NotAutoinstallError(Error):
497 """The directory was not an autoinstall, due to missing .scripts-version file."""
498 #: Directory in question
500 def __init__(self, dir):
505 ERROR: Could not find .scripts-version file. Are you sure
506 this is an autoinstalled application?
509 class NoTagError(Error):
510 """Deployment has a tag that does not have an equivalent in upstream repository."""
513 def __init__(self, tag):
518 ERROR: Could not find tag %s in repository.""" % self.tag
520 class NoLocalTagError(Error):
521 """Could not find tag in local repository."""
524 def __init__(self, tag):
529 ERROR: Could not find tag %s in local repository.""" % self.tag
531 class InconsistentPristineTagError(Error):
532 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
535 def __init__(self, tag):
540 ERROR: Local pristine tag %s did not match repository's. This
541 probably means an upstream rebase occured.""" % self.tag
543 class InconsistentScriptsTagError(Error):
544 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
547 def __init__(self, tag):
552 ERROR: Local scripts tag %s did not match repository's. This
553 probably means an upstream rebase occurred.""" % self.tag
555 class HeadNotDescendantError(Error):
556 """HEAD is not connected to tag."""
557 #: Tag that HEAD should have been descendant of.
559 def __init__(self, tag):
564 ERROR: HEAD is not a descendant of %s. This probably
565 means that an upstream rebase occurred, and new tags were
566 pulled, but local user commits were never rebased.""" % self.tag
568 class VersionDetectionError(Error):
569 """Could not detect real version of application."""
573 ERROR: Could not detect the real version of the application."""
575 class VersionMismatchError(Error):
576 """Git version of application does not match detected version."""
581 def __init__(self, real_version, git_version):
582 self.real_version = real_version
583 self.git_version = git_version
587 ERROR: The detected version %s did not match the Git
588 version %s.""" % (self.real_version, self.git_version)
590 class WebVerificationError(Error):
591 """Could not access the application on the web"""
595 ERROR: We were not able to access the application on the
596 web. This may indicate that the website is behind
597 authentication on the htaccess level. You can find
598 the contents of the page from the debug backtraces."""
600 class UnknownWebPath(Error):
601 """Could not determine application's web path."""
605 ERROR: We were not able to determine what the application's
606 host and path were in order to perform a web request
607 on the application. You can specify this manually using
608 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment