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`.
14 from wizard import app, git, old_log, scripts, shell, util
16 ## -- Global Functions --
18 def get_install_lines(versions_store, user=None):
20 Low level function that retrieves a list of lines from the
21 :term:`versions store` that can be passed to :meth:`Deployment.parse`.
23 if os.path.isfile(versions_store):
24 return fileinput.input([versions_store])
26 return fileinput.input([versions_store + "/" + user])
27 return fileinput.input([versions_store + "/" + f for f in sorted(os.listdir(versions_store))])
29 def parse_install_lines(show, versions_store, yield_errors = False, user = None):
31 Generator function for iterating through all autoinstalls.
32 Each item is an instance of :class:`Deployment`, or possibly
33 a :class:`wizard.deploy.Error` if ``yield_errors`` is ``True``. You can
34 filter out applications and versions by specifying ``app``
35 or ``app-1.2.3`` in ``show``. This function may generate
39 show = app.applications()
40 elif isinstance(show, str):
41 # otherwise, frozenset will treat string as an iterable
42 show = frozenset([show])
44 show = frozenset(show)
45 for line in get_install_lines(versions_store, user):
48 d = Deployment.parse(line)
49 name = d.application.name
50 except app.NoSuchApplication as e:
55 # we consider this a worse error
56 logging.warning("Error with '%s'" % line.rstrip())
59 if name + "-" + str(d.version) in show or name in show:
66 ## -- Model Objects --
69 def chdir_to_location(f, self, *args, **kwargs):
71 Decorator for making a function have working directory
72 :attr:`Deployment.location`.
74 with util.ChangeDirectory(self.location):
75 return f(self, *args, **kwargs)
77 class Deployment(object):
79 Represents a deployment of an autoinstall, e.g. directory
80 that has ``.scripts`` directory or ``.scripts-version``
81 file in it. Supply ``version`` with an :class:`ApplicationVersion` only if
82 you were reading from the :term:`versions store` and care about
83 speed (data from there can be stale).
85 The Deployment interface is somewhat neutered, so you may
86 want to use :class:`WorkingCopy` or :class:`ProductionCopy` for
87 more powerful operations.
89 #: Absolute path to the deployment
91 def __init__(self, location, version=None):
92 self.location = os.path.abspath(location)
93 self._app_version = version
94 # some cache variables
97 def invalidateCache(self):
99 Invalidates all cached variables. This currently applies to
100 :attr:`app_version`, :attr:`old_log` and :meth:`read`.
102 self._app_version = None
103 self._read_cache = {}
105 def read(self, file, force = False):
107 Reads a file's contents, possibly from cache unless ``force``
110 if force or file not in self._read_cache:
111 f = open(os.path.join(self.location, file))
112 self._read_cache[file] = f.read()
114 return self._read_cache[file]
117 Extracts all the values of all variables from deployment.
118 These variables may be used for parametrizing generic parent
119 commits and include things such as database access credentials
120 and local configuration.
122 return self.application.extract(self)
126 Checks if this is an autoinstall, throws an exception if there
129 with util.ChangeDirectory(self.location):
130 has_git = os.path.isdir(".git")
131 has_scripts = os.path.isdir(".scripts")
132 if not has_git and has_scripts:
133 raise CorruptedAutoinstallError(self.location)
134 elif has_git and not has_scripts:
135 raise AlreadyVersionedError(self.location)
136 elif not has_git and not has_scripts:
137 if os.path.isfile(".scripts-version"):
138 raise NotMigratedError(self.location)
140 def verifyTag(self, srv_path):
142 Checks if the purported version has a corresponding tag
143 in the upstream repository.
145 repo = self.application.repository(srv_path)
147 shell.Shell().eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
148 except shell.CallError:
149 raise NoTagError(self.app_version.scripts_tag)
151 def verifyGit(self, srv_path):
153 Checks if the autoinstall's Git repository makes sense,
154 checking if the tag is parseable and corresponds to
155 a real application, and if the tag in this repository
156 corresponds to the one in the remote repository.
158 with util.ChangeDirectory(self.location):
160 repo = self.application.repository(srv_path)
161 def repo_rev_parse(tag):
162 return sh.eval("git", "--git-dir", repo, "rev-parse", tag)
163 def self_rev_parse(tag):
165 return sh.safeCall("git", "rev-parse", tag, strip=True)
166 except shell.CallError:
167 raise NoLocalTagError(tag)
168 def compare_tags(tag):
169 return repo_rev_parse(tag) == self_rev_parse(tag)
170 if not compare_tags(self.app_version.pristine_tag):
171 raise InconsistentPristineTagError(self.app_version.pristine_tag)
172 if not compare_tags(self.app_version.scripts_tag):
173 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
174 parent = repo_rev_parse(self.app_version.scripts_tag)
175 merge_base = sh.safeCall("git", "merge-base", parent, "HEAD", strip=True)
176 if merge_base != parent:
177 raise HeadNotDescendantError(self.app_version.scripts_tag)
179 def verifyConfigured(self):
181 Checks if the autoinstall is configured running.
183 if not self.configured:
184 raise NotConfiguredError(self.location)
187 def verifyVersion(self):
189 Checks if our version and the version number recorded in a file
192 real = self.application.detectVersion(self)
194 raise VersionDetectionError
195 elif not str(real) == self.app_version.pristine_tag.partition('-')[2]:
196 raise VersionMismatchError(real, self.version)
200 def configured(self):
201 """Whether or not an autoinstall has been configured/installed for use."""
202 return self.application.checkConfig(self)
205 """Whether or not the autoinstalls has been migrated."""
206 return os.path.isdir(self.scripts_dir)
208 def scripts_dir(self):
209 """The absolute path of the ``.scripts`` directory."""
210 return os.path.join(self.location, '.scripts')
212 def old_version_file(self):
214 The absolute path of either ``.scripts-version`` (for unmigrated
215 installs) or ``.scripts/version``.
219 Use of this is discouraged for migrated installs.
221 return os.path.join(self.location, '.scripts-version')
223 def version_file(self):
224 """The absolute path of the ``.scripts/version`` file."""
225 return os.path.join(self.scripts_dir, 'version')
227 def application(self):
228 """The :class:`app.Application` of this deployment."""
229 return self.app_version.application
233 The :class:`wizard.old_log.Log` of this deployment. This
234 is only applicable to un-migrated autoinstalls.
236 if not self._old_log:
237 self._old_log = old_log.DeployLog.load(self)
242 The :class:`distutils.version.LooseVersion` of this
245 return self.app_version.version
247 def app_version(self):
248 """The :class:`app.ApplicationVersion` of this deployment."""
249 if not self._app_version:
250 if os.path.isdir(os.path.join(self.location, ".git")):
252 with util.ChangeDirectory(self.location):
253 appname, _, version = git.describe().partition('-')
254 self._app_version = app.ApplicationVersion.make(appname, version)
255 except shell.CallError:
257 if not self._app_version:
259 self._app_version = self.old_log[-1].version
260 except old_log.ScriptsVersionNoSuchFile:
262 if not self._app_version:
263 appname = shell.Shell().eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
264 self._app_version = app.ApplicationVersion.make(appname, "unknown")
265 return self._app_version
269 Parses a line from the :term:`versions store`.
273 Use this method only when speed is of the utmost
274 importance. You should prefer to directly create a deployment
275 with only a ``location`` when possible.
279 location, deploydir = line.split(":")
281 return ProductionCopy(line) # lazy loaded version
283 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
285 e.location = location
288 class ProductionCopy(Deployment):
290 Represents the production copy of a deployment. This copy
291 is canonical, and is the only one guaranteed to be accessible
292 via web, have a database, etc.
295 def upgrade(self, version, options):
297 Performs an upgrade of database schemas and other non-versioned data.
299 return self.application.upgrade(self, version, options)
301 def backup(self, options):
303 Performs a backup of database schemas and other non-versioned data.
305 backupdir = os.path.join(self.location, ".scripts", "backups")
306 backup = str(self.version) + "-" + datetime.date.today().isoformat()
307 outdir = os.path.join(backupdir, backup)
308 if not os.path.exists(backupdir):
310 if os.path.exists(outdir):
311 util.safe_unlink(outdir)
313 self.application.backup(self, outdir, options)
316 def restore(self, backup, options):
318 Restores a backup. Destroys state, so be careful! Also, this does
319 NOT restore the file-level backup, which is what 'wizard restore'
320 does, so you probably do NOT want to call this elsewhere unless
321 you know what you're doing (call 'wizard restore' instead).
323 backup_dir = os.path.join(".scripts", "backups", backup)
324 return self.application.restore(self, backup_dir, options)
327 Checks if the autoinstall is viewable from the web.
330 if not self.application.checkWeb(self, out):
331 raise WebVerificationError(out[0])
332 def fetch(self, path, post=None):
334 Performs a HTTP request on the website.
337 host, basepath = scripts.get_web_host_and_path(self.location)
338 except (ValueError, TypeError):
340 return util.fetch(host, basepath, path, post)
342 class WorkingCopy(Deployment):
344 Represents a temporary clone of a deployment that we can make
345 modifications to without fear of interfering with a production
346 deployment. More operations are permitted on these copies.
349 def parametrize(self, deployment):
351 Edits files in ``dir`` to replace WIZARD_* variables with literal
352 instances based on ``deployment``. This is used for constructing
353 virtual merge bases, and as such ``deployment`` will generally not
356 return self.application.parametrize(self, deployment)
358 def prepareConfig(self):
360 Edits files in the deployment such that any user-specific configuration
361 is replaced with generic WIZARD_* variables.
363 return self.application.prepareConfig(self)
365 def resolveConflicts(self):
367 Resolves conflicted files in this working copy. Returns whether or
368 not all conflicted files were resolved or not. Fully resolved
369 files are added to the index, but no commit is made.
371 return self.application.resolveConflicts(self)
373 def prepareMerge(self):
375 Performs various edits to files in the current working directory in
376 order to make a merge go more smoothly. This is usually
377 used to fix botched line-endings.
379 return self.application.prepareMerge(self)
383 class Error(wizard.Error):
384 """Base error class for this module"""
387 class NotMigratedError(Error):
389 The deployment contains a .scripts-version file, but no .git
390 or .scripts directory.
392 #: Directory of deployment
394 def __init__(self, dir):
397 return """This installation was not migrated"""
399 class AlreadyVersionedError(Error):
400 """The deployment contained a .git directory but no .scripts directory."""
401 #: Directory of deployment
403 def __init__(self, dir):
408 ERROR: Directory contains a .git directory, but not
409 a .scripts directory. If this is not a corrupt
410 migration, this means that the user was versioning their
411 install using Git."""
413 class NotConfiguredError(Error):
414 """The install was missing essential configuration."""
415 #: Directory of unconfigured install
417 def __init__(self, dir):
422 ERROR: The install was well-formed, but not configured
423 (essential configuration files were not found.)"""
425 class CorruptedAutoinstallError(Error):
426 """The install was missing a .git directory, but had a .scripts directory."""
427 #: Directory of the corrupted install
429 def __init__(self, dir):
434 ERROR: Directory contains a .scripts directory,
435 but not a .git directory."""
437 class NotAutoinstallError(Error):
438 """The directory was not an autoinstall, due to missing .scripts-version file."""
439 #: Directory in question
441 def __init__(self, dir):
446 ERROR: Could not find .scripts-version file. Are you sure
447 this is an autoinstalled application?
450 class NoTagError(Error):
451 """Deployment has a tag that does not have an equivalent in upstream repository."""
454 def __init__(self, tag):
459 ERROR: Could not find tag %s in repository.""" % self.tag
461 class NoLocalTagError(Error):
462 """Could not find tag in local repository."""
465 def __init__(self, tag):
470 ERROR: Could not find tag %s in local repository.""" % self.tag
472 class InconsistentPristineTagError(Error):
473 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
476 def __init__(self, tag):
481 ERROR: Local pristine tag %s did not match repository's. This
482 probably means an upstream rebase occured.""" % self.tag
484 class InconsistentScriptsTagError(Error):
485 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
488 def __init__(self, tag):
493 ERROR: Local scripts tag %s did not match repository's. This
494 probably means an upstream rebase occurred.""" % self.tag
496 class HeadNotDescendantError(Error):
497 """HEAD is not connected to tag."""
498 #: Tag that HEAD should have been descendant of.
500 def __init__(self, tag):
505 ERROR: HEAD is not a descendant of %s. This probably
506 means that an upstream rebase occurred, and new tags were
507 pulled, but local user commits were never rebased.""" % self.tag
509 class VersionDetectionError(Error):
510 """Could not detect real version of application."""
514 ERROR: Could not detect the real version of the application."""
516 class VersionMismatchError(Error):
517 """Git version of application does not match detected version."""
522 def __init__(self, real_version, git_version):
523 self.real_version = real_version
524 self.git_version = git_version
528 ERROR: The detected version %s did not match the Git
529 version %s.""" % (self.real_version, self.git_version)
531 class WebVerificationError(Error):
532 """Could not access the application on the web"""
533 #: Contents of web page access
535 def __init__(self, contents):
536 self.contents = contents
540 ERROR: We were not able to access the application on the
541 web. This may indicate that the website is behind
542 authentication on the htaccess level. The contents
545 %s""" % self.contents
547 class UnknownWebPath(Error):
548 """Could not determine application's web path."""
552 ERROR: We were not able to determine what the application's
553 host and path were in order to perform a web request
554 on the application. You can specify this manually using
555 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment