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:
258 self._app_version = self.old_log[-1].version
259 return self._app_version
263 Parses a line from the :term:`versions store`.
267 Use this method only when speed is of the utmost
268 importance. You should prefer to directly create a deployment
269 with only a ``location`` when possible.
273 location, deploydir = line.split(":")
275 return ProductionCopy(line) # lazy loaded version
277 return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
279 e.location = location
282 class ProductionCopy(Deployment):
284 Represents the production copy of a deployment. This copy
285 is canonical, and is the only one guaranteed to be accessible
286 via web, have a database, etc.
289 def upgrade(self, version, options):
291 Performs an upgrade of database schemas and other non-versioned data.
293 return self.application.upgrade(self, version, options)
295 def backup(self, options):
297 Performs a backup of database schemas and other non-versioned data.
299 backupdir = os.path.join(self.location, ".scripts", "backups")
300 backup = str(self.version) + "-" + datetime.date.today().isoformat()
301 outdir = os.path.join(backupdir, backup)
302 if not os.path.exists(backupdir):
304 if os.path.exists(outdir):
305 util.safe_unlink(outdir)
307 self.application.backup(self, outdir, options)
310 def restore(self, backup, options):
312 Restores a backup. Destroys state, so be careful! Also, this does
313 NOT restore the file-level backup, which is what 'wizard restore'
314 does, so you probably do NOT want to call this elsewhere unless
315 you know what you're doing (call 'wizard restore' instead).
317 backup_dir = os.path.join(".scripts", "backups", backup)
318 return self.application.restore(self, backup_dir, options)
321 Checks if the autoinstall is viewable from the web.
324 if not self.application.checkWeb(self, out):
325 raise WebVerificationError(out[0])
326 def fetch(self, path, post=None):
328 Performs a HTTP request on the website.
331 host, basepath = scripts.get_web_host_and_path(self.location)
332 except (ValueError, TypeError):
334 return util.fetch(host, basepath, path, post)
336 class WorkingCopy(Deployment):
338 Represents a temporary clone of a deployment that we can make
339 modifications to without fear of interfering with a production
340 deployment. More operations are permitted on these copies.
343 def parametrize(self, deployment):
345 Edits files in ``dir`` to replace WIZARD_* variables with literal
346 instances based on ``deployment``. This is used for constructing
347 virtual merge bases, and as such ``deployment`` will generally not
350 return self.application.parametrize(self, deployment)
352 def prepareConfig(self):
354 Edits files in the deployment such that any user-specific configuration
355 is replaced with generic WIZARD_* variables.
357 return self.application.prepareConfig(self)
359 def resolveConflicts(self):
361 Resolves conflicted files in this working copy. Returns whether or
362 not all conflicted files were resolved or not. Fully resolved
363 files are added to the index, but no commit is made.
365 return self.application.resolveConflicts(self)
367 def prepareMerge(self):
369 Performs various edits to files in the current working directory in
370 order to make a merge go more smoothly. This is usually
371 used to fix botched line-endings.
373 return self.application.prepareMerge(self)
377 class Error(wizard.Error):
378 """Base error class for this module"""
381 class NotMigratedError(Error):
383 The deployment contains a .scripts-version file, but no .git
384 or .scripts directory.
386 #: Directory of deployment
388 def __init__(self, dir):
391 return """This installation was not migrated"""
393 class AlreadyVersionedError(Error):
394 """The deployment contained a .git directory but no .scripts directory."""
395 #: Directory of deployment
397 def __init__(self, dir):
402 ERROR: Directory contains a .git directory, but not
403 a .scripts directory. If this is not a corrupt
404 migration, this means that the user was versioning their
405 install using Git."""
407 class NotConfiguredError(Error):
408 """The install was missing essential configuration."""
409 #: Directory of unconfigured install
411 def __init__(self, dir):
416 ERROR: The install was well-formed, but not configured
417 (essential configuration files were not found.)"""
419 class CorruptedAutoinstallError(Error):
420 """The install was missing a .git directory, but had a .scripts directory."""
421 #: Directory of the corrupted install
423 def __init__(self, dir):
428 ERROR: Directory contains a .scripts directory,
429 but not a .git directory."""
431 class NotAutoinstallError(Error):
432 """The directory was not an autoinstall, due to missing .scripts-version file."""
433 #: Directory in question
435 def __init__(self, dir):
440 ERROR: Could not find .scripts-version file. Are you sure
441 this is an autoinstalled application?
444 class NoTagError(Error):
445 """Deployment has a tag that does not have an equivalent in upstream repository."""
448 def __init__(self, tag):
453 ERROR: Could not find tag %s in repository.""" % self.tag
455 class NoLocalTagError(Error):
456 """Could not find tag in local repository."""
459 def __init__(self, tag):
464 ERROR: Could not find tag %s in local repository.""" % self.tag
466 class InconsistentPristineTagError(Error):
467 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
470 def __init__(self, tag):
475 ERROR: Local pristine tag %s did not match repository's. This
476 probably means an upstream rebase occured.""" % self.tag
478 class InconsistentScriptsTagError(Error):
479 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
482 def __init__(self, tag):
487 ERROR: Local scripts tag %s did not match repository's. This
488 probably means an upstream rebase occurred.""" % self.tag
490 class HeadNotDescendantError(Error):
491 """HEAD is not connected to tag."""
492 #: Tag that HEAD should have been descendant of.
494 def __init__(self, tag):
499 ERROR: HEAD is not a descendant of %s. This probably
500 means that an upstream rebase occurred, and new tags were
501 pulled, but local user commits were never rebased.""" % self.tag
503 class VersionDetectionError(Error):
504 """Could not detect real version of application."""
508 ERROR: Could not detect the real version of the application."""
510 class VersionMismatchError(Error):
511 """Git version of application does not match detected version."""
516 def __init__(self, real_version, git_version):
517 self.real_version = real_version
518 self.git_version = git_version
522 ERROR: The detected version %s did not match the Git
523 version %s.""" % (self.real_version, self.git_version)
525 class WebVerificationError(Error):
526 """Could not access the application on the web"""
527 #: Contents of web page access
529 def __init__(self, contents):
530 self.contents = contents
534 ERROR: We were not able to access the application on the
535 web. This may indicate that the website is behind
536 authentication on the htaccess level. The contents
539 %s""" % self.contents
541 class UnknownWebPath(Error):
542 """Could not determine application's web path."""
546 ERROR: We were not able to determine what the application's
547 host and path were in order to perform a web request
548 on the application. You can specify this manually using
549 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment