Preserve device names during instance move
[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 UpgradeIPolicy(ipolicy):
106   minmax_keys = ["min", "max"]
107   if any((k in ipolicy) for k in minmax_keys):
108     minmax = {}
109     ipolicy["minmax"] = minmax
110     for key in minmax_keys:
111       if key in ipolicy:
112         minmax[key] = ipolicy[key]
113         del ipolicy[key]
114       else:
115         minmax[key] = {}
116
117
118 def UpgradeNetworks(config_data):
119   networks = config_data.get("networks", None)
120   if not networks:
121     config_data["networks"] = {}
122
123
124 def UpgradeCluster(config_data):
125   cluster = config_data.get("cluster", None)
126   if cluster is None:
127     raise Error("Cannot find cluster")
128   ipolicy = cluster.get("ipolicy", None)
129   if ipolicy:
130     UpgradeIPolicy(ipolicy)
131
132
133 def UpgradeGroups(config_data):
134   for group in config_data["nodegroups"].values():
135     networks = group.get("networks", None)
136     if not networks:
137       group["networks"] = {}
138     ipolicy = group.get("ipolicy", None)
139     if ipolicy:
140       UpgradeIPolicy(ipolicy)
141
142
143 def UpgradeInstances(config_data):
144   network2uuid = dict((n["name"], n["uuid"])
145                       for n in config_data["networks"].values())
146   if "instances" not in config_data:
147     raise Error("Can't find the 'instances' key in the configuration!")
148
149   for instance, iobj in config_data["instances"].items():
150     for nic in iobj["nics"]:
151       name = nic.get("network", None)
152       if name:
153         uuid = network2uuid.get(name, None)
154         if uuid:
155           print("NIC with network name %s found."
156                 " Substituting with uuid %s." % (name, uuid))
157           nic["network"] = uuid
158
159     if "disks" not in iobj:
160       raise Error("Instance '%s' doesn't have a disks entry?!" % instance)
161     disks = iobj["disks"]
162     for idx, dobj in enumerate(disks):
163       expected = "disk/%s" % idx
164       current = dobj.get("iv_name", "")
165       if current != expected:
166         logging.warning("Updating iv_name for instance %s/disk %s"
167                         " from '%s' to '%s'",
168                         instance, idx, current, expected)
169         dobj["iv_name"] = expected
170
171
172 def UpgradeRapiUsers():
173   if (os.path.isfile(options.RAPI_USERS_FILE_PRE24) and
174       not os.path.islink(options.RAPI_USERS_FILE_PRE24)):
175     if os.path.exists(options.RAPI_USERS_FILE):
176       raise Error("Found pre-2.4 RAPI users file at %s, but another file"
177                   " already exists at %s" %
178                   (options.RAPI_USERS_FILE_PRE24, options.RAPI_USERS_FILE))
179     logging.info("Found pre-2.4 RAPI users file at %s, renaming to %s",
180                  options.RAPI_USERS_FILE_PRE24, options.RAPI_USERS_FILE)
181     if not options.dry_run:
182       utils.RenameFile(options.RAPI_USERS_FILE_PRE24, options.RAPI_USERS_FILE,
183                        mkdir=True, mkdir_mode=0750)
184
185   # Create a symlink for RAPI users file
186   if (not (os.path.islink(options.RAPI_USERS_FILE_PRE24) or
187            os.path.isfile(options.RAPI_USERS_FILE_PRE24)) and
188       os.path.isfile(options.RAPI_USERS_FILE)):
189     logging.info("Creating symlink from %s to %s",
190                  options.RAPI_USERS_FILE_PRE24, options.RAPI_USERS_FILE)
191     if not options.dry_run:
192       os.symlink(options.RAPI_USERS_FILE, options.RAPI_USERS_FILE_PRE24)
193
194
195 def UpgradeWatcher():
196   # Remove old watcher state file if it exists
197   if os.path.exists(options.WATCHER_STATEFILE):
198     logging.info("Removing watcher state file %s", options.WATCHER_STATEFILE)
199     if not options.dry_run:
200       utils.RemoveFile(options.WATCHER_STATEFILE)
201
202
203 def UpgradeFileStoragePaths(config_data):
204   # Write file storage paths
205   if not os.path.exists(options.FILE_STORAGE_PATHS_FILE):
206     cluster = config_data["cluster"]
207     file_storage_dir = cluster.get("file_storage_dir")
208     shared_file_storage_dir = cluster.get("shared_file_storage_dir")
209     del cluster
210
211     logging.info("Ganeti 2.7 and later only allow whitelisted directories"
212                  " for file storage; writing existing configuration values"
213                  " into '%s'",
214                  options.FILE_STORAGE_PATHS_FILE)
215
216     if file_storage_dir:
217       logging.info("File storage directory: %s", file_storage_dir)
218     if shared_file_storage_dir:
219       logging.info("Shared file storage directory: %s",
220                    shared_file_storage_dir)
221
222     buf = StringIO()
223     buf.write("# List automatically generated from configuration by\n")
224     buf.write("# cfgupgrade at %s\n" % time.asctime())
225     if file_storage_dir:
226       buf.write("%s\n" % file_storage_dir)
227     if shared_file_storage_dir:
228       buf.write("%s\n" % shared_file_storage_dir)
229     utils.WriteFile(file_name=options.FILE_STORAGE_PATHS_FILE,
230                     data=buf.getvalue(),
231                     mode=0600,
232                     dry_run=options.dry_run,
233                     backup=True)
234
235
236 def UpgradeAll(config_data):
237   config_data["version"] = constants.BuildVersion(TARGET_MAJOR,
238                                                   TARGET_MINOR, 0)
239   UpgradeRapiUsers()
240   UpgradeWatcher()
241   UpgradeFileStoragePaths(config_data)
242   UpgradeNetworks(config_data)
243   UpgradeCluster(config_data)
244   UpgradeGroups(config_data)
245   UpgradeInstances(config_data)
246
247
248 def DowngradeIPolicy(ipolicy):
249   # Downgrade IPolicy to 2.7 (stable)
250   minmax_keys = ["min", "max"]
251   specs_is_split = any((k in ipolicy) for k in minmax_keys)
252   if not specs_is_split:
253     if "minmax" in ipolicy:
254       minmax = ipolicy["minmax"]
255       del ipolicy["minmax"]
256     else:
257       minmax = {}
258     for key in minmax_keys:
259       spec = minmax.get(key, {})
260       ipolicy[key] = spec
261
262
263 def DowngradeGroups(config_data):
264   for group in config_data["nodegroups"].values():
265     ipolicy = group.get("ipolicy", None)
266     if ipolicy:
267       DowngradeIPolicy(ipolicy)
268
269
270 def DowngradeStorageTypes(cluster):
271   # Remove storage types to downgrade to 2.7
272   if "enabled_storage_types" in cluster:
273     logging.warning("Removing cluster storage types; value = %s",
274                     utils.CommaJoin(cluster["enabled_storage_types"]))
275     del cluster["enabled_storage_types"]
276
277
278 def DowngradeCluster(config_data):
279   cluster = config_data.get("cluster", None)
280   if cluster is None:
281     raise Error("Cannot find cluster")
282   DowngradeStorageTypes(cluster)
283   ipolicy = cluster.get("ipolicy", None)
284   if ipolicy:
285     DowngradeIPolicy(ipolicy)
286
287
288 def DowngradeAll(config_data):
289   # Any code specific to a particular version should be labeled that way, so
290   # it can be removed when updating to the next version.
291   DowngradeCluster(config_data)
292   DowngradeGroups(config_data)
293
294
295 def main():
296   """Main program.
297
298   """
299   global options, args # pylint: disable=W0603
300
301   # Option parsing
302   parser = optparse.OptionParser(usage="%prog [--debug|--verbose] [--force]")
303   parser.add_option("--dry-run", dest="dry_run",
304                     action="store_true",
305                     help="Try to do the conversion, but don't write"
306                          " output file")
307   parser.add_option(cli.FORCE_OPT)
308   parser.add_option(cli.DEBUG_OPT)
309   parser.add_option(cli.VERBOSE_OPT)
310   parser.add_option("--ignore-hostname", dest="ignore_hostname",
311                     action="store_true", default=False,
312                     help="Don't abort if hostname doesn't match")
313   parser.add_option("--path", help="Convert configuration in this"
314                     " directory instead of '%s'" % pathutils.DATA_DIR,
315                     default=pathutils.DATA_DIR, dest="data_dir")
316   parser.add_option("--confdir",
317                     help=("Use this directory instead of '%s'" %
318                           pathutils.CONF_DIR),
319                     default=pathutils.CONF_DIR, dest="conf_dir")
320   parser.add_option("--no-verify",
321                     help="Do not verify configuration after upgrade",
322                     action="store_true", dest="no_verify", default=False)
323   parser.add_option("--downgrade",
324                     help="Downgrade to the previous stable version",
325                     action="store_true", dest="downgrade", default=False)
326   (options, args) = parser.parse_args()
327
328   # We need to keep filenames locally because they might be renamed between
329   # versions.
330   options.data_dir = os.path.abspath(options.data_dir)
331   options.CONFIG_DATA_PATH = options.data_dir + "/config.data"
332   options.SERVER_PEM_PATH = options.data_dir + "/server.pem"
333   options.KNOWN_HOSTS_PATH = options.data_dir + "/known_hosts"
334   options.RAPI_CERT_FILE = options.data_dir + "/rapi.pem"
335   options.SPICE_CERT_FILE = options.data_dir + "/spice.pem"
336   options.SPICE_CACERT_FILE = options.data_dir + "/spice-ca.pem"
337   options.RAPI_USERS_FILE = options.data_dir + "/rapi/users"
338   options.RAPI_USERS_FILE_PRE24 = options.data_dir + "/rapi_users"
339   options.CONFD_HMAC_KEY = options.data_dir + "/hmac.key"
340   options.CDS_FILE = options.data_dir + "/cluster-domain-secret"
341   options.SSCONF_MASTER_NODE = options.data_dir + "/ssconf_master_node"
342   options.WATCHER_STATEFILE = options.data_dir + "/watcher.data"
343   options.FILE_STORAGE_PATHS_FILE = options.conf_dir + "/file-storage-paths"
344
345   SetupLogging()
346
347   # Option checking
348   if args:
349     raise Error("No arguments expected")
350   if options.downgrade and not options.no_verify:
351     options.no_verify = True
352
353   # Check master name
354   if not (CheckHostname(options.SSCONF_MASTER_NODE) or options.ignore_hostname):
355     logging.error("Aborting due to hostname mismatch")
356     sys.exit(constants.EXIT_FAILURE)
357
358   if not options.force:
359     if options.downgrade:
360       usertext = ("The configuration is going to be DOWNGRADED to version %s.%s"
361                   " Some configuration data might be removed if they don't fit"
362                   " in the old format. Please make sure you have read the"
363                   " upgrade notes (available in the UPGRADE file and included"
364                   " in other documentation formats) to understand what they"
365                   " are. Continue with *DOWNGRADING* the configuration?" %
366                   (DOWNGRADE_MAJOR, DOWNGRADE_MINOR))
367     else:
368       usertext = ("Please make sure you have read the upgrade notes for"
369                   " Ganeti %s (available in the UPGRADE file and included"
370                   " in other documentation formats). Continue with upgrading"
371                   " configuration?" % constants.RELEASE_VERSION)
372     if not cli.AskUser(usertext):
373       sys.exit(constants.EXIT_FAILURE)
374
375   # Check whether it's a Ganeti configuration directory
376   if not (os.path.isfile(options.CONFIG_DATA_PATH) and
377           os.path.isfile(options.SERVER_PEM_PATH) and
378           os.path.isfile(options.KNOWN_HOSTS_PATH)):
379     raise Error(("%s does not seem to be a Ganeti configuration"
380                  " directory") % options.data_dir)
381
382   if not os.path.isdir(options.conf_dir):
383     raise Error("Not a directory: %s" % options.conf_dir)
384
385   config_data = serializer.LoadJson(utils.ReadFile(options.CONFIG_DATA_PATH))
386
387   try:
388     config_version = config_data["version"]
389   except KeyError:
390     raise Error("Unable to determine configuration version")
391
392   (config_major, config_minor, config_revision) = \
393     constants.SplitVersion(config_version)
394
395   logging.info("Found configuration version %s (%d.%d.%d)",
396                config_version, config_major, config_minor, config_revision)
397
398   if "config_version" in config_data["cluster"]:
399     raise Error("Inconsistent configuration: found config_version in"
400                 " configuration file")
401
402   # Downgrade to the previous stable version
403   if options.downgrade:
404     if config_major != TARGET_MAJOR or config_minor != TARGET_MINOR:
405       raise Error("Downgrade supported only from the latest version (%s.%s),"
406                   " found %s (%s.%s.%s) instead" %
407                   (TARGET_MAJOR, TARGET_MINOR, config_version, config_major,
408                    config_minor, config_revision))
409     DowngradeAll(config_data)
410
411   # Upgrade from 2.{0..7} to 2.7
412   elif config_major == 2 and config_minor in range(0, 8):
413     if config_revision != 0:
414       logging.warning("Config revision is %s, not 0", config_revision)
415     UpgradeAll(config_data)
416
417   elif config_major == TARGET_MAJOR and config_minor == TARGET_MINOR:
418     logging.info("No changes necessary")
419
420   else:
421     raise Error("Configuration version %d.%d.%d not supported by this tool" %
422                 (config_major, config_minor, config_revision))
423
424   try:
425     logging.info("Writing configuration file to %s", options.CONFIG_DATA_PATH)
426     utils.WriteFile(file_name=options.CONFIG_DATA_PATH,
427                     data=serializer.DumpJson(config_data),
428                     mode=0600,
429                     dry_run=options.dry_run,
430                     backup=True)
431
432     if not options.dry_run:
433       bootstrap.GenerateClusterCrypto(
434         False, False, False, False, False,
435         nodecert_file=options.SERVER_PEM_PATH,
436         rapicert_file=options.RAPI_CERT_FILE,
437         spicecert_file=options.SPICE_CERT_FILE,
438         spicecacert_file=options.SPICE_CACERT_FILE,
439         hmackey_file=options.CONFD_HMAC_KEY,
440         cds_file=options.CDS_FILE)
441
442   except Exception:
443     logging.critical("Writing configuration failed. It is probably in an"
444                      " inconsistent state and needs manual intervention.")
445     raise
446
447   # test loading the config file
448   all_ok = True
449   if not (options.dry_run or options.no_verify):
450     logging.info("Testing the new config file...")
451     cfg = config.ConfigWriter(cfg_file=options.CONFIG_DATA_PATH,
452                               accept_foreign=options.ignore_hostname,
453                               offline=True)
454     # if we reached this, it's all fine
455     vrfy = cfg.VerifyConfig()
456     if vrfy:
457       logging.error("Errors after conversion:")
458       for item in vrfy:
459         logging.error(" - %s", item)
460       all_ok = False
461     else:
462       logging.info("File loaded successfully after upgrading")
463     del cfg
464
465   if options.downgrade:
466     action = "downgraded"
467     out_ver = "%s.%s" % (DOWNGRADE_MAJOR, DOWNGRADE_MINOR)
468   else:
469     action = "upgraded"
470     out_ver = constants.RELEASE_VERSION
471   if all_ok:
472     cli.ToStderr("Configuration successfully %s to version %s.",
473                  action, out_ver)
474   else:
475     cli.ToStderr("Configuration %s to version %s, but there are errors."
476                  "\nPlease review the file.", action, out_ver)
477
478
479 if __name__ == "__main__":
480   main()