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 which
82 produces a list of candidate urls.
84 This function implements a plugin interface named :ref:`wizard.deploy.web`.
87 if isinstance(url, str):
88 url = urlparse.urlparse(url)
92 for entry in pkg_resources.iter_entry_points("wizard.deploy.web"):
95 if isinstance(r, str):
96 r = urlparse.urlparse(r)
100 host = os.getenv("WIZARD_WEB_HOST")
101 path = os.getenv("WIZARD_WEB_PATH")
102 if host is not None and path is not None:
103 yield urlparse.ParseResult(
109 ## -- Model Objects --
112 def chdir_to_location(f, self, *args, **kwargs):
114 Decorator for making a function have working directory
115 :attr:`Deployment.location`.
117 with util.ChangeDirectory(self.location):
118 return f(self, *args, **kwargs)
120 class Deployment(object):
122 Represents a deployment of an autoinstall, e.g. directory
123 that has ``.scripts`` directory or ``.scripts-version``
124 file in it. Supply ``version`` with an :class:`ApplicationVersion` only if
125 you were reading from the :term:`versions store` and care about
126 speed (data from there can be stale).
128 The Deployment interface is somewhat neutered, so you may
129 want to use :class:`WorkingCopy` or :class:`ProductionCopy` for
130 more powerful operations.
132 #: Absolute path to the deployment
134 def __init__(self, location, version=None):
135 self.location = os.path.abspath(location)
136 self._app_version = version
137 # some cache variables
138 self._read_cache = {}
143 def invalidateCache(self):
145 Invalidates all cached variables. This currently applies to
146 :attr:`app_version`, :attr:`old_log` and :meth:`read`.
148 self._app_version = None
149 self._read_cache = {}
151 def read(self, file, force = False):
153 Reads a file's contents, possibly from cache unless ``force``
156 if force or file not in self._read_cache:
157 f = open(os.path.join(self.location, file))
158 self._read_cache[file] = f.read()
160 return self._read_cache[file]
163 Extracts all the values of all variables from deployment.
164 These variables may be used for parametrizing generic parent
165 commits and include things such as database access credentials
166 and local configuration.
168 return self.application.extract(self)
172 Checks if this is an autoinstall, throws an exception if there
175 with util.ChangeDirectory(self.location):
176 has_git = os.path.isdir(".git")
177 has_scripts = os.path.isdir(".scripts")
178 if not has_git and has_scripts:
179 raise CorruptedAutoinstallError(self.location)
180 elif has_git and not has_scripts:
181 raise AlreadyVersionedError(self.location)
182 elif not has_git and not has_scripts:
183 if os.path.isfile(".scripts-version"):
184 raise NotMigratedError(self.location)
186 raise NotAutoinstallError(self.location)
188 def verifyTag(self, srv_path):
190 Checks if the purported version has a corresponding tag
191 in the upstream repository.
193 repo = self.application.repository(srv_path)
195 shell.eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
196 except shell.CallError:
197 raise NoTagError(self.app_version.scripts_tag)
199 def verifyGit(self, srv_path):
201 Checks if the autoinstall's Git repository makes sense,
202 checking if the tag is parseable and corresponds to
203 a real application, and if the tag in this repository
204 corresponds to the one in the remote repository.
206 with util.ChangeDirectory(self.location):
207 repo = self.application.repository(srv_path)
208 def repo_rev_parse(tag):
209 return shell.eval("git", "--git-dir", repo, "rev-parse", tag)
210 def self_rev_parse(tag):
212 return shell.safeCall("git", "rev-parse", tag, strip=True)
213 except shell.CallError:
214 raise NoLocalTagError(tag)
215 def compare_tags(tag):
216 return repo_rev_parse(tag) == self_rev_parse(tag)
217 if not compare_tags(self.app_version.pristine_tag):
218 raise InconsistentPristineTagError(self.app_version.pristine_tag)
219 if not compare_tags(self.app_version.scripts_tag):
220 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
221 parent = repo_rev_parse(self.app_version.scripts_tag)
222 merge_base = shell.safeCall("git", "merge-base", parent, "HEAD", strip=True)
223 if merge_base != parent:
224 raise HeadNotDescendantError(self.app_version.scripts_tag)
226 def verifyConfigured(self):
228 Checks if the autoinstall is configured running.
230 if not self.configured:
231 raise NotConfiguredError(self.location)
234 def verifyVersion(self):
236 Checks if our version and the version number recorded in a file
239 real = self.detectVersion()
240 if not str(real) == self.app_version.pristine_tag.partition('-')[2]:
241 raise VersionMismatchError(real, self.version)
244 def detectVersion(self):
246 Returns the real version, based on filesystem, of install.
248 Throws a :class:`VersionDetectionError` if we couldn't figure out
249 what the real version was.
251 real = self.application.detectVersion(self)
253 raise VersionDetectionError
258 def configured(self):
259 """Whether or not an autoinstall has been configured/installed for use."""
260 return self.application.checkConfig(self)
263 """Whether or not the autoinstalls has been migrated."""
264 return os.path.isdir(self.scripts_dir)
266 def scripts_dir(self):
267 """The absolute path of the ``.scripts`` directory."""
268 return os.path.join(self.location, '.scripts')
270 def old_version_file(self):
272 The absolute path of either ``.scripts-version`` (for unmigrated
273 installs) or ``.scripts/version``.
277 Use of this is discouraged for migrated installs.
279 return os.path.join(self.location, '.scripts-version')
281 def version_file(self):
282 """The absolute path of the ``.scripts/version`` file."""
283 return os.path.join(self.scripts_dir, 'version')
286 """The absolute path of the :file:`.scripts/dsn` override file."""
287 return os.path.join(self.scripts_dir, 'dsn')
290 """The absolute path of the :file:`.scripts/url` override file."""
291 return os.path.join(self.scripts_dir, 'url')
293 def application(self):
294 """The :class:`app.Application` of this deployment."""
295 return self.app_version.application
299 The :class:`wizard.old_log.Log` of this deployment. This
300 is only applicable to un-migrated autoinstalls.
302 if not self._old_log:
303 self._old_log = old_log.DeployLog.load(self)
308 The :class:`distutils.version.LooseVersion` of this
311 return self.app_version.version
313 def app_version(self):
314 """The :class:`app.ApplicationVersion` of this deployment."""
315 if not self._app_version:
316 if os.path.isdir(os.path.join(self.location, ".git")):
318 with util.ChangeDirectory(self.location):
319 appname, _, version = git.describe().partition('-')
320 self._app_version = app.ApplicationVersion.make(appname, version)
321 except shell.CallError:
323 if not self._app_version:
325 self._app_version = self.old_log[-1].version
326 except old_log.ScriptsVersionNoSuchFile:
328 if not self._app_version:
329 appname = shell.eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
330 self._app_version = app.ApplicationVersion.make(appname, "unknown")
331 return self._app_version
334 """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
336 self._dsn = sql.auth(self.application.dsn(self))
340 """The :class:`urlparse.ParseResult` for this deployment."""
346 Initializes :attr:`url` with a possible URL the web application may
347 be located at. It may be called again to switch to another possible
348 URL, usually in the event of a web access failure.
351 self._urlGen = web(self.location, self.application.url(self))
353 self._url = self._urlGen.next() # pylint: disable-msg=E1101
355 except StopIteration:
360 Parses a line from the :term:`versions store`.
364 Use this method only when speed is of the utmost
365 importance. You should prefer to directly create a deployment
366 with only a ``location`` when possible.
370 location, deploydir = line.split(":")
372 return ProductionCopy(line) # lazy loaded version
374 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
376 e.location = location
379 class ProductionCopy(Deployment):
381 Represents the production copy of a deployment. This copy
382 is canonical, and is the only one guaranteed to be accessible
383 via web, have a database, etc.
386 def upgrade(self, version, options):
388 Performs an upgrade of database schemas and other non-versioned data.
390 return self.application.upgrade(self, version, options)
392 def backup(self, options):
394 Performs a backup of database schemas and other non-versioned data.
396 # There are retarded amounts of race-safety in this function,
397 # because we do NOT want to claim to have made a backup, when
398 # actually something weird happened to it.
399 backupdir = os.path.join(self.scripts_dir, "backups")
400 if not os.path.exists(backupdir):
404 if e.errno == errno.EEXIST:
408 tmpdir = tempfile.mkdtemp() # actually will be kept around
410 self.application.backup(self, tmpdir, options)
411 except app.BackupFailure:
412 # the backup is bogus, don't let it show up
413 shutil.rmtree(tmpdir)
416 with util.LockDirectory(os.path.join(backupdir, "lock")):
418 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
419 outdir = os.path.join(backupdir, backup)
420 if os.path.exists(outdir):
421 logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
425 shutil.move(tmpdir, outdir)
427 # don't leave half-baked stuff lying around
429 shutil.rmtree(outdir)
436 def restore(self, backup, options):
438 Restores a backup. Destroys state, so be careful! Also, this does
439 NOT restore the file-level backup, which is what 'wizard restore'
440 does, so you probably do NOT want to call this elsewhere unless
441 you know what you're doing (call 'wizard restore' instead).
443 backup_dir = os.path.join(".scripts", "backups", backup)
444 return self.application.restore(self, backup_dir, options)
446 def remove(self, options):
448 Deletes all non-local or non-filesystem data (such as databases) that
449 this application uses.
451 self.application.remove(self, options)
452 def verifyDatabase(self):
454 Checks if the autoinstall has a properly configured database.
456 if not self.application.checkDatabase(self):
457 raise DatabaseVerificationError
460 Checks if the autoinstall is viewable from the web. If you do not run
461 this, there is no guarantee that the url returned by this application
465 if not self.application.checkWeb(self):
468 except UnknownWebPath:
469 raise WebVerificationError
472 def fetch(self, path, post=None):
474 Performs a HTTP request on the website.
476 return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
478 class WorkingCopy(Deployment):
480 Represents a temporary clone of a deployment that we can make
481 modifications to without fear of interfering with a production
482 deployment. More operations are permitted on these copies.
484 def setAppVersion(self, app_version):
486 Manually resets the application version; useful if the working
487 copy is off in space (i.e. not anchored to something we can
488 git describe off of.)
490 self._app_version = app_version
492 def parametrize(self, deployment):
494 Edits files in ``dir`` to replace WIZARD_* variables with literal
495 instances based on ``deployment``. This is used for constructing
496 virtual merge bases, and as such ``deployment`` will generally not
499 return self.application.parametrize(self, deployment)
501 def prepareConfig(self):
503 Edits files in the deployment such that any user-specific configuration
504 is replaced with generic WIZARD_* variables.
506 return self.application.prepareConfig(self)
508 def resolveConflicts(self):
510 Resolves conflicted files in this working copy. Returns whether or
511 not all conflicted files were resolved or not. Fully resolved
512 files are added to the index, but no commit is made.
514 return self.application.resolveConflicts(self)
516 def prepareMerge(self):
518 Performs various edits to files in the current working directory in
519 order to make a merge go more smoothly. This is usually
520 used to fix botched line-endings.
522 return self.application.prepareMerge(self)
526 class Error(wizard.Error):
527 """Base error class for this module"""
530 class NotMigratedError(Error):
532 The deployment contains a .scripts-version file, but no .git
533 or .scripts directory.
535 #: Directory of deployment
537 def __init__(self, dir):
540 return """This installation was not migrated"""
542 class AlreadyVersionedError(Error):
543 """The deployment contained a .git directory but no .scripts directory."""
544 #: Directory of deployment
546 def __init__(self, dir):
551 ERROR: Directory contains a .git directory, but not
552 a .scripts directory. If this is not a corrupt
553 migration, this means that the user was versioning their
554 install using Git."""
556 class NotConfiguredError(Error):
557 """The install was missing essential configuration."""
558 #: Directory of unconfigured install
560 def __init__(self, dir):
565 ERROR: The install was well-formed, but not configured
566 (essential configuration files were not found.)"""
568 class CorruptedAutoinstallError(Error):
569 """The install was missing a .git directory, but had a .scripts directory."""
570 #: Directory of the corrupted install
572 def __init__(self, dir):
577 ERROR: Directory contains a .scripts directory,
578 but not a .git directory."""
580 class NotAutoinstallError(Error):
581 """Application is not an autoinstall."""
582 #: Directory of the not autoinstall
584 def __init__(self, dir):
593 does not appear to be an autoinstall. If you are in a
594 subdirectory of an autoinstall, you need to use the root
595 directory for the autoinstall.""" % self.dir
597 class NoTagError(Error):
598 """Deployment has a tag that does not have an equivalent in upstream repository."""
601 def __init__(self, tag):
606 ERROR: Could not find tag %s in repository.""" % self.tag
608 class NoLocalTagError(Error):
609 """Could not find tag in local repository."""
612 def __init__(self, tag):
617 ERROR: Could not find tag %s in local repository.""" % self.tag
619 class InconsistentPristineTagError(Error):
620 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
623 def __init__(self, tag):
628 ERROR: Local pristine tag %s did not match repository's. This
629 probably means an upstream rebase occured.""" % self.tag
631 class InconsistentScriptsTagError(Error):
632 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
635 def __init__(self, tag):
640 ERROR: Local scripts tag %s did not match repository's. This
641 probably means an upstream rebase occurred.""" % self.tag
643 class HeadNotDescendantError(Error):
644 """HEAD is not connected to tag."""
645 #: Tag that HEAD should have been descendant of.
647 def __init__(self, tag):
652 ERROR: HEAD is not a descendant of %s. This probably
653 means that an upstream rebase occurred, and new tags were
654 pulled, but local user commits were never rebased.""" % self.tag
656 class VersionDetectionError(Error):
657 """Could not detect real version of application."""
661 ERROR: Could not detect the real version of the application."""
663 class VersionMismatchError(Error):
664 """Git version of application does not match detected version."""
669 def __init__(self, real_version, git_version):
670 self.real_version = real_version
671 self.git_version = git_version
675 ERROR: The detected version %s did not match the Git
676 version %s.""" % (self.real_version, self.git_version)
678 class WebVerificationError(Error):
679 """Could not access the application on the web"""
683 ERROR: We were not able to access the application on the
684 web. This may indicate that the website is behind
685 authentication on the htaccess level. You can find
686 the contents of the page from the debug backtraces."""
688 class DatabaseVerificationError(Error):
689 """Could not access the database"""
693 ERROR: We were not able to access the database for
694 this application; this probably means that your database
695 configuration is misconfigured."""
697 class UnknownWebPath(Error):
698 """Could not determine application's web path."""
702 ERROR: We were not able to determine what the application's
703 host and path were in order to perform a web request
704 on the application. You can specify this manually using
705 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment