]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
Implement post-installation machinery for Wordpress.
[wizard.git] / wizard / deploy.py
1 """
2 Object model for querying information and manipulating deployments
3 of autoinstalls.  Every :class:`Deployment` has an :class:`app.ApplicationVersion`
4 which in turn has an :class:`app.Application`.
5 """
6
7 import os.path
8 import fileinput
9 import logging
10 import decorator
11 import datetime
12
13 import wizard
14 from wizard import app, git, old_log, scripts, shell, util
15
16 ## -- Global Functions --
17
18 def get_install_lines(versions_store, user=None):
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     if user:
26         return fileinput.input([versions_store + "/" + user])
27     return fileinput.input([versions_store + "/" + f for f in sorted(os.listdir(versions_store))])
28
29 def parse_install_lines(show, versions_store, yield_errors = False, user = None):
30     """
31     Generator function for iterating through all autoinstalls.
32     Each item is an instance of :class:`Deployment`, or possibly
33     a :class:`wizard.deploy.Error` if ``yield_errors`` is ``True``.  You can
34     filter out applications and versions by specifying ``app``
35     or ``app-1.2.3`` in ``show``.  This function may generate
36     log output.
37     """
38     if not show:
39         show = app.applications()
40     elif isinstance(show, str):
41         # otherwise, frozenset will treat string as an iterable
42         show = frozenset([show])
43     else:
44         show = frozenset(show)
45     for line in get_install_lines(versions_store, user):
46         # construction
47         try:
48             d = Deployment.parse(line)
49             name = d.application.name
50         except app.NoSuchApplication as e:
51             if yield_errors:
52                 yield e
53             continue
54         except Error:
55             # we consider this a worse error
56             logging.warning("Error with '%s'" % line.rstrip())
57             continue
58         # filter
59         if name + "-" + str(d.version) in show or name in show:
60             pass
61         else:
62             continue
63         # yield
64         yield d
65
66 ## -- Model Objects --
67
68 @decorator.decorator
69 def chdir_to_location(f, self, *args, **kwargs):
70     """
71     Decorator for making a function have working directory
72     :attr:`Deployment.location`.
73     """
74     with util.ChangeDirectory(self.location):
75         return f(self, *args, **kwargs)
76
77 class Deployment(object):
78     """
79     Represents a deployment of an autoinstall, e.g. directory
80     that has ``.scripts`` directory or ``.scripts-version``
81     file in it.  Supply ``version`` with an :class:`ApplicationVersion` only if
82     you were reading from the :term:`versions store` and care about
83     speed (data from there can be stale).
84
85     The Deployment interface is somewhat neutered, so you may
86     want to use :class:`WorkingCopy` or :class:`ProductionCopy` for
87     more powerful operations.
88     """
89     #: Absolute path to the deployment
90     location = None
91     def __init__(self, location, version=None):
92         self.location = os.path.abspath(location)
93         self._app_version = version
94         # some cache variables
95         self._read_cache = {}
96         self._old_log = None
97     def invalidateCache(self):
98         """
99         Invalidates all cached variables.  This currently applies to
100         :attr:`app_version`, :attr:`old_log` and :meth:`read`.
101         """
102         self._app_version = None
103         self._read_cache = {}
104         self._old_log = None
105     def read(self, file, force = False):
106         """
107         Reads a file's contents, possibly from cache unless ``force``
108         is ``True``.
109         """
110         if force or file not in self._read_cache:
111             f = open(os.path.join(self.location, file))
112             self._read_cache[file] = f.read()
113             f.close()
114         return self._read_cache[file]
115     def extract(self):
116         """
117         Extracts all the values of all variables from deployment.
118         These variables may be used for parametrizing generic parent
119         commits and include things such as database access credentials
120         and local configuration.
121         """
122         return self.application.extract(self)
123
124     def verify(self):
125         """
126         Checks if this is an autoinstall, throws an exception if there
127         are problems.
128         """
129         with util.ChangeDirectory(self.location):
130             has_git = os.path.isdir(".git")
131             has_scripts = os.path.isdir(".scripts")
132             if not has_git and has_scripts:
133                 raise CorruptedAutoinstallError(self.location)
134             elif has_git and not has_scripts:
135                 raise AlreadyVersionedError(self.location)
136             elif not has_git and not has_scripts:
137                 if os.path.isfile(".scripts-version"):
138                     raise NotMigratedError(self.location)
139
140     def verifyTag(self, srv_path):
141         """
142         Checks if the purported version has a corresponding tag
143         in the upstream repository.
144         """
145         repo = self.application.repository(srv_path)
146         try:
147             shell.Shell().eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
148         except shell.CallError:
149             raise NoTagError(self.app_version.scripts_tag)
150
151     def verifyGit(self, srv_path):
152         """
153         Checks if the autoinstall's Git repository makes sense,
154         checking if the tag is parseable and corresponds to
155         a real application, and if the tag in this repository
156         corresponds to the one in the remote repository.
157         """
158         with util.ChangeDirectory(self.location):
159             sh = shell.Shell()
160             repo = self.application.repository(srv_path)
161             def repo_rev_parse(tag):
162                 return sh.eval("git", "--git-dir", repo, "rev-parse", tag)
163             def self_rev_parse(tag):
164                 try:
165                     return sh.safeCall("git", "rev-parse", tag, strip=True)
166                 except shell.CallError:
167                     raise NoLocalTagError(tag)
168             def compare_tags(tag):
169                 return repo_rev_parse(tag) == self_rev_parse(tag)
170             if not compare_tags(self.app_version.pristine_tag):
171                 raise InconsistentPristineTagError(self.app_version.pristine_tag)
172             if not compare_tags(self.app_version.scripts_tag):
173                 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
174             parent = repo_rev_parse(self.app_version.scripts_tag)
175             merge_base = sh.safeCall("git", "merge-base", parent, "HEAD", strip=True)
176             if merge_base != parent:
177                 raise HeadNotDescendantError(self.app_version.scripts_tag)
178
179     def verifyConfigured(self):
180         """
181         Checks if the autoinstall is configured running.
182         """
183         if not self.configured:
184             raise NotConfiguredError(self.location)
185
186     @chdir_to_location
187     def verifyVersion(self):
188         """
189         Checks if our version and the version number recorded in a file
190         are consistent.
191         """
192         real = self.application.detectVersion(self)
193         if not real:
194             raise VersionDetectionError
195         elif not str(real) == self.app_version.pristine_tag.partition('-')[2]:
196             raise VersionMismatchError(real, self.version)
197
198     @property
199     @chdir_to_location
200     def configured(self):
201         """Whether or not an autoinstall has been configured/installed for use."""
202         return self.application.checkConfig(self)
203     @property
204     def migrated(self):
205         """Whether or not the autoinstalls has been migrated."""
206         return os.path.isdir(self.scripts_dir)
207     @property
208     def scripts_dir(self):
209         """The absolute path of the ``.scripts`` directory."""
210         return os.path.join(self.location, '.scripts')
211     @property
212     def old_version_file(self):
213         """
214         The absolute path of either ``.scripts-version`` (for unmigrated
215         installs) or ``.scripts/version``.
216
217         .. note::
218
219             Use of this is discouraged for migrated installs.
220         """
221         return os.path.join(self.location, '.scripts-version')
222     @property
223     def version_file(self):
224         """The absolute path of the ``.scripts/version`` file."""
225         return os.path.join(self.scripts_dir, 'version')
226     @property
227     def application(self):
228         """The :class:`app.Application` of this deployment."""
229         return self.app_version.application
230     @property
231     def old_log(self):
232         """
233         The :class:`wizard.old_log.Log` of this deployment.  This
234         is only applicable to un-migrated autoinstalls.
235         """
236         if not self._old_log:
237             self._old_log = old_log.DeployLog.load(self)
238         return self._old_log
239     @property
240     def version(self):
241         """
242         The :class:`distutils.version.LooseVersion` of this
243         deployment.
244         """
245         return self.app_version.version
246     @property
247     def app_version(self):
248         """The :class:`app.ApplicationVersion` of this deployment."""
249         if not self._app_version:
250             if os.path.isdir(os.path.join(self.location, ".git")):
251                 try:
252                     with util.ChangeDirectory(self.location):
253                         appname, _, version = git.describe().partition('-')
254                     self._app_version = app.ApplicationVersion.make(appname, version)
255                 except shell.CallError:
256                     pass
257         if not self._app_version:
258             try:
259                 self._app_version = self.old_log[-1].version
260             except old_log.ScriptsVersionNoSuchFile:
261                 pass
262         if not self._app_version:
263             appname = shell.Shell().eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
264             self._app_version = app.ApplicationVersion.make(appname, "unknown")
265         return self._app_version
266     @staticmethod
267     def parse(line):
268         """
269         Parses a line from the :term:`versions store`.
270
271         .. note::
272
273             Use this method only when speed is of the utmost
274             importance.  You should prefer to directly create a deployment
275             with only a ``location`` when possible.
276         """
277         line = line.rstrip()
278         try:
279             location, deploydir = line.split(":")
280         except ValueError:
281             return ProductionCopy(line) # lazy loaded version
282         try:
283             return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
284         except Error as e:
285             e.location = location
286             raise e
287
288 class ProductionCopy(Deployment):
289     """
290     Represents the production copy of a deployment.  This copy
291     is canonical, and is the only one guaranteed to be accessible
292     via web, have a database, etc.
293     """
294     @chdir_to_location
295     def upgrade(self, version, options):
296         """
297         Performs an upgrade of database schemas and other non-versioned data.
298         """
299         return self.application.upgrade(self, version, options)
300     @chdir_to_location
301     def backup(self, options):
302         """
303         Performs a backup of database schemas and other non-versioned data.
304         """
305         backupdir = os.path.join(self.location, ".scripts", "backups")
306         backup = str(self.version) + "-" + datetime.date.today().isoformat()
307         outdir = os.path.join(backupdir, backup)
308         if not os.path.exists(backupdir):
309             os.mkdir(backupdir)
310         if os.path.exists(outdir):
311             util.safe_unlink(outdir)
312         os.mkdir(outdir)
313         self.application.backup(self, outdir, options)
314         return backup
315     @chdir_to_location
316     def restore(self, backup, options):
317         """
318         Restores a backup. Destroys state, so be careful! Also, this does
319         NOT restore the file-level backup, which is what 'wizard restore'
320         does, so you probably do NOT want to call this elsewhere unless
321         you know what you're doing (call 'wizard restore' instead).
322         """
323         backup_dir = os.path.join(".scripts", "backups", backup)
324         return self.application.restore(self, backup_dir, options)
325     def verifyWeb(self):
326         """
327         Checks if the autoinstall is viewable from the web.
328         """
329         out = []
330         if not self.application.checkWeb(self, out):
331             raise WebVerificationError(out[0])
332     def fetch(self, path, post=None):
333         """
334         Performs a HTTP request on the website.
335         """
336         try:
337             host, basepath = scripts.get_web_host_and_path(self.location)
338         except (ValueError, TypeError):
339             raise UnknownWebPath
340         return util.fetch(host, basepath, path, post)
341
342 class WorkingCopy(Deployment):
343     """
344     Represents a temporary clone of a deployment that we can make
345     modifications to without fear of interfering with a production
346     deployment.  More operations are permitted on these copies.
347     """
348     @chdir_to_location
349     def parametrize(self, deployment):
350         """
351         Edits files in ``dir`` to replace WIZARD_* variables with literal
352         instances based on ``deployment``.  This is used for constructing
353         virtual merge bases, and as such ``deployment`` will generally not
354         equal ``self``.
355         """
356         return self.application.parametrize(self, deployment)
357     @chdir_to_location
358     def prepareConfig(self):
359         """
360         Edits files in the deployment such that any user-specific configuration
361         is replaced with generic WIZARD_* variables.
362         """
363         return self.application.prepareConfig(self)
364     @chdir_to_location
365     def resolveConflicts(self):
366         """
367         Resolves conflicted files in this working copy.  Returns whether or
368         not all conflicted files were resolved or not.  Fully resolved
369         files are added to the index, but no commit is made.
370         """
371         return self.application.resolveConflicts(self)
372     @chdir_to_location
373     def prepareMerge(self):
374         """
375         Performs various edits to files in the current working directory in
376         order to make a merge go more smoothly.  This is usually
377         used to fix botched line-endings.
378         """
379         return self.application.prepareMerge(self)
380
381 ## -- Exceptions --
382
383 class Error(wizard.Error):
384     """Base error class for this module"""
385     pass
386
387 class NotMigratedError(Error):
388     """
389     The deployment contains a .scripts-version file, but no .git
390     or .scripts directory.
391     """
392     #: Directory of deployment
393     dir = None
394     def __init__(self, dir):
395         self.dir = dir
396     def __str__(self):
397         return """This installation was not migrated"""
398
399 class AlreadyVersionedError(Error):
400     """The deployment contained a .git directory but no .scripts directory."""
401     #: Directory of deployment
402     dir = None
403     def __init__(self, dir):
404         self.dir = dir
405     def __str__(self):
406         return """
407
408 ERROR: Directory contains a .git directory, but not
409 a .scripts directory.  If this is not a corrupt
410 migration, this means that the user was versioning their
411 install using Git."""
412
413 class NotConfiguredError(Error):
414     """The install was missing essential configuration."""
415     #: Directory of unconfigured install
416     dir = None
417     def __init__(self, dir):
418         self.dir = dir
419     def __str__(self):
420         return """
421
422 ERROR: The install was well-formed, but not configured
423 (essential configuration files were not found.)"""
424
425 class CorruptedAutoinstallError(Error):
426     """The install was missing a .git directory, but had a .scripts directory."""
427     #: Directory of the corrupted install
428     dir = None
429     def __init__(self, dir):
430         self.dir = dir
431     def __str__(self):
432         return """
433
434 ERROR: Directory contains a .scripts directory,
435 but not a .git directory."""
436
437 class NotAutoinstallError(Error):
438     """The directory was not an autoinstall, due to missing .scripts-version file."""
439     #: Directory in question
440     dir = None
441     def __init__(self, dir):
442         self.dir = dir
443     def __str__(self):
444         return """
445
446 ERROR: Could not find .scripts-version file. Are you sure
447 this is an autoinstalled application?
448 """
449
450 class NoTagError(Error):
451     """Deployment has a tag that does not have an equivalent in upstream repository."""
452     #: Missing tag
453     tag = None
454     def __init__(self, tag):
455         self.tag = tag
456     def __str__(self):
457         return """
458
459 ERROR: Could not find tag %s in repository.""" % self.tag
460
461 class NoLocalTagError(Error):
462     """Could not find tag in local repository."""
463     #: Missing tag
464     tag = None
465     def __init__(self, tag):
466         self.tag = tag
467     def __str__(self):
468         return """
469
470 ERROR: Could not find tag %s in local repository.""" % self.tag
471
472 class InconsistentPristineTagError(Error):
473     """Pristine tag commit ID does not match upstream pristine tag commit ID."""
474     #: Inconsistent tag
475     tag = None
476     def __init__(self, tag):
477         self.tag = tag
478     def __str__(self):
479         return """
480
481 ERROR: Local pristine tag %s did not match repository's.  This
482 probably means an upstream rebase occured.""" % self.tag
483
484 class InconsistentScriptsTagError(Error):
485     """Scripts tag commit ID does not match upstream scripts tag commit ID."""
486     #: Inconsistent tag
487     tag = None
488     def __init__(self, tag):
489         self.tag = tag
490     def __str__(self):
491         return """
492
493 ERROR: Local scripts tag %s did not match repository's.  This
494 probably means an upstream rebase occurred.""" % self.tag
495
496 class HeadNotDescendantError(Error):
497     """HEAD is not connected to tag."""
498     #: Tag that HEAD should have been descendant of.
499     tag = None
500     def __init__(self, tag):
501         self.tag = tag
502     def __str__(self):
503         return """
504
505 ERROR: HEAD is not a descendant of %s.  This probably
506 means that an upstream rebase occurred, and new tags were
507 pulled, but local user commits were never rebased.""" % self.tag
508
509 class VersionDetectionError(Error):
510     """Could not detect real version of application."""
511     def __str__(self):
512         return """
513
514 ERROR: Could not detect the real version of the application."""
515
516 class VersionMismatchError(Error):
517     """Git version of application does not match detected version."""
518     #: Detected version
519     real_version = None
520     #: Version from Git
521     git_version = None
522     def __init__(self, real_version, git_version):
523         self.real_version = real_version
524         self.git_version = git_version
525     def __str__(self):
526         return """
527
528 ERROR: The detected version %s did not match the Git
529 version %s.""" % (self.real_version, self.git_version)
530
531 class WebVerificationError(Error):
532     """Could not access the application on the web"""
533     #: Contents of web page access
534     contents = None
535     def __init__(self, contents):
536         self.contents = contents
537     def __str__(self):
538         return """
539
540 ERROR: We were not able to access the application on the
541 web.  This may indicate that the website is behind
542 authentication on the htaccess level.  The contents
543 of the page were:
544
545 %s""" % self.contents
546
547 class UnknownWebPath(Error):
548     """Could not determine application's web path."""
549     def __str__(self):
550         return """
551
552 ERROR: We were not able to determine what the application's
553 host and path were in order to perform a web request
554 on the application.  You can specify this manually using
555 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment
556 variables."""
557