]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
Majorly update and expand documentation. Minor API changes.
[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 log
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_path)])
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.isfile(self.version_file):
173                 fh = open(self.version_file)
174                 appname, _, version = fh.read().rstrip().partition('-')
175                 fh.close()
176                 self._app_version = ApplicationVersion.make(appname, version)
177             else:
178                 self._app_version = self.log[-1].version
179         return self._app_version
180     @staticmethod
181     def parse(line):
182         """
183         Parses a line from the :term:`versions store`.
184
185         .. note::
186
187             Use this method only when speed is of the utmost
188             importance.  You should prefer to directly create a deployment
189             with only a ``location`` when possible.
190         """
191         line = line.rstrip()
192         try:
193             location, deploydir = line.split(":")
194         except ValueError:
195             return Deployment(line) # lazy loaded version
196         try:
197             return Deployment(location, version=ApplicationVersion.parse(deploydir))
198         except Error as e:
199             e.location = location
200             raise e
201
202 class Application(object):
203     """Represents an application, i.e. mediawiki or phpbb."""
204     #: String name of the application
205     name = None
206     #: Dictionary of version strings to :class:`ApplicationVersion`.
207     #: See also :meth:`makeVersion`.
208     versions = None
209     #: List of files that need to be modified when parametrizing.
210     #: This is a class-wide constant, and should not normally be modified.
211     parametrized_files = []
212     def __init__(self, name):
213         self.name = name
214         self.versions = {}
215         # cache variables
216         self._extractors = {}
217         self._parametrizers = {}
218     @property
219     def repository(self):
220         """
221         Returns the Git repository that would contain this application.
222         Throws :exc:`NoRepositoryError` if the calculated path does not
223         exist.
224         """
225         repo = os.path.join("/afs/athena.mit.edu/contrib/scripts/git/autoinstalls", self.name + ".git")
226         if not os.path.isdir(repo):
227             raise NoRepositoryError(app)
228         return repo
229     def makeVersion(self, version):
230         """
231         Creates or retrieves the :class:`ApplicationVersion` singleton for the
232         specified version.
233         """
234         if version not in self.versions:
235             self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
236         return self.versions[version]
237     def extract(self, deployment):
238         """Extracts wizard variables from a deployment."""
239         result = {}
240         for k,extractor in self.extractors.items():
241             result[k] = extractor(deployment)
242         return result
243     def parametrize(self, deployment, dir):
244         """
245         Takes a generic source checkout at dir and parametrizes
246         it according to the values of deployment.
247         """
248         variables = deployment.extract()
249         for file in self.parametrized_files:
250             fullpath = os.path.join(dir, file)
251             f = open(fullpath, "r")
252             contents = f.read()
253             f.close()
254             for key, value in variables.items():
255                 if value is None: continue
256                 contents = contents.replace(key, value)
257             tmp = tempfile.NamedTemporaryFile(delete=False)
258             tmp.write(contents)
259             tmp.close()
260             os.rename(tmp.name, fullpath)
261     @property
262     def extractors(self):
263         """
264         Dictionary of variable names to extractor functions.  These functions
265         take a :class:`Deployment` as an argument and return the value of
266         the variable, or ``None`` if it could not be found.
267         See also :func:`wizard.app.filename_regex_extractor`.
268         """
269         return {}
270     @staticmethod
271     def make(name):
272         """Makes an application, but uses the correct subtype if available."""
273         try:
274             __import__("wizard.app." + name)
275             return getattr(wizard.app, name).Application(name)
276         except ImportError:
277             return Application(name)
278
279 class ApplicationVersion(object):
280     """Represents an abstract notion of a version for an application, where
281     ``version`` is a :class:`distutils.version.LooseVersion` and
282     ``application`` is a :class:`Application`."""
283     #: The :class:`distutils.version.LooseVersion` of this instance.
284     version = None
285     #: The :class:`Appliation` of this instance.
286     application = None
287     def __init__(self, version, application):
288         self.version = version
289         self.application = application
290     @property
291     def scripts_tag(self):
292         """
293         Returns the name of the Git tag for this version.
294
295         .. note::
296
297             Use this function only during migration, as it does
298             not account for the existence of ``-scripts2``.
299         """
300         return "v%s-scripts" % self.version
301     def __cmp__(x, y):
302         return cmp(x.version, y.version)
303     @staticmethod
304     def parse(value):
305         """
306         Parses a line from the :term:`versions store` and return
307         :class:`ApplicationVersion`.
308
309         Use this only for cases when speed is of primary importance;
310         the data in version is unreliable and when possible, you should
311         prefer directly instantiating a Deployment and having it query
312         the autoinstall itself for information.
313
314         The `value` to parse will vary.  For old style installs, it
315         will look like::
316
317            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
318
319         For new style installs, it will look like::
320
321            APP-x.y.z-scripts
322         """
323         name = value.split("/")[-1]
324         try:
325             if name.find("-") != -1:
326                 app, _, version = name.partition("-")
327             else:
328                 # kind of poor, maybe should error.  Generally this
329                 # will actually result in a not found error
330                 app = name
331                 version = "trunk"
332         except ValueError:
333             raise DeploymentParseError(deploydir)
334         return ApplicationVersion.make(app, version)
335     @staticmethod
336     def make(app, version):
337         """
338         Makes/retrieves a singleton :class:`ApplicationVersion` from
339         a``app`` and ``version`` string.
340         """
341         try:
342             # defer to the application for version creation to enforce
343             # singletons
344             return applications()[app].makeVersion(version)
345         except KeyError:
346             raise NoSuchApplication(app)
347
348 ## -- Exceptions --
349
350 class Error(wizard.Error):
351     """Base error class for this module"""
352     pass
353
354 class NoSuchApplication(Error):
355     """
356     You attempted to reference a :class:`Application` named
357     ``app``, which is not recognized by Wizard.
358     """
359     #: The name of the application that does not exist.
360     app = None
361     #: The location of the autoinstall that threw this variable.
362     #: This should be set by error handling code when it is availble.
363     location = None
364     def __init__(self, app):
365         self.app = app
366
367 class DeploymentParseError(Error):
368     """
369     Could not parse ``value`` from :term:`versions store`.
370     """
371     #: The value that failed to parse.
372     value = None
373     #: The location of the autoinstall that threw this variable.
374     #: This should be set by error handling code when it is available.
375     location = None
376     def __init__(self, value):
377         self.value = value
378
379 class NoRepositoryError(Error):
380     """
381     :class:`Application` does not appear to have a Git repository
382     in the normal location.
383     """
384     #: The name of the application that does not have a Git repository.
385     app = None
386     def __init__(self, app):
387         self.app = app
388
389 # If you want, you can wrap this up into a registry and access things
390 # through that, but it's not really necessary
391
392 _application_list = [
393     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
394     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
395     # these are technically deprecated
396     "advancedpoll", "gallery",
397 ]
398 _applications = None
399
400 def applications():
401     """Hash table for looking up string application name to instance"""
402     global _applications
403     if not _applications:
404         _applications = dict([(n,Application.make(n)) for n in _application_list ])
405     return _applications
406