This patch adjusts the SSH connectivity test that
'gnt-cluster verify' does and introduces a couple of
sanity checks for the new SSH setup with individual
keys.

Note that it won't be possible for this to always hold
through the entire patch series. I decided to put it in
anyway, because it a great debugging tool during the
development itself as keeping track of the states of
various key files is tedious manual work.

Signed-off-by: Helga Velroyen <hel...@google.com>
---
 lib/backend.py                     | 114 +++++++++++++++++++++++++++++++++++--
 lib/cmdlib/cluster.py              |  50 +++++++++++++++-
 lib/cmdlib/node.py                 |   2 +-
 lib/ssh.py                         |  28 +++++++++
 src/Ganeti/Constants.hs            |   3 +
 test/py/cmdlib/cluster_unittest.py |  67 ++++++++++++++--------
 test/py/ganeti.backend_unittest.py |  88 ++++++++++++++++++++++++++++
 test/py/ganeti.ssh_unittest.py     |   5 ++
 8 files changed, 326 insertions(+), 31 deletions(-)

diff --git a/lib/backend.py b/lib/backend.py
index d9526c4..717298c 100644
--- a/lib/backend.py
+++ b/lib/backend.py
@@ -948,6 +948,100 @@ def 
_VerifyClientCertificate(cert_file=pathutils.NODED_CLIENT_CERT_FILE):
     return (None, utils.GetCertificateDigest(cert_filename=cert_file))
 
 
