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