#
#

# Copyright (C) 2006, 2007, 2010, 2011 Google Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


"""Module encapsulating ssh functionality.

"""


import logging
import os
import tempfile

from collections import namedtuple
from functools import partial

from ganeti import utils
from ganeti import errors
from ganeti import constants
from ganeti import netutils
from ganeti import pathutils
from ganeti import vcluster
from ganeti import compat
from ganeti import serializer
from ganeti import ssconf


def GetUserFiles(user, mkdir=False, dircheck=True, kind=constants.SSHK_DSA,
                 _homedir_fn=None):
  """Return the paths of a user's SSH files.

  @type user: string
  @param user: Username
  @type mkdir: bool
  @param mkdir: Whether to create ".ssh" directory if it doesn't exist
  @type dircheck: bool
  @param dircheck: Whether to check if ".ssh" directory exists
  @type kind: string
  @param kind: One of L{constants.SSHK_ALL}
  @rtype: tuple; (string, string, string)
  @return: Tuple containing three file system paths; the private SSH key file,
    the public SSH key file and the user's C{authorized_keys} file
  @raise errors.OpExecError: When home directory of the user can not be
    determined
  @raise errors.OpExecError: Regardless of the C{mkdir} parameters, this
    exception is raised if C{~$user/.ssh} is not a directory and C{dircheck}
    is set to C{True}

  """
  if _homedir_fn is None:
    _homedir_fn = utils.GetHomeDir

  user_dir = _homedir_fn(user)
  if not user_dir:
    raise errors.OpExecError("Cannot resolve home of user '%s'" % user)

  if kind == constants.SSHK_DSA:
    suffix = "dsa"
  elif kind == constants.SSHK_RSA:
    suffix = "rsa"
  elif kind == constants.SSHK_ECDSA:
    suffix = "ecdsa"
  else:
    raise errors.ProgrammerError("Unknown SSH key kind '%s'" % kind)

  ssh_dir = utils.PathJoin(user_dir, ".ssh")
  if mkdir:
    utils.EnsureDirs([(ssh_dir, constants.SECURE_DIR_MODE)])
  elif dircheck and not os.path.isdir(ssh_dir):
    raise errors.OpExecError("Path %s is not a directory" % ssh_dir)

  return [utils.PathJoin(ssh_dir, base)
          for base in ["id_%s" % suffix, "id_%s.pub" % suffix,
                       "authorized_keys"]]


def GetAllUserFiles(user, mkdir=False, dircheck=True, _homedir_fn=None):
  """Wrapper over L{GetUserFiles} to retrieve files for all SSH key types.

  See L{GetUserFiles} for details.

  @rtype: tuple; (string, dict with string as key, tuple of (string, string) as
    value)

  """
  helper = compat.partial(GetUserFiles, user, mkdir=mkdir, dircheck=dircheck,
                          _homedir_fn=_homedir_fn)
  result = [(kind, helper(kind=kind)) for kind in constants.SSHK_ALL]

  authorized_keys = [i for (_, (_, _, i)) in result]

  assert len(frozenset(authorized_keys)) == 1, \
    "Different paths for authorized_keys were returned"

  return (authorized_keys[0],
          dict((kind, (privkey, pubkey))
               for (kind, (privkey, pubkey, _)) in result))


def _SplitSshKey(key):
  """Splits a line for SSH's C{authorized_keys} file.

  If the line has no options (e.g. no C{command="..."}), only the significant
  parts, the key type and its hash, are used. Otherwise the whole line is used
  (split at whitespace).

  @type key: string
  @param key: Key line
  @rtype: tuple

  """
  parts = key.split()

  if parts and parts[0] in constants.SSHAK_ALL:
    # If the key has no options in front of it, we only want the significant
    # fields
    return (False, parts[:2])
  else:
    # Can't properly split the line, so use everything
    return (True, parts)


def AddAuthorizedKeys(file_obj, keys):
  """Adds a list of SSH public key to an authorized_keys file.

  @type file_obj: str or file handle
  @param file_obj: path to authorized_keys file
  @type keys: list of str
  @param keys: list of strings containing keys

  """
  key_field_list = [(key, _SplitSshKey(key)) for key in keys]

  if isinstance(file_obj, str):
    f = open(file_obj, "a+")
    f.seek(0)
  else:
    f = file_obj

  try:
    nl = True
    for line in f:
      # Ignore whitespace changes
      line_key = _SplitSshKey(line)
      key_field_list[:] = [(key, split_key) for (key, split_key)
                           in key_field_list
                           if split_key != line_key]
      nl = line.endswith("\n")

    if not nl:
      f.write("\n")
    for (key, _) in key_field_list:
      f.write(key.rstrip("\r\n"))
      f.write("\n")
    f.flush()
  finally:
    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, str):
    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 CheckForMultipleKeys(file_obj, node_names):
  """Check if there is at most one key per host in 'authorized_keys' file.

  @type file_obj: str or file handle
  @param file_obj: path to authorized_keys file
  @type node_names: list of str
  @param node_names: list of names of nodes of the cluster
  @returns: a dictionary with hostnames which occur more than once

  """

  if isinstance(file_obj, str):
    f = open(file_obj, "r")
  else:
    f = file_obj

  occurrences = {}

  try:
    index = 0
    for line in f:
      index += 1
      if line.startswith("#"):
        continue
      chunks = line.split()
      # find the chunk with user@hostname
      user_hostname = [chunk.strip() for chunk in chunks if "@" in chunk][0]
      if not user_hostname in occurrences:
        occurrences[user_hostname] = []
      occurrences[user_hostname].append(index)
  finally:
    f.close()

  bad_occurrences = {}
  for user_hostname, occ in occurrences.items():
    _, hostname = user_hostname.split("@")
    if hostname in node_names and len(occ) > 1:
      bad_occurrences[user_hostname] = occ

  return bad_occurrences


def AddAuthorizedKey(file_obj, key):
  """Adds an SSH public key to an 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

  """
  AddAuthorizedKeys(file_obj, [key])


def RemoveAuthorizedKeys(file_name, keys):
  """Removes public SSH keys from an authorized_keys file.

  @type file_name: str
  @param file_name: path to authorized_keys file
  @type keys: list of str
  @param keys: list of strings containing keys

  """
  key_field_list = [_SplitSshKey(key) for key in keys]

  fd, tmpname = tempfile.mkstemp(dir=os.path.dirname(file_name))
  try:
    out = os.fdopen(fd, "w")
    try:
      f = open(file_name, "r")
      try:
        for line in f:
          # Ignore whitespace changes while comparing lines
          if _SplitSshKey(line) not in key_field_list:
            out.write(line)

        out.flush()
        os.rename(tmpname, file_name)
      finally:
        f.close()
    finally:
      out.close()
  except:
    utils.RemoveFile(tmpname)
    raise


def RemoveAuthorizedKey(file_name, key):
  """Removes an SSH public key from an authorized_keys file.

  @type file_name: str
  @param file_name: path to authorized_keys file
  @type key: str
  @param key: string containing key

  """
  RemoveAuthorizedKeys(file_name, [key])


def _AddPublicKeyProcessLine(new_uuid, new_key, line_uuid, line_key, 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 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, string)
  @return: a possibly updated value of C{found} and the processed line

  """
  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
  return (found, "%s %s\n" % (line_uuid, line_key))


def _AddPublicKeyElse(new_uuid, new_key):
  """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
  @rtype: string
  @return: a new line to be added to the file

  """
  return "%s %s\n" % (new_uuid, new_key)


def _RemovePublicKeyProcessLine(
    target_uuid, _target_key,
    line_uuid, line_key, 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 found: boolean
  @param found: whether or not the UUID was already found.
  @rtype: (boolean, string)
  @return: a tuple, indicating if the target line was found and the processed
    line; the line is 'None', if the original line is removed

  """
  if line_uuid != target_uuid:
    return (found, "%s %s\n" % (line_uuid, line_key))
  else:
    return (True, None)


def _RemovePublicKeyElse(
    target_uuid, _target_key):
  """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)
  @rtype: string
  @return: in this case, always None

  """
  logging.debug("Trying to remove key of node '%s' which is not in list"
                " of public keys.", target_uuid)
  return None


def _ReplaceNameByUuidProcessLine(
    node_name, _key, line_identifier, line_key, 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 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
  @rtype: (boolean, string)
  @return: a tuple indicating whether the target line was found and the
    processed line

  """
  if node_name == line_identifier:
    return (True, "%s %s\n" % (node_uuid, line_key))
  else:
    return (found, "%s %s\n" % (line_identifier, line_key))


def _ReplaceNameByUuidElse(
    node_uuid, node_name, _key):
  """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)
  @rtype: string
  @return: in this case, always None

  """
  logging.debug("Trying to replace node name '%s' with UUID '%s', but"
                " no line with that name was found.", node_name, node_uuid)
  return None


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) The function processes each line of the original ganeti public key file,
  applies the C{process_line_fn} function on it, which returns a possibly
  manipulated line and an indicator whether the line in question was found.
  If a line is returned, it is added to a list of lines for later writing
  to the file.
  2) 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 list of lines.
  3) Finally, the list of lines is assembled to a string and written
  atomically to the public key file, thereby overriding it.

  If the public key file does not exist, we create it. This is necessary for
  a smooth transition after an upgrade.

  @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

  old_lines = []
  f_orig = None
  if os.path.exists(key_file):
    try:
      f_orig = open(key_file, "r")
      old_lines = f_orig.readlines()
    finally:
      f_orig.close()
  else:
    try:
      f_orig = open(key_file, "w")
      f_orig.close()
    except IOError as e:
      raise errors.SshUpdateError("Cannot create public key file: %s" % e)

  found = False
  new_lines = []
  for line in old_lines:
    (uuid, key) = _ParseKeyLine(line, error_fn)
    if not uuid:
      continue
    (new_found, new_line) = process_line_fn(target_identifier, target_key,
                                            uuid, key, found)
    if new_found:
      found = True
    if new_line is not None:
      new_lines.append(new_line)
  if not found:
    new_line = process_else_fn(target_identifier, target_key)
    if new_line is not None:
      new_lines.append(new_line)
  new_file_content = "".join(new_lines)
  utils.WriteFile(key_file, data=new_file_content)


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 ClearPubKeyFile(key_file=pathutils.SSH_PUB_KEYS, mode=0o600):
  """Resets the content of the public key file.

  """
  utils.WriteFile(key_file, data="", mode=mode)


def OverridePubKeyFile(key_map, key_file=pathutils.SSH_PUB_KEYS):
  """Overrides the public key file with a list of given keys.

  @type key_map: dict from str to list of str
  @param key_map: dictionary mapping uuids to lists of SSH keys

  """
  new_lines = []
  for (uuid, keys) in key_map.items():
    for key in keys:
      new_lines.append("%s %s\n" % (uuid, key))
  new_file_content = "".join(new_lines)
  utils.WriteFile(key_file, data=new_file_content)


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

  """
  all_keys = target_uuids is None
  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 all_keys or (uuid in target_uuids):
        if uuid not in result:
          result[uuid] = []
        result[uuid].append(key)
  finally:
    f.close()
  return result


