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