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