2 import distutils.version
6 import logging.handlers
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.
15 from wizard import app, command, deploy, merge, shell, user, util
17 buffer = 1024 * 1024 * 30 # 30 MiB we will always leave available
18 errno_blacklisted = 64
20 def main(argv, baton):
21 options, args = parse_args(argv, baton)
22 dir = os.path.abspath(args[0]) if args else os.getcwd()
24 shell.drop_priviledges(dir, options.log_file)
26 upgrade = Upgrade(options)
28 if not options.non_interactive:
29 print "Upgrade complete"
31 class Upgrade(object):
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
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"
43 #: String commit ID of the latest, greatest wizard version; i.e. "theirs"
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.
50 #: The temporary directory containing our working copy for merging
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``.
56 #: Upstream repository to use. This does not need to be saved.
59 #: Instance of :class:`wizard.deploy.WorkingCopy` for this upgrade
61 #: Instance of :class:`wizard.deploy.ProductionCopy` for this upgrade
64 #: Options object that the installer was called with
67 def __init__(self, options):
69 self.user_commit = None
70 self.next_commit = None
72 self.temp_wc_dir = None
73 self.use_shm = False # False until proven otherwise.
76 self.options = options
78 def execute(self, dir):
80 Executes an upgrade. This is the entry-point. This expects
81 that it's current working directory is the same as ``dir``.
83 assert os.path.abspath(dir) == os.getcwd()
85 if self.options.continue_:
86 logging.info("Continuing upgrade...")
89 logging.info("Upgrading %s" % os.getcwd())
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!
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)
100 backup = self.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.
106 if self.use_shm and self.temp_dir and os.path.exists(self.temp_dir):
107 shutil.rmtree(self.temp_dir)
111 In the event of a ``--continue`` flag, we have to restore state and
112 perform some sanity checks.
118 def resumeChdir(self):
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.
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()
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(".")
140 self.user_commit, self.next_commit = open(".git/WIZARD_PARENTS", "r").read().split()
141 self.version = open(".git/WIZARD_UPGRADE_VERSION", "r").read()
143 if e.errno == errno.ENOENT:
144 raise CannotResumeError()
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."""
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()
162 raise LocalChangesError()
163 except shell.CallError:
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)
171 Make sure that a number of pre-upgrade invariants are met before
174 options = self.options
176 self.prod = deploy.ProductionCopy(".")
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()
183 self.prod.verifyDatabase()
184 self.prod.verifyTag(options.srv_path)
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()
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:
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)
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)
218 raise VersionRematchFailed
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
229 raise BlacklistedError(reason)
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")
240 def preflightQuota(self):
244 if limit is not None and (limit - usage) < buffer:
245 logging.info("preflightQuota: limit = %d, usage = %d, buffer = %d", limit, usage, buffer)
249 if not self.options.dry_run:
250 self.mergePreCommit()
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()
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()
274 message += "\nPre-commit-by: " + util.get_operator_git()
275 except util.NoOperatorInfo:
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
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
289 if not self.options.dry_run and not self.options.debug:
290 self.use_shm = os.path.exists("/dev/shm")
292 dir = "/dev/shm/wizard"
293 if not os.path.exists(dir):
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")
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")
326 merge.merge(self.wc.app_version.wizard_tag, self.version,
327 prepare_config, resolve_conflicts)
328 except merge.MergeError:
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)
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)
355 print "ERROR: The merge failed with %d conflicts in these files:" % conflicts
357 for file in sorted(files):
360 print "Please resolve these conflicts (edit and then `git add`), and"
361 print "then type 'exit'. You will now be dropped into a shell whose working"
362 print "directory is %s" % self.temp_wc_dir
364 print "NOTE: If you resolve these conflicts, and then the upgrade fails for"
365 print "an unrelated reason, you can run 'wizard upgrade --continue' from this"
366 print "directory to try again."
368 if shell.eval("git", "ls-files", "--unmerged").strip():
370 print "WARNING: There are still unmerged files."
371 out = raw_input("Continue editing? [y/N]: ")
372 if out == "y" or out == "Y":
375 print "Aborting. The conflicted working copy can be found at:"
377 print " %s" % self.temp_wc_dir
379 print "and you can resume the upgrade process by running in that directory:"
381 print " wizard upgrade --continue"
385 def postflight(self):
386 with util.ChangeDirectory(self.temp_wc_dir):
387 if shell.eval("git", "ls-files", "-u").strip():
388 raise UnmergedChangesError
389 shell.call("git", "commit", "--allow-empty", "-am", "throw-away commit")
390 self.wc.parametrize(self.prod)
391 shell.call("git", "add", ".")
392 message = self.postflightCommitMessage()
393 new_tree = shell.eval("git", "write-tree")
394 final_commit = shell.eval("git", "commit-tree", new_tree,
395 "-p", self.user_commit, "-p", self.next_commit, input=message, log=True)
396 # a master branch may not necessarily exist if the user
397 # was manually installed to an earlier version
399 shell.call("git", "checkout", "-q", "-b", "master", "--")
400 except shell.CallError:
401 shell.call("git", "checkout", "-q", "master", "--")
402 shell.call("git", "reset", "-q", "--hard", final_commit)
403 # This is a quick sanity check to make sure we didn't completely
405 self.wc.invalidateCache()
406 self.wc.verifyVersion()
407 def postflightCommitMessage(self):
408 message = "Upgraded autoinstall to %s.\n\n%s" % (self.version, util.get_git_footer())
410 message += "\nUpgraded-by: " + util.get_operator_git()
411 except util.NoOperatorInfo:
416 # Ok, now we have to do a crazy complicated dance to see if we're
417 # going to have enough quota to finish what we need
418 pre_size = int(open(os.path.join(self.temp_wc_dir, ".git/WIZARD_SIZE"), "r").read())
419 post_size = util.disk_usage(self.temp_wc_dir)
420 backup = self.prod.backup(self.options)
424 if limit is not None and (limit - usage) - (post_size - pre_size) < buffer:
425 shutil.rmtree(os.path.join(self.prod.backup_dir, shell.eval("wizard", "restore").splitlines()[0]))
429 def upgrade(self, backup):
430 # XXX: frob .htaccess to make site inaccessible
431 # XXX: frob Git to disallow Git operations after the pull
432 with util.IgnoreKeyboardInterrupts():
433 with util.LockDirectory(".wizard-upgrade-lock"):
434 shell.call("git", "fetch", "--tags")
435 # git merge (which performs a fast forward)
436 shell.call("git", "pull", "-q", self.temp_wc_dir, "master")
437 version_obj = distutils.version.LooseVersion(self.version.partition('-')[2])
440 self.prod.upgrade(version_obj, self.options)
443 os.unlink(self.prod.pending_file)
445 if e.errno != errno.ENOENT:
447 except app.UpgradeFailure:
448 logging.warning("Upgrade failed: rolling back")
449 self.upgradeRollback(backup)
451 except deploy.WebVerificationError as e:
452 logging.warning("Web verification failed: rolling back")
453 self.upgradeRollback(backup)
454 raise app.UpgradeVerificationFailure()
455 # XXX: frob .htaccess to make site accessible
456 # to do this, check if .htaccess changed, first. Upgrade
457 # process might have frobbed it. Don't be
458 # particularly worried if the segment disappeared
459 def upgradeRollback(self, backup):
460 # You don't want d.restore() because it doesn't perform
461 # the file level backup
462 if not self.options.disable_rollback:
463 shell.call("wizard", "restore", backup)
466 except deploy.WebVerificationError:
467 logging.critical("Web verification failed after rollback")
469 logging.warning("Rollback was disabled; you can rollback with `wizard restore %s`", backup)
472 if not self.options.skip_verification:
473 self.prod.verifyWeb()
478 def mv_shm_to_tmp(curdir, use_shm):
479 if not use_shm: return curdir
480 # Keeping all of our autoinstalls in shared memory is
481 # a recipe for disaster, so let's move them to slightly
482 # less volatile storage (a temporary directory)
483 os.chdir(tempfile.gettempdir())
484 newdir = tempfile.mkdtemp(prefix="wizard")
485 # shutil, not os; at least on Ubuntu os.move fails
486 # with "[Errno 18] Invalid cross-device link"
487 shutil.move(curdir, newdir)
488 shutil.rmtree(os.path.dirname(curdir))
489 curdir = os.path.join(newdir, "repo")
492 def parse_args(argv, baton):
493 usage = """usage: %prog upgrade [ARGS] [DIR]
495 Upgrades an autoinstall to the latest version. This involves updating
496 files the upgrade script associated with this application. If the merge
497 fails, this program will write the number of conflicts and the directory
498 of the conflicted working tree to stdout, separated by a space."""
499 parser = command.WizardOptionParser(usage)
500 parser.add_option("--dry-run", dest="dry_run", action="store_true",
501 default=False, help="Prints would would be run without changing anything")
502 # notice trailing underscore
503 parser.add_option("--continue", dest="continue_", action="store_true",
504 default=False, help="Continues an upgrade that has had its merge manually "
505 "resolved using the current working directory as the resolved copy.")
506 parser.add_option("--force", dest="force", action="store_true",
507 default=False, help="Force running upgrade even if it's already at latest version.")
508 parser.add_option("--skip-verification", dest="skip_verification", action="store_true",
509 default=False, help="Skip running configuration and web verification checks.")
510 parser.add_option("--non-interactive", dest="non_interactive", action="store_true",
511 default=False, help="Don't drop to shell in event of conflict.")
512 parser.add_option("--rr-cache", dest="rr_cache", metavar="PATH",
513 default=None, help="Use this folder to reuse recorded merge resolutions. Defaults to"
514 "your production copy's rr-cache, if it exists.")
515 parser.add_option("--disable-rollback", dest="disable_rollback", action="store_true",
516 default=util.boolish(os.getenv("WIZARD_DISABLE_ROLLBACK")),
517 help="Skips rollback in the event of a failed upgrade. Envvar is WIZARD_DISABLE_ROLLBACK.")
518 baton.push(parser, "srv_path")
519 options, args = parser.parse_all(argv)
521 parser.error("too many arguments")
522 if options.skip_verification:
523 logging.warning("Verification is disabled; Wizard may break your application and will not tell you about it")
526 class Error(command.Error):
527 """Base exception for all exceptions raised by upgrade"""
530 class QuotaTooLow(Error):
534 ERROR: The locker quota was too low to complete the autoinstall
538 class AlreadyUpgraded(Error):
543 ERROR: This autoinstall is already at the latest version."""
545 class MergeFailed(Error):
550 ERROR: Merge failed. Above is the temporary directory that
551 the conflicted merge is in: resolve the merge by cd'ing to the
552 temporary directory, finding conflicted files with `git status`,
553 resolving the files, adding them using `git add` and then
554 running `wizard upgrade --continue`."""
556 class LocalChangesError(Error):
560 ERROR: Local changes occurred in the install while the merge was
561 being processed so that a pull would not result in a fast-forward.
562 The best way to resolve this is probably to attempt an upgrade again,
563 with git rerere to remember merge resolutions (XXX: not sure if
564 this actually works)."""
566 class UnmergedChangesError(Error):
570 ERROR: You attempted to continue an upgrade, but there were
571 still local unmerged changes in your working copy. Please resolve
572 them all and try again."""
574 class BlacklistedError(Error):
575 #: Reason why the autoinstall was blacklisted
577 exitcode = errno_blacklisted
578 def __init__(self, reason):
583 ERROR: This autoinstall was manually blacklisted against errors;
584 if the user has not been notified of this, please send them
585 mail. If you know that this application is blacklisted and
586 would like to attempt an upgrade anyway, run:
588 wizard blacklist --delete
590 The reason was: %s""" % self.reason
592 class CannotResumeError(Error):
596 ERROR: We cannot resume the upgrade process; either this working
597 copy is missing essential metadata, or you've attempt to continue
598 from a production copy that does not have any pending upgrades.
601 class VersionRematchFailed(Error):
605 ERROR: Your Git version information was not consistent with your
606 files on the system, and we were unable to create a fake merge
607 to make the two consistent."""
609 class UnknownVersionError(Error):
610 #: Version that we didn't have
612 def __init__(self, version):
613 self.version = version
617 ERROR: The version you are attempting to upgrade from (%s)
618 is unknown to the repository Wizard is using.""" % str(self.version)
620 class UpgradeInProgressError(Error):
621 #: Location of pending upgrade
623 #: Time of pending upgrade
625 def __init__(self, location, time):
626 self.location = location
631 ERROR: There is already an upgrade in progress at
635 which was last started at %s.
637 To ignore and start another upgrade anyway, remove the file
638 .wizard/pending and try again.""" % (self.location, time.ctime(self.time))