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.
418 def setAppVersion(self, app_version):
420 Manually resets the application version; useful if the working
421 copy is off in space (i.e. not anchored to something we can
422 git describe off of.)
424 self._app_version = app_version
426 def parametrize(self, deployment):
428 Edits files in ``dir`` to replace WIZARD_* variables with literal
429 instances based on ``deployment``. This is used for constructing
430 virtual merge bases, and as such ``deployment`` will generally not
433 return self.application.parametrize(self, deployment)
435 def prepareConfig(self):
437 Edits files in the deployment such that any user-specific configuration
438 is replaced with generic WIZARD_* variables.
440 return self.application.prepareConfig(self)
442 def resolveConflicts(self):
444 Resolves conflicted files in this working copy. Returns whether or
445 not all conflicted files were resolved or not. Fully resolved
446 files are added to the index, but no commit is made.
448 return self.application.resolveConflicts(self)
450 def prepareMerge(self):
452 Performs various edits to files in the current working directory in
453 order to make a merge go more smoothly. This is usually
454 used to fix botched line-endings.
456 return self.application.prepareMerge(self)
460 class Error(wizard.Error):
461 """Base error class for this module"""
464 class NotMigratedError(Error):
466 The deployment contains a .scripts-version file, but no .git
467 or .scripts directory.
469 #: Directory of deployment
471 def __init__(self, dir):
474 return """This installation was not migrated"""
476 class AlreadyVersionedError(Error):
477 """The deployment contained a .git directory but no .scripts directory."""
478 #: Directory of deployment
480 def __init__(self, dir):
485 ERROR: Directory contains a .git directory, but not
486 a .scripts directory. If this is not a corrupt
487 migration, this means that the user was versioning their
488 install using Git."""
490 class NotConfiguredError(Error):
491 """The install was missing essential configuration."""
492 #: Directory of unconfigured install
494 def __init__(self, dir):
499 ERROR: The install was well-formed, but not configured
500 (essential configuration files were not found.)"""
502 class CorruptedAutoinstallError(Error):
503 """The install was missing a .git directory, but had a .scripts directory."""
504 #: Directory of the corrupted install
506 def __init__(self, dir):
511 ERROR: Directory contains a .scripts directory,
512 but not a .git directory."""
514 class NotAutoinstallError(Error):
515 """The directory was not an autoinstall, due to missing .scripts-version file."""
516 #: Directory in question
518 def __init__(self, dir):
523 ERROR: Could not find .scripts-version file. Are you sure
524 this is an autoinstalled application?
527 class NoTagError(Error):
528 """Deployment has a tag that does not have an equivalent in upstream repository."""
531 def __init__(self, tag):
536 ERROR: Could not find tag %s in repository.""" % self.tag
538 class NoLocalTagError(Error):
539 """Could not find tag in local repository."""
542 def __init__(self, tag):
547 ERROR: Could not find tag %s in local repository.""" % self.tag
549 class InconsistentPristineTagError(Error):
550 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
553 def __init__(self, tag):
558 ERROR: Local pristine tag %s did not match repository's. This
559 probably means an upstream rebase occured.""" % self.tag
561 class InconsistentScriptsTagError(Error):
562 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
565 def __init__(self, tag):
570 ERROR: Local scripts tag %s did not match repository's. This
571 probably means an upstream rebase occurred.""" % self.tag
573 class HeadNotDescendantError(Error):
574 """HEAD is not connected to tag."""
575 #: Tag that HEAD should have been descendant of.
577 def __init__(self, tag):
582 ERROR: HEAD is not a descendant of %s. This probably
583 means that an upstream rebase occurred, and new tags were
584 pulled, but local user commits were never rebased.""" % self.tag
586 class VersionDetectionError(Error):
587 """Could not detect real version of application."""
591 ERROR: Could not detect the real version of the application."""
593 class VersionMismatchError(Error):
594 """Git version of application does not match detected version."""
599 def __init__(self, real_version, git_version):
600 self.real_version = real_version
601 self.git_version = git_version
605 ERROR: The detected version %s did not match the Git
606 version %s.""" % (self.real_version, self.git_version)
608 class WebVerificationError(Error):
609 """Could not access the application on the web"""
613 ERROR: We were not able to access the application on the
614 web. This may indicate that the website is behind
615 authentication on the htaccess level. You can find
616 the contents of the page from the debug backtraces."""
618 class UnknownWebPath(Error):
619 """Could not determine application's web path."""
623 ERROR: We were not able to determine what the application's
624 host and path were in order to perform a web request
625 on the application. You can specify this manually using
626 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment