Abstract self-promotion decision
[ganeti-local] / lib / bootstrap.py
1 #
2 #
3
4 # Copyright (C) 2006, 2007, 2008 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 """Functions to bootstrap a new cluster.
23
24 """
25
26 import os
27 import os.path
28 import re
29 import logging
30 import tempfile
31 import time
32
33 from ganeti import rpc
34 from ganeti import ssh
35 from ganeti import utils
36 from ganeti import errors
37 from ganeti import config
38 from ganeti import constants
39 from ganeti import objects
40 from ganeti import ssconf
41 from ganeti import serializer
42 from ganeti import hypervisor
43
44
45 def _InitSSHSetup():
46   """Setup the SSH configuration for the cluster.
47
48   This generates a dsa keypair for root, adds the pub key to the
49   permitted hosts and adds the hostkey to its own known hosts.
50
51   """
52   priv_key, pub_key, auth_keys = ssh.GetUserFiles(constants.GANETI_RUNAS)
53
54   for name in priv_key, pub_key:
55     if os.path.exists(name):
56       utils.CreateBackup(name)
57     utils.RemoveFile(name)
58
59   result = utils.RunCmd(["ssh-keygen", "-t", "dsa",
60                          "-f", priv_key,
61                          "-q", "-N", ""])
62   if result.failed:
63     raise errors.OpExecError("Could not generate ssh keypair, error %s" %
64                              result.output)
65
66   utils.AddAuthorizedKey(auth_keys, utils.ReadFile(pub_key))
67
68
69 def _GenerateSelfSignedSslCert(file_name, validity=(365 * 5)):
70   """Generates a self-signed SSL certificate.
71
72   @type file_name: str
73   @param file_name: Path to output file
74   @type validity: int
75   @param validity: Validity for certificate in days
76
77   """
78   (fd, tmp_file_name) = tempfile.mkstemp(dir=os.path.dirname(file_name))
79   try:
80     try:
81       # Set permissions before writing key
82       os.chmod(tmp_file_name, 0600)
83
84       result = utils.RunCmd(["openssl", "req", "-new", "-newkey", "rsa:1024",
85                              "-days", str(validity), "-nodes", "-x509",
86                              "-keyout", tmp_file_name, "-out", tmp_file_name,
87                              "-batch"])
88       if result.failed:
89         raise errors.OpExecError("Could not generate SSL certificate, command"
90                                  " %s had exitcode %s and error message %s" %
91                                  (result.cmd, result.exit_code, result.output))
92
93       # Make read-only
94       os.chmod(tmp_file_name, 0400)
95
96       os.rename(tmp_file_name, file_name)
97     finally:
98       utils.RemoveFile(tmp_file_name)
99   finally:
100     os.close(fd)
101
102
103 def _InitGanetiServerSetup():
104   """Setup the necessary configuration for the initial node daemon.
105
106   This creates the nodepass file containing the shared password for
107   the cluster and also generates the SSL certificate.
108
109   """
110   _GenerateSelfSignedSslCert(constants.SSL_CERT_FILE)
111
112   # Don't overwrite existing file
113   if not os.path.exists(constants.RAPI_CERT_FILE):
114     _GenerateSelfSignedSslCert(constants.RAPI_CERT_FILE)
115
116   if not os.path.exists(constants.HMAC_CLUSTER_KEY):
117     utils.WriteFile(constants.HMAC_CLUSTER_KEY,
118                     data=utils.GenerateSecret(),
119                     mode=0400)
120
121   result = utils.RunCmd([constants.NODE_INITD_SCRIPT, "restart"])
122
123   if result.failed:
124     raise errors.OpExecError("Could not start the node daemon, command %s"
125                              " had exitcode %s and error %s" %
126                              (result.cmd, result.exit_code, result.output))
127
128
129 def InitCluster(cluster_name, mac_prefix,
130                 master_netdev, file_storage_dir, candidate_pool_size,
131                 secondary_ip=None, vg_name=None, beparams=None,
132                 nicparams=None, hvparams=None, enabled_hypervisors=None,
133                 modify_etc_hosts=True):
134   """Initialise the cluster.
135
136   @type candidate_pool_size: int
137   @param candidate_pool_size: master candidate pool size
138
139   """
140   # TODO: complete the docstring
141   if config.ConfigWriter.IsCluster():
142     raise errors.OpPrereqError("Cluster is already initialised")
143
144   if not enabled_hypervisors:
145     raise errors.OpPrereqError("Enabled hypervisors list must contain at"
146                                " least one member")
147   invalid_hvs = set(enabled_hypervisors) - constants.HYPER_TYPES
148   if invalid_hvs:
149     raise errors.OpPrereqError("Enabled hypervisors contains invalid"
150                                " entries: %s" % invalid_hvs)
151
152   hostname = utils.HostInfo()
153
154   if hostname.ip.startswith("127."):
155     raise errors.OpPrereqError("This host's IP resolves to the private"
156                                " range (%s). Please fix DNS or %s." %
157                                (hostname.ip, constants.ETC_HOSTS))
158
159   if not utils.OwnIpAddress(hostname.ip):
160     raise errors.OpPrereqError("Inconsistency: this host's name resolves"
161                                " to %s,\nbut this ip address does not"
162                                " belong to this host."
163                                " Aborting." % hostname.ip)
164
165   clustername = utils.HostInfo(cluster_name)
166
167   if utils.TcpPing(clustername.ip, constants.DEFAULT_NODED_PORT,
168                    timeout=5):
169     raise errors.OpPrereqError("Cluster IP already active. Aborting.")
170
171   if secondary_ip:
172     if not utils.IsValidIP(secondary_ip):
173       raise errors.OpPrereqError("Invalid secondary ip given")
174     if (secondary_ip != hostname.ip and
175         not utils.OwnIpAddress(secondary_ip)):
176       raise errors.OpPrereqError("You gave %s as secondary IP,"
177                                  " but it does not belong to this host." %
178                                  secondary_ip)
179   else:
180     secondary_ip = hostname.ip
181
182   if vg_name is not None:
183     # Check if volume group is valid
184     vgstatus = utils.CheckVolumeGroupSize(utils.ListVolumeGroups(), vg_name,
185                                           constants.MIN_VG_SIZE)
186     if vgstatus:
187       raise errors.OpPrereqError("Error: %s\nspecify --no-lvm-storage if"
188                                  " you are not using lvm" % vgstatus)
189
190   file_storage_dir = os.path.normpath(file_storage_dir)
191
192   if not os.path.isabs(file_storage_dir):
193     raise errors.OpPrereqError("The file storage directory you passed is"
194                                " not an absolute path.")
195
196   if not os.path.exists(file_storage_dir):
197     try:
198       os.makedirs(file_storage_dir, 0750)
199     except OSError, err:
200       raise errors.OpPrereqError("Cannot create file storage directory"
201                                  " '%s': %s" %
202                                  (file_storage_dir, err))
203
204   if not os.path.isdir(file_storage_dir):
205     raise errors.OpPrereqError("The file storage directory '%s' is not"
206                                " a directory." % file_storage_dir)
207
208   if not re.match("^[0-9a-z]{2}:[0-9a-z]{2}:[0-9a-z]{2}$", mac_prefix):
209     raise errors.OpPrereqError("Invalid mac prefix given '%s'" % mac_prefix)
210
211   result = utils.RunCmd(["ip", "link", "show", "dev", master_netdev])
212   if result.failed:
213     raise errors.OpPrereqError("Invalid master netdev given (%s): '%s'" %
214                                (master_netdev,
215                                 result.output.strip()))
216
217   if not (os.path.isfile(constants.NODE_INITD_SCRIPT) and
218           os.access(constants.NODE_INITD_SCRIPT, os.X_OK)):
219     raise errors.OpPrereqError("Init.d script '%s' missing or not"
220                                " executable." % constants.NODE_INITD_SCRIPT)
221
222   dirs = [(constants.RUN_GANETI_DIR, constants.RUN_DIRS_MODE)]
223   utils.EnsureDirs(dirs)
224
225   utils.ForceDictType(beparams, constants.BES_PARAMETER_TYPES)
226   utils.ForceDictType(nicparams, constants.NICS_PARAMETER_TYPES)
227   objects.NIC.CheckParameterSyntax(nicparams)
228
229   # hvparams is a mapping of hypervisor->hvparams dict
230   for hv_name, hv_params in hvparams.iteritems():
231     utils.ForceDictType(hv_params, constants.HVS_PARAMETER_TYPES)
232     hv_class = hypervisor.GetHypervisor(hv_name)
233     hv_class.CheckParameterSyntax(hv_params)
234
235   # set up the inter-node password and certificate
236   _InitGanetiServerSetup()
237
238   # set up ssh config and /etc/hosts
239   sshline = utils.ReadFile(constants.SSH_HOST_RSA_PUB)
240   sshkey = sshline.split(" ")[1]
241
242   if modify_etc_hosts:
243     utils.AddHostToEtcHosts(hostname.name)
244
245   _InitSSHSetup()
246
247   now = time.time()
248
249   # init of cluster config file
250   cluster_config = objects.Cluster(
251     serial_no=1,
252     rsahostkeypub=sshkey,
253     highest_used_port=(constants.FIRST_DRBD_PORT - 1),
254     mac_prefix=mac_prefix,
255     volume_group_name=vg_name,
256     tcpudp_port_pool=set(),
257     master_node=hostname.name,
258     master_ip=clustername.ip,
259     master_netdev=master_netdev,
260     cluster_name=clustername.name,
261     file_storage_dir=file_storage_dir,
262     enabled_hypervisors=enabled_hypervisors,
263     beparams={constants.PP_DEFAULT: beparams},
264     nicparams={constants.PP_DEFAULT: nicparams},
265     hvparams=hvparams,
266     candidate_pool_size=candidate_pool_size,
267     modify_etc_hosts=modify_etc_hosts,
268     ctime=now,
269     mtime=now,
270     uuid=utils.NewUUID(),
271     )
272   master_node_config = objects.Node(name=hostname.name,
273                                     primary_ip=hostname.ip,
274                                     secondary_ip=secondary_ip,
275                                     serial_no=1,
276                                     master_candidate=True,
277                                     offline=False, drained=False,
278                                     )
279   InitConfig(constants.CONFIG_VERSION, cluster_config, master_node_config)
280   cfg = config.ConfigWriter()
281   ssh.WriteKnownHostsFile(cfg, constants.SSH_KNOWN_HOSTS_FILE)
282   cfg.Update(cfg.GetClusterInfo())
283
284   # start the master ip
285   # TODO: Review rpc call from bootstrap
286   # TODO: Warn on failed start master
287   rpc.RpcRunner.call_node_start_master(hostname.name, True, False)
288
289
290 def InitConfig(version, cluster_config, master_node_config,
291                cfg_file=constants.CLUSTER_CONF_FILE):
292   """Create the initial cluster configuration.
293
294   It will contain the current node, which will also be the master
295   node, and no instances.
296
297   @type version: int
298   @param version: configuration version
299   @type cluster_config: L{objects.Cluster}
300   @param cluster_config: cluster configuration
301   @type master_node_config: L{objects.Node}
302   @param master_node_config: master node configuration
303   @type cfg_file: string
304   @param cfg_file: configuration file path
305
306   """
307   nodes = {
308     master_node_config.name: master_node_config,
309     }
310
311   now = time.time()
312   config_data = objects.ConfigData(version=version,
313                                    cluster=cluster_config,
314                                    nodes=nodes,
315                                    instances={},
316                                    serial_no=1,
317                                    ctime=now, mtime=now)
318   utils.WriteFile(cfg_file,
319                   data=serializer.Dump(config_data.ToDict()),
320                   mode=0600)
321
322
323 def FinalizeClusterDestroy(master):
324   """Execute the last steps of cluster destroy
325
326   This function shuts down all the daemons, completing the destroy
327   begun in cmdlib.LUDestroyOpcode.
328
329   """
330   result = rpc.RpcRunner.call_node_stop_master(master, True)
331   msg = result.fail_msg
332   if msg:
333     logging.warning("Could not disable the master role: %s" % msg)
334   result = rpc.RpcRunner.call_node_leave_cluster(master)
335   msg = result.fail_msg
336   if msg:
337     logging.warning("Could not shutdown the node daemon and cleanup"
338                     " the node: %s", msg)
339
340
341 def SetupNodeDaemon(cluster_name, node, ssh_key_check):
342   """Add a node to the cluster.
343
344   This function must be called before the actual opcode, and will ssh
345   to the remote node, copy the needed files, and start ganeti-noded,
346   allowing the master to do the rest via normal rpc calls.
347
348   @param cluster_name: the cluster name
349   @param node: the name of the new node
350   @param ssh_key_check: whether to do a strict key check
351
352   """
353   sshrunner = ssh.SshRunner(cluster_name)
354
355   noded_cert = utils.ReadFile(constants.SSL_CERT_FILE)
356   rapi_cert = utils.ReadFile(constants.RAPI_CERT_FILE)
357   hmac_key = utils.ReadFile(constants.HMAC_CLUSTER_KEY)
358
359   # in the base64 pem encoding, neither '!' nor '.' are valid chars,
360   # so we use this to detect an invalid certificate; as long as the
361   # cert doesn't contain this, the here-document will be correctly
362   # parsed by the shell sequence below. HMAC keys are hexadecimal strings,
363   # so the same restrictions apply.
364   for content in (noded_cert, rapi_cert, hmac_key):
365     if re.search('^!EOF\.', content, re.MULTILINE):
366       raise errors.OpExecError("invalid SSL certificate or HMAC key")
367
368   if not noded_cert.endswith("\n"):
369     noded_cert += "\n"
370   if not rapi_cert.endswith("\n"):
371     rapi_cert += "\n"
372   if not hmac_key.endswith("\n"):
373     hmac_key += "\n"
374
375   # set up inter-node password and certificate and restarts the node daemon
376   # and then connect with ssh to set password and start ganeti-noded
377   # note that all the below variables are sanitized at this point,
378   # either by being constants or by the checks above
379   mycommand = ("umask 077 && "
380                "cat > '%s' << '!EOF.' && \n"
381                "%s!EOF.\n"
382                "cat > '%s' << '!EOF.' && \n"
383                "%s!EOF.\n"
384                "cat > '%s' << '!EOF.' && \n"
385                "%s!EOF.\n"
386                "chmod 0400 %s %s %s && "
387                "%s restart" %
388                (constants.SSL_CERT_FILE, noded_cert,
389                 constants.RAPI_CERT_FILE, rapi_cert,
390                 constants.HMAC_CLUSTER_KEY, hmac_key,
391                 constants.SSL_CERT_FILE, constants.RAPI_CERT_FILE,
392                 constants.HMAC_CLUSTER_KEY,
393                 constants.NODE_INITD_SCRIPT))
394
395   result = sshrunner.Run(node, 'root', mycommand, batch=False,
396                          ask_key=ssh_key_check,
397                          use_cluster_key=False,
398                          strict_host_check=ssh_key_check)
399   if result.failed:
400     raise errors.OpExecError("Remote command on node %s, error: %s,"
401                              " output: %s" %
402                              (node, result.fail_reason, result.output))
403
404
405 def MasterFailover(no_voting=False):
406   """Failover the master node.
407
408   This checks that we are not already the master, and will cause the
409   current master to cease being master, and the non-master to become
410   new master.
411
412   @type no_voting: boolean
413   @param no_voting: force the operation without remote nodes agreement
414                       (dangerous)
415
416   """
417   sstore = ssconf.SimpleStore()
418
419   old_master, new_master = ssconf.GetMasterAndMyself(sstore)
420   node_list = sstore.GetNodeList()
421   mc_list = sstore.GetMasterCandidates()
422
423   if old_master == new_master:
424     raise errors.OpPrereqError("This commands must be run on the node"
425                                " where you want the new master to be."
426                                " %s is already the master" %
427                                old_master)
428
429   if new_master not in mc_list:
430     mc_no_master = [name for name in mc_list if name != old_master]
431     raise errors.OpPrereqError("This node is not among the nodes marked"
432                                " as master candidates. Only these nodes"
433                                " can become masters. Current list of"
434                                " master candidates is:\n"
435                                "%s" % ('\n'.join(mc_no_master)))
436
437   if not no_voting:
438     vote_list = GatherMasterVotes(node_list)
439
440     if vote_list:
441       voted_master = vote_list[0][0]
442       if voted_master is None:
443         raise errors.OpPrereqError("Cluster is inconsistent, most nodes did"
444                                    " not respond.")
445       elif voted_master != old_master:
446         raise errors.OpPrereqError("I have a wrong configuration, I believe"
447                                    " the master is %s but the other nodes"
448                                    " voted %s. Please resync the configuration"
449                                    " of this node." %
450                                    (old_master, voted_master))
451   # end checks
452
453   rcode = 0
454
455   logging.info("Setting master to %s, old master: %s", new_master, old_master)
456
457   result = rpc.RpcRunner.call_node_stop_master(old_master, True)
458   msg = result.fail_msg
459   if msg:
460     logging.error("Could not disable the master role on the old master"
461                  " %s, please disable manually: %s", old_master, msg)
462
463   # Here we have a phase where no master should be running
464
465   # instantiate a real config writer, as we now know we have the
466   # configuration data
467   cfg = config.ConfigWriter()
468
469   cluster_info = cfg.GetClusterInfo()
470   cluster_info.master_node = new_master
471   # this will also regenerate the ssconf files, since we updated the
472   # cluster info
473   cfg.Update(cluster_info)
474
475   result = rpc.RpcRunner.call_node_start_master(new_master, True, no_voting)
476   msg = result.fail_msg
477   if msg:
478     logging.error("Could not start the master role on the new master"
479                   " %s, please check: %s", new_master, msg)
480     rcode = 1
481
482   return rcode
483
484
485 def GetMaster():
486   """Returns the current master node.
487
488   This is a separate function in bootstrap since it's needed by
489   gnt-cluster, and instead of importing directly ssconf, it's better
490   to abstract it in bootstrap, where we do use ssconf in other
491   functions too.
492
493   """
494   sstore = ssconf.SimpleStore()
495
496   old_master, _ = ssconf.GetMasterAndMyself(sstore)
497
498   return old_master
499
500
501 def GatherMasterVotes(node_list):
502   """Check the agreement on who is the master.
503
504   This function will return a list of (node, number of votes), ordered
505   by the number of votes. Errors will be denoted by the key 'None'.
506
507   Note that the sum of votes is the number of nodes this machine
508   knows, whereas the number of entries in the list could be different
509   (if some nodes vote for another master).
510
511   We remove ourselves from the list since we know that (bugs aside)
512   since we use the same source for configuration information for both
513   backend and boostrap, we'll always vote for ourselves.
514
515   @type node_list: list
516   @param node_list: the list of nodes to query for master info; the current
517       node will be removed if it is in the list
518   @rtype: list
519   @return: list of (node, votes)
520
521   """
522   myself = utils.HostInfo().name
523   try:
524     node_list.remove(myself)
525   except ValueError:
526     pass
527   if not node_list:
528     # no nodes left (eventually after removing myself)
529     return []
530   results = rpc.RpcRunner.call_master_info(node_list)
531   if not isinstance(results, dict):
532     # this should not happen (unless internal error in rpc)
533     logging.critical("Can't complete rpc call, aborting master startup")
534     return [(None, len(node_list))]
535   votes = {}
536   for node in results:
537     nres = results[node]
538     data = nres.payload
539     msg = nres.fail_msg
540     fail = False
541     if msg:
542       logging.warning("Error contacting node %s: %s", node, msg)
543       fail = True
544     elif not isinstance(data, (tuple, list)) or len(data) < 3:
545       logging.warning("Invalid data received from node %s: %s", node, data)
546       fail = True
547     if fail:
548       if None not in votes:
549         votes[None] = 0
550       votes[None] += 1
551       continue
552     master_node = data[2]
553     if master_node not in votes:
554       votes[master_node] = 0
555     votes[master_node] += 1
556
557   vote_list = [v for v in votes.items()]
558   # sort first on number of votes then on name, since we want None
559   # sorted later if we have the half of the nodes not responding, and
560   # half voting all for the same master
561   vote_list.sort(key=lambda x: (x[1], x[0]), reverse=True)
562
563   return vote_list