This patch adds the '--new-ssh-keys' option
to 'gnt-cluster renew-crypto'. In the client, it retrieves
all current ssh keys and (re-)writes the 'ganeti_pub_key'
file with it, then in the backend, the new keys are
generated and distributed.

Signed-off-by: Helga Velroyen <hel...@google.com>
---
 UPGRADE                                       |  21 +++++
 lib/backend.py                                | 113 +++++++++++++++++++++++++-
 lib/cli.py                                    |  24 ++++++
 lib/client/gnt_cluster.py                     |  72 ++++++++++++++--
 lib/cmdlib/cluster.py                         |  52 ++++++++++++
 lib/rpc_defs.py                               |   7 ++
 lib/server/noded.py                           |  11 +++
 lib/tools/ssh_update.py                       |  28 ++++++-
 qa/qa_cluster.py                              |   7 +-
 src/Ganeti/Constants.hs                       |  12 ++-
 src/Ganeti/OpCodes.hs                         |   1 +
 src/Ganeti/OpParams.hs                        |   7 ++
 test/hs/Test/Ganeti/OpCodes.hs                |   3 +-
 test/py/ganeti.backend_unittest.py            |  24 +++++-
 test/py/ganeti.client.gnt_cluster_unittest.py | 108 ++++++++++++++++++++++++
 test/py/ganeti.mcpu_unittest.py               |   1 -
 tools/post-upgrade                            |   8 ++
 17 files changed, 483 insertions(+), 16 deletions(-)

diff --git a/UPGRADE b/UPGRADE
index 62624ff..2186298 100644
--- a/UPGRADE
+++ b/UPGRADE
@@ -39,6 +39,27 @@ the Ganeti binaries should happen in the same way as for all 
other binaries on
 your system.
 
 