def InitSSHSetup(key_type, key_bits, error_fn=errors.OpPrereqError,
                 _homedir_fn=None, _suffix=""):
  """Setup the SSH configuration for the node.

  This generates a dsa keypair for root, adds the pub key to the
  permitted hosts and adds the hostkey to its own known hosts.

  @param key_type: the type of SSH keypair to be generated
  @param key_bits: the key length, in bits, to be used

  """
  priv_key, _, auth_keys = GetUserFiles(constants.SSH_LOGIN_USER, kind=key_type,
                                        mkdir=True, _homedir_fn=_homedir_fn)

  new_priv_key_name = priv_key + _suffix
  new_pub_key_name = priv_key + _suffix + ".pub"

  for name in new_priv_key_name, new_pub_key_name:
    if os.path.exists(name):
      utils.CreateBackup(name)
    utils.RemoveFile(name)

  result = utils.RunCmd(["ssh-keygen", "-b", str(key_bits), "-t", key_type,
                         "-f", new_priv_key_name,
                         "-q", "-N", ""])
  if result.failed:
    raise error_fn("Could not generate ssh keypair, error %s" %
                   result.output)

  AddAuthorizedKey(auth_keys, utils.ReadFile(new_pub_key_name))


def InitPubKeyFile(master_uuid, key_type, key_file=pathutils.SSH_PUB_KEYS):
  """Creates the public key file and adds the master node's SSH key.

  @type master_uuid: str
  @param master_uuid: the master node's UUID
  @type key_type: one of L{constants.SSHK_ALL}
  @param key_type: the type of ssh key to be used
  @type key_file: str
  @param key_file: name of the file containing the public keys

  """
  _, pub_key, _ = GetUserFiles(constants.SSH_LOGIN_USER, kind=key_type)
  ClearPubKeyFile(key_file=key_file)
  key = utils.ReadFile(pub_key)
  AddPublicKey(master_uuid, key, key_file=key_file)


class SshRunner(object):
  """Wrapper for SSH commands.

  """
  def __init__(self, cluster_name):
    """Initializes this class.

    @type cluster_name: str
    @param cluster_name: name of the cluster

    """
    self.cluster_name = cluster_name
    family = ssconf.SimpleStore().GetPrimaryIPFamily()
    self.ipv6 = (family == netutils.IP6Address.family)

  def _BuildSshOptions(self, batch, ask_key, use_cluster_key,
                       strict_host_check, private_key=None, quiet=True,
                       port=None):
    """Builds a list with needed SSH options.

    @param batch: same as ssh's batch option
    @param ask_key: allows ssh to ask for key confirmation; this
        parameter conflicts with the batch one
    @param use_cluster_key: if True, use the cluster name as the
        HostKeyAlias name
    @param strict_host_check: this makes the host key checking strict
    @param private_key: use this private key instead of the default
    @param quiet: whether to enable -q to ssh
    @param port: the SSH port to use, or None to use the default

    @rtype: list
    @return: the list of options ready to use in L{utils.process.RunCmd}

    """
    options = [
      "-oEscapeChar=none",
      "-oHashKnownHosts=no",
      "-oGlobalKnownHostsFile=%s" % pathutils.SSH_KNOWN_HOSTS_FILE,
      "-oUserKnownHostsFile=/dev/null",
      "-oCheckHostIp=no",
      "-oConnectTimeout=10",
      ]

    if use_cluster_key:
      options.append("-oHostKeyAlias=%s" % self.cluster_name)

    if quiet:
      options.append("-q")

    if private_key:
      options.append("-i%s" % private_key)

    if port:
      options.append("-oPort=%d" % port)

    # TODO: Too many boolean options, maybe convert them to more descriptive
    # constants.

    # Note: ask_key conflicts with batch mode
    if batch:
      if ask_key:
        raise errors.ProgrammerError("SSH call requested conflicting options")

      options.append("-oBatchMode=yes")

      if strict_host_check:
        options.append("-oStrictHostKeyChecking=yes")
      else:
        options.append("-oStrictHostKeyChecking=no")

    else:
      # non-batch mode

      if ask_key:
        options.append("-oStrictHostKeyChecking=ask")
      elif strict_host_check:
        options.append("-oStrictHostKeyChecking=yes")
      else:
        options.append("-oStrictHostKeyChecking=no")

    if self.ipv6:
      options.append("-6")
    else:
      options.append("-4")

    return options

  def BuildCmd(self, hostname, user, command, batch=True, ask_key=False,
               tty=False, use_cluster_key=True, strict_host_check=True,
               private_key=None, quiet=True, port=None):
    """Build an ssh command to execute a command on a remote node.

    @param hostname: the target host, string
    @param user: user to auth as
    @param command: the command
    @param batch: if true, ssh will run in batch mode with no prompting
    @param ask_key: if true, ssh will run with
        StrictHostKeyChecking=ask, so that we can connect to an
        unknown host (not valid in batch mode)
    @param use_cluster_key: whether to expect and use the
        cluster-global SSH key
    @param strict_host_check: whether to check the host's SSH key at all
    @param private_key: use this private key instead of the default
    @param quiet: whether to enable -q to ssh
    @param port: the SSH port on which the node's daemon is running

    @return: the ssh call to run 'command' on the remote host.

    """
    argv = [constants.SSH]
    argv.extend(self._BuildSshOptions(batch, ask_key, use_cluster_key,
                                      strict_host_check, private_key,
                                      quiet=quiet, port=port))
    if tty:
      argv.extend(["-t", "-t"])

    argv.append("%s@%s" % (user, hostname))

    # Insert variables for virtual nodes
    argv.extend("export %s=%s;" %
                (utils.ShellQuote(name), utils.ShellQuote(value))
                for (name, value) in
                  vcluster.EnvironmentForHost(hostname).items())

    argv.append(command)

    return argv

  def Run(self, *args, **kwargs):
    """Runs a command on a remote node.

    This method has the same return value as `utils.RunCmd()`, which it
    uses to launch ssh.

    Args: see SshRunner.BuildCmd.

    @rtype: L{utils.process.RunResult}
    @return: the result as from L{utils.process.RunCmd()}

    """
    return utils.RunCmd(self.BuildCmd(*args, **kwargs))

  def CopyFileToNode(self, node, port, filename):
    """Copy a file to another node with scp.

    @param node: node in the cluster
    @param filename: absolute pathname of a local file

    @rtype: boolean
    @return: the success of the operation

    """
    if not os.path.isabs(filename):
      logging.error("File %s must be an absolute path", filename)
      return False

    if not os.path.isfile(filename):
      logging.error("File %s does not exist", filename)
      return False

    command = [constants.SCP, "-p"]
    command.extend(self._BuildSshOptions(True, False, True, True, port=port))
    command.append(filename)
    if netutils.IP6Address.IsValid(node):
      node = netutils.FormatAddress((node, None))

    command.append("%s:%s" % (node, vcluster.ExchangeNodeRoot(node, filename)))

    result = utils.RunCmd(command)

    if result.failed:
      logging.error("Copy to node %s failed (%s) error '%s',"
                    " command was '%s'",
                    node, result.fail_reason, result.output, result.cmd)

    return not result.failed

  def VerifyNodeHostname(self, node, ssh_port):
    """Verify hostname consistency via SSH.

    This functions connects via ssh to a node and compares the hostname
    reported by the node to the name with have (the one that we
    connected to).

    This is used to detect problems in ssh known_hosts files
    (conflicting known hosts) and inconsistencies between dns/hosts
    entries and local machine names

    @param node: nodename of a host to check; can be short or
        full qualified hostname
    @param ssh_port: the port of a SSH daemon running on the node

    @return: (success, detail), where:
        - success: True/False
        - detail: string with details

    """
    cmd = ("if test -z \"$GANETI_HOSTNAME\"; then"
           "  hostname --fqdn;"
           "else"
           "  echo \"$GANETI_HOSTNAME\";"
           "fi")
    retval = self.Run(node, constants.SSH_LOGIN_USER, cmd,
                      quiet=False, port=ssh_port)

    if retval.failed:
      msg = "ssh problem"
      output = retval.output
      if output:
        msg += ": %s" % output
      else:
        msg += ": %s (no output)" % retval.fail_reason
      logging.error("Command %s failed: %s", retval.cmd, msg)
      return False, msg

    remotehostname = retval.stdout.strip()

    if not remotehostname or remotehostname != node:
      if node.startswith(remotehostname + "."):
        msg = "hostname not FQDN"
      else:
        msg = "hostname mismatch"
      return False, ("%s: expected %s but got %s" %
                     (msg, node, remotehostname))

    return True, "host matches"


