]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
Fix documentation.
[wizard.git] / wizard / deploy.py
1 """
2 Object model for querying information and manipulating deployments
3 of autoinstalls.  Every :class:`Deployment` has an :class:`ApplicationVersion`
4 which in turn has an :class:`Application`.
5 """
6
7 import os.path
8 import fileinput
9 import dateutil.parser
10 import distutils.version
11 import tempfile
12 import logging
13
14 import wizard
15 from wizard import git, old_log, shell, util
16
17 ## -- Global Functions --
18
19 def get_install_lines(versions_store):
20     """
21     Low level function that retrieves a list of lines from the
22     :term:`versions store` that can be passed to :meth:`Deployment.parse`.
23     """
24     if os.path.isfile(versions_store):
25         return fileinput.input([versions_store])
26     return fileinput.input([versions_store + "/" + f for f in sorted(os.listdir(versions_store))])
27
28 def parse_install_lines(show, versions_store, yield_errors = False):
29     """
30     Generator function for iterating through all autoinstalls.
31     Each item is an instance of :class:`Deployment`, or possibly
32     a :class:`wizard.deploy.Error` if ``yield_errors`` is ``True``.  You can
33     filter out applications and versions by specifying ``app``
34     or ``app-1.2.3`` in ``show``.  This function may generate
35     log output.  Make sure that ``show`` is a list and not a string.
36     """
37     if not show: show = applications()
38     show = frozenset(show)
39     for line in get_install_lines(versions_store):
40         # construction
41         try:
42             d = Deployment.parse(line)
43             name = d.application.name
44         except NoSuchApplication as e:
45             if yield_errors:
46                 yield e
47             continue
48         except Error:
49             # we consider this a worse error
50             logging.warning("Error with '%s'" % line.rstrip())
51             continue
52         # filter
53         if name + "-" + str(d.version) in show or name in show:
54             pass
55         else:
56             continue
57         # yield
58         yield d
59
60 ## -- Model Objects --
61
62 class Deployment(object):
63     """
64     Represents a deployment of an autoinstall, e.g. directory in a user's
65     web_scripts that has ``.scripts`` directory or ``.scripts-version``
66     file in it.  Supply ``version`` with an :class:`ApplicationVersion` only if
67     you were reading from the :term:`versions store` and care about
68     speed (data from there can be stale).
69     """
70     #: Absolute path to the deployment
71     location = None
72     def __init__(self, location, version=None):
73         self.location = os.path.abspath(location)
74         self._app_version = version
75         # some cache variables
76         self._read_cache = {}
77         self._old_log = None
78     def read(self, file, force = False):
79         """
80         Reads a file's contents, possibly from cache unless ``force``
81         is ``True``.
82         """
83         if force or file not in self._read_cache:
84             f = open(os.path.join(self.location, file))
85             self._read_cache[file] = f.read()
86             f.close()
87         return self._read_cache[file]
88     def extract(self):
89         """
90         Extracts all the values of all variables from deployment.
91         These variables may be used for parametrizing generic parent
92         commits and include things such as database access credentials
93         and local configuration.
94         """
95         return self.application.extract(self)
96     def parametrize(self, dir):
97         """
98         Edits files in ``dir`` to replace WIZARD_* variables with literal
99         instances.  This is used for constructing virtual merge bases, and
100         as such dir will generally not equal :attr:`location`.
101         """
102         return self.application.parametrize(self, dir)
103     def prepareConfig(self):
104         """
105         Edits files in the deployment such that any user-specific configuration
106         is replaced with generic WIZARD_* variables.
107         """
108         return self.application.prepareConfig(self)
109     def checkConfig(self, deployment):
110         """
111         Checks if the application is configured.
112         """
113         raise NotImplemented
114
115     def verify(self):
116         """
117         Checks if this is an autoinstall, throws an exception if there
118         are problems.
119         """
120         with util.ChangeDirectory(self.location):
121             has_git = os.path.isdir(".git")
122             has_scripts = os.path.isdir(".scripts")
123             if not has_git and has_scripts:
124                 raise CorruptedAutoinstallError(self.location)
125             elif has_git and not has_scripts:
126                 raise AlreadyVersionedError(self.location)
127             elif not has_git and not has_scripts:
128                 if os.path.isfile(".scripts-version"):
129                     raise NotMigratedError(self.location)
130
131     def verifyTag(self, srv_path):
132         """
133         Checks if the purported version has a corresponding tag
134         in the upstream repository.
135         """
136         repo = self.application.repository(srv_path)
137         try:
138             shell.Shell().eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
139         except shell.CallError:
140             raise NoTagError(self.app_version.scripts_tag)
141
142     def verifyGit(self, srv_path):
143         """
144         Checks if the autoinstall's Git repository makes sense,
145         checking if the tag is parseable and corresponds to
146         a real application, and if the tag in this repository
147         corresponds to the one in the remote repository.
148         """
149         with util.ChangeDirectory(self.location):
150             sh = shell.Shell()
151             repo = self.application.repository(srv_path)
152             def repo_rev_parse(tag):
153                 return sh.eval("git", "--git-dir", repo, "rev-parse", tag)
154             def self_rev_parse(tag):
155                 try:
156                     return sh.safeCall("git", "rev-parse", tag, strip=True)
157                 except shell.CallError:
158                     raise NoLocalTagError(tag)
159             def compare_tags(tag):
160                 return repo_rev_parse(tag) == self_rev_parse(tag)
161             if not compare_tags(self.app_version.pristine_tag):
162                 raise InconsistentPristineTagError(self.app_version.pristine_tag)
163             if not compare_tags(self.app_version.scripts_tag):
164                 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
165             parent = repo_rev_parse(self.app_version.scripts_tag)
166             merge_base = sh.safeCall("git", "merge-base", parent, "HEAD", strip=True)
167             if merge_base != parent:
168                 raise HeadNotDescendantError(self.app_version.scripts_tag)
169
170     def verifyConfigured(self):
171         """
172         Checks if the autoinstall is configured running.
173         """
174         if not self.configured:
175             raise NotConfiguredError(self.location)
176
177     def verifyVersion(self):
178         """
179         Checks if our version and the version number recorded in a file
180         are consistent.
181         """
182         real = self.application.detectVersion(self)
183         if not real:
184             raise VersionDetectionError
185         elif not str(real) == self.app_version.pristine_tag.partition('-')[2]:
186             raise VersionMismatchError(real, self.version)
187
188     @property
189     def configured(self):
190         """Whether or not an autoinstall has been configured/installed for use."""
191         return self.application.checkConfig(self)
192     @property
193     def migrated(self):
194         """Whether or not the autoinstalls has been migrated."""
195         return os.path.isdir(self.scripts_dir)
196     @property
197     def scripts_dir(self):
198         """The absolute path of the ``.scripts`` directory."""
199         return os.path.join(self.location, '.scripts')
200     @property
201     def old_version_file(self):
202         """
203         The absolute path of either ``.scripts-version`` (for unmigrated
204         installs) or ``.scripts/version``.
205
206         .. note::
207
208             Use of this is discouraged for migrated installs.
209         """
210         return os.path.join(self.location, '.scripts-version')
211     @property
212     def version_file(self):
213         """The absolute path of the ``.scripts/version`` file."""
214         return os.path.join(self.scripts_dir, 'version')
215     @property
216     def application(self):
217         """The :class:`Application` of this deployment."""
218         return self.app_version.application
219     @property
220     def old_log(self):
221         """
222         The :class:`wizard.old_log.Log` of this deployment.  This
223         is only applicable to un-migrated autoinstalls.
224         """
225         if not self._old_log:
226             self._old_log = old_log.DeployLog.load(self)
227         return self._old_log
228     @property
229     def version(self):
230         """
231         The :class:`distutils.version.LooseVersion` of this
232         deployment.
233         """
234         return self.app_version.version
235     @property
236     def app_version(self):
237         """The :class:`ApplicationVersion` of this deployment."""
238         if not self._app_version:
239             if os.path.isdir(os.path.join(self.location, ".git")):
240                 try:
241                     with util.ChangeDirectory(self.location):
242                         appname, _, version = git.describe().partition('-')
243                     self._app_version = ApplicationVersion.make(appname, version)
244                 except shell.CallError:
245                     pass
246         if not self._app_version:
247             self._app_version = self.old_log[-1].version
248         return self._app_version
249     @staticmethod
250     def parse(line):
251         """
252         Parses a line from the :term:`versions store`.
253
254         .. note::
255
256             Use this method only when speed is of the utmost
257             importance.  You should prefer to directly create a deployment
258             with only a ``location`` when possible.
259         """
260         line = line.rstrip()
261         try:
262             location, deploydir = line.split(":")
263         except ValueError:
264             return Deployment(line) # lazy loaded version
265         try:
266             return Deployment(location, version=ApplicationVersion.parse(deploydir))
267         except Error as e:
268             e.location = location
269             raise e
270
271 class Application(object):
272     """Represents an application, i.e. mediawiki or phpbb."""
273     #: String name of the application
274     name = None
275     #: Dictionary of version strings to :class:`ApplicationVersion`.
276     #: See also :meth:`makeVersion`.
277     versions = None
278     #: List of files that need to be modified when parametrizing.
279     #: This is a class-wide constant, and should not normally be modified.
280     parametrized_files = []
281     def __init__(self, name):
282         self.name = name
283         self.versions = {}
284         # cache variables
285         self._extractors = {}
286         self._substitutions = {}
287     def repository(self, srv_path):
288         """
289         Returns the Git repository that would contain this application.
290         ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
291         """
292         repo = os.path.join(srv_path, self.name + ".git")
293         if not os.path.isdir(repo):
294             repo = os.path.join(srv_path, self.name, ".git")
295             if not os.path.isdir(repo):
296                 raise NoRepositoryError(self.name)
297         return repo
298     def makeVersion(self, version):
299         """
300         Creates or retrieves the :class:`ApplicationVersion` singleton for the
301         specified version.
302         """
303         if version not in self.versions:
304             self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
305         return self.versions[version]
306     def extract(self, deployment):
307         """Extracts wizard variables from a deployment."""
308         result = {}
309         for k,extractor in self.extractors.items():
310             result[k] = extractor(deployment)
311         return result
312     def parametrize(self, deployment, dir):
313         """
314         Takes a generic source checkout at dir and parametrizes
315         it according to the values of deployment.
316         """
317         variables = deployment.extract()
318         for file in self.parametrized_files:
319             fullpath = os.path.join(dir, file)
320             try:
321                 contents = open(fullpath, "r").read()
322             except IOError:
323                 continue
324             for key, value in variables.items():
325                 if value is None: continue
326                 contents = contents.replace(key, value)
327             tmp = tempfile.NamedTemporaryFile(delete=False)
328             tmp.write(contents)
329             os.rename(tmp.name, fullpath)
330     def prepareConfig(self, deployment):
331         """
332         Takes a deployment and replaces any explicit instances
333         of a configuration variable with generic WIZARD_* constants.
334         There is a sane default implementation built on substitutions;
335         you can override this method to provide arbitrary extra
336         behavior.
337         """
338         for key, subst in self.substitutions.items():
339             subs = subst(deployment)
340             if not subs and key not in self.deprecated_keys:
341                 logging.warning("No substitutions for %s" % key)
342     def install(self, options):
343         """
344         Run for 'wizard configure' (and, by proxy, 'wizard install')
345         to configure an application.
346         """
347         raise NotImplemented
348     def upgrade(self, options):
349         """
350         Run for 'wizard upgrade' to upgrade database schemas and other
351         non-versioned data in an application.
352         """
353         raise NotImplemented
354     def detectVersion(self, deployment):
355         """
356         Checks source files to determine the version manually.
357         """
358         return None
359     @property
360     def extractors(self):
361         """
362         Dictionary of variable names to extractor functions.  These functions
363         take a :class:`Deployment` as an argument and return the value of
364         the variable, or ``None`` if it could not be found.
365         See also :func:`wizard.app.filename_regex_extractor`.
366         """
367         return {}
368     @property
369     def substitutions(self):
370         """
371         Dictionary of variable names to substitution functions.  These functions
372         take a :class:`Deployment` as an argument and modify the deployment such
373         that an explicit instance of the variable is released with the generic
374         WIZARD_* constant.  See also :func:`wizard.app.filename_regex_substitution`.
375         """
376         return {}
377     @staticmethod
378     def make(name):
379         """Makes an application, but uses the correct subtype if available."""
380         try:
381             __import__("wizard.app." + name)
382             return getattr(wizard.app, name).Application(name)
383         except ImportError:
384             return Application(name)
385
386 class ApplicationVersion(object):
387     """Represents an abstract notion of a version for an application, where
388     ``version`` is a :class:`distutils.version.LooseVersion` and
389     ``application`` is a :class:`Application`."""
390     #: The :class:`distutils.version.LooseVersion` of this instance.
391     version = None
392     #: The :class:`Application` of this instance.
393     application = None
394     def __init__(self, version, application):
395         self.version = version
396         self.application = application
397     @property
398     def tag(self):
399         """
400         Returns the name of the git describe tag for the commit the user is
401         presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
402         """
403         return "%s-%s" % (self.application, self.version)
404     @property
405     def scripts_tag(self):
406         """
407         Returns the name of the Git tag for this version.
408         """
409         end = str(self.version).partition('-scripts')[2].partition('-')[0]
410         return "%s-scripts%s" % (self.pristine_tag, end)
411     @property
412     def pristine_tag(self):
413         """
414         Returns the name of the Git tag for the pristine version corresponding
415         to this version.
416         """
417         return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
418     def __cmp__(x, y):
419         return cmp(x.version, y.version)
420     @staticmethod
421     def parse(value):
422         """
423         Parses a line from the :term:`versions store` and return
424         :class:`ApplicationVersion`.
425
426         Use this only for cases when speed is of primary importance;
427         the data in version is unreliable and when possible, you should
428         prefer directly instantiating a Deployment and having it query
429         the autoinstall itself for information.
430
431         The `value` to parse will vary.  For old style installs, it
432         will look like::
433
434            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
435
436         For new style installs, it will look like::
437
438            APP-x.y.z-scripts
439         """
440         name = value.split("/")[-1]
441         try:
442             if name.find("-") != -1:
443                 app, _, version = name.partition("-")
444             else:
445                 # kind of poor, maybe should error.  Generally this
446                 # will actually result in a not found error
447                 app = name
448                 version = "trunk"
449         except ValueError:
450             raise DeploymentParseError(deploydir)
451         return ApplicationVersion.make(app, version)
452     @staticmethod
453     def make(app, version):
454         """
455         Makes/retrieves a singleton :class:`ApplicationVersion` from
456         a``app`` and ``version`` string.
457         """
458         try:
459             # defer to the application for version creation to enforce
460             # singletons
461             return applications()[app].makeVersion(version)
462         except KeyError:
463             raise NoSuchApplication(app)
464
465 ## -- Exceptions --
466
467 class Error(wizard.Error):
468     """Base error class for this module"""
469     pass
470
471 class NoSuchApplication(Error):
472     """
473     You attempted to reference a :class:`Application` named
474     ``app``, which is not recognized by Wizard.
475     """
476     #: The name of the application that does not exist.
477     app = None
478     #: The location of the autoinstall that threw this variable.
479     #: This should be set by error handling code when it is availble.
480     location = None
481     def __init__(self, app):
482         self.app = app
483
484 class DeploymentParseError(Error):
485     """
486     Could not parse ``value`` from :term:`versions store`.
487     """
488     #: The value that failed to parse.
489     value = None
490     #: The location of the autoinstall that threw this variable.
491     #: This should be set by error handling code when it is available.
492     location = None
493     def __init__(self, value):
494         self.value = value
495
496 class NoRepositoryError(Error):
497     """
498     :class:`Application` does not appear to have a Git repository
499     in the normal location.
500     """
501     #: The name of the application that does not have a Git repository.
502     app = None
503     def __init__(self, app):
504         self.app = app
505     def __str__(self):
506         return """Could not find Git repository for '%s'.  If you would like to use a local version, try specifying --srv-path or WIZARD_SRV_PATH.""" % self.app
507
508 class NotMigratedError(Error):
509     """
510     The deployment contains a .scripts-version file, but no .git
511     or .scripts directory.
512     """
513     #: Directory of deployment
514     dir = None
515     def __init__(self, dir):
516         self.dir = dir
517     def __str__(self):
518         return """This installation was not migrated"""
519
520 class AlreadyVersionedError(Error):
521     """The deployment contained a .git directory but no .scripts directory."""
522     #: Directory of deployment
523     dir = None
524     def __init__(self, dir):
525         self.dir = dir
526     def __str__(self):
527         return """
528
529 ERROR: Directory contains a .git directory, but not
530 a .scripts directory.  If this is not a corrupt
531 migration, this means that the user was versioning their
532 install using Git."""
533
534 class NotConfiguredError(Error):
535     """The install was missing essential configuration."""
536     #: Directory of unconfigured install
537     dir = None
538     def __init__(self, dir):
539         self.dir = dir
540     def __str__(self):
541         return """
542
543 ERROR: The install was well-formed, but not configured
544 (essential configuration files were not found.)"""
545
546 class CorruptedAutoinstallError(Error):
547     """The install was missing a .git directory, but had a .scripts directory."""
548     #: Directory of the corrupted install
549     dir = None
550     def __init__(self, dir):
551         self.dir = dir
552     def __str__(self):
553         return """
554
555 ERROR: Directory contains a .scripts directory,
556 but not a .git directory."""
557
558 class NotAutoinstallError(Error):
559     """The directory was not an autoinstall, due to missing .scripts-version file."""
560     #: Directory in question
561     dir = None
562     def __init__(self, dir):
563         self.dir = dir
564     def __str__(self):
565         return """
566
567 ERROR: Could not find .scripts-version file. Are you sure
568 this is an autoinstalled application?
569 """
570
571 class NoTagError(Error):
572     """Deployment has a tag that does not have an equivalent in upstream repository."""
573     #: Missing tag
574     tag = None
575     def __init__(self, tag):
576         self.tag = tag
577     def __str__(self):
578         return """
579
580 ERROR: Could not find tag %s in repository.""" % self.tag
581
582 class NoLocalTagError(Error):
583     """Could not find tag in local repository."""
584     #: Missing tag
585     tag = None
586     def __init__(self, tag):
587         self.tag = tag
588     def __str__(self):
589         return """
590
591 ERROR: Could not find tag %s in local repository.""" % self.tag
592
593 class InconsistentPristineTagError(Error):
594     """Pristine tag commit ID does not match upstream pristine tag commit ID."""
595     #: Inconsistent tag
596     tag = None
597     def __init__(self, tag):
598         self.tag = tag
599     def __str__(self):
600         return """
601
602 ERROR: Local pristine tag %s did not match repository's.  This
603 probably means an upstream rebase occured.""" % self.tag
604
605 class InconsistentScriptsTagError(Error):
606     """Scripts tag commit ID does not match upstream scripts tag commit ID."""
607     #: Inconsistent tag
608     tag = None
609     def __init__(self, tag):
610         self.tag = tag
611     def __str__(self):
612         return """
613
614 ERROR: Local scripts tag %s did not match repository's.  This
615 probably means an upstream rebase occurred.""" % self.tag
616
617 class HeadNotDescendantError(Error):
618     """HEAD is not connected to tag."""
619     #: Tag that HEAD should have been descendant of.
620     tag = None
621     def __init__(self, tag):
622         self.tag = tag
623     def __str__(self):
624         return """
625
626 ERROR: HEAD is not a descendant of %s.  This probably
627 means that an upstream rebase occurred, and new tags were
628 pulled, but local user commits were never rebased.""" % self.tag
629
630 class VersionDetectionError(Error):
631     """Could not detect real version of application."""
632     def __str__(self):
633         return """
634
635 ERROR: Could not detect the real version of the application."""
636
637 class VersionMismatchError(Error):
638     """Git version of application does not match detected version."""
639     #: Detected version
640     real_version = None
641     #: Version from Git
642     git_version = None
643     def __init__(self, real_version, git_version):
644         self.real_version = real_version
645         self.git_version = git_version
646     def __str__(self):
647         return """
648
649 ERROR: The detected version %s did not match the Git
650 version %s.""" % (self.real_version, self.git_version)
651
652 _application_list = [
653     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
654     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
655     # these are technically deprecated
656     "advancedpoll", "gallery",
657 ]
658 _applications = None
659
660 def applications():
661     """Hash table for looking up string application name to instance"""
662     global _applications
663     if not _applications:
664         _applications = dict([(n,Application.make(n)) for n in _application_list ])
665     return _applications
666