+2.13
+----
+
+When upgrading to 2.13, first apply the instructions of ``2.11 and
+above``. 2.13 comes with the new feature of enhanced SSH security
+through individual SSH keys. This features needs to be enabled
+after the upgrade by::
+
+   $ gnt-cluster renew-crypt --new-ssh-keys --no-ssh-key-check
+
+Note that new SSH keys are generated automatically without warning when
+upgrading with ``gnt-cluster upgrade``.
+
+If you instructed Ganeti to not touch the SSH setup (by using the
+``--no-ssh-init`` option of ``gnt-cluster init``, the changes in the
+handling of SSH keys will not affect your cluster.
+
+If you want to be prompted for each newly created SSH key, leave out
+the ``--no-ssh-key-check`` option in the command listed above.
+
+
 2.11
 ----
 
diff --git a/lib/backend.py b/lib/backend.py
index efdc00a..4a6ad04 100644
--- a/lib/backend.py
+++ b/lib/backend.py
@@ -1471,7 +1471,7 @@ def AddNodeSshKey(node_uuid, node_name,
   pot_mc_data = copy.deepcopy(base_data)
   if to_public_keys:
     pot_mc_data[constants.SSHS_SSH_PUBLIC_KEYS] = \
-      (constants.SSHS_ADD, keys_by_uuid)
+      (constants.SSHS_REPLACE_OR_ADD, keys_by_uuid)
 
   all_nodes = ssconf_store.GetNodeList()
   master_node = ssconf_store.GetMasterNode()
@@ -1626,6 +1626,117 @@ def RemoveNodeSshKey(node_uuid, node_name, 
from_authorized_keys,
                    node_name, e)
 
 
+def _GenerateNodeSshKey(node_uuid, node_name, ssh_port_map,
+                        pub_key_file=pathutils.SSH_PUB_KEYS,
+                        ssconf_store=None,
+                        noded_cert_file=pathutils.NODED_CERT_FILE,
+                        run_cmd_fn=ssh.RunSshCmdWithStdin):
+  """Generates the root SSH key pair on the node.
+
+  @type node_uuid: str
+  @param node_uuid: UUID of the node whose key is removed
+  @type node_name: str
+  @param node_name: name of the node whose key is remove
+  @type ssh_port_map: dict of str to int
+  @param ssh_port_map: mapping of node names to their SSH port
+
+  """
+  if not ssconf_store:
+    ssconf_store = ssconf.SimpleStore()
+
+  keys_by_uuid = ssh.QueryPubKeyFile([node_uuid], key_file=pub_key_file)
+  if not keys_by_uuid or node_uuid not in keys_by_uuid:
+    raise errors.SshUpdateError("Node %s (UUID: %s) whose key is requested to"
+                                " be regenerated is not registered in the"
+                                " public keys file." % (node_name, node_uuid))
+
+  data = {}
+  _InitSshUpdateData(data, noded_cert_file, ssconf_store)
+  cluster_name = data[constants.SSHS_CLUSTER_NAME]
+  data[constants.SSHS_GENERATE] = True
+
+  run_cmd_fn(cluster_name, node_name, pathutils.SSH_UPDATE,
+             True, True, False, False, False,
+             ssh_port_map.get(node_name), data, ssconf_store)
+
+
+def RenewSshKeys(node_uuids, node_names, ssh_port_map,
+                 master_candidate_uuids,
+                 potential_master_candidates,
+                 pub_key_file=pathutils.SSH_PUB_KEYS,
+                 ssconf_store=None,
+                 noded_cert_file=pathutils.NODED_CERT_FILE,
+                 run_cmd_fn=ssh.RunSshCmdWithStdin):
+  """Renews all SSH keys and updates authorized_keys and ganeti_pub_keys.
+
+  """
+  if not ssconf_store:
+    ssconf_store = ssconf.SimpleStore()
+  cluster_name = ssconf_store.GetClusterName()
+
+  if not len(node_uuids) == len(node_names):
+    raise errors.ProgrammerError("List of nodes UUIDs and node names"
+                                 " does not match in length.")
+
+  (_, root_keyfiles) = \
+    ssh.GetAllUserFiles(constants.SSH_LOGIN_USER, mkdir=False, dircheck=False)
+
+  node_uuid_name_map = zip(node_uuids, node_names)
+
+  master_node_name = ssconf_store.GetMasterNode()
+  # process non-master nodes
+  for node_uuid, node_name in node_uuid_name_map:
+    if node_name == master_node_name:
+      continue
+    master_candidate = node_uuid in master_candidate_uuids
+    potential_master_candidate = node_name in potential_master_candidates
+
+    keys_by_uuid = ssh.QueryPubKeyFile([node_uuid], key_file=pub_key_file)
+    if not keys_by_uuid:
+      raise errors.SshUpdateError("No public key of node %s (UUID %s) found,"
+                                  " not generating a new key."
+                                  % (node_name, node_uuid))
+
+    RemoveNodeSshKey(node_uuid, node_name,
+                     master_candidate, # from authorized keys
+                     False, # Don't remove (yet) from public keys
+                     False, # Don't clear authorized_keys
+                     ssh_port_map,
+                     master_candidate_uuids,
+                     potential_master_candidates)
+
+    _GenerateNodeSshKey(node_uuid, node_name, ssh_port_map,
+                        pub_key_file=pub_key_file,
+                        ssconf_store=ssconf_store,
+                        noded_cert_file=noded_cert_file,
+                        run_cmd_fn=run_cmd_fn)
+
+    fetched_keys = ssh.ReadRemoteSshPubKeys(root_keyfiles, node_name,
+                                            cluster_name,
+                                            ssh_port_map[node_name],
+                                            False, # ask_key
+                                            False) # key_check
+    if not fetched_keys:
+      raise errors.SshUpdateError("Could not fetch key of node %s"
+                                  " (UUID %s)" % (node_name, node_uuid))
+
+    if potential_master_candidate:
+      ssh.RemovePublicKey(node_uuid, key_file=pub_key_file)
+      for pub_key in fetched_keys.values():
+        ssh.AddPublicKey(node_uuid, pub_key, key_file=pub_key_file)
+
+    AddNodeSshKey(node_uuid, node_name,
+                  master_candidate, # Add to authorized_keys file
+                  potential_master_candidate, # Add to public_keys
+                  True, # Get public keys
+                  ssh_port_map, potential_master_candidates,
+                  pub_key_file=pub_key_file, ssconf_store=ssconf_store,
+                  noded_cert_file=noded_cert_file,
+                  run_cmd_fn=run_cmd_fn)
+
+  # FIXME: Update master key as well
+
+
 def GetBlockDevSizes(devices):
   """Return the size of the given block devices
 
diff --git a/lib/cli.py b/lib/cli.py
index e939b5c..77c9323 100644
--- a/lib/cli.py
+++ b/lib/cli.py
@@ -136,6 +136,7 @@ __all__ = [
   "NETWORK6_OPT",
   "NEW_CLUSTER_CERT_OPT",
   "NEW_NODE_CERT_OPT",
+  "NEW_SSH_KEY_OPT",
   "NEW_CLUSTER_DOMAIN_SECRET_OPT",
   "NEW_CONFD_HMAC_KEY_OPT",
   "NEW_RAPI_CERT_OPT",
@@ -255,6 +256,7 @@ __all__ = [
   "GetClient",
   "GetOnlineNodes",
   "GetNodesSshPorts",
+  "GetNodeUUIDs",
   "JobExecutor",
   "JobSubmittedException",
   "ParseTimespec",
@@ -1491,6 +1493,10 @@ NEW_NODE_CERT_OPT = cli_option(
   "--new-node-certificates", dest="new_node_cert", default=False,
   action="store_true", help="Generate new node certificates (for all nodes)")
 
+NEW_SSH_KEY_OPT = cli_option(
+  "--new-ssh-keys", dest="new_ssh_keys", default=False,
+  action="store_true", help="Generate new node SSH keys (for all nodes)")
+
 RAPI_CERT_OPT = cli_option("--rapi-certificate", dest="rapi_cert",
                            default=None,
                            help="File containing new RAPI certificate")
@@ -3729,6 +3735,7 @@ def GetNodesSshPorts(nodes, cl):
   @type cl: L{ganeti.luxi.Client}
   @return: the list of SSH ports corresponding to the nodes
   @rtype: a list of tuples
+
   """
   return map(lambda t: t[0],
              cl.QueryNodes(names=nodes,
@@ -3736,6 +3743,23 @@ def GetNodesSshPorts(nodes, cl):
                            use_locking=False))
 
 
+def GetNodeUUIDs(nodes, cl):
+  """Retrieves the UUIDs of given nodes.
+
+  @param nodes: the names of nodes
+  @type nodes: a list of string
+  @param cl: a client to use for the query
+  @type cl: L{ganeti.luxi.Client}
+  @return: the list of UUIDs corresponding to the nodes
+  @rtype: a list of tuples
+
+  """
+  return map(lambda t: t[0],
+             cl.QueryNodes(names=nodes,
+                           fields=["uuid"],
+                           use_locking=False))
+
+
 def _ToStream(stream, txt, *args):
   """Write a message to a stream, bypassing the logging system
 
diff --git a/lib/client/gnt_cluster.py b/lib/client/gnt_cluster.py
index daa64d6..6c8c28e 100644
--- a/lib/client/gnt_cluster.py
+++ b/lib/client/gnt_cluster.py
@@ -927,7 +927,7 @@ def _ReadAndVerifyCert(cert_filename, 
verify_private_key=False):
 def _RenewCrypto(new_cluster_cert, new_rapi_cert, # pylint: disable=R0911
                  rapi_cert_filename, new_spice_cert, spice_cert_filename,
                  spice_cacert_filename, new_confd_hmac_key, new_cds,
-                 cds_filename, force, new_node_cert):
+                 cds_filename, force, new_node_cert, new_ssh_keys):
   """Renews cluster certificates, keys and secrets.
 
   @type new_cluster_cert: bool
@@ -951,8 +951,10 @@ def _RenewCrypto(new_cluster_cert, new_rapi_cert, # 
pylint: disable=R0911
   @param cds_filename: Path to file containing new cluster domain secret
   @type force: bool
   @param force: Whether to ask user for confirmation
-  @type new_node_cert: string
+  @type new_node_cert: bool
   @param new_node_cert: Whether to generate new node certificates
+  @type new_ssh_keys: bool
+  @param new_ssh_keys: Whether to generate new node SSH keys
 
   """
   if new_rapi_cert and rapi_cert_filename:
@@ -1047,18 +1049,75 @@ def _RenewCrypto(new_cluster_cert, new_rapi_cert, # 
pylint: disable=R0911
   ToStdout("All requested certificates and keys have been replaced."
            " Running \"gnt-cluster verify\" now is recommended.")
 
-  if new_node_cert:
+  if new_node_cert or new_ssh_keys:
     cl = GetClient()
-    renew_op = opcodes.OpClusterRenewCrypto(node_certificates=new_node_cert)
+    renew_op = opcodes.OpClusterRenewCrypto(node_certificates=new_node_cert,
+                                            ssh_keys=new_ssh_keys)
     SubmitOpCode(renew_op, cl=cl)
 
   return 0
 
 
+def _BuildGanetiPubKeys(options, pub_key_file=pathutils.SSH_PUB_KEYS, cl=None,
+                        get_online_nodes_fn=GetOnlineNodes,
+                        get_nodes_ssh_ports_fn=GetNodesSshPorts,
+                        get_node_uuids_fn=GetNodeUUIDs,
+                        homedir_fn=None):
+  """Recreates the 'ganeti_pub_key' file by polling all nodes.
+
+  """
+  if os.path.exists(pub_key_file):
+    utils.CreateBackup(pub_key_file)
+    utils.RemoveFile(pub_key_file)
+
+  ssh.ClearPubKeyFile(pub_key_file)
+
+  if not cl:
+    cl = GetClient()
+
+  (cluster_name, master_node) = \
+    cl.QueryConfigValues(["cluster_name", "master_node"])
+
+  online_nodes = get_online_nodes_fn([], cl=cl)
+  ssh_ports = get_nodes_ssh_ports_fn(online_nodes + [master_node], cl)
+  ssh_port_map = dict(zip(online_nodes + [master_node], ssh_ports))
+
+  node_uuids = get_node_uuids_fn(online_nodes + [master_node], cl)
+  node_uuid_map = dict(zip(online_nodes + [master_node], node_uuids))
+
+  nonmaster_nodes = [name for name in online_nodes
+                     if name != master_node]
+
+  (_, root_keyfiles) = \
+    ssh.GetAllUserFiles(constants.SSH_LOGIN_USER, mkdir=False, dircheck=False,
+                        _homedir_fn=homedir_fn)
+
+  # get the key file of the master node
+  for (_, (_, public_key_file)) in root_keyfiles.items():
+    try:
+      pub_key = utils.ReadFile(public_key_file)
+      ssh.AddPublicKey(node_uuid_map[master_node], pub_key,
+                       key_file=pub_key_file)
+    except IOError:
+      # Not all types of keys might be existing
+      pass
+
+  # get the key files of all non-master nodes
+  for node in nonmaster_nodes:
+    fetched_keys = ssh.ReadRemoteSshPubKeys(root_keyfiles, node, cluster_name,
+                                            ssh_port_map[node],
+                                            options.ssh_key_check,
+                                            options.ssh_key_check)
+    for pub_key in fetched_keys.values():
+      ssh.AddPublicKey(node_uuid_map[node], pub_key, key_file=pub_key_file)
+
+
 def RenewCrypto(opts, args):
   """Renews cluster certificates, keys and secrets.
 
   """
+  if opts.new_ssh_keys:
+    _BuildGanetiPubKeys(opts)
   return _RenewCrypto(opts.new_cluster_cert,
                       opts.new_rapi_cert,
                       opts.rapi_cert,
@@ -1069,7 +1128,8 @@ def RenewCrypto(opts, args):
                       opts.new_cluster_domain_secret,
                       opts.cluster_domain_secret,
                       opts.force,
-                      opts.new_node_cert)
+                      opts.new_node_cert,
+                      opts.new_ssh_keys)
 
 
 def _GetEnabledDiskTemplates(opts):
@@ -2258,7 +2318,7 @@ commands = {
      NEW_CONFD_HMAC_KEY_OPT, FORCE_OPT,
      NEW_CLUSTER_DOMAIN_SECRET_OPT, CLUSTER_DOMAIN_SECRET_OPT,
      NEW_SPICE_CERT_OPT, SPICE_CERT_OPT, SPICE_CACERT_OPT,
-     NEW_NODE_CERT_OPT],
+     NEW_NODE_CERT_OPT, NEW_SSH_KEY_OPT, NOSSH_KEYCHECK_OPT],
     "[opts...]",
     "Renews cluster certificates, keys and secrets"),
   "epo": (
diff --git a/lib/cmdlib/cluster.py b/lib/cmdlib/cluster.py
index 9b2e155..3a5508f 100644
--- a/lib/cmdlib/cluster.py
+++ b/lib/cmdlib/cluster.py
@@ -99,6 +99,32 @@ class LUClusterRenewCrypto(NoHooksLU):
 
   """
 
+  REQ_BGL = False
+
+  def ExpandNames(self):
+    self.needed_locks = {
+      locking.LEVEL_NODE: locking.ALL_SET,
+      locking.LEVEL_NODE_RES: locking.ALL_SET,
+      locking.LEVEL_NODE_ALLOC: locking.ALL_SET,
+    }
+    self.share_locks = ShareAll()
+    self.share_locks[locking.LEVEL_NODE] = 0
+    self.share_locks[locking.LEVEL_NODE_RES] = 0
+    self.share_locks[locking.LEVEL_NODE_ALLOC] = 0
+
+  def CheckPrereq(self):
+    """Check prerequisites.
+
+    This checks whether the cluster is empty.
+
+    Any errors are signaled by raising errors.OpPrereqError.
+
+    """
+    self._ssh_renewal_suppressed = False
+    if not self.cfg.GetClusterInfo().modify_ssh_setup:
+      if self.op.ssh_keys:
+        self._ssh_renewal_suppressed = True
+
   def _RenewNodeSslCertificates(self):
     """Renews the nodes' SSL certificates.
 
@@ -132,9 +158,35 @@ class LUClusterRenewCrypto(NoHooksLU):
     self.cfg.RemoveNodeFromCandidateCerts("%s-SERVER" % master_uuid)
     self.cfg.RemoveNodeFromCandidateCerts("%s-OLDMASTER" % master_uuid)
 
+  def _RenewSshKeys(self):
+    """Renew all nodes' SSH keys.
+
+    """
+    master_uuid = self.cfg.GetMasterNode()
+
+    nodes = self.cfg.GetAllNodesInfo()
+    nodes_uuid_names = [(node_uuid, node_info.name) for (node_uuid, node_info)
+                        in nodes.items() if not node_info.offline]
+    node_names = [name for (_, name) in nodes_uuid_names]
+    node_uuids = [uuid for (uuid, _) in nodes_uuid_names]
+    port_map = ssh.GetSshPortMap(node_names, self.cfg)
+    potential_master_candidates = self.cfg.GetPotentialMasterCandidates()
+    master_candidate_uuids = self.cfg.GetMasterCandidateUuids()
+    result = self.rpc.call_node_ssh_keys_renew(
+      [master_uuid],
+      node_uuids, node_names, port_map,
+      master_candidate_uuids,
+      potential_master_candidates)
+    result[master_uuid].Raise("Could not renew the SSH keys of all nodes")
+
   def Exec(self, feedback_fn):
     if self.op.node_certificates:
       self._RenewNodeSslCertificates()
+    if self.op.ssh_keys and not self._ssh_renewal_suppressed:
+      self._RenewSshKeys()
+    elif self._ssh_renewal_suppressed:
+      feedback_fn("Cannot renew SSH keys if the cluster is configured to not"
+                  " modify the SSH setup.")
 
 
 class LUClusterActivateMasterIp(NoHooksLU):
diff --git a/lib/rpc_defs.py b/lib/rpc_defs.py
index ee66da0..b944373 100644
--- a/lib/rpc_defs.py
+++ b/lib/rpc_defs.py
@@ -549,6 +549,13 @@ _NODE_CALLS = [
     ("master_candidate_uuids", None, "List of UUIDs of master candidates."),
     ("potential_master_candidates", None, "Potential master candidates")],
     None, None, "Remove a node's SSH key from the other nodes' key files."),
+  ("node_ssh_keys_renew", MULTI, None, constants.RPC_TMO_URGENT, [
+    ("node_uuids", None, "UUIDs of the nodes whose key is renewed"),
+    ("node_names", None, "Names of the nodes whose key is renewed"),
+    ("ssh_port_map", None, "Map of nodes' SSH ports to be used for transfers"),
+    ("master_candidate_uuids", None, "List of UUIDs of master candidates."),
+    ("potential_master_candidates", None, "Potential master candidates")],
+    None, None, "Renew all SSH key pairs of all nodes nodes."),
   ]
 
 _MISC_CALLS = [
diff --git a/lib/server/noded.py b/lib/server/noded.py
index 05af067..05bbc04 100644
--- a/lib/server/noded.py
+++ b/lib/server/noded.py
@@ -922,6 +922,17 @@ class NodeRequestHandler(http.server.HttpServerHandler):
                                  ssh_port_map, potential_master_candidates)
 
   @staticmethod
+  def perspective_node_ssh_keys_renew(params):
+    """Generates a new root SSH key pair on the node.
+
+    """
+    (node_uuids, node_names, ssh_port_map,
+     master_candidate_uuids, potential_master_candidates) = params
+    return backend.RenewSshKeys(node_uuids, node_names, ssh_port_map,
+                                master_candidate_uuids,
+                                potential_master_candidates)
+
+  @staticmethod
   def perspective_node_ssh_key_remove(params):
     """Removes a node's SSH key from the other nodes' SSH files.
 
diff --git a/lib/tools/ssh_update.py b/lib/tools/ssh_update.py
index 41a9de8..052a2e4 100644
--- a/lib/tools/ssh_update.py
+++ b/lib/tools/ssh_update.py
@@ -53,6 +53,7 @@ _DATA_CHECK = ht.TStrictDict(False, True, {
     ht.TItems(
       [ht.TElemOf(constants.SSHS_ACTIONS),
        ht.TDictOf(ht.TNonEmptyString, ht.TListOf(ht.TNonEmptyString))]),
+  constants.SSHS_GENERATE: ht.TBool,
   })
 
 
@@ -143,11 +144,14 @@ def UpdatePubKeyFile(data, dry_run, 
key_file=pathutils.SSH_PUB_KEYS):
       logging.info("This is a dry run, not overriding %s", key_file)
     else:
       ssh.OverridePubKeyFile(public_keys, key_file=key_file)
-  elif action == constants.SSHS_ADD:
+  elif action in [constants.SSHS_ADD, constants.SSHS_REPLACE_OR_ADD]:
     if dry_run:
-      logging.info("This is a dry run, not adding a key to %s", key_file)
+      logging.info("This is a dry run, not adding or replacing a key to %s",
+                   key_file)
     else:
       for uuid, keys in public_keys.items():
+        if action == constants.SSHS_REPLACE_OR_ADD:
+          ssh.RemovePublicKey(uuid, key_file=key_file)
         for key in keys:
           ssh.AddPublicKey(uuid, key, key_file=key_file)
   elif action == constants.SSHS_REMOVE:
@@ -166,6 +170,23 @@ def UpdatePubKeyFile(data, dry_run, 
key_file=pathutils.SSH_PUB_KEYS):
                          % action)
 
 
+def GenerateRootSshKeys(data, dry_run):
+  """(Re-)generates the root SSH keys.
+
+  @type data: dict
+  @param data: Input data
+  @type dry_run: boolean
+  @param dry_run: Whether to perform a dry run
+
+  """
+  generate = data.get(constants.SSHS_GENERATE)
+  if generate:
+    if dry_run:
+      logging.info("This is a dry run, not generating any files.")
+    else:
+      common.GenerateRootSshKeys(SshUpdateError)
+
+
 def Main():
   """Main routine.
 
@@ -181,9 +202,10 @@ def Main():
     common.VerifyClusterName(data, SshUpdateError)
     common.VerifyCertificate(data, SshUpdateError)
 
-    # Update SSH files
+    # Update / Generate SSH files
     UpdateAuthorizedKeys(data, opts.dry_run)
     UpdatePubKeyFile(data, opts.dry_run)
+    GenerateRootSshKeys(data, opts.dry_run)
 
     logging.info("Setup finished successfully")
   except Exception, err: # pylint: disable=W0703
diff --git a/qa/qa_cluster.py b/qa/qa_cluster.py
index ff59eec..408fdb0 100644
--- a/qa/qa_cluster.py
+++ b/qa/qa_cluster.py
@@ -1219,12 +1219,17 @@ def TestClusterRenewCrypto():
     AssertCommand(["gnt-cluster", "renew-crypto", "--force",
                    "--new-cluster-certificate", "--new-confd-hmac-key",
                    "--new-rapi-certificate", "--new-cluster-domain-secret",
-                   "--new-node-certificates"])
+                   "--new-node-certificates", "--new-ssh-keys",
+                   "--no-ssh-key-check"])
 
     # Only renew node certificates
     AssertCommand(["gnt-cluster", "renew-crypto", "--force",
                    "--new-node-certificates"])
 
+    # Only renew SSH keys
+    AssertCommand(["gnt-cluster", "renew-crypto", "--force",
+                   "--new-ssh-keys", "--no-ssh-key-check"])
+
     # Restore RAPI certificate
     AssertCommand(["gnt-cluster", "renew-crypto", "--force",
                    "--rapi-certificate=%s" % rapi_cert_backup])
diff --git a/src/Ganeti/Constants.hs b/src/Ganeti/Constants.hs
index 0557cb4..b6d9833 100644
--- a/src/Ganeti/Constants.hs
+++ b/src/Ganeti/Constants.hs
@@ -4465,6 +4465,9 @@ sshsNodeDaemonCertificate = "node_daemon_certificate"
 sshsAdd :: String
 sshsAdd = "add"
 
+sshsReplaceOrAdd :: String
+sshsReplaceOrAdd = "replace_or_add"
+
 sshsRemove :: String
 sshsRemove = "remove"
 
@@ -4474,8 +4477,15 @@ sshsOverride = "override"
 sshsClear :: String
 sshsClear = "clear"
 
+sshsGenerate :: String
+sshsGenerate = "generate"
+
 sshsActions :: FrozenSet String
-sshsActions = ConstantUtils.mkSet [sshsAdd, sshsRemove, sshsOverride, 
sshsClear]
+sshsActions = ConstantUtils.mkSet [ sshsAdd
+                                  , sshsRemove
+                                  , sshsOverride
+                                  , sshsClear
+                                  , sshsReplaceOrAdd]
 
 -- * Key files for SSH daemon
 
diff --git a/src/Ganeti/OpCodes.hs b/src/Ganeti/OpCodes.hs
index 0fdcc05..91cc0b4 100644
--- a/src/Ganeti/OpCodes.hs
+++ b/src/Ganeti/OpCodes.hs
@@ -267,6 +267,7 @@ $(genOpCode "OpCode"
      [t| () |],
      OpDoc.opClusterRenewCrypto,
      [ pNodeSslCerts
+     , pSshKeys
      ],
      [])
   , ("OpQuery",
diff --git a/src/Ganeti/OpParams.hs b/src/Ganeti/OpParams.hs
index 8bb7f70..ae5f03b 100644
--- a/src/Ganeti/OpParams.hs
+++ b/src/Ganeti/OpParams.hs
@@ -283,6 +283,7 @@ module Ganeti.OpParams
   , pEnabledUserShutdown
   , pAdminStateSource
   , pNodeSslCerts
+  , pSshKeys
   ) where
 
 import Control.Monad (liftM, mplus)
@@ -1832,3 +1833,9 @@ pNodeSslCerts =
   withDoc "Whether to renew node SSL certificates" .
   defaultField [| False |] $
   simpleField "node_certificates" [t| Bool |]
+
+pSshKeys :: Field
+pSshKeys =
+  withDoc "Whether to renew SSH keys" .
+  defaultField [| False |] $
+  simpleField "ssh_keys" [t| Bool |]
diff --git a/test/hs/Test/Ganeti/OpCodes.hs b/test/hs/Test/Ganeti/OpCodes.hs
index 2594ae0..a78d53c 100644
--- a/test/hs/Test/Ganeti/OpCodes.hs
+++ b/test/hs/Test/Ganeti/OpCodes.hs
@@ -149,7 +149,8 @@ instance Arbitrary OpCodes.OpCode where
       "OP_TAGS_DEL" ->
         arbitraryOpTagsDel
       "OP_CLUSTER_POST_INIT" -> pure OpCodes.OpClusterPostInit
-      "OP_CLUSTER_RENEW_CRYPTO" -> pure OpCodes.OpClusterRenewCrypto
+      "OP_CLUSTER_RENEW_CRYPTO" ->
+        OpCodes.OpClusterRenewCrypto <$> arbitrary <*> arbitrary
       "OP_CLUSTER_DESTROY" -> pure OpCodes.OpClusterDestroy
       "OP_CLUSTER_QUERY" -> pure OpCodes.OpClusterQuery
       "OP_CLUSTER_VERIFY" ->
diff --git a/test/py/ganeti.backend_unittest.py 
b/test/py/ganeti.backend_unittest.py
index 9b18763..5fcc6b5 100755
--- a/test/py/ganeti.backend_unittest.py
+++ b/test/py/ganeti.backend_unittest.py
@@ -941,7 +941,7 @@ class TestSpaceReportingConstants(unittest.TestCase):
       self.assertEqual(None, backend._STORAGE_TYPE_INFO_FN[storage_type])
 
 
-class TestAddAndRemoveNodeSshKey(testutils.GanetiTestCase):
+class TestAddRemoveGenerateNodeSshKey(testutils.GanetiTestCase):
 
   _CLUSTER_NAME = "mycluster"
   _SSH_PORT = 22
@@ -1024,7 +1024,8 @@ class 
TestAddAndRemoveNodeSshKey(testutils.GanetiTestCase):
                    expected_key):
     return self._KeyOperationExecuted(
       key_data, node_name, expected_type, expected_key,
-      [constants.SSHS_ADD, constants.SSHS_OVERRIDE])
+      [constants.SSHS_ADD, constants.SSHS_OVERRIDE,
+       constants.SSHS_REPLACE_OR_ADD])
 
   def _KeyRemoved(self, key_data, node_name, expected_type,
                   expected_key):
@@ -1051,6 +1052,25 @@ class 
TestAddAndRemoveNodeSshKey(testutils.GanetiTestCase):
       calls_per_node[node].append(data)
     return calls_per_node
 
+  def testGenerateKey(self):
+    test_node_name = "node_name_7"
+    test_node_uuid = "node_uuid_7"
+
+    self._SetupTestData()
+
+    backend._GenerateNodeSshKey(test_node_uuid, test_node_name,
+                                self._ssh_port_map,
+                                pub_key_file=self._pub_key_file,
+                                ssconf_store=self._ssconf_mock,
+                                noded_cert_file=self.noded_cert_file,
+                                run_cmd_fn=self._run_cmd_mock)
+
+    calls_per_node = self._GetCallsPerNode()
+    for node, calls in calls_per_node.items():
+      self.assertEquals(node, test_node_name)
+      for call in calls:
+        self.assertTrue(constants.SSHS_GENERATE in call)
+
   def testAddNodeSshKeyValid(self):
     new_node_name = "new_node_name"
     new_node_uuid = "new_node_uuid"
diff --git a/test/py/ganeti.client.gnt_cluster_unittest.py 
b/test/py/ganeti.client.gnt_cluster_unittest.py
index b2ce012..7589baa 100755
--- a/test/py/ganeti.client.gnt_cluster_unittest.py
+++ b/test/py/ganeti.client.gnt_cluster_unittest.py
@@ -23,12 +23,17 @@
 
 import unittest
 import optparse
+import os
+import shutil
+import tempfile
 
 from ganeti import errors
 from ganeti.client import gnt_cluster
 from ganeti import utils
 from ganeti import compat
 from ganeti import constants
+from ganeti import ssh
+from ganeti import cli
 
 import mock
 import testutils
@@ -354,5 +359,108 @@ class GetDrbdHelper(DrbdHelperTestCase):
     self.assertEquals(opts.drbd_helper, helper)
 
 
+class TestBuildGanetiPubKeys(testutils.GanetiTestCase):
+
+  _SOME_KEY_DICT = {"rsa": "key_rsa",
+                    "dsa": "key_dsa"}
+  _MASTER_NODE_NAME = "master_node"
+  _MASTER_NODE_UUID = "master_uuid"
+  _NUM_NODES = 2 # excluding master node
+  _ONLINE_NODE_NAMES = ["node%s_name" % i for i in range(_NUM_NODES)]
+  _ONLINE_NODE_UUIDS = ["node%s_uuid" % i for i in range(_NUM_NODES)]
+  _CLUSTER_NAME = "cluster_name"
+  _PRIV_KEY = "master_private_key"
+  _PUB_KEY = "master_public_key"
+  _AUTH_KEYS = "a\nb\nc"
+
+  def _setUpFakeKeys(self):
+    os.makedirs(os.path.join(self.tmpdir, ".ssh"))
+
+    for key_type in ["rsa", "dsa"]:
+      self.priv_filename = os.path.join(self.tmpdir, ".ssh", "id_%s" % 
key_type)
+      priv_fd = open(self.priv_filename, 'w')
+      priv_fd.write(self._PRIV_KEY)
+      priv_fd.close()
+
+      self.pub_filename = os.path.join(
+        self.tmpdir, ".ssh", "id_%s.pub" % key_type)
+      pub_fd = open(self.pub_filename, 'w')
+      pub_fd.write(self._PUB_KEY)
+      pub_fd.close()
+
+    self.auth_filename = os.path.join(self.tmpdir, ".ssh", "authorized_keys")
+    auth_fd = open(self.auth_filename, 'w')
+    auth_fd.write(self._AUTH_KEYS)
+    auth_fd.close()
+
+  def setUp(self):
+    testutils.GanetiTestCase.setUp(self)
+    self.tmpdir = tempfile.mkdtemp()
+    self.pub_key_filename = os.path.join(self.tmpdir, "ganeti_test_pub_keys")
+    self._setUpFakeKeys()
+
+    self._ssh_read_remote_ssh_pub_keys_patcher = testutils \
+      .patch_object(ssh, "ReadRemoteSshPubKeys")
+    self._ssh_read_remote_ssh_pub_keys_mock = \
+      self._ssh_read_remote_ssh_pub_keys_patcher.start()
+    self._ssh_read_remote_ssh_pub_keys_mock.return_value = self._SOME_KEY_DICT
+
+    self.mock_cl = mock.Mock()
+    self.mock_cl.QueryConfigValues = mock.Mock()
+    self.mock_cl.QueryConfigValues.return_value = \
+      (self._CLUSTER_NAME, self._MASTER_NODE_NAME)
+
+    self._get_online_nodes_mock = mock.Mock()
+    self._get_online_nodes_mock.return_value = \
+      self._ONLINE_NODE_NAMES
+
+    self._get_nodes_ssh_ports_mock = mock.Mock()
+    self._get_nodes_ssh_ports_mock.return_value = \
+      [22 for i in range(self._NUM_NODES + 1)]
+
+    self._get_node_uuids_mock = mock.Mock()
+    self._get_node_uuids_mock.return_value = \
+      self._ONLINE_NODE_UUIDS + [self._MASTER_NODE_UUID]
+
+    self._options = mock.Mock()
+    self._options.ssh_key_check = False
+
+  def _GetTempHomedir(self, _):
+    return self.tmpdir
+
+  def tearDown(self):
+    super(testutils.GanetiTestCase, self).tearDown()
+    shutil.rmtree(self.tmpdir)
+    self._ssh_read_remote_ssh_pub_keys_patcher.stop()
+
+  def testNewPubKeyFile(self):
+    gnt_cluster._BuildGanetiPubKeys(
+      self._options,
+      pub_key_file=self.pub_key_filename,
+      cl=self.mock_cl,
+      get_online_nodes_fn=self._get_online_nodes_mock,
+      get_nodes_ssh_ports_fn=self._get_nodes_ssh_ports_mock,
+      get_node_uuids_fn=self._get_node_uuids_mock,
+      homedir_fn=self._GetTempHomedir)
+    key_file_result = utils.ReadFile(self.pub_key_filename)
+    for node_uuid in self._ONLINE_NODE_UUIDS + [self._MASTER_NODE_UUID]:
+      self.assertTrue(node_uuid in key_file_result)
+    self.assertTrue(self._PUB_KEY in key_file_result)
+
+  def testOverridePubKeyFile(self):
+    fd = open(self.pub_key_filename, "w")
+    fd.write("Pink Bunny")
+    fd.close()
+    gnt_cluster._BuildGanetiPubKeys(
+      self._options,
+      pub_key_file=self.pub_key_filename,
+      cl=self.mock_cl,
+      get_online_nodes_fn=self._get_online_nodes_mock,
+      get_nodes_ssh_ports_fn=self._get_nodes_ssh_ports_mock,
+      get_node_uuids_fn=self._get_node_uuids_mock,
+      homedir_fn=self._GetTempHomedir)
+    self.assertFalse("Pink" in self.pub_key_filename)
+
+
 if __name__ == "__main__":
   testutils.GanetiTestProgram()
diff --git a/test/py/ganeti.mcpu_unittest.py b/test/py/ganeti.mcpu_unittest.py
index 02a97ef..27549d8 100755
--- a/test/py/ganeti.mcpu_unittest.py
+++ b/test/py/ganeti.mcpu_unittest.py
@@ -46,7 +46,6 @@ REQ_BGL_WHITELIST = compat.UniqueFrozenset([
   opcodes.OpClusterDestroy,
   opcodes.OpClusterPostInit,
   opcodes.OpClusterRename,
-  opcodes.OpClusterRenewCrypto,
   opcodes.OpNodeAdd,
   opcodes.OpNodeRemove,
   opcodes.OpTestAllocator,
diff --git a/tools/post-upgrade b/tools/post-upgrade
index 6aec3ff..92ad7b9 100644
--- a/tools/post-upgrade
+++ b/tools/post-upgrade
@@ -48,6 +48,14 @@ def main():
     if result.failed:
       cli.ToStderr("Failed to create node certificates: %s; Output %s" %
                    (result.fail_reason, result.output))
+
+  if utils.version.IsBefore(version, 2, 13, 0):
+    result = utils.RunCmd(["gnt-cluster", "renew-crypto",
+                           "--new-ssh-keys", "--no-ssh-key-check", "-f"])
+    if result.failed:
+      cli.ToStderr("Failed to create node certificates: %s; Output %s" %
+                   (result.fail_reason, result.output))
+
   return 0
 
 if __name__ == "__main__":
-- 
2.0.0.526.g5318336

Reply via email to