]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
Add version detection.
[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     def __init__(self, dir):
514         self.dir = dir
515     def __str__(self):
516         return """This installation was not migrated"""
517
518 class AlreadyVersionedError(Error):
519     def __init__(self, dir):
520         self.dir = dir
521     def __str__(self):
522         return """
523
524 ERROR: Directory contains a .git directory, but not
525 a .scripts directory.  If this is not a corrupt
526 migration, this means that the user was versioning their
527 install using Git."""
528
529 class NotConfiguredError(Error):
530     def __init__(self, dir):
531         self.dir = dir
532     def __str__(self):
533         return """
534
535 ERROR: The install was well-formed, but not configured
536 (essential configuration files were not found.)"""
537
538 class CorruptedAutoinstallError(Error):
539     def __init__(self, dir):
540         self.dir = dir
541     def __str__(self):
542         return """
543
544 ERROR: Directory contains a .scripts directory,
545 but not a .git directory."""
546
547 class NotAutoinstallError(Error):
548     def __init__(self, dir):
549         self.dir = dir
550     def __str__(self):
551         return """
552
553 ERROR: Could not find .scripts-version file. Are you sure
554 this is an autoinstalled application?
555 """
556
557 class NoTagError(Error):
558     def __init__(self, tag):
559         self.tag = tag
560     def __str__(self):
561         return """
562
563 ERROR: Could not find tag %s in repository.""" % self.tag
564
565 class NoLocalTagError(Error):
566     def __init__(self, tag):
567         self.tag = tag
568     def __str__(self):
569         return """
570
571 ERROR: Could not find tag %s in local repository.""" % self.tag
572
573 class InconsistentPristineTagError(Error):
574     def __init__(self, tag):
575         self.tag = tag
576     def __str__(self):
577         return """
578
579 ERROR: Local pristine tag %s did not match repository's.  This
580 probably means an upstream rebase occured.""" % self.tag
581
582 class InconsistentScriptsTagError(Error):
583     def __init__(self, tag):
584         self.tag = tag
585     def __str__(self):
586         return """
587
588 ERROR: Local scripts tag %s did not match repository's.  This
589 probably means an upstream rebase occurred.""" % self.tag
590
591 class HeadNotDescendantError(Error):
592     def __init__(self, tag):
593         self.tag = tag
594     def __str__(self):
595         return """
596
597 ERROR: HEAD is not a descendant of %s.  This probably
598 means that an upstream rebase occurred, and new tags were
599 pulled, but local user commits were never rebased.""" % self.tag
600
601 class VersionDetectionError(Error):
602     def __str__(self):
603         return """
604
605 ERROR: Could not detect the real version of the application."""
606
607 class VersionMismatchError(Error):
608     def __init__(self, real_version, git_version):
609         self.real_version = real_version
610         self.git_version = git_version
611     def __str__(self):
612         return """
613
614 ERROR: The detected version %s did not match the Git
615 version %s.""" % (self.real_version, self.git_version)
616
617 _application_list = [
618     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
619     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
620     # these are technically deprecated
621     "advancedpoll", "gallery",
622 ]
623 _applications = None
624
625 def applications():
626     """Hash table for looking up string application name to instance"""
627     global _applications
628     if not _applications:
629         _applications = dict([(n,Application.make(n)) for n in _application_list ])
630     return _applications
631