]> scripts.mit.edu Git - wizard.git/blob - wizard/command/upgrade.py
Change to production directory before running certain commands.
[wizard.git] / wizard / command / upgrade.py
1 import sys
2 import distutils.version
3 import os
4 import os.path
5 import shutil
6 import logging.handlers
7 import tempfile
8 import itertools
9 import time
10 import errno
11
12 from wizard import app, command, deploy, merge, shell, user, util
13
14 buffer = 1024 * 1024 * 30 # 30 MiB we will always leave available
15 errno_blacklisted = 64
16
17 def main(argv, baton):
18     options, args = parse_args(argv, baton)
19     dir = os.path.abspath(args[0]) if args else os.getcwd()
20     os.chdir(dir)
21     shell.drop_priviledges(dir, options.log_file)
22     util.set_git_env()
23     upgrade = Upgrade(options)
24     upgrade.execute(dir)
25     if not options.non_interactive:
26         print "Upgrade complete"
27
28 class Upgrade(object):
29     """
30     Represents the algorithm for upgrading an application.  This is in
31     a class and not a function because it's a multi-step process that
32     requires state betweens steps.  Steps are represented as methods
33     in this object.
34     """
35
36     #: Version of application we are upgrading to, i.e. the latest version.
37     version = None # XXX: This is a string... I'm not convinced it should be
38     #: String commit ID of the user's latest wc; i.e. "ours"
39     user_commit = None
40     #: String commit ID of the latest, greatest wizard version; i.e. "theirs"
41     next_commit = None
42     #: The temporary directory that the system gave us; may stay as ``None``
43     #: if we don't ever make ourselves a temporary directory (e.g. ``--continue``).
44     #: While we should clean this up if it is set to something, it may
45     #: not correspond to anything useful.
46     temp_dir = None
47     #: The temporary directory containing our working copy for merging
48     temp_wc_dir = None
49     #: We place the temporary repositories inside a tmpfs while merging;
50     #: this makes merges not disk-bound and affords a modest speed increase.
51     #: If you are running ``--continue``, this is guaranteed to be ``False``.
52     use_shm = None
53     #: Upstream repository to use.  This does not need to be saved.
54     repo = None
55
56     #: Instance of :class:`wizard.deploy.WorkingCopy` for this upgrade
57     wc = None
58     #: Instance of :class:`wizard.deploy.ProductionCopy` for this upgrade
59     prod = None
60
61     #: Options object that the installer was called with
62     options = None
63
64     def __init__(self, options):
65         self.version = None
66         self.user_commit = None
67         self.next_commit = None
68         self.temp_dir = None
69         self.temp_wc_dir = None
70         self.use_shm = False # False until proven otherwise.
71         self.wc = None
72         self.prod = None
73         self.options = options
74
75     def execute(self, dir):
76         """
77         Executes an upgrade.  This is the entry-point.  This expects
78         that it's current working directory is the same as ``dir``.
79         """
80         assert os.path.abspath(dir) == os.getcwd()
81         try:
82             if self.options.continue_:
83                 logging.info("Continuing upgrade...")
84                 self.resume()
85             else:
86                 logging.info("Upgrading %s" % os.getcwd())
87                 self.preflight()
88                 self.merge()
89             self.postflight()
90             # Till now, all of our operations were in a tmp sandbox.
91             if self.options.dry_run:
92                 logging.info("Dry run, bailing.  See results at %s" % self.temp_wc_dir)
93                 return
94             backup = self.backup()
95             self.upgrade(backup)
96             # Note: disable_rollback assumes that upgrade is the last
97             # step, if you add another setp you may have to modify this
98             # to accomodate that.
99         finally:
100             if self.use_shm and self.temp_dir and os.path.exists(self.temp_dir):
101                 shutil.rmtree(self.temp_dir)
102
103     def resume(self):
104         """
105         In the event of a ``--continue`` flag, we have to restore state and
106         perform some sanity checks.
107         """
108         self.resumeChdir()
109         self.resumeState()
110         self.resumeLogging()
111         util.chdir(shell.eval("git", "config", "remote.origin.url"))
112         self.resumeProd()
113     def resumeChdir(self):
114         """
115         If we called ``--continue`` inside a production copy,  check if
116         :file:`.wizard/pending` exists and change to that directory if so.
117         """
118         util.chdir_to_production()
119     def resumeState(self):
120         self.temp_wc_dir = os.getcwd()
121         self.wc = deploy.WorkingCopy(".")
122         try:
123             self.user_commit, self.next_commit = open(".git/WIZARD_PARENTS", "r").read().split()
124             self.version = open(".git/WIZARD_UPGRADE_VERSION", "r").read()
125         except IOError as e:
126             if e.errno == errno.ENOENT:
127                 raise CannotResumeError()
128             else:
129                 raise
130     def resumeLogging(self):
131         options = self.options
132         if not options.log_file and os.path.exists(".git/WIZARD_LOG_FILE"):
133             options.log_file = open(".git/WIZARD_LOG_FILE", "r").read()
134             command.setup_file_logger(options.log_file, options.debug)
135     def resumeProd(self):
136         """Restore :attr:`prod` attribute, and check if the production copy has drifted."""
137         self.prod = deploy.ProductionCopy(".")
138         try:
139             # simulate the action of `git status`, based on cmd_status()'s call to
140             # refresh_cache() in builtin-commit.c
141             shell.call("git", "update-index", "-q", "--unmerged", "--refresh")
142             r1 = shell.eval("git", "diff-files", "--name-only").strip()
143             r2 = shell.eval("git", "diff-index", "--name-only", "HEAD").strip()
144             if r1 or r2:
145                 raise LocalChangesError()
146         except shell.CallError:
147             pass
148         # Working copy is not anchored anywhere useful for git describe,
149         # so we need to give it a hint.
150         self.wc.setAppVersion(self.prod.app_version)
151
152     def preflight(self):
153         """
154         Make sure that a number of pre-upgrade invariants are met before
155         attempting anything.
156         """
157         options = self.options
158         for i in range(0,2):
159             self.prod = deploy.ProductionCopy(".")
160             self.prod.verify()
161             self.repo = self.prod.application.repository(options.srv_path)
162             # XXX: put this in Application
163             self.version = shell.eval("git", "--git-dir="+self.repo, "describe", "--tags", "master")
164             self.preflightBlacklist()
165             self.prod.verify()
166             self.prod.verifyDatabase()
167             self.prod.verifyTag(options.srv_path)
168             self.prod.verifyGit(options.srv_path)
169             if not options.skip_verification:
170                 self.prod.verifyConfigured()
171             try:
172                 shell.call("git", "fetch", "--tags") # XXX: hack since some installs have stale tags
173             except shell.CallError as e:
174                 if "Disk quota exceeded" in e.stderr:
175                     raise QuotaTooLow
176                 raise
177             try:
178                 self.prod.verifyVersion()
179             except deploy.VersionMismatchError as e:
180                 # XXX: kind of hacky, mainly it does change the Git working copy
181                 # state (although /very/ non-destructively)
182                 try:
183                     shell.call("git", "merge", "--strategy=ours", self.prod.application.makeVersion(str(e.real_version)).wizard_tag)
184                 except shell.CallError as e2:
185                     if "does not point to a commit" in e2.stderr:
186                         raise UnknownVersionError(e.real_version)
187                     else:
188                         raise
189                 continue
190             break
191         else:
192             raise VersionRematchFailed
193         if not options.skip_verification:
194             self.prod.verifyWeb()
195         self.preflightAlreadyUpgraded()
196         self.preflightQuota()
197     def preflightBlacklist(self):
198         # XXX: should use deploy info
199         if os.path.exists(".wizard/blacklisted"):
200             reason = open(".wizard/blacklisted").read()
201             # ignore blank blacklisted files
202             if reason:
203                 print reason
204                 raise BlacklistedError(reason)
205             else:
206                 logging.warning("Application was blacklisted, but no reason was found");
207     def preflightAlreadyUpgraded(self):
208         if self.version == self.prod.app_version.wizard_tag and not self.options.force:
209             # don't log this error; we need to have the traceback line
210             # so that the parsing code can catch it
211             # XXX: maybe we should build this in as a flag to add
212             # to exceptions w/ our exception handler
213             sys.stderr.write("Traceback:\n  (n/a)\nAlreadyUpgraded\n")
214             sys.exit(2)
215     def preflightQuota(self):
216         r = user.quota()
217         if r is not None:
218             usage, limit = r
219             if limit is not None and (limit - usage) < buffer:
220                 logging.info("preflightQuota: limit = %d, usage = %d, buffer = %d", limit, usage, buffer)
221                 raise QuotaTooLow
222
223     def merge(self):
224         if not self.options.dry_run:
225             self.mergePreCommit()
226         self.mergeClone()
227         logging.debug("Temporary WC dir is %s", self.temp_wc_dir)
228         with util.ChangeDirectory(self.temp_wc_dir):
229             self.wc = deploy.WorkingCopy(".")
230             shell.call("git", "remote", "add", "wizard", self.repo)
231             shell.call("git", "fetch", "-q", "wizard")
232             self.user_commit = shell.eval("git", "rev-parse", "HEAD")
233             self.next_commit = shell.eval("git", "rev-parse", self.version)
234             self.mergeSaveState()
235             self.mergePerform()
236     def mergePreCommit(self):
237         def get_file_set(rev):
238             return set(shell.eval("git", "ls-tree", "-r", "--name-only", rev).split("\n"))
239         # add all files that are unversioned but would be replaced by the pull,
240         # and generate a new commit
241         old_files = get_file_set("HEAD")
242         new_files = get_file_set(self.version)
243         added_files = new_files - old_files
244         for f in added_files:
245             if os.path.lexists(f): # broken symbolic links count too!
246                 shell.call("git", "add", f)
247         message = "Pre-commit before autoinstall upgrade.\n\n%s" % util.get_git_footer()
248         try:
249             message += "\nPre-commit-by: " + util.get_operator_git()
250         except util.NoOperatorInfo:
251             pass
252         try:
253             shell.call("git", "commit", "-a", "-m", message)
254         except shell.CallError as e:
255             if "Permission denied" in e.stderr:
256                 raise util.PermissionsError
257             elif e.stderr:
258                 raise
259             logging.info("No changes detected")
260     def mergeClone(self):
261         # If /dev/shm exists, it's a tmpfs and we can use it
262         # to do a fast git merge. Don't forget to move it to
263         # /tmp if it fails.
264         if not self.options.dry_run and not self.options.debug:
265             self.use_shm = os.path.exists("/dev/shm")
266         if self.use_shm:
267             dir = "/dev/shm/wizard"
268             if not os.path.exists(dir):
269                 os.mkdir(dir)
270                 # XXX: race
271                 os.chmod(dir, 0o777)
272         else:
273             dir = None
274         self.temp_dir = tempfile.mkdtemp(prefix="wizard", dir=dir)
275         self.temp_wc_dir = os.path.join(self.temp_dir, "repo")
276         logging.info("Using temporary directory: " + self.temp_wc_dir)
277         shell.call("git", "clone", "-q", "--shared", ".", self.temp_wc_dir)
278     def mergeSaveState(self):
279         """Save variables so that ``--continue`` will work."""
280         # yeah yeah no trailing newline whatever
281         open(".git/WIZARD_UPGRADE_VERSION", "w").write(self.version)
282         open(".git/WIZARD_PARENTS", "w").write("%s\n%s" % (self.user_commit, self.next_commit))
283         open(".git/WIZARD_SIZE", "w").write(str(util.disk_usage()))
284         if self.options.log_file:
285             open(".git/WIZARD_LOG_FILE", "w").write(self.options.log_file)
286     def mergePerform(self):
287         def prepare_config():
288             self.wc.prepareConfig()
289             shell.call("git", "add", ".")
290         def resolve_conflicts():
291             return self.wc.resolveConflicts()
292         shell.call("git", "config", "merge.conflictstyle", "diff3")
293         # setup rerere
294         if self.options.rr_cache is None:
295             self.options.rr_cache = os.path.join(self.prod.location, ".git", "rr-cache")
296         if not os.path.exists(self.options.rr_cache):
297             os.mkdir(self.options.rr_cache)
298         os.symlink(self.options.rr_cache, os.path.join(self.wc.location, ".git", "rr-cache"))
299         shell.call("git", "config", "rerere.enabled", "true")
300         try:
301             merge.merge(self.wc.app_version.wizard_tag, self.version,
302                         prepare_config, resolve_conflicts)
303         except merge.MergeError:
304             self.mergeFail()
305     def mergeFail(self):
306         files = set()
307         for line in shell.eval("git", "ls-files", "--unmerged").splitlines():
308             files.add(line.split(None, 3)[-1])
309         conflicts = len(files)
310         # XXX: this is kind of fiddly; note that temp_dir still points at the OLD
311         # location after this code.
312         self.temp_wc_dir = mv_shm_to_tmp(os.getcwd(), self.use_shm)
313         self.wc.location = self.temp_wc_dir
314         os.chdir(self.temp_wc_dir)
315         open(self.prod.pending_file, "w").write(self.temp_wc_dir)
316         if self.options.non_interactive:
317             print "%d %s" % (conflicts, self.temp_wc_dir)
318             raise MergeFailed
319         else:
320             user_shell = os.getenv("SHELL")
321             if not user_shell: user_shell = "/bin/bash"
322             # XXX: scripts specific hack, since mbash doesn't respect the current working directory
323             # When the revolution comes (i.e. $ATHENA_HOMEDIR/Scripts is your Scripts home
324             # directory) this isn't strictly necessary, but we'll probably need to support
325             # web_scripts directories ad infinitum.
326             if user_shell == "/usr/local/bin/mbash": user_shell = "/bin/bash"
327             while 1:
328                 print
329                 print "ERROR: The merge failed with %d conflicts in these files:" % conflicts
330                 print
331                 for file in sorted(files):
332                     print "  * %s" % file
333                 print
334                 print "Please resolve these conflicts (edit and then `git add`), and"
335                 print "then type 'exit'.  You will now be dropped into a shell whose working"
336                 print "directory is %s" % self.temp_wc_dir
337                 try:
338                     shell.call(user_shell, "-i", interactive=True)
339                 except shell.CallError as e:
340                     logging.warning("Shell returned non-zero exit code %d" % e.code)
341                 if shell.eval("git", "ls-files", "--unmerged").strip():
342                     print
343                     print "WARNING: There are still unmerged files."
344                     out = raw_input("Continue editing? [y/N]: ")
345                     if out == "y" or out == "Y":
346                         continue
347                     else:
348                         print "Aborting.  The conflicted working copy can be found at:"
349                         print
350                         print "    %s" % self.temp_wc_dir
351                         print
352                         print "and you can resume the upgrade process by running in that directory:"
353                         print
354                         print "    wizard upgrade --continue"
355                         sys.exit(1)
356                 break
357
358     def postflight(self):
359         with util.ChangeDirectory(self.temp_wc_dir):
360             if shell.eval("git", "ls-files", "-u").strip():
361                 raise UnmergedChangesError
362             shell.call("git", "commit", "--allow-empty", "-am", "throw-away commit")
363             self.wc.parametrize(self.prod)
364             shell.call("git", "add", ".")
365             message = self.postflightCommitMessage()
366             new_tree = shell.eval("git", "write-tree")
367             final_commit = shell.eval("git", "commit-tree", new_tree,
368                     "-p", self.user_commit, "-p", self.next_commit, input=message, log=True)
369             # a master branch may not necessarily exist if the user
370             # was manually installed to an earlier version
371             try:
372                 shell.call("git", "checkout", "-q", "-b", "master", "--")
373             except shell.CallError:
374                 shell.call("git", "checkout", "-q", "master", "--")
375             shell.call("git", "reset", "-q", "--hard", final_commit)
376             # This is a quick sanity check to make sure we didn't completely
377             # mess up the merge
378             self.wc.invalidateCache()
379             self.wc.verifyVersion()
380     def postflightCommitMessage(self):
381         message = "Upgraded autoinstall to %s.\n\n%s" % (self.version, util.get_git_footer())
382         try:
383             message += "\nUpgraded-by: " + util.get_operator_git()
384         except util.NoOperatorInfo:
385             pass
386         return message
387
388     def backup(self):
389         # Ok, now we have to do a crazy complicated dance to see if we're
390         # going to have enough quota to finish what we need
391         pre_size = int(open(os.path.join(self.temp_wc_dir, ".git/WIZARD_SIZE"), "r").read())
392         post_size = util.disk_usage(self.temp_wc_dir)
393         backup = self.prod.backup(self.options)
394         r = user.quota()
395         if r is not None:
396             usage, limit = r
397             if limit is not None and (limit - usage) - (post_size - pre_size) < buffer:
398                 shutil.rmtree(os.path.join(self.prod.backup_dir, shell.eval("wizard", "restore").splitlines()[0]))
399                 raise QuotaTooLow
400         return backup
401
402     def upgrade(self, backup):
403         # XXX: frob .htaccess to make site inaccessible
404         # XXX: frob Git to disallow Git operations after the pull
405         with util.IgnoreKeyboardInterrupts():
406             with util.LockDirectory(".wizard-upgrade-lock"):
407                 shell.call("git", "fetch", "--tags")
408                 # git merge (which performs a fast forward)
409                 shell.call("git", "pull", "-q", self.temp_wc_dir, "master")
410                 version_obj = distutils.version.LooseVersion(self.version.partition('-')[2])
411                 try:
412                     # run update script
413                     self.prod.upgrade(version_obj, self.options)
414                     self.prod.verifyWeb()
415                 except app.UpgradeFailure:
416                     logging.warning("Upgrade failed: rolling back")
417                     self.upgradeRollback(backup)
418                     raise
419                 except deploy.WebVerificationError as e:
420                     logging.warning("Web verification failed: rolling back")
421                     self.upgradeRollback(backup)
422                     raise app.UpgradeVerificationFailure()
423         # XXX: frob .htaccess to make site accessible
424         #       to do this, check if .htaccess changed, first.  Upgrade
425         #       process might have frobbed it.  Don't be
426         #       particularly worried if the segment disappeared
427     def upgradeRollback(self, backup):
428         # You don't want d.restore() because it doesn't perform
429         # the file level backup
430         if not self.options.disable_rollback:
431             shell.call("wizard", "restore", backup)
432             try:
433                 self.prod.verifyWeb()
434             except deploy.WebVerificationError:
435                 logging.critical("Web verification failed after rollback")
436         else:
437             logging.warning("Rollback was disabled; you can rollback with `wizard restore %s`", backup)
438
439 # utility functions
440
441 def mv_shm_to_tmp(curdir, use_shm):
442     if not use_shm: return curdir
443     # Keeping all of our autoinstalls in shared memory is
444     # a recipe for disaster, so let's move them to slightly
445     # less volatile storage (a temporary directory)
446     os.chdir(tempfile.gettempdir())
447     newdir = tempfile.mkdtemp(prefix="wizard")
448     # shutil, not os; at least on Ubuntu os.move fails
449     # with "[Errno 18] Invalid cross-device link"
450     shutil.move(curdir, newdir)
451     shutil.rmtree(os.path.dirname(curdir))
452     curdir = os.path.join(newdir, "repo")
453     return curdir
454
455 def parse_args(argv, baton):
456     usage = """usage: %prog upgrade [ARGS] [DIR]
457
458 Upgrades an autoinstall to the latest version.  This involves updating
459 files the upgrade script associated with this application.  If the merge
460 fails, this program will write the number of conflicts and the directory
461 of the conflicted working tree to stdout, separated by a space."""
462     parser = command.WizardOptionParser(usage)
463     parser.add_option("--dry-run", dest="dry_run", action="store_true",
464             default=False, help="Prints would would be run without changing anything")
465     # notice trailing underscore
466     parser.add_option("--continue", dest="continue_", action="store_true",
467             default=False, help="Continues an upgrade that has had its merge manually "
468             "resolved using the current working directory as the resolved copy.")
469     parser.add_option("--force", dest="force", action="store_true",
470             default=False, help="Force running upgrade even if it's already at latest version.")
471     parser.add_option("--skip-verification", dest="skip_verification", action="store_true",
472             default=False, help="Skip running configuration and web verification checks.")
473     parser.add_option("--non-interactive", dest="non_interactive", action="store_true",
474             default=False, help="Don't drop to shell in event of conflict.")
475     parser.add_option("--rr-cache", dest="rr_cache", metavar="PATH",
476             default=None, help="Use this folder to reuse recorded merge resolutions.  Defaults to"
477             "your production copy's rr-cache, if it exists.")
478     parser.add_option("--disable-rollback", dest="disable_rollback", action="store_true",
479             default=util.boolish(os.getenv("WIZARD_DISABLE_ROLLBACK")),
480             help="Skips rollback in the event of a failed upgrade. Envvar is WIZARD_DISABLE_ROLLBACK.")
481     baton.push(parser, "srv_path")
482     options, args = parser.parse_all(argv)
483     if len(args) > 1:
484         parser.error("too many arguments")
485     return options, args
486
487 class Error(command.Error):
488     """Base exception for all exceptions raised by upgrade"""
489     pass
490
491 class QuotaTooLow(Error):
492     def __str__(self):
493         return """
494
495 ERROR: The locker quota was too low to complete the autoinstall
496 upgrade.
497 """
498
499 class AlreadyUpgraded(Error):
500     quiet = True
501     def __str__(self):
502         return """
503
504 ERROR: This autoinstall is already at the latest version."""
505
506 class MergeFailed(Error):
507     quiet = True
508     def __str__(self):
509         return """
510
511 ERROR: Merge failed.  Above is the temporary directory that
512 the conflicted merge is in: resolve the merge by cd'ing to the
513 temporary directory, finding conflicted files with `git status`,
514 resolving the files, adding them using `git add` and then
515 running `wizard upgrade --continue`."""
516
517 class LocalChangesError(Error):
518     def __str__(self):
519         return """
520
521 ERROR: Local changes occurred in the install while the merge was
522 being processed so that a pull would not result in a fast-forward.
523 The best way to resolve this is probably to attempt an upgrade again,
524 with git rerere to remember merge resolutions (XXX: not sure if
525 this actually works)."""
526
527 class UnmergedChangesError(Error):
528     def __str__(self):
529         return """
530
531 ERROR: You attempted to continue an upgrade, but there were
532 still local unmerged changes in your working copy.  Please resolve
533 them all and try again."""
534
535 class BlacklistedError(Error):
536     #: Reason why the autoinstall was blacklisted
537     reason = None
538     exitcode = errno_blacklisted
539     def __init__(self, reason):
540         self.reason = reason
541     def __str__(self):
542         return """
543
544 ERROR: This autoinstall was manually blacklisted against errors;
545 if the user has not been notified of this, please send them
546 mail.
547
548 The reason was: %s""" % self.reason
549
550 class CannotResumeError(Error):
551     def __str__(self):
552         return """
553
554 ERROR: We cannot resume the upgrade process; either this working
555 copy is missing essential metadata, or you've attempt to continue
556 from a production copy that does not have any pending upgrades.
557 """
558
559 class VersionRematchFailed(Error):
560     def __str__(self):
561         return """
562
563 ERROR: Your Git version information was not consistent with your
564 files on the system, and we were unable to create a fake merge
565 to make the two consistent."""
566
567 class UnknownVersionError(Error):
568     #: Version that we didn't have
569     version = None
570     def __init__(self, version):
571         self.version = version
572     def __str__(self):
573         return """
574
575 ERROR: The version you are attempting to upgrade from (%s)
576 is unknown to the repository Wizard is using.""" % str(self.version)