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