[capirca] r254 committed - New addition to Capirca tools:...

45 views
Skip to first unread message

cap...@googlecode.com

unread,
Jun 26, 2014, 11:45:38 AM6/26/14
to capir...@googlegroups.com
Revision: 254
Author: wat...@google.com
Date: Thu Jun 26 15:45:10 2014 UTC
Log: New addition to Capirca tools:
A cross-vendor configuration distribution tool. This is useful for pushing
ACLs or other pieces of configuration to network elements. It can also be
used
to send commands to a list of devices and gather the results.

Thanks to Ryan Shea (ryan...@google.com) for efforts in development and in
prepping this for release.

A wiki doc page will be added shortly.


http://code.google.com/p/capirca/source/detail?r=254

Added:
/trunk/tools/ldpush
/trunk/tools/ldpush/README
/trunk/tools/ldpush/aruba.py
/trunk/tools/ldpush/base_device.py
/trunk/tools/ldpush/brocade.py
/trunk/tools/ldpush/brocade_test.py
/trunk/tools/ldpush/cisconx.py
/trunk/tools/ldpush/ciscoxr.py
/trunk/tools/ldpush/fake_ssh_connection.py
/trunk/tools/ldpush/hp.py
/trunk/tools/ldpush/ios.py
/trunk/tools/ldpush/junos.py
/trunk/tools/ldpush/junos_test.py
/trunk/tools/ldpush/paramiko_device.py
/trunk/tools/ldpush/paramiko_device_test.py
/trunk/tools/ldpush/pexpect_connection.py
/trunk/tools/ldpush/push.py
/trunk/tools/ldpush/push_exceptions.py
/trunk/tools/ldpush/sshclient.py

