Refactor of Remote Instance client

* Moved instance client into Compute namespace
* Moved ping client into Compute namespace
* Added instance client base class
* Simplified functionality provided by instance client
* (unrelated change but necessary to test this code) Added config
  for splitting ephemeral disks

Change-Id: I2ed044436d3c4751afa79e5ba140e160ee67edc1
This commit is contained in:
Daryl Walleck 2013-10-08 00:24:40 -05:00
parent 0de5137a1e
commit 356cf1cde5
10 changed files with 577 additions and 7 deletions

View File

@ -0,0 +1,15 @@
"""
Copyright 2013 Rackspace
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.
"""

View File

@ -0,0 +1,61 @@
"""
Copyright 2013 Rackspace
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.
"""
import platform
import re
import subprocess
from IPy import IP
class PingClient(object):
PING_IPV4_COMMAND_LINUX = 'ping -c 3'
PING_IPV6_COMMAND_LINUX = 'ping6 -c 3'
PING_IPV4_COMMAND_WINDOWS = 'ping'
PING_IPV6_COMMAND_WINDOWS = 'ping -6'
PING_PACKET_LOSS_REGEX = '(\d{1,3})\.?\d*\%.*loss'
@classmethod
def ping(cls, ip):
"""
@summary: Ping a server with a IP
@param ip: IP address to ping
@type ip: string
@return: True if the server was reachable, False otherwise
@rtype: bool
"""
address = IP(ip)
ip_address_version = address.version()
os_type = platform.system().lower()
ping_ipv4 = (cls.PING_IPV4_COMMAND_WINDOWS if os_type == 'windows'
else cls.PING_IPV4_COMMAND_LINUX)
ping_ipv6 = (cls.PING_IPV6_COMMAND_WINDOWS if os_type == 'windows'
else cls.PING_IPV6_COMMAND_LINUX)
ping_command = ping_ipv6 if ip_address_version == 6 else ping_ipv4
command = '{command} {address}'.format(
command=ping_command, address=ip)
process = subprocess.Popen(command, shell=True,
stdout=subprocess.PIPE)
process.wait()
try:
packet_loss_percent = re.search(
cls.PING_PACKET_LOSS_REGEX,
process.stdout.read()).group(1)
except Exception:
# If there is no match, fail
return False
return packet_loss_percent != '100'

View File

@ -0,0 +1,15 @@
"""
Copyright 2013 Rackspace
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.
"""

View File

@ -0,0 +1,59 @@
import abc
from cafe.engine.clients.base import BaseClient
class RemoteInstanceClient(BaseClient):
__metaclass__ = abc.ABCMeta
def get_hostname(self):
"""Returns the machine's hostname."""
raise NotImplementedError
def get_allocated_ram(self):
"""Returns the amount of RAM in megabytes."""
raise NotImplementedError
def get_disk_size(self, disk_path):
"""Returns the size of the disk in gigabytes."""
raise NotImplementedError
def get_number_of_cpus(self):
"""Returns the number of CPUs the remote machine has."""
raise NotImplementedError
def get_uptime(self):
"""Returns the amount of time since the last reboot."""
raise NotImplementedError
def create_directory(self, path):
"""Creates a directory at the given path."""
raise NotImplementedError
def get_all_disks(self):
"""Returns a list of the physical disks available to the server."""
raise NotImplementedError
def mount_disk(self, source_path, destination):
"""Mounts a disk to a given path."""
raise NotImplementedError
def get_directory_details(self, dir_path):
"""Returns data about the given directory."""
raise NotImplementedError
def get_file_details(self, file_path):
"""Returns the permissions and contents of a file."""
raise NotImplementedError
def is_file_present(self, file_path):
"""Verifies that the file at the given path exists."""
raise NotImplementedError
def is_directory_present(self, dir_path):
"""Verifies that the given directory exists."""
raise NotImplementedError
def can_authenticate(self):
"""Verifies a remote connection can be made to the server."""
raise NotImplementedError

View File

@ -0,0 +1,15 @@
"""
Copyright 2013 Rackspace
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.
"""

View File

@ -0,0 +1,15 @@
"""
Copyright 2013 Rackspace
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.
"""

View File

@ -0,0 +1,355 @@
"""
Copyright 2013 Rackspace
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.
"""
import re
import time
from cafe.common.reporting import cclogging
from cafe.engine.clients.remote_instance.models.dir_details \
import DirectoryDetails
from cafe.engine.clients.remote_instance.exceptions \
import DirectoryNotFoundException
from cafe.engine.clients.remote_instance.models.file_details \
import FileDetails
from cafe.engine.clients.ssh import SSHAuthStrategy, SSHBehaviors
from cloudcafe.compute.common.clients.ping import PingClient
from cloudcafe.compute.common.clients.remote_instance.base_client import \
RemoteInstanceClient
from cloudcafe.compute.common.exceptions import FileNotFoundException, \
ServerUnreachable, SshConnectionException
class LinuxClient(RemoteInstanceClient):
def __init__(self, ip_address=None, username='root', password=None,
key=None, connection_timeout=600, retry_interval=10):
self.client_log = cclogging.getLogger(
cclogging.get_object_namespace(self.__class__))
if ip_address is None:
raise ServerUnreachable("None")
self.ip_address = ip_address
self.username = username
self.password = password
# Verify the server can be pinged before attempting to connect
start = int(time.time())
reachable = False
while not reachable:
reachable = PingClient.ping(ip_address)
if reachable:
break
time.sleep(retry_interval)
if int(time.time()) - start >= connection_timeout:
raise ServerUnreachable(ip_address)
if key is not None:
auth_strategy = SSHAuthStrategy.KEY_STRING
else:
auth_strategy = SSHAuthStrategy.PASSWORD
self.ssh_client = SSHBehaviors(
username=self.username, password=self.password,
host=self.ip_address, tcp_timeout=20, auth_strategy=auth_strategy,
look_for_keys=False, key=key)
self.ssh_client.connect_with_timeout(
cooldown=20, timeout=connection_timeout)
if not self.ssh_client.is_connected():
message = ('SSH timeout after {timeout} seconds: '
'Could not connect to {ip_address}.')
raise SshConnectionException(message.format(
timeout=connection_timeout, ip_address=ip_address))
def can_authenticate(self):
"""
Verifies that a connection was made to the remote server
@return: Whether the connection was successful
@rtype: bool
"""
return self.ssh_client.is_connected()
def get_hostname(self):
"""
Gets the host name of the server
@return: The host name of the server
@rtype: string
"""
output = self.ssh_client.execute_command("hostname")
if output:
return output.stdout.rstrip()
def get_allocated_ram(self):
"""
Returns the amount of RAM the server has
@return: The RAM size in MB
@rtype: string
"""
output = self.ssh_client.execute_command('free -m | grep Mem')
if output:
return output.stdout.split()[1]
def get_disk_size(self, disk_path):
"""
Returns the size of a given disk
@return: The disk size in GB
@rtype: int
"""
disks = self.get_all_disks()
return disks.get(disk_path)
def get_number_of_cpus(self):
"""
Return the number of CPUs assigned to the server
@return: The number of CPUs a server has
@rtype: int
"""
command = 'cat /proc/cpuinfo | grep processor | wc -l'
output = self.ssh_client.execute_command(command)
if output:
return int(output.stdout)
def get_uptime(self):
"""
Get the uptime time of the server.
@return: The uptime of the server in seconds
@rtype: int
"""
result = self.ssh_client.execute_command('cat /proc/uptime')
if result:
uptime = float(result.stdout.split(' ')[0])
return uptime
def create_file(self, file_name, file_content, file_path=None):
"""
Creates a new file with the provided content.
@param file_name: File name
@type file_name: string
@param file_content: File content
@type file_content: String
@rtype: FileDetails
"""
if file_path is None:
file_path = "/root/{file_name}".format(file_name=file_name)
self.ssh_client.execute_command(
'echo -n {file_content} >> {file_path}'.format(
file_content=file_content, file_name=file_name))
return FileDetails("644", file_content, file_path)
def get_file_details(self, file_path):
"""
Retrieves the contents of a file and its permissions.
@param file_path: Path to the file
@type file_path: string
@return: File details including permissions and content
@rtype: FileDetails
"""
command = ('[ -f {file_path} ] && echo "File exists" || '
'echo "File does not exist"'.format(file_path=file_path))
output = self.ssh_client.execute_command(command)
if output is None:
return None
output = output.stdout
if not output.rstrip('\n') == 'File exists':
raise FileNotFoundException(
"File {file_path} not found on instance.".format(
file_path=file_path))
file_permissions = self.ssh_client.execute_command(
'stat -c %a {file_path}'.format(
file_path=file_path)).stdout.rstrip("\n")
file_contents = self.ssh_client.execute_command(
'cat {file_path}'.format(file_path=file_path)).stdout
return FileDetails(file_permissions, file_contents, file_path)
def is_file_present(self, file_path):
"""
Verifies if the given file is present.
@param file_path: Path to the file
@type file_path: string
@return: True if File exists, False otherwise
@rtype: bool
"""
command = ('[ -f {file_path} ] && echo "File exists" || '
'echo "File does not exist"'.format(file_path=file_path))
output = self.ssh_client.execute_command(command).stdout
if output:
return output.rstrip('\n') == 'File exists'
def mount_disk(self, source_path, destination_path):
"""
Mounts a disk to specified destination.
@param source_path: Path to file source
@type source_path: string
@param destination_path: Path to mount destination
@type destination_path: string
"""
self.ssh_client.execute_command(
'mount {source_path} {destination_path}'.format(
source_path=source_path, destination_path=destination_path))
def get_xen_user_metadata(self):
"""
Retrieves the user-metadata section from the XenStore.
@return: The contents of the user-metadata
@rtype: dict
"""
command = 'xenstore-ls vm-data/user-metadata'
output = self.ssh_client.execute_command(command)
if not output:
return None
output = output.stdout
meta_list = output.split('\n')
meta = {}
for item in meta_list:
# Skip any blank lines
if item:
meta_item = item.split("=")
key = meta_item[0].strip()
value = meta_item[1].strip('" ')
meta[key] = value
return meta
def get_xenstore_disk_config_value(self):
"""
Returns the XenStore value for disk config.
@return: Whether the virtual machine uses auto disk config
@rtype: bool
"""
command = 'xenstore-read vm-data/auto-disk-config'
output = self.ssh_client.execute_command(command)
if output is None:
return None
output = output.stdout
return output.strip().lower() == 'true'
def create_directory(self, path):
"""
Creates a directory at the specified path.
@param path: Directory path
@type path: string
"""
command = "mkdir -p {path}".format(path=path)
output = self.ssh_client.execute_command(command)
if output is None:
return None
return output.stdout
def is_directory_present(self, directory_path):
"""
Check if given directory exists.
@param directory_path: Path to the directory
@type directory_path: string
@return: Result of directory check
@rtype: bool
"""
command = ("[ -d {path} ] && echo 'Directory found'"
"|| echo 'Directory {path} not found'".format(
path=directory_path))
output = self.ssh_client.execute_command(command)
if output is None:
return None
output = output.stdout
return output.rstrip('\n') == 'Directory found'
def get_directory_details(self, dir_path):
"""
Retrieves informational data about a directory.
@param dir_path: Path to the directory
@type dir_path: string
@return: Directory details
@rtype: DirectoryDetails
"""
output = self.is_directory_present(dir_path)
if output is None:
raise DirectoryNotFoundException(
"Directory: {0} not found.".format(dir_path))
dir_permissions = self.ssh_client.execute_command(
"stat -c %a {0}".format(dir_path)).stdout.rstrip("\n")
dir_size = float(self.ssh_client.execute_command(
"du -s {0}".format(dir_path)).stdout.split('\t', 1)[0])
return DirectoryDetails(dir_permissions, dir_size, dir_path)
def get_all_disks(self):
"""
Returns a list of all block devices for a server.
@return: The accessible block devices
@rtype: dict
"""
disks_raw = self.ssh_client.execute_command('fdisk -l')
if disks_raw is None:
return None
disks_raw = disks_raw.stdout
p = re.compile('Disk /dev/\w+: \d+.*')
disks_list = p.findall(disks_raw)
disks = {}
for disk in disks_list:
items = disk.split()
disk_name = items[1].replace(':', '')
size = int(items[4])/(1 << 30)
disks[disk_name] = size
return disks
def format_disk(self, disk, filesystem_type):
"""
Formats a disk to the provided filesystem type.
@param disk: The path to the disk to be formatted
@type disk: string
@param filesystem_type: The filesystem type to format the disk to
@type filesystem_type: string
@return: Output of command execution
@rtype: string
"""
out = self.ssh_client.execute_command('mkfs -t {type} {disk}'.format(
type=filesystem_type, disk=disk))
if out is None:
return None
return out.stdout