def WriteKnownHostsFile(cfg, file_name):
  """Writes the cluster-wide equally known_hosts file.

  """
  data = ""
  if cfg.GetRsaHostKey():
    data += "%s ssh-rsa %s\n" % (cfg.GetClusterName(), cfg.GetRsaHostKey())
  if cfg.GetDsaHostKey():
    data += "%s ssh-dss %s\n" % (cfg.GetClusterName(), cfg.GetDsaHostKey())

  utils.WriteFile(file_name, mode=0o600, data=data)


def _EnsureCorrectGanetiVersion(cmd):
  """Ensured the correct Ganeti version before running a command via SSH.

  Before a command is run on a node via SSH, it makes sense in some
  situations to ensure that this node is indeed running the correct
  version of Ganeti like the rest of the cluster.

  @type cmd: string
  @param cmd: string
  @rtype: list of strings
  @return: a list of commands with the newly added ones at the beginning

  """
  logging.debug("Ensure correct Ganeti version: %s", cmd)

  version = constants.DIR_VERSION
  all_cmds = [["test", "-d", os.path.join(pathutils.PKGLIBDIR, version)]]
  if constants.HAS_GNU_LN:
    all_cmds.extend([["ln", "-s", "-f", "-T",
                      os.path.join(pathutils.PKGLIBDIR, version),
                      os.path.join(pathutils.SYSCONFDIR, "ganeti/lib")],
                     ["ln", "-s", "-f", "-T",
                      os.path.join(pathutils.SHAREDIR, version),
                      os.path.join(pathutils.SYSCONFDIR, "ganeti/share")]])
  else:
    all_cmds.extend([["rm", "-f",
                      os.path.join(pathutils.SYSCONFDIR, "ganeti/lib")],
                     ["ln", "-s", "-f",
                      os.path.join(pathutils.PKGLIBDIR, version),
                      os.path.join(pathutils.SYSCONFDIR, "ganeti/lib")],
                     ["rm", "-f",
                      os.path.join(pathutils.SYSCONFDIR, "ganeti/share")],
                     ["ln", "-s", "-f",
                      os.path.join(pathutils.SHAREDIR, version),
                      os.path.join(pathutils.SYSCONFDIR, "ganeti/share")]])
  all_cmds.append(cmd)
  return all_cmds


