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