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.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):
167 repo = self.application.repository(srv_path)
168 def repo_rev_parse(tag):
169 return sh.eval("git", "--git-dir", repo, "rev-parse", tag)
170 def self_rev_parse(tag):
172 return sh.safeCall("git", "rev-parse", tag, strip=True)
173 except shell.CallError:
174 raise NoLocalTagError(tag)
175 def compare_tags(tag):
176 return repo_rev_parse(tag) == self_rev_parse(tag)
177 if not compare_tags(self.app_version.pristine_tag):
178 raise InconsistentPristineTagError(self.app_version.pristine_tag)
179 if not compare_tags(self.app_version.scripts_tag):
180 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
181 parent = repo_rev_parse(self.app_version.scripts_tag)
182 merge_base = sh.safeCall("git", "merge-base", parent, "HEAD", strip=True)
183 if merge_base != parent:
184 raise HeadNotDescendantError(self.app_version.scripts_tag)
186 def verifyConfigured(self):
188 Checks if the autoinstall is configured running.
190 if not self.configured:
191 raise NotConfiguredError(self.location)
194 def verifyVersion(self):
196 Checks if our version and the version number recorded in a file
199 real = self.application.detectVersion(self)
201 raise VersionDetectionError
202 elif not str(real) == self.app_version.pristine_tag.partition('-')[2]:
203 raise VersionMismatchError(real, self.version)
207 def configured(self):
208 """Whether or not an autoinstall has been configured/installed for use."""
209 return self.application.checkConfig(self)
212 """Whether or not the autoinstalls has been migrated."""
213 return os.path.isdir(self.scripts_dir)
215 def scripts_dir(self):
216 """The absolute path of the ``.scripts`` directory."""
217 return os.path.join(self.location, '.scripts')
219 def old_version_file(self):
221 The absolute path of either ``.scripts-version`` (for unmigrated
222 installs) or ``.scripts/version``.
226 Use of this is discouraged for migrated installs.
228 return os.path.join(self.location, '.scripts-version')
230 def version_file(self):
231 """The absolute path of the ``.scripts/version`` file."""
232 return os.path.join(self.scripts_dir, 'version')
235 """The absolute path of the :file:`.scripts/dsn` override file."""
236 return os.path.join(self.scripts_dir, 'dsn')
239 """The absolute path of the :file:`.scripts/url` override file."""
240 return os.path.join(self.scripts_dir, 'url')
242 def application(self):
243 """The :class:`app.Application` of this deployment."""
244 return self.app_version.application
248 The :class:`wizard.old_log.Log` of this deployment. This
249 is only applicable to un-migrated autoinstalls.
251 if not self._old_log:
252 self._old_log = old_log.DeployLog.load(self)
257 The :class:`distutils.version.LooseVersion` of this
260 return self.app_version.version
262 def app_version(self):
263 """The :class:`app.ApplicationVersion` of this deployment."""
264 if not self._app_version:
265 if os.path.isdir(os.path.join(self.location, ".git")):
267 with util.ChangeDirectory(self.location):
268 appname, _, version = git.describe().partition('-')
269 self._app_version = app.ApplicationVersion.make(appname, version)
270 except shell.CallError:
272 if not self._app_version:
274 self._app_version = self.old_log[-1].version
275 except old_log.ScriptsVersionNoSuchFile:
277 if not self._app_version:
278 appname = shell.Shell().eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
279 self._app_version = app.ApplicationVersion.make(appname, "unknown")
280 return self._app_version
283 """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
285 self._dsn = sql.fill_url(self.application.dsn(self))
289 """The :class:`urlparse.ParseResult` for this deployment."""
291 self._url = scripts.fill_url(self.location, self.application.url(self))
298 Parses a line from the :term:`versions store`.
302 Use this method only when speed is of the utmost
303 importance. You should prefer to directly create a deployment
304 with only a ``location`` when possible.
308 location, deploydir = line.split(":")
310 return ProductionCopy(line) # lazy loaded version
312 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
314 e.location = location
317 class ProductionCopy(Deployment):
319 Represents the production copy of a deployment. This copy
320 is canonical, and is the only one guaranteed to be accessible
321 via web, have a database, etc.
324 def upgrade(self, version, options):
326 Performs an upgrade of database schemas and other non-versioned data.
328 return self.application.upgrade(self, version, options)
330 def backup(self, options):
332 Performs a backup of database schemas and other non-versioned data.
334 # There are retarded amounts of race-safety in this function,
335 # because we do NOT want to claim to have made a backup, when
336 # actually something weird happened to it.
337 backupdir = os.path.join(self.scripts_dir, "backups")
338 if not os.path.exists(backupdir):
342 if e.errno == errno.EEXIST:
346 tmpdir = tempfile.mkdtemp() # actually will be kept around
348 self.application.backup(self, tmpdir, options)
349 except app.BackupFailure:
350 # the backup is bogus, don't let it show up
351 shutil.rmtree(tmpdir)
354 with util.LockDirectory(os.path.join(backupdir, "lock")):
356 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
357 outdir = os.path.join(backupdir, backup)
358 if os.path.exists(outdir):
359 logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
363 shutil.move(tmpdir, outdir)
365 # don't leave half-baked stuff lying around
367 shutil.rmtree(outdir)
374 def restore(self, backup, options):
376 Restores a backup. Destroys state, so be careful! Also, this does
377 NOT restore the file-level backup, which is what 'wizard restore'
378 does, so you probably do NOT want to call this elsewhere unless
379 you know what you're doing (call 'wizard restore' instead).
381 backup_dir = os.path.join(".scripts", "backups", backup)
382 return self.application.restore(self, backup_dir, options)
384 def remove(self, options):
386 Deletes all non-local or non-filesystem data (such as databases) that
387 this application uses.
389 self.application.remove(self, options)
392 Checks if the autoinstall is viewable from the web.
394 if not self.application.checkWeb(self):
395 raise WebVerificationError
396 def fetch(self, path, post=None):
398 Performs a HTTP request on the website.
400 return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
402 class WorkingCopy(Deployment):
404 Represents a temporary clone of a deployment that we can make
405 modifications to without fear of interfering with a production
406 deployment. More operations are permitted on these copies.
409 def parametrize(self, deployment):
411 Edits files in ``dir`` to replace WIZARD_* variables with literal
412 instances based on ``deployment``. This is used for constructing
413 virtual merge bases, and as such ``deployment`` will generally not
416 return self.application.parametrize(self, deployment)
418 def prepareConfig(self):
420 Edits files in the deployment such that any user-specific configuration
421 is replaced with generic WIZARD_* variables.
423 return self.application.prepareConfig(self)
425 def resolveConflicts(self):
427 Resolves conflicted files in this working copy. Returns whether or
428 not all conflicted files were resolved or not. Fully resolved
429 files are added to the index, but no commit is made.
431 return self.application.resolveConflicts(self)
433 def prepareMerge(self):
435 Performs various edits to files in the current working directory in
436 order to make a merge go more smoothly. This is usually
437 used to fix botched line-endings.
439 return self.application.prepareMerge(self)
443 class Error(wizard.Error):
444 """Base error class for this module"""
447 class NotMigratedError(Error):
449 The deployment contains a .scripts-version file, but no .git
450 or .scripts directory.
452 #: Directory of deployment
454 def __init__(self, dir):
457 return """This installation was not migrated"""
459 class AlreadyVersionedError(Error):
460 """The deployment contained a .git directory but no .scripts directory."""
461 #: Directory of deployment
463 def __init__(self, dir):
468 ERROR: Directory contains a .git directory, but not
469 a .scripts directory. If this is not a corrupt
470 migration, this means that the user was versioning their
471 install using Git."""
473 class NotConfiguredError(Error):
474 """The install was missing essential configuration."""
475 #: Directory of unconfigured install
477 def __init__(self, dir):
482 ERROR: The install was well-formed, but not configured
483 (essential configuration files were not found.)"""
485 class CorruptedAutoinstallError(Error):
486 """The install was missing a .git directory, but had a .scripts directory."""
487 #: Directory of the corrupted install
489 def __init__(self, dir):
494 ERROR: Directory contains a .scripts directory,
495 but not a .git directory."""
497 class NotAutoinstallError(Error):
498 """The directory was not an autoinstall, due to missing .scripts-version file."""
499 #: Directory in question
501 def __init__(self, dir):
506 ERROR: Could not find .scripts-version file. Are you sure
507 this is an autoinstalled application?
510 class NoTagError(Error):
511 """Deployment has a tag that does not have an equivalent in upstream repository."""
514 def __init__(self, tag):
519 ERROR: Could not find tag %s in repository.""" % self.tag
521 class NoLocalTagError(Error):
522 """Could not find tag in local repository."""
525 def __init__(self, tag):
530 ERROR: Could not find tag %s in local repository.""" % self.tag
532 class InconsistentPristineTagError(Error):
533 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
536 def __init__(self, tag):
541 ERROR: Local pristine tag %s did not match repository's. This
542 probably means an upstream rebase occured.""" % self.tag
544 class InconsistentScriptsTagError(Error):
545 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
548 def __init__(self, tag):
553 ERROR: Local scripts tag %s did not match repository's. This
554 probably means an upstream rebase occurred.""" % self.tag
556 class HeadNotDescendantError(Error):
557 """HEAD is not connected to tag."""
558 #: Tag that HEAD should have been descendant of.
560 def __init__(self, tag):
565 ERROR: HEAD is not a descendant of %s. This probably
566 means that an upstream rebase occurred, and new tags were
567 pulled, but local user commits were never rebased.""" % self.tag
569 class VersionDetectionError(Error):
570 """Could not detect real version of application."""
574 ERROR: Could not detect the real version of the application."""
576 class VersionMismatchError(Error):
577 """Git version of application does not match detected version."""
582 def __init__(self, real_version, git_version):
583 self.real_version = real_version
584 self.git_version = git_version
588 ERROR: The detected version %s did not match the Git
589 version %s.""" % (self.real_version, self.git_version)
591 class WebVerificationError(Error):
592 """Could not access the application on the web"""
596 ERROR: We were not able to access the application on the
597 web. This may indicate that the website is behind
598 authentication on the htaccess level. You can find
599 the contents of the page from the debug backtraces."""
601 class UnknownWebPath(Error):
602 """Could not determine application's web path."""
606 ERROR: We were not able to determine what the application's
607 host and path were in order to perform a web request
608 on the application. You can specify this manually using
609 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment