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