def RunSshCmdWithStdin(cluster_name, node, basecmd, port, data,
                       debug=False, verbose=False, use_cluster_key=False,
                       ask_key=False, strict_host_check=False,
                       ensure_version=False):
  """Runs a command on a remote machine via SSH and provides input in stdin.

  @type cluster_name: string
  @param cluster_name: Cluster name
  @type node: string
  @param node: Node name
  @type basecmd: string
  @param basecmd: Base command (path on the remote machine)
  @type port: int
  @param port: The SSH port of the remote machine or None for the default
  @param data: JSON-serializable input data for script (passed to stdin)
  @type debug: bool
  @param debug: Enable debug output
  @type verbose: bool
  @param verbose: Enable verbose output
  @type use_cluster_key: bool
  @param use_cluster_key: See L{ssh.SshRunner.BuildCmd}
  @type ask_key: bool
  @param ask_key: See L{ssh.SshRunner.BuildCmd}
  @type strict_host_check: bool
  @param strict_host_check: See L{ssh.SshRunner.BuildCmd}

  """
  cmd = [basecmd]

  # Pass --debug/--verbose to the external script if set on our invocation
  if debug:
    cmd.append("--debug")

  if verbose:
    cmd.append("--verbose")

  if ensure_version:
    all_cmds = _EnsureCorrectGanetiVersion(cmd)
  else:
    all_cmds = [cmd]

  if port is None:
    port = netutils.GetDaemonPort(constants.SSH)

  srun = SshRunner(cluster_name)
  scmd = srun.BuildCmd(node, constants.SSH_LOGIN_USER,
                       utils.ShellQuoteArgs(
                           utils.ShellCombineCommands(all_cmds)),
                       batch=False, ask_key=ask_key, quiet=False,
                       strict_host_check=strict_host_check,
                       use_cluster_key=use_cluster_key,
                       port=port)

  tempfh = tempfile.TemporaryFile()
  try:
    tempfh.write(serializer.DumpJson(data))
    tempfh.seek(0)

    result = utils.RunCmd(scmd, interactive=True, input_fd=tempfh)
  finally:
    tempfh.close()

  if result.failed:
    raise errors.OpExecError("Command '%s' failed: %s" %
                             (result.cmd, result.fail_reason))


def ReadRemoteSshPubKeys(pub_key_file, node, cluster_name, port, ask_key,
                         strict_host_check):
  """Fetches a public SSH key from a node via SSH.

  @type pub_key_file: string
  @param pub_key_file: a tuple consisting of the file name of the public DSA key

  """
  ssh_runner = SshRunner(cluster_name)

  cmd = ["cat", pub_key_file]
  ssh_cmd = ssh_runner.BuildCmd(node, constants.SSH_LOGIN_USER,
                                utils.ShellQuoteArgs(cmd),
                                batch=False, ask_key=ask_key, quiet=False,
                                strict_host_check=strict_host_check,
                                use_cluster_key=False,
                                port=port)

  result = utils.RunCmd(ssh_cmd)
  if result.failed:
    raise errors.OpPrereqError("Could not fetch a public SSH key (%s) from node"
                               " '%s': ran command '%s', failure reason: '%s'."
                               % (pub_key_file, node, cmd, result.fail_reason),
                               errors.ECODE_INVAL)
  return result.stdout


# Update gnt-cluster.rst when changing which combinations are valid.
KeyBitInfo = namedtuple('KeyBitInfo', ['default', 'validation_fn'])
SSH_KEY_VALID_BITS = {
  constants.SSHK_DSA: KeyBitInfo(1024, lambda b: b == 1024),
  constants.SSHK_RSA: KeyBitInfo(2048, lambda b: b >= 768),
  constants.SSHK_ECDSA: KeyBitInfo(384, lambda b: b in [256, 384, 521]),
}


def DetermineKeyBits(key_type, key_bits, old_key_type, old_key_bits):
  """Checks the key bits to be used for a given key type, or provides defaults.

  @type key_type: one of L{constants.SSHK_ALL}
  @param key_type: The key type to use.
  @type key_bits: positive int or None
  @param key_bits: The number of bits to use, if supplied by user.
  @type old_key_type: one of L{constants.SSHK_ALL} or None
  @param old_key_type: The previously used key type, if any.
  @type old_key_bits: positive int or None
  @param old_key_bits: The previously used number of bits, if any.

  @rtype: positive int
  @return: The number of bits to use.

  """
  if key_bits is None:
    if old_key_type is not None and old_key_type == key_type:
      key_bits = old_key_bits
    else:
      key_bits = SSH_KEY_VALID_BITS[key_type].default

  if not SSH_KEY_VALID_BITS[key_type].validation_fn(key_bits):
    raise errors.OpPrereqError("Invalid key type and bit size combination:"
                               " %s with %s bits" % (key_type, key_bits),
                               errors.ECODE_INVAL)

  return key_bits