=======================================
--- /dev/null
+++ /trunk/tools/ldpush/README Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,37 @@
+A cross-vendor configuration distribution tool. This is useful for pushing
+ACLs or other pieces of configuration to network elements. It can also be
used
+to send commands to a list of devices and gather the results.
+
+Install
+-------
+
+To run this tool you will need to install several modules not part of the
+Python standard library, all of which should be easily installable via pip,
+easy_install, or distribution package:
+
+ pexpect
+ paramiko
+ progressbar
+ gflags
+ termcolor
+
+Examples
+--------
+
+Push the configurations in /tmp/foo and /tmp/bar to two devices,
192.168.192.1
+and router1.foo.com. Push cannot guess what vendor is in use, but you could
+change the default vendor flag at the top if you only use one vendor. This
+example forces the use of the username dude rather than your own.
+
+ ./push.py --targets 192.168.192.1,router1.foo.com --vendor ios --user
dude \
+ /tmp/foo /tmp/bar
+
+Send a 'show version' command to the list of devices. Output will be
sprayed to
+STDOUT. Target names must be resolvable on your machine.
+
+ ./push.py --targets r1,r2,r3,r4 --vendor ios --command 'show version'
+
+Use filenames to determine the name of the target device. The string file
name
+must be resolvable.
+
+ ./push.py --devices_from_filenames --vendor ios devicefiles/*
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/aruba.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,155 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""An Aruba device .
+
+This module implements the base device interface of base_device.py for
+Aruba devices.
+"""
+import os
+
+import pexpect
+
+import gflags
+import logging
+
+import base_device
+import pexpect_connection
+import push_exceptions as exceptions
+
+FLAGS = gflags.FLAGS
+
+gflags.DEFINE_float('aruba_timeout_response', None,
+ 'Aruba device response timeout in seconds.')
+gflags.DEFINE_float('aruba_timeout_connect', None,
+ 'Aruba device connect timeout in seconds.')
+gflags.DEFINE_float('aruba_timeout_idle', None,
+ 'Aruba device idle timeout in seconds.')
+gflags.DEFINE_float('aruba_timeout_disconnect', None,
+ 'Aruba device disconnect timeout in seconds.')
+gflags.DEFINE_float('aruba_timeout_act_user', None,
+ 'Aruba device user activation timeout in seconds.')
+
+# Error message format while executing an invalid command eg:.
+# (sydpirwmc1) #asdfasd.
+# ^
+# % Invalid input detected at '^' marker.
+INVALID_OUT1 = "% Invalid input detected at '^' marker.\n\n"
+# eg: (sydpirwmc1) #traceroute a.
+# Incorrect Input !use traceroute <ipaddr>.
+INVALID_OUT2 = 'Incorrect Input'
+
+
+class ArubaDevice(base_device.BaseDevice):
+ """A base device model suitable for Aruba devices.
+
+ See the base_device.BaseDevice method docstrings.
+ """
+
+ def __init__(self, **kwargs):
+ self.vendor_name = 'aruba'
+ super(ArubaDevice, self).__init__(**kwargs)
+ # Aruba prompt sample = '(sydpirwmc1) #'.
+ self._success = r'(?:^|\n)(\([A-Za-z0-9\.\-]+\)\s[#>])'
+
+ def _Connect(self, username, password=None, ssh_keys=None,
+ enable_password=None, ssl_cert_set=None):
+ _ = enable_password, ssl_cert_set
+ self._connection = pexpect_connection.ParamikoSshConnection(
+ self.loopback_ipv4, username, password, self._success,
+ timeout=self.timeout_connect, find_prompt=True, ssh_keys=ssh_keys)
+ try:
+ self._connection.Connect()
+ self._DisablePager()
+ self.connected = True
+ except pexpect_connection.ConnectionError as e:
+ self.connected = False
+ raise exceptions.ConnectError(e)
+ except pexpect_connection.TimeoutError as e:
+ self.connected = False
+ raise exceptions.ConnectError('Timed out connecting to %s(%s) after '
+ '%s seconds.' %
+ (self.host, self.loopback_ipv4,
str(e)))
+
+ def _Cmd(self, command, mode=None):
+
+ def SendAndWait(command):
+ """Sends a command and waits for a response."""
+ self._connection.child.send(command+'\r')
+ self._connection.child.expect('\r\n', timeout=self.timeout_response)
+ self._connection.child.expect(self._connection.re_prompt,
+ timeout=self.timeout_response,
+ searchwindowsize=128)
+ return self._connection.child.before.replace('\r\n', os.linesep)
+
+ _ = mode
+ command = command.replace('?', '')
+ result = ''
+ try:
+ result = SendAndWait(command)
+ except pexpect.TIMEOUT as e:
+ self.connected = False
+ raise exceptions.CmdError('%s: %s' % (e.__class__, str(e)))
+ except pexpect.EOF:
+ # Retry once on EOF error, in case we have been idle disconnected.
+ try:
+ self.connected = False
+ self._connection.Connect()
+ self._DisablePager()
+ self.connected = True
+ result = SendAndWait(command)
+ except pexpect.EOF:
+ raise exceptions.CmdError('Failed with EOF error twice.')
+ except pexpect_connection.ConnectionError as e:
+ raise exceptions.CmdError('Auto-reconnect failed: %s' % e)
+ except pexpect_connection.TimeoutError as e:
+ raise exceptions.CmdError('Auto-reconnect timed out: %s' % e)
+
+ # Fix trailing \r to \n (if \n of last \r\n is captured by prompt).
+ if result and result[-1] == '\r':
+ result = result[:-1] + '\n'
+
+ if result.endswith(INVALID_OUT1) or result.startswith(INVALID_OUT2):
+ raise exceptions.CmdError('Command failed: %s' % result)
+
+ return result
+
+ def _Disconnect(self):
+ if hasattr(self, '_connection'):
+ try:
+ self._connection.child.send('exit\r')
+ # Loose prompt RE as prompt changes after first exit.
+ self._connection.child.expect(self._success,
+ timeout=self.timeout_act_user)
+ self._connection.child.send('exit\r')
+ self._connection.child.expect(self._connection.exit_list,
+ timeout=self.timeout_act_user)
+ self.connected = False
+ except (pexpect.EOF, pexpect.TIMEOUT) as e:
+ self.connected = False
+ raise exceptions.DisconnectError('%s: %s' % (e.__class__, str(e)))
+
+ def _DisablePager(self):
+ """Disables the paging."""
+ try:
+ self._connection.child.send('no paging\r')
+ self._connection.child.expect(self._connection.re_prompt,
+ timeout=self.timeout_connect,
+ searchwindowsize=128)
+ except (pexpect.EOF, pexpect.TIMEOUT) as e:
+ self.connected = False
+ raise exceptions.ConnectError('%s: %s' % (e.__class__, str(e)))
+ logging.debug('Disabled paging on aruba device')
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/base_device.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,426 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""An abstract device model.
+
+Concrete implementations should be placed alongside this.
+
+Concerete subclasses must implement all methods that have
NotImplementedError
+exceptions raised in this abstract interface. Methods Lock and Unlock are
+optional, so clients of the device classes should expect that a
+NotSupportedError will potentially be raised.
+"""
+
+import time
+import gflags
+import push_exceptions as exceptions
+import logging
+
+
+FLAGS = gflags.FLAGS
+
+gflags.DEFINE_float('host_down_sinbin_time',
+ 180.0,
+ 'Seconds that down hosts are placed in the sin-bin
for.')
+
+# Define the default timeout values for each vendor.
+# Each vendor also provides the same flags (s/base/$VENDOR_NAME/),
+# with None as the default value. See BaseDevice._SetupTimeouts.
+gflags.DEFINE_float('base_timeout_response',
+ 300.0,
+ 'Default device response timeout in seconds.')
+gflags.DEFINE_float('base_timeout_connect',
+ 10.0,
+ 'Default device connect timeout in seconds.')
+gflags.DEFINE_float('base_timeout_idle',
+ 600.0,
+ 'Default device idle timeout in seconds.')
+gflags.DEFINE_float('base_timeout_disconnect',
+ 10.0,
+ 'Default device disconnect timeout in seconds.')
+gflags.DEFINE_float('base_timeout_act_user',
+ 10.0,
+ 'Default device user activation timeout in seconds.')
+# The default for this is set to 180 seconds, so that it is the same as
+# host_down_sinbin_time's default. This effectively disables the faster
retries
+# by default - the flag must be used to enable them.
+gflags.DEFINE_float('base_device_initial_failure_delay', 180.0,
+ 'If a device fails to connect, retry after '
+ 'this many seconds at first, doubling each time '
+ 'for frequent errors (only applies to whitelisted
devices).')
+gflags.DEFINE_float('base_device_failure_forgiveness_delay', 10 * 60,
+ 'Forget connect failures that happened more than this
many '
+ 'seconds ago (only on whitelisted devices).')
+
+
+class BaseDevice(object):
+ """A skeleton base device referring to a specific device in the network.
+
+ Notes:
+ All methods other than Connect and Nop raise NotImplementedError as
+ they are pure virtual methods.
+
+ Methods that have arguments perform argument type testing prior to
+ calling private implementations of their method. Replace the private
+ method in your implementation.
+
+ Attributes:
+ host: A string, the host name.
+ loopback_ipv4: A string representation of the IPv4 address used for
+ device management inside device modules.
+ vendor: A string, the vendor, e.g., 'JUNIPER'.
+ connected: A bool, whether we are connected to the device or not.
+ active: A bool, whether we're active or not.
+ rollout: A list of strings, active rollout tags for the device.
+ """
+ # A dict to map from vendor string to vendor class, e.g.,
+ # {'FORCE10': ftos.FtosDevice}
+ # This dict is updated by each concrete subclass at class load time (by
+ # factory.py).
+ vendor_classes = {}
+
+ # Standardized strings defining types of configurations.
+ CONFIG_RUNNING = 'running-config'
+ CONFIG_STARTUP = 'startup-config'
+ CONFIG_PATCH = 'patch-config'
+ NON_FILE_DESTINATIONS = (CONFIG_RUNNING, CONFIG_STARTUP, CONFIG_PATCH)
+
+ def __init__(self, **kwargs):
+ # Use kwargs so that subclasses can extend this state via the factory.
+ self.host = kwargs.get('host', None)
+ self.loopback_ipv4 = kwargs.get('loopback_ipv4', None)
+ self.accessproxy = kwargs.get('accessproxy', None)
+ self.accessproxy_device_dict = {}
+ self.role = kwargs.get('role', None)
+ self.realm = kwargs.get('realm', None)
+ self.notes = self.__class__.__name__
+ # Default to true for active.
+ self.active = kwargs.get('active', True)
+ self.vendor = kwargs.get('vendor', None)
+ self.rollout = kwargs.get('rollout', [])
+ self._subclass = kwargs.get('subclass', False)
+ # Connection details.
+ self._username = kwargs.get('username', None)
+ self._password = None
+ self._ssh_keys = None
+ self._enable_password = None
+ self._ssl_cert_set = None
+ # Boolean attribute containing the considered state of the device.
(True=up)
+ self._host_status = True
+ # The time the host's up/down status changed. If None, ignore this
value.
+ self._host_last_status_change = None
+ # Connected boolean, accessed via property connected.
+ self._connected = False
+
+ # Our last-raised exception if not None.
+ self.__exc = None
+ # If we have been initialised directly, set our vendor name.
+ if not hasattr(self, 'vendor_name'):
+ self.vendor_name = 'base'
+ # Some sub-classes override this.
+ if not hasattr(self, 'unsupported_non_file_destinations'):
+ self.unsupported_non_file_destinations = (self.CONFIG_PATCH,)
+ # Setup timeouts.
+ self._InitialiseTimeouts()
+
+ def __del__(self):
+ """Special delete method called on object garbage collection.
+
+ Holders of device objects should call Disconnect() explicltly,
+ rather than relying on disconnection by this method.
+
+ A global Exception handler must ensure deletion of references to
+ instances of this class. Garbage collection will close device
+ connections when it runs this method, but there are no guarantees it
+ will be run for all classes at program exit.
+ """
+ if self.connected:
+ logging.debug('Garbage collection disconnecting %r' % self.host)
+ self.Disconnect()
+
+ def __str__(self):
+ return '%s(host=%s, vendor=%s, role=%s)' % (
+ self.__class__.__name__,
+ repr(self.host),
+ repr(self.vendor),
+ repr(self.role))
+
+ def _InitialiseTimeouts(self):
+ """Sets up timeouts by scanning module flags.
+
+ Subclasses must provide a _SetTimeouts method, to be called at the
+ end of initialization.
+ """
+ for var in ('connect', 'response', 'idle', 'disconnect', 'act_user'):
+ flag_name = '%s_timeout_%s' % (self.vendor_name, var)
+ default_flag_name = 'base_timeout_%s' % var
+
+ if getattr(FLAGS, flag_name) is not None:
+ value = getattr(FLAGS, flag_name)
+ setattr(self, 'timeout_%s' % var, value)
+ else:
+ default_value = getattr(FLAGS, default_flag_name)
+ setattr(self, 'timeout_%s' % var, default_value)
+ # Allow devices to optionally override timeouts.
+ self._SetupTimeouts()
+
+ def _SetupTimeouts(self):
+ """Optionally setup device specific timeout value logic.
+
+ If more than a global and device module specific timeout value are
+ required (e.g., to set a minima), implement this method in the
+ concrete device module. It need not be provided otherwise.
+ """
+ pass
+
+ def _HostDownPrepareConnect(self):
+ """Works out if it's safe to retry a connection attempt.
+
+ Raises an exception if we're not prepared to retry the connection
attempt.
+ See also Connect, and HandleConnectFailure.
+
+ Raises:
+ The last exception class recorded in self.__exc.
+ """
+ now = time.time()
+ time_left = self._dampen_end_time - now
+ logging.debug('BaseDevice.Connect is waiting because of previous '
+ 'connection errors, host is %s, time_left is %s',
+ self.host, time_left)
+ if time_left > 0:
+ # pylint: disable=g-doc-exception
+ raise self.__exc.__class__(
+ 'Connection to %s(%s) failed. Will not retry for %.1fs.'
+ % (self.host, self.loopback_ipv4, time_left),
+ dampen_connect=True)
+ # pylint: enable=g-doc-exception
+ else:
+ # Next time, we'll try to connect.
+ self._host_status = True
+ self.connected = False
+
+ def Connect(self, username, password=None, ssh_keys=None,
+ enable_password=None, ssl_cert_set=None):
+ """Sets up a connection to the device.
+
+ Concrete classes must implement _Connect() instead, with the same
arguments.
+
+ Concrete classes are expected not to disconnect the connection until it
+ is cleaned-up by Disconnect(). A generic exception handler at the top-
+ level should ensure sessions have an opportunity to be cleaned-up upon
+ abnormal program termination.
+
+ Args:
+ username: A string, the username (role account) to use.
+ password: A string, the password to use (optional; may be None).
+ ssh_keys: A tuple of strings, SSH private keys (optional; may be
None).
+ enable_password: A string, an optional enable password (may be None).
+ ssl_cert_set: An optional SSLCertificateSet protobuf (may be None).
+
+ Raises:
+ exceptions.ConnectError: the connection could not be established.
+ exceptions.AuthenticationError: A device authentication error
occurred, or
+ neither a password nor an SSH private key was supplied.
+ """
+ # Either an SSH key or password must be supplied for authentication.
+ if password is None and not ssh_keys and not ssl_cert_set:
+ raise exceptions.AuthenticationError(
+ 'Cannot connect. No authentication information provided to
device '
+ 'Connect method.')
+
+ self._username = username
+ self._password = password
+ self._ssh_keys = ssh_keys or ()
+ self._enable_password = enable_password
+ self._ssl_cert_set = ssl_cert_set
+
+ if not self.loopback_ipv4 and not self.accessproxy_device_dict:
+ raise exceptions.ConnectError(
+ 'Device %r, or any access proxies, need to have an IPv4 '
+ 'management address.'
+ % self.host)
+
+ logging.debug('In BaseDevice.Connect, host is %s, _connected is %s',
+ self.host, self._connected)
+ while not self.connected:
+ try:
+ if self._host_status:
+ logging.debug('CONNECTING %s(%s)',
+ self.host, self.loopback_ipv4)
+ self._Connect(username, password=password,
ssh_keys=self._ssh_keys,
+ enable_password=enable_password,
+ ssl_cert_set=ssl_cert_set)
+ self.connected = True
+ logging.debug('CONNECTED %s(%s)',
+ self.host, self.loopback_ipv4)
+ self._last_failure_time = None
+ else:
+ self._HostDownPrepareConnect()
+ except (exceptions.ConnectError,
+ exceptions.AuthenticationError), e:
+ logging.error('CONNECT FAILURE %s(%s)',
+ self.host, self.loopback_ipv4)
+ self._host_status = False
+ self.__exc = e
+ raise
+ logging.debug('Leaving BaseDevice.Connect, host is %s, _connected
is %s',
+ self.host, self._connected)
+ return None
+
+ def Nop(self, name):
+ """No-operation.
+
+ Args:
+ name: A string, the (no) operation's name.
+
+ Returns:
+ A string, some output (can be ignored by the client).
+ """
+ msg = 'No-operation request named `%s` received.' % name
+ logging.debug('ActionRequest: NOP %s %s', str(self.__class__),
repr(msg))
+ return msg
+
+ def Cmd(self, command, mode=None):
+ """Executes a command.
+
+ Concrete classes must define _Cmd with the same arguments.
+
+ Args:
+ command: A string, the command to execute.
+ mode: A string, the CLI mode to use for this command (e.g., 'shell'
+ on Netscaler). The empty string or None will use the device's
+ default mode.
+
+ Returns:
+ A string, the response.
+
+ Raises:
+ exceptions.CmdError: An error occurred inside the call to _Cmd.
+ """
+ if not command:
+ raise exceptions.CmdError('No command supplied for Cmd() method.')
+ else:
+ if not mode:
+ mode = None
+ return self._Cmd(command, mode=mode)
+
+ def GetConfig(self, source):
+ """Returns a configuration file from the device.
+
+ Concrete classes must define _GetConfig with the same arguments.
+
+ Args:
+ source: A string, representing either a path to a configuration file
or a
+ string to be interpreted by the device module. For readability,
+ consider using CONFIG_RUNNING and CONFIG_STARTUP to represent the
+ generic concepts of the running and startup configurations.
+
+ Returns:
+ A string, the configuration file. (This may be large).
+
+ Raises:
+ GetConfigError: the GetConfig operation failed.
+ EmptyConfigError: the operation produced an empty configuration.
+ """
+ return self._GetConfig(source)
+
+ def SetConfig(self, destination_file, data, canary,
+ juniper_skip_show_compare=False,
+ juniper_skip_commit_check=False,
+ juniper_get_rollback_patch=False):
+ """Updates a devices' configuration.
+
+ Concrete classes must define _SetConfig with the same arguments.
+
+ Args:
+ destination_file: A string. A path to a file on the device.
+ data: A string, the configuration data to set.
+ canary: A boolean, whether to canary, rather than set, the
configuration.
+ juniper_skip_show_compare: A boolean, temporary flag to skip
+ 'show | compare' on Junipers due to a bug.
+ juniper_skip_commit_check: A boolean, flag to skip 'commit check' on
+ Junipers when doing a canary.
+ juniper_get_rollback_patch: A boolean, optionally try to retrieve a
+ patch to rollback the config change.
+
+ Returns:
+ A SetConfigResult. Transcript of any device interaction that
occurred
+ during the operation, plus any optional extras.
+
+ Raises:
+ exceptions.SetConfigError: the SetConfig operation failed.
+ exceptions.SetConfigSyntaxError: the configuration data had a syntax
+ error.
+ """
+ if destination_file in self.unsupported_non_file_destinations:
+ raise exceptions.SetConfigError(
+ '%s devices do not support %s as a destination.' %
+ (self.vendor_name, destination_file))
+ if ((juniper_skip_show_compare or
+ juniper_skip_commit_check or
+ juniper_get_rollback_patch) and
+ self.__class__.__name__ == 'JunosDevice'):
+ return self._SetConfig(destination_file, data, canary,
+ skip_show_compare=juniper_skip_show_compare,
+ skip_commit_check=juniper_skip_commit_check,
+ get_rollback_patch=juniper_get_rollback_patch)
+ else:
+ return self._SetConfig(destination_file, data, canary)
+
+ def Disconnect(self):
+ """Disconnects from the device.
+
+ Concrete classes must define _Disconnect.
+
+ This method is called by the class __del__ method, and should also be
+ called by any global Exception handler (as __del__() is not guaranteed
to
+ be called when the Python interpreter exits).
+
+ Disconnect is also called by the Device Manager during garbage
collection.
+
+ Raises:
+ exceptions.DisconnectError if the disconnect operation failed.
+ """
+ self._Disconnect()
+ self.connected = False
+ logging.debug('DISCONNECTED %s(%s)',
+ self.host, self.loopback_ipv4)
+
+ def _GetConnected(self):
+ return self._connected
+
+ def _SetConnected(self, c):
+ logging.debug('Setting connected property on host %s to %s',
+ self.host, c)
+ self._connected = c
+
+ # Property for the connection status.
+ connected = property(_GetConnected, _SetConnected)
+
+
+class SetConfigResult(object):
+ """Results of one SetConfig, including transcript and any optional
extras.
+
+ Attributes:
+ transcript: A string, the chatter from the router and/or any error
text.
+ rollback_patch: None or a string, the optional rollback patch, if
supported.
+ """
+
+ def __init__(self):
+ self.transcript = ''
+ self.rollback_patch = None
+
+ def __len__(self):
+ return len(self.transcript) + len(self.rollback_patch or '')
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/brocade.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,430 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Brocade device implementation.
+
+This module implements the base device interface of base_device.py for
+several Brocade models; MLX, TurboIron and FastIron.
+"""
+
+__author__ = 'we...@google.com (Wei Su)'
+
+import os
+import re
+import string
+import time
+
+import pexpect
+
+import gflags
+import logging
+
+import base_device
+import pexpect_connection
+import push_exceptions as exceptions
+
+
+FLAGS = gflags.FLAGS
+
+gflags.DEFINE_float('brocademlx_timeout_response', None,
+ 'Brocade device response timeout in seconds.')
+gflags.DEFINE_float('brocademlx_timeout_connect', None,
+ 'Brocade device connect timeout in seconds.')
+gflags.DEFINE_float('brocademlx_timeout_idle', None,
+ 'Brocade device idle timeout in seconds.')
+gflags.DEFINE_float('brocademlx_timeout_disconnect', None,
+ 'Brocade device disconnect timeout in seconds.')
+gflags.DEFINE_float('brocademlx_timeout_act_user', None,
+ 'Brocade device user activation timeout in seconds.')
+gflags.DEFINE_float('brocadefi_timeout_response', None,
+ 'Brocade FastIron device response timeout in seconds.')
+gflags.DEFINE_float('brocadefi_timeout_connect', None,
+ 'Brocade FastIron device connect timeout in seconds.')
+gflags.DEFINE_float('brocadefi_timeout_idle', None,
+ 'Brocade FastIron device idle timeout in seconds.')
+gflags.DEFINE_float('brocadefi_timeout_disconnect', None,
+ 'Brocade FastIron device disconnect timeout in
seconds.')
+gflags.DEFINE_float('brocadefi_timeout_act_user', None,
+ 'Brocade FastIron device user activation timeout in'
+ 'seconds.')
+gflags.DEFINE_float('brocadeti_timeout_response', None,
+ 'Brocade TurboIron device response timeout in seconds.')
+gflags.DEFINE_float('brocadeti_timeout_connect', None,
+ 'Brocade TurboIron device connect timeout in seconds.')
+gflags.DEFINE_float('brocadeti_timeout_idle', None,
+ 'Brocade TurboIron device idle timeout in seconds.')
+gflags.DEFINE_float('brocadeti_timeout_disconnect', None,
+ 'Brocade TurboIron device disconnect timeout in
seconds.')
+gflags.DEFINE_float('brocadeti_timeout_act_user', None,
+ 'Brocade TurboIron device user activation timeout in'
+ 'seconds.')
+
+# Used in sleep statements for a minor pause.
+MINOR_PAUSE = 0.05
+
+RE_FILE_LISTING = re.compile(
+ r'^[\d\/]+' # Leading whitespace, then the file number.
+ r'\s+' # Whitespace.
+ r'[\d\:]+' # Hour:minute:seconds.
+ r'\s+'
+ r'([\d\,]+)' # File size in bytes.
+ r'\s+'
+ r'(.*)') # File name.
+
+_BROCADE_TIFI_DISABLE_PAGER = 'skip-page-display\r'
+_BROCADE_MLX_DISABLE_PAGER = 'terminal length 0\r'
+
+class BrocadeDevice(base_device.BaseDevice):
+ """A common superclass for Brocade devices."""
+
+ verboten_commands = (
+ 'monitor ',
+ 'terminal length ',
+ 'terminal monitor',
+ 'page-display',
+ 'quit',
+ 'exit',
+ )
+
+ disable_pager_command = ''
+
+ def __init__(self, **kwargs):
+ self.ssh_client = kwargs.pop('ssh_client', None)
+ super(BrocadeDevice, self).__init__(**kwargs)
+ self._success = r'(?:^|\n)([A-Za-z0-9@\.\-]+[>#])'
+
+ def _Connect(self, username=None, password=None, ssh_keys=None,
+ enable_password=None, ssl_cert_set=None):
+ _ = enable_password, ssl_cert_set
+ self._connection = pexpect_connection.ParamikoSshConnection(
+ self.loopback_ipv4, username, password, self._success,
+ timeout=self.timeout_connect, find_prompt=True, ssh_keys=ssh_keys,
+ # Brocade case 1101014 - \n\r\0 newlines in some 'tm voq' outputs.
+ ssh_client=self.ssh_client, find_prompt_prefix=r'(?:^|\n|\n\r\0)')
+ try:
+ self._connection.Connect()
+ self._DisablePager()
+ self.connected = True
+ except pexpect_connection.ConnectionError, e:
+ self.connected = False
+ raise exceptions.ConnectError(e)
+ except pexpect_connection.TimeoutError, e:
+ self.connected = False
+ raise exceptions.ConnectError('Timed out connecting to %s(%s) after '
+ '%s seconds.' %
+ (self.host, self.loopback_ipv4,
str(e)))
+
+ def _Cmd(self, command, mode=None):
+
+ def SendAndWait(command):
+ """Sends a command and waits for a response."""
+ self._connection.child.send(command+'\r')
+ self._connection.child.expect('\r\n', timeout=self.timeout_response)
+ self._connection.child.expect(self._connection.re_prompt,
+ timeout=self.timeout_response,
+ searchwindowsize=128)
+ return self._connection.child.before.replace('\r\n', os.linesep)
+
+ _ = mode
+ command = command.replace('?', '')
+ if next((command
+ for prefix in self.verboten_commands
+ if command.startswith(prefix)), False):
+ raise exceptions.CmdError(
+ 'Command %s is not permitted on Brocade devices.' % command)
+ result = ''
+ try:
+ result = SendAndWait(command)
+ except pexpect.TIMEOUT, e:
+ self.connected = False
+ raise exceptions.CmdError('%s: %s' % (e.__class__, str(e)))
+ except pexpect.EOF:
+ # Retry once on EOF error, in case we have been idle disconnected.
+ try:
+ self.connected = False
+ self._connection.Connect()
+ self._DisablePager()
+ self.connected = True
+ result = SendAndWait(command)
+ except pexpect.EOF:
+ raise exceptions.CmdError('Failed with EOF error twice.')
+ except pexpect_connection.ConnectionError, e:
+ raise exceptions.CmdError('Auto-reconnect failed: %s' % e)
+ except pexpect_connection.TimeoutError, e:
+ raise exceptions.CmdError('Auto-reconnect timed out: %s' % e)
+
+ # Fix trailing \r to \n (if \n of last \r\n is captured by prompt).
+ if result and result[-1] == '\r':
+ result = result[:-1] + '\n'
+
+ if (result.startswith('Invalid input -> ') or
+ result == 'Not authorized to execute this command.\n'):
+ if result.endswith('\nType ? for a list\n'):
+ result = result[:-19]
+ elif result.endswith('\n'):
+ result = result[:-1]
+ raise exceptions.CmdError(result)
+ return result
+
+ def _GetConnected(self):
+ """Returns the connected state."""
+ if not (hasattr(self, '_connection') and
+ hasattr(self._connection, 'child')):
+ # The connection has disappeared.
+ self.connected = False
+ else:
+ # Are we still connected?
+ try:
+ self.connected = not bool(self._connection.child.flag_eof)
+ except (AttributeError, TypeError):
+ # The connection has (just) disappeared.
+ self.connected = False
+ return self.connected
+
+ def _Disconnect(self):
+ if hasattr(self, '_connection'):
+ try:
+ self._connection.child.send('exit\r')
+ # Loose prompt RE as prompt changes after first exit.
+ self._connection.child.expect(self._success,
+ timeout=self.timeout_act_user)
+ self._connection.child.send('exit\r')
+ self._connection.child.expect(self._connection.exit_list,
+ timeout=self.timeout_act_user)
+ self.connected = False
+ except (pexpect.EOF, pexpect.TIMEOUT), e:
+ self.connected = False
+ raise exceptions.DisconnectError('%s: %s' % (e.__class__, str(e)))
+
+ def _DisablePager(self):
+ """Disables the pager."""
+ try:
+ self._connection.child.send(self.disable_pager_command)
+ self._connection.child.expect(self._connection.re_prompt,
+ timeout=self.timeout_connect,
+ searchwindowsize=128)
+ except (pexpect.EOF, pexpect.TIMEOUT), e:
+ self.connected = False
+ raise exceptions.CmdError('%s: %s' % (e.__class__, str(e)))
+
+
+class BrocadeMlxDevice(BrocadeDevice):
+ """A base device model suitable for Brocade MLX devices.
+
+ See the base_device.BaseDevice method docstrings.
+ """
+
+ disable_pager_command = _BROCADE_MLX_DISABLE_PAGER
+
+ def __init__(self, **kwargs):
+ self.vendor_name = 'brocademlx'
+ super(BrocadeMlxDevice, self).__init__(**kwargs)
+
+ def _GetFileSize(self, file_name, data):
+ """Gets the size of a file in Brocade 'dir' output.
+
+ Args:
+ file_name: A string, the file name.
+ data: A string, the Brocade's "dir" output.
+
+ Returns:
+ An int, the file size, or None if the value could not be determined.
+ """
+ for line in data.splitlines():
+ match = RE_FILE_LISTING.match(line)
+ if match is not None:
+ (file_size, fname) = match.groups()
+ for char in string.punctuation:
+ file_size = file_size.replace(char, '')
+ if file_name.strip() == fname.strip():
+ try:
+ return int(file_size)
+ except ValueError:
+ continue
+ return None
+
+ def _SetConfig(self, destination_file, data, canary):
+ # Canarying is not supported on BROCADE.
+ if canary:
+ raise exceptions.SetConfigCanaryingError('%s devices do not support '
+ 'configuration canarying.' %
+ self.vendor_name)
+ # The result object.
+ result = base_device.SetConfigResult()
+ # Check for a connection to the Brocade.
+ if not self._GetConnected():
+ raise exceptions.SetConfigError('Cannot use unless already '
+ 'connected to the device.')
+
+ if destination_file in self.NON_FILE_DESTINATIONS:
+ # Use a random remote file name
+ file_name = 'push.%s' % os.urandom(8).encode('hex')
+ else:
+ # Okay, the user is just copying a file, not a configuraiton into
either
+ # startup-config or running-config, therefore we should use the
entire
+ # path.
+ file_name = destination_file
+
+ # Copy the file to the router using SCP.
+ scp = pexpect_connection.ScpPutConnection(
+ host=self.loopback_ipv4,
+ username=self._username,
+ password=self._password)
+
+ # This is a workaround. Brocade case: 537017.
+ # Brocade changed all the filename to lowercases after scp
+ file_name = file_name.lower()
+ try:
+ scp.Copy(data, destination_file='slot1:' + file_name)
+ except pexpect_connection.Error, e:
+ raise exceptions.SetConfigError(
+ 'Failed to copy configuration to remote device. %s' % str(e))
+ # Now that everything is OK locally and the file has been copied,
+ # check the file and tell the device to set the new configuration.
+ try:
+ # Get the file size on the Brocade.
+ try:
+ cmd = 'dir /slot1/%s' % file_name
+ dir_output = self._Cmd(cmd)
+ except exceptions.CmdError, e:
+ if 'Invalid input at' in str(e):
+ raise exceptions.AuthenticationError(
+ 'Username/password for %s(%s) has insufficient privileges '
+ 'to set configuration.' %
+ (self.host, self.loopback_ipv4))
+ else:
+ raise exceptions.SetConfigError('Could not traverse directory '
+ 'output. Command was: %r. '
+ 'Error: %r' % (cmd, str(e)))
+ destination_file_size = self._GetFileSize(file_name, dir_output)
+ # We couldn't parse the output for some reason.
+ if destination_file_size is None:
+ raise exceptions.SetConfigError('Could not find or parse remote '
+ 'file size after copy to device.')
+
+ # Verify file is the correct size on the Brocade.
+ # This should use a checksum (e.g. MD5 or SHA1); Brocade case:
609719.
+ if destination_file_size != len(data):
+ raise exceptions.SetConfigError(
+ 'File transfer corrupted. Source file was: %d bytes, '
+ 'Destination file was: %d bytes.' %
+ (len(data), destination_file_size))
+
+ # Copy the file from flash to the
+ # destination(running-config, startup-config)
+ if destination_file == self.CONFIG_STARTUP:
+ try:
+ self._connection.child.send(
+ 'copy slot1 startup-config %s\r' % file_name)
+ time.sleep(MINOR_PAUSE)
+ pindex = self._connection.child.expect(
+ ['Total bytes', self._connection.re_prompt, 'Error'],
+ timeout=self.timeout_act_user)
+ if pindex == 2:
+ raise exceptions.SetConfigError('Could not copy temporary '
+ 'file to startup-config.')
+ except (pexpect.EOF, pexpect.TIMEOUT), e:
+ raise exceptions.SetConfigError(str(e))
+ elif destination_file == self.CONFIG_RUNNING:
+ try:
+ # This is not working, unfortunately. Cannot copy a file to a
running
+ # config, raised support case RFE2901
+ self._Cmd('copy slot1 running-config %s' % file_name)
+ except exceptions.CmdError, e:
+ raise exceptions.SetConfigError(str(e))
+ # We need to 'write memory' if we are doing running-config.
+ logging.vlog(3, 'Attempting to copy running-config to
startup-config '
+ 'on %s(%s)', self.host, self.loopback_ipv4)
+ try:
+ self._Cmd('wr mem')
+ except exceptions.CmdError, e:
+ raise exceptions.SetConfigError('Failed to write startup-config '
+ 'for %s(%s). Error was: %s' %
+ (self.host, self.loopback_ipv4,
+ str(e)))
+
+ finally:
+ # Now remove the remote temporary file.
+ # If this fails, we may have already copied the file, so log warnings
+ # regarding this and return this information to the user in the
+ # RPC response, so that they can delete the files.
+ if destination_file in self.NON_FILE_DESTINATIONS:
+ try:
+ self._connection.child.send('delete /slot1/%s\r' % file_name)
+ pindex = self._connection.child.expect(
+ ['/slot1/%s removed' % file_name,
+ 'Remove file /slot1/%s failed - File not found' % file_name,
+ r'Error: .*'],
+ timeout=self.timeout_act_user)
+ if pindex == 0:
+ self._connection.child.expect(self._connection.re_prompt,
+ timeout=self.timeout_act_user,
+ searchwindowsize=128)
+ elif pindex == 1:
+ result.transcript = ('Could not delete temporary file %r '
+ '(file does not exist). ' % file_name)
+ logging.warn(result.transcript)
+ else:
+ result.transcript = ('Unable to delete temporary file %r.
Error: %s'
+ % (file_name,
str(self._connection.child)))
+ logging.warn(result.transcript)
+ except (pexpect.EOF, pexpect.TIMEOUT), e:
+ result.transcript = ('Unable to delete temporary file %r.
Error: %s'
+ % (file_name, str(self._connection.child)))
+ logging.warn(result.transcript)
+
+ else:
+ result.transcript = 'SetConfig uploaded the file successfully.'
+
+ return result
+
+ def _GetConfig(self, source):
+ try:
+ if source == 'running-config':
+ result = self._Cmd('show %s' % source)
+ elif source == 'startup-config':
+ result = self._Cmd('show configuration')
+ else:
+ raise exceptions.GetConfigError('source argument must be '
+ '"running-config" or '
+ '"startup-config".')
+ if not result:
+ return exceptions.EmptyConfigError('%s has an empty configuration.'
+ % self.host)
+ else:
+ return result
+ except exceptions.Error, e:
+ raise exceptions.GetConfigError('Could not fetch config from %s. %s.'
+ % (self.host, str(e)))
+
+
+class BrocadeFiDevice(BrocadeDevice):
+ """A base device model suitable for Brocade FastIron devices."""
+
+ disable_pager_command = _BROCADE_TIFI_DISABLE_PAGER
+
+ def __init__(self, **kwargs):
+ self.vendor_name = 'brocadefi'
+ super(BrocadeFiDevice, self).__init__(**kwargs)
+
+
+class BrocadeTiDevice(BrocadeDevice):
+ """A base device model suitable for Brocade TurboIron devices."""
+
+ disable_pager_command = _BROCADE_TIFI_DISABLE_PAGER
+
+ def __init__(self, **kwargs):
+ self.vendor_name = 'brocadeti'
+ super(BrocadeTiDevice, self).__init__(**kwargs)
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/brocade_test.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,93 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for brocade."""
+
+import unittest
+import brocade
+import fake_ssh_connection
+
+
+class BrocadeMlxDeviceTest(unittest.TestCase):
+
+ def setUp(self):
+ self.device = brocade.BrocadeMlxDevice(host='bx01.sql01')
+
+ def testCmd(self):
+ def DoCmd():
+ self.device.Cmd('show interfaces')
+ self.assertRaises(AttributeError, DoCmd)
+
+ def testGetConfig(self):
+ def DoGetConfig():
+ self.device.GetConfig('running-config')
+ self.assertRaises(AttributeError, DoGetConfig)
+
+ def testDisconnect(self):
+ self.assertIsNone(self.device._Disconnect())
+
+
+class BrocadeTiDeviceTest(unittest.TestCase):
+
+ def setUp(self):
+ cli_prompt = 'SSH@cdzncsa1switch#'
+ config_snippet = """\r
+Current configuration:\r
+!\r
+ver 04.2.00d
+!\r
+interface ethernet 17\r
+ port-name cs01.cd.xe-5/8 [T=naFP]\r
+ ip address 10.240.129.82 255.255.255.252\r
+ link-aggregate configure timeout short\r
+ link-aggregate configure key 10001\r
+ link-aggregate active\r
+!
+end\r
+\r
+%s""" % cli_prompt
+
+ self.show_running_config_result = """Current configuration:
+!
+ver 04.2.00d
+!
+interface ethernet 17
+ port-name cs01.cd.xe-5/8 [T=naFP]
+ ip address 10.240.129.82 255.255.255.252
+ link-aggregate configure timeout short
+ link-aggregate configure key 10001
+ link-aggregate active
+!
+end
+
+"""
+ # Commands and responses from the perspective of the device.
+ command_response_dict = {
+ '__logged_in__': cli_prompt,
+ 'skip-page-display\r': 'Disable page display mode\r\n%s' %
cli_prompt,
+ 'show running-config\r': config_snippet}
+ ssh_client = fake_ssh_connection.FakeSshClient(command_response_dict)
+ self.device = brocade.BrocadeTiDevice(
+ host='cdzncsa1switch', ssh_client=ssh_client)
+
+ def testShowRunningConfig(self):
+ self.device._Connect(username='userX', password='passwordX',
+ enable_password='enableX')
+ response = self.device._Cmd('show running-config')
+ self.assertEqual(self.show_running_config_result, response)
+
+
+if __name__ == '__main__':
+ unittest.main()
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/cisconx.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,88 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""A push implementation for Cisco Nexus(NX-OS) devices.
+
+This module implements the base device interface of base_device.py for
+Cisco NX-OS devices.
+"""
+
+__author__ = 'mij...@google.com (Mijith)'
+
+
+import gflags
+
+import paramiko_device
+import push_exceptions as exceptions
+
+FLAGS = gflags.FLAGS
+
+gflags.DEFINE_float('cisconx_timeout_response', None,
+ 'Cisco nexus device response timeout in seconds.')
+gflags.DEFINE_float('cisconx_timeout_connect', None,
+ 'Cisco nexus device connect timeout in seconds.')
+gflags.DEFINE_float('cisconx_timeout_idle', None,
+ 'Cisco nexus device idle timeout in seconds.')
+gflags.DEFINE_float('cisconx_timeout_disconnect', None,
+ 'Cisco nexus device disconnect timeout in seconds.')
+gflags.DEFINE_float('cisconx_timeout_act_user', None,
+ 'Cisco nexus device user activation timeout in
seconds.')
+
+INVALID_OUT = 'Cmd exec error.'
+# eg:.
+# [ mijith@pulsar: ~ ].
+# $ ssh gmon...@us-mtv-43-fabsw1.mtv 'foo'.
+# Syntax error while parsing 'foo'.
+#
+# Cmd exec error.
+
+
+class CiscoNexusDevice(paramiko_device.ParamikoDevice):
+ """A base device model suitable for Cisco Nexus devices.
+
+ See the base_device.BaseDevice method docstrings.
+ """
+
+ def __init__(self, **kwargs):
+ self.vendor_name = 'cisconx'
+ super(CiscoNexusDevice, self).__init__(**kwargs)
+
+ def _Cmd(self, command, mode=None):
+ """Cisco Nexus wrapper for ParamikoDevice._Cmd()."""
+
+ result = super(CiscoNexusDevice, self)._Cmd(command, mode)
+ # On Successful execution of a command.
+ # ssh gmon...@us-mtv-43-fabsw1.mtv 'show version'.
+ # Password:.
+ # Cisco Nexus Operating System (NX-OS) Software
+ # TAC support: http://www.cisco.com/tac.
+ # [output truncated].
+
+ # Incomplete Command Example.
+ # [ mijith@pulsar: ~ ].
+ # $ ssh gmon...@us-mtv-43-fabsw1.mtv 'show'
+ # Syntax error while parsing 'show'.
+ # Cmd exec error.
+
+ # Invalid Command Example.
+ # [ mijith@pulsar: ~ ].
+ # $ ssh gmon...@us-mtv-43-fabsw1.mtv 'foo'.
+ # Syntax error while parsing 'foo'.
+ # Cmd exec error.
+
+ if result.endswith(INVALID_OUT):
+ raise exceptions.CmdError('INVALID COMMAND: %s' % command)
+
+ return result
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/ciscoxr.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,66 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""A Cisco XR device .
+
+This module implements the base device interface of base_device.py for
+CiscoXR devices.
+"""
+
+
+import gflags
+
+import paramiko_device
+import push_exceptions as exceptions
+
+FLAGS = gflags.FLAGS
+
+gflags.DEFINE_float('ciscoxr_timeout_response', None,
+ 'CiscoXR device response timeout in seconds.')
+gflags.DEFINE_float('ciscoxr_timeout_connect', None,
+ 'CiscoXR device connect timeout in seconds.')
+gflags.DEFINE_float('ciscoxr_timeout_idle', None,
+ 'CiscoXR device idle timeout in seconds.')
+gflags.DEFINE_float('ciscoxr_timeout_disconnect', None,
+ 'CiscoXR device disconnect timeout in seconds.')
+gflags.DEFINE_float('ciscoxr_timeout_act_user', None,
+ 'CiscoXR device user activation timeout in seconds.')
+
+# pylint: disable=arguments-differ
+# 38:CiscoXrDevice._Cmd: Arguments number differs from overridden method.
+
+
+class CiscoXrDevice(paramiko_device.ParamikoDevice):
+ """A base device model suitable for CiscoXR devices.
+
+ See the base_device.BaseDevice method docstrings.
+ """
+
+ def __init__(self, **kwargs):
+ self.vendor_name = 'ciscoxr'
+ super(CiscoXrDevice, self).__init__(**kwargs)
+
+ def _Cmd(self, command, mode=None):
+ """CiscoXR wrapper for ParamikoDevice._Cmd()."""
+
+ result = super(CiscoXrDevice, self)._Cmd(command, mode)
+ if result.endswith("% Invalid input detected at '^' marker.\r\n"):
+ raise exceptions.CmdError('Invalid input: %s' % command)
+ if result.endswith('% Bad hostname or protocol not running\r\n'):
+ raise exceptions.CmdError(
+ 'Bad hostname or protocol not running: %s' % command)
+ if result.endswith('% Incomplete command.\r\n'):
+ raise exceptions.CmdError('Incomplete command: %s' % command)
+ return result
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/fake_ssh_connection.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,87 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Fake classes for unit tests.
+
+The class FakeSshClient is a fake for paramiko.SSHClient, it implements a
very
+minimal set of methods just enough too stub out paramiko.SSHClient when
used in
+unit test for clients based on pexpect_client.ParamikoSshConnection.
+The classes FakeChannel and FakeTransport are substitutes for their
paramiko
+counterparts Channel and Transport.
+"""
+# pylint: disable=g-bad-name
+class Error(Exception):
+ pass
+
+
+class FakeChannelError(Error):
+ """An error occured in the fake Channel class."""
+
+
+class FakeTransport(object):
+ """A fake transport class for unit test purposes."""
+
+ def __init__(self):
+ self.active = True
+
+ def is_active(self):
+ return self.active
+
+
+class FakeChannel(object):
+ """A fake channel class for unit test purposes."""
+
+ def __init__(self, command_response_dict):
+ self.command_response_dict = command_response_dict
+ self.transport = FakeTransport()
+ self.timeout = None
+ self.last_sent = '__logged_in__'
+
+ def set_combine_stderr(self, unused_arg):
+ pass
+
+ def get_id(self):
+ return 1
+
+ def get_transport(self):
+ return self.transport
+
+ def settimeout(self, timeout):
+ self.timeout = timeout
+
+ def recv(self, unused_size):
+ if self.last_sent:
+ last_sent = self.last_sent
+ self.last_sent = None
+ if last_sent in self.command_response_dict:
+ return self.command_response_dict[last_sent]
+ else:
+ raise FakeChannelError('unknown input %r' % last_sent)
+
+ def send(self, command):
+ self.last_sent = command
+
+
+class FakeSshClient(object):
+ """A fake SSH client class for unit test purposes."""
+
+ def __init__(self, command_response_dict):
+ self.channel = FakeChannel(command_response_dict)
+
+ def Connect(self, **unused_kwargs):
+ return self
+
+ def invoke_shell(self):
+ return self.channel
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/hp.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,167 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""An HP ProCurve switch device .
+
+This module implements the base device interface of base_device.py for
+Hewlett-Packard ProCurve Ethernet switches.
+"""
+
+__author__ = 'af...@google.com (Andreux Fort)'
+
+import os
+import re
+
+import pexpect
+
+import gflags
+import logging
+
+import base_device
+import pexpect_connection
+import push_exceptions as exceptions
+
+
+FLAGS = gflags.FLAGS
+
+
+gflags.DEFINE_float('hp_timeout_response', None,
+ 'HP device response timeout in seconds.')
+gflags.DEFINE_float('hp_timeout_connect', 22.0,
+ 'HP device connect timeout in seconds.')
+gflags.DEFINE_float('hp_timeout_idle', None,
+ 'HP device idle timeout in seconds.')
+gflags.DEFINE_float('hp_timeout_disconnect', None,
+ 'HP device disconnect timeout in seconds.')
+gflags.DEFINE_float('hp_timeout_act_user', None,
+ 'HP device user activation timeout in seconds.')
+
+
+class HpProCurveDevice(base_device.BaseDevice):
+ """A base device model for Hewlett-Packard ProCurve switches."""
+
+ RE_INVALID = re.compile(r'^(Invalid|Ambiguous) input:', re.I | re.M)
+ RE_PAGER = re.compile(r'-- MORE --, next page: Space, next line: Enter, '
+ 'quit: Control-C')
+
+ def __init__(self, **kwargs):
+ self.vendor_name = 'hp'
+ super(HpProCurveDevice, self).__init__(**kwargs)
+
+ # The response regexp indicating connection success.
+ self._success = r'ProCurve .*[Ss]witch'
+
+ def _Connect(self, username, password=None, ssh_keys=None,
+ enable_password=None, ssl_cert_set=None):
+ # Quieten pylint.
+ _ = ssl_cert_set
+ self._connection = pexpect_connection.HpSshFilterConnection(
+ self.loopback_ipv4, username, password, success=self._success,
+ timeout=self.timeout_connect, find_prompt=True, ssh_keys=ssh_keys,
+ enable_password=enable_password)
+ try:
+ self._connection.Connect()
+ self._DisablePager()
+ except pexpect_connection.ConnectionError, e:
+ self.connected = False
+ raise exceptions.ConnectError(e)
+ except pexpect_connection.TimeoutError, e:
+ self.connected = False
+ raise exceptions.ConnectError('Timed out connecting to %s(%s) after '
+ '%s seconds.' %
+ (self.host, self.loopback_ipv4,
str(e)))
+
+ def _Cmd(self, command, mode=None, called_already=False):
+ _ = mode
+ # Strip question marks and short-circuit if we have nothing more.
+ command = command.replace('?', '')
+ if not command:
+ return ''
+
+ try:
+ self._connection.child.send(command+'\r')
+ self._connection.child.expect(command+'\n')
+ result = ''
+ while True:
+ i = self._connection.child.expect([self._connection.re_prompt,
+ self.RE_PAGER],
+ timeout=self.timeout_response,
+ searchwindowsize=128)
+ # HP prefers \n\r to \r\n.
+ result += self._connection.child.before.replace('\n\r', os.linesep)
+ if i == 1:
+ self._connection.child.send(' ')
+ else:
+ break
+ # Check if the device told us our command was not recognized.
+ if self.RE_INVALID.search(result) is not None:
+ raise exceptions.CmdError('Command %r invalid on %s(%s)' %
+ (command, self.host, self.loopback_ipv4))
+ return result
+ except pexpect.TIMEOUT, e:
+ self.connected = False
+ raise exceptions.CmdError('%s: %s' % (e.__class__, str(e)))
+ except pexpect.EOF, e:
+ if not called_already:
+ return self._Cmd(command, mode=mode, called_already=True)
+ else:
+ self.connected = False
+ raise exceptions.CmdError('%s: %s' % (e.__class__, str(e)))
+
+ def _DisablePager(self):
+ """Enables and logs in so the pager can be disabled."""
+ # Maximum terminal size on sw version M.08.74 (8095) is 1920x1000.
+ try:
+ self._connection.child.send('terminal length 1000\r')
+ self._connection.child.expect(self._connection.re_prompt,
+ timeout=self.timeout_act_user,
+ searchwindowsize=128)
+ self._connection.child.send('terminal width 1920\r')
+ self._connection.child.expect(self._connection.re_prompt,
+ timeout=self.timeout_act_user,
+ searchwindowsize=128)
+ except (pexpect.EOF, pexpect.TIMEOUT), e:
+ self.connected = False
+ raise exceptions.CmdError('%s: %s' % (e.__class__, str(e)))
+
+ def _Disconnect(self):
+ """Disconnects from the device."""
+ if hasattr(self, '_connection'):
+ try:
+ try:
+ self._connection.child.send('exit\r')
+ while True:
+ i = self._connection.child.expect([r'\S(?:#|>) ',
+ r'Do you want to log out',
+ r'Do you want to save'],
+
timeout=self.timeout_act_user)
+ if i == 0:
+ self._connection.child.send('exit\r')
+ continue
+ elif i == 1:
+ self._connection.child.send('y')
+ return
+ elif i == 2:
+ self._connection.child.send('n')
+ logging.warn('Uncomitted config on %s(%s). Not saving.',
+ self.host, self.loopback_ipv4)
+ return
+ except pexpect.TIMEOUT, e:
+ raise exceptions.DisconnectError('%s: %s' % (e.__class__,
str(e)))
+ except pexpect.EOF, e:
+ # An EOF now means nothing more than a disconnect.
+ pass
+ finally:
+ self.connected = False
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/ios.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,383 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""A Cisco IOS devicemodel.
+
+This module implements a device interface of base_device.py for
+most of the herd of variants of Cisco IOS devices.
+"""
+
+import hashlib
+import os
+import re
+import time
+
+import pexpect
+
+import gflags
+import logging
+
+import base_device
+import pexpect_connection
+import push_exceptions as exceptions
+
+FLAGS = gflags.FLAGS
+
+gflags.DEFINE_float('ios_timeout_response', None,
+ 'IOS device response timeout in seconds.')
+gflags.DEFINE_float('ios_timeout_connect', None,
+ 'IOS device connect timeout in seconds.')
+gflags.DEFINE_float('ios_timeout_idle', None,
+ 'IOS device idle timeout in seconds.')
+gflags.DEFINE_float('ios_timeout_disconnect', None,
+ 'IOS device disconnect timeout in seconds.')
+gflags.DEFINE_float('ios_timeout_act_user', None,
+ 'IOS device user activation timeout in seconds.')
+
+MD5_RE = re.compile(r'verify /md5 \(\S+\)\s+=\s+([A-Fa-f0-9]+)')
+# Used in sleep statements for a minor pause.
+MINOR_PAUSE = 0.05
+
+# Some Cisco ways of saying 'access denied' and/or 'invalid command'.
+# Due to the way Cisco privilege levels work and since unknown commands
+# may be looked up in DNS, any of these could be a response which really
+# means 'access denied', or they could mean what they say.
+INVALID_1 = "% Invalid input detected at '^' marker.\n\n"
+INVALID_2 = ('% Unknown command or computer name, or unable to find
computer '
+ 'address\n')
+INVALID_3 = 'Command authorization failed.\n\n'
+INVALID_4 = '% Authorization failed.\n\n'
+INVALID_5 = '% Incomplete command.\n\n'
+INVALID_6_PREFIX = '% Ambiguous command:'
+
+
+class DeleteFileError(Exception):
+ """A file was not successfully deleted."""
+
+
+class IosDevice(base_device.BaseDevice):
+ """A device model for devices with IOS-like interfaces."""
+
+ def __init__(self, **kwargs):
+ self.vendor_name = 'ios'
+ super(IosDevice, self).__init__(**kwargs)
+
+ # The response regexp indicating connection success.
+ self._success = r'(?:^|\n)([]A-Za-z0-9\.\-[]+[>#])'
+
+ def _Connect(self, username, password=None, ssh_keys=None,
+ enable_password=None, ssl_cert_set=None):
+ _ = enable_password, ssl_cert_set
+ self._connection = pexpect_connection.ParamikoSshConnection(
+ self.loopback_ipv4, username, password, self._success,
+ timeout=self.timeout_connect, find_prompt=True, ssh_keys=ssh_keys)
+ try:
+ self._connection.Connect()
+ self._DisablePager()
+ self.connected = True
+ except pexpect_connection.ConnectionError as e:
+ self.connected = False
+ raise exceptions.ConnectError(e)
+ except pexpect_connection.TimeoutError as e:
+ self.connected = False
+ raise exceptions.ConnectError('Timed out connecting to %s(%s) after '
+ '%s seconds.' %
+ (self.host, self.loopback_ipv4,
str(e)))
+
+ def _Cmd(self, command, mode=None):
+
+ def SendAndWait(command):
+ """Sends a command and waits for a response."""
+ self._connection.child.send(command+'\r')
+ self._connection.child.expect('\r\n', timeout=self.timeout_response)
+ self._connection.child.expect(self._connection.re_prompt,
+ timeout=self.timeout_response,
+ searchwindowsize=128)
+ return self._connection.child.before.replace('\r\n', os.linesep)
+
+ # Quieten pylint.
+ _ = mode
+ # We strip question-marks ('?') from the input as they upset the
+ # buffering for minimal gain (they work only on IOS and not on FTOS).
+ command = command.replace('?', '')
+ result = ''
+ try:
+ result = SendAndWait(command)
+ except pexpect.TIMEOUT as e:
+ self.connected = False
+ raise exceptions.CmdError('%s: %s' % (e.__class__, str(e)))
+ except pexpect.EOF:
+ # Retry once on EOF error, in case we have been idle disconnected.
+ try:
+ self.connected = False
+ self._connection.Connect()
+ self._DisablePager()
+ self.connected = True
+ result = SendAndWait(command)
+ except pexpect.EOF:
+ raise exceptions.CmdError('Failed with EOF error twice.')
+ except pexpect_connection.ConnectionError as e:
+ raise exceptions.CmdError('Auto-reconnect failed: %s' % e)
+ except pexpect_connection.TimeoutError as e:
+ raise exceptions.CmdError('Auto-reconnect timed out: %s' % e)
+
+ # Fix trailing \r to \n (if \n of last \r\n is captured by prompt).
+ if result and result[-1] == '\r':
+ result = result[:-1] + '\n'
+
+ if (result.endswith(INVALID_1) or result.endswith(INVALID_2) or
+ result.endswith(INVALID_3) or result.endswith(INVALID_4) or
+ result.endswith(INVALID_5) or (
+ result.endswith('\n') and
+ result[result[:-1].rfind('\n') + 1:].startswith(
+ INVALID_6_PREFIX))):
+ raise exceptions.CmdError('Command failed: %s' % result)
+
+ return result
+
+ def _SetConfig(self, destination_file, data, canary):
+ # Canarying is not supported on IOS.
+ if canary:
+ raise exceptions.SetConfigCanaryingError('%s devices do not support '
+ 'configuration canarying.' %
+ self.vendor_name)
+ # We only support copying to 'running-config' or 'startup-config' on
IOS.
+ if destination_file not in ('running-config', 'startup-config'):
+ raise exceptions.SetConfigError('destination_file argument must be '
+ '"running-config"
or "startup-config" '
+ 'for %s devices.' % self.vendor_name)
+ # Result object.
+ result = base_device.SetConfigResult()
+
+ # Get the MD5 sum of the file.
+ local_digest = hashlib.md5(data).hexdigest()
+
+ try:
+ # Get the working path from the remote device
+# remote_path = self._Cmd('pwd')
+ remote_path = 'nvram:/'
+ except exceptions.CmdError as e:
+ msg = 'Error obtaining working directory: %s' % e
+ logging.error(msg)
+ raise exceptions.SetConfigError(msg)
+
+ # Use a random remote file name
+ remote_tmpfile = '%s/push.%s' % (
+ remote_path.rstrip(), os.urandom(8).encode('hex'))
+
+ # Upload the file to the device.
+ scp = pexpect_connection.ScpPutConnection(
+ self.loopback_ipv4,
+ username=self._username,
+ password=self._password)
+ try:
+ scp.Copy(data, remote_tmpfile)
+ except pexpect_connection.Error as e:
+ raise exceptions.SetConfigError(
+ 'Failed to copy configuration to remote device. %s' % str(e))
+
+ # Get the file size on the router.
+ try:
+ # Get the MD5 hexdigest of the file on the remote device.
+ try:
+ verify_output = self._Cmd('verify /md5 %s' % remote_tmpfile)
+ match = MD5_RE.search(verify_output)
+ if match is not None:
+ remote_digest = match.group(1)
+ else:
+ raise exceptions.SetConfigError(
+ 'The "verify /md5 <filename>" command did not produce '
+ 'expected results. It returned: %r' % verify_output)
+ except exceptions.CmdError as e:
+ raise exceptions.SetConfigError(
+ 'The MD5 hash command on the router did not succed. '
+ 'The device may not support: "verify /md5 <filename>"')
+ # Verify the local_digest and remote_digest are the same.
+ if local_digest != remote_digest:
+ raise exceptions.SetConfigError(
+ 'File transfer to remote host corrupted. Local digest: %r, '
+ 'Remote digest: %r' % (local_digest, remote_digest))
+
+ # Copy the file from flash to the
+ # destination(running-config, startup-config).
+ # Catch errors that may occur during application, and report
+ # these to the user.
+ try:
+ self._connection.child.send(
+ 'copy %s %s\r' % (remote_tmpfile, destination_file))
+ pindex = self._connection.child.expect(
+ [r'Destination filename \[%s\]\?' % destination_file,
+ r'%\s*\S*.*',
+ r'%Error.*',
+ self._connection.re_prompt],
+ timeout=self.timeout_act_user)
+ if pindex == 0:
+ self._connection.child.send('\r')
+ try:
+ pindex = self._connection.child.expect(
+ [r'Invalid input detected',
+ self._connection.re_prompt,
+ r'%Warning:There is a file already existing.*'
+ 'Do you want to over write\? \[confirm\]'],
+ timeout=self.timeout_act_user)
+ if pindex == 0:
+ # Search again using findall to get all bad lines.
+ bad_lines = re.findall(
+ r'^(.*)$[\s\^]+% Invalid input',
+ self._connection.child.match.string,
+ re.MULTILINE)
+ raise exceptions.SetConfigSyntaxError(
+ 'Configuration loaded, but with bad lines:\n%s' %
+ '\n'.join(bad_lines))
+ if pindex == 2:
+ # Don't over-write.
+ self._connection.child.send('n')
+ raise exceptions.SetConfigError(
+ 'Destination file %r already exists, cannot overwrite.'
+ % destination_file)
+ except (pexpect.EOF, pexpect.TIMEOUT) as e:
+ raise exceptions.SetConfigError(
+ 'Copied file to device, but did not '
+ 'receive prompt afterwards. %s %s' %
+ (self._connection.child.before,
self._connection.child.after))
+
+ elif pindex == 2:
+ print "MATCHED 2"
+ # The expect does a re.search, search again using findall to get
all
+ raise exceptions.SetConfigError('Could not copy temporary '
+ 'file to %s.' % destination_file)
+ except (pexpect.EOF, pexpect.TIMEOUT) as e:
+ raise exceptions.SetConfigError(
+ 'Attempted to copy to bootflash, but a timeout occurred.')
+
+ # We need to 'write memory' if we are doing running-config.
+ if destination_file == 'running-config':
+ logging.debug('Attempting to copy running-config to
startup-config '
+ 'on %s(%s)', self.host, self.loopback_ipv4)
+ try:
+ self._Cmd('wr mem')
+ except exceptions.CmdError as e:
+ raise exceptions.SetConfigError('Failed to write startup-config '
+ 'for %s(%s). Changes applied. '
+ 'Error was: %s' %
+ (self.host, self.loopback_ipv4,
+ str(e)))
+ finally:
+ try:
+ self._DeleteFile(remote_tmpfile)
+ except DeleteFileError as e:
+ result.transcript = 'SetConfig warning: %s' % str(e)
+ logging.warn(result.transcript)
+
+ # And finally, return the result text.
+ return result
+
+ def _DeleteFile(self, file_name):
+ """Delete a file.
+
+ Args:
+ file_name: A string, the file name.
+
+ Raises:
+ DeleteFileError, if the deletion failed.
+ """
+ try:
+ self._connection.child.send('\r')
+ self._connection.child.expect('\r\n', timeout=self.timeout_act_user)
+ self._connection.child.expect(self._connection.re_prompt,
+ timeout=self.timeout_act_user,
+ searchwindowsize=128)
+ self._connection.child.send('delete %s\r' % file_name)
+ except pexpect.ExceptionPexpect:
+ raise DeleteFileError('DeleteFile operation failed. %s' %
+ self._connection.child)
+
+ try:
+ pindex = self._connection.child.expect(
+ [r'Delete filename \[.*\]\?',
+ r'%.*Error.*'],
+ timeout=self.timeout_act_user)
+ if pindex == 0:
+ self._connection.child.send('\r')
+ logging.debug('DeleteFile: answering first confirmation.')
+ self._connection.child.expect([r'Delete .*\[confirm\]'],
+ timeout=self.timeout_act_user)
+ logging.debug('DeleteFile: answering second confirmation.')
+ self._connection.child.send('\r')
+ elif pindex == 1:
+ raise DeleteFileError('DeleteFile operation failed. %s' %
+ self._connection.child.match)
+
+ pindex = self._connection.child.expect([self._connection.re_prompt,
+ r'%.*Error.*'],
+ timeout=self.timeout_act_user)
+ if pindex == 1:
+ raise DeleteFileError('DeleteFile operation failed. %s' %
+ self._connection.child.match)
+ logging.debug('DeleteFile: success.')
+ except pexpect.ExceptionPexpect:
+ raise DeleteFileError('DeleteFile operation failed. %s' %
+ self._connection.child)
+
+ def _GetConfig(self, source):
+ try:
+ if source in ('running-config', 'startup-config'):
+ result = self._Cmd('show %s' % source)
+ else:
+ raise exceptions.GetConfigError('source argument must be '
+ '"running-config" or '
+ '"startup-config".')
+ if not result:
+ return exceptions.EmptyConfigError('%s has an empty
configuration.' %
+ self.host)
+ else:
+ return result
+ except exceptions.Error as e:
+ raise exceptions.GetConfigError('Could not fetch config
from %s. %s.' %
+ (self.host, str(e)))
+
+ def _Disconnect(self):
+ if hasattr(self, '_connection'):
+ try:
+ self._connection.child.send('exit\r')
+ self._connection.child.expect(self._connection.exit_list,
+ timeout=self.timeout_act_user)
+ self.connected = False
+ except (pexpect.EOF, pexpect.TIMEOUT) as e:
+ self.connected = False
+ raise exceptions.DisconnectError('%s: %s' % (e.__class__, str(e)))
+
+ def _DisablePager(self):
+ """Disables the pager."""
+ try:
+ self._connection.child.send('\r')
+ self._connection.child.expect(r'\r\n',
+ timeout=self.timeout_connect)
+ self._connection.child.expect(self._connection.re_prompt,
+ timeout=self.timeout_connect,
+ searchwindowsize=128)
+ self._connection.child.send('terminal length 0\r')
+ pindex = self._connection.child.expect(
+ [self._connection.re_prompt, r'Command authorization failed\.'],
+ timeout=self.timeout_connect)
+ if pindex == 1:
+ self.connected = False
+ raise exceptions.ConnectError('terminal length 0 command denied.')
+ # Pause momentarily to avoid a TAC+ packet drop. See b/1890881.
+ time.sleep(0.5)
+ except (pexpect.EOF, pexpect.TIMEOUT) as e:
+ self.connected = False
+ raise exceptions.ConnectError('%s: %s' % (e.__class__, str(e)))
+ logging.debug('terminal length set to 0')
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/junos.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,507 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""A JunOS device.
+
+This module implements the device interface of base_device.py for
+Juniper Networks' devices running the JunOS operating system.
+These devices are typically routers, such as the T640 and MX960.
+"""
+
+import hashlib
+import os
+import re
+import tempfile
+import threading
+
+import paramiko
+
+import gflags
+import logging
+
+import base_device
+import paramiko_device
+import push_exceptions as exceptions
+
+
+FLAGS = gflags.FLAGS
+
+gflags.DEFINE_float('junos_timeout_response', None,
+ 'JunOS device response timeout in seconds.')
+gflags.DEFINE_float('junos_timeout_connect', None,
+ 'JunOS device connect timeout in seconds.')
+gflags.DEFINE_float('junos_timeout_idle', None,
+ 'JunOS device idle timeout in seconds.')
+gflags.DEFINE_float('junos_timeout_disconnect', None,
+ 'JunOS device disconnect timeout in seconds.')
+gflags.DEFINE_float('junos_timeout_act_user', None,
+ 'JunOS device user activation timeout in seconds.')
+gflags.DEFINE_boolean('paramiko_logging',
+ False,
+ 'Log Paramiko output to STDERR found.')
+
+
+class JunosDevice(paramiko_device.ParamikoDevice):
+ """A device model suitable for Juniper JunOS devices.
+
+ See the base_device.BaseDevice method docstrings.
+ """
+ # Used to protect the SetupParamikoLogging method, and state.
+ _paramiko_logging_lock = threading.Lock()
+ _paramiko_logging_initialized = False
+
+ # Response strings that indicate an error during SetConfig().
+ JUNOS_LOAD_ERRORS = ('error:',
+ ' errors',
+ 'error recovery ignores input until this point:')
+
+ # Response strings that do *not* indicate an error, and should be ignored
+ # This currently matches that start with a "diff" character, or indicate
a
+ # failure to communicate with an RE.
+ IGNORED_JUNOS_LINES = re.compile(
+ r'^[+!-]|' # Match diff characters at start of line.
+ r'(?<!syntax )error: .*connect to re[0-9] :', # Match missing RE
errors.
+ re.IGNORECASE)
+
+ @staticmethod
+ def _CleanupErrorLine(line):
+ """Removes text from "line" which does not indicate an error.
+
+ This is a helper function for _RaiseExceptionIfLoadError.
+
+ If the line contains a single or double quote character, then it's
assumed
+ to start a quoted chunk of configuration from the client. Characters
after
+ the quote are ignored.
+ If the (remaining part of the) line matches IGNORED_JUNOS_LINES, '' is
+ returned.
+ Otherwise, returns the characters before the first quote.
+
+ This avoids problems where a user-entered interface description
contains the
+ word "error", for example.
+
+ Args:
+ line: A string, the string to clean up.
+
+ Returns:
+ The line, or a substring of the line (can be empty).
+ """
+ # Chop off anything after single or double quotes first.
+ remaining = line.partition('\'')[0].partition('"')[0]
+ if JunosDevice.IGNORED_JUNOS_LINES.match(remaining):
+ return ''
+ else:
+ return remaining
+
+ @staticmethod
+ def _RaiseExceptionIfLoadError(result, expect_config_check=False,
+ expect_commit=False):
+ """Checks if a result string from a load configuration contains an
error.
+
+ Args:
+ result: A string, the result of loading the configuration.
+ expect_config_check: A boolean. If true, then the exception-raising
code
+ will raise a special "configuration check failed" exception if the
+ string "configuration check succeeds" isn't found.
+ expect_commit: A boolean. If True, then the function raises an
exception
+ if the string "commit complete" isn't found on a line by itself.
+
+ Raises:
+ An exception derived from exceptions.SetConfigError if the result
+ indicates an error, else nothing.
+ """
+ # Remove output assumed to be part of diffs or quoted parts of the
input.
+ # Lines that are considered to be part of diffs start with + or - or !,
+ # start and end with square brackets like "[edit ... ]", or immediately
+ # follow a line that starts and ends with square brackets.
+ lines = []
+ last_line_started_diff = False
+ for line in result.splitlines():
+ if last_line_started_diff:
+ last_line_started_diff = False
+ # Ignore the line.
+ elif line.startswith('[') and line.endswith(']'):
+ last_line_started_diff = True
+ else:
+ lines.append(JunosDevice._CleanupErrorLine(line))
+
+ for error in JunosDevice.JUNOS_LOAD_ERRORS:
+ if any(error in line for line in lines):
+ break
+ else:
+ # No special "error" string found, check for "commit complete".
b/9750034
+ # and Junos bug PR799925.
+ if expect_commit and all(
+ 'commit complete' not in line for line in lines):
+ raise exceptions.SetConfigError(
+ '"commit complete" expected, but not found in output:\n%s' %
result)
+ return
+
+ # Raise the right type of exception based on the error string found.
+ if any('syntax error' in line for line in lines):
+ raise exceptions.SetConfigSyntaxError(
+ 'Device reports a syntax error in the configuration.\n%s' %
+ result)
+ elif expect_config_check and all(
+ 'configuration check succeeds' not in line for line in lines):
+ raise exceptions.SetConfigSyntaxError(
+ 'Configuration check failed.\n%s' % result)
+ else:
+ raise exceptions.SetConfigError(
+ 'Error occurred during config load.\n%s' % result)
+
+ def __init__(self, **kwargs):
+ self.vendor_name = 'junos'
+ self.unsupported_non_file_destinations = ()
+ super(JunosDevice, self).__init__(**kwargs)
+
+ # Setup paramiko logging once only.
+ if not JunosDevice._paramiko_logging_initialized:
+ self._SetupParamikoLogging()
+
+ def _SetupTimeouts(self):
+ if FLAGS.junos_timeout_idle is None:
+ self.timeout_idle = 1790.0
+
+ def _SetupParamikoLogging(self):
+ if not JunosDevice._paramiko_logging_initialized and
FLAGS.paramiko_logging:
+ # UN*X specific.
+ log_dir_string = '/dev/fd/2'
+ logging.info('Paramiko SSH2 logging to path: %r', log_dir_string)
+ paramiko.util.log_to_file(log_dir_string)
+ JunosDevice._paramiko_logging_initialized = True
+
+ def _Cmd(self, command, mode=None):
+ # Enforce that the 'ping' and 'monitor' commands have a count (else
they
+ # never complete). JunOS allows these to be abbreviated to 'p'
and 'mo'
+ # since no other commands begin with those prefixes.
+ if command.startswith('p') or command.startswith('mo'):
+ if ' count ' not in command:
+ # Use 5 pings by default (same as default for 'ping <host> rapid').
+ command += ' count 5'
+ # Enforce that traceroute and monitor have the stderr (header) and
stdout
+ # merged, in that order.
+ if command.startswith('tr') or command.startswith('mo'):
+ merge_stderr_first = True
+ else:
+ merge_stderr_first = False
+ # Run the modified command.
+ result = super(JunosDevice, self)._Cmd(
+ command, mode=mode,
+ merge_stderr_first=merge_stderr_first,
+ require_low_chanid=True)
+ # Report JunOS errors on stdout as CmdError.
+ if result.startswith('\nerror: '):
+ raise exceptions.CmdError(result[1:]) # Drop the leading \n.
+ else:
+ return result
+
+ def _ChecksumsMatch(self, local_file_name, remote_file_name):
+ """Compares the local and remote checksums for the named file.
+
+ Args:
+ local_file_name: A string, the filename on the local host.
+ remote_file_name: A string, the file path and name of the remote
file.
+
+ Returns:
+ A boolean. True iff the checksums match, else False.
+ """
+ remote_md5 = self._Cmd('file checksum md5 ' + remote_file_name)
+ logging.debug('Remote checksum output: %s', remote_md5)
+ local_md5 = hashlib.md5(open(local_file_name).read()).hexdigest()
+ logging.debug('Local checksum: %s', local_md5)
+ try:
+ if local_md5 == remote_md5.split()[3]:
+ logging.debug('PASS MD5 checksums match.')
+ return True
+ else:
+ logging.error('FAIL MD5 checksums do not match.')
+ return False
+ except IndexError:
+ logging.error('ERROR MD5 checksum parse error.')
+ logging.error('ERROR local checksum: %r', local_md5)
+ logging.error('ERROR remote checksum: %r', remote_md5)
+ return False
+
+ def _GetConfig(self, source_file):
+ """Gets file or running configuration from the remote device.
+
+ Args:
+ source_file: A string, containing path to the file that should be
+ retrieved from the remote device. It can also contain the defined
+ reserved word self.CONFIG_RUNNING, in which case this method
+ retrieves the running configuration from the remote device.
+
+ Returns:
+ response: A string, content of the retrieved file or running
+ configuration.
+
+ Raises:
+ exceptions.GetConfigError: An error occured during the retrieval.
+ exceptions.EmptyConfigError: Running configuration is empty.
+ """
+ response = ''
+
+ if source_file == self.CONFIG_RUNNING:
+ try:
+ response = self._Cmd('show configuration')
+ except exceptions.CmdError:
+ msg = ('Could not retrieve system configuration from %s' %
+ repr(self.host))
+ logging.error(msg)
+ raise exceptions.GetConfigError(msg)
+ if not response:
+ raise exceptions.EmptyConfigError(
+ 'Configuration of %s is empty' % repr(self.host))
+
+ else:
+ tempfile_ptr = tempfile.NamedTemporaryFile()
+ try:
+ self._GetFileViaSftp(local_filename=tempfile_ptr.name,
+ remote_filename=source_file)
+ except (paramiko.SFTPError, IOError) as e:
+ msg = ('Could not retrieve configuration file %r from %s, '
+ 'error: %s' % (source_file, self.host, e))
+ logging.error(msg)
+ raise exceptions.GetConfigError(msg)
+ response = tempfile_ptr.read()
+
+ return response
+
+ def _JunosLoad(self, operation, filename, canary=False,
+ skip_show_compare=False, skip_commit_check=False,
+ rollback_patch=None):
+ """Loads the configuration to the remote device using a given
operation.
+
+ Args:
+ operation: A string, the load operation
(e.g., 'replace', 'override').
+ filename: A string, the remote temporary filename to stage
configuration.
+ canary: A boolean, if True, only canary check the configuration,
don't
+ apply it.
+ skip_show_compare: A boolean, if True, "show | compare" will be
skipped.
+ This is a temporary flag due to a JunOS bug and may be removed in
the
+ future.
+ skip_commit_check: A boolean, if True, "commit check" (running the
commit
+ scripts) will be skipped in canary mode.
+ rollback_patch: None or a string, optional filename into which to
+ record and return a patch to rollback the config change.
+
+ Returns:
+ A base_device.SetConfigResult, all responses from the router during
the
+ check/load operation, plus any optional extras.
+ """
+
+ show_compare = 'show | compare; '
+ if skip_show_compare:
+ show_compare = ''
+ if canary:
+ commit_check = 'commit check; '
+ if skip_commit_check:
+ commit_check = ''
+ cmd = ('edit exclusive; load %s %s; %s%srollback 0; exit' %
+ (operation, filename, show_compare, commit_check))
+ else:
+ save_rollback_patch = ''
+ if rollback_patch:
+ save_rollback_patch = ('rollback 1; show | compare | save %s;
rollback;'
+ % rollback_patch)
+ cmd = ('edit exclusive; load %s %s; %s'
+ 'commit comment "push: load %s %s";%s exit' %
+ (operation, filename, show_compare, operation, filename,
+ save_rollback_patch))
+ result = base_device.SetConfigResult()
+ result.transcript = self._Cmd(cmd)
+ self._RaiseExceptionIfLoadError(
+ result.transcript,
+ expect_config_check=canary and not skip_commit_check,
+ expect_commit=not canary)
+ return result
+
+ def _SetConfig(self, destination_file, data, canary,
skip_show_compare=False,
+ skip_commit_check=False, get_rollback_patch=False):
+ copied = False
+
+ file_ptr = tempfile.NamedTemporaryFile()
+ rollback_patch_ptr = tempfile.NamedTemporaryFile()
+ rollback_patch = None
+ # Setting the file name based upon if we are trying to copy a file or
+ # we are trying to copy a config into the control plane.
+ if destination_file in self.NON_FILE_DESTINATIONS:
+ file_name = os.path.basename(file_ptr.name)
+ if get_rollback_patch:
+ rollback_patch = os.path.basename(rollback_patch_ptr.name)
+ else:
+ file_name = destination_file
+ logging.info('Remote file path: %s', file_name)
+
+ try:
+ file_ptr.write(data)
+ file_ptr.flush()
+ except IOError:
+ raise exceptions.SetConfigError('Could not open temporary file %r' %
+ file_ptr.name)
+ result = base_device.SetConfigResult()
+ try:
+ # Copy the file to the remote device.
+ try:
+ self._SendFileViaSftp(local_filename=file_ptr.name,
+ remote_filename=file_name)
+ copied = True
+ except (paramiko.SFTPError, IOError) as e:
+ # _SendFileViaSftp puts the normalized destination path in
e.args[1].
+ msg = 'SFTP failed (filename %r to device %s(%s):%s): %s: %s' % (
+ file_ptr.name, self.host, self.loopback_ipv4, e.args[1],
+ e.__class__.__name__, e.args[0])
+ raise exceptions.SetConfigError(msg)
+
+ if not self._ChecksumsMatch(local_file_name=file_ptr.name,
+ remote_file_name=file_name):
+ raise exceptions.SetConfigError(
+ 'Local and remote file checksum mismatch.')
+
+ if self.CONFIG_RUNNING == destination_file:
+ operation = 'replace'
+ elif self.CONFIG_STARTUP == destination_file:
+ operation = 'override'
+ elif self.CONFIG_PATCH == destination_file:
+ operation = 'patch'
+ else:
+ result.transcript = 'SetConfig uploaded the file successfully.'
+ print "### hi there"
+ return result
+ if canary:
+ logging.debug('Canary syntax checking configuration file %r.',
+ file_name)
+ result = self._JunosLoad(operation, file_name, canary=True,
+ skip_show_compare=skip_show_compare,
+ skip_commit_check=skip_commit_check)
+ else:
+ logging.debug('Setting destination %r with configuration file %r.',
+ destination_file, file_name)
+ print "### LOADING CONFIGURATION"
+ result = self._JunosLoad(operation, file_name,
+ skip_show_compare=skip_show_compare,
+ skip_commit_check=skip_commit_check,
+ rollback_patch=rollback_patch)
+ print "### ", result
+
+ if rollback_patch:
+ try:
+ self._GetFileViaSftp(local_filename=rollback_patch_ptr.name,
+ remote_filename=rollback_patch)
+ result.rollback_patch = rollback_patch_ptr.read()
+ except (paramiko.SFTPError, IOError) as e:
+ # _GetFileViaSftp puts the normalized source path in e.args[1].
+ result.transcript += (
+ 'SFTP rollback patch retrieval failed '
+ '(filename %r from device %s(%s):%s): %s: %s' % (
+ rollback_patch_ptr.name, self.host, self.loopback_ipv4,
+ e.args[1], e.__class__.__name__, e.args[0]))
+
+ # Return the diagnostic results as the (optional) result.
+ return result
+
+ finally:
+ local_delete_exception = None
+ # Unlink the original temporary file.
+ try:
+ logging.info('Deleting the file on the local machine: %s',
+ file_ptr.name)
+ file_ptr.close()
+ except IOError:
+ local_delete_exception = exceptions.SetConfigError(
+ 'Could not close temporary file.')
+
+ local_rollback_patch_delete_exception = None
+ # Unlink the rollback patch temporary file.
+ try:
+ logging.info('Deleting the file on the local machine: %s',
+ rollback_patch_ptr.name)
+ rollback_patch_ptr.close()
+ except IOError:
+ local_rollback_patch_delete_exception = exceptions.SetConfigError(
+ 'Could not close temporary rollback patch file.')
+
+ # If we copied the file to the router and we were pushing a
configuration,
+ # delete the temporary file off the router.
+ if copied and destination_file in self.NON_FILE_DESTINATIONS:
+ logging.info('Deleting file on the router: %s', file_name)
+ self.Cmd('file delete ' + file_name)
+
+ # Delete any rollback patch file too.
+ if rollback_patch:
+ logging.info('Deleting patch on the router: %s', rollback_patch)
+ self.Cmd('file delete ' + rollback_patch)
+
+ # If we got an exception on the local file delete, but did not get a
+ # (more important) exception on the remote delete, raise the local
delete
+ # exception.
+ #
+ # pylint is confused by the re-raising <http://b/5683453>
+ # pylint: disable=raising-bad-type
+ if local_delete_exception is not None:
+ raise local_delete_exception
+ if local_rollback_patch_delete_exception is not None:
+ raise local_rollback_patch_delete_exception
+
+ def _GetFileViaSftp(self, local_filename, remote_filename):
+ """Gets the file named remote_filename from the remote device via SFTP.
+
+ Args:
+ local_filename: A string, the filename (must exist).
+ remote_filename: A string, the path to the remote file location and
+ filename.
+
+ Raises:
+ paramiko.SFTPError: An error occurred during the SFTP.
+ IOError: There was an IOError accessing the named file.
+ """
+ sftp = self._ssh_client.open_sftp()
+ try:
+ sftp.get(remote_filename, local_filename)
+ except (paramiko.SFTPError, IOError) as e:
+ try:
+ remote_filename = sftp.normalize(remote_filename)
+ except (paramiko.SFTPError, IOError):
+ pass
+ raise e.__class__(e.args[0], remote_filename)
+ finally:
+ sftp.close() # Request close from peer.
+
+ def _SendFileViaSftp(self, local_filename, remote_filename):
+ """Sends the file named filename to the remote device via SFTP.
+
+ Args:
+ local_filename: A string, the filename (must exist).
+ remote_filename: A string, the path to the remote file location and
+ filename.
+
+ Returns:
+ A tuple like stat() returns, the remote file's stat result.
+
+ Raises:
+ paramiko.SFTPError: An error occurred during the SFTP.
+ IOError: There was an IOError accessing the named file.
+ """
+ sftp = self._ssh_client.open_sftp()
+ try:
+ sftp.put(local_filename, remote_filename)
+ except (paramiko.SFTPError, IOError) as e:
+ try:
+ remote_filename = sftp.normalize(remote_filename)
+ except (paramiko.SFTPError, IOError):
+ pass
+ raise e.__class__(e.args[0], remote_filename)
+ finally:
+ sftp.close() # Request close from peer.
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/junos_test.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,243 @@
+#!/usr/bin/python
+
+"""Tests for junos devices."""
+
+import tempfile
+import textwrap
+import mox
+
+import unittest
+import junos
+import paramiko_device
+import push_exceptions as exceptions
+
+
+class JunosTest(unittest.TestCase):
+
+ def setUp(self):
+ self._mox = mox.Mox()
+ self.device = junos.JunosDevice(host='pr01.dub01')
+
+ def tearDown(self):
+ self._mox.UnsetStubs()
+ self._mox.ResetAll()
+ self._mox.UnsetStubs()
+
+ def testGetConfigSuccessfulConfigTransfer(self):
+ self._mox.StubOutWithMock(paramiko_device.ParamikoDevice, '_Cmd')
+ paramiko_device.ParamikoDevice._Cmd(
+ 'show configuration', mode=None, merge_stderr_first=False,
+ require_low_chanid=True).AndReturn(
+ 'Some configuration\n response.')
+ self._mox.ReplayAll()
+ response = self.device._GetConfig('running-config')
+ self._mox.VerifyAll()
+ self.assertEquals('Some configuration\n response.', response)
+
+ def testGetConfigFailedConfigTransfer(self):
+ self._mox.StubOutWithMock(paramiko_device.ParamikoDevice, '_Cmd')
+ paramiko_device.ParamikoDevice._Cmd(
+ 'show configuration', mode=None, merge_stderr_first=False,
+ require_low_chanid=True).AndRaise(exceptions.CmdError)
+ self._mox.ReplayAll()
+ self.assertRaises(exceptions.GetConfigError, self.device._GetConfig,
+ 'running-config')
+ self._mox.VerifyAll()
+
+ def testGetConfigEmptyConfigTransfer(self):
+ self._mox.StubOutWithMock(paramiko_device.ParamikoDevice, '_Cmd')
+ paramiko_device.ParamikoDevice._Cmd(
+ 'show configuration', mode=None, merge_stderr_first=False,
+ require_low_chanid=True).AndReturn('')
+ self._mox.ReplayAll()
+ self.assertRaises(exceptions.EmptyConfigError, self.device._GetConfig,
+ 'running-config')
+ self._mox.VerifyAll()
+
+ def testGetConfigSuccessfulFileTransfer(self):
+ tempfile_ptr = tempfile.NamedTemporaryFile()
+ tempfile_ptr.write('Fake file content.')
+ tempfile_ptr.seek(0)
+ self._mox.StubOutWithMock(tempfile, 'NamedTemporaryFile')
+ self._mox.StubOutWithMock(junos.JunosDevice, '_GetFileViaSftp')
+
+ tempfile.NamedTemporaryFile().AndReturn(tempfile_ptr)
+ self.device._GetFileViaSftp(local_filename=tempfile_ptr.name,
+ remote_filename='/var/tmp/testfile')
+ self._mox.ReplayAll()
+ response = self.device._GetConfig('/var/tmp/testfile')
+ self._mox.VerifyAll()
+ self.assertEquals('Fake file content.', response)
+
+ def testGetConfigFailedFileTransfer(self):
+ tempfile_ptr = tempfile.NamedTemporaryFile()
+ self._mox.StubOutWithMock(tempfile, 'NamedTemporaryFile')
+ self._mox.StubOutWithMock(junos.JunosDevice, '_GetFileViaSftp')
+
+ tempfile.NamedTemporaryFile().AndReturn(tempfile_ptr)
+ self.device._GetFileViaSftp(
+ local_filename=tempfile_ptr.name,
+ remote_filename='/var/tmp/testfile').AndRaise(IOError)
+ self._mox.ReplayAll()
+ self.assertRaises(exceptions.GetConfigError, self.device._GetConfig,
+ '/var/tmp/testfile')
+ self._mox.VerifyAll()
+
+ def testCleanupErrorLine(self):
+ self.assertEquals('', self.device._CleanupErrorLine(''))
+ self.assertEquals('a', self.device._CleanupErrorLine('a'))
+ self.assertEquals('invalid value ',
+ self.device._CleanupErrorLine(
+ 'invalid value \'257\' in ip address:
\'257.0.0.0'))
+ self.assertEquals('description ',
+ self.device._CleanupErrorLine('description "foo";'))
+ self.assertEquals(
+ '', self.device._CleanupErrorLine('+ description "error: foo";'))
+ self.assertEquals(
+ '', self.device._CleanupErrorLine('- description "1 errors";'))
+ self.assertEquals(
+ '', self.device._CleanupErrorLine('! description "error foo";'))
+ self.assertEquals('foo -1', self.device._CleanupErrorLine('foo -1'))
+
+ def testLoadErrors(self):
+ # Make an alias for the function under test,
_RaiseExceptionIfLoadError,
+ # because writing "self.device._RaiseExceptionIfLoadError" is verbose.
+ test_function = self.device._RaiseExceptionIfLoadError
+
+ # Check some non-throwing cases.
+ self.assertTrue(test_function('') is None)
+ self.assertTrue(test_function('', expect_config_check=True) is None)
+ self.assertTrue(
+ test_function('+ description "error: syntax error";',
+ expect_config_check=True)
+ is None)
+ self.assertTrue(
+ test_function('! description "error: syntax error";',
+ expect_config_check=True)
+ is None)
+ self.assertTrue(test_function('[edit ... ]') is None)
+ self.assertTrue(test_function('[edit ... ]\n error: foo') is None)
+ self.assertTrue(test_function('[edit ... ]\n+ error: foo') is None)
+ missing_re_output = textwrap.dedent("""\
+ Entering configuration mode
+ load complete
+
+ error: Could not connect to re1 : No route to host
+ warning: Cannot connect to other RE, ignoring it
+ commit complete
+ Exiting configuration mode
+ """)
+ self.assertTrue(test_function(missing_re_output, expect_commit=True)
+ is None)
+
+ # This is a successful commit.
+ warning_output = textwrap.dedent("""\
+ [edit]
+ Entering configuration mode
+ 'interfaces'
+ warning: statement has no contents; ignored
+
+ load complete
+ commit complete
+ Exiting configuration mode
+ """)
+ self.assertIsNone(
+ test_function(warning_output, expect_config_check=False,
+ expect_commit=True))
+ # Also a successful commit from a switch-type device - b/10202762.
+ output = textwrap.dedent("""\
+ Entering configuration mode
+ |load complete
+ configuration check succeedscommit complete
+ Exiting configuration mode
+ """)
+ self.assertIsNone(
+ test_function(output, expect_config_check=True,
expect_commit=True))
+
+ # Check throwing cases.
+ self.assertRaises(
+ exceptions.SetConfigSyntaxError,
+ test_function, 'foo\n syntax error: ')
+ self.assertRaises(
+ exceptions.SetConfigError,
+ test_function, ' load failed (1 errors)')
+ self.assertRaises(
+ exceptions.SetConfigError,
+ test_function, ' load complete (1 errors)')
+ self.assertRaises(
+ exceptions.SetConfigError,
+ test_function, '[edit ...]\n syntax error\nerror: foo')
+ self.assertRaises(
+ exceptions.SetConfigError,
+ test_function, 'error: configuration check-out failed')
+ self.assertRaises(
+ exceptions.SetConfigSyntaxError,
+ test_function, 'syntax error: "connect to re1 :"')
+ self.assertRaises(
+ exceptions.SetConfigSyntaxError,
+ test_function, 'syntax error: connect to re1 :')
+ # Check all JUNOS_LOAD_ERRORS strings
+ for error in self.device.JUNOS_LOAD_ERRORS:
+ self.assertRaises(exceptions.SetConfigError,
+ test_function, error)
+ self.assertRaises(exceptions.SetConfigError,
+ test_function, error, expect_commit=True)
+ # Check the commit_check parameter.
+ self.assertRaises(
+ exceptions.SetConfigSyntaxError,
+ test_function, ' load failed (1 errors)',
expect_config_check=True)
+ self.assertRaises(
+ exceptions.SetConfigSyntaxError,
+ test_function, 'error:\nsyntax error', expect_config_check=True)
+ self.assertRaises(
+ exceptions.SetConfigError,
+ test_function, 'configuration check succeeds\n(1 errors)',
+ expect_config_check=True)
+ self.assertRaises(
+ exceptions.SetConfigSyntaxError,
+ test_function, '\'configuration check succeeds\'\nerror:',
+ expect_config_check=True)
+ # Check that we don't raise a syntax error just because someone wrote
+ # "syntax error" in a description.
+ self.assertRaises(
+ exceptions.SetConfigError,
+ test_function, '+description "syntax error";\nerror:')
+ # This is nearly-real output from b/7176238, including a message about
a
+ # missing RE.
+ syntax_error_with_missing_re = textwrap.dedent("""
+ Entering configuration mode
+ Users currently editing the configuration:
+ netops terminal p3 (pid 44448) on since 2012-09-05 10:00:49
PDT, ...
+ [edit]
+ netops terminal p4 (pid 63408) on since 2012-09-16 23:57:00
PDT, ...
+ private [edit]
+ |\x08tmpPjACa3:1:(10) syntax error: deactivate
+ load complete (1 errors)
+
+ error: Could not connect to re1 : No route to host
+ warning: Cannot connect to other RE, ignoring it
+ commit complete
+ Exiting configuration mode
+ """)
+ self.assertRaises(exceptions.SetConfigSyntaxError,
+ test_function, syntax_error_with_missing_re,
+ expect_config_check=False,
+ expect_commit=False)
+ # Also do the test with commit_check=True
+ self.assertRaises(exceptions.SetConfigSyntaxError,
+ test_function, syntax_error_with_missing_re,
+ expect_config_check=False, expect_commit=True)
+ failed_commit_b_9750034 = textwrap.dedent("""\
+ re0:
+ error: Could not connect to re1 : No route to host
+
+ [edit]
+ """)
+ self.assertRaises(
+ exceptions.SetConfigError,
+ test_function, failed_commit_b_9750034, expect_config_check=False,
+ expect_commit=True)
+
+
+if __name__ == '__main__':
+ unittest.main()
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/paramiko_device.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,183 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""An abstract Paramiko SSH2 capable device model.
+
+For devices that can use Paramiko SSH2, this device defines the connection
+setup and teardown mechanisms. When sub-classing, you must define all API
+methods you wish to implement. Others will return an NotImplemented
Exception.
+"""
+
+import time
+import gflags
+import logging
+
+import paramiko
+
+import sshclient
+import base_device
+import push_exceptions as exceptions
+
+
+FLAGS = gflags.FLAGS
+
+# Remote channel ids greater than this trigger a reconnect. The higher this
+# number, the more channels can be 'in flight' in a single session.
+_LOW_CHANID_THRESHOLD = 1
+
+
+class ParamikoDevice(base_device.BaseDevice):
+ """A device model suitable for devices which support paramiko SSHv2.
+
+ See the base_device.BaseDevice docstrings.
+ """
+
+ def __init__(self, **kwargs):
+ super(ParamikoDevice, self).__init__(**kwargs)
+ # Setup local state.
+ self._ssh_client = None
+ self._port = kwargs.get('port', 22)
+ self._username = None
+ self._password = None
+
+ def _Connect(self, username, password=None, ssh_keys=None,
+ enable_password=None, ssl_cert_set=None):
+ _ = ssl_cert_set
+ logging.debug('In Paramiko._Connect, host is %s, self._connected? %s'
+ '_ssh_client is None? %s', self.host, self._connected,
+ self._ssh_client is None)
+ self._username = username
+ self._password = password or self._password
+ self._ssh_keys = ssh_keys or self._ssh_keys or ()
+ self._enable_password = enable_password or self._enable_password
+
+ self._ssh_client = sshclient.Connect(hostname=self.loopback_ipv4,
+ username=self._username,
+ password=self._password,
+ port=self._port,
+ ssh_keys=self._ssh_keys)
+ return None
+
+ def _GetConnected(self):
+ """Sanity-checks the connected status prior to returning it.
+
+ Returns:
+ A bool, the connected status.
+ """
+ logging.debug('In ParamikoDevice._GetConnected, host is %s (?)',
self.host)
+ if self._connected:
+ if (self._ssh_client is None or
+ self._ssh_client.get_transport() is None or
+ not self._ssh_client.get_transport().is_active()):
+ self._connected = False
+ return self._connected
+
+ def _Disconnect(self):
+ logging.debug('In ParamikoDevice._Disconnect, host is %s, '
+ 'connected is %s, self._ssh_client is None? %s',
+ self.host, self._connected, self._ssh_client is None)
+ if self.connected and self._ssh_client is not None:
+ self._ssh_client.close()
+ self._ssh_client = None
+ return None
+
+ def _Cmd(self, command, mode=None, merge_stderr_first=False, send=None,
+ require_low_chanid=False):
+ response = ''
+ retries_left = 1
+ while True:
+ try:
+ chan = self._ssh_client.get_transport().open_session()
+ chan.settimeout(self.timeout_response)
+ if require_low_chanid and chan.remote_chanid >
_LOW_CHANID_THRESHOLD:
+ # We should not be having multiple channels open. If we do,
+ # close them before proceeding.
+ logging.error(
+ 'Remote ssh channel id %d exceeded %d when opening session
to '
+ '%s(%s), reconnecting.',
+ chan.remote_chanid, _LOW_CHANID_THRESHOLD, self.host,
+ self.loopback_ipv4)
+ self.Disconnect()
+ self.Connect(self._username, self._password, self._ssh_keys,
+ self._enable_password)
+ chan = self._ssh_client.get_transport().open_session()
+ chan.exec_command(command)
+ stdin = chan.makefile('wb', -1)
+ stdout = chan.makefile('rb', -1)
+ stderr = chan.makefile_stderr('rb', -1)
+ if send is not None:
+ stdin.write(send)
+ stdout_data = stdout.read()
+ stderr_data = stderr.read()
+
+ # Request channel close by remote peer.
+ chan.close()
+ break
+ except paramiko.SSHException as e:
+ msg = str(e)
+ logging.error('%s(%s) Cmd(%r, mode=%r): %s', self.host,
+ self.loopback_ipv4, command, mode, msg)
+ raise exceptions.CmdError(msg)
+ except AttributeError:
+ # This occurs when self._ssh_client becomes None after a Paramiko
+ # failure. Pause momentarily, try to reconnect and loop to resend
+ # the command.
+ time.sleep(0.25)
+ try:
+ if retries_left:
+ self._Connect(self._username, self._password, self._ssh_keys)
+ retries_left -= 1
+ continue
+ else:
+ raise exceptions.CmdError('Failed to exec_command after
retry.')
+ except paramiko.SSHException as e:
+ msg = str(e)
+ logging.error('%s(%s) Cmd(%r, mode=%r): %s', self.host,
+ self.loopback_ipv4, command, mode, msg)
+ raise exceptions.ConnectError(msg)
+ except Exception as e:
+ # Paramiko may raise any exception, so catch and log it here.
+ msg = '%s:%s(%s) Cmd(%r, mode=%r): %s: %s' % (
+ type(e), self.host, self.loopback_ipv4, command, mode,
+ e.__class__.__name__, str(e))
+ logging.exception(msg)
+ raise exceptions.CmdError('%s: %s' % (e.__class__.__name__,
str(e)))
+
+ # Remove stderr lines started with 'waiting for'.
+ if stderr_data and not merge_stderr_first:
+ out = []
+ for l in stderr_data.splitlines():
+ if not l.startswith('waiting for'):
+ out.append(l)
+ stderr_data = '\n'.join(out)
+
+ # Marshal the response from the stdout/err channels and handle errors.
+ if stderr_data and not merge_stderr_first:
+ raise exceptions.CmdError(stderr_data)
+ elif stdout_data:
+ if merge_stderr_first and stderr_data:
+ response = stderr_data
+ response += stdout_data
+ else:
+ # Sometimes, a command (e.g., 'show system license keys') returns
+ # nothing. This can mean that the channel went away on us, and we
+ # got no data back (and no error).
+ if self.connected:
+ logging.warn('Both STDOUT and STDERR empty after %s on %s(%s)',
+ repr(command), self.host, self.loopback_ipv4)
+ else:
+ raise exceptions.CmdError('Connection to %s(%s) was terminated.' %
+ (self.host, self.loopback_ipv4))
+ return response
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/paramiko_device_test.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,106 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for paramiko_device."""
+
+import cStringIO as StringIO
+import push_exceptions as exceptions
+import gflags
+import mox
+import paramiko_device
+import time
+import unittest
+
+FLAGS = gflags.FLAGS
+
+
+def FakeSshLibrary(stderr='', expected_command=''):
+ """Creates a simple fake SSH connection."""
+ # pylint:disable=g-bad-name
+
+ class FakeSshClient(object):
+
+ def __init__(self, *unused_args, **unused_kwargs):
+ self._channels = {}
+
+ def close(self):
+ self._closed = True
+
+ def get_transport(self):
+ return self
+
+ def open_session(self):
+ return self
+
+ def settimeout(self, unused_timeout):
+ pass
+
+ def exec_command(self, command):
+ assert command == expected_command, (
+ 'exec_command(%r) expected, got exec_command(%r)' % (
+ expected_command, command))
+
+ def makefile(self, unused_mode, unused_arg):
+ return StringIO.StringIO()
+
+ def makefile_stderr(self, unused_mode, unused_arg):
+ return StringIO.StringIO(stderr)
+
+ return FakeSshClient()
+
+
+class ParamikoDeviceTest(unittest.TestCase):
+
+ def setUp(self):
+ self._mox = mox.Mox()
+ self._mox.StubOutWithMock(time, 'sleep')
+ self.user = 'joe'
+ self.pw = 'pass'
+
+ def tearDown(self):
+ self._mox.UnsetStubs()
+ self._mox.VerifyAll()
+
+ def testCommandSuccess(self):
+ self._mox.StubOutWithMock(paramiko_device.sshclient, 'Connect')
+ device = paramiko_device.ParamikoDevice()
+ device.host = '127.0.0.1'
+ device.loopback_ipv4 = '127.0.0.1'
+ paramiko_device.sshclient.Connect(
+ hostname=device.host, password=self.pw, port=22, ssh_keys=(),
+ username=self.user).AndReturn(
+ FakeSshLibrary(stderr='', expected_command='show version'))
+ self._mox.ReplayAll()
+
+ device.Connect(username=self.user, password=self.pw)
+ device.Cmd('show version')
+
+ def testCommandError(self):
+ self._mox.StubOutWithMock(paramiko_device.sshclient, 'Connect')
+ device = paramiko_device.ParamikoDevice()
+ device.host = '128.0.0.1'
+ device.loopback_ipv4 = '127.0.0.1'
+ paramiko_device.sshclient.Connect(
+ hostname=device.host, password=self.pw, port=22, ssh_keys=(),
+ username=self.user).AndReturn(
+ FakeSshLibrary(stderr='failboat', expected_command='show
version'))
+ self._mox.ReplayAll()
+
+ device.Connect(username=self.user, password=self.pw)
+ self.assertRaises(exceptions.CmdError, device.Cmd, 'show version')
+
+
+if __name__ == '__main__':
+ unittest.main()
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/pexpect_connection.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,611 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Connections via pexpect to SSH and Telnet endpoints.
+
+By deliberate side-effect, this module overwrites pexpect.spawn.__select
+with an implementation based on poll(), to support use with higher file
+descriptors than supported by select().
+"""
+
+import errno
+import os
+import re
+import select
+import socket
+import time
+
+import paramiko
+import pexpect
+
+import gflags
+import logging
+
+import sshclient
+import push_exceptions as exceptions
+
+FLAGS = gflags.FLAGS
+
+TIMEOUT_DEFAULT = 20.0
+
+
+class Error(Exception):
+ pass
+
+
+class ConnectionError(Error):
+ """The connection failed due to an error."""
+
+
+class TimeoutError(Error):
+ """The operation timed-out."""
+
+
+class OperationFailedError(Error):
+ """The sub-process had a non-zero exit status."""
+
+
+class ScpError(Error):
+ """An error occurred during an SCP operation."""
+
+
+def _SelectViaPoll(_, rfds, wfds, efds, timeout):
+ """poll() based replacement for pexpect.spawn.__select().
+
+ As mentioned in the module docstring, this is required since Python's
select
+ is unable to wait for events on high-numbered file descriptors. The API
is
+ as per select.select(), however if we are interrupted by a signal, we
wait
+ again for the remaining time.
+
+ Args:
+ _: An object, self, unused.
+ rfds: A list, file descriptors to check for read.
+ wfds: A list, file descriptors to check for write.
+ efds: A list, file descriptors to check for exceptions.
+ timeout: A float, timeout (seconds).
+
+ Returns:
+ A tuple of three lists, being the descriptors in each of the incoming
lists
+ which are ready for read, write or have an exception, respectively.
+ """
+ if wfds or efds:
+ logging.fatal('Unexpected code change in pexpect: __select '
+ 'called with wfds=%s efds=%s', wfds, efds)
+ p = select.poll()
+ for fd in rfds:
+ p.register(fd, select.POLLIN)
+
+ # See pexpect.spawn.__select for timeout handling logic; this is the same
+ # in select() and poll(), except that the timeout argument to poll() is
+ # in milliseconds. poll() raises the same exception on timeout as
select().
+ if timeout is not None:
+ end_time = time.time() + timeout
+ while True:
+ try:
+ fdstate = p.poll(int(timeout * 1000) if timeout is not None else
None)
+ # Build a list of descriptors which select() would return
as 'available
+ # for read' (which includes EOF conditions which may be indicated as
+ # POLLIN, POLLHUP or POLLIN|POLLHUP, depending on the type of file
+ # descriptor).
+ rrfds = []
+ for fd, state in fdstate:
+ if state & select.POLLIN or state & select.POLLHUP:
+ rrfds.append(fd)
+ return (rrfds, [], [])
+ except select.error as e:
+ if e[0] == errno.EINTR:
+ if timeout is not None:
+ timeout = end_time - time.time()
+ if timeout < 0:
+ return ([], [], [])
+ else:
+ raise
+
+# Override pexpect.spawn.__select as mentioned in module docstring.
+pexpect.spawn._spawn__select = _SelectViaPoll
+
+
+class Connection(object):
+ """The base class for pexpect connections."""
+
+ def __init__(self, host, username, password=None, success=None,
+ connect_command=None, timeout=None, find_prompt=False,
+ enable_password=None, find_prompt_prefix=None):
+ """Initializer.
+
+ Args:
+ host: A string, the hostname or IP address to connect to.
+ username: A string, the username to use on the connection.
+ password: A string, the password to use on the connection.
+ success: A string, the string to expect to trigger successful
completion.
+ connect_command: A string, the command to connect (minus the host
suffix).
+ timeout: A float, the number of seconds before a connection times
out.
+ find_prompt: A bool, if true then success is a regexp and it's
group(1)
+ should be used to build self._prompt.
+ enable_password: A string, the enable password to optionally use.
+ find_prompt_prefix: A string, the prefix to put before group(1) from
the
+ success regexp to build self._prompt, if find_prompt is true.
+ """
+ self._connect_timeout = timeout or TIMEOUT_DEFAULT
+ self._host = host
+ self._username = username
+ self._password = password
+ self._success = success
+ self._find_prompt = find_prompt
+ self._connect_command = connect_command
+ self._enable_password = enable_password
+ self._find_prompt_prefix = (
+ r'(?:^|\n)' if find_prompt_prefix is None else find_prompt_prefix)
+ self.child = None
+
+ def _MaybeFindPrompt(self):
+ if self._find_prompt:
+ try:
+ self._prompt = self._find_prompt_prefix + re.escape(
+ self.child.match.group(1))
+ self.re_prompt = re.compile(self._prompt)
+ logging.debug('%s: prompt set to %r', self._host, self._prompt)
+ except IndexError:
+ logging.debug('%s: find_prompt set but no capture group -
skipping',
+ self._host)
+
+
+class SocketSpawn(pexpect.spawn):
+ """Wrapper around pexpect.spawn to use a supplied socket.
+
+ This class does not close the file; it assumes it is a Python socket
+ which will be held/destroyed by the caller.
+ """
+ # pylint: disable=g-bad-name
+
+ def __init__(self, sock, *args, **kwargs):
+ pexpect.spawn.__init__(self, None, *args, **kwargs)
+ self.child_fd = sock.fileno()
+ self.closed = False
+ self.name = '<file descriptor %d>' % self.child_fd
+
+ def isalive(self):
+ if self.child_fd == -1:
+ return False
+ try:
+ os.fstat(self.child_fd)
+ return True
+ except OSError:
+ return False
+
+ def __del__(self):
+ return
+
+ def close(self):
+ return
+
+ def terminate(self, force=False):
+ _ = force
+ return
+
+ def kill(self, sig):
+ _ = sig
+ return
+
+
+class SocketConnection(Connection):
+ """IPv4 TCP socket connection class."""
+
+ def __init__(self, host, port, username, password=None, success=None,
+ timeout=None, initial_chat=None, find_prompt=False,
+ find_prompt_prefix=None):
+ """Creates an IPv4 TCP socket connection.
+
+ Args:
+ host: As per parent.
+ port: An int, the port number to connect to.
+ username: As per parent.
+ password: As per parent.
+ success: As per parent.
+ timeout: As per parent.
+ initial_chat: A tuple of tuples, each tuple in this list is a string
+ to expect from the socket and a response; the chat must occur in
the
+ exact order specified. Intended only for telnet option
negotiation.
+ find_prompt: As per parent.
+ find_prompt_prefix: As per parent.
+ """
+ super(SocketConnection, self).__init__(
+ host, username=username, password=password, success=success,
+ timeout=timeout, find_prompt=find_prompt,
+ find_prompt_prefix=find_prompt_prefix)
+ self._port = port
+ self._initial_chat = initial_chat
+ self._connect_timeout = timeout or TIMEOUT_DEFAULT
+ if success is None:
+ self._success = self._username+r'.*> '
+
+ def Connect(self):
+ """Makes the connection."""
+ self._sock = socket.socket()
+ self._sock.settimeout(self._connect_timeout)
+ try:
+ self._sock.connect((self._host, self._port))
+ except socket.timeout:
+ raise TimeoutError(self._connect_timeout)
+ except socket.gaierror as e:
+ raise ConnectionError('Lookup failure for %r: %s' % (self._host,
e[1]))
+ except socket.error as e:
+ raise ConnectionError('Connect failure for %r: %s' % (self._host,
e[1]))
+
+ if self._initial_chat is not None:
+ try:
+ for expected_recv, to_send in self._initial_chat:
+ actual_recv = self._sock.recv(len(expected_recv))
+ if actual_recv == expected_recv:
+ self._sock.send(to_send)
+ else:
+ raise ConnectionError('Initial chat failure for %r:
expected %r, '
+ 'got %r' % (self._host, expected_recv,
+ actual_recv))
+ except socket.timeout:
+ logging.debug('Initial chat timeout for %r', self._host)
+ raise TimeoutError(self._connect_timeout)
+
+ self._sock.settimeout(None)
+ self.child = SocketSpawn(self._sock, maxread=8192)
+ self.child.timeout = self._connect_timeout
+ logging.debug('Socket connected to %r:%s', self._host, self._port)
+
+ responses = self.child.compile_pattern_list([
+ self._success,
+ r'[Ll]ogin|[Uu]ser[Nn]ame',
+ r'[Pp]assword:',
+ r'Permission denied|Authentication failed'])
+ self.exit_list = self.child.compile_pattern_list(pexpect.EOF)
+
+ while True:
+ try:
+ timeout = max(1, self._connect_timeout)
+ pattern = self.child.expect_list(responses, timeout=timeout)
+ logging.debug('Connect() matched responses[%d]', pattern)
+ if pattern == 0:
+ self._MaybeFindPrompt()
+ break
+ elif pattern == 1:
+ self.child.send(self._username+'\r')
+ elif pattern == 2:
+ self.child.send(self._password+'\r')
+ elif pattern == 3:
+ raise ConnectionError('Permission denied for %r' % self._host)
+ else:
+ raise ConnectionError('Unexpected pattern %d' % pattern)
+ except pexpect.TIMEOUT:
+ raise TimeoutError(timeout)
+ except pexpect.EOF as e:
+ raise ConnectionError(str(e))
+ return None
+
+
+class SshSpawn(pexpect.spawn):
+ """Wrapper around pexpect.spawn to use a Paramiko channel."""
+ # pylint: disable=g-bad-name
+
+ def __init__(self, channel, *args, **kwargs):
+ pexpect.spawn.__init__(self, None, *args, **kwargs)
+ self.channel = channel
+ self.child_fd = None
+ self.closed = False
+ self.name = '<ssh channel %s>' % channel.get_id()
+
+ def isalive(self):
+ try:
+ return self.channel.get_transport().is_active()
+ except AttributeError:
+ return False
+
+ def read_nonblocking(self, size=1, timeout=None):
+ """See parent. This actually may or may not block based on timeout."""
+ if not self.isalive():
+ raise pexpect.EOF('End Of File (EOF) in read() - Not alive.')
+
+ if timeout == -1:
+ timeout = self.timeout
+
+ self.channel.settimeout(timeout)
+ try:
+ s = self.channel.recv(size)
+ except socket.timeout:
+ raise pexpect.TIMEOUT('Timeout (%s) exceeded in read().' % timeout)
+ except paramiko.SSHException as e:
+ raise pexpect.EOF('Paramiko exception: %s' % e)
+ except EOFError:
+ raise pexpect.EOF('Paramiko reported End Of File (EOF) in read()')
+ if not s:
+ self.flag_eof = 1
+ raise pexpect.EOF('End Of File (EOF) in read().')
+ return s
+
+ def send(self, s):
+ return self.channel.send(s)
+
+ def __del__(self):
+ return
+
+ def close(self):
+ return
+
+ def terminate(self, force=False):
+ _ = force
+ return
+
+ def kill(self, sig):
+ _ = sig
+ return
+
+
+class HpSshSpawn(SshSpawn):
+ """Wrapped pexpect.spawn to use a Paramiko channel and HP ANSI filters.
+
+ This also deals with the annoying pager which cannot be disabled.
+ """
+ # ANSI character sequences to convert to a newline.
+ NEWLINE_RE = re.compile('\x1B(?:\\[0m|E)')
+
+ # All other ANSI character sequences (removed from the output).
+ # Matches all strings containing \x1B, unless they contain a truncated
ANSI
+ # sequence at the end of the string.
+ ANSI_RE = re.compile('\x1B([^[]|\\[[^@-~]*[@-~])')
+
+ def __init__(self, channel, *args, **kwargs):
+ SshSpawn.__init__(self, channel, *args, **kwargs)
+ self._read_nonblocking_buf = ''
+
+ def _Filter(self, text):
+ text = re.sub(self.NEWLINE_RE, '\n', text)
+ text = re.sub(self.ANSI_RE, '', text)
+ logging.vlog(4, 'Filtered: %r', text)
+ return text
+
+ def read_nonblocking(self, size=1, timeout=None):
+ """Read, handling terminal control input from an HP ProCurve.
+
+ This may or may not actually block, as per its parent.
+
+ Args:
+ size: An int, the minimum size block to return.
+ timeout: An optional float, wait only timeout seconds at most.
+
+ Returns:
+ A string, the filtered output.
+ """
+ start = time.time()
+ if timeout == -1:
+ timeout = self.timeout
+ while True:
+ if timeout and time.time() > start + timeout:
+ return ''
+ in_data = SshSpawn.read_nonblocking(self, size=size, timeout=timeout)
+ logging.vlog(4, 'Unfiltered: %r', in_data)
+ if in_data and self._read_nonblocking_buf:
+ logging.debug('Prepending data: %r', self._read_nonblocking_buf)
+ in_data = self._read_nonblocking_buf + in_data
+ self._read_nonblocking_buf = ''
+ filtered = self._Filter(in_data)
+ escape_location = filtered.find('\x1B')
+ if escape_location != -1:
+ logging.debug('Partial ANSI tag in filtered data: %r', filtered)
+ self._read_nonblocking_buf = filtered[escape_location:]
+ filtered = filtered[:escape_location]
+ if filtered:
+ return filtered
+
+
+class ParamikoSshConnection(Connection):
+ """Base class for SSH connections using Paramiko."""
+
+ def __init__(self, host, username, password=None, success=None,
+ timeout=None, find_prompt=False, ssh_keys=None,
+ enable_password=None, ssh_client=None,
find_prompt_prefix=None):
+ """Initializer.
+
+ Args:
+ host: As per parent.
+ username: As per parent.
+ password: As per parent.
+ success: As per parent.
+ timeout: As per parent.
+ find_prompt: As per parent.
+ ssh_keys: A tuple of strings, SSH private keys (optional; may be
None).
+ enable_password: As per parent.
+ ssh_client: A instance of an object that implements an SSH client.
+ find_prompt_prefix: As per parent.
+ """
+ super(ParamikoSshConnection, self).__init__(
+ host, username, password, success, None, timeout, find_prompt,
+ enable_password=enable_password,
find_prompt_prefix=find_prompt_prefix)
+ if success is None:
+ self._success = self._username+r'.*> '
+ self.ssh_client = ssh_client
+ self._ssh_client = None
+ self._ssh_keys = ssh_keys or ()
+ self._spawn = SshSpawn
+ if self._spawn is None:
+ raise NotImplementedError('Must supply a spawn= keywoard argument.')
+
+ def Connect(self):
+ """Makes the connection.
+
+ We can have an instance of this class without being connected to the
+ device, e.g. after a disconnect. Hence setting up the actual SSH
connection
+ should happen in this method, not in the constructor.
+ """
+ try:
+ if self.ssh_client:
+ # An SSH client was provided. Use it.
+ self._ssh_client = self.ssh_client.Connect(
+ hostname=self._host,
+ username=self._username,
+ password=self._password,
+ ssh_keys=self._ssh_keys,
+ timeout=self._connect_timeout)
+ else:
+ # The Connect() function from the sshclient module is a factory
that
+ # returns a paramiko.SSHClient instance.
+ self._ssh_client = sshclient.Connect(
+ hostname=self._host,
+ username=self._username,
+ password=self._password,
+ ssh_keys=self._ssh_keys,
+ timeout=self._connect_timeout)
+ except (exceptions.ConnectError, exceptions.AuthenticationError) as e:
+ raise ConnectionError(str(e))
+ # We are connected. Now set up pexpect.
+ try:
+ ssh_channel = self._ssh_client.invoke_shell()
+ ssh_channel.set_combine_stderr(True)
+ self.child = self._spawn(ssh_channel, maxread=8192)
+ timeout = max(1, self._connect_timeout)
+ pattern = self.child.expect([self._success], timeout=timeout)
+ if pattern == 0:
+ self._MaybeFindPrompt()
+ except pexpect.TIMEOUT:
+ raise TimeoutError(timeout)
+ except pexpect.EOF as e:
+ raise ConnectionError(str(e))
+ except paramiko.SSHException as e:
+ msg = 'SSHException connecting to %r: %s' % (self._host, e)
+ raise ConnectionError(msg)
+
+ # Used by _Disconnect in ftos.py and ios.py.
+ self.exit_list = self.child.compile_pattern_list(pexpect.EOF)
+ return None
+
+
+class HpSshFilterConnection(ParamikoSshConnection):
+ """Creates an SSH connection to an HP Switch with terminal escape
filtering.
+
+ This filters terminal escape sequences seen on the Hewlett-Packard
ProCurve
+ ethernet switches.
+ """
+
+ def __init__(self, host, username, password=None, success=None,
+ timeout=None, find_prompt=False, ssh_keys=None,
+ enable_password=None, ssh_client=None,
find_prompt_prefix=None):
+ super(HpSshFilterConnection, self).__init__(
+ host, username, password, success, timeout, find_prompt,
+ ssh_keys=ssh_keys, enable_password=enable_password,
+ ssh_client=ssh_client, find_prompt_prefix=find_prompt_prefix)
+ self._spawn = HpSshSpawn
+
+ def _MaybeFindPrompt(self):
+ """Perform real login and then enable if we have an enable password."""
+ # We always run this for HP, no matter the state of self._find_prompt.
+ self._prompt = r'(?:^|\n|\r)([A-Za-z0-9\._-]+)(?:>|#) '
+ # Shake out the prompt. We may be facing a Password prompt or
+ # a 'Press any key to continue' prompt.
+ self.child.send('\r')
+
+ # Only send the password once.
+ password_sent = False
+ try:
+ # Login.
+ while True:
+ logging.vlog(3, 'Expecting prompt %r', self._prompt)
+ compiled_regexes = self.child.compile_pattern_list(
+ [self._prompt, r'Press any key to continue',
+ 'Password:', 'Invalid password',
+ 'Unable to verify password'])
+ i = self.child.expect(compiled_regexes, timeout=10)
+ if i == 0:
+ re_str = (re.escape(self.child.match.group(1)) +
+ r'(?:>|#) ')
+ logging.vlog(3, 'Prompt set to %r', re_str)
+ self.re_prompt = re.compile(re_str)
+ break
+ elif i == 1:
+ logging.vlog(3, 'Pressing any key (space)')
+ self.child.send(' ')
+ elif i == 2 and not password_sent:
+ # Send the password only once.
+ try:
+ self.child.sendline(self._password)
+ logging.vlog(3, 'Sent user password (again) to %r', self._host)
+ password_sent = True
+ except (pexpect.TIMEOUT, pexpect.EOF) as e:
+ self._ssh_client = None
+ raise ConnectionError(str(e))
+ elif i <= 3 and i < 5:
+ logging.error('CONNECT_ERROR Incorrect user password on %r',
+ self._host)
+
+ # Sleep momentarily before expecting again to break buffer swap
races.
+ time.sleep(0.05)
+
+ # Enable.
+ password_sent = False
+ logging.vlog(3, 'Enabling for HP on %r', self._host)
+ self.child.sendline('enable')
+ while True:
+ i = self.child.expect([self._prompt, 'Password:',
+ 'Invalid password',
+ 'Unable to verify password'], timeout=10)
+ if i == 0:
+ # Found the prompt, we're enabled.
+ break
+ elif i == 1 and not password_sent:
+ if self._enable_password is not None:
+ self.child.sendline(self._enable_password)
+ logging.vlog(3, 'Sent enable password to %r', self._host)
+ else:
+ self.child.sendline(self._password)
+ logging.vlog(3, 'Sent user password to %r', self._host)
+ password_sent = True
+ elif i <= 3 and i < 5:
+ logging.error('CONNECT_ERROR Incorrect user password on %r',
+ self._host)
+ # Sleep momentarily before expecting again to break buffer swap
races.
+ time.sleep(0.05)
+ except (pexpect.TIMEOUT, pexpect.EOF) as e:
+ self._ssh_client = None
+ raise ConnectionError(str(e))
+
+
+class ScpPutConnection(Connection):
+ """Copies a file via SCP (RCP over SSH)."""
+
+ def __init__(self, host, username, password=None):
+ """Initializer.
+
+ Args:
+ host: As per parent.
+ username: As per parent.
+ password: As per parent.
+ """
+ super(ScpPutConnection, self).__init__(host, username, password)
+ self._ssh_client = sshclient.Connect(hostname=self._host,
+ username=self._username,
+ password=self._password)
+ self.transport = self._ssh_client.get_transport()
+
+ def Copy(self, source_data, destination_file):
+ """Handles the SCP file copy.
+
+ Args:
+ source_data: The source data to copy as a string
+ destination_file: The file on the remote device
+
+ Raises:
+ ScpError: There was an error copying the file.
+ """
+ try:
+ sshclient.ScpPut(self.transport, source_data, destination_file,
+ self._connect_timeout)
+ except sshclient.ScpError as e:
+ raise ScpError('SCP put failed: %s: %s' % (e.__class__.__name__, e))
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/push.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,184 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Distribute bits of configuration to network elements.
+
+Given some device names and configuration files (or a list of configuration
+files with names hinting at the target device) send the configuration to
the
+target devices. These types of pushes can be IO bound, so threading is
+appropriate.
+"""
+
+import getpass
+import gflags
+import logging
+import os
+import progressbar
+import sys
+import socket
+import termcolor
+import threading
+
+# Eval is used for building vendor objects.
+# pylint: disable-msg=W0611
+import ios
+import junos
+import paramiko_device
+# pylint: enable-msg=W0611
+
+
+FLAGS = gflags.FLAGS
+
+gflags.DEFINE_list('targets', '', 'A comma separated list of target
devices.',
+ short_name='T')
+
+gflags.DEFINE_bool('canary', False,
+ 'Do everything possible, save for applying the config.',
+ short_name='c')
+
+gflags.DEFINE_bool('devices_from_filenames', False,
+ 'Use the configuration file names to determine the
target '
+ 'device.', short_name='d')
+
+gflags.DEFINE_string('vendor', '', 'A vendor name. Must be one of the '
+ 'implementations in this directory',
+ short_name='v')
+
+gflags.DEFINE_string('user', '', 'Username for logging into the devices.
This '
+ 'will default to your own username.',
+ short_name='u')
+
+gflags.DEFINE_string('command', '', 'Rather than a config file, you would
like '
+ 'to issue a command and get a response.',
+ short_name='C')
+
+# TODO: Add devices_from_filenames.
+
+class Error(Exception):
+ """Base exception class."""
+
+
+class UsageError(Error):
+ """Incorrect flags usage."""
+
+
+class PushThread(threading.Thread):
+ def __init__(self, target, config, vendor_class, password):
+ """Initiator.
+
+ Args:
+ target: str; Resolvable device name or IP of the target.
+ config: str; Contents of the configuration or command to be sent to
the
+ target.
+ vendor_class: type; Vendor appropriate class to use for this push.
+ password: str; Password to use for devices (username is set in
FLAGS).
+ """
+ threading.Thread.__init__(self)
+ self._target = target
+ self._config = config
+ self._vendor_class = vendor_class
+ self._password = password
+
+
+ def run(self):
+ device = self._vendor_class(host=self._target,
loopback_ipv4=self._target)
+ device.Connect(username=FLAGS.user, password=self._password)
+ if FLAGS.command:
+ print termcolor.cprint(self._target, 'red')
+ print device.Cmd(command=self._config)
+ else:
+ device.SetConfig(destination_file='running-config',
data=self._config,
+ canary=FLAGS.canary)
+ device.Disconnect()
+
+
+def JoinFiles(files):
+ """Take a list of file names, read and join their content.
+
+ Args:
+ files: list; String filenames to open and read.
+ Returns:
+ str; The consolidated content of the provided filenames.
+ """
+ configlet = ''
+ for f in files:
+ # Let IOErrors happen naturally.
+ configlet = configlet + (open(f).read())
+ return configlet
+
+
+def main(argv):
+ """Check flags and start the threaded push."""
+
+ files = FLAGS(argv)[1:]
+
+ # Flags "devices" and "devices_from_filenames" are mutually exclusive.
+ if ((not FLAGS.targets and not FLAGS.devices_from_filenames)
+ or (FLAGS.targets and FLAGS.devices_from_filenames)):
+ raise UsageError(
+ 'No targets defined, try --targets.')
+
+ # User must provide a vendor.
+ elif not FLAGS.vendor:
+ raise UsageError(
+ 'No vendor defined, try the --vendor flag (i.e. --vendor ios)')
+
+ # We need some configuration files unless --command is used.
+ elif not files and not FLAGS.command:
+ raise UsageError(
+ 'No configuration files provided. Provide these via argv / glob.')
+
+ else:
+ # Vendor implementations must be named correctly, i.e. IosDevice.
+ vendor_classname = FLAGS.vendor.capitalize() + 'Device'
+ class_path = '.'.join([FLAGS.vendor.lower(), vendor_classname])
+ try:
+ pusher = eval(class_path)
+ except NameError:
+ raise UsageError(
+ 'The vendor "%s" is not implemented or imported. Please select
a '
+ 'valid vendor' % FLAGS.vendor)
+
+ if not FLAGS.user:
+ FLAGS.user = getpass.getuser()
+
+ if FLAGS.devices_from_filenames:
+ FLAGS.targets = [os.path.basename(x) for x in files]
+ print 'Ready to push per-device configurations to %s' % FLAGS.targets
+ else:
+ print 'Ready to push %s to %s' % (files or FLAGS.command,
FLAGS.targets)
+
+ passw= getpass.getpass('Password:')
+
+ widgets = [
+ 'Pushing... ', progressbar.Percentage(), ' ',
+ progressbar.Bar(marker=progressbar.RotatingMarker()), ' ',
+ progressbar.ETA(), ' ', progressbar.FileTransferSpeed()]
+ pbar = progressbar.ProgressBar(widgets=widgets).start()
+
+ for counter, device in enumerate(FLAGS.targets):
+ if FLAGS.command:
+ thread = PushThread(device, FLAGS.command, pusher, passw)
+ else:
+ consolidated = JoinFiles(files)
+ thread = PushThread(device, consolidated, pusher, passw)
+
+ thread.start()
+ pbar.update((len(FLAGS.targets)/100.0) * counter)
+ pbar.finish()
+
+
+if __name__ == '__main__':
+ main(sys.argv)
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/push_exceptions.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,56 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Exceptions raised by the push librarires."""
+
+
+class Error(Exception):
+ pass
+
+
+class ConnectError(Error):
+ """Indicates a connection could not be established."""
+
+
+class CmdError(Error):
+ """An Error that occurred while executing a Cmd method."""
+
+
+class GetConfigError(Error):
+ """An Error that occurred inside GetConfig."""
+
+
+class EmptyConfigError(GetConfigError):
+ """An empty configuration was produced by the GetConfig command."""
+
+
+class SetConfigError(Error):
+ """An Error that occurred inside SetConfig."""
+
+
+class SetConfigCanaryingError(Error):
+ """The request to canary the configuration failed (probably not
supported)."""
+
+
+class SetConfigSyntaxError(Error):
+ """The device reported a configuration syntax error during SetConfig."""
+
+
+class DisconnectError(Error):
+ """An error occurred during device Disconnect."""
+
+
+class AuthenticationError(Error):
+ """The authentication details for the connection failed to gain
access."""
=======================================
--- /dev/null
+++ /trunk/tools/ldpush/sshclient.py Thu Jun 26 15:45:10 2014 UTC
@@ -0,0 +1,389 @@
+#!/usr/bin/python
+#
+# Copyright 2013 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Commmon helper methods for creating an SSH connection."""
+
+import cStringIO
+import push_exceptions as exceptions
+import gflags
+import logging
+import paramiko
+import socket
+import threading
+
+
+gflags.DEFINE_string('paramiko_ssh_config',
+ '',
+ 'Use this file to pass options using the same format
as '
+ 'OpenSSH.')
+
+FLAGS = gflags.FLAGS
+
+TIMEOUT_DEFAULT = 20.0
+
+
+class Error(Exception):
+ pass
+
+
+class ScpError(Error):
+ """An error occurred while attempting a SCP copy."""
+
+
+class ScpTimeoutError(ScpError):
+ """A device failed to respond to a SCP command within the timeout."""
+
+
+class ScpMinorError(ScpError):
+ """A device reported a SCP minor error."""
+
+
+class ScpMajorError(ScpError):
+ """A device reported a SCP major error."""
+
+
+class ScpProtocolError(ScpError):
+ """An unexpected SCP error occurred."""
+
+
+class ScpChannelError(ScpError):
+ """An error occurred with the SCP channel."""
+
+
+class ScpClosedError(ScpError):
+ """A device closed the SCP connection."""
+
+
+class SshConfigError(ScpError):
+ """The configuration file is either missing or malformed."""
+
+
+class SshOptions(object):
+ """Singleton wrapper class around the SSH configuration.
+
+ This class creates a SSHOption object if the command line flag
+ --paramiko_ssh_config was found and store the result for future
+ use. Since this class is called from several threads, it uses a lock
+ to protect concurrent attempts to load the configuration.
+ """
+ _lock = threading.Lock()
+ _need_init = True
+ _ssh_options = None
+
+ def __init__(self):
+ """Read the configuration if present and store it for later.
+
+ Check if the flag --paramiko_ssh_config was set and parse the
+ configuration file.
+ """
+
+ # This flag may be set by another thread concurrently. We will
+ # check the value again under a lock.
+ if SshOptions._need_init:
+ try:
+ with SshOptions._lock:
+ if SshOptions._need_init and FLAGS.paramiko_ssh_config:
+ logging.debug(
+ 'Reading configuration from %s', FLAGS.paramiko_ssh_config)
+
+ try:
+ configfile = open(FLAGS.paramiko_ssh_config)
+ ssh_config = paramiko.SSHConfig()
+ ssh_config.parse(configfile)
+ SshOptions._ssh_options = ssh_config
+ except Exception as e: # pylint: disable=broad-except
+ # Unfortunately paramiko raises "Exception" if there is an
+ # error in the config file.
+ logging.fatal('Unable to read or parse "%s": %s',
+ FLAGS.paramiko_ssh_config, e)
+ finally:
+ SshOptions._need_init = False
+
+ def Lookup(self, hostname, port, username):
+ """Translate the hostname, port and username using the configuration.
+
+ If the port is not defined, 22 is used. If the username is not
+ defined and no option override it, it will remain undefined.
+
+ Args:
+ hostname: A string, the hostname to use as the key for searching the
+ configuration.
+ port: An integer, the TCP port to used to reach the device. If not
+ defined, the default value (22) will be returned.
+ username: A string, the username to use to connect to the device. It
+ will only be overridden if not defined.
+ Returns:
+ A tuple of (string, int, string) containing the new (hostname, port,
+ username).
+ """
+
+ new_hostname = hostname
+ new_port = port
+ new_username = username
+
+ if SshOptions._ssh_options:
+ # We can't arrive here without first executing __init__, so we
+ # can assume that the _ssh_option is set and we don't need a
+ # lock since we're only doing readonly accesses.
+ host_config = SshOptions._ssh_options.lookup(hostname)
+ if host_config:
+ if 'hostname' in host_config:
+ new_hostname = host_config['hostname']
+
+ if (not new_port or new_port == 22) and 'port' in host_config:
+ try:
+ new_port = int(host_config['port'])
+ except ValueError:
+ raise SshConfigError('Invalid port value %s for %s' %
+ (host_config['port'], hostname))
+
+ if not new_username and 'user' in host_config:
+ new_username = host_config['user']
+
+ logging.debug(
+ 'Translating %s:%s to %s:%s', hostname, port, new_hostname,
+ new_port)
+
+ if not new_port:
+ new_port = 22
+
+ return (new_hostname, new_port, new_username)
+
+
+def Connect(hostname, username, password=None, port=22, ssh_keys=(),
+ timeout=TIMEOUT_DEFAULT):
+ """Makes a paramiko SSH connection to a device.
+
+ Args:
+ hostname: A string, the hostname or IP address to connect to.
+ username: A string, the username to use on the connection.
+ password: A string, the password to use on the connection.
+ port: An int, the port number to connect to.
+ ssh_keys: A tuple of strings, SSH private keys (optional; may be None).
+ timeout: A float, the number of seconds before a connection times out.
+
+ Returns:
+ A paramiko.SSHClient() instance
+ """
+
+ options = SshOptions()
+ hostname, port, username = options.Lookup(hostname, port, username)
+ ssh_client = None
+
+ def RaiseError(e, msg):
+ """Raises an exception, disconnecting the SSH client.
+
+ Args:
+ e: An Exception.
+ msg: An object, exception arguments.
+ """
+ raise e(msg)
+
+ try:
+ ssh_client = paramiko.SSHClient()
+ # Always auto-add remote SSH host keys.
+ ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ ssh_client.load_system_host_keys()
+ # Connect using paramiko with a timeout parameter (requires paramiko
1.7)
+ if ssh_keys:
+ pkeys = []
+ for key in ssh_keys:
+ logging.debug('Using SSH private key for device authentication.')
+ # Use a virtual temporary file to store the key.
+ ssh_key_fileobj = cStringIO.StringIO()
+ ssh_key_fileobj.write(key)
+ ssh_key_fileobj.reset()
+ try:
+ pkeys.append(paramiko.DSSKey(file_obj=ssh_key_fileobj))
+ logging.debug('Using SSH DSA key for %r', hostname)
+ except (IndexError, paramiko.SSHException) as e:
+ if (isinstance(e, IndexError) or
+ 'not a valid DSA private key file' in str(e)):
+ ssh_key_fileobj.reset()
+ try:
+ logging.debug('Using SSH RSA key for %r', hostname)
+ pkeys.append(paramiko.RSAKey(file_obj=ssh_key_fileobj))
+ except (IndexError, paramiko.SSHException) as e:
+ raise exceptions.AuthenticationError(str(e))
+ else:
+ raise exceptions.ConnectError('SSHException: %s' % str(e))
+ else:
+ logging.debug('Using password for %r', hostname)
+ pkeys = [None]
+ for pkey in pkeys:
+ saved_exception = None
+ try:
+ ssh_client.connect(hostname=hostname,
+ port=port,
+ username=username,
+ password=password,
+ pkey=pkey,
+ timeout=timeout,
+ allow_agent=False,
+ look_for_keys=False)
+ break
+ except (paramiko.AuthenticationException, paramiko.SSHException) as
e:
+ saved_exception = e
+ if saved_exception is not None:
+ raise saved_exception # pylint: disable=raising-bad-type
+ transport = ssh_client.get_transport()
+ # Sometimes we have to authenticate a second time, eg. on Force10
+ # we always fail the first authentication (if we try pkey + pass,
+ # the pass succeeds; but if we do pass only, we have to do it
+ # twice). connect() above will have authenticated once.
+ if not transport.is_authenticated():
+ if pkeys != [None]:
+ for pkey in pkeys:
+ try:
+ transport.auth_publickey(username, pkey)
+ break
+ except paramiko.SSHException:
+ pass
+ if not transport.is_authenticated():
+ if password is not None:
+ try:
+ transport.auth_password(username, password)
+ except paramiko.SSHException:
+ pass
+ if not transport.is_authenticated():
+ msg = 'Not authenticated after two attempts on %r' % hostname
+ RaiseError(exceptions.ConnectError, msg)
+ except EOFError:
+ msg = 'EOFError connecting to: %r' % hostname
+ RaiseError(exceptions.ConnectError, msg)
+ except paramiko.AuthenticationException as e:
+ msg = 'Authentication error connecting to %s: %s' % (hostname, str(e))
+ RaiseError(exceptions.AuthenticationError, msg)
+ except paramiko.SSHException as e:
+ msg = 'SSHException connecting to %s: %s' % (hostname, str(e))
+ RaiseError(exceptions.ConnectError, msg)
+ except socket.timeout as e:
+ msg = 'Timed-out while connecting to %s: %s' % (hostname, str(e))
+ RaiseError(exceptions.ConnectError, msg)
+ except socket.error as e:
+ msg = 'Socket error connecting to %r: %s %s' % (hostname, e.__class__,
e)
+ RaiseError(exceptions.ConnectError, msg)
+
+ return ssh_client
+
+
+def _ScpRecvResponse(channel):
+ """Receives a response on a SCP channel.
+
+ Args:
+ channel: A Paramiko channel object.
+
+ Raises:
+ ScpClosedError: If the device has closed the connection.
+ ScpMajorError: If the device reports a major error.
+ ScpMinorError: If the device reports a minor error.
+ ScpProtocolError: If an unexpected error occurs.
+ ScpTimeoutError: If no response is received within the timeout.
+ """
+ buf = channel.recv(1)
+ while True:
+ if channel.recv_stderr_ready():
+ # Dodgy: Cisco sometimes *ask* for a password, but they don't
actually
+ err = channel.recv_stderr(512)
+ if err == 'Password: ':
+ logging.warn('Password prompt received on SCP stderr, assuming '
+ 'IOS bug (ignoring)')
+ else:
+ raise ScpProtocolError('Data on stderr: %r' % err)
+
+ if not buf:
+ raise ScpClosedError('Connection closed by remote device')
+
+ if buf == '\x00':
+ # Code \x00 indicates success. Brocade have been observed sending
+ # \x00\x02 followed by an error message, so we need to only read
+ # the single \x00 and leave the error message to be handled in a
+ # future call to _ScpRecvResponse.
+ return
+
+ try:
+ extra = channel.recv(512)
+ if not extra:
+ raise ScpProtocolError(
+ 'Connection closed by remote device; partial response: %r' %
buf)
+ else:
+ buf += extra
+ except socket.timeout:
+ if buf:
+ raise ScpProtocolError(
+ 'Timed out reading from socket; partial response: %r' % buf)
+ else:
+ raise ScpTimeoutError('Timed out reading from socket')
+
+ if buf[-1] == '\n':
+ if buf[0] == '\x01':
+ if buf.startswith('\x01File ') and buf.rstrip().endswith(
+ 'created successfully.'):
+ return
+ raise ScpMinorError(buf[1:-1])
+ elif buf[0] == '\x02':
+ # Code \x02: Fatal error.
+ raise ScpMajorError(buf[1:-1])
+ else:
+ # Default case: Fatal error.
+ raise ScpMajorError(buf[:-1])
+
+
+def ScpPut(transport, source_data, destination_file, timeout,
send_buffer=8192):
+ """Puts a file via SCP protocol.
+
+ Args:
+ transport: A Paramiko transport object.
+ source_data: The source data to copy as a string.
+ destination_file: The file on the remote device.
+ timeout: The timeout to use for the SCP channel.
+ send_buffer: The number of bytes to send in each operation.
+
+ Raises:
+ ConnectionError: There was an error trying to start the SCP connection.
+ ScpError: There was an error copying the file.
+ """
+ channel = transport.open_session()
+ try:
+ channel.settimeout(timeout)
+ channel.exec_command('scp -t %s' % destination_file)
+
+ # Server must acknowledge our connection.
+ _ScpRecvResponse(channel)
+
+ # Send file attributes, length and a dummy source file basename.
+ source_size = len(source_data)
+ channel.sendall('C0644 %d 1\n' % source_size)
+
+ # Server must acknowledge our request to send.
+ _ScpRecvResponse(channel)
+
+ # Send the data in chunks rather than all at once
+ pos = 0
+ while pos < source_size:
+ channel.sendall(source_data[pos:pos + send_buffer])
+ pos += send_buffer
+
+ # Indicate that we experienced no errors while sending.
+ channel.sendall('\0')
+
+ # Get the final status back from the device. Note: Force10 actually
sends
+ # final status prior to getting the "all OK" from us.
+ _ScpRecvResponse(channel)
+ finally:
+ try:
+ channel.close()
+ except EOFError:
+ raise ScpChannelError('Error closing SCP channel')
Reply all
Reply to author
Forward
0 new messages