View File

@ -0,0 +1,15 @@
"""
Copyright 2013 Rackspace
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.
"""

View File

@ -17,6 +17,7 @@ limitations under the License.
import time
from cafe.engine.behaviors import BaseBehavior
from cloudcafe.compute.common.clients.remote_instance.linux.linux_client import LinuxClient
from cafe.engine.clients.remote_instance.instance_client import \
InstanceClientFactory
from cloudcafe.compute.common.types import InstanceAuthStrategies
@ -233,13 +234,15 @@ class ServerBehaviors(BaseBehavior):
password = server.admin_pass
# (TODO) dwalleck: Remove hard coding of distro
return InstanceClientFactory.get_instance_client(
ip_address=ip_address, username=username, password=password,
os_distro='linux', config=config)
return LinuxClient(ip_address=ip_address, username='root', password=password)
#return InstanceClientFactory.get_instance_client(
# ip_address=ip_address, username=username, password=password,
# os_distro='linux', config=config)
else:
return InstanceClientFactory.get_instance_client(
ip_address=ip_address, username=username, os_distro='linux',
config=config, key=key)
return LinuxClient(ip_address=ip_address, username='root', key=key)
#return InstanceClientFactory.get_instance_client(
# ip_address=ip_address, username=username, os_distro='linux',
# config=config, key=key)
def resize_and_await(self, server_id, new_flavor):
"""
@ -329,4 +332,4 @@ class ServerBehaviors(BaseBehavior):
assert resp.status_code is 202
resp = self.wait_for_server_status(server_id,
ServerStates.ACTIVE)
return resp.entity
return resp.entity

View File

@ -26,6 +26,23 @@ class ServersConfig(ConfigSectionInterface):
"""Strategy to use for authenticating to an instance (password|key)"""
return self.get("instance_auth_strategy")
@property
def split_ephemeral_disk_enabled(self):
"""
Enable if splitting of ephemeral disks (limiting of the disk
size and splitting into multiple disks if necessary) is enabled.
"""
return self.get_boolean("split_ephemeral_disk_enabled", False)
@property
def ephemeral_disk_max_size(self):
"""
If ephemeral disk splitting is enabled, this is the maximum
size of an ephemeral disk. If this value is less than the
requested ephemeral disk, multiple disks will be created.
"""
return int(self.get("ephemeral_disk_max_size", 0))
@property
def disk_config_override(self):
"""Optional override for the disk_config parameter (all actions)"""