cfgupgrade: Add --downgrade option
[ganeti-local] / tools / cfgupgrade
1 #!/usr/bin/python
2 #
3
4 # Copyright (C) 2007, 2008, 2009, 2010, 2011, 2012, 2013 Google Inc.
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19 # 02110-1301, USA.
20
21
22 """Tool to upgrade the configuration file.
23
24 This code handles only the types supported by simplejson. As an
25 example, 'set' is a 'list'.
26
27 """
28
29
30 import os
31 import os.path
32 import sys
33 import optparse
34 import logging
35 import time
36 from cStringIO import StringIO
37
38 from ganeti import constants
39 from ganeti import serializer
40 from ganeti import utils
41 from ganeti import cli
42 from ganeti import bootstrap
43 from ganeti import config
44 from ganeti import netutils
45 from ganeti import pathutils
46
47
48 options = None
49 args = None
50
51
52 #: Target major version we will upgrade to
53 TARGET_MAJOR = 2
54 #: Target minor version we will upgrade to
55 TARGET_MINOR = 7
56 #: Target major version for downgrade
57 DOWNGRADE_MAJOR = 2
58 #: Target minor version for downgrade
59 DOWNGRADE_MINOR = 7
60
61
62 class Error(Exception):
63   """Generic exception"""
64   pass
65
66
67 def SetupLogging():
68   """Configures the logging module.
69
70   """
71   formatter = logging.Formatter("%(asctime)s: %(message)s")
72
73   stderr_handler = logging.StreamHandler()
74   stderr_handler.setFormatter(formatter)
75   if options.debug:
76     stderr_handler.setLevel(logging.NOTSET)
77   elif options.verbose:
78     stderr_handler.setLevel(logging.INFO)
79   else:
80     stderr_handler.setLevel(logging.WARNING)
81
82   root_logger = logging.getLogger("")
83   root_logger.setLevel(logging.NOTSET)
84   root_logger.addHandler(stderr_handler)
85
86
87 def CheckHostname(path):
88   """Ensures hostname matches ssconf value.
89
90   @param path: Path to ssconf file
91
92   """
93   ssconf_master_node = utils.ReadOneLineFile(path)
94   hostname = netutils.GetHostname().name
95
96   if ssconf_master_node == hostname:
97     return True
98
99   logging.warning("Warning: ssconf says master node is '%s', but this"
100                   " machine's name is '%s'; this tool must be run on"
101                   " the master node", ssconf_master_node, hostname)
102   return False
103
104
105 def UpgradeNetworks(config_data):
106   networks = config_data.get("networks", None)
107   if not networks:
108     config_data["networks"] = {}
109
110
111 def UpgradeGroups(config_data):
112   for group in config_data["nodegroups"].values():
113     networks = group.get("networks", None)
114     if not networks:
115       group["networks"] = {}
116
117
118 def UpgradeInstances(config_data):
119   network2uuid = dict((n["name"], n["uuid"])
120                       for n in config_data["networks"].values())
121   if "instances" not in config_data:
122     raise Error("Can't find the 'instances' key in the configuration!")
123
124   for instance, iobj in config_data["instances"].items():
125     for nic in iobj["nics"]:
126       name = nic.get("network", None)
127       if name:
128         uuid = network2uuid.get(name, None)
129         if uuid:
130           print("NIC with network name %s found."
131                 " Substituting with uuid %s." % (name, uuid))
132           nic["network"] = uuid
133
134     if "disks" not in iobj:
135       raise Error("Instance '%s' doesn't have a disks entry?!" % instance)
136     disks = iobj["disks"]
137     for idx, dobj in enumerate(disks):
138       expected = "disk/%s" % idx
139       current = dobj.get("iv_name", "")
140       if current != expected:
141         logging.warning("Updating iv_name for instance %s/disk %s"
142                         " from '%s' to '%s'",
143                         instance, idx, current, expected)
144         dobj["iv_name"] = expected
145
146
147 def UpgradeRapiUsers():
148   if (os.path.isfile(options.RAPI_USERS_FILE_PRE24) and
149       not os.path.islink(options.RAPI_USERS_FILE_PRE24)):
150     if os.path.exists(options.RAPI_USERS_FILE):
151       raise Error("Found pre-2.4 RAPI users file at %s, but another file"
152                   " already exists at %s" %
153                   (options.RAPI_USERS_FILE_PRE24, options.RAPI_USERS_FILE))
154     logging.info("Found pre-2.4 RAPI users file at %s, renaming to %s",
155                  options.RAPI_USERS_FILE_PRE24, options.RAPI_USERS_FILE)
156     if not options.dry_run:
157       utils.RenameFile(options.RAPI_USERS_FILE_PRE24, options.RAPI_USERS_FILE,
158                        mkdir=True, mkdir_mode=0750)
159
160   # Create a symlink for RAPI users file
161   if (not (os.path.islink(options.RAPI_USERS_FILE_PRE24) or
162            os.path.isfile(options.RAPI_USERS_FILE_PRE24)) and
163       os.path.isfile(options.RAPI_USERS_FILE)):
164     logging.info("Creating symlink from %s to %s",
165                  options.RAPI_USERS_FILE_PRE24, options.RAPI_USERS_FILE)
166     if not options.dry_run:
167       os.symlink(options.RAPI_USERS_FILE, options.RAPI_USERS_FILE_PRE24)
168
169
170 def UpgradeWatcher():
171   # Remove old watcher state file if it exists
172   if os.path.exists(options.WATCHER_STATEFILE):
173     logging.info("Removing watcher state file %s", options.WATCHER_STATEFILE)
174     if not options.dry_run:
175       utils.RemoveFile(options.WATCHER_STATEFILE)
176
177
178 def UpgradeFileStoragePaths(config_data):
179   # Write file storage paths
180   if not os.path.exists(options.FILE_STORAGE_PATHS_FILE):
181     cluster = config_data["cluster"]
182     file_storage_dir = cluster.get("file_storage_dir")
183     shared_file_storage_dir = cluster.get("shared_file_storage_dir")
184     del cluster
185
186     logging.info("Ganeti 2.7 and later only allow whitelisted directories"
187                  " for file storage; writing existing configuration values"
188                  " into '%s'",
189                  options.FILE_STORAGE_PATHS_FILE)
190
191     if file_storage_dir:
192       logging.info("File storage directory: %s", file_storage_dir)
193     if shared_file_storage_dir:
194       logging.info("Shared file storage directory: %s",
195                    shared_file_storage_dir)
196
197     buf = StringIO()
198     buf.write("# List automatically generated from configuration by\n")
199     buf.write("# cfgupgrade at %s\n" % time.asctime())
200     if file_storage_dir:
201       buf.write("%s\n" % file_storage_dir)
202     if shared_file_storage_dir:
203       buf.write("%s\n" % shared_file_storage_dir)
204     utils.WriteFile(file_name=options.FILE_STORAGE_PATHS_FILE,
205                     data=buf.getvalue(),
206                     mode=0600,
207                     dry_run=options.dry_run,
208                     backup=True)
209
210
211 def UpgradeAll(config_data):
212   config_data["version"] = constants.BuildVersion(TARGET_MAJOR,
213                                                   TARGET_MINOR, 0)
214   UpgradeRapiUsers()
215   UpgradeWatcher()
216   UpgradeFileStoragePaths(config_data)
217   UpgradeNetworks(config_data)
218   UpgradeGroups(config_data)
219   UpgradeInstances(config_data)
220
221
222 def DowngradeStorageTypes(cluster):
223   # Remove storage types to downgrade to 2.7
224   if "enabled_storage_types" in cluster:
225     logging.warning("Removing cluster storage types; value = %s",
226                     utils.CommaJoin(cluster["enabled_storage_types"]))
227     del cluster["enabled_storage_types"]
228
229
230 def DowngradeCluster(config_data):
231   cluster = config_data.get("cluster", None)
232   if cluster is None:
233     raise Error("Cannot find cluster")
234   DowngradeStorageTypes(cluster)
235
236
237 def DowngradeAll(config_data):
238   # Any code specific to a particular version should be labeled that way, so
239   # it can be removed when updating to the next version.
240   DowngradeCluster(config_data)
241
242
243 def main():
244   """Main program.
245
246   """
247   global options, args # pylint: disable=W0603
248
249   # Option parsing
250   parser = optparse.OptionParser(usage="%prog [--debug|--verbose] [--force]")
251   parser.add_option("--dry-run", dest="dry_run",
252                     action="store_true",
253                     help="Try to do the conversion, but don't write"
254                          " output file")
255   parser.add_option(cli.FORCE_OPT)
256   parser.add_option(cli.DEBUG_OPT)
257   parser.add_option(cli.VERBOSE_OPT)
258   parser.add_option("--ignore-hostname", dest="ignore_hostname",
259                     action="store_true", default=False,
260                     help="Don't abort if hostname doesn't match")
261   parser.add_option("--path", help="Convert configuration in this"
262                     " directory instead of '%s'" % pathutils.DATA_DIR,
263                     default=pathutils.DATA_DIR, dest="data_dir")
264   parser.add_option("--confdir",
265                     help=("Use this directory instead of '%s'" %
266                           pathutils.CONF_DIR),
267                     default=pathutils.CONF_DIR, dest="conf_dir")
268   parser.add_option("--no-verify",
269                     help="Do not verify configuration after upgrade",
270                     action="store_true", dest="no_verify", default=False)
271   parser.add_option("--downgrade",
272                     help="Downgrade to the previous stable version",
273                     action="store_true", dest="downgrade", default=False)
274   (options, args) = parser.parse_args()
275
276   # We need to keep filenames locally because they might be renamed between
277   # versions.
278   options.data_dir = os.path.abspath(options.data_dir)
279   options.CONFIG_DATA_PATH = options.data_dir + "/config.data"
280   options.SERVER_PEM_PATH = options.data_dir + "/server.pem"
281   options.KNOWN_HOSTS_PATH = options.data_dir + "/known_hosts"
282   options.RAPI_CERT_FILE = options.data_dir + "/rapi.pem"
283   options.SPICE_CERT_FILE = options.data_dir + "/spice.pem"
284   options.SPICE_CACERT_FILE = options.data_dir + "/spice-ca.pem"
285   options.RAPI_USERS_FILE = options.data_dir + "/rapi/users"
286   options.RAPI_USERS_FILE_PRE24 = options.data_dir + "/rapi_users"
287   options.CONFD_HMAC_KEY = options.data_dir + "/hmac.key"
288   options.CDS_FILE = options.data_dir + "/cluster-domain-secret"
289   options.SSCONF_MASTER_NODE = options.data_dir + "/ssconf_master_node"
290   options.WATCHER_STATEFILE = options.data_dir + "/watcher.data"
291   options.FILE_STORAGE_PATHS_FILE = options.conf_dir + "/file-storage-paths"
292
293   SetupLogging()
294
295   # Option checking
296   if args:
297     raise Error("No arguments expected")
298   if options.downgrade and not options.no_verify:
299     options.no_verify = True
300
301   # Check master name
302   if not (CheckHostname(options.SSCONF_MASTER_NODE) or options.ignore_hostname):
303     logging.error("Aborting due to hostname mismatch")
304     sys.exit(constants.EXIT_FAILURE)
305
306   if not options.force:
307     if options.downgrade:
308       usertext = ("The configuration is going to be DOWNGRADED to version %s.%s"
309                   " Some configuration data might be removed if they don't fit"
310                   " in the old format. Please make sure you have read the"
311                   " upgrade notes (available in the UPGRADE file and included"
312                   " in other documentation formats) to understand what they"
313                   " are. Continue with *DOWNGRADING* the configuration?" %
314                   (DOWNGRADE_MAJOR, DOWNGRADE_MINOR))
315     else:
316       usertext = ("Please make sure you have read the upgrade notes for"
317                   " Ganeti %s (available in the UPGRADE file and included"
318                   " in other documentation formats). Continue with upgrading"
319                   " configuration?" % constants.RELEASE_VERSION)
320     if not cli.AskUser(usertext):
321       sys.exit(constants.EXIT_FAILURE)
322
323   # Check whether it's a Ganeti configuration directory
324   if not (os.path.isfile(options.CONFIG_DATA_PATH) and
325           os.path.isfile(options.SERVER_PEM_PATH) and
326           os.path.isfile(options.KNOWN_HOSTS_PATH)):
327     raise Error(("%s does not seem to be a Ganeti configuration"
328                  " directory") % options.data_dir)
329
330   if not os.path.isdir(options.conf_dir):
331     raise Error("Not a directory: %s" % options.conf_dir)
332
333   config_data = serializer.LoadJson(utils.ReadFile(options.CONFIG_DATA_PATH))
334
335   try:
336     config_version = config_data["version"]
337   except KeyError:
338     raise Error("Unable to determine configuration version")
339
340   (config_major, config_minor, config_revision) = \
341     constants.SplitVersion(config_version)
342
343   logging.info("Found configuration version %s (%d.%d.%d)",
344                config_version, config_major, config_minor, config_revision)
345
346   if "config_version" in config_data["cluster"]:
347     raise Error("Inconsistent configuration: found config_version in"
348                 " configuration file")
349
350   # Downgrade to the previous stable version
351   if options.downgrade:
352     if config_major != TARGET_MAJOR or config_minor != TARGET_MINOR:
353       raise Error("Downgrade supported only from the latest version (%s.%s),"
354                   " found %s (%s.%s.%s) instead" %
355                   (TARGET_MAJOR, TARGET_MINOR, config_version, config_major,
356                    config_minor, config_revision))
357     DowngradeAll(config_data)
358
359   # Upgrade from 2.{0..6} to 2.7
360   elif config_major == 2 and config_minor in (0, 1, 2, 3, 4, 5, 6):
361     if config_revision != 0:
362       logging.warning("Config revision is %s, not 0", config_revision)
363     UpgradeAll(config_data)
364
365   elif config_major == TARGET_MAJOR and config_minor == TARGET_MINOR:
366     logging.info("No changes necessary")
367
368   else:
369     raise Error("Configuration version %d.%d.%d not supported by this tool" %
370                 (config_major, config_minor, config_revision))
371
372   try:
373     logging.info("Writing configuration file to %s", options.CONFIG_DATA_PATH)
374     utils.WriteFile(file_name=options.CONFIG_DATA_PATH,
375                     data=serializer.DumpJson(config_data),
376                     mode=0600,
377                     dry_run=options.dry_run,
378                     backup=True)
379
380     if not options.dry_run:
381       bootstrap.GenerateClusterCrypto(
382         False, False, False, False, False,
383         nodecert_file=options.SERVER_PEM_PATH,
384         rapicert_file=options.RAPI_CERT_FILE,
385         spicecert_file=options.SPICE_CERT_FILE,
386         spicecacert_file=options.SPICE_CACERT_FILE,
387         hmackey_file=options.CONFD_HMAC_KEY,
388         cds_file=options.CDS_FILE)
389
390   except Exception:
391     logging.critical("Writing configuration failed. It is probably in an"
392                      " inconsistent state and needs manual intervention.")
393     raise
394
395   # test loading the config file
396   all_ok = True
397   if not (options.dry_run or options.no_verify):
398     logging.info("Testing the new config file...")
399     cfg = config.ConfigWriter(cfg_file=options.CONFIG_DATA_PATH,
400                               accept_foreign=options.ignore_hostname,
401                               offline=True)
402     # if we reached this, it's all fine
403     vrfy = cfg.VerifyConfig()
404     if vrfy:
405       logging.error("Errors after conversion:")
406       for item in vrfy:
407         logging.error(" - %s", item)
408       all_ok = False
409     else:
410       logging.info("File loaded successfully after upgrading")
411     del cfg
412
413   if options.downgrade:
414     action = "downgraded"
415     out_ver = "%s.%s" % (DOWNGRADE_MAJOR, DOWNGRADE_MINOR)
416   else:
417     action = "upgraded"
418     out_ver = constants.RELEASE_VERSION
419   if all_ok:
420     cli.ToStderr("Configuration successfully %s to version %s.",
421                  action, out_ver)
422   else:
423     cli.ToStderr("Configuration %s to version %s, but there are errors."
424                  "\nPlease review the file.", action, out_ver)
425
426
427 if __name__ == "__main__":
428   main()