+def _VerifySshSetup(node_status_list, my_name):
+  """Verifies the state of the SSH key files.
+
+  @type node_status_list: list of tuples
+  @param node_status_list: list of nodes of the cluster associated with a
+    couple of flags: (uuid, name, is_master_candidate,
+    is_potential_master_candidate, online)
+  @type my_name: str
+  @param my_name: name of this node
+
+  """
+  if node_status_list is None:
+    return ["No node list to check against the pub_key_file received."]
+
+  my_status_list = [(my_uuid, name, mc, pot_mc) for (my_uuid, name, mc, pot_mc)
+                    in node_status_list if name == my_name]
+  if len(my_status_list) < 0:
+    return ["Cannot find node information for node '%s'." % my_name]
+  (my_uuid, _, _, potential_master_candidate) = \
+     my_status_list[0]
+
+  result = []
+  pot_mc_uuids = [uuid for (uuid, _, _, _) in node_status_list]
+  pub_keys = ssh.QueryPubKeyFile(None)
+
+  if potential_master_candidate:
+    # Check that the set of potential master candidates matches the
+    # public key file
+    pub_uuids_set = set(pub_keys.keys())
+    pot_mc_uuids_set = set(pot_mc_uuids)
+    missing_uuids = set([])
+    if pub_uuids_set != pot_mc_uuids_set:
+      unknown_uuids = pub_uuids_set - pot_mc_uuids_set
+      if unknown_uuids:
+        result.append("The following node UUIDs are listed in the public key"
+                      " file on node '%s', but are not potential master"
+                      " candidates: %s."
+                      % (my_name, ", ".join(list(unknown_uuids))))
+      missing_uuids = pot_mc_uuids_set - pub_uuids_set
+      if missing_uuids:
+        result.append("The following node UUIDs of potential master candidates"
+                      " are missing in the public key file on node %s: %s."
+                      % (my_name, ", ".join(list(missing_uuids))))
+
+    (_, key_files) = \
+      ssh.GetAllUserFiles(constants.SSH_LOGIN_USER, mkdir=False, 
dircheck=False)
+    my_keys = pub_keys[my_uuid]
+    num_keys = 0
+    for (key_type, (_, pub_key_file)) in key_files.items():
+      try:
+        pub_key = utils.ReadFile(pub_key_file)
+        if pub_key.strip() not in my_keys:
+          result.append("The %s key of node %s does not match this node's keys"
+                        " in the pub key file." % (key_type, my_name))
+        num_keys += 1
+      except IOError:
+        # There might not be keys of every type.
+        pass
+    if num_keys != len(my_keys):
+      result.append("The number of keys for node %s in the public key file"
+                    " (%s) does not match the number of keys on the node"
+                    " (%s)." % (my_name, len(my_keys), len(key_files)))
+  else:
+    if len(pub_keys.keys()) > 0:
+      result.append("The public key file of node '%s' is not empty, although"
+                    " the node is not a potential master candidate."
+                    % my_name)
+
+  # Check that all master candidate keys are in the authorized_keys file
+  (auth_key_file, _) = \
+    ssh.GetAllUserFiles(constants.SSH_LOGIN_USER, mkdir=False, dircheck=False)
+  for (uuid, name, mc, _) in node_status_list:
+    if uuid in missing_uuids:
+      continue
+    if mc:
+      for key in pub_keys[uuid]:
+        if not ssh.HasAuthorizedKey(auth_key_file, key):
+          result.append("A SSH key of master candidate '%s' (UUID: '%s') is"
+                        " not in the 'authorized_keys' file of node '%s'."
+                        % (name, uuid, my_name))
+    else:
+      for key in pub_keys[uuid]:
+        if name != my_name and ssh.HasAuthorizedKey(auth_key_file, key):
+          result.append("A SSH key of normal node '%s' (UUID: '%s') is in the"
+                        " 'authorized_keys' file of node '%s'."
+                        % (name, uuid, my_name))
+        if name == my_name and not ssh.HasAuthorizedKey(auth_key_file, key):
+          result.append("A SSH key of normal node '%s' (UUID: '%s') is not"
+                        " in the 'authorized_keys' file of itself."
+                        % (my_name, uuid))
+
+  return result
+
+
 def VerifyNode(what, cluster_name, all_hvparams, node_groups, groups_cfg):
   """Verify the status of the local node.
 
@@ -1004,8 +1098,12 @@ def VerifyNode(what, cluster_name, all_hvparams, 
node_groups, groups_cfg):
   if constants.NV_CLIENT_CERT in what:
     result[constants.NV_CLIENT_CERT] = _VerifyClientCertificate()
 
+  if constants.NV_SSH_SETUP in what:
+    result[constants.NV_SSH_SETUP] = \
+      _VerifySshSetup(what[constants.NV_SSH_SETUP], my_name)
+
   if constants.NV_NODELIST in what:
-    (nodes, bynode) = what[constants.NV_NODELIST]
+    (nodes, bynode, mcs) = what[constants.NV_NODELIST]
 
     # Add nodes from other groups (different for each node)
     try:
@@ -1023,10 +1121,16 @@ def VerifyNode(what, cluster_name, all_hvparams, 
node_groups, groups_cfg):
       ssh_port = params["ndparams"].get(constants.ND_SSH_PORT)
       logging.debug("Ssh port %s (None = default) for node %s",
                     str(ssh_port), node)
-      success, message = _GetSshRunner(cluster_name). \
-                            VerifyNodeHostname(node, ssh_port)
-      if not success:
-        val[node] = message
+
+      # We only test if master candidates can communicate to other nodes.
+      # We cannot test if normal nodes cannot communicate with other nodes,
+      # because the administrator might have installed additional SSH keys,
+      # over which Ganeti has no power.
+      if my_name in mcs:
+        success, message = _GetSshRunner(cluster_name). \
+                              VerifyNodeHostname(node, ssh_port)
+        if not success:
+          val[node] = message
 
     result[constants.NV_NODELIST] = val
 
diff --git a/lib/cmdlib/cluster.py b/lib/cmdlib/cluster.py
index 915c418..d0c54de 100644
--- a/lib/cmdlib/cluster.py
+++ b/lib/cmdlib/cluster.py
@@ -2682,6 +2682,27 @@ class LUClusterVerifyGroup(LogicalUnit, _VerifyErrors):
             " certificate of another node which is master candidate.",
             node.uuid)
 
+  def _VerifySshSetup(self, nodes, all_nvinfo):
+    """Evaluates the verification results of the SSH setup.
+
+    @param nodes: List of L{objects.Node} objects
+    @param all_nvinfo: RPC results
+
+    """
+    for node in nodes:
+      if not node.offline:
+        nresult = all_nvinfo[node.uuid]
+        if nresult.fail_msg or not nresult.payload:
+          self._ErrorIf(True, constants.CV_ENODESSH, node.name,
+                        "Could not verify the SSH setup of this node.")
+          return
+        result = nresult.payload.get(constants.NV_SSH_SETUP, None)
+        error_msg = ""
+        if isinstance(result, list):
+          error_msg = " ".join(result)
+        self._ErrorIf(result,
+                      constants.CV_ENODESSH, None, error_msg)
+
   def _VerifyFiles(self, nodes, master_node_uuid, all_nvinfo,
                    (files_all, files_opt, files_mc, files_vm)):
     """Verifies file checksums collected from all nodes.
@@ -3286,17 +3307,38 @@ class LUClusterVerifyGroup(LogicalUnit, _VerifyErrors):
     We will make nodes contact all nodes in their group, and one node from
     every other group.
 
+    @rtype: tuple of (string, dict of strings to list of strings, string)
+    @return: a tuple containing the list of all online nodes, a dictionary
+      mapping node names to additional nodes of other node groups to which
+      connectivity should be tested, and a list of all online master
+      candidates
+
     @warning: This algorithm has a known issue if one node group is much
       smaller than others (e.g. just one node). In such a case all other
       nodes will talk to the single node.
 
     """
     online_nodes = sorted(node.name for node in group_nodes if not 
node.offline)
+    online_mcs = sorted(node.name for node in group_nodes
+                        if (node.master_candidate and not node.offline))
     sel = cls._SshNodeSelector(group_uuid, all_nodes)
 
     return (online_nodes,
             dict((name, sorted([i.next() for i in sel]))
-                 for name in online_nodes))
+                 for name in online_nodes),
+            online_mcs)
+
+  def _PrepareSshSetupCheck(self):
+    """Prepare the input data for the SSH setup verification.
+
+    """
+    all_nodes_info = self.cfg.GetAllNodesInfo()
+    potential_master_candidates = self.cfg.GetPotentialMasterCandidates()
+    node_status = [
+      (uuid, node_info.name, node_info.master_candidate,
+       node_info.name in potential_master_candidates)
+      for (uuid, node_info) in all_nodes_info.items()]
+    return node_status
 
   def BuildHooksEnv(self):
     """Build hooks env.
@@ -3391,6 +3433,9 @@ class LUClusterVerifyGroup(LogicalUnit, _VerifyErrors):
       constants.NV_CLIENT_CERT: None,
       }
 
+    if self.cfg.GetClusterInfo().modify_ssh_setup:
+      node_verify_param[constants.NV_SSH_SETUP] = self._PrepareSshSetupCheck()
+
     if vg_name is not None:
       node_verify_param[constants.NV_VGLIST] = None
       node_verify_param[constants.NV_LVLIST] = vg_name
@@ -3573,7 +3618,8 @@ class LUClusterVerifyGroup(LogicalUnit, _VerifyErrors):
     feedback_fn("* Verifying configuration file consistency")
 
     self._VerifyClientCertificates(self.my_node_info.values(), all_nvinfo)
-
+    if self.cfg.GetClusterInfo().modify_ssh_setup:
+      self._VerifySshSetup(self.my_node_info.values(), all_nvinfo)
     self._VerifyFiles(vf_node_info, master_node_uuid, vf_nvinfo, filemap)
 
     feedback_fn("* Verifying node status")
diff --git a/lib/cmdlib/node.py b/lib/cmdlib/node.py
index 9badcba..823ebb6 100644
--- a/lib/cmdlib/node.py
+++ b/lib/cmdlib/node.py
@@ -420,7 +420,7 @@ class LUNodeAdd(LogicalUnit):
 
     node_verifier_uuids = [self.cfg.GetMasterNode()]
     node_verify_param = {
-      constants.NV_NODELIST: ([self.new_node.name], {}),
+      constants.NV_NODELIST: ([self.new_node.name], {}, []),
       # TODO: do a node-net-test as well?
     }
 
diff --git a/lib/ssh.py b/lib/ssh.py
index 78adce6..7e2f64c 100644
--- a/lib/ssh.py
+++ b/lib/ssh.py
@@ -173,6 +173,34 @@ def AddAuthorizedKeys(file_obj, keys):
     f.close()
 
 
+def HasAuthorizedKey(file_obj, key):
+  """Check if a particular key is in the 'authorized_keys' file.
+
+  @type file_obj: str or file handle
+  @param file_obj: path to authorized_keys file
+  @type key: str
+  @param key: string containing key
+
+  """
+  key_fields = _SplitSshKey(key)
+
+  if isinstance(file_obj, basestring):
+    f = open(file_obj, "r")
+  else:
+    f = file_obj
+
+  try:
+    for line in f:
+      # Ignore whitespace changes
+      line_key = _SplitSshKey(line)
+      if line_key == key_fields:
+        return True
+  finally:
+    f.close()
+
+  return False
+
+
 def AddAuthorizedKey(file_obj, key):
   """Adds an SSH public key to an authorized_keys file.
 
diff --git a/src/Ganeti/Constants.hs b/src/Ganeti/Constants.hs
index 36227e6..4a2db43 100644
--- a/src/Ganeti/Constants.hs
+++ b/src/Ganeti/Constants.hs
@@ -3209,6 +3209,9 @@ nvVglist = "vglist"
 nvVmnodes :: String
 nvVmnodes = "vmnodes"
 
+nvSshSetup :: String
+nvSshSetup = "ssh-setup"
+
 -- * Instance status
 
 inststAdmindown :: String
diff --git a/test/py/cmdlib/cluster_unittest.py 
b/test/py/cmdlib/cluster_unittest.py
index d0a6542..fa0e655 100644
--- a/test/py/cmdlib/cluster_unittest.py
+++ b/test/py/cmdlib/cluster_unittest.py
@@ -50,29 +50,46 @@ class TestClusterVerifySsh(unittest.TestCase):
   def testMultipleGroups(self):
     fn = cluster.LUClusterVerifyGroup._SelectSshCheckNodes
     mygroupnodes = [
-      objects.Node(name="node20", group="my", offline=False),
-      objects.Node(name="node21", group="my", offline=False),
-      objects.Node(name="node22", group="my", offline=False),
-      objects.Node(name="node23", group="my", offline=False),
-      objects.Node(name="node24", group="my", offline=False),
-      objects.Node(name="node25", group="my", offline=False),
-      objects.Node(name="node26", group="my", offline=True),
+      objects.Node(name="node20", group="my", offline=False,
+                   master_candidate=True),
+      objects.Node(name="node21", group="my", offline=False,
+                   master_candidate=True),
+      objects.Node(name="node22", group="my", offline=False,
+                   master_candidate=False),
+      objects.Node(name="node23", group="my", offline=False,
+                   master_candidate=True),
+      objects.Node(name="node24", group="my", offline=False,
+                   master_candidate=True),
+      objects.Node(name="node25", group="my", offline=False,
+                   master_candidate=False),
+      objects.Node(name="node26", group="my", offline=True,
+                   master_candidate=True),
       ]
     nodes = [
-      objects.Node(name="node1", group="g1", offline=True),
-      objects.Node(name="node2", group="g1", offline=False),
-      objects.Node(name="node3", group="g1", offline=False),
-      objects.Node(name="node4", group="g1", offline=True),
-      objects.Node(name="node5", group="g1", offline=False),
-      objects.Node(name="node10", group="xyz", offline=False),
-      objects.Node(name="node11", group="xyz", offline=False),
-      objects.Node(name="node40", group="alloff", offline=True),
-      objects.Node(name="node41", group="alloff", offline=True),
-      objects.Node(name="node50", group="aaa", offline=False),
+      objects.Node(name="node1", group="g1", offline=True,
+                   master_candidate=True),
+      objects.Node(name="node2", group="g1", offline=False,
+                   master_candidate=False),
+      objects.Node(name="node3", group="g1", offline=False,
+                   master_candidate=True),
+      objects.Node(name="node4", group="g1", offline=True,
+                   master_candidate=True),
+      objects.Node(name="node5", group="g1", offline=False,
+                   master_candidate=True),
+      objects.Node(name="node10", group="xyz", offline=False,
+                   master_candidate=True),
+      objects.Node(name="node11", group="xyz", offline=False,
+                   master_candidate=True),
+      objects.Node(name="node40", group="alloff", offline=True,
+                   master_candidate=True),
+      objects.Node(name="node41", group="alloff", offline=True,
+                   master_candidate=True),
+      objects.Node(name="node50", group="aaa", offline=False,
+                   master_candidate=True),
       ] + mygroupnodes
     assert not utils.FindDuplicates(map(operator.attrgetter("name"), nodes))
 
-    (online, perhost) = fn(mygroupnodes, "my", nodes)
+    (online, perhost, _) = fn(mygroupnodes, "my", nodes)
     self.assertEqual(online, ["node%s" % i for i in range(20, 26)])
     self.assertEqual(set(perhost.keys()), set(online))
 
@@ -88,14 +105,18 @@ class TestClusterVerifySsh(unittest.TestCase):
   def testSingleGroup(self):
     fn = cluster.LUClusterVerifyGroup._SelectSshCheckNodes
     nodes = [
-      objects.Node(name="node1", group="default", offline=True),
-      objects.Node(name="node2", group="default", offline=False),
-      objects.Node(name="node3", group="default", offline=False),
-      objects.Node(name="node4", group="default", offline=True),
+      objects.Node(name="node1", group="default", offline=True,
+                   master_candidate=True),
+      objects.Node(name="node2", group="default", offline=False,
+                   master_candidate=True),
+      objects.Node(name="node3", group="default", offline=False,
+                   master_candidate=True),
+      objects.Node(name="node4", group="default", offline=True,
+                   master_candidate=True),
       ]
     assert not utils.FindDuplicates(map(operator.attrgetter("name"), nodes))
 
-    (online, perhost) = fn(nodes, "default", nodes)
+    (online, perhost, _) = fn(nodes, "default", nodes)
     self.assertEqual(online, ["node2", "node3"])
     self.assertEqual(set(perhost.keys()), set(online))
 
diff --git a/test/py/ganeti.backend_unittest.py 
b/test/py/ganeti.backend_unittest.py
index 6e54273..9b18763 100755
--- a/test/py/ganeti.backend_unittest.py
+++ b/test/py/ganeti.backend_unittest.py
@@ -21,6 +21,7 @@
 
 """Script for testing ganeti.backend"""
 
+import copy
 import mock
 import os
 import shutil
@@ -1275,7 +1276,94 @@ class TestVerifySshSetup(testutils.GanetiTestCase):
   _NODE2_NAME = "name2"
   _NODE3_NAME = "name3"
   _NODE1_KEYS = ["key11", "key12"]
+  _NODE2_KEYS = ["key21"]
+  _NODE3_KEYS = ["key31"]
+
+  _NODE_STATUS_LIST = [
+    (_NODE1_UUID, _NODE1_NAME, True, True),
+    (_NODE2_UUID, _NODE2_NAME, False, True),
+    (_NODE3_UUID, _NODE3_NAME, False, False),
+    ]
+
+  _PUB_KEY_RESULT = {
+    _NODE1_UUID: _NODE1_KEYS,
+    _NODE2_UUID: _NODE2_KEYS,
+    _NODE3_UUID: _NODE3_KEYS,
+    }
+
+  _AUTH_RESULT = {
+    _NODE1_KEYS[0]: True,
+    _NODE1_KEYS[1]: True,
+    _NODE2_KEYS[0]: False,
+    _NODE3_KEYS[0]: False,
+  }
 
+  def setUp(self):
+    testutils.GanetiTestCase.setUp(self)
+    self._has_authorized_patcher = testutils \
+      .patch_object(ssh, "HasAuthorizedKey")
+    self._has_authorized_mock = self._has_authorized_patcher.start()
+    self._query_patcher = testutils \
+      .patch_object(ssh, "QueryPubKeyFile")
+    self._query_mock = self._query_patcher.start()
+    self._read_file_patcher = testutils \
+      .patch_object(utils, "ReadFile")
+    self._read_file_mock = self._read_file_patcher.start()
+    self._read_file_mock.return_value = self._NODE1_KEYS[0]
+
+  def tearDown(self):
+    super(testutils.GanetiTestCase, self).tearDown()
+    self._has_authorized_patcher.stop()
+    self._query_patcher.stop()
+    self._read_file_patcher.stop()
+
+  def testValidData(self):
+    self._has_authorized_mock.side_effect = \
+      lambda _, key : self._AUTH_RESULT[key]
+    self._query_mock.return_value = self._PUB_KEY_RESULT
+    result = backend._VerifySshSetup(self._NODE_STATUS_LIST,
+                                     self._NODE1_NAME)
+    self.assertEqual(result, [])
+
+  def testMissingKey(self):
+    self._has_authorized_mock.side_effect = \
+      lambda _, key : self._AUTH_RESULT[key]
+    pub_key_missing = copy.deepcopy(self._PUB_KEY_RESULT)
+    del pub_key_missing[self._NODE2_UUID]
+    self._query_mock.return_value = pub_key_missing
+    result = backend._VerifySshSetup(self._NODE_STATUS_LIST,
+                                     self._NODE1_NAME)
+    self.assertTrue(self._NODE2_UUID in result[0])
+
+  def testUnknownKey(self):
+    self._has_authorized_mock.side_effect = \
+      lambda _, key : self._AUTH_RESULT[key]
+    pub_key_missing = copy.deepcopy(self._PUB_KEY_RESULT)
+    pub_key_missing["unkownnodeuuid"] = "pinkbunny"
+    self._query_mock.return_value = pub_key_missing
+    result = backend._VerifySshSetup(self._NODE_STATUS_LIST,
+                                     self._NODE1_NAME)
+    self.assertTrue("unkownnodeuuid" in result[0])
+
+  def testMissingMasterCandidate(self):
+    auth_result = copy.deepcopy(self._AUTH_RESULT)
+    auth_result["key12"] = False
+    self._has_authorized_mock.side_effect = \
+      lambda _, key : auth_result[key]
+    self._query_mock.return_value = self._PUB_KEY_RESULT
+    result = backend._VerifySshSetup(self._NODE_STATUS_LIST,
+                                     self._NODE1_NAME)
+    self.assertTrue(self._NODE1_UUID in result[0])
+
+  def testSuperfluousNormalNode(self):
+    auth_result = copy.deepcopy(self._AUTH_RESULT)
+    auth_result["key31"] = True
+    self._has_authorized_mock.side_effect = \
+      lambda _, key : auth_result[key]
+    self._query_mock.return_value = self._PUB_KEY_RESULT
+    result = backend._VerifySshSetup(self._NODE_STATUS_LIST,
+                                     self._NODE1_NAME)
+    self.assertTrue(self._NODE3_UUID in result[0])
 
 
 if __name__ == "__main__":
diff --git a/test/py/ganeti.ssh_unittest.py b/test/py/ganeti.ssh_unittest.py
index 9abc629..b0ba9dd 100755
--- a/test/py/ganeti.ssh_unittest.py
+++ b/test/py/ganeti.ssh_unittest.py
@@ -165,6 +165,11 @@ class TestSshKeys(testutils.GanetiTestCase):
     finally:
       handle.close()
 
+  def testHasAuthorizedKey(self):
+    self.assertTrue(ssh.HasAuthorizedKey(self.tmpname, self.KEY_A))
+    self.assertFalse(ssh.HasAuthorizedKey(
+      self.tmpname, "I am the key of the pink bunny!"))
+
   def testAddingNewKey(self):
     ssh.AddAuthorizedKey(self.tmpname,
                          "ssh-dss AAAAB3NzaC1kc3MAAACB root@test")
-- 
2.1.0.rc2.206.gedb03e5

Reply via email to