]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
8612457d35df3100e2493a7f18aff604609fd673
[wizard.git] / wizard / deploy.py
1 import os.path
2 import math
3 import fileinput
4 import dateutil.parser
5 import distutils.version
6
7 import wizard
8
9 def getInstallLines(global_options):
10     """Retrieves a list of lines from the version directory that
11     can be passed to Deployment.parse()"""
12     vs = global_options.versions
13     if os.path.isfile(vs):
14         return fileinput.input([vs])
15     return fileinput.input([vs + "/" + f for f in os.listdir(vs)])
16
17 class Deployment(object):
18     """Represents a deployment of an autoinstall; i.e. a concrete
19     directory in web_scripts that has .scripts-version in it."""
20     def __init__(self, location, log=None, version=None):
21         """ `location`  Location of the deployment
22             `version`   ApplicationVersion of the app (this is cached info)
23             `log`       DeployLog of the app"""
24         self.location = location
25         self._version = version
26         self._log = log
27     @staticmethod
28     def parse(line):
29         """Parses a line from the results of parallel-find.pl.
30         This will work out of the box with fileinput, see
31         getInstallLines()"""
32         line = line.rstrip()
33         try:
34             location, deploydir = line.split(":")
35         except ValueError:
36             return Deployment(line) # lazy loaded version
37         return Deployment(location, version=ApplicationVersion.parse(deploydir))
38     @staticmethod
39     def fromDir(dir):
40         """Lazily creates a deployment from a directory"""
41         return Deployment(dir)
42     def getVersionFile(self):
43         return os.path.join(self.location, '.scripts-version')
44     def getApplication(self):
45         return self.getAppVersion().application
46     def getLog(self):
47         if not self._log:
48             self._log = DeployLog.load(self.getVersionFile())
49         return self._log
50     def getVersion(self):
51         """Returns the distutils Version of the deployment"""
52         return self.getAppVersion().version
53     def getAppVersion(self, force = False):
54         """Returns the ApplicationVersion of the deployment"""
55         if self._version and not force: return self._version
56         else: return self.getLog()[-1].version
57     def count(self):
58         """Simple method which registers the deployment as a +1 on the
59         appropriate version. No further inspection is done."""
60         self.getAppVersion().count(self)
61         return True
62     def count_exists(self, file):
63         """Checks if the codebase has a certain file/directory in it."""
64         if os.path.exists(self.location + "/" + file):
65             self.getAppVersion().count_exists(self, file)
66             return True
67         return False
68
69 class Application(object):
70     """Represents the generic notion of an application, i.e.
71     mediawiki or phpbb."""
72     def __init__(self, name):
73         self.name = name
74         self.versions = {}
75         # This is 'wizard summary' specific code
76         self._total = 0
77         self._max   = 0
78         self._c_exists = {}
79     def getRepository(self):
80         """Returns the Git repository that would contain this application."""
81         repo = os.path.join("/afs/athena.mit.edu/contrib/scripts/git/autoinstalls", self.name + ".git")
82         if not os.path.isdir(repo):
83             raise NoRepositoryError(app)
84         return repo
85     def getVersion(self, version):
86         if version not in self.versions:
87             self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
88         return self.versions[version]
89     # XXX: This code should go in summary.py; maybe as a mixin, maybe as
90     # a visitor acceptor
91     HISTOGRAM_WIDTH = 30
92     def _graph(self, v):
93         return '+' * int(math.ceil(float(v)/self._max * self.HISTOGRAM_WIDTH))
94     def report(self):
95         if not self.versions: return "%-11s   no installs" % self.name
96         ret = \
97             ["%-16s %3d installs" % (self.name, self._total)] + \
98             [v.report() for v in sorted(self.versions.values())]
99         for f,c in self._c_exists.items():
100             ret.append("%d users have %s" % (c,f))
101         return "\n".join(ret)
102
103 class DeployLog(list):
104     # As per #python; if you decide to start overloading magic methods,
105     # we should remove this subclass
106     """Equivalent to .scripts-version: a series of DeployRevisions."""
107     def __init__(self, revs = []):
108         """`revs`  List of DeployRevision objects"""
109         list.__init__(self, revs) # pass to list
110     @staticmethod
111     def load(file):
112         """Loads a scripts version file and parses it into
113         DeployLog and DeployRevision objects"""
114         # XXX: DIRTY DIRTY HACK
115         # What we should actually do is parse the git logs
116         scriptsdir = os.path.join(os.path.dirname(file), ".scripts")
117         if os.path.isdir(scriptsdir):
118             file = os.path.join(scriptsdir, "old-version")
119         i = 0
120         rev = DeployRevision()
121         revs = []
122         def append(rev):
123             if i:
124                 if i != 4:
125                     raise ScriptsVersionNotEnoughFieldsError()
126                 revs.append(rev)
127         try:
128             fh = open(file)
129         except IOError:
130             raise ScriptsVersionNoSuchFile(file)
131         # XXX: possibly rewrite this parsing code. This might
132         # be legacy soon
133         for line in fh:
134             line = line.rstrip()
135             if not line:
136                 append(rev)
137                 i = 0
138                 rev = DeployRevision()
139                 continue
140             if i == 0:
141                 # we need the dateutil parser in order to
142                 # be able to parse time offsets
143                 rev.datetime = dateutil.parser.parse(line)
144             elif i == 1:
145                 rev.user = line
146             elif i == 2:
147                 rev.source = DeploySource.parse(line)
148             elif i == 3:
149                 rev.version = ApplicationVersion.parse(line)
150             else:
151                 # ruh oh
152                 raise ScriptsVersionTooManyFieldsError()
153             i += 1
154         append(rev)
155         return DeployLog(revs)
156     def __repr__(self):
157         return '<DeployLog %s>' % list.__repr__(self)
158
159 class DeployRevision(object):
160     """A single entry in the .scripts-version file. Contains who deployed
161     this revision, what application version this is, etc."""
162     def __init__(self, datetime=None, user=None, source=None, version=None):
163         """ `datetime`  Time this revision was deployed
164             `user`      Person who deployed this revision, in user@host format.
165             `source`    Instance of DeploySource
166             `version`   Instance of ApplicationVersion
167         Note: This object is typically built incrementally."""
168         self.datetime = datetime
169         self.user = user
170         self.source = source
171         self.version = version
172
173 class DeploySource(object):
174     """Source of the deployment; see subclasses for examples"""
175     def __init__(self):
176         raise NotImplementedError # abstract class
177     @staticmethod
178     def parse(line):
179         # munge out common prefix
180         rel = os.path.relpath(line, "/afs/athena.mit.edu/contrib/scripts/")
181         parts = rel.split("/")
182         if parts[0] == "wizard":
183             return WizardUpdate()
184         elif parts[0] == "deploy" or parts[0] == "deploydev":
185             isDev = ( parts[0] == "deploydev" )
186             try:
187                 if parts[1] == "updates":
188                     return OldUpdate(isDev)
189                 else:
190                     return TarballInstall(line, isDev)
191             except IndexError:
192                 pass
193         return UnknownDeploySource(line)
194
195 class TarballInstall(DeploySource):
196     """Original installation from tarball, characterized by
197     /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z.tar.gz
198     """
199     def __init__(self, location, isDev):
200         self.location = location
201         self.isDev = isDev
202
203 class OldUpdate(DeploySource):
204     """Upgrade using old upgrade infrastructure, characterized by
205     /afs/athena.mit.edu/contrib/scripts/deploydev/updates/update-scripts-version.pl
206     """
207     def __init__(self, isDev):
208         self.isDev = isDev
209
210 class WizardUpdate(DeploySource):
211     """Upgrade using wizard infrastructure, characterized by
212     /afs/athena.mit.edu/contrib/scripts/wizard/bin/wizard HASHGOBBLEDYGOOK
213     """
214     def __init__(self):
215         pass
216
217 class UnknownDeploySource(DeploySource):
218     """Deployment that we don't know the meaning of. Wot!"""
219     def __init__(self, line):
220         self.line = line
221
222 class ApplicationVersion(object):
223     """Represents an abstract notion of a version for an application"""
224     def __init__(self, version, application):
225         """ `version`       Instance of distutils.LooseVersion
226             `application`   Instance of Application
227         WARNING: Please don't call this directly; instead, use getVersion()
228         on the application you want, so that this version gets registered."""
229         self.version = version
230         self.application = application
231         self.c = 0
232         self.c_exists = {}
233     def getScriptsTag(self):
234         """Returns the name of the Git tag for this version"""
235         # XXX: This assumes that there's only a -scripts version
236         # which will not be true in the future.
237         return "v%s-scripts" % self.version
238     def __cmp__(x, y):
239         return cmp(x.version, y.version)
240     @staticmethod
241     def parse(deploydir,applookup=None):
242         # The version of the deployment, will be:
243         #   /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z for old style installs
244         #   /afs/athena.mit.edu/contrib/scripts/wizard/srv/APP.git vx.y.z-scripts for new style installs
245         #   XXX: ^- the above is wrong; there will be no more .scripts-version
246         name = deploydir.split("/")[-1]
247         try:
248             if name.find(" ") != -1:
249                 raw_app, raw_version = name.split(" ")
250                 version = raw_version[1:] # remove leading v
251                 app, _ = raw_app.split(".") # remove trailing .git
252             elif name.find("-") != -1:
253                 app, _, version = name.partition("-")
254             # XXX: this should be removed after the next parallel-find run
255             elif name == "deploy":
256                 raise NoSuchApplication()
257             else:
258                 raise DeploymentParseError(deploydir)
259         except ValueError: # mostly from the a, b = foo.split(' ')
260             raise DeploymentParseError(deploydir)
261         if not applookup: applookup = applications
262         try:
263             # defer to the application for version creation
264             return applookup[app].getVersion(version)
265         except KeyError:
266             raise NoSuchApplication()
267     # This is summary specific code
268     def count(self, deployment):
269         self.c += 1
270         self.application._total += 1
271         if self.c > self.application._max:
272             self.application._max = self.c
273     def count_exists(self, deployment, n):
274         if n in self.c_exists: self.c_exists[n] += 1
275         else: self.c_exists[n] = 1
276         if n in self.application._c_exists: self.application._c_exists[n] += 1
277         else: self.application._c_exists[n] = 1
278     def report(self):
279         return "    %-12s %3d  %s" \
280             % (self.version, self.c, self.application._graph(self.c))
281
282 class Error(wizard.Error):
283     """Base error class for deploy errors"""
284     pass
285
286 class NoSuchApplication(Error):
287     pass
288
289 class DeploymentParseError(Error):
290     def __init__(self, malformed):
291         self.malformed = malformed
292     def __str__(self):
293         return """
294
295 ERROR: Could not parse deployment string:
296 %s
297 """ % self.malformed
298
299 class ScriptsVersionTooManyFieldsError(Error):
300     def __str__(self):
301         return """
302
303 ERROR: Could not parse .scripts-version file.  It
304 contained too many fields.
305 """
306
307 class ScriptsVersionNotEnoughFieldsError(Error):
308     def __str__(self):
309         return """
310
311 ERROR: Could not parse .scripts-version file. It
312 didn't contain enough fields.
313 """
314
315 class ScriptsVersionNoSuchFile(Error):
316     def __init__(self, file):
317         self.file = file
318     def __str__(self):
319         return """
320
321 ERROR: File %s didn't exist.
322 """ % self.file
323
324 # If you want, you can wrap this up into a registry and access things
325 # through that, but it's not really necessary
326
327 application_list = [
328     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
329     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
330     # these are technically deprecated
331     "advancedpoll", "gallery",
332 ]
333
334 """Hash table for looking up string application name to instance"""
335 applications = dict([(n,Application(n)) for n in application_list ])
336