LGTM, thanks

On Tue, Sep 02, 2014 at 04:19:29PM +0200, 'Helga Velroyen' via ganeti-devel 
wrote:
This patch introduced infrastructure to handle the
newly introduced file of public SSH keys of potential
master candidates (as described in
"design-node-security.rst"). It supports the operation
to add and remove keys and to query the file for a set of
keys. In this patch it does not get called by any code yet;
this will be done in the next patches. Unit tests are
included.

Signed-off-by: Helga Velroyen <[email protected]>
---
lib/pathutils.py               |   1 +
lib/ssh.py                     | 349 +++++++++++++++++++++++++++++++++++++++++
test/py/ganeti.ssh_unittest.py | 117 ++++++++++++++
3 files changed, 467 insertions(+)

diff --git a/lib/pathutils.py b/lib/pathutils.py
index 0a1d962..2715504 100644
--- a/lib/pathutils.py
+++ b/lib/pathutils.py
@@ -75,6 +75,7 @@ SSH_HOST_DSA_PRIV = _constants.SSH_HOST_DSA_PRIV
SSH_HOST_DSA_PUB = _constants.SSH_HOST_DSA_PUB
SSH_HOST_RSA_PRIV = _constants.SSH_HOST_RSA_PRIV
SSH_HOST_RSA_PUB = _constants.SSH_HOST_RSA_PUB
+SSH_PUB_KEYS = DATA_DIR + "/ganeti_pub_keys"

BDEV_CACHE_DIR = RUN_DIR + "/bdev-cache"
DISK_LINKS_DIR = RUN_DIR + "/instance-disks"
diff --git a/lib/ssh.py b/lib/ssh.py
index d54684b..0e92317 100644
--- a/lib/ssh.py
+++ b/lib/ssh.py
@@ -28,6 +28,8 @@ import logging
import os
import tempfile

+from functools import partial
+
from ganeti import utils
from ganeti import errors
from ganeti import constants
@@ -196,6 +198,353 @@ def RemoveAuthorizedKey(file_name, key):
    raise


+def _AddPublicKeyProcessLine(new_uuid, new_key, line_uuid, line_key, tmp_file,
+                             found):
+  """Processes one line of the public key file when adding a key.
+
+  This is a sub function that can be called within the
+  C{_ManipulatePublicKeyFile} function. It processes one line of the public
+  key file, checks if this line contains the key to add already and if so,
+  notes the occurrence in the return value.
+
+  @type new_uuid: string
+  @param new_uuid: the node UUID of the node whose key is added
+  @type new_key: string
+  @param new_key: the SSH key to be added
+  @type line_uuid: the UUID of the node whose line in the public key file
+    is processed in this function call
+  @param line_key: the SSH key of the node whose line in the public key
+    file is processed in this function call
+  @type tmp_file: file descriptor
+  @param tmp_file: the temporary file to which the manipulated public key
+    file is written to, before replacing the original public key file
+    automically
+  @type found: boolean
+  @param found: whether or not the (UUID, key) pair of the node whose key
+    is being added was found in the public key file already.
+  @rtype: boolean
+  @return: a possibly updated value of C{found}
+
+  """
+  if line_uuid == new_uuid and line_key == new_key:
+    logging.debug("SSH key of node '%s' already in key file.", new_uuid)
+    found = True
+  tmp_file.write("%s %s\n" % (line_uuid, line_key))
+  return found
+
+
+def _AddPublicKeyElse(new_uuid, new_key, tmp_file):
+  """Adds a new SSH key to the key file if it did not exist already.
+
+  This is an auxiliary function for C{_ManipulatePublicKeyFile} which
+  is carried out when a new key is added to the public key file and
+  after processing the whole file, we found out that the key does
+  not exist in the file yet but needs to be appended at the end.
+
+  @type new_uuid: string
+  @param new_uuid: the UUID of the node whose key is added
+  @type new_key: string
+  @param new_key: the SSH key to be added
+  @type tmp_file: file descriptor
+  @param tmp_file: the file where the key is appended
+
+  """
+  tmp_file.write("%s %s\n" % (new_uuid, new_key))
+
+
+def _RemovePublicKeyProcessLine(
+    target_uuid, _target_key,
+    line_uuid, line_key, tmp_file, found):
+  """Processes a line in the public key file when aiming for removing a key.
+
+  This is an auxiliary function for C{_ManipulatePublicKeyFile} when we
+  are removing a key from the public key file. This particular function
+  only checks if the current line contains the UUID of the node in
+  question and writes the line to the temporary file otherwise.
+
+  @type target_uuid: string
+  @param target_uuid: UUID of the node whose key is being removed
+  @type _target_key: string
+  @param _target_key: SSH key of the node (not used)
+  @type line_uuid: string
+  @param line_uuid: UUID of the node whose line is processed in this call
+  @type line_key: string
+  @param line_key: SSH key of the nodes whose line is processed in this call
+  @type tmp_file: file descriptor
+  @param tmp_file: temporary file which eventually replaces the ganeti public
+    key file
+  @type found: boolean
+  @param found: whether or not the UUID was already found.
+
+  """
+  if line_uuid != target_uuid:
+    tmp_file.write("%s %s\n" % (line_uuid, line_key))
+    return found
+  else:
+    return True
+
+
+def _RemovePublicKeyElse(
+    target_uuid, _target_key, _tmp_file):
+  """Logs when we tried to remove a key that does not exist.
+
+  This is an auxiliary function for C{_ManipulatePublicKeyFile} which is
+  run after we have processed the complete public key file and did not find
+  the key to be removed.
+
+  @type target_uuid: string
+  @param target_uuid: the UUID of the node whose key was supposed to be removed
+  @type _target_key: string
+  @param _target_key: the key of the node which was supposed to be removed
+    (not used)
+  @type _tmp_file: file descriptor
+  @param _tmp_file: the temporary file which eventually will replace the public
+    key file (not used)
+
+  """
+  logging.debug("Trying to remove key of node '%s' which is not in list"
+                " of public keys.", target_uuid)
+
+
+def _ReplaceNameByUuidProcessLine(
+    node_name, _key, line_identifier, line_key, tmp_file, found,
+    node_uuid=None):
+  """Replaces a node's name with its UUID on a matching line in the key file.
+
+  This is an auxiliary function for C{_ManipulatePublicKeyFile} which processes
+  a line of the ganeti public key file. If the line in question matches the
+  node's name, the name will be replaced by the node's UUID.
+
+  @type node_name: string
+  @param node_name: name of the node to be replaced by the UUID
+  @type _key: string
+  @param _key: SSH key of the node (not used)
+  @type line_identifier: string
+  @param line_identifier: an identifier of a node in a line of the public key
+    file. This can be either a node name or a node UUID, depending on if it
+    got replaced already or not.
+  @type line_key: string
+  @param line_key: SSH key of the node whose line is processed
+  @type tmp_file: file descriptor
+  @param tmp_file: temporary file which will eventually replace the public
+    key file
+  @type found: boolean
+  @param found: whether or not the line matches the node's name
+  @type node_uuid: string
+  @param node_uuid: the node's UUID which will replace the node name
+
+  """
+  if node_name == line_identifier:
+    found = True
+    tmp_file.write("%s %s\n" % (node_uuid, line_key))
+  else:
+    tmp_file.write("%s %s\n" % (line_identifier, line_key))
+  return found
+
+
+def _ReplaceNameByUuidElse(
+    node_uuid, node_name, _key, _tmp_file):
+  """Logs a debug message when we try to replace a key that is not there.
+
+  This is an implementation of the auxiliary C{process_else_fn} function for
+  the C{_ManipulatePubKeyFile} function when we use it to replace a line
+  in the public key file that is indexed by the node's name instead of the
+  node's UUID.
+
+  @type node_uuid: string
+  @param node_uuid: the node's UUID
+  @type node_name: string
+  @param node_name: the node's UUID
+  @type _key: string (not used)
+  @param _key: the node's SSH key (not used)
+  @type _tmp_file: file descriptor
+  @param _tmp_file: temporary file for manipulating the public key file
+    (not used)
+
+  """
+  logging.debug("Trying to replace node name '%s' with UUID '%s', but"
+                " no line with that name was found.", node_name, node_uuid)
+
+
+def _ParseKeyLine(line, error_fn):
+  """Parses a line of the public key file.
+
+  @type line: string
+  @param line: line of the public key file
+  @type error_fn: function
+  @param error_fn: function to process error messages
+  @rtype: tuple (string, string)
+  @return: a tuple containing the UUID of the node and a string containing
+    the SSH key and possible more parameters for the key
+
+  """
+  if len(line.rstrip()) == 0:
+    return (None, None)
+  chunks = line.split(" ")
+  if len(chunks) < 2:
+    raise error_fn("Error parsing public SSH key file. Line: '%s'"
+                   % line)
+  uuid = chunks[0]
+  key = " ".join(chunks[1:]).rstrip()
+  return (uuid, key)
+
+
+def _ManipulatePubKeyFile(target_identifier, target_key,
+                          key_file=pathutils.SSH_PUB_KEYS,
+                          error_fn=errors.ProgrammerError,
+                          process_line_fn=None, process_else_fn=None):
+  """Manipulates the list of public SSH keys of the cluster.
+
+  This is a general function to manipulate the public key file. It needs
+  two auxiliary functions C{process_line_fn} and C{process_else_fn} to
+  work. Generally, the public key file is processed as follows:
+  1) A temporary file is opened to write the content of the ganeti public key
+  file to (possibly with changes).
+  2) The function processes each line of the original ganeti public key file,
+  applies the C{process_line_fn} function on it, which possibly writes the
+  original line, a changed line or no line to the temporary file. If
+  the return value of the C{process_line_fn} function is True, it will
+  be recorded in the 'found' variable for later use.
+  3) If all lines are processed and the 'found' variable is False, the
+  seconds auxiliary function C{process_else_fn} is called to possibly
+  add more lines to the temporary file.
+  4) Finally, the temporary file is written to disk and moved to the original
+  files name to ensure atomic writing.
+
+  @type target_identifier: str
+  @param target_identifier: identifier of the node whose key is added; in most
+    cases this is the node's UUID, but in some it is the node's host name
+  @type target_key: str
+  @param target_key: string containing a public SSH key (a complete line
+    possibly including more parameters than just the key)
+  @type key_file: str
+  @param key_file: filename of the file of public node keys (optional
+    parameter for testing)
+  @type error_fn: function
+  @param error_fn: Function that returns an exception, used to customize
+    exception types depending on the calling context
+  @type process_line_fn: function
+  @param process_line_fn: function to process one line of the public key file
+  @type process_else_fn: function
+  @param process_else_fn: function to be called if no line of the key file
+    matches the target uuid
+
+  """
+  assert process_else_fn is not None
+  assert process_line_fn is not None
+
+  fd_tmp, tmpname = tempfile.mkstemp(dir=os.path.dirname(key_file))
+  try:
+    f_tmp = os.fdopen(fd_tmp, "w")
+    try:
+      f_orig = open(key_file, "r")
+      try:
+        found = False
+        for line in f_orig:
+          (uuid, key) = _ParseKeyLine(line, error_fn)
+          if not uuid:
+            continue
+          if process_line_fn(target_identifier, target_key, uuid,
+                             key, f_tmp, found):
+            found = True
+        if not found:
+          process_else_fn(target_identifier, target_key, f_tmp)
+        f_tmp.flush()
+        os.rename(tmpname, key_file)
+      finally:
+        f_orig.close()
+    finally:
+      f_tmp.close()
+  except:
+    utils.RemoveFile(tmpname)
+    raise
+
+
+def AddPublicKey(new_uuid, new_key, key_file=pathutils.SSH_PUB_KEYS,
+                 error_fn=errors.ProgrammerError):
+  """Adds a new key to the list of public keys.
+
+  @see: _ManipulatePubKeyFile for parameter descriptions.
+
+  """
+  _ManipulatePubKeyFile(new_uuid, new_key, key_file=key_file,
+                        process_line_fn=_AddPublicKeyProcessLine,
+                        process_else_fn=_AddPublicKeyElse,
+                        error_fn=error_fn)
+
+
+def RemovePublicKey(target_uuid, key_file=pathutils.SSH_PUB_KEYS,
+                    error_fn=errors.ProgrammerError):
+  """Removes a key from the list of public keys.
+
+  @see: _ManipulatePubKeyFile for parameter descriptions.
+
+  """
+  _ManipulatePubKeyFile(target_uuid, None, key_file=key_file,
+                        process_line_fn=_RemovePublicKeyProcessLine,
+                        process_else_fn=_RemovePublicKeyElse,
+                        error_fn=error_fn)
+
+
+def ReplaceNameByUuid(node_uuid, node_name, key_file=pathutils.SSH_PUB_KEYS,
+                      error_fn=errors.ProgrammerError):
+  """Replaces a host name with the node's corresponding UUID.
+
+  When a node is added to the cluster, we don't know it's UUID yet. So first
+  its SSH key gets added to the public key file and in a second step, the
+  node's name gets replaced with the node's UUID as soon as we know the UUID.
+
+  @type node_uuid: string
+  @param node_uuid: the node's UUID to replace the node's name
+  @type node_name: string
+  @param node_name: the node's name to be replaced by the node's UUID
+
+  @see: _ManipulatePubKeyFile for the other parameter descriptions.
+
+  """
+  process_line_fn = partial(_ReplaceNameByUuidProcessLine, node_uuid=node_uuid)
+  process_else_fn = partial(_ReplaceNameByUuidElse, node_uuid=node_uuid)
+  _ManipulatePubKeyFile(node_name, None, key_file=key_file,
+                        process_line_fn=process_line_fn,
+                        process_else_fn=process_else_fn,
+                        error_fn=error_fn)
+
+
+def QueryPubKeyFile(target_uuids, key_file=pathutils.SSH_PUB_KEYS,
+                    error_fn=errors.ProgrammerError):
+  """Retrieves a map of keys for the requested node UUIDs.
+
+  @type target_uuids: str or list of str
+  @param target_uuids: UUID of the node to retrieve the key for or a list
+    of UUIDs of nodes to retrieve the keys for
+  @type key_file: str
+  @param key_file: filename of the file of public node keys (optional
+    parameter for testing)
+  @type error_fn: function
+  @param error_fn: Function that returns an exception, used to customize
+    exception types depending on the calling context
+  @rtype: dict mapping strings to list of strings
+  @return: dictionary mapping node uuids to their ssh keys
+
+  """
+  if isinstance(target_uuids, str):
+    target_uuids = [target_uuids]
+  result = {}
+  f = open(key_file, "r")
+  try:
+    for line in f:
+      (uuid, key) = _ParseKeyLine(line, error_fn)
+      if not uuid:
+        continue
+      if uuid in target_uuids:
+        if uuid not in result:
+          result[uuid] = []
+        result[uuid].append(key)
+  finally:
+    f.close()
+  return result
+
+
def InitSSHSetup(error_fn=errors.OpPrereqError):
  """Setup the SSH configuration for the node.

diff --git a/test/py/ganeti.ssh_unittest.py b/test/py/ganeti.ssh_unittest.py
index 621d2c0..a928268 100755
--- a/test/py/ganeti.ssh_unittest.py
+++ b/test/py/ganeti.ssh_unittest.py
@@ -216,5 +216,122 @@ class TestSshKeys(testutils.GanetiTestCase):
      " ssh-dss AAAAB3NzaC1w520smc01ms0jfJs22 root@key-b\n")


+class TestPublicSshKeys(testutils.GanetiTestCase):
+  """Test case for the handling of the list of public ssh keys."""
+
+  KEY_A = "ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a"
+  KEY_B = "ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b"
+  UUID_1 = "123-456"
+  UUID_2 = "789-ABC"
+
+  def setUp(self):
+    testutils.GanetiTestCase.setUp(self)
+
+  def testAddingAndRemovingPubKey(self):
+    pub_key_file = self._CreateTempFile()
+    ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
+    ssh.AddPublicKey(self.UUID_2, self.KEY_B, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file,
+      "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
+      "789-ABC ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
+
+    ssh.RemovePublicKey(self.UUID_2, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file,
+      "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n")
+
+  def testAddingExistingPubKey(self):
+    expected_file_content = \
+      "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n" + \
+      "789-ABC ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n"
+    pub_key_file = self._CreateTempFile()
+    ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
+    ssh.AddPublicKey(self.UUID_2, self.KEY_B, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file, expected_file_content)
+
+    ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file, expected_file_content)
+
+    ssh.AddPublicKey(self.UUID_1, self.KEY_B, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file,
+      "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
+      "789-ABC ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n"
+      "123-456 ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
+
+  def testRemoveNonexistingKey(self):
+    pub_key_file = self._CreateTempFile()
+    ssh.AddPublicKey(self.UUID_1, self.KEY_B, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file,
+      "123-456 ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
+
+    ssh.RemovePublicKey(self.UUID_2, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file,
+      "123-456 ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
+
+  def testRemoveAllExistingKeys(self):
+    pub_key_file = self._CreateTempFile()
+    ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
+    ssh.AddPublicKey(self.UUID_1, self.KEY_B, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file,
+      "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
+      "123-456 ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
+
+    ssh.RemovePublicKey(self.UUID_1, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file, "")
+
+  def testRemoveKeyFromEmptyFile(self):
+    pub_key_file = self._CreateTempFile()
+    ssh.RemovePublicKey(self.UUID_2, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file, "")
+
+  def testRetrieveKeys(self):
+    pub_key_file = self._CreateTempFile()
+    ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
+    ssh.AddPublicKey(self.UUID_2, self.KEY_B, key_file=pub_key_file)
+    result = ssh.QueryPubKeyFile(self.UUID_1, key_file=pub_key_file)
+    self.assertEquals([self.KEY_A], result[self.UUID_1])
+
+    target_uuids = [self.UUID_1, self.UUID_2, "non-existing-UUID"]
+    result = ssh.QueryPubKeyFile(target_uuids, key_file=pub_key_file)
+    self.assertEquals([self.KEY_A], result[self.UUID_1])
+    self.assertEquals([self.KEY_B], result[self.UUID_2])
+    self.assertEquals(2, len(result))
+
+  def testReplaceNameByUuid(self):
+    pub_key_file = self._CreateTempFile()
+    name = "my.precious.node"
+    ssh.AddPublicKey(name, self.KEY_A, key_file=pub_key_file)
+    ssh.AddPublicKey(self.UUID_2, self.KEY_A, key_file=pub_key_file)
+    ssh.AddPublicKey(name, self.KEY_B, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file,
+      "my.precious.node ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
+      "789-ABC ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
+      "my.precious.node ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
+
+    ssh.ReplaceNameByUuid(self.UUID_1, name, key_file=pub_key_file)
+    self.assertFileContent(pub_key_file,
+      "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
+      "789-ABC ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
+      "123-456 ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
+
+  def testParseEmptyLines(self):
+    pub_key_file = self._CreateTempFile()
+    ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
+
+    # Add an empty line
+    fd = open(pub_key_file, 'a')
+    fd.write("\n")
+    fd.close()
+
+    ssh.AddPublicKey(self.UUID_2, self.KEY_B, key_file=pub_key_file)
+
+    # Add a whitespace line
+    fd = open(pub_key_file, 'a')
+    fd.write("    \n")
+    fd.close()
+
+    result = ssh.QueryPubKeyFile(self.UUID_1, key_file=pub_key_file)
+    self.assertEquals([self.KEY_A], result[self.UUID_1])
+
+
if __name__ == "__main__":
  testutils.GanetiTestProgram()
--
2.1.0.rc2.206.gedb03e5

Reply via email to