]> scripts.mit.edu Git - wizard.git/blob - wizard/app/__init__.py
1fc1669461c5d0b608b3cbc046e9833354182e82
[wizard.git] / wizard / app / __init__.py
1 """
2 Plumbing object model for representing applications we want to
3 install.  This module does the heavy lifting, but you probably
4 want to use :class:`wizard.deploy.Deployment` which is more user-friendly.
5 You'll need to know how to overload the :class:`Application` class
6 and use some of the functions in this module in order to specify
7 new applications.
8
9 .. testsetup:: *
10
11     import re
12     import shutil
13     import os
14     from wizard import deploy, util
15     from wizard.app import *
16 """
17
18 import os.path
19 import re
20 import distutils.version
21 import decorator
22 import shlex
23
24 import wizard
25 from wizard import scripts, shell, util
26
27 _application_list = [
28     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
29     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
30     # these are technically deprecated
31     "advancedpoll", "gallery",
32 ]
33 _applications = None
34
35 def applications():
36     """Hash table for looking up string application name to instance"""
37     global _applications
38     if not _applications:
39         _applications = dict([(n,Application.make(n)) for n in _application_list ])
40     return _applications
41
42
43 class Application(object):
44     """
45     Represents an application, i.e. mediawiki or phpbb.
46
47     .. note::
48         Many of these methods assume a specific working
49         directory; prefer using the corresponding methods
50         in :class:`wizard.deploy.Deployment` and its subclasses.
51     """
52     #: String name of the application
53     name = None
54     #: Dictionary of version strings to :class:`ApplicationVersion`.
55     #: See also :meth:`makeVersion`.
56     versions = None
57     #: List of files that need to be modified when parametrizing.
58     #: This is a class-wide constant, and should not normally be modified.
59     parametrized_files = []
60     #: Keys that are used in older versions of the application, but
61     #: not for the most recent version.
62     deprecated_keys = []
63     #: Dictionary of variable names to extractor functions.  These functions
64     #: take a :class:`wizard.deploy.Deployment` as an argument and return the value of
65     #: the variable, or ``None`` if it could not be found.
66     #: See also :func:`filename_regex_extractor`.
67     extractors = {}
68     #: Dictionary of variable names to substitution functions.  These functions
69     #: take a :class:`wizard.deploy.Deployment` as an argument and modify the deployment such
70     #: that an explicit instance of the variable is released with the generic
71     #: ``WIZARD_*`` constant.  See also :func:`filename_regex_substitution`.
72     substitutions = {}
73     #: Dictionary of file names to a list of resolutions, which are tuples of
74     #: a conflict marker string and a result list.  See :mod:`wizard.resolve`
75     #: for more information.
76     resolutions = {}
77     def __init__(self, name):
78         self.name = name
79         self.versions = {}
80         # cache variables
81         self._extractors = {}
82         self._substitutions = {}
83     def repository(self, srv_path):
84         """
85         Returns the Git repository that would contain this application.
86         ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
87         """
88         repo = os.path.join(srv_path, self.name + ".git")
89         if not os.path.isdir(repo):
90             repo = os.path.join(srv_path, self.name, ".git")
91             if not os.path.isdir(repo):
92                 raise NoRepositoryError(self.name)
93         return repo
94     def makeVersion(self, version):
95         """
96         Creates or retrieves the :class:`ApplicationVersion` singleton for the
97         specified version.
98         """
99         if version not in self.versions:
100             self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
101         return self.versions[version]
102     def extract(self, deployment):
103         """
104         Extracts wizard variables from a deployment.  Default implementation
105         uses :attr:`extractors`.
106         """
107         result = {}
108         for k,extractor in self.extractors.items():
109             result[k] = extractor(deployment)
110         return result
111     def parametrize(self, deployment, ref_deployment):
112         """
113         Takes a generic source checkout and parametrizes it according to the
114         values of ``deployment``.  This function operates on the current
115         working directory.  ``deployment`` should **not** be the same as the
116         current working directory.  Default implementation uses
117         :attr:`parametrized_files` and a simple search and replace on those
118         files.
119         """
120         variables = ref_deployment.extract()
121         for file in self.parametrized_files:
122             try:
123                 contents = open(file, "r").read()
124             except IOError:
125                 continue
126             for key, value in variables.items():
127                 if value is None: continue
128                 contents = contents.replace(key, value)
129             f = open(file, "w")
130             f.write(contents)
131     def resolveConflicts(self, deployment):
132         """
133         Resolves conflicted files in the current working directory.  Returns
134         whether or not all conflicted files were resolved or not.  Fully
135         resolved files are added to the index, but no commit is made.  The
136         default implementation uses :attr:`resolutions`.
137         """
138         resolved = True
139         sh = shell.Shell()
140         for status in sh.eval("git", "ls-files", "--unmerged").splitlines():
141             file = status.split()[-1]
142             if file in self.resolutions:
143                 contents = open(file, "r").read()
144                 for spec, result in self.resolutions[file]:
145                     old_contents = contents
146                     contents = resolve.resolve(contents, spec, result)
147                     if old_contents != contents:
148                         logging.info("Did resolution with spec:\n" + spec)
149                 open(file, "w").write(contents)
150                 if not resolve.is_conflict(contents):
151                     sh.call("git", "add", file)
152                 else:
153                     resolved = False
154             else:
155                 resolved = False
156         return resolved
157     def prepareMerge(self, deployment):
158         """
159         Performs various edits to files in the current working directory in
160         order to make a merge go more smoothly.  This is usually
161         used to fix botched line-endings.  If you add new files,
162         you have to 'git add' them; this is not necessary for edits.
163         By default this is a no-op; subclasses should replace this
164         with useful behavior.
165         """
166         pass
167     def prepareConfig(self, deployment):
168         """
169         Takes a deployment and replaces any explicit instances
170         of a configuration variable with generic ``WIZARD_*`` constants.
171         The default implementation uses :attr:`substitutions`, and
172         emits warnings when it encounters keys in :attr:`deprecated_keys`.
173         """
174         for key, subst in self.substitutions.items():
175             subs = subst(deployment)
176             if not subs and key not in self.deprecated_keys:
177                 logging.warning("No substitutions for %s" % key)
178     def install(self, version, options):
179         """
180         Run for 'wizard configure' (and, by proxy, 'wizard install') to
181         configure an application.  This assumes that the current working
182         directory is a deployment.  (Unlike its kin, this function does not
183         take a :class:`wizard.deploy.Deployment` as a parameter.)  Subclasses should
184         provide an implementation.
185         """
186         raise NotImplemented
187     def upgrade(self, deployment, version, options):
188         """
189         Run for 'wizard upgrade' to upgrade database schemas and other
190         non-versioned data in an application after the filesystem has been
191         upgraded.  This assumes that the current working directory is the
192         deployment.  Subclasses should provide an implementation.
193         """
194         raise NotImplemented
195     def backup(self, deployment, outdir, options):
196         """
197         Run for 'wizard backup' and upgrades to backup database schemas
198         and other non-versioned data in an application.  ``outdir`` is
199         the directory that backup files should be placed.  This assumes
200         that the current working directory is the deployment.  Subclasses
201         should provide an implementation, even if it is a no-op.
202
203         .. note::
204             Static user files may not need to be backed up, since in
205             many applications upgrades do not modify static files.
206         """
207         raise NotImplemented
208     def restore(self, deployment, backup_dir, options):
209         """
210         Run for 'wizard restore' and failed upgrades to restore database
211         and other non-versioned data to a backed up version.  This assumes
212         that the current working directory is the deployment.  Subclasses
213         should provide an implementation.
214         """
215         raise NotImplemented
216     def detectVersion(self, deployment):
217         """
218         Checks source files to determine the version manually.  This assumes
219         that the current working directory is the deployment.  Subclasses
220         should provide an implementation.
221         """
222         raise NotImplemented
223     def checkWeb(self, deployment, output=None):
224         """
225         Checks if the autoinstall is viewable from the web.  To get
226         the HTML source that was retrieved, pass a variable containing
227         an empty list to ``output``; it will be mutated to have its
228         first element be the output.  Subclasses should provide an
229         implementation.
230
231         .. note::
232             Finding a reasonable heuristic that works across skinning
233             choices can be difficult.  We've had reasonable success
234             searching for metadata.  Be sure that the standard error
235             page does not contain the features you search for.  Try
236             not to depend on pages that are not the main page.
237         """
238         raise NotImplemented
239     def checkConfig(self, deployment):
240         """
241         Checks whether or not an autoinstall has been configured/installed
242         for use.  Assumes that the current working directory is the deployment.
243         Subclasses should provide an implementation.
244         """
245         raise NotImplemented
246     @staticmethod
247     def make(name):
248         """Makes an application, but uses the correct subtype if available."""
249         try:
250             __import__("wizard.app." + name)
251             return getattr(wizard.app, name).Application(name)
252         except ImportError:
253             return Application(name)
254
255 class ApplicationVersion(object):
256     """Represents an abstract notion of a version for an application, where
257     ``version`` is a :class:`distutils.version.LooseVersion` and
258     ``application`` is a :class:`Application`."""
259     #: The :class:`distutils.version.LooseVersion` of this instance.
260     version = None
261     #: The :class:`Application` of this instance.
262     application = None
263     def __init__(self, version, application):
264         self.version = version
265         self.application = application
266     @property
267     def tag(self):
268         """
269         Returns the name of the git describe tag for the commit the user is
270         presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
271         """
272         return "%s-%s" % (self.application, self.version)
273     @property
274     def scripts_tag(self):
275         """
276         Returns the name of the Git tag for this version.
277         """
278         end = str(self.version).partition('-scripts')[2].partition('-')[0]
279         return "%s-scripts%s" % (self.pristine_tag, end)
280     @property
281     def pristine_tag(self):
282         """
283         Returns the name of the Git tag for the pristine version corresponding
284         to this version.
285         """
286         return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
287     def __cmp__(self, y):
288         return cmp(self.version, y.version)
289     @staticmethod
290     def parse(value):
291         """
292         Parses a line from the :term:`versions store` and return
293         :class:`ApplicationVersion`.
294
295         Use this only for cases when speed is of primary importance;
296         the data in version is unreliable and when possible, you should
297         prefer directly instantiating a :class:`wizard.deploy.Deployment` and having it query
298         the autoinstall itself for information.
299
300         The `value` to parse will vary.  For old style installs, it
301         will look like::
302
303            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
304
305         For new style installs, it will look like::
306
307            APP-x.y.z-scripts
308         """
309         name = value.split("/")[-1]
310         try:
311             if name.find("-") != -1:
312                 app, _, version = name.partition("-")
313             else:
314                 # kind of poor, maybe should error.  Generally this
315                 # will actually result in a not found error
316                 app = name
317                 version = "trunk"
318         except ValueError:
319             raise DeploymentParseError(value)
320         return ApplicationVersion.make(app, version)
321     @staticmethod
322     def make(app, version):
323         """
324         Makes/retrieves a singleton :class:`ApplicationVersion` from
325         a``app`` and ``version`` string.
326         """
327         try:
328             # defer to the application for version creation to enforce
329             # singletons
330             return applications()[app].makeVersion(version)
331         except KeyError:
332             raise NoSuchApplication(app)
333
334 def expand_re(val):
335     """
336     Takes a tree of values (implement using nested lists) and
337     transforms them into regular expressions.
338
339         >>> expand_re('*')
340         '\\\\*'
341         >>> expand_re(['a', 'b'])
342         '(?:a|b)'
343         >>> expand_re(['*', ['b', 'c']])
344         '(?:\\\\*|(?:b|c))'
345     """
346     if isinstance(val, str):
347         return re.escape(val)
348     else:
349         return '(?:' + '|'.join(map(expand_re, val)) + ')'
350
351 def make_extractors(seed):
352     """
353     Take a dictionary of ``key``s to ``(file, regex)`` tuples and convert them into
354     extractor functions (which take a :class:`wizard.deploy.Deployment`
355     and return the value of the second subpattern of ``regex`` when matched
356     with the contents of ``file``).
357     """
358     return util.dictmap(lambda a: filename_regex_extractor(*a), seed)
359
360 def make_substitutions(seed):
361     """
362     Take a dictionary of ``key``s to ``(file, regex)`` tuples and convert them into substitution
363     functions (which take a :class:`wizard.deploy.Deployment`, replace the second subpattern
364     of ``regex`` with ``key`` in ``file``, and returns the number of substitutions made.)
365     """
366     return util.dictkmap(lambda k, v: filename_regex_substitution(k, *v), seed)
367
368 # The following two functions are *highly* functional, and I recommend
369 # not touching them unless you know what you're doing.
370
371 def filename_regex_extractor(file, regex):
372     """
373     .. highlight:: haskell
374
375     Given a relative file name ``file``, a regular expression ``regex``, and a
376     :class:`wizard.deploy.Deployment` extracts a value out of the file in that
377     deployment.  This function is curried, so you pass just ``file`` and
378     ``regex``, and then pass ``deployment`` to the resulting function.
379
380     Its Haskell-style type signature would be::
381
382         Filename -> Regex -> (Deployment -> String)
383
384     The regular expression requires a very specific form, essentially ``()()()``
385     (with the second subgroup being the value to extract).  These enables
386     the regular expression to be used equivalently with filename
387
388     .. highlight:: python
389
390     For convenience purposes, we also accept ``[Filename]``, in which case
391     we use the first entry (index 0).  Passing an empty list is invalid.
392
393         >>> open("test-settings.extractor.ini", "w").write("config_var = 3\\n")
394         >>> f = filename_regex_extractor('test-settings.extractor.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
395         >>> f(deploy.Deployment("."))
396         '3'
397         >>> os.unlink("test-settings.extractor.ini")
398
399     .. note::
400         The first application of ``regex`` and ``file`` is normally performed
401         at compile-time inside a submodule; the second application is
402         performed at runtime.
403     """
404     if not isinstance(file, str):
405         file = file[0]
406     def h(deployment):
407         try:
408             contents = deployment.read(file) # cached
409         except IOError:
410             return None
411         match = regex.search(contents)
412         if not match: return None
413         # assumes that the second match is the one we want.
414         return match.group(2)
415     return h
416
417 def filename_regex_substitution(key, files, regex):
418     """
419     .. highlight:: haskell
420
421     Given a Wizard ``key`` (``WIZARD_*``), a list of ``files``, a
422     regular expression ``regex``, and a :class:`wizard.deploy.Deployment`
423     performs a substitution of the second subpattern of ``regex``
424     with ``key``.  Returns the number of replacements made.  This function
425     is curried, so you pass just ``key``, ``files`` and ``regex``, and
426     then pass ``deployment`` to the resulting function.
427
428     Its Haskell-style type signature would be::
429
430         Key -> ([File], Regex) -> (Deployment -> IO Int)
431
432     .. highlight:: python
433
434     For convenience purposes, we also accept ``Filename``, in which case it is treated
435     as a single item list.
436
437         >>> open("test-settings.substitution.ini", "w").write("config_var = 3")
438         >>> f = filename_regex_substitution('WIZARD_KEY', 'test-settings.substitution.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
439         >>> f(deploy.Deployment("."))
440         1
441         >>> print open("test-settings.substitution.ini", "r").read()
442         config_var = WIZARD_KEY
443         >>> os.unlink("test-settings.substitution.ini")
444     """
445     if isinstance(files, str):
446         files = (files,)
447     def h(deployment):
448         base = deployment.location
449         subs = 0
450         for file in files:
451             file = os.path.join(base, file)
452             try:
453                 contents = open(file, "r").read()
454                 contents, n = regex.subn("\\1" + key + "\\3", contents)
455                 subs += n
456                 open(file, "w").write(contents)
457             except IOError:
458                 pass
459         return subs
460     return h
461
462 def backup_database(outdir, deployment):
463     """
464     Generic database backup function.  Assumes that ``WIZARD_DBNAME``
465     is extractable, and that :func:`wizard.scripts.get_sql_credentials`
466     works.
467     """
468     sh = shell.Shell()
469     outfile = os.path.join(outdir, "db.sql")
470     try:
471         sh.call("mysqldump", "--compress", "-r", outfile, *get_mysql_args(deployment))
472         sh.call("gzip", "--best", outfile)
473     except shell.CallError as e:
474         shutil.rmtree(outdir)
475         raise BackupFailure(e.stderr)
476
477 def restore_database(backup_dir, deployment):
478     sh = shell.Shell()
479     if not os.path.exists(backup_dir):
480         raise RestoreFailure("Backup %s doesn't exist", backup_dir.rpartition("/")[2])
481     sql = open(os.path.join(backup_dir, "db.sql"), 'w+')
482     sh.call("gunzip", "-c", os.path.join(backup_dir, "db.sql.gz"), stdout=sql)
483     sql.seek(0)
484     sh.call("mysql", *get_mysql_args(deployment), stdin=sql)
485     sql.close()
486
487 def get_mysql_args(d):
488     # XXX: add support for getting these out of options
489     vars = d.extract()
490     if 'WIZARD_DBNAME' not in vars:
491         raise app.BackupFailure("Could not determine database name")
492     triplet = scripts.get_sql_credentials(vars)
493     args = []
494     if triplet is not None:
495         server, user, password = triplet
496         args += ["-h", server, "-u", user, "-p" + password]
497     name = shlex.split(vars['WIZARD_DBNAME'])[0]
498     args.append(name)
499     return args
500
501 class Error(wizard.Error):
502     """Generic error class for this module."""
503     pass
504
505 class NoRepositoryError(Error):
506     """
507     :class:`Application` does not appear to have a Git repository
508     in the normal location.
509     """
510     #: The name of the application that does not have a Git repository.
511     app = None
512     def __init__(self, app):
513         self.app = app
514     def __str__(self):
515         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
516
517 class DeploymentParseError(Error):
518     """
519     Could not parse ``value`` from :term:`versions store`.
520     """
521     #: The value that failed to parse.
522     value = None
523     #: The location of the autoinstall that threw this variable.
524     #: This should be set by error handling code when it is available.
525     location = None
526     def __init__(self, value):
527         self.value = value
528
529 class NoSuchApplication(Error):
530     """
531     You attempted to reference a :class:`Application` named
532     ``app``, which is not recognized by Wizard.
533     """
534     #: The name of the application that does not exist.
535     app = None
536     #: The location of the autoinstall that threw this variable.
537     #: This should be set by error handling code when it is availble.
538     location = None
539     def __init__(self, app):
540         self.app = app
541
542 class UpgradeFailure(Error):
543     """Upgrade script failed."""
544     #: String details of failure (possibly stdout or stderr output)
545     details = None
546     def __init__(self, details):
547         self.details = details
548     def __str__(self):
549         return """
550
551 ERROR: Upgrade script failed, details:
552
553 %s""" % self.details
554
555 class UpgradeVerificationFailure(Error):
556     """Upgrade script passed, but website wasn't accessible afterwards"""
557     #: String details of failure (possibly stdout or stderr output)
558     details = None
559     def __init__(self, details):
560         self.details = details
561     def __str__(self):
562         return """
563
564 ERROR: Upgrade script passed, but website wasn't accessible afterwards. Details:
565
566 %s""" % self.details
567
568 class BackupFailure(Error):
569     """Backup script failed."""
570     #: String details of failure
571     details = None
572     def __init__(self, details):
573         self.details = details
574     def __str__(self):
575         return """
576
577 ERROR: Backup script failed, details:
578
579 %s""" % self.details
580
581 class RestoreFailure(Error):
582     """Restore script failed."""
583     #: String details of failure
584     details = None
585     def __init__(self, details):
586         self.details = details
587     def __str__(self):
588         return """
589
590 ERROR: Restore script failed, details:
591
592 %s""" % self.details