]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
Flesh out research errors, fix bad merge commit.
[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     @property
178     def configured(self):
179         """Whether or not an autoinstall has been configured/installed for use."""
180         return self.application.checkConfig(self)
181     @property
182     def migrated(self):
183         """Whether or not the autoinstalls has been migrated."""
184         return os.path.isdir(self.scripts_dir)
185     @property
186     def scripts_dir(self):
187         """The absolute path of the ``.scripts`` directory."""
188         return os.path.join(self.location, '.scripts')
189     @property
190     def old_version_file(self):
191         """
192         The absolute path of either ``.scripts-version`` (for unmigrated
193         installs) or ``.scripts/version``.
194
195         .. note::
196
197             Use of this is discouraged for migrated installs.
198         """
199         return os.path.join(self.location, '.scripts-version')
200     @property
201     def version_file(self):
202         """The absolute path of the ``.scripts/version`` file."""
203         return os.path.join(self.scripts_dir, 'version')
204     @property
205     def application(self):
206         """The :class:`Application` of this deployment."""
207         return self.app_version.application
208     @property
209     def old_log(self):
210         """
211         The :class:`wizard.old_log.Log` of this deployment.  This
212         is only applicable to un-migrated autoinstalls.
213         """
214         if not self._old_log:
215             self._old_log = old_log.DeployLog.load(self)
216         return self._old_log
217     @property
218     def version(self):
219         """
220         The :class:`distutils.version.LooseVersion` of this
221         deployment.
222         """
223         return self.app_version.version
224     @property
225     def app_version(self):
226         """The :class:`ApplicationVersion` of this deployment."""
227         if not self._app_version:
228             if os.path.isdir(os.path.join(self.location, ".git")):
229                 try:
230                     with util.ChangeDirectory(self.location):
231                         appname, _, version = git.describe().partition('-')
232                     self._app_version = ApplicationVersion.make(appname, version)
233                 except shell.CallError:
234                     pass
235         if not self._app_version:
236             self._app_version = self.old_log[-1].version
237         return self._app_version
238     @staticmethod
239     def parse(line):
240         """
241         Parses a line from the :term:`versions store`.
242
243         .. note::
244
245             Use this method only when speed is of the utmost
246             importance.  You should prefer to directly create a deployment
247             with only a ``location`` when possible.
248         """
249         line = line.rstrip()
250         try:
251             location, deploydir = line.split(":")
252         except ValueError:
253             return Deployment(line) # lazy loaded version
254         try:
255             return Deployment(location, version=ApplicationVersion.parse(deploydir))
256         except Error as e:
257             e.location = location
258             raise e
259
260 class Application(object):
261     """Represents an application, i.e. mediawiki or phpbb."""
262     #: String name of the application
263     name = None
264     #: Dictionary of version strings to :class:`ApplicationVersion`.
265     #: See also :meth:`makeVersion`.
266     versions = None
267     #: List of files that need to be modified when parametrizing.
268     #: This is a class-wide constant, and should not normally be modified.
269     parametrized_files = []
270     def __init__(self, name):
271         self.name = name
272         self.versions = {}
273         # cache variables
274         self._extractors = {}
275         self._substitutions = {}
276     def repository(self, srv_path):
277         """
278         Returns the Git repository that would contain this application.
279         ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
280         """
281         repo = os.path.join(srv_path, self.name + ".git")
282         if not os.path.isdir(repo):
283             repo = os.path.join(srv_path, self.name, ".git")
284             if not os.path.isdir(repo):
285                 raise NoRepositoryError(self.name)
286         return repo
287     def makeVersion(self, version):
288         """
289         Creates or retrieves the :class:`ApplicationVersion` singleton for the
290         specified version.
291         """
292         if version not in self.versions:
293             self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
294         return self.versions[version]
295     def extract(self, deployment):
296         """Extracts wizard variables from a deployment."""
297         result = {}
298         for k,extractor in self.extractors.items():
299             result[k] = extractor(deployment)
300         return result
301     def parametrize(self, deployment, dir):
302         """
303         Takes a generic source checkout at dir and parametrizes
304         it according to the values of deployment.
305         """
306         variables = deployment.extract()
307         for file in self.parametrized_files:
308             fullpath = os.path.join(dir, file)
309             try:
310                 contents = open(fullpath, "r").read()
311             except IOError:
312                 continue
313             for key, value in variables.items():
314                 if value is None: continue
315                 contents = contents.replace(key, value)
316             tmp = tempfile.NamedTemporaryFile(delete=False)
317             tmp.write(contents)
318             os.rename(tmp.name, fullpath)
319     def prepareConfig(self, deployment):
320         """
321         Takes a deployment and replaces any explicit instances
322         of a configuration variable with generic WIZARD_* constants.
323         There is a sane default implementation built on substitutions;
324         you can override this method to provide arbitrary extra
325         behavior.
326         """
327         for key, subst in self.substitutions.items():
328             subs = subst(deployment)
329             if not subs and key not in self.deprecated_keys:
330                 logging.warning("No substitutions for %s" % key)
331     def install(self, options):
332         """
333         Run for 'wizard configure' (and, by proxy, 'wizard install')
334         to configure an application.
335         """
336         raise NotImplemented
337     def upgrade(self, options):
338         """
339         Run for 'wizard upgrade' to upgrade database schemas and other
340         non-versioned data in an application.
341         """
342         raise NotImplemented
343     @property
344     def extractors(self):
345         """
346         Dictionary of variable names to extractor functions.  These functions
347         take a :class:`Deployment` as an argument and return the value of
348         the variable, or ``None`` if it could not be found.
349         See also :func:`wizard.app.filename_regex_extractor`.
350         """
351         return {}
352     @property
353     def substitutions(self):
354         """
355         Dictionary of variable names to substitution functions.  These functions
356         take a :class:`Deployment` as an argument and modify the deployment such
357         that an explicit instance of the variable is released with the generic
358         WIZARD_* constant.  See also :func:`wizard.app.filename_regex_substitution`.
359         """
360         return {}
361     @staticmethod
362     def make(name):
363         """Makes an application, but uses the correct subtype if available."""
364         try:
365             __import__("wizard.app." + name)
366             return getattr(wizard.app, name).Application(name)
367         except ImportError:
368             return Application(name)
369
370 class ApplicationVersion(object):
371     """Represents an abstract notion of a version for an application, where
372     ``version`` is a :class:`distutils.version.LooseVersion` and
373     ``application`` is a :class:`Application`."""
374     #: The :class:`distutils.version.LooseVersion` of this instance.
375     version = None
376     #: The :class:`Application` of this instance.
377     application = None
378     def __init__(self, version, application):
379         self.version = version
380         self.application = application
381     @property
382     def tag(self):
383         """
384         Returns the name of the git describe tag for the commit the user is
385         presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
386         """
387         return "%s-%s" % (self.application, self.version)
388     @property
389     def scripts_tag(self):
390         """
391         Returns the name of the Git tag for this version.
392         """
393         end = str(self.version).partition('-scripts')[2].partition('-')[0]
394         return "%s-scripts%s" % (self.pristine_tag, end)
395     @property
396     def pristine_tag(self):
397         """
398         Returns the name of the Git tag for the pristine version corresponding
399         to this version.
400         """
401         return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
402     def __cmp__(x, y):
403         return cmp(x.version, y.version)
404     @staticmethod
405     def parse(value):
406         """
407         Parses a line from the :term:`versions store` and return
408         :class:`ApplicationVersion`.
409
410         Use this only for cases when speed is of primary importance;
411         the data in version is unreliable and when possible, you should
412         prefer directly instantiating a Deployment and having it query
413         the autoinstall itself for information.
414
415         The `value` to parse will vary.  For old style installs, it
416         will look like::
417
418            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
419
420         For new style installs, it will look like::
421
422            APP-x.y.z-scripts
423         """
424         name = value.split("/")[-1]
425         try:
426             if name.find("-") != -1:
427                 app, _, version = name.partition("-")
428             else:
429                 # kind of poor, maybe should error.  Generally this
430                 # will actually result in a not found error
431                 app = name
432                 version = "trunk"
433         except ValueError:
434             raise DeploymentParseError(deploydir)
435         return ApplicationVersion.make(app, version)
436     @staticmethod
437     def make(app, version):
438         """
439         Makes/retrieves a singleton :class:`ApplicationVersion` from
440         a``app`` and ``version`` string.
441         """
442         try:
443             # defer to the application for version creation to enforce
444             # singletons
445             return applications()[app].makeVersion(version)
446         except KeyError:
447             raise NoSuchApplication(app)
448
449 ## -- Exceptions --
450
451 class Error(wizard.Error):
452     """Base error class for this module"""
453     pass
454
455 class NoSuchApplication(Error):
456     """
457     You attempted to reference a :class:`Application` named
458     ``app``, which is not recognized by Wizard.
459     """
460     #: The name of the application that does not exist.
461     app = None
462     #: The location of the autoinstall that threw this variable.
463     #: This should be set by error handling code when it is availble.
464     location = None
465     def __init__(self, app):
466         self.app = app
467
468 class DeploymentParseError(Error):
469     """
470     Could not parse ``value`` from :term:`versions store`.
471     """
472     #: The value that failed to parse.
473     value = None
474     #: The location of the autoinstall that threw this variable.
475     #: This should be set by error handling code when it is available.
476     location = None
477     def __init__(self, value):
478         self.value = value
479
480 class NoRepositoryError(Error):
481     """
482     :class:`Application` does not appear to have a Git repository
483     in the normal location.
484     """
485     #: The name of the application that does not have a Git repository.
486     app = None
487     def __init__(self, app):
488         self.app = app
489     def __str__(self):
490         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
491
492 class NotMigratedError(Error):
493     """
494     The deployment contains a .scripts-version file, but no .git
495     or .scripts directory.
496     """
497     def __init__(self, dir):
498         self.dir = dir
499     def __str__(self):
500         return """This installation was not migrated"""
501
502 class AlreadyVersionedError(Error):
503     def __init__(self, dir):
504         self.dir = dir
505     def __str__(self):
506         return """
507
508 ERROR: Directory contains a .git directory, but not
509 a .scripts directory.  If this is not a corrupt
510 migration, this means that the user was versioning their
511 install using Git."""
512
513 class NotConfiguredError(Error):
514     def __init__(self, dir):
515         self.dir = dir
516     def __str__(self):
517         return """
518
519 ERROR: The install was well-formed, but not configured
520 (essential configuration files were not found.)"""
521
522 class CorruptedAutoinstallError(Error):
523     def __init__(self, dir):
524         self.dir = dir
525     def __str__(self):
526         return """
527
528 ERROR: Directory contains a .scripts directory,
529 but not a .git directory."""
530
531 class NotAutoinstallError(Error):
532     def __init__(self, dir):
533         self.dir = dir
534     def __str__(self):
535         return """
536
537 ERROR: Could not find .scripts-version file. Are you sure
538 this is an autoinstalled application?
539 """
540
541 class NoTagError(Error):
542     def __init__(self, tag):
543         self.tag = tag
544     def __str__(self):
545         return """
546
547 ERROR: Could not find tag %s in repository.""" % self.tag
548
549 class NoLocalTagError(Error):
550     def __init__(self, tag):
551         self.tag = tag
552     def __str__(self):
553         return """
554
555 ERROR: Could not find tag %s in local repository.""" % self.tag
556
557 class InconsistentPristineTagError(Error):
558     def __init__(self, tag):
559         self.tag = tag
560     def __str__(self):
561         return """
562
563 ERROR: Local pristine tag %s did not match repository's.  This
564 probably means an upstream rebase occured.""" % self.tag
565
566 class InconsistentScriptsTagError(Error):
567     def __init__(self, tag):
568         self.tag = tag
569     def __str__(self):
570         return """
571
572 ERROR: Local scripts tag %s did not match repository's.  This
573 probably means an upstream rebase occurred.""" % self.tag
574
575 class HeadNotDescendantError(Error):
576     def __init__(self, tag):
577         self.tag = tag
578     def __str__(self):
579         return """
580
581 ERROR: HEAD is not a descendant of %s.  This probably
582 means that an upstream rebase occurred, and new tags were
583 pulled, but local user commits were never rebased.""" % self.tag
584
585 _application_list = [
586     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
587     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
588     # these are technically deprecated
589     "advancedpoll", "gallery",
590 ]
591 _applications = None
592
593 def applications():
594     """Hash table for looking up string application name to instance"""
595     global _applications
596     if not _applications:
597         _applications = dict([(n,Application.make(n)) for n in _application_list ])
598     return _applications
599