]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
a923a0ffc3b56f6230379b325879fcc699c330a7
[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, 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.
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     @property
110     def migrated(self):
111         """Whether or not the autoinstalls has been migrated."""
112         return os.path.isdir(self.scripts_dir)
113     @property
114     def scripts_dir(self):
115         """The absolute path of the ``.scripts`` directory."""
116         return os.path.join(self.location, '.scripts')
117     @property
118     def old_version_file(self):
119         """
120         The absolute path of either ``.scripts-version`` (for unmigrated
121         installs) or ``.scripts/version``.
122
123         .. note::
124
125             Use of this is discouraged for migrated installs.
126         """
127         if self.migrated:
128             return os.path.join(self.scripts_dir, 'old-version')
129         else:
130             return os.path.join(self.location, '.scripts-version')
131     @property
132     def version_file(self):
133         """The absolute path of the ``.scripts/version`` file."""
134         return os.path.join(self.scripts_dir, 'version')
135     @property
136     def application(self):
137         """The :class:`Application` of this deployment."""
138         return self.app_version.application
139     @property
140     def old_log(self):
141         """
142         The :class:`wizard.old_log.Log` of this deployment.  This
143         is only applicable to un-migrated autoinstalls.
144         """
145         if not self._old_log:
146             self._old_log = old_log.DeployLog.load(self)
147         return self._old_log
148     @property
149     def version(self):
150         """
151         The :class:`distutils.version.LooseVersion` of this
152         deployment.
153         """
154         return self.app_version.version
155     @property
156     def app_version(self):
157         """The :class:`ApplicationVersion` of this deployment."""
158         if not self._app_version:
159             if os.path.isdir(os.path.join(self.location, ".git")):
160                 with util.ChangeDirectory(self.location):
161                     appname, _, version = git.describe().partition('-')
162                 self._app_version = ApplicationVersion.make(appname, version)
163             else:
164                 self._app_version = self.old_log[-1].version
165         return self._app_version
166     @staticmethod
167     def parse(line):
168         """
169         Parses a line from the :term:`versions store`.
170
171         .. note::
172
173             Use this method only when speed is of the utmost
174             importance.  You should prefer to directly create a deployment
175             with only a ``location`` when possible.
176         """
177         line = line.rstrip()
178         try:
179             location, deploydir = line.split(":")
180         except ValueError:
181             return Deployment(line) # lazy loaded version
182         try:
183             return Deployment(location, version=ApplicationVersion.parse(deploydir))
184         except Error as e:
185             e.location = location
186             raise e
187
188 class Application(object):
189     """Represents an application, i.e. mediawiki or phpbb."""
190     #: String name of the application
191     name = None
192     #: Dictionary of version strings to :class:`ApplicationVersion`.
193     #: See also :meth:`makeVersion`.
194     versions = None
195     #: List of files that need to be modified when parametrizing.
196     #: This is a class-wide constant, and should not normally be modified.
197     parametrized_files = []
198     def __init__(self, name):
199         self.name = name
200         self.versions = {}
201         # cache variables
202         self._extractors = {}
203         self._substitutions = {}
204     def repository(self, srv_path):
205         """
206         Returns the Git repository that would contain this application.
207         ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
208         Throws :exc:`NoRepositoryError` if the calculated path does not
209         exist.
210         """
211         repo = os.path.join(srv_path, self.name + ".git")
212         if not os.path.isdir(repo):
213             repo = os.path.join(srv_path, self.name, ".git")
214             if not os.path.isdir(repo):
215                 raise NoRepositoryError(self.name)
216         return repo
217     def makeVersion(self, version):
218         """
219         Creates or retrieves the :class:`ApplicationVersion` singleton for the
220         specified version.
221         """
222         if version not in self.versions:
223             self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
224         return self.versions[version]
225     def extract(self, deployment):
226         """Extracts wizard variables from a deployment."""
227         result = {}
228         for k,extractor in self.extractors.items():
229             result[k] = extractor(deployment)
230         return result
231     def parametrize(self, deployment, dir):
232         """
233         Takes a generic source checkout at dir and parametrizes
234         it according to the values of deployment.
235         """
236         variables = deployment.extract()
237         for file in self.parametrized_files:
238             fullpath = os.path.join(dir, file)
239             try:
240                 f = open(fullpath, "r")
241             except IOError:
242                 continue
243             contents = f.read()
244             f.close()
245             for key, value in variables.items():
246                 if value is None: continue
247                 contents = contents.replace(key, value)
248             tmp = tempfile.NamedTemporaryFile(delete=False)
249             tmp.write(contents)
250             tmp.close()
251             os.rename(tmp.name, fullpath)
252     def prepareConfig(self, deployment):
253         """
254         Takes a deployment and replaces any explicit instances
255         of a configuration variable with generic WIZARD_* constants.
256         There is a sane default implementation built on substitutions;
257         you can override this method to provide arbitrary extra
258         behavior.
259         """
260         for key, subst in self.substitutions.items():
261             subs = subst(deployment)
262             if not subs and key not in self.deprecated_keys:
263                 logging.warning("No substitutions for %s" % key)
264     def install(self, options):
265         """
266         Run for 'wizard configure' (and, by proxy, 'wizard install')
267         to configure an application.
268         """
269         raise NotImplemented
270     def upgrade(self, options):
271         """
272         Run for 'wizard upgrade' to upgrade database schemas and other
273         non-versioned data in an application.
274         """
275         raise NotImplemented
276     @property
277     def extractors(self):
278         """
279         Dictionary of variable names to extractor functions.  These functions
280         take a :class:`Deployment` as an argument and return the value of
281         the variable, or ``None`` if it could not be found.
282         See also :func:`wizard.app.filename_regex_extractor`.
283         """
284         return {}
285     @property
286     def substitutions(self):
287         """
288         Dictionary of variable names to substitution functions.  These functions
289         take a :class:`Deployment` as an argument and modify the deployment such
290         that an explicit instance of the variable is released with the generic
291         WIZARD_* constant.  See also :func:`wizard.app.filename_regex_substitution`.
292         """
293         return {}
294     @staticmethod
295     def make(name):
296         """Makes an application, but uses the correct subtype if available."""
297         try:
298             __import__("wizard.app." + name)
299             return getattr(wizard.app, name).Application(name)
300         except ImportError:
301             return Application(name)
302
303 class ApplicationVersion(object):
304     """Represents an abstract notion of a version for an application, where
305     ``version`` is a :class:`distutils.version.LooseVersion` and
306     ``application`` is a :class:`Application`."""
307     #: The :class:`distutils.version.LooseVersion` of this instance.
308     version = None
309     #: The :class:`Application` of this instance.
310     application = None
311     def __init__(self, version, application):
312         self.version = version
313         self.application = application
314     @property
315     def scripts_tag(self):
316         """
317         Returns the name of the Git tag for this version.
318         """
319         end = str(self.version).partition('-scripts')[2].partition('-')[0]
320         return "%s-scripts%s" % (self.pristine_tag, end)
321     @property
322     def pristine_tag(self):
323         """
324         Returns the name of the Git tag for the pristine version corresponding
325         to this version.
326         """
327         return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
328     def __cmp__(x, y):
329         return cmp(x.version, y.version)
330     @staticmethod
331     def parse(value):
332         """
333         Parses a line from the :term:`versions store` and return
334         :class:`ApplicationVersion`.
335
336         Use this only for cases when speed is of primary importance;
337         the data in version is unreliable and when possible, you should
338         prefer directly instantiating a Deployment and having it query
339         the autoinstall itself for information.
340
341         The `value` to parse will vary.  For old style installs, it
342         will look like::
343
344            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
345
346         For new style installs, it will look like::
347
348            APP-x.y.z-scripts
349         """
350         name = value.split("/")[-1]
351         try:
352             if name.find("-") != -1:
353                 app, _, version = name.partition("-")
354             else:
355                 # kind of poor, maybe should error.  Generally this
356                 # will actually result in a not found error
357                 app = name
358                 version = "trunk"
359         except ValueError:
360             raise DeploymentParseError(deploydir)
361         return ApplicationVersion.make(app, version)
362     @staticmethod
363     def make(app, version):
364         """
365         Makes/retrieves a singleton :class:`ApplicationVersion` from
366         a``app`` and ``version`` string.
367         """
368         try:
369             # defer to the application for version creation to enforce
370             # singletons
371             return applications()[app].makeVersion(version)
372         except KeyError:
373             raise NoSuchApplication(app)
374
375 ## -- Exceptions --
376
377 class Error(wizard.Error):
378     """Base error class for this module"""
379     pass
380
381 class NoSuchApplication(Error):
382     """
383     You attempted to reference a :class:`Application` named
384     ``app``, which is not recognized by Wizard.
385     """
386     #: The name of the application that does not exist.
387     app = None
388     #: The location of the autoinstall that threw this variable.
389     #: This should be set by error handling code when it is availble.
390     location = None
391     def __init__(self, app):
392         self.app = app
393
394 class DeploymentParseError(Error):
395     """
396     Could not parse ``value`` from :term:`versions store`.
397     """
398     #: The value that failed to parse.
399     value = None
400     #: The location of the autoinstall that threw this variable.
401     #: This should be set by error handling code when it is available.
402     location = None
403     def __init__(self, value):
404         self.value = value
405
406 class NoRepositoryError(Error):
407     """
408     :class:`Application` does not appear to have a Git repository
409     in the normal location.
410     """
411     #: The name of the application that does not have a Git repository.
412     app = None
413     def __init__(self, app):
414         self.app = app
415     def __str__(self):
416         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
417
418 # If you want, you can wrap this up into a registry and access things
419 # through that, but it's not really necessary
420
421 _application_list = [
422     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
423     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
424     # these are technically deprecated
425     "advancedpoll", "gallery",
426 ]
427 _applications = None
428
429 def applications():
430     """Hash table for looking up string application name to instance"""
431     global _applications
432     if not _applications:
433         _applications = dict([(n,Application.make(n)) for n in _application_list ])
434     return _applications
435