]> scripts.mit.edu Git - wizard.git/blob - wizard/command/upgrade.py
ed8c8ce645632a8b9e4ae7efee5dc5489d3df826
[wizard.git] / wizard / command / upgrade.py
1 import optparse
2 import sys
3 import distutils.version
4 import os
5 import shutil
6 import logging.handlers
7 import errno
8 import tempfile
9 import itertools
10
11 from wizard import app, command, deploy, shell, util
12
13 def main(argv, baton):
14     options, args = parse_args(argv, baton)
15     if args:
16         dir = args[0]
17     else:
18         dir = "."
19     shell.drop_priviledges(dir, options.log_file)
20     util.chdir(dir)
21     if os.path.exists(".scripts/blacklisted"):
22         raise BlacklistedError()
23     sh = shell.Shell()
24     util.set_git_env()
25     if options.continue_:
26         temp_wc_dir = os.getcwd()
27         user_commit, next_commit = open(".git/WIZARD_PARENTS", "r").read().split()
28         repo = open(".git/WIZARD_REPO", "r").read()
29         version = open(".git/WIZARD_UPGRADE_VERSION", "r").read()
30         util.chdir(sh.eval("git", "config", "remote.origin.url"))
31         d = deploy.Deployment(".")
32         try:
33             sh.call("git", "status")
34             raise LocalChangesError()
35         except shell.CallError:
36             pass
37     else:
38         d = deploy.Deployment(".")
39         d.verify()
40         d.verifyTag(options.srv_path)
41         d.verifyGit(options.srv_path)
42         d.verifyConfigured()
43         d.verifyVersion()
44         if not options.dry_run:
45             d.verifyWeb()
46         repo = d.application.repository(options.srv_path)
47         version = calculate_newest_version(sh, repo)
48         if version == d.app_version.scripts_tag and not options.force:
49             raise AlreadyUpgraded
50         if not options.dry_run:
51             perform_pre_commit(sh)
52         temp_wc_dir = perform_tmp_clone(sh)
53         with util.ChangeDirectory(temp_wc_dir):
54             sh.call("git", "remote", "add", "scripts", repo)
55             sh.call("git", "fetch", "scripts")
56             user_commit, next_commit = calculate_parents(sh, version)
57             # save variables so that --continue will work
58             # yeah yeah no trailing newline whatever
59             open(".git/WIZARD_REPO", "w").write(repo)
60             open(".git/WIZARD_UPGRADE_VERSION", "w").write(version)
61             open(".git/WIZARD_PARENTS", "w").write("%s\n%s" % (user_commit, next_commit))
62             perform_merge(sh, repo, d, version)
63     # variables: version, user_commit, next_commit, temp_wc_dir
64     with util.ChangeDirectory(temp_wc_dir):
65         try:
66             sh.call("git", "status")
67             sh.call("git", "commit", "-m", "throw-away commit")
68         except shell.CallError:
69             pass
70         message = make_commit_message(version)
71         new_tree = sh.eval("git", "rev-parse", "HEAD^{tree}")
72         final_commit = sh.eval("git", "commit-tree", new_tree,
73                 "-p", user_commit, "-p", next_commit, input=message, log=True)
74         # a master branch may not necessarily exist if the user
75         # was manually installed to an earlier version
76         try:
77             sh.call("git", "checkout", "-b", "master", "--")
78         except shell.CallError:
79             sh.call("git", "checkout", "master", "--")
80         sh.call("git", "reset", "--hard", final_commit)
81         d.verifyVersion()
82     # Till now, all of our operations were in a tmp sandbox.
83     if options.dry_run:
84         logging.info("Dry run, bailing.  See results at %s" % temp_wc_dir)
85         return
86     # perform database backup
87     backup = d.backup(options)
88     # XXX: frob .htaccess to make site inaccessible
89     with util.IgnoreKeyboardInterrupts():
90         with util.LockDirectory(".scripts-upgrade-lock"):
91             # git merge (which performs a fast forward)
92             sh.call("git", "pull", temp_wc_dir, "master")
93             version_obj = distutils.version.LooseVersion(version.partition('-')[2])
94             try:
95                 # run update script
96                 d.upgrade(version_obj, options)
97                 d.verifyWeb()
98             except app.UpgradeFailure:
99                 logging.warning("Upgrade failed: rolling back")
100                 perform_restore(d, backup)
101                 raise
102             except deploy.WebVerificationError as e:
103                 logging.warning("Web verification failed: rolling back")
104                 logging.info("Web page that was output was:\n\n%s" % e.contents)
105                 perform_restore(d, backup)
106                 raise app.UpgradeVerificationFailure("Upgrade caused website to become inaccessible; site was rolled back")
107     # XXX: frob .htaccess to make site accessible
108     #       to do this, check if .htaccess changed, first.  Upgrade
109     #       process might have frobbed it.  Don't be
110     #       particularly worried if the segment disappeared
111
112 def perform_restore(d, backup):
113     # You don't want d.restore() because it doesn't perform
114     # the file level backup
115     shell.Shell().call("wizard", "restore", backup)
116     try:
117         d.verifyWeb()
118     except deploy.WebVerificationError:
119         logging.critical("Web verification failed after rollback")
120
121 def make_commit_message(version):
122     message = "Upgraded autoinstall in %s to %s.\n\n%s" % (util.get_dir_owner(), version, util.get_git_footer())
123     try:
124         message += "\nUpgraded-by: " + util.get_operator_git()
125     except util.NoOperatorInfo:
126         pass
127     return message
128
129 def calculate_newest_version(sh, repo):
130     # XXX: put this in Application
131     return sh.eval("git", "--git-dir="+repo, "describe", "--tags", "master")
132
133 def calculate_parents(sh, version):
134     user_commit = sh.eval("git", "rev-parse", "HEAD")
135     next_commit = sh.eval("git", "rev-parse", version)
136     return user_commit, next_commit
137
138 def perform_pre_commit(sh):
139     message = "Pre-commit of %s locker before autoinstall upgrade.\n\n%s" % (util.get_dir_owner(), util.get_git_footer())
140     try:
141         message += "\nPre-commit-by: " + util.get_operator_git()
142     except util.NoOperatorInfo:
143         pass
144     try:
145         sh.call("git", "commit", "-a", "-m", message)
146     except shell.CallError:
147         logging.info("No changes detected")
148
149 def perform_tmp_clone(sh):
150     temp_dir = tempfile.mkdtemp(prefix="wizard")
151     temp_wc_dir = os.path.join(temp_dir, "repo")
152     logging.info("Using temporary directory: " + temp_wc_dir)
153     sh.call("git", "clone", "--shared", ".", temp_wc_dir)
154     return temp_wc_dir
155
156 def perform_merge(sh, repo, d, version):
157     # naive merge algorithm:
158     # sh.call("git", "merge", "-m", message, "scripts/master")
159     # crazy merge algorithm:
160     def make_virtual_commit(tag, parents = []):
161         """WARNING: Changes state of Git repository"""
162         sh.call("git", "checkout", tag, "--")
163         d.parametrize(".")
164         for file in d.application.parametrized_files:
165             try:
166                 sh.call("git", "add", "--", file)
167             except shell.CallError:
168                 pass
169         virtual_tree = sh.eval("git", "write-tree", log=True)
170         parent_args = itertools.chain(*(["-p", p] for p in parents))
171         virtual_commit = sh.eval("git", "commit-tree", virtual_tree,
172                 *parent_args, input="", log=True)
173         sh.call("git", "reset", "--hard")
174         return virtual_commit
175     user_tree = sh.eval("git", "rev-parse", "HEAD^{tree}")
176     base_virtual_commit = make_virtual_commit(d.app_version.scripts_tag)
177     next_virtual_commit = make_virtual_commit(version, [base_virtual_commit])
178     user_virtual_commit = sh.eval("git", "commit-tree", user_tree,
179             "-p", base_virtual_commit, input="", log=True)
180     sh.call("git", "checkout", user_virtual_commit, "--")
181     try:
182         sh.call("git", "merge", next_virtual_commit)
183     except shell.CallError:
184         print os.getcwd()
185         logging.info("Conflict info:\n", sh.eval("git", "diff"))
186         raise MergeFailed
187
188 def parse_args(argv, baton):
189     usage = """usage: %prog upgrade [ARGS] [DIR]
190
191 Upgrades an autoinstall to the latest version.  This involves
192 updating files and running .scripts/update.
193
194 WARNING: This is still experimental."""
195     parser = command.WizardOptionParser(usage)
196     parser.add_option("--dry-run", dest="dry_run", action="store_true",
197             default=False, help="Prints would would be run without changing anything")
198     # notice trailing underscore
199     parser.add_option("--continue", dest="continue_", action="store_true",
200             default=False, help="Continues an upgrade that has had its merge manually "
201             "resolved using the current working directory as the resolved copy.")
202     parser.add_option("--force", dest="force", action="store_true",
203             default=False, help="Force running upgrade even if it's already at latest version.")
204     baton.push(parser, "srv_path")
205     options, args = parser.parse_all(argv)
206     if len(args) > 1:
207         parser.error("too many arguments")
208     return options, args
209
210 class Error(command.Error):
211     """Base exception for all exceptions raised by upgrade"""
212     pass
213
214 class AlreadyUpgraded(Error):
215     def __str__(self):
216         return """
217
218 ERROR: This autoinstall is already at the latest version."""
219
220 class MergeFailed(Error):
221     def __str__(self):
222         return """
223
224 ERROR: Merge failed.  Resolve the merge by cd'ing to the
225 temporary directory, finding conflicted files with `git status`,
226 resolving the files, adding them using `git add` and then
227 running `wizard upgrade --continue`."""
228
229 class LocalChangesError(Error):
230     def __str__(self):
231         return """
232
233 ERROR: Local changes occurred in the install while the merge was
234 being processed so that a pull would not result in a fast-forward.
235 The best way to resolve this is probably to attempt an upgrade again,
236 with git rerere to remember merge resolutions (XXX: not sure if
237 this actually works)."""
238
239 class BlacklistedError(Error):
240     def __str__(self):
241         return """
242
243 ERROR: This autoinstall was manually blacklisted against errors;
244 if the user has not been notified of this, please send them
245 mail."""