]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
Remove unnecessary imports.
[wizard.git] / wizard / deploy.py
1 """
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`.
5 """
6
7 import os.path
8 import fileinput
9 import logging
10 import decorator
11 import datetime
12
13 import wizard
14 from wizard import app, git, old_log, scripts, shell, util
15
16 ## -- Global Functions --
17
18 def get_install_lines(versions_store, user=None):
19     """
20     Low level function that retrieves a list of lines from the
21     :term:`versions store` that can be passed to :meth:`Deployment.parse`.
22     """
23     if os.path.isfile(versions_store):
24         return fileinput.input([versions_store])
25     if user:
26         return fileinput.input([versions_store + "/" + user])
27     return fileinput.input([versions_store + "/" + f for f in sorted(os.listdir(versions_store))])
28
29 def parse_install_lines(show, versions_store, yield_errors = False, user = None):
30     """
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
36     log output.
37     """
38     if not show:
39         show = app.applications()
40     elif isinstance(show, str):
41         # otherwise, frozenset will treat string as an iterable
42         show = frozenset([show])
43     else:
44         show = frozenset(show)
45     for line in get_install_lines(versions_store, user):
46         # construction
47         try:
48             d = Deployment.parse(line)
49             name = d.application.name
50         except app.NoSuchApplication as e:
51             if yield_errors:
52                 yield e
53             continue
54         except Error:
55             # we consider this a worse error
56             logging.warning("Error with '%s'" % line.rstrip())
57             continue
58         # filter
59         if name + "-" + str(d.version) in show or name in show:
60             pass
61         else:
62             continue
63         # yield
64         yield d
65
66 ## -- Model Objects --
67
68 @decorator.decorator
69 def chdir_to_location(f, self, *args, **kwargs):
70     """
71     Decorator for making a function have working directory
72     :attr:`Deployment.location`.
73     """
74     with util.ChangeDirectory(self.location):
75         return f(self, *args, **kwargs)
76
77 class Deployment(object):
78     """
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).
84
85     The Deployment interface is somewhat neutered, so you may
86     want to use :class:`WorkingCopy` or :class:`ProductionCopy` for
87     more powerful operations.
88     """
89     #: Absolute path to the deployment
90     location = None
91     def __init__(self, location, version=None):
92         self.location = os.path.abspath(location)
93         self._app_version = version
94         # some cache variables
95         self._read_cache = {}
96         self._old_log = None
97     def invalidateCache(self):
98         """
99         Invalidates all cached variables.  This currently applies to
100         :attr:`app_version`, :attr:`old_log` and :meth:`read`.
101         """
102         self._app_version = None
103         self._read_cache = {}
104         self._old_log = None
105     def read(self, file, force = False):
106         """
107         Reads a file's contents, possibly from cache unless ``force``
108         is ``True``.
109         """
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()
113             f.close()
114         return self._read_cache[file]
115     def extract(self):
116         """
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.
121         """
122         return self.application.extract(self)
123
124     def verify(self):
125         """
126         Checks if this is an autoinstall, throws an exception if there
127         are problems.
128         """
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)
139
140     def verifyTag(self, srv_path):
141         """
142         Checks if the purported version has a corresponding tag
143         in the upstream repository.
144         """
145         repo = self.application.repository(srv_path)
146         try:
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)
150
151     def verifyGit(self, srv_path):
152         """
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.
157         """
158         with util.ChangeDirectory(self.location):
159             sh = shell.Shell()
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):
164                 try:
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)
178
179     def verifyConfigured(self):
180         """
181         Checks if the autoinstall is configured running.
182         """
183         if not self.configured:
184             raise NotConfiguredError(self.location)
185
186     @chdir_to_location
187     def verifyVersion(self):
188         """
189         Checks if our version and the version number recorded in a file
190         are consistent.
191         """
192         real = self.application.detectVersion(self)
193         if not real:
194             raise VersionDetectionError
195         elif not str(real) == self.app_version.pristine_tag.partition('-')[2]:
196             raise VersionMismatchError(real, self.version)
197
198     @property
199     @chdir_to_location
200     def configured(self):
201         """Whether or not an autoinstall has been configured/installed for use."""
202         return self.application.checkConfig(self)
203     @property
204     def migrated(self):
205         """Whether or not the autoinstalls has been migrated."""
206         return os.path.isdir(self.scripts_dir)
207     @property
208     def scripts_dir(self):
209         """The absolute path of the ``.scripts`` directory."""
210         return os.path.join(self.location, '.scripts')
211     @property
212     def old_version_file(self):
213         """
214         The absolute path of either ``.scripts-version`` (for unmigrated
215         installs) or ``.scripts/version``.
216
217         .. note::
218
219             Use of this is discouraged for migrated installs.
220         """
221         return os.path.join(self.location, '.scripts-version')
222     @property
223     def version_file(self):
224         """The absolute path of the ``.scripts/version`` file."""
225         return os.path.join(self.scripts_dir, 'version')
226     @property
227     def application(self):
228         """The :class:`app.Application` of this deployment."""
229         return self.app_version.application
230     @property
231     def old_log(self):
232         """
233         The :class:`wizard.old_log.Log` of this deployment.  This
234         is only applicable to un-migrated autoinstalls.
235         """
236         if not self._old_log:
237             self._old_log = old_log.DeployLog.load(self)
238         return self._old_log
239     @property
240     def version(self):
241         """
242         The :class:`distutils.version.LooseVersion` of this
243         deployment.
244         """
245         return self.app_version.version
246     @property
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")):
251                 try:
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:
256                     pass
257         if not self._app_version:
258             self._app_version = self.old_log[-1].version
259         return self._app_version
260     @staticmethod
261     def parse(line):
262         """
263         Parses a line from the :term:`versions store`.
264
265         .. note::
266
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.
270         """
271         line = line.rstrip()
272         try:
273             location, deploydir = line.split(":")
274         except ValueError:
275             return ProductionCopy(line) # lazy loaded version
276         try:
277             return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
278         except Error as e:
279             e.location = location
280             raise e
281
282 class ProductionCopy(Deployment):
283     """
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.
287     """
288     @chdir_to_location
289     def upgrade(self, version, options):
290         """
291         Performs an upgrade of database schemas and other non-versioned data.
292         """
293         return self.application.upgrade(self, version, options)
294     @chdir_to_location
295     def backup(self, options):
296         """
297         Performs a backup of database schemas and other non-versioned data.
298         """
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):
303             os.mkdir(backupdir)
304         if os.path.exists(outdir):
305             util.safe_unlink(outdir)
306         os.mkdir(outdir)
307         self.application.backup(self, outdir, options)
308         return backup
309     @chdir_to_location
310     def restore(self, backup, options):
311         """
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).
316         """
317         backup_dir = os.path.join(".scripts", "backups", backup)
318         return self.application.restore(self, backup_dir, options)
319     def verifyWeb(self):
320         """
321         Checks if the autoinstall is viewable from the web.
322         """
323         out = []
324         if not self.application.checkWeb(self, out):
325             raise WebVerificationError(out[0])
326     def fetch(self, path, post=None):
327         """
328         Performs a HTTP request on the website.
329         """
330         try:
331             host, basepath = scripts.get_web_host_and_path(self.location)
332         except (ValueError, TypeError):
333             raise UnknownWebPath
334         return util.fetch(host, basepath, path, post)
335
336 class WorkingCopy(Deployment):
337     """
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.
341     """
342     @chdir_to_location
343     def parametrize(self, deployment):
344         """
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
348         equal ``self``.
349         """
350         return self.application.parametrize(self, deployment)
351     @chdir_to_location
352     def prepareConfig(self):
353         """
354         Edits files in the deployment such that any user-specific configuration
355         is replaced with generic WIZARD_* variables.
356         """
357         return self.application.prepareConfig(self)
358     @chdir_to_location
359     def resolveConflicts(self):
360         """
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.
364         """
365         return self.application.resolveConflicts(self)
366     @chdir_to_location
367     def prepareMerge(self):
368         """
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.
372         """
373         return self.application.prepareMerge(self)
374
375 ## -- Exceptions --
376
377 class Error(wizard.Error):
378     """Base error class for this module"""
379     pass
380
381 class NotMigratedError(Error):
382     """
383     The deployment contains a .scripts-version file, but no .git
384     or .scripts directory.
385     """
386     #: Directory of deployment
387     dir = None
388     def __init__(self, dir):
389         self.dir = dir
390     def __str__(self):
391         return """This installation was not migrated"""
392
393 class AlreadyVersionedError(Error):
394     """The deployment contained a .git directory but no .scripts directory."""
395     #: Directory of deployment
396     dir = None
397     def __init__(self, dir):
398         self.dir = dir
399     def __str__(self):
400         return """
401
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."""
406
407 class NotConfiguredError(Error):
408     """The install was missing essential configuration."""
409     #: Directory of unconfigured install
410     dir = None
411     def __init__(self, dir):
412         self.dir = dir
413     def __str__(self):
414         return """
415
416 ERROR: The install was well-formed, but not configured
417 (essential configuration files were not found.)"""
418
419 class CorruptedAutoinstallError(Error):
420     """The install was missing a .git directory, but had a .scripts directory."""
421     #: Directory of the corrupted install
422     dir = None
423     def __init__(self, dir):
424         self.dir = dir
425     def __str__(self):
426         return """
427
428 ERROR: Directory contains a .scripts directory,
429 but not a .git directory."""
430
431 class NotAutoinstallError(Error):
432     """The directory was not an autoinstall, due to missing .scripts-version file."""
433     #: Directory in question
434     dir = None
435     def __init__(self, dir):
436         self.dir = dir
437     def __str__(self):
438         return """
439
440 ERROR: Could not find .scripts-version file. Are you sure
441 this is an autoinstalled application?
442 """
443
444 class NoTagError(Error):
445     """Deployment has a tag that does not have an equivalent in upstream repository."""
446     #: Missing tag
447     tag = None
448     def __init__(self, tag):
449         self.tag = tag
450     def __str__(self):
451         return """
452
453 ERROR: Could not find tag %s in repository.""" % self.tag
454
455 class NoLocalTagError(Error):
456     """Could not find tag in local repository."""
457     #: Missing tag
458     tag = None
459     def __init__(self, tag):
460         self.tag = tag
461     def __str__(self):
462         return """
463
464 ERROR: Could not find tag %s in local repository.""" % self.tag
465
466 class InconsistentPristineTagError(Error):
467     """Pristine tag commit ID does not match upstream pristine tag commit ID."""
468     #: Inconsistent tag
469     tag = None
470     def __init__(self, tag):
471         self.tag = tag
472     def __str__(self):
473         return """
474
475 ERROR: Local pristine tag %s did not match repository's.  This
476 probably means an upstream rebase occured.""" % self.tag
477
478 class InconsistentScriptsTagError(Error):
479     """Scripts tag commit ID does not match upstream scripts tag commit ID."""
480     #: Inconsistent tag
481     tag = None
482     def __init__(self, tag):
483         self.tag = tag
484     def __str__(self):
485         return """
486
487 ERROR: Local scripts tag %s did not match repository's.  This
488 probably means an upstream rebase occurred.""" % self.tag
489
490 class HeadNotDescendantError(Error):
491     """HEAD is not connected to tag."""
492     #: Tag that HEAD should have been descendant of.
493     tag = None
494     def __init__(self, tag):
495         self.tag = tag
496     def __str__(self):
497         return """
498
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
502
503 class VersionDetectionError(Error):
504     """Could not detect real version of application."""
505     def __str__(self):
506         return """
507
508 ERROR: Could not detect the real version of the application."""
509
510 class VersionMismatchError(Error):
511     """Git version of application does not match detected version."""
512     #: Detected version
513     real_version = None
514     #: Version from Git
515     git_version = None
516     def __init__(self, real_version, git_version):
517         self.real_version = real_version
518         self.git_version = git_version
519     def __str__(self):
520         return """
521
522 ERROR: The detected version %s did not match the Git
523 version %s.""" % (self.real_version, self.git_version)
524
525 class WebVerificationError(Error):
526     """Could not access the application on the web"""
527     #: Contents of web page access
528     contents = None
529     def __init__(self, contents):
530         self.contents = contents
531     def __str__(self):
532         return """
533
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
537 of the page were:
538
539 %s""" % self.contents
540
541 class UnknownWebPath(Error):
542     """Could not determine application's web path."""
543     def __str__(self):
544         return """
545
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
550 variables."""
551