]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
60351f6fcdfe392756fdae3242c5b25687ace52c
[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
13 import wizard
14 from wizard import git, log, util
15
16 ## -- Global Functions --
17
18 def get_install_lines(versions_store):
19     """
20     Low level function that retrieves a list of lines from the
21     :term:`versions store` that can be passed to :meth:`Deployment.parse`.
22     """
23     if os.path.isfile(versions_store):
24         return fileinput.input([versions_store])
25     return fileinput.input([versions_store + "/" + f for f in os.listdir(versions_store)])
26
27 def parse_install_lines(show, versions_store, yield_errors = False):
28     """
29     Generator function for iterating through all autoinstalls.
30     Each item is an instance of :class:`Deployment`, or possibly
31     a :class:`wizard.deploy.Error` if ``yield_errors`` is ``True``.  You can
32     filter out applications and versions by specifying ``app``
33     or ``app-1.2.3`` in ``show``.  This function may generate
34     log output.
35     """
36     if not show: show = applications()
37     show = frozenset(show)
38     for line in get_install_lines(versions_store):
39         # construction
40         try:
41             d = Deployment.parse(line)
42             name = d.application.name
43         except NoSuchApplication as e:
44             if yield_errors:
45                 yield e
46             continue
47         except Error:
48             # we consider this a worse error
49             logging.warning("Error with '%s'" % line.rstrip())
50             continue
51         # filter
52         if name + "-" + str(d.version) in show or name in show:
53             pass
54         else:
55             continue
56         # yield
57         yield d
58
59 ## -- Model Objects --
60
61 class Deployment(object):
62     """
63     Represents a deployment of an autoinstall, e.g. directory in a user's
64     web_scripts that has ``.scripts`` directory or ``.scripts-version``
65     file in it.  Supply ``version`` with an :class:`ApplicationVersion` only if
66     you were reading from the :term:`versions store` and care about
67     speed (data from there can be stale).
68     """
69     #: Absolute path to the deployment
70     location = None
71     def __init__(self, location, version=None):
72         self.location = os.path.realpath(location)
73         self._app_version = version
74         # some cache variables
75         self._read_cache = {}
76         self._log = None
77     def read(self, file, force = False):
78         """
79         Reads a file's contents, possibly from cache unless ``force``
80         is ``True``.
81         """
82         if force or file not in self._read_cache:
83             f = open(os.path.join(self.location, file))
84             self._read_cache[file] = f.read()
85             f.close()
86         return self._read_cache[file]
87     def extract(self):
88         """
89         Extracts all the values of all variables from deployment.
90         These variables may be used for parametrizing generic parent
91         commits and include things such as database access credentials
92         and local configuration.
93         """
94         return self.application.extract(self)
95     def parametrize(self, dir):
96         """
97         Edits files in ``dir`` to replace WIZARD_* variables with literal
98         instances.  This is used for constructing virtual merge bases, and
99         as such dir will generally not equal :attr:`location`.
100         """
101         return self.application.parametrize(self, dir)
102     def updateVersion(self, version):
103         """
104         Update the version of this deployment.
105
106         This method will update the version of this deployment in memory
107         and on disk.  It doesn't actually do an upgrade.  The version
108         string you pass here should have ``-scripts`` as a suffix.
109         """
110         self._app_version = self.application.makeVersion(version)
111         f = open(os.path.join(self.scripts_dir, 'version'), 'w')
112         f.write(self.application.name + '-' + version + "\n")
113         f.close()
114     def scriptsifyVersion(self):
115         """
116         Converts from ``v1.0`` to ``v1.0-scripts``; use at end of migration.
117
118         .. note::
119
120             This makes the assumption that a migration will be to
121             a ``-scripts`` tag and not a ``-scripts2`` tag.  If you botch
122             migration, blow away the tag and try again.
123         """
124         self.updateVersion(self.app_version.scripts_tag)
125     @property
126     def migrated(self):
127         """Whether or not the autoinstalls has been migrated."""
128         return os.path.isdir(self.scripts_dir)
129     @property
130     def scripts_dir(self):
131         """The absolute path of the ``.scripts`` directory."""
132         return os.path.join(self.location, '.scripts')
133     @property
134     def old_version_file(self):
135         """
136         The absolute path of either ``.scripts-version`` (for unmigrated
137         installs) or ``.scripts/version``.
138
139         .. note::
140
141             Use of this is discouraged for migrated installs.
142         """
143         if self.migrated:
144             return os.path.join(self.scripts_dir, 'old-version')
145         else:
146             return os.path.join(self.location, '.scripts-version')
147     @property
148     def version_file(self):
149         """The absolute path of the ``.scripts/version`` file."""
150         return os.path.join(self.scripts_dir, 'version')
151     @property
152     def application(self):
153         """The :class:`Application` of this deployment."""
154         return self.app_version.application
155     @property
156     def log(self):
157         """The :class:`wizard.deploy.Log` of this deployment."""
158         if not self._log:
159             self._log = log.DeployLog.load(self)
160         return self._log
161     @property
162     def version(self):
163         """
164         The :class:`distutils.version.LooseVersion` of this
165         deployment.
166         """
167         return self.app_version.version
168     @property
169     def app_version(self):
170         """The :class:`ApplicationVersion` of this deployment."""
171         if not self._app_version:
172             if os.path.isdir(os.path.join(self.location, ".git")):
173                 with util.ChangeDirectory(self.location):
174                     appname, _, version = git.describe().partition('-')
175                 self._app_version = ApplicationVersion.make(appname, version)
176             else:
177                 self._app_version = self.log[-1].version
178         return self._app_version
179     @staticmethod
180     def parse(line):
181         """
182         Parses a line from the :term:`versions store`.
183
184         .. note::
185
186             Use this method only when speed is of the utmost
187             importance.  You should prefer to directly create a deployment
188             with only a ``location`` when possible.
189         """
190         line = line.rstrip()
191         try:
192             location, deploydir = line.split(":")
193         except ValueError:
194             return Deployment(line) # lazy loaded version
195         try:
196             return Deployment(location, version=ApplicationVersion.parse(deploydir))
197         except Error as e:
198             e.location = location
199             raise e
200
201 class Application(object):
202     """Represents an application, i.e. mediawiki or phpbb."""
203     #: String name of the application
204     name = None
205     #: Dictionary of version strings to :class:`ApplicationVersion`.
206     #: See also :meth:`makeVersion`.
207     versions = None
208     #: List of files that need to be modified when parametrizing.
209     #: This is a class-wide constant, and should not normally be modified.
210     parametrized_files = []
211     def __init__(self, name):
212         self.name = name
213         self.versions = {}
214         # cache variables
215         self._extractors = {}
216         self._parametrizers = {}
217     def repository(self, srv_path):
218         """
219         Returns the Git repository that would contain this application.
220         ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
221         Throws :exc:`NoRepositoryError` if the calculated path does not
222         exist.
223         """
224         repo = os.path.join(srv_path, self.name + ".git")
225         if not os.path.isdir(repo):
226             repo = os.path.join(srv_path, self.name, ".git")
227             if not os.path.isdir(repo):
228                 raise NoRepositoryError(self.name)
229         return repo
230     def makeVersion(self, version):
231         """
232         Creates or retrieves the :class:`ApplicationVersion` singleton for the
233         specified version.
234         """
235         if version not in self.versions:
236             self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
237         return self.versions[version]
238     def extract(self, deployment):
239         """Extracts wizard variables from a deployment."""
240         result = {}
241         for k,extractor in self.extractors.items():
242             result[k] = extractor(deployment)
243         return result
244     def parametrize(self, deployment, dir):
245         """
246         Takes a generic source checkout at dir and parametrizes
247         it according to the values of deployment.
248         """
249         variables = deployment.extract()
250         for file in self.parametrized_files:
251             fullpath = os.path.join(dir, file)
252             f = open(fullpath, "r")
253             contents = f.read()
254             f.close()
255             for key, value in variables.items():
256                 if value is None: continue
257                 contents = contents.replace(key, value)
258             tmp = tempfile.NamedTemporaryFile(delete=False)
259             tmp.write(contents)
260             tmp.close()
261             os.rename(tmp.name, fullpath)
262     @property
263     def extractors(self):
264         """
265         Dictionary of variable names to extractor functions.  These functions
266         take a :class:`Deployment` as an argument and return the value of
267         the variable, or ``None`` if it could not be found.
268         See also :func:`wizard.app.filename_regex_extractor`.
269         """
270         return {}
271     @staticmethod
272     def make(name):
273         """Makes an application, but uses the correct subtype if available."""
274         try:
275             __import__("wizard.app." + name)
276             return getattr(wizard.app, name).Application(name)
277         except ImportError:
278             return Application(name)
279
280 class ApplicationVersion(object):
281     """Represents an abstract notion of a version for an application, where
282     ``version`` is a :class:`distutils.version.LooseVersion` and
283     ``application`` is a :class:`Application`."""
284     #: The :class:`distutils.version.LooseVersion` of this instance.
285     version = None
286     #: The :class:`Application` of this instance.
287     application = None
288     def __init__(self, version, application):
289         self.version = version
290         self.application = application
291     @property
292     def scripts_tag(self):
293         """
294         Returns the name of the Git tag for this version.
295
296         .. note::
297
298             Use this function only during migration, as it does
299             not account for the existence of ``-scripts2``.
300         """
301         return "%s-scripts" % self.pristine_tag
302     @property
303     def pristine_tag(self):
304         """
305         Returns the name of the Git tag for the pristine version corresponding
306         to this version.
307         """
308         return "%s-%s" % (self.application.name, self.version)
309     def __cmp__(x, y):
310         return cmp(x.version, y.version)
311     @staticmethod
312     def parse(value):
313         """
314         Parses a line from the :term:`versions store` and return
315         :class:`ApplicationVersion`.
316
317         Use this only for cases when speed is of primary importance;
318         the data in version is unreliable and when possible, you should
319         prefer directly instantiating a Deployment and having it query
320         the autoinstall itself for information.
321
322         The `value` to parse will vary.  For old style installs, it
323         will look like::
324
325            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
326
327         For new style installs, it will look like::
328
329            APP-x.y.z-scripts
330         """
331         name = value.split("/")[-1]
332         try:
333             if name.find("-") != -1:
334                 app, _, version = name.partition("-")
335             else:
336                 # kind of poor, maybe should error.  Generally this
337                 # will actually result in a not found error
338                 app = name
339                 version = "trunk"
340         except ValueError:
341             raise DeploymentParseError(deploydir)
342         return ApplicationVersion.make(app, version)
343     @staticmethod
344     def make(app, version):
345         """
346         Makes/retrieves a singleton :class:`ApplicationVersion` from
347         a``app`` and ``version`` string.
348         """
349         try:
350             # defer to the application for version creation to enforce
351             # singletons
352             return applications()[app].makeVersion(version)
353         except KeyError:
354             raise NoSuchApplication(app)
355
356 ## -- Exceptions --
357
358 class Error(wizard.Error):
359     """Base error class for this module"""
360     pass
361
362 class NoSuchApplication(Error):
363     """
364     You attempted to reference a :class:`Application` named
365     ``app``, which is not recognized by Wizard.
366     """
367     #: The name of the application that does not exist.
368     app = None
369     #: The location of the autoinstall that threw this variable.
370     #: This should be set by error handling code when it is availble.
371     location = None
372     def __init__(self, app):
373         self.app = app
374
375 class DeploymentParseError(Error):
376     """
377     Could not parse ``value`` from :term:`versions store`.
378     """
379     #: The value that failed to parse.
380     value = None
381     #: The location of the autoinstall that threw this variable.
382     #: This should be set by error handling code when it is available.
383     location = None
384     def __init__(self, value):
385         self.value = value
386
387 class NoRepositoryError(Error):
388     """
389     :class:`Application` does not appear to have a Git repository
390     in the normal location.
391     """
392     #: The name of the application that does not have a Git repository.
393     app = None
394     def __init__(self, app):
395         self.app = app
396     def __str__(self):
397         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
398
399 # If you want, you can wrap this up into a registry and access things
400 # through that, but it's not really necessary
401
402 _application_list = [
403     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
404     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
405     # these are technically deprecated
406     "advancedpoll", "gallery",
407 ]
408 _applications = None
409
410 def applications():
411     """Hash table for looking up string application name to instance"""
412     global _applications
413     if not _applications:
414         _applications = dict([(n,Application.make(n)) for n in _application_list ])
415     return _applications
416