]> scripts.mit.edu Git - wizard.git/blob - wizard/deploy.py
Properly register salt values as random.
[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 import tempfile
13 import time
14 import traceback
15 import shutil
16 import errno
17 import pkg_resources
18 import urlparse
19
20 import wizard
21 from wizard import app, git, old_log, shell, sql, util
22
23 ## -- Global Functions --
24
25 def get_install_lines(versions_store, user=None):
26     """
27     Low level function that retrieves a list of lines from the
28     :term:`versions store` that can be passed to :meth:`Deployment.parse`.
29     """
30     if os.path.isfile(versions_store):
31         return fileinput.input([versions_store])
32     if user:
33         return fileinput.input([versions_store + "/" + user])
34     return fileinput.input([versions_store + "/" + f for f in sorted(os.listdir(versions_store))])
35
36 def parse_install_lines(show, versions_store, yield_errors = False, user = None):
37     """
38     Generator function for iterating through all autoinstalls.
39     Each item is an instance of :class:`Deployment`, or possibly
40     a :class:`wizard.deploy.Error` if ``yield_errors`` is ``True``.  You can
41     filter out applications and versions by specifying ``app``
42     or ``app-1.2.3`` in ``show``.  This function may generate
43     log output.
44     """
45     if not show:
46         show = app.applications()
47     elif isinstance(show, str):
48         # otherwise, frozenset will treat string as an iterable
49         show = frozenset([show])
50     else:
51         show = frozenset(show)
52     for line in get_install_lines(versions_store, user):
53         # construction
54         try:
55             d = Deployment.parse(line)
56             name = d.application.name
57         except app.NoSuchApplication as e:
58             if not e.location:
59                 try:
60                     e.location = line.split(':')[0]
61                 except IndexError:
62                     e.location = line
63             if yield_errors:
64                 yield e
65             continue
66         except Error:
67             # we consider this a worse error
68             logging.warning("Error with '%s'" % line.rstrip())
69             continue
70         # filter
71         if name + "-" + str(d.version) in show or name in show:
72             pass
73         else:
74             continue
75         # yield
76         yield d
77
78 def web(dir, url=None):
79     """
80     Attempts to determine the URL a directory would be web-accessible at.
81     If ``url`` is specified, automatically use it.  Returns a generator whic
82     produces a list of candidate urls.
83
84     This function implements a plugin interface named :ref:`wizard.deploy.web`.
85     """
86     if url:
87         yield url
88         return
89
90     for entry in pkg_resources.iter_entry_points("wizard.deploy.web"):
91         f = entry.load()
92         for r in f(dir):
93             if isinstance(r, str):
94                 r = urlparse.urlparse(r)
95             yield r
96
97     # try the environment
98     host = os.getenv("WIZARD_WEB_HOST")
99     path = os.getenv("WIZARD_WEB_PATH")
100     if host is not None and path is not None:
101         yield urlparse.ParseResult(
102                     "http",
103                     host,
104                     path.rstrip('/'),
105                     "", "", "")
106
107 ## -- Model Objects --
108
109 @decorator.decorator
110 def chdir_to_location(f, self, *args, **kwargs):
111     """
112     Decorator for making a function have working directory
113     :attr:`Deployment.location`.
114     """
115     with util.ChangeDirectory(self.location):
116         return f(self, *args, **kwargs)
117
118 class Deployment(object):
119     """
120     Represents a deployment of an autoinstall, e.g. directory
121     that has ``.scripts`` directory or ``.scripts-version``
122     file in it.  Supply ``version`` with an :class:`ApplicationVersion` only if
123     you were reading from the :term:`versions store` and care about
124     speed (data from there can be stale).
125
126     The Deployment interface is somewhat neutered, so you may
127     want to use :class:`WorkingCopy` or :class:`ProductionCopy` for
128     more powerful operations.
129     """
130     #: Absolute path to the deployment
131     location = None
132     def __init__(self, location, version=None):
133         self.location = os.path.abspath(location)
134         self._app_version = version
135         # some cache variables
136         self._read_cache = {}
137         self._old_log = None
138         self._dsn = None
139         self._url = None
140         self._urlGen = None
141     def invalidateCache(self):
142         """
143         Invalidates all cached variables.  This currently applies to
144         :attr:`app_version`, :attr:`old_log` and :meth:`read`.
145         """
146         self._app_version = None
147         self._read_cache = {}
148         self._old_log = None
149     def read(self, file, force = False):
150         """
151         Reads a file's contents, possibly from cache unless ``force``
152         is ``True``.
153         """
154         if force or file not in self._read_cache:
155             f = open(os.path.join(self.location, file))
156             self._read_cache[file] = f.read()
157             f.close()
158         return self._read_cache[file]
159     def extract(self):
160         """
161         Extracts all the values of all variables from deployment.
162         These variables may be used for parametrizing generic parent
163         commits and include things such as database access credentials
164         and local configuration.
165         """
166         return self.application.extract(self)
167
168     def verify(self):
169         """
170         Checks if this is an autoinstall, throws an exception if there
171         are problems.
172         """
173         with util.ChangeDirectory(self.location):
174             has_git = os.path.isdir(".git")
175             has_scripts = os.path.isdir(".scripts")
176             if not has_git and has_scripts:
177                 raise CorruptedAutoinstallError(self.location)
178             elif has_git and not has_scripts:
179                 raise AlreadyVersionedError(self.location)
180             elif not has_git and not has_scripts:
181                 if os.path.isfile(".scripts-version"):
182                     raise NotMigratedError(self.location)
183                 else:
184                     raise NotAutoinstallError(self.location)
185
186     def verifyTag(self, srv_path):
187         """
188         Checks if the purported version has a corresponding tag
189         in the upstream repository.
190         """
191         repo = self.application.repository(srv_path)
192         try:
193             shell.eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
194         except shell.CallError:
195             raise NoTagError(self.app_version.scripts_tag)
196
197     def verifyGit(self, srv_path):
198         """
199         Checks if the autoinstall's Git repository makes sense,
200         checking if the tag is parseable and corresponds to
201         a real application, and if the tag in this repository
202         corresponds to the one in the remote repository.
203         """
204         with util.ChangeDirectory(self.location):
205             repo = self.application.repository(srv_path)
206             def repo_rev_parse(tag):
207                 return shell.eval("git", "--git-dir", repo, "rev-parse", tag)
208             def self_rev_parse(tag):
209                 try:
210                     return shell.safeCall("git", "rev-parse", tag, strip=True)
211                 except shell.CallError:
212                     raise NoLocalTagError(tag)
213             def compare_tags(tag):
214                 return repo_rev_parse(tag) == self_rev_parse(tag)
215             if not compare_tags(self.app_version.pristine_tag):
216                 raise InconsistentPristineTagError(self.app_version.pristine_tag)
217             if not compare_tags(self.app_version.scripts_tag):
218                 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
219             parent = repo_rev_parse(self.app_version.scripts_tag)
220             merge_base = shell.safeCall("git", "merge-base", parent, "HEAD", strip=True)
221             if merge_base != parent:
222                 raise HeadNotDescendantError(self.app_version.scripts_tag)
223
224     def verifyConfigured(self):
225         """
226         Checks if the autoinstall is configured running.
227         """
228         if not self.configured:
229             raise NotConfiguredError(self.location)
230
231     @chdir_to_location
232     def verifyVersion(self):
233         """
234         Checks if our version and the version number recorded in a file
235         are consistent.
236         """
237         real = self.detectVersion()
238         if not str(real) == self.app_version.pristine_tag.partition('-')[2]:
239             raise VersionMismatchError(real, self.version)
240
241     @chdir_to_location
242     def detectVersion(self):
243         """
244         Returns the real version, based on filesystem, of install.
245
246         Throws a :class:`VersionDetectionError` if we couldn't figure out
247         what the real version was.
248         """
249         real = self.application.detectVersion(self)
250         if not real:
251             raise VersionDetectionError
252         return real
253
254     @property
255     @chdir_to_location
256     def configured(self):
257         """Whether or not an autoinstall has been configured/installed for use."""
258         return self.application.checkConfig(self)
259     @property
260     def migrated(self):
261         """Whether or not the autoinstalls has been migrated."""
262         return os.path.isdir(self.scripts_dir)
263     @property
264     def scripts_dir(self):
265         """The absolute path of the ``.scripts`` directory."""
266         return os.path.join(self.location, '.scripts')
267     @property
268     def old_version_file(self):
269         """
270         The absolute path of either ``.scripts-version`` (for unmigrated
271         installs) or ``.scripts/version``.
272
273         .. note::
274
275             Use of this is discouraged for migrated installs.
276         """
277         return os.path.join(self.location, '.scripts-version')
278     @property
279     def version_file(self):
280         """The absolute path of the ``.scripts/version`` file."""
281         return os.path.join(self.scripts_dir, 'version')
282     @property
283     def dsn_file(self):
284         """The absolute path of the :file:`.scripts/dsn` override file."""
285         return os.path.join(self.scripts_dir, 'dsn')
286     @property
287     def url_file(self):
288         """The absolute path of the :file:`.scripts/url` override file."""
289         return os.path.join(self.scripts_dir, 'url')
290     @property
291     def application(self):
292         """The :class:`app.Application` of this deployment."""
293         return self.app_version.application
294     @property
295     def old_log(self):
296         """
297         The :class:`wizard.old_log.Log` of this deployment.  This
298         is only applicable to un-migrated autoinstalls.
299         """
300         if not self._old_log:
301             self._old_log = old_log.DeployLog.load(self)
302         return self._old_log
303     @property
304     def version(self):
305         """
306         The :class:`distutils.version.LooseVersion` of this
307         deployment.
308         """
309         return self.app_version.version
310     @property
311     def app_version(self):
312         """The :class:`app.ApplicationVersion` of this deployment."""
313         if not self._app_version:
314             if os.path.isdir(os.path.join(self.location, ".git")):
315                 try:
316                     with util.ChangeDirectory(self.location):
317                         appname, _, version = git.describe().partition('-')
318                     self._app_version = app.ApplicationVersion.make(appname, version)
319                 except shell.CallError:
320                     pass
321         if not self._app_version:
322             try:
323                 self._app_version = self.old_log[-1].version
324             except old_log.ScriptsVersionNoSuchFile:
325                 pass
326         if not self._app_version:
327             appname = shell.eval("git", "config", "remote.origin.url").rpartition("/")[2].partition(".")[0]
328             self._app_version = app.ApplicationVersion.make(appname, "unknown")
329         return self._app_version
330     @property
331     def dsn(self):
332         """The :class:`sqlalchemy.engine.url.URL` for this deployment."""
333         if not self._dsn:
334             self._dsn = sql.auth(self.application.dsn(self))
335         return self._dsn
336     @property
337     def url(self):
338         """The :class:`urlparse.ParseResult` for this deployment."""
339         if not self._urlGen:
340             self._urlGen = web(self.location, self.application.url(self))
341             self.nextUrl()
342         return self._url
343     def nextUrl(self):
344         """"""
345         try:
346             self._url = self._urlGen.next() # pylint: disable-msg=E1101
347             return self._url
348         except StopIteration:
349             raise UnknownWebPath
350     @staticmethod
351     def parse(line):
352         """
353         Parses a line from the :term:`versions store`.
354
355         .. note::
356
357             Use this method only when speed is of the utmost
358             importance.  You should prefer to directly create a deployment
359             with only a ``location`` when possible.
360         """
361         line = line.rstrip()
362         try:
363             location, deploydir = line.split(":")
364         except ValueError:
365             return ProductionCopy(line) # lazy loaded version
366         try:
367             return ProductionCopy(location, version=app.ApplicationVersion.parse(deploydir))
368         except Error as e:
369             e.location = location
370             raise e
371
372 class ProductionCopy(Deployment):
373     """
374     Represents the production copy of a deployment.  This copy
375     is canonical, and is the only one guaranteed to be accessible
376     via web, have a database, etc.
377     """
378     @chdir_to_location
379     def upgrade(self, version, options):
380         """
381         Performs an upgrade of database schemas and other non-versioned data.
382         """
383         return self.application.upgrade(self, version, options)
384     @chdir_to_location
385     def backup(self, options):
386         """
387         Performs a backup of database schemas and other non-versioned data.
388         """
389         # There are retarded amounts of race-safety in this function,
390         # because we do NOT want to claim to have made a backup, when
391         # actually something weird happened to it.
392         backupdir = os.path.join(self.scripts_dir, "backups")
393         if not os.path.exists(backupdir):
394             try:
395                 os.mkdir(backupdir)
396             except OSError as e:
397                 if e.errno == errno.EEXIST:
398                     pass
399                 else:
400                     raise
401         tmpdir = tempfile.mkdtemp() # actually will be kept around
402         try:
403             self.application.backup(self, tmpdir, options)
404         except app.BackupFailure:
405             # the backup is bogus, don't let it show up
406             shutil.rmtree(tmpdir)
407             raise
408         backup = None
409         with util.LockDirectory(os.path.join(backupdir, "lock")):
410             while 1:
411                 backup = str(self.version) + "-" + datetime.datetime.today().strftime("%Y-%m-%dT%H%M%S")
412                 outdir = os.path.join(backupdir, backup)
413                 if os.path.exists(outdir):
414                     logging.warning("Backup: A backup occurred in the last second. Trying again in a second...")
415                     time.sleep(1)
416                     continue
417                 try:
418                     shutil.move(tmpdir, outdir)
419                 except:
420                     # don't leave half-baked stuff lying around
421                     try:
422                         shutil.rmtree(outdir)
423                     except OSError:
424                         pass
425                     raise
426                 break
427         return backup
428     @chdir_to_location
429     def restore(self, backup, options):
430         """
431         Restores a backup. Destroys state, so be careful! Also, this does
432         NOT restore the file-level backup, which is what 'wizard restore'
433         does, so you probably do NOT want to call this elsewhere unless
434         you know what you're doing (call 'wizard restore' instead).
435         """
436         backup_dir = os.path.join(".scripts", "backups", backup)
437         return self.application.restore(self, backup_dir, options)
438     @chdir_to_location
439     def remove(self, options):
440         """
441         Deletes all non-local or non-filesystem data (such as databases) that
442         this application uses.
443         """
444         self.application.remove(self, options)
445     def verifyDatabase(self):
446         """
447         Checks if the autoinstall has a properly configured database.
448         """
449         if not self.application.checkDatabase(self):
450             raise DatabaseVerificationError
451     def verifyWeb(self):
452         """
453         Checks if the autoinstall is viewable from the web.  If you do not run
454         this, there is no guarantee that the url returned by this application
455         is the correct one.
456         """
457         while True:
458             if not self.application.checkWeb(self):
459                 try:
460                     self.nextUrl()
461                 except UnknownWebPath:
462                     raise WebVerificationError
463             else:
464                 break
465     def fetch(self, path, post=None):
466         """
467         Performs a HTTP request on the website.
468         """
469         return util.fetch(self.url.netloc, self.url.path, path, post) # pylint: disable-msg=E1103
470
471 class WorkingCopy(Deployment):
472     """
473     Represents a temporary clone of a deployment that we can make
474     modifications to without fear of interfering with a production
475     deployment.  More operations are permitted on these copies.
476     """
477     def setAppVersion(self, app_version):
478         """
479         Manually resets the application version; useful if the working
480         copy is off in space (i.e. not anchored to something we can
481         git describe off of.)
482         """
483         self._app_version = app_version
484     @chdir_to_location
485     def parametrize(self, deployment):
486         """
487         Edits files in ``dir`` to replace WIZARD_* variables with literal
488         instances based on ``deployment``.  This is used for constructing
489         virtual merge bases, and as such ``deployment`` will generally not
490         equal ``self``.
491         """
492         return self.application.parametrize(self, deployment)
493     @chdir_to_location
494     def prepareConfig(self):
495         """
496         Edits files in the deployment such that any user-specific configuration
497         is replaced with generic WIZARD_* variables.
498         """
499         return self.application.prepareConfig(self)
500     @chdir_to_location
501     def resolveConflicts(self):
502         """
503         Resolves conflicted files in this working copy.  Returns whether or
504         not all conflicted files were resolved or not.  Fully resolved
505         files are added to the index, but no commit is made.
506         """
507         return self.application.resolveConflicts(self)
508     @chdir_to_location
509     def prepareMerge(self):
510         """
511         Performs various edits to files in the current working directory in
512         order to make a merge go more smoothly.  This is usually
513         used to fix botched line-endings.
514         """
515         return self.application.prepareMerge(self)
516
517 ## -- Exceptions --
518
519 class Error(wizard.Error):
520     """Base error class for this module"""
521     pass
522
523 class NotMigratedError(Error):
524     """
525     The deployment contains a .scripts-version file, but no .git
526     or .scripts directory.
527     """
528     #: Directory of deployment
529     dir = None
530     def __init__(self, dir):
531         self.dir = dir
532     def __str__(self):
533         return """This installation was not migrated"""
534
535 class AlreadyVersionedError(Error):
536     """The deployment contained a .git directory but no .scripts directory."""
537     #: Directory of deployment
538     dir = None
539     def __init__(self, dir):
540         self.dir = dir
541     def __str__(self):
542         return """
543
544 ERROR: Directory contains a .git directory, but not
545 a .scripts directory.  If this is not a corrupt
546 migration, this means that the user was versioning their
547 install using Git."""
548
549 class NotConfiguredError(Error):
550     """The install was missing essential configuration."""
551     #: Directory of unconfigured install
552     dir = None
553     def __init__(self, dir):
554         self.dir = dir
555     def __str__(self):
556         return """
557
558 ERROR: The install was well-formed, but not configured
559 (essential configuration files were not found.)"""
560
561 class CorruptedAutoinstallError(Error):
562     """The install was missing a .git directory, but had a .scripts directory."""
563     #: Directory of the corrupted install
564     dir = None
565     def __init__(self, dir):
566         self.dir = dir
567     def __str__(self):
568         return """
569
570 ERROR: Directory contains a .scripts directory,
571 but not a .git directory."""
572
573 class NotAutoinstallError(Error):
574     """Application is not an autoinstall."""
575     #: Directory of the not autoinstall
576     dir = None
577     def __init__(self, dir):
578         self.dir = dir
579     def __str__(self):
580         return """
581
582 ERROR: The directory
583
584     %s
585
586 does not appear to be an autoinstall.  If you are in a
587 subdirectory of an autoinstall, you need to use the root
588 directory for the autoinstall.""" % self.dir
589
590 class NoTagError(Error):
591     """Deployment has a tag that does not have an equivalent in upstream repository."""
592     #: Missing tag
593     tag = None
594     def __init__(self, tag):
595         self.tag = tag
596     def __str__(self):
597         return """
598
599 ERROR: Could not find tag %s in repository.""" % self.tag
600
601 class NoLocalTagError(Error):
602     """Could not find tag in local repository."""
603     #: Missing tag
604     tag = None
605     def __init__(self, tag):
606         self.tag = tag
607     def __str__(self):
608         return """
609
610 ERROR: Could not find tag %s in local repository.""" % self.tag
611
612 class InconsistentPristineTagError(Error):
613     """Pristine tag commit ID does not match upstream pristine tag commit ID."""
614     #: Inconsistent tag
615     tag = None
616     def __init__(self, tag):
617         self.tag = tag
618     def __str__(self):
619         return """
620
621 ERROR: Local pristine tag %s did not match repository's.  This
622 probably means an upstream rebase occured.""" % self.tag
623
624 class InconsistentScriptsTagError(Error):
625     """Scripts tag commit ID does not match upstream scripts tag commit ID."""
626     #: Inconsistent tag
627     tag = None
628     def __init__(self, tag):
629         self.tag = tag
630     def __str__(self):
631         return """
632
633 ERROR: Local scripts tag %s did not match repository's.  This
634 probably means an upstream rebase occurred.""" % self.tag
635
636 class HeadNotDescendantError(Error):
637     """HEAD is not connected to tag."""
638     #: Tag that HEAD should have been descendant of.
639     tag = None
640     def __init__(self, tag):
641         self.tag = tag
642     def __str__(self):
643         return """
644
645 ERROR: HEAD is not a descendant of %s.  This probably
646 means that an upstream rebase occurred, and new tags were
647 pulled, but local user commits were never rebased.""" % self.tag
648
649 class VersionDetectionError(Error):
650     """Could not detect real version of application."""
651     def __str__(self):
652         return """
653
654 ERROR: Could not detect the real version of the application."""
655
656 class VersionMismatchError(Error):
657     """Git version of application does not match detected version."""
658     #: Detected version
659     real_version = None
660     #: Version from Git
661     git_version = None
662     def __init__(self, real_version, git_version):
663         self.real_version = real_version
664         self.git_version = git_version
665     def __str__(self):
666         return """
667
668 ERROR: The detected version %s did not match the Git
669 version %s.""" % (self.real_version, self.git_version)
670
671 class WebVerificationError(Error):
672     """Could not access the application on the web"""
673     def __str__(self):
674         return """
675
676 ERROR: We were not able to access the application on the
677 web.  This may indicate that the website is behind
678 authentication on the htaccess level.  You can find
679 the contents of the page from the debug backtraces."""
680
681 class DatabaseVerificationError(Error):
682     """Could not access the database"""
683     def __str__(self):
684         return """
685
686 ERROR: We were not able to access the database for
687 this application; this probably means that your database
688 configuration is misconfigured."""
689
690 class UnknownWebPath(Error):
691     """Could not determine application's web path."""
692     def __str__(self):
693         return """
694
695 ERROR: We were not able to determine what the application's
696 host and path were in order to perform a web request
697 on the application.  You can specify this manually using
698 the WIZARD_WEB_HOST and WIZARD_WEB_PATH environment
699 variables."""