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))
308 Parses a line from the :term:`versions store`.
312 Use this method only when speed is of the utmost
313 importance. You should prefer to directly create a deployment
314 with only a ``location`` when possible.
318 location, deploydir = line.split(":")
320 return ProductionCopy(line) # lazy loaded version
322 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
324 e.location = location
327 class ProductionCopy(Deployment):
329 Represents the production copy of a deployment. This copy
330 is canonical, and is the only one guaranteed to be accessible
331 via web, have a database, etc.
334 def upgrade(self, version, options):
336 Performs an upgrade of database schemas and other non-versioned data.
338 return self.application.upgrade(self, version, options)
340 def backup(self, options):
342 Performs a backup of database schemas and other non-versioned data.
344 # There are retarded amounts of race-safety in this function,
345 # because we do NOT want to claim to have made a backup, when
346 # actually something weird happened to it.
347 backupdir = os.path.join(self.scripts_dir, "backups")
348 if not os.path.exists(backupdir):
352 if e.errno == errno.EEXIST:
356 tmpdir = tempfile.mkdtemp() # actually will be kept around
358 self.application.backup(self, tmpdir, options)
359 except app.BackupFailure:
360 # the backup is bogus, don't let it show up
361 shutil.rmtree(tmpdir)
364 with util.LockDirectory(os.path.join(backupdir, "lock")):
366 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
367 outdir = os.path.join(backupdir, backup)
368 if os.path.exists(outdir):
369 logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
373 shutil.move(tmpdir, outdir)
375 # don't leave half-baked stuff lying around
377 shutil.rmtree(outdir)
384 def restore(self, backup, options):
386 Restores a backup. Destroys state, so be careful! Also, this does
387 NOT restore the file-level backup, which is what 'wizard restore'
388 does, so you probably do NOT want to call this elsewhere unless
389 you know what you're doing (call 'wizard restore' instead).
391 backup_dir = os.path.join(".scripts", "backups", backup)
392 return self.application.restore(self, backup_dir, options)
394 def remove(self, options):
396 Deletes all non-local or non-filesystem data (such as databases) that
397 this application uses.
399 self.application.remove(self, options)
402 Checks if the autoinstall is viewable from the web.
404 if not self.application.checkWeb(self):
405 raise WebVerificationError
406 def fetch(self, path, post=None):
408 Performs a HTTP request on the website.
410 return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
412 class WorkingCopy(Deployment):
414 Represents a temporary clone of a deployment that we can make
415 modifications to without fear of interfering with a production
416 deployment. More operations are permitted on these copies.
419 def parametrize(self, deployment):
421 Edits files in ``dir`` to replace WIZARD_* variables with literal
422 instances based on ``deployment``. This is used for constructing
423 virtual merge bases, and as such ``deployment`` will generally not
426 return self.application.parametrize(self, deployment)
428 def prepareConfig(self):
430 Edits files in the deployment such that any user-specific configuration
431 is replaced with generic WIZARD_* variables.
433 return self.application.prepareConfig(self)
435 def resolveConflicts(self):
437 Resolves conflicted files in this working copy. Returns whether or
438 not all conflicted files were resolved or not. Fully resolved
439 files are added to the index, but no commit is made.
441 return self.application.resolveConflicts(self)
443 def prepareMerge(self):
445 Performs various edits to files in the current working directory in
446 order to make a merge go more smoothly. This is usually
447 used to fix botched line-endings.
449 return self.application.prepareMerge(self)
453 class Error(wizard.Error):
454 """Base error class for this module"""
457 class NotMigratedError(Error):
459 The deployment contains a .scripts-version file, but no .git
460 or .scripts directory.
462 #: Directory of deployment
464 def __init__(self, dir):
467 return """This installation was not migrated"""
469 class AlreadyVersionedError(Error):
470 """The deployment contained a .git directory but no .scripts directory."""
471 #: Directory of deployment
473 def __init__(self, dir):
478 ERROR: Directory contains a .git directory, but not
479 a .scripts directory. If this is not a corrupt
480 migration, this means that the user was versioning their
481 install using Git."""
483 class NotConfiguredError(Error):
484 """The install was missing essential configuration."""
485 #: Directory of unconfigured install
487 def __init__(self, dir):
492 ERROR: The install was well-formed, but not configured
493 (essential configuration files were not found.)"""
495 class CorruptedAutoinstallError(Error):
496 """The install was missing a .git directory, but had a .scripts directory."""
497 #: Directory of the corrupted install
499 def __init__(self, dir):
504 ERROR: Directory contains a .scripts directory,
505 but not a .git directory."""
507 class NotAutoinstallError(Error):
508 """The directory was not an autoinstall, due to missing .scripts-version file."""
509 #: Directory in question
511 def __init__(self, dir):
516 ERROR: Could not find .scripts-version file. Are you sure
517 this is an autoinstalled application?
520 class NoTagError(Error):
521 """Deployment has a tag that does not have an equivalent in upstream repository."""
524 def __init__(self, tag):
529 ERROR: Could not find tag %s in repository.""" % self.tag
531 class NoLocalTagError(Error):
532 """Could not find tag in local repository."""
535 def __init__(self, tag):
540 ERROR: Could not find tag %s in local repository.""" % self.tag
542 class InconsistentPristineTagError(Error):
543 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
546 def __init__(self, tag):
551 ERROR: Local pristine tag %s did not match repository's. This
552 probably means an upstream rebase occured.""" % self.tag
554 class InconsistentScriptsTagError(Error):
555 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
558 def __init__(self, tag):
563 ERROR: Local scripts tag %s did not match repository's. This
564 probably means an upstream rebase occurred.""" % self.tag
566 class HeadNotDescendantError(Error):
567 """HEAD is not connected to tag."""
568 #: Tag that HEAD should have been descendant of.
570 def __init__(self, tag):
575 ERROR: HEAD is not a descendant of %s. This probably
576 means that an upstream rebase occurred, and new tags were
577 pulled, but local user commits were never rebased.""" % self.tag
579 class VersionDetectionError(Error):
580 """Could not detect real version of application."""
584 ERROR: Could not detect the real version of the application."""
586 class VersionMismatchError(Error):
587 """Git version of application does not match detected version."""
592 def __init__(self, real_version, git_version):
593 self.real_version = real_version
594 self.git_version = git_version
598 ERROR: The detected version %s did not match the Git
599 version %s.""" % (self.real_version, self.git_version)
601 class WebVerificationError(Error):
602 """Could not access the application on the web"""
606 ERROR: We were not able to access the application on the
607 web. This may indicate that the website is behind
608 authentication on the htaccess level. You can find
609 the contents of the page from the debug backtraces."""
611 class UnknownWebPath(Error):
612 """Could not determine application's web path."""
616 ERROR: We were not able to determine what the application's
617 host and path were in order to perform a web request
618 on the application. You can specify this manually using
619 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment