]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
Implement mass-upgrade.
[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, shell, 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 sorted(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:
38         show = applications()
39     elif isinstance(show, str):
40         # otherwise, frozenset will treat string as an iterable
41         show = frozenset([show])
42     else:
43         show = frozenset(show)
44     for line in get_install_lines(versions_store):
45         # construction
46         try:
47             d = Deployment.parse(line)
48             name = d.application.name
49         except NoSuchApplication as e:
50             if yield_errors:
51                 yield e
52             continue
53         except Error:
54             # we consider this a worse error
55             logging.warning("Error with '%s'" % line.rstrip())
56             continue
57         # filter
58         if name + "-" + str(d.version) in show or name in show:
59             pass
60         else:
61             continue
62         # yield
63         yield d
64
65 ## -- Model Objects --
66
67 class Deployment(object):
68     """
69     Represents a deployment of an autoinstall, e.g. directory in a user's
70     web_scripts that has ``.scripts`` directory or ``.scripts-version``
71     file in it.  Supply ``version`` with an :class:`ApplicationVersion` only if
72     you were reading from the :term:`versions store` and care about
73     speed (data from there can be stale).
74     """
75     #: Absolute path to the deployment
76     location = None
77     def __init__(self, location, version=None):
78         self.location = os.path.abspath(location)
79         self._app_version = version
80         # some cache variables
81         self._read_cache = {}
82         self._old_log = None
83     def read(self, file, force = False):
84         """
85         Reads a file's contents, possibly from cache unless ``force``
86         is ``True``.
87         """
88         if force or file not in self._read_cache:
89             f = open(os.path.join(self.location, file))
90             self._read_cache[file] = f.read()
91             f.close()
92         return self._read_cache[file]
93     def extract(self):
94         """
95         Extracts all the values of all variables from deployment.
96         These variables may be used for parametrizing generic parent
97         commits and include things such as database access credentials
98         and local configuration.
99         """
100         return self.application.extract(self)
101     def parametrize(self, dir):
102         """
103         Edits files in ``dir`` to replace WIZARD_* variables with literal
104         instances.  This is used for constructing virtual merge bases, and
105         as such dir will generally not equal :attr:`location`.
106         """
107         return self.application.parametrize(self, dir)
108     def prepareConfig(self):
109         """
110         Edits files in the deployment such that any user-specific configuration
111         is replaced with generic WIZARD_* variables.
112         """
113         return self.application.prepareConfig(self)
114     def checkConfig(self, deployment):
115         """
116         Checks if the application is configured.
117         """
118         raise NotImplemented
119
120     def verify(self):
121         """
122         Checks if this is an autoinstall, throws an exception if there
123         are problems.
124         """
125         with util.ChangeDirectory(self.location):
126             has_git = os.path.isdir(".git")
127             has_scripts = os.path.isdir(".scripts")
128             if not has_git and has_scripts:
129                 raise CorruptedAutoinstallError(self.location)
130             elif has_git and not has_scripts:
131                 raise AlreadyVersionedError(self.location)
132             elif not has_git and not has_scripts:
133                 if os.path.isfile(".scripts-version"):
134                     raise NotMigratedError(self.location)
135
136     def verifyTag(self, srv_path):
137         """
138         Checks if the purported version has a corresponding tag
139         in the upstream repository.
140         """
141         repo = self.application.repository(srv_path)
142         try:
143             shell.Shell().eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
144         except shell.CallError:
145             raise NoTagError(self.app_version.scripts_tag)
146
147     def verifyGit(self, srv_path):
148         """
149         Checks if the autoinstall's Git repository makes sense,
150         checking if the tag is parseable and corresponds to
151         a real application, and if the tag in this repository
152         corresponds to the one in the remote repository.
153         """
154         with util.ChangeDirectory(self.location):
155             sh = shell.Shell()
156             repo = self.application.repository(srv_path)
157             def repo_rev_parse(tag):
158                 return sh.eval("git", "--git-dir", repo, "rev-parse", tag)
159             def self_rev_parse(tag):
160                 try:
161                     return sh.safeCall("git", "rev-parse", tag, strip=True)
162                 except shell.CallError:
163                     raise NoLocalTagError(tag)
164             def compare_tags(tag):
165                 return repo_rev_parse(tag) == self_rev_parse(tag)
166             if not compare_tags(self.app_version.pristine_tag):
167                 raise InconsistentPristineTagError(self.app_version.pristine_tag)
168             if not compare_tags(self.app_version.scripts_tag):
169                 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
170             parent = repo_rev_parse(self.app_version.scripts_tag)
171             merge_base = sh.safeCall("git", "merge-base", parent, "HEAD", strip=True)
172             if merge_base != parent:
173                 raise HeadNotDescendantError(self.app_version.scripts_tag)
174
175     def verifyConfigured(self):
176         """
177         Checks if the autoinstall is configured running.
178         """
179         if not self.configured:
180             raise NotConfiguredError(self.location)
181
182     def verifyVersion(self):
183         """
184         Checks if our version and the version number recorded in a file
185         are consistent.
186         """
187         real = self.application.detectVersion(self)
188         if not real:
189             raise VersionDetectionError
190         elif not str(real) == self.app_version.pristine_tag.partition('-')[2]:
191             raise VersionMismatchError(real, self.version)
192
193     @property
194     def configured(self):
195         """Whether or not an autoinstall has been configured/installed for use."""
196         return self.application.checkConfig(self)
197     @property
198     def migrated(self):
199         """Whether or not the autoinstalls has been migrated."""
200         return os.path.isdir(self.scripts_dir)
201     @property
202     def scripts_dir(self):
203         """The absolute path of the ``.scripts`` directory."""
204         return os.path.join(self.location, '.scripts')
205     @property
206     def old_version_file(self):
207         """
208         The absolute path of either ``.scripts-version`` (for unmigrated
209         installs) or ``.scripts/version``.
210
211         .. note::
212
213             Use of this is discouraged for migrated installs.
214         """
215         return os.path.join(self.location, '.scripts-version')
216     @property
217     def version_file(self):
218         """The absolute path of the ``.scripts/version`` file."""
219         return os.path.join(self.scripts_dir, 'version')
220     @property
221     def application(self):
222         """The :class:`Application` of this deployment."""
223         return self.app_version.application
224     @property
225     def old_log(self):
226         """
227         The :class:`wizard.old_log.Log` of this deployment.  This
228         is only applicable to un-migrated autoinstalls.
229         """
230         if not self._old_log:
231             self._old_log = old_log.DeployLog.load(self)
232         return self._old_log
233     @property
234     def version(self):
235         """
236         The :class:`distutils.version.LooseVersion` of this
237         deployment.
238         """
239         return self.app_version.version
240     @property
241     def app_version(self):
242         """The :class:`ApplicationVersion` of this deployment."""
243         if not self._app_version:
244             if os.path.isdir(os.path.join(self.location, ".git")):
245                 try:
246                     with util.ChangeDirectory(self.location):
247                         appname, _, version = git.describe().partition('-')
248                     self._app_version = ApplicationVersion.make(appname, version)
249                 except shell.CallError:
250                     pass
251         if not self._app_version:
252             self._app_version = self.old_log[-1].version
253         return self._app_version
254     @staticmethod
255     def parse(line):
256         """
257         Parses a line from the :term:`versions store`.
258
259         .. note::
260
261             Use this method only when speed is of the utmost
262             importance.  You should prefer to directly create a deployment
263             with only a ``location`` when possible.
264         """
265         line = line.rstrip()
266         try:
267             location, deploydir = line.split(":")
268         except ValueError:
269             return Deployment(line) # lazy loaded version
270         try:
271             return Deployment(location, version=ApplicationVersion.parse(deploydir))
272         except Error as e:
273             e.location = location
274             raise e
275
276 class Application(object):
277     """Represents an application, i.e. mediawiki or phpbb."""
278     #: String name of the application
279     name = None
280     #: Dictionary of version strings to :class:`ApplicationVersion`.
281     #: See also :meth:`makeVersion`.
282     versions = None
283     #: List of files that need to be modified when parametrizing.
284     #: This is a class-wide constant, and should not normally be modified.
285     parametrized_files = []
286     def __init__(self, name):
287         self.name = name
288         self.versions = {}
289         # cache variables
290         self._extractors = {}
291         self._substitutions = {}
292     def repository(self, srv_path):
293         """
294         Returns the Git repository that would contain this application.
295         ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
296         """
297         repo = os.path.join(srv_path, self.name + ".git")
298         if not os.path.isdir(repo):
299             repo = os.path.join(srv_path, self.name, ".git")
300             if not os.path.isdir(repo):
301                 raise NoRepositoryError(self.name)
302         return repo
303     def makeVersion(self, version):
304         """
305         Creates or retrieves the :class:`ApplicationVersion` singleton for the
306         specified version.
307         """
308         if version not in self.versions:
309             self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
310         return self.versions[version]
311     def extract(self, deployment):
312         """Extracts wizard variables from a deployment."""
313         result = {}
314         for k,extractor in self.extractors.items():
315             result[k] = extractor(deployment)
316         return result
317     def parametrize(self, deployment, dir):
318         """
319         Takes a generic source checkout at dir and parametrizes
320         it according to the values of deployment.
321         """
322         variables = deployment.extract()
323         for file in self.parametrized_files:
324             fullpath = os.path.join(dir, file)
325             try:
326                 contents = open(fullpath, "r").read()
327             except IOError:
328                 continue
329             for key, value in variables.items():
330                 if value is None: continue
331                 contents = contents.replace(key, value)
332             tmp = tempfile.NamedTemporaryFile(delete=False)
333             tmp.write(contents)
334             os.rename(tmp.name, fullpath)
335     def prepareConfig(self, deployment):
336         """
337         Takes a deployment and replaces any explicit instances
338         of a configuration variable with generic WIZARD_* constants.
339         There is a sane default implementation built on substitutions;
340         you can override this method to provide arbitrary extra
341         behavior.
342         """
343         for key, subst in self.substitutions.items():
344             subs = subst(deployment)
345             if not subs and key not in self.deprecated_keys:
346                 logging.warning("No substitutions for %s" % key)
347     def install(self, options):
348         """
349         Run for 'wizard configure' (and, by proxy, 'wizard install')
350         to configure an application.
351         """
352         raise NotImplemented
353     def upgrade(self, options):
354         """
355         Run for 'wizard upgrade' to upgrade database schemas and other
356         non-versioned data in an application.
357         """
358         raise NotImplemented
359     def detectVersion(self, deployment):
360         """
361         Checks source files to determine the version manually.
362         """
363         return None
364     @property
365     def extractors(self):
366         """
367         Dictionary of variable names to extractor functions.  These functions
368         take a :class:`Deployment` as an argument and return the value of
369         the variable, or ``None`` if it could not be found.
370         See also :func:`wizard.app.filename_regex_extractor`.
371         """
372         return {}
373     @property
374     def substitutions(self):
375         """
376         Dictionary of variable names to substitution functions.  These functions
377         take a :class:`Deployment` as an argument and modify the deployment such
378         that an explicit instance of the variable is released with the generic
379         WIZARD_* constant.  See also :func:`wizard.app.filename_regex_substitution`.
380         """
381         return {}
382     @staticmethod
383     def make(name):
384         """Makes an application, but uses the correct subtype if available."""
385         try:
386             __import__("wizard.app." + name)
387             return getattr(wizard.app, name).Application(name)
388         except ImportError:
389             return Application(name)
390
391 class ApplicationVersion(object):
392     """Represents an abstract notion of a version for an application, where
393     ``version`` is a :class:`distutils.version.LooseVersion` and
394     ``application`` is a :class:`Application`."""
395     #: The :class:`distutils.version.LooseVersion` of this instance.
396     version = None
397     #: The :class:`Application` of this instance.
398     application = None
399     def __init__(self, version, application):
400         self.version = version
401         self.application = application
402     @property
403     def tag(self):
404         """
405         Returns the name of the git describe tag for the commit the user is
406         presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
407         """
408         return "%s-%s" % (self.application, self.version)
409     @property
410     def scripts_tag(self):
411         """
412         Returns the name of the Git tag for this version.
413         """
414         end = str(self.version).partition('-scripts')[2].partition('-')[0]
415         return "%s-scripts%s" % (self.pristine_tag, end)
416     @property
417     def pristine_tag(self):
418         """
419         Returns the name of the Git tag for the pristine version corresponding
420         to this version.
421         """
422         return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
423     def __cmp__(x, y):
424         return cmp(x.version, y.version)
425     @staticmethod
426     def parse(value):
427         """
428         Parses a line from the :term:`versions store` and return
429         :class:`ApplicationVersion`.
430
431         Use this only for cases when speed is of primary importance;
432         the data in version is unreliable and when possible, you should
433         prefer directly instantiating a Deployment and having it query
434         the autoinstall itself for information.
435
436         The `value` to parse will vary.  For old style installs, it
437         will look like::
438
439            /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
440
441         For new style installs, it will look like::
442
443            APP-x.y.z-scripts
444         """
445         name = value.split("/")[-1]
446         try:
447             if name.find("-") != -1:
448                 app, _, version = name.partition("-")
449             else:
450                 # kind of poor, maybe should error.  Generally this
451                 # will actually result in a not found error
452                 app = name
453                 version = "trunk"
454         except ValueError:
455             raise DeploymentParseError(deploydir)
456         return ApplicationVersion.make(app, version)
457     @staticmethod
458     def make(app, version):
459         """
460         Makes/retrieves a singleton :class:`ApplicationVersion` from
461         a``app`` and ``version`` string.
462         """
463         try:
464             # defer to the application for version creation to enforce
465             # singletons
466             return applications()[app].makeVersion(version)
467         except KeyError:
468             raise NoSuchApplication(app)
469
470 ## -- Exceptions --
471
472 class Error(wizard.Error):
473     """Base error class for this module"""
474     pass
475
476 class NoSuchApplication(Error):
477     """
478     You attempted to reference a :class:`Application` named
479     ``app``, which is not recognized by Wizard.
480     """
481     #: The name of the application that does not exist.
482     app = None
483     #: The location of the autoinstall that threw this variable.
484     #: This should be set by error handling code when it is availble.
485     location = None
486     def __init__(self, app):
487         self.app = app
488
489 class DeploymentParseError(Error):
490     """
491     Could not parse ``value`` from :term:`versions store`.
492     """
493     #: The value that failed to parse.
494     value = None
495     #: The location of the autoinstall that threw this variable.
496     #: This should be set by error handling code when it is available.
497     location = None
498     def __init__(self, value):
499         self.value = value
500
501 class NoRepositoryError(Error):
502     """
503     :class:`Application` does not appear to have a Git repository
504     in the normal location.
505     """
506     #: The name of the application that does not have a Git repository.
507     app = None
508     def __init__(self, app):
509         self.app = app
510     def __str__(self):
511         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
512
513 class NotMigratedError(Error):
514     """
515     The deployment contains a .scripts-version file, but no .git
516     or .scripts directory.
517     """
518     #: Directory of deployment
519     dir = None
520     def __init__(self, dir):
521         self.dir = dir
522     def __str__(self):
523         return """This installation was not migrated"""
524
525 class AlreadyVersionedError(Error):
526     """The deployment contained a .git directory but no .scripts directory."""
527     #: Directory of deployment
528     dir = None
529     def __init__(self, dir):
530         self.dir = dir
531     def __str__(self):
532         return """
533
534 ERROR: Directory contains a .git directory, but not
535 a .scripts directory.  If this is not a corrupt
536 migration, this means that the user was versioning their
537 install using Git."""
538
539 class NotConfiguredError(Error):
540     """The install was missing essential configuration."""
541     #: Directory of unconfigured install
542     dir = None
543     def __init__(self, dir):
544         self.dir = dir
545     def __str__(self):
546         return """
547
548 ERROR: The install was well-formed, but not configured
549 (essential configuration files were not found.)"""
550
551 class CorruptedAutoinstallError(Error):
552     """The install was missing a .git directory, but had a .scripts directory."""
553     #: Directory of the corrupted install
554     dir = None
555     def __init__(self, dir):
556         self.dir = dir
557     def __str__(self):
558         return """
559
560 ERROR: Directory contains a .scripts directory,
561 but not a .git directory."""
562
563 class NotAutoinstallError(Error):
564     """The directory was not an autoinstall, due to missing .scripts-version file."""
565     #: Directory in question
566     dir = None
567     def __init__(self, dir):
568         self.dir = dir
569     def __str__(self):
570         return """
571
572 ERROR: Could not find .scripts-version file. Are you sure
573 this is an autoinstalled application?
574 """
575
576 class NoTagError(Error):
577     """Deployment has a tag that does not have an equivalent in upstream repository."""
578     #: Missing tag
579     tag = None
580     def __init__(self, tag):
581         self.tag = tag
582     def __str__(self):
583         return """
584
585 ERROR: Could not find tag %s in repository.""" % self.tag
586
587 class NoLocalTagError(Error):
588     """Could not find tag in local repository."""
589     #: Missing tag
590     tag = None
591     def __init__(self, tag):
592         self.tag = tag
593     def __str__(self):
594         return """
595
596 ERROR: Could not find tag %s in local repository.""" % self.tag
597
598 class InconsistentPristineTagError(Error):
599     """Pristine tag commit ID does not match upstream pristine tag commit ID."""
600     #: Inconsistent tag
601     tag = None
602     def __init__(self, tag):
603         self.tag = tag
604     def __str__(self):
605         return """
606
607 ERROR: Local pristine tag %s did not match repository's.  This
608 probably means an upstream rebase occured.""" % self.tag
609
610 class InconsistentScriptsTagError(Error):
611     """Scripts tag commit ID does not match upstream scripts tag commit ID."""
612     #: Inconsistent tag
613     tag = None
614     def __init__(self, tag):
615         self.tag = tag
616     def __str__(self):
617         return """
618
619 ERROR: Local scripts tag %s did not match repository's.  This
620 probably means an upstream rebase occurred.""" % self.tag
621
622 class HeadNotDescendantError(Error):
623     """HEAD is not connected to tag."""
624     #: Tag that HEAD should have been descendant of.
625     tag = None
626     def __init__(self, tag):
627         self.tag = tag
628     def __str__(self):
629         return """
630
631 ERROR: HEAD is not a descendant of %s.  This probably
632 means that an upstream rebase occurred, and new tags were
633 pulled, but local user commits were never rebased.""" % self.tag
634
635 class VersionDetectionError(Error):
636     """Could not detect real version of application."""
637     def __str__(self):
638         return """
639
640 ERROR: Could not detect the real version of the application."""
641
642 class VersionMismatchError(Error):
643     """Git version of application does not match detected version."""
644     #: Detected version
645     real_version = None
646     #: Version from Git
647     git_version = None
648     def __init__(self, real_version, git_version):
649         self.real_version = real_version
650         self.git_version = git_version
651     def __str__(self):
652         return """
653
654 ERROR: The detected version %s did not match the Git
655 version %s.""" % (self.real_version, self.git_version)
656
657 _application_list = [
658     "mediawiki", "wordpress", "joomla", "e107", "gallery2",
659     "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
660     # these are technically deprecated
661     "advancedpoll", "gallery",
662 ]
663 _applications = None
664
665 def applications():
666     """Hash table for looking up string application name to instance"""
667     global _applications
668     if not _applications:
669         _applications = dict([(n,Application.make(n)) for n in _application_list ])
670     return _applications
671