From 5ebd2e97977cc7b2ef2625b72db6bad5de757998 Mon Sep 17 00:00:00 2001 From: Russell Haering Date: Fri, 11 Apr 2014 16:46:36 -0700 Subject: Organize agent extensions Move extensions under an ironic_python_agent.extensions module. This change also moves the @async_command() decorator into the base extension module. Change-Id: I4021fcc33a30f3460a31bca44a4bf776cd53d488 --- ironic_python_agent/agent.py | 2 +- ironic_python_agent/base.py | 194 -------------- ironic_python_agent/decom.py | 20 -- ironic_python_agent/decorators.py | 41 --- ironic_python_agent/extensions/__init__.py | 0 ironic_python_agent/extensions/base.py | 219 +++++++++++++++ ironic_python_agent/extensions/decom.py | 20 ++ ironic_python_agent/extensions/flow.py | 64 +++++ ironic_python_agent/extensions/standby.py | 198 ++++++++++++++ ironic_python_agent/flow.py | 65 ----- ironic_python_agent/standby.py | 199 -------------- ironic_python_agent/tests/agent.py | 2 +- ironic_python_agent/tests/api.py | 2 +- ironic_python_agent/tests/base.py | 92 ------- ironic_python_agent/tests/decom.py | 26 -- ironic_python_agent/tests/extensions/__init__.py | 0 ironic_python_agent/tests/extensions/base.py | 92 +++++++ ironic_python_agent/tests/extensions/decom.py | 26 ++ ironic_python_agent/tests/extensions/flow.py | 113 ++++++++ ironic_python_agent/tests/extensions/standby.py | 327 +++++++++++++++++++++++ ironic_python_agent/tests/flow.py | 114 -------- ironic_python_agent/tests/standby.py | 320 ---------------------- 22 files changed, 1062 insertions(+), 1074 deletions(-) delete mode 100644 ironic_python_agent/base.py delete mode 100644 ironic_python_agent/decom.py delete mode 100644 ironic_python_agent/decorators.py create mode 100644 ironic_python_agent/extensions/__init__.py create mode 100644 ironic_python_agent/extensions/base.py create mode 100644 ironic_python_agent/extensions/decom.py create mode 100644 ironic_python_agent/extensions/flow.py create mode 100644 ironic_python_agent/extensions/standby.py delete mode 100644 ironic_python_agent/flow.py delete mode 100644 ironic_python_agent/standby.py delete mode 100644 ironic_python_agent/tests/base.py delete mode 100644 ironic_python_agent/tests/decom.py create mode 100644 ironic_python_agent/tests/extensions/__init__.py create mode 100644 ironic_python_agent/tests/extensions/base.py create mode 100644 ironic_python_agent/tests/extensions/decom.py create mode 100644 ironic_python_agent/tests/extensions/flow.py create mode 100644 ironic_python_agent/tests/extensions/standby.py delete mode 100644 ironic_python_agent/tests/flow.py delete mode 100644 ironic_python_agent/tests/standby.py (limited to 'ironic_python_agent') diff --git a/ironic_python_agent/agent.py b/ironic_python_agent/agent.py index a611c04e..39c8368c 100644 --- a/ironic_python_agent/agent.py +++ b/ironic_python_agent/agent.py @@ -21,9 +21,9 @@ from stevedore import extension from wsgiref import simple_server from ironic_python_agent.api import app -from ironic_python_agent import base from ironic_python_agent import encoding from ironic_python_agent import errors +from ironic_python_agent.extensions import base from ironic_python_agent import hardware from ironic_python_agent import ironic_api_client from ironic_python_agent.openstack.common import log diff --git a/ironic_python_agent/base.py b/ironic_python_agent/base.py deleted file mode 100644 index 8a795535..00000000 --- a/ironic_python_agent/base.py +++ /dev/null @@ -1,194 +0,0 @@ -# Copyright 2013 Rackspace, 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. - -import threading -import uuid - -import six - -from ironic_python_agent import encoding -from ironic_python_agent import errors -from ironic_python_agent.openstack.common import log -from ironic_python_agent import utils - - -class AgentCommandStatus(object): - RUNNING = u'RUNNING' - SUCCEEDED = u'SUCCEEDED' - FAILED = u'FAILED' - - -class BaseCommandResult(encoding.Serializable): - def __init__(self, command_name, command_params): - self.id = six.text_type(uuid.uuid4()) - self.command_name = command_name - self.command_params = command_params - self.command_status = AgentCommandStatus.RUNNING - self.command_error = None - self.command_result = None - - def serialize(self): - return dict(( - (u'id', self.id), - (u'command_name', self.command_name), - (u'command_params', self.command_params), - (u'command_status', self.command_status), - (u'command_error', self.command_error), - (u'command_result', self.command_result), - )) - - def is_done(self): - return self.command_status != AgentCommandStatus.RUNNING - - def join(self): - return self - - -class SyncCommandResult(BaseCommandResult): - def __init__(self, command_name, command_params, success, result_or_error): - super(SyncCommandResult, self).__init__(command_name, - command_params) - if success: - self.command_status = AgentCommandStatus.SUCCEEDED - self.command_result = result_or_error - else: - self.command_status = AgentCommandStatus.FAILED - self.command_error = result_or_error - - -class AsyncCommandResult(BaseCommandResult): - """A command that executes asynchronously in the background. - - :param execute_method: a callable to be executed asynchronously - """ - def __init__(self, command_name, command_params, execute_method): - super(AsyncCommandResult, self).__init__(command_name, command_params) - self.execute_method = execute_method - self.command_state_lock = threading.Lock() - - thread_name = 'agent-command-{0}'.format(self.id) - self.execution_thread = threading.Thread(target=self.run, - name=thread_name) - - def serialize(self): - with self.command_state_lock: - return super(AsyncCommandResult, self).serialize() - - def start(self): - self.execution_thread.start() - return self - - def join(self, timeout=None): - self.execution_thread.join(timeout) - return self - - def is_done(self): - with self.command_state_lock: - return super(AsyncCommandResult, self).is_done() - - def run(self): - try: - result = self.execute_method(self.command_name, - **self.command_params) - with self.command_state_lock: - self.command_result = result - self.command_status = AgentCommandStatus.SUCCEEDED - - except Exception as e: - if not isinstance(e, errors.RESTError): - e = errors.CommandExecutionError(str(e)) - - with self.command_state_lock: - self.command_error = e - self.command_status = AgentCommandStatus.FAILED - - -class BaseAgentExtension(object): - def __init__(self, name): - super(BaseAgentExtension, self).__init__() - self.log = log.getLogger(__name__) - self.name = name - self.command_map = {} - - def execute(self, command_name, **kwargs): - if command_name not in self.command_map: - raise errors.InvalidCommandError( - 'Unknown command: {0}'.format(command_name)) - - result = self.command_map[command_name](command_name, **kwargs) - - # In order to enable extremely succinct synchronous commands, we allow - # them to return a value directly, and we'll handle wrapping it up in a - # SyncCommandResult - if not isinstance(result, BaseCommandResult): - result = SyncCommandResult(command_name, kwargs, True, result) - - return result - - def check_cmd_presence(self, ext_obj, ext, cmd): - if not (hasattr(ext_obj, 'execute') and hasattr(ext_obj, 'command_map') - and cmd in ext_obj.command_map): - raise errors.InvalidCommandParamsError( - "Extension {0} doesn't provide {1} method".format(ext, cmd)) - - -class ExecuteCommandMixin(object): - def __init__(self): - self.command_lock = threading.Lock() - self.command_results = utils.get_ordereddict() - self.ext_mgr = self.get_extension_manager() - - def get_extension_manager(self): - raise NotImplementedError( - 'get_extension_manager should be implemented in successor class') - - def split_command(self, command_name): - command_parts = command_name.split('.', 1) - if len(command_parts) != 2: - raise errors.InvalidCommandError( - 'Command name must be of the form .') - - return (command_parts[0], command_parts[1]) - - def execute_command(self, command_name, **kwargs): - """Execute an agent command.""" - with self.command_lock: - extension_part, command_part = self.split_command(command_name) - - if len(self.command_results) > 0: - last_command = list(self.command_results.values())[-1] - if not last_command.is_done(): - raise errors.CommandExecutionError('agent is busy') - - try: - ext = self.ext_mgr[extension_part].obj - result = ext.execute(command_part, **kwargs) - except KeyError: - # Extension Not found - raise errors.RequestedObjectNotFoundError('Extension', - extension_part) - except errors.InvalidContentError as e: - # Any command may raise a InvalidContentError which will be - # returned to the caller directly. - raise e - except Exception as e: - # Other errors are considered command execution errors, and are - # recorded as an - result = SyncCommandResult(command_name, - kwargs, - False, - six.text_type(e)) - - self.command_results[result.id] = result - return result diff --git a/ironic_python_agent/decom.py b/ironic_python_agent/decom.py deleted file mode 100644 index 85a138d8..00000000 --- a/ironic_python_agent/decom.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2013 Rackspace, 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. - -from ironic_python_agent import base - - -class DecomExtension(base.BaseAgentExtension): - def __init__(self): - super(DecomExtension, self).__init__('DECOM') diff --git a/ironic_python_agent/decorators.py b/ironic_python_agent/decorators.py deleted file mode 100644 index 30f7cf7c..00000000 --- a/ironic_python_agent/decorators.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright 2013 Rackspace, 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. - -import functools - -from ironic_python_agent import base - - -def async_command(validator=None): - """Will run the command in an AsyncCommandResult in its own thread. - command_name is set based on the func name and command_params will - be whatever args/kwargs you pass into the decorated command. - """ - def async_decorator(func): - @functools.wraps(func) - def wrapper(self, command_name, **command_params): - # Run a validator before passing everything off to async. - # validators should raise exceptions or return silently. - if validator: - validator(self, **command_params) - - # bind self to func so that AsyncCommandResult doesn't need to - # know about the mode - bound_func = functools.partial(func, self) - - return base.AsyncCommandResult(command_name, - command_params, - bound_func).start() - return wrapper - return async_decorator diff --git a/ironic_python_agent/extensions/__init__.py b/ironic_python_agent/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ironic_python_agent/extensions/base.py b/ironic_python_agent/extensions/base.py new file mode 100644 index 00000000..54fde2c6 --- /dev/null +++ b/ironic_python_agent/extensions/base.py @@ -0,0 +1,219 @@ +# Copyright 2013 Rackspace, 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. + +import functools +import threading +import uuid + +import six + +from ironic_python_agent import encoding +from ironic_python_agent import errors +from ironic_python_agent.openstack.common import log +from ironic_python_agent import utils + + +class AgentCommandStatus(object): + RUNNING = u'RUNNING' + SUCCEEDED = u'SUCCEEDED' + FAILED = u'FAILED' + + +class BaseCommandResult(encoding.Serializable): + def __init__(self, command_name, command_params): + self.id = six.text_type(uuid.uuid4()) + self.command_name = command_name + self.command_params = command_params + self.command_status = AgentCommandStatus.RUNNING + self.command_error = None + self.command_result = None + + def serialize(self): + return dict(( + (u'id', self.id), + (u'command_name', self.command_name), + (u'command_params', self.command_params), + (u'command_status', self.command_status), + (u'command_error', self.command_error), + (u'command_result', self.command_result), + )) + + def is_done(self): + return self.command_status != AgentCommandStatus.RUNNING + + def join(self): + return self + + +class SyncCommandResult(BaseCommandResult): + def __init__(self, command_name, command_params, success, result_or_error): + super(SyncCommandResult, self).__init__(command_name, + command_params) + if success: + self.command_status = AgentCommandStatus.SUCCEEDED + self.command_result = result_or_error + else: + self.command_status = AgentCommandStatus.FAILED + self.command_error = result_or_error + + +class AsyncCommandResult(BaseCommandResult): + """A command that executes asynchronously in the background. + + :param execute_method: a callable to be executed asynchronously + """ + def __init__(self, command_name, command_params, execute_method): + super(AsyncCommandResult, self).__init__(command_name, command_params) + self.execute_method = execute_method + self.command_state_lock = threading.Lock() + + thread_name = 'agent-command-{0}'.format(self.id) + self.execution_thread = threading.Thread(target=self.run, + name=thread_name) + + def serialize(self): + with self.command_state_lock: + return super(AsyncCommandResult, self).serialize() + + def start(self): + self.execution_thread.start() + return self + + def join(self, timeout=None): + self.execution_thread.join(timeout) + return self + + def is_done(self): + with self.command_state_lock: + return super(AsyncCommandResult, self).is_done() + + def run(self): + try: + result = self.execute_method(self.command_name, + **self.command_params) + with self.command_state_lock: + self.command_result = result + self.command_status = AgentCommandStatus.SUCCEEDED + + except Exception as e: + if not isinstance(e, errors.RESTError): + e = errors.CommandExecutionError(str(e)) + + with self.command_state_lock: + self.command_error = e + self.command_status = AgentCommandStatus.FAILED + + +class BaseAgentExtension(object): + def __init__(self, name): + super(BaseAgentExtension, self).__init__() + self.log = log.getLogger(__name__) + self.name = name + self.command_map = {} + + def execute(self, command_name, **kwargs): + if command_name not in self.command_map: + raise errors.InvalidCommandError( + 'Unknown command: {0}'.format(command_name)) + + result = self.command_map[command_name](command_name, **kwargs) + + # In order to enable extremely succinct synchronous commands, we allow + # them to return a value directly, and we'll handle wrapping it up in a + # SyncCommandResult + if not isinstance(result, BaseCommandResult): + result = SyncCommandResult(command_name, kwargs, True, result) + + return result + + def check_cmd_presence(self, ext_obj, ext, cmd): + if not (hasattr(ext_obj, 'execute') and hasattr(ext_obj, 'command_map') + and cmd in ext_obj.command_map): + raise errors.InvalidCommandParamsError( + "Extension {0} doesn't provide {1} method".format(ext, cmd)) + + +class ExecuteCommandMixin(object): + def __init__(self): + self.command_lock = threading.Lock() + self.command_results = utils.get_ordereddict() + self.ext_mgr = self.get_extension_manager() + + def get_extension_manager(self): + raise NotImplementedError( + 'get_extension_manager should be implemented in successor class') + + def split_command(self, command_name): + command_parts = command_name.split('.', 1) + if len(command_parts) != 2: + raise errors.InvalidCommandError( + 'Command name must be of the form .') + + return (command_parts[0], command_parts[1]) + + def execute_command(self, command_name, **kwargs): + """Execute an agent command.""" + with self.command_lock: + extension_part, command_part = self.split_command(command_name) + + if len(self.command_results) > 0: + last_command = list(self.command_results.values())[-1] + if not last_command.is_done(): + raise errors.CommandExecutionError('agent is busy') + + try: + ext = self.ext_mgr[extension_part].obj + result = ext.execute(command_part, **kwargs) + except KeyError: + # Extension Not found + raise errors.RequestedObjectNotFoundError('Extension', + extension_part) + except errors.InvalidContentError as e: + # Any command may raise a InvalidContentError which will be + # returned to the caller directly. + raise e + except Exception as e: + # Other errors are considered command execution errors, and are + # recorded as an + result = SyncCommandResult(command_name, + kwargs, + False, + six.text_type(e)) + + self.command_results[result.id] = result + return result + + +def async_command(validator=None): + """Will run the command in an AsyncCommandResult in its own thread. + command_name is set based on the func name and command_params will + be whatever args/kwargs you pass into the decorated command. + """ + def async_decorator(func): + @functools.wraps(func) + def wrapper(self, command_name, **command_params): + # Run a validator before passing everything off to async. + # validators should raise exceptions or return silently. + if validator: + validator(self, **command_params) + + # bind self to func so that AsyncCommandResult doesn't need to + # know about the mode + bound_func = functools.partial(func, self) + + return AsyncCommandResult(command_name, + command_params, + bound_func).start() + return wrapper + return async_decorator diff --git a/ironic_python_agent/extensions/decom.py b/ironic_python_agent/extensions/decom.py new file mode 100644 index 00000000..6cd483fb --- /dev/null +++ b/ironic_python_agent/extensions/decom.py @@ -0,0 +1,20 @@ +# Copyright 2013 Rackspace, 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. + +from ironic_python_agent.extensions import base + + +class DecomExtension(base.BaseAgentExtension): + def __init__(self): + super(DecomExtension, self).__init__('DECOM') diff --git a/ironic_python_agent/extensions/flow.py b/ironic_python_agent/extensions/flow.py new file mode 100644 index 00000000..617eebac --- /dev/null +++ b/ironic_python_agent/extensions/flow.py @@ -0,0 +1,64 @@ +# Copyright 2014 Mirantis, 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. + +from stevedore import enabled + +from ironic_python_agent import errors +from ironic_python_agent.extensions import base +from ironic_python_agent.openstack.common import log + +LOG = log.getLogger(__name__) + + +def _load_extension(ext): + disabled_extension_list = ['flow'] + return ext.name not in disabled_extension_list + + +def _validate_exts(ext, flow=None): + for task in flow: + for method in task: + ext_name, cmd = ext.split_command(method) + if ext_name not in ext.ext_mgr.names(): + raise errors.RequestedObjectNotFoundError('Extension', + ext_name) + ext_obj = ext.ext_mgr[ext_name].obj + ext.check_cmd_presence(ext_obj, ext_name, cmd) + + +class FlowExtension(base.BaseAgentExtension, base.ExecuteCommandMixin): + def __init__(self): + super(FlowExtension, self).__init__('FLOW') + self.command_map['start_flow'] = self.start_flow + + def get_extension_manager(self): + return enabled.EnabledExtensionManager( + 'ironic_python_agent.extensions', + _load_extension, + invoke_on_load=True, + propagate_map_exceptions=True, + ) + + @base.async_command(_validate_exts) + def start_flow(self, command_name, flow=None): + for task in flow: + for method, params in task.items(): + LOG.info("Executing method %s for now" % method) + result = self.execute_command(method, **params) + result.join() + LOG.info("%s method's execution is done" % method) + if result.command_status == base.AgentCommandStatus.FAILED: + raise errors.CommandExecutionError( + "%s was failed" % method + ) diff --git a/ironic_python_agent/extensions/standby.py b/ironic_python_agent/extensions/standby.py new file mode 100644 index 00000000..a73086c8 --- /dev/null +++ b/ironic_python_agent/extensions/standby.py @@ -0,0 +1,198 @@ +# Copyright 2013 Rackspace, 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. + +import hashlib +import os +import requests +import subprocess +import time + +from ironic_python_agent import configdrive +from ironic_python_agent import errors +from ironic_python_agent.extensions import base +from ironic_python_agent import hardware +from ironic_python_agent.openstack.common import log + +LOG = log.getLogger(__name__) + + +def _configdrive_location(): + return '/tmp/configdrive' + + +def _image_location(image_info): + return '/tmp/{0}'.format(image_info['id']) + + +def _path_to_script(script): + cwd = os.path.dirname(os.path.realpath(__file__)) + return os.path.join(cwd, script) + + +def _write_image(image_info, device): + starttime = time.time() + image = _image_location(image_info) + + script = _path_to_script('shell/write_image.sh') + command = ['/bin/bash', script, image, device] + LOG.info('Writing image with command: {0}'.format(' '.join(command))) + exit_code = subprocess.call(command) + if exit_code != 0: + raise errors.ImageWriteError(exit_code, device) + totaltime = time.time() - starttime + LOG.info('Image {0} written to device {1} in {2} seconds'.format( + image, device, totaltime)) + + +def _copy_configdrive_to_disk(configdrive_dir, device): + starttime = time.time() + script = _path_to_script('shell/copy_configdrive_to_disk.sh') + command = ['/bin/bash', script, configdrive_dir, device] + LOG.info('copying configdrive to disk with command {0}'.format( + ' '.join(command))) + exit_code = subprocess.call(command) + + if exit_code != 0: + raise errors.ConfigDriveWriteError(exit_code, device) + + totaltime = time.time() - starttime + LOG.info('configdrive copied from {0} to {1} in {2} seconds'.format( + configdrive_dir, + device, + totaltime)) + + +def _request_url(image_info, url): + resp = requests.get(url, stream=True) + if resp.status_code != 200: + raise errors.ImageDownloadError(image_info['id']) + return resp + + +def _download_image(image_info): + starttime = time.time() + resp = None + for url in image_info['urls']: + try: + LOG.info("Attempting to download image from {0}".format(url)) + resp = _request_url(image_info, url) + except errors.ImageDownloadError: + failtime = time.time() - starttime + log_msg = "Image download failed. URL: {0}; time: {1} seconds" + LOG.warning(log_msg.format(url, failtime)) + continue + else: + break + if resp is None: + raise errors.ImageDownloadError(image_info['id']) + + image_location = _image_location(image_info) + with open(image_location, 'wb') as f: + try: + for chunk in resp.iter_content(1024 * 1024): + f.write(chunk) + except Exception: + raise errors.ImageDownloadError(image_info['id']) + + totaltime = time.time() - starttime + LOG.info("Image downloaded from {0} in {1} seconds".format(image_location, + totaltime)) + + if not _verify_image(image_info, image_location): + raise errors.ImageChecksumError(image_info['id']) + + +def _verify_image(image_info, image_location): + hashes = image_info['hashes'] + for k, v in hashes.items(): + algo = getattr(hashlib, k, None) + if algo is None: + continue + log_msg = 'Verifying image at {0} with algorithm {1} against hash {2}' + LOG.debug(log_msg.format(image_location, k, v)) + hash_ = algo(open(image_location).read()).hexdigest() + if hash_ == v: + return True + else: + log_msg = ('Image verification failed. Location: {0};' + 'algorithm: {1}; image hash: {2};' + 'verification hash: {3}') + LOG.warning(log_msg.format(image_location, k, hash_, v)) + return False + + +def _validate_image_info(ext, image_info=None, **kwargs): + image_info = image_info or {} + + for field in ['id', 'urls', 'hashes']: + if field not in image_info: + msg = 'Image is missing \'{0}\' field.'.format(field) + raise errors.InvalidCommandParamsError(msg) + + if type(image_info['urls']) != list or not image_info['urls']: + raise errors.InvalidCommandParamsError( + 'Image \'urls\' must be a list with at least one element.') + + if type(image_info['hashes']) != dict or not image_info['hashes']: + raise errors.InvalidCommandParamsError( + 'Image \'hashes\' must be a dictionary with at least one ' + 'element.') + + +class StandbyExtension(base.BaseAgentExtension): + def __init__(self): + super(StandbyExtension, self).__init__('STANDBY') + self.command_map['cache_image'] = self.cache_image + self.command_map['prepare_image'] = self.prepare_image + self.command_map['run_image'] = self.run_image + + self.cached_image_id = None + + @base.async_command(_validate_image_info) + def cache_image(self, command_name, image_info=None, force=False): + device = hardware.get_manager().get_os_install_device() + + if self.cached_image_id != image_info['id'] or force: + _download_image(image_info) + _write_image(image_info, device) + self.cached_image_id = image_info['id'] + + @base.async_command(_validate_image_info) + def prepare_image(self, + command_name, + image_info=None, + metadata=None, + files=None): + location = _configdrive_location() + device = hardware.get_manager().get_os_install_device() + + # don't write image again if already cached + if self.cached_image_id != image_info['id']: + _download_image(image_info) + _write_image(image_info, device) + self.cached_image_id = image_info['id'] + + LOG.debug('Writing configdrive to {0}'.format(location)) + configdrive.write_configdrive(location, metadata, files) + _copy_configdrive_to_disk(location, device) + + @base.async_command() + def run_image(self, command_name): + script = _path_to_script('shell/reboot.sh') + LOG.info('Rebooting system') + command = ['/bin/bash', script] + # this should never return if successful + exit_code = subprocess.call(command) + if exit_code != 0: + raise errors.SystemRebootError(exit_code) diff --git a/ironic_python_agent/flow.py b/ironic_python_agent/flow.py deleted file mode 100644 index 9d4d43ba..00000000 --- a/ironic_python_agent/flow.py +++ /dev/null @@ -1,65 +0,0 @@ -# Copyright 2014 Mirantis, 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. - -from stevedore import enabled - -from ironic_python_agent import base -from ironic_python_agent import decorators -from ironic_python_agent import errors -from ironic_python_agent.openstack.common import log - -LOG = log.getLogger(__name__) - - -def _load_extension(ext): - disabled_extension_list = ['flow'] - return ext.name not in disabled_extension_list - - -def _validate_exts(ext, flow=None): - for task in flow: - for method in task: - ext_name, cmd = ext.split_command(method) - if ext_name not in ext.ext_mgr.names(): - raise errors.RequestedObjectNotFoundError('Extension', - ext_name) - ext_obj = ext.ext_mgr[ext_name].obj - ext.check_cmd_presence(ext_obj, ext_name, cmd) - - -class FlowExtension(base.BaseAgentExtension, base.ExecuteCommandMixin): - def __init__(self): - super(FlowExtension, self).__init__('FLOW') - self.command_map['start_flow'] = self.start_flow - - def get_extension_manager(self): - return enabled.EnabledExtensionManager( - 'ironic_python_agent.extensions', - _load_extension, - invoke_on_load=True, - propagate_map_exceptions=True, - ) - - @decorators.async_command(_validate_exts) - def start_flow(self, command_name, flow=None): - for task in flow: - for method, params in task.items(): - LOG.info("Executing method %s for now" % method) - result = self.execute_command(method, **params) - result.join() - LOG.info("%s method's execution is done" % method) - if result.command_status == base.AgentCommandStatus.FAILED: - raise errors.CommandExecutionError( - "%s was failed" % method - ) diff --git a/ironic_python_agent/standby.py b/ironic_python_agent/standby.py deleted file mode 100644 index 133bcf71..00000000 --- a/ironic_python_agent/standby.py +++ /dev/null @@ -1,199 +0,0 @@ -# Copyright 2013 Rackspace, 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. - -import hashlib -import os -import requests -import subprocess -import time - -from ironic_python_agent import base -from ironic_python_agent import configdrive -from ironic_python_agent import decorators -from ironic_python_agent import errors -from ironic_python_agent import hardware -from ironic_python_agent.openstack.common import log - -LOG = log.getLogger(__name__) - - -def _configdrive_location(): - return '/tmp/configdrive' - - -def _image_location(image_info): - return '/tmp/{0}'.format(image_info['id']) - - -def _path_to_script(script): - cwd = os.path.dirname(os.path.realpath(__file__)) - return os.path.join(cwd, script) - - -def _write_image(image_info, device): - starttime = time.time() - image = _image_location(image_info) - - script = _path_to_script('shell/write_image.sh') - command = ['/bin/bash', script, image, device] - LOG.info('Writing image with command: {0}'.format(' '.join(command))) - exit_code = subprocess.call(command) - if exit_code != 0: - raise errors.ImageWriteError(exit_code, device) - totaltime = time.time() - starttime - LOG.info('Image {0} written to device {1} in {2} seconds'.format( - image, device, totaltime)) - - -def _copy_configdrive_to_disk(configdrive_dir, device): - starttime = time.time() - script = _path_to_script('shell/copy_configdrive_to_disk.sh') - command = ['/bin/bash', script, configdrive_dir, device] - LOG.info('copying configdrive to disk with command {0}'.format( - ' '.join(command))) - exit_code = subprocess.call(command) - - if exit_code != 0: - raise errors.ConfigDriveWriteError(exit_code, device) - - totaltime = time.time() - starttime - LOG.info('configdrive copied from {0} to {1} in {2} seconds'.format( - configdrive_dir, - device, - totaltime)) - - -def _request_url(image_info, url): - resp = requests.get(url, stream=True) - if resp.status_code != 200: - raise errors.ImageDownloadError(image_info['id']) - return resp - - -def _download_image(image_info): - starttime = time.time() - resp = None - for url in image_info['urls']: - try: - LOG.info("Attempting to download image from {0}".format(url)) - resp = _request_url(image_info, url) - except errors.ImageDownloadError: - failtime = time.time() - starttime - log_msg = "Image download failed. URL: {0}; time: {1} seconds" - LOG.warning(log_msg.format(url, failtime)) - continue - else: - break - if resp is None: - raise errors.ImageDownloadError(image_info['id']) - - image_location = _image_location(image_info) - with open(image_location, 'wb') as f: - try: - for chunk in resp.iter_content(1024 * 1024): - f.write(chunk) - except Exception: - raise errors.ImageDownloadError(image_info['id']) - - totaltime = time.time() - starttime - LOG.info("Image downloaded from {0} in {1} seconds".format(image_location, - totaltime)) - - if not _verify_image(image_info, image_location): - raise errors.ImageChecksumError(image_info['id']) - - -def _verify_image(image_info, image_location): - hashes = image_info['hashes'] - for k, v in hashes.items(): - algo = getattr(hashlib, k, None) - if algo is None: - continue - log_msg = 'Verifying image at {0} with algorithm {1} against hash {2}' - LOG.debug(log_msg.format(image_location, k, v)) - hash_ = algo(open(image_location).read()).hexdigest() - if hash_ == v: - return True - else: - log_msg = ('Image verification failed. Location: {0};' - 'algorithm: {1}; image hash: {2};' - 'verification hash: {3}') - LOG.warning(log_msg.format(image_location, k, hash_, v)) - return False - - -def _validate_image_info(ext, image_info=None, **kwargs): - image_info = image_info or {} - - for field in ['id', 'urls', 'hashes']: - if field not in image_info: - msg = 'Image is missing \'{0}\' field.'.format(field) - raise errors.InvalidCommandParamsError(msg) - - if type(image_info['urls']) != list or not image_info['urls']: - raise errors.InvalidCommandParamsError( - 'Image \'urls\' must be a list with at least one element.') - - if type(image_info['hashes']) != dict or not image_info['hashes']: - raise errors.InvalidCommandParamsError( - 'Image \'hashes\' must be a dictionary with at least one ' - 'element.') - - -class StandbyExtension(base.BaseAgentExtension): - def __init__(self): - super(StandbyExtension, self).__init__('STANDBY') - self.command_map['cache_image'] = self.cache_image - self.command_map['prepare_image'] = self.prepare_image - self.command_map['run_image'] = self.run_image - - self.cached_image_id = None - - @decorators.async_command(_validate_image_info) - def cache_image(self, command_name, image_info=None, force=False): - device = hardware.get_manager().get_os_install_device() - - if self.cached_image_id != image_info['id'] or force: - _download_image(image_info) - _write_image(image_info, device) - self.cached_image_id = image_info['id'] - - @decorators.async_command(_validate_image_info) - def prepare_image(self, - command_name, - image_info=None, - metadata=None, - files=None): - location = _configdrive_location() - device = hardware.get_manager().get_os_install_device() - - # don't write image again if already cached - if self.cached_image_id != image_info['id']: - _download_image(image_info) - _write_image(image_info, device) - self.cached_image_id = image_info['id'] - - LOG.debug('Writing configdrive to {0}'.format(location)) - configdrive.write_configdrive(location, metadata, files) - _copy_configdrive_to_disk(location, device) - - @decorators.async_command() - def run_image(self, command_name): - script = _path_to_script('shell/reboot.sh') - LOG.info('Rebooting system') - command = ['/bin/bash', script] - # this should never return if successful - exit_code = subprocess.call(command) - if exit_code != 0: - raise errors.SystemRebootError(exit_code) diff --git a/ironic_python_agent/tests/agent.py b/ironic_python_agent/tests/agent.py index 680a8fb5..49fa28f8 100644 --- a/ironic_python_agent/tests/agent.py +++ b/ironic_python_agent/tests/agent.py @@ -22,10 +22,10 @@ import six from wsgiref import simple_server from ironic_python_agent import agent -from ironic_python_agent import base from ironic_python_agent.cmd import agent as agent_cmd from ironic_python_agent import encoding from ironic_python_agent import errors +from ironic_python_agent.extensions import base from ironic_python_agent import hardware EXPECTED_ERROR = RuntimeError('command execution failed') diff --git a/ironic_python_agent/tests/api.py b/ironic_python_agent/tests/api.py index c5493267..c762a7c6 100644 --- a/ironic_python_agent/tests/api.py +++ b/ironic_python_agent/tests/api.py @@ -20,7 +20,7 @@ import pecan import pecan.testing from ironic_python_agent import agent -from ironic_python_agent import base +from ironic_python_agent.extensions import base PATH_PREFIX = '/v1' diff --git a/ironic_python_agent/tests/base.py b/ironic_python_agent/tests/base.py deleted file mode 100644 index 7b221e1b..00000000 --- a/ironic_python_agent/tests/base.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright 2013 Rackspace, 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. - -import mock -from oslotest import base as test_base -from stevedore import extension - -from ironic_python_agent import base -from ironic_python_agent import errors - - -class FakeExtension(base.BaseAgentExtension): - def __init__(self): - super(FakeExtension, self).__init__('FAKE') - - -class FakeAgent(base.ExecuteCommandMixin): - def __init__(self): - super(FakeAgent, self).__init__() - - def get_extension_manager(self): - return extension.ExtensionManager.make_test_instance( - [extension.Extension('fake', None, FakeExtension, - FakeExtension())]) - - -class TestExecuteCommandMixin(test_base.BaseTestCase): - def setUp(self): - super(TestExecuteCommandMixin, self).setUp() - self.agent = FakeAgent() - - def test_execute_command(self): - do_something_impl = mock.Mock() - fake_extension = FakeExtension() - fake_extension.command_map['do_something'] = do_something_impl - self.agent.ext_mgr = extension.ExtensionManager.make_test_instance( - [extension.Extension('fake', None, FakeExtension, fake_extension)]) - - self.agent.execute_command('fake.do_something', foo='bar') - do_something_impl.assert_called_once_with('do_something', foo='bar') - - def test_execute_invalid_command(self): - self.assertRaises(errors.InvalidCommandError, - self.agent.execute_command, - 'do_something', - foo='bar') - - def test_execute_unknown_extension(self): - self.assertRaises(errors.RequestedObjectNotFoundError, - self.agent.execute_command, - 'do.something', - foo='bar') - - def test_execute_command_success(self): - expected_result = base.SyncCommandResult('fake', None, True, None) - fake_ext = self.agent.ext_mgr['fake'].obj - fake_ext.execute = mock.Mock() - fake_ext.execute.return_value = expected_result - result = self.agent.execute_command('fake.sleep', - sleep_info={"time": 1}) - self.assertEqual(expected_result, result) - - def test_execute_command_invalid_content(self): - fake_ext = self.agent.ext_mgr['fake'].obj - fake_ext.execute = mock.Mock() - fake_ext.execute.side_effect = errors.InvalidContentError('baz') - self.assertRaises(errors.InvalidContentError, - self.agent.execute_command, - 'fake.sleep', sleep_info={"time": 1}) - - def test_execute_command_other_exception(self): - msg = 'foo bar baz' - fake_ext = self.agent.ext_mgr['fake'].obj - fake_ext.execute = mock.Mock() - fake_ext.execute.side_effect = Exception(msg) - result = self.agent.execute_command( - 'fake.sleep', sleep_info={"time": 1} - ) - self.assertEqual(result.command_status, - base.AgentCommandStatus.FAILED) - self.assertEqual(result.command_error, msg) diff --git a/ironic_python_agent/tests/decom.py b/ironic_python_agent/tests/decom.py deleted file mode 100644 index 8aa5a1a5..00000000 --- a/ironic_python_agent/tests/decom.py +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright 2013 Rackspace, 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. - -from oslotest import base as test_base - -from ironic_python_agent import decom - - -class TestDecomExtension(test_base.BaseTestCase): - def setUp(self): - super(TestDecomExtension, self).setUp() - self.agent_extension = decom.DecomExtension() - - def test_decom_extension(self): - self.assertEqual(self.agent_extension.name, 'DECOM') diff --git a/ironic_python_agent/tests/extensions/__init__.py b/ironic_python_agent/tests/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ironic_python_agent/tests/extensions/base.py b/ironic_python_agent/tests/extensions/base.py new file mode 100644 index 00000000..8eb91554 --- /dev/null +++ b/ironic_python_agent/tests/extensions/base.py @@ -0,0 +1,92 @@ +# Copyright 2013 Rackspace, 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. + +import mock +from oslotest import base as test_base +from stevedore import extension + +from ironic_python_agent import errors +from ironic_python_agent.extensions import base + + +class FakeExtension(base.BaseAgentExtension): + def __init__(self): + super(FakeExtension, self).__init__('FAKE') + + +class FakeAgent(base.ExecuteCommandMixin): + def __init__(self): + super(FakeAgent, self).__init__() + + def get_extension_manager(self): + return extension.ExtensionManager.make_test_instance( + [extension.Extension('fake', None, FakeExtension, + FakeExtension())]) + + +class TestExecuteCommandMixin(test_base.BaseTestCase): + def setUp(self): + super(TestExecuteCommandMixin, self).setUp() + self.agent = FakeAgent() + + def test_execute_command(self): + do_something_impl = mock.Mock() + fake_extension = FakeExtension() + fake_extension.command_map['do_something'] = do_something_impl + self.agent.ext_mgr = extension.ExtensionManager.make_test_instance( + [extension.Extension('fake', None, FakeExtension, fake_extension)]) + + self.agent.execute_command('fake.do_something', foo='bar') + do_something_impl.assert_called_once_with('do_something', foo='bar') + + def test_execute_invalid_command(self): + self.assertRaises(errors.InvalidCommandError, + self.agent.execute_command, + 'do_something', + foo='bar') + + def test_execute_unknown_extension(self): + self.assertRaises(errors.RequestedObjectNotFoundError, + self.agent.execute_command, + 'do.something', + foo='bar') + + def test_execute_command_success(self): + expected_result = base.SyncCommandResult('fake', None, True, None) + fake_ext = self.agent.ext_mgr['fake'].obj + fake_ext.execute = mock.Mock() + fake_ext.execute.return_value = expected_result + result = self.agent.execute_command('fake.sleep', + sleep_info={"time": 1}) + self.assertEqual(expected_result, result) + + def test_execute_command_invalid_content(self): + fake_ext = self.agent.ext_mgr['fake'].obj + fake_ext.execute = mock.Mock() + fake_ext.execute.side_effect = errors.InvalidContentError('baz') + self.assertRaises(errors.InvalidContentError, + self.agent.execute_command, + 'fake.sleep', sleep_info={"time": 1}) + + def test_execute_command_other_exception(self): + msg = 'foo bar baz' + fake_ext = self.agent.ext_mgr['fake'].obj + fake_ext.execute = mock.Mock() + fake_ext.execute.side_effect = Exception(msg) + result = self.agent.execute_command( + 'fake.sleep', sleep_info={"time": 1} + ) + self.assertEqual(result.command_status, + base.AgentCommandStatus.FAILED) + self.assertEqual(result.command_error, msg) diff --git a/ironic_python_agent/tests/extensions/decom.py b/ironic_python_agent/tests/extensions/decom.py new file mode 100644 index 00000000..8d76c828 --- /dev/null +++ b/ironic_python_agent/tests/extensions/decom.py @@ -0,0 +1,26 @@ +# Copyright 2013 Rackspace, 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. + +from oslotest import base as test_base + +from ironic_python_agent.extensions import decom + + +class TestDecomExtension(test_base.BaseTestCase): + def setUp(self): + super(TestDecomExtension, self).setUp() + self.agent_extension = decom.DecomExtension() + + def test_decom_extension(self): + self.assertEqual(self.agent_extension.name, 'DECOM') diff --git a/ironic_python_agent/tests/extensions/flow.py b/ironic_python_agent/tests/extensions/flow.py new file mode 100644 index 00000000..d8b26ccb --- /dev/null +++ b/ironic_python_agent/tests/extensions/flow.py @@ -0,0 +1,113 @@ +# Copyright 2014 Mirantis, 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. + +import time + +import mock +from oslotest import base as test_base +from stevedore import enabled +from stevedore import extension + +from ironic_python_agent import errors +from ironic_python_agent.extensions import base +from ironic_python_agent.extensions import flow + + +FLOW_INFO = [ + {"fake.sleep": {"sleep_info": {"time": 1}}}, + {"fake.sleep": {"sleep_info": {"time": 2}}}, + {"fake.sync_sleep": {"sleep_info": {"time": 3}}}, + {"fake.sleep": {"sleep_info": {"time": 4}}}, + {"fake.sync_sleep": {"sleep_info": {"time": 5}}}, + {"fake.sleep": {"sleep_info": {"time": 6}}}, + {"fake.sleep": {"sleep_info": {"time": 7}}}, +] + + +class FakeExtension(base.BaseAgentExtension): + def __init__(self): + super(FakeExtension, self).__init__('FAKE') + self.command_map['sleep'] = self.sleep + self.command_map['sync_sleep'] = self.sync_sleep + + @base.async_command() + def sleep(self, command_name, sleep_info=None): + time.sleep(sleep_info['time']) + + def sync_sleep(self, command_name, sleep_info=None): + time.sleep(sleep_info['time']) + + +class TestFlowExtension(test_base.BaseTestCase): + def setUp(self): + super(TestFlowExtension, self).setUp() + self.agent_extension = flow.FlowExtension() + self.agent_extension.ext_mgr = enabled.EnabledExtensionManager.\ + make_test_instance([extension.Extension('fake', None, + FakeExtension, + FakeExtension())]) + + def test_flow_extension(self): + self.assertEqual(self.agent_extension.name, 'FLOW') + + @mock.patch('time.sleep', autospec=True) + def test_sleep_flow_success(self, sleep_mock): + result = self.agent_extension.start_flow('start_flow', flow=FLOW_INFO) + result.join() + sleep_calls = [mock.call(i) for i in range(1, 8)] + sleep_mock.assert_has_calls(sleep_calls) + + @mock.patch('time.sleep', autospec=True) + def test_sleep_flow_failed(self, sleep_mock): + sleep_mock.side_effect = errors.RESTError() + result = self.agent_extension.start_flow('start_flow', flow=FLOW_INFO) + result.join() + self.assertEqual(base.AgentCommandStatus.FAILED, result.command_status) + self.assertTrue(isinstance(result.command_error, + errors.CommandExecutionError)) + + @mock.patch('time.sleep', autospec=True) + def test_sleep_flow_failed_on_second_command(self, sleep_mock): + sleep_mock.side_effect = [None, Exception('foo'), None, None] + result = self.agent_extension.start_flow('start_flow', + flow=FLOW_INFO[:4]) + result.join() + self.assertEqual(base.AgentCommandStatus.FAILED, result.command_status) + self.assertTrue(isinstance(result.command_error, + errors.CommandExecutionError)) + self.assertEqual(2, sleep_mock.call_count) + + def test_validate_exts_success(self): + flow._validate_exts(self.agent_extension, flow=FLOW_INFO) + + def test_validate_exts_failed_to_find_extension(self): + self.agent_extension.ext_mgr.names = mock.Mock() + self.agent_extension.ext_mgr.names.return_value = ['fake_fake'] + self.assertRaises(errors.RequestedObjectNotFoundError, + flow._validate_exts, self.agent_extension, + flow=FLOW_INFO) + + def test_validate_exts_failed_empty_command_map(self): + fake_ext = self.agent_extension.ext_mgr['fake'].obj + delattr(fake_ext, 'command_map') + self.assertRaises(errors.InvalidCommandParamsError, + flow._validate_exts, self.agent_extension, + flow=FLOW_INFO) + + def test_validate_exts_failed_missing_command(self): + fake_ext = self.agent_extension.ext_mgr['fake'].obj + fake_ext.command_map = {'not_exist': 'fake'} + self.assertRaises(errors.InvalidCommandParamsError, + flow._validate_exts, self.agent_extension, + flow=FLOW_INFO) diff --git a/ironic_python_agent/tests/extensions/standby.py b/ironic_python_agent/tests/extensions/standby.py new file mode 100644 index 00000000..739cf96b --- /dev/null +++ b/ironic_python_agent/tests/extensions/standby.py @@ -0,0 +1,327 @@ +# Copyright 2013 Rackspace, 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. + +import mock +from oslotest import base as test_base +import six + +from ironic_python_agent import errors +from ironic_python_agent.extensions import standby + +if six.PY2: + OPEN_FUNCTION_NAME = '__builtin__.open' +else: + OPEN_FUNCTION_NAME = 'builtins.open' + + +class TestStandbyExtension(test_base.BaseTestCase): + def setUp(self): + super(TestStandbyExtension, self).setUp() + self.agent_extension = standby.StandbyExtension() + + def test_standby_extension(self): + self.assertEqual(self.agent_extension.name, 'STANDBY') + + def _build_fake_image_info(self): + return { + 'id': 'fake_id', + 'urls': [ + 'http://example.org', + ], + 'hashes': { + 'md5': 'abc123', + }, + } + + def test_validate_image_info_success(self): + standby._validate_image_info(None, self._build_fake_image_info()) + + def test_validate_image_info_missing_field(self): + for field in ['id', 'urls', 'hashes']: + invalid_info = self._build_fake_image_info() + del invalid_info[field] + + self.assertRaises(errors.InvalidCommandParamsError, + standby._validate_image_info, + invalid_info) + + def test_validate_image_info_invalid_urls(self): + invalid_info = self._build_fake_image_info() + invalid_info['urls'] = 'this_is_not_a_list' + + self.assertRaises(errors.InvalidCommandParamsError, + standby._validate_image_info, + invalid_info) + + def test_validate_image_info_empty_urls(self): + invalid_info = self._build_fake_image_info() + invalid_info['urls'] = [] + + self.assertRaises(errors.InvalidCommandParamsError, + standby._validate_image_info, + invalid_info) + + def test_validate_image_info_invalid_hashes(self): + invalid_info = self._build_fake_image_info() + invalid_info['hashes'] = 'this_is_not_a_dict' + + self.assertRaises(errors.InvalidCommandParamsError, + standby._validate_image_info, + invalid_info) + + def test_validate_image_info_empty_hashes(self): + invalid_info = self._build_fake_image_info() + invalid_info['hashes'] = {} + + self.assertRaises(errors.InvalidCommandParamsError, + standby._validate_image_info, + invalid_info) + + def test_cache_image_success(self): + result = self.agent_extension.cache_image( + 'cache_image', + image_info=self._build_fake_image_info()) + result.join() + + def test_cache_image_invalid_image_list(self): + self.assertRaises(errors.InvalidCommandParamsError, + self.agent_extension.cache_image, + 'cache_image', + image_info={'foo': 'bar'}) + + def test_image_location(self): + image_info = self._build_fake_image_info() + location = standby._image_location(image_info) + self.assertEqual(location, '/tmp/fake_id') + + @mock.patch(OPEN_FUNCTION_NAME, autospec=True) + @mock.patch('subprocess.call', autospec=True) + def test_write_image(self, call_mock, open_mock): + image_info = self._build_fake_image_info() + device = '/dev/sda' + location = standby._image_location(image_info) + script = standby._path_to_script('shell/write_image.sh') + command = ['/bin/bash', script, location, device] + call_mock.return_value = 0 + + standby._write_image(image_info, device) + call_mock.assert_called_once_with(command) + + call_mock.reset_mock() + call_mock.return_value = 1 + + self.assertRaises(errors.ImageWriteError, + standby._write_image, + image_info, + device) + + call_mock.assert_called_once_with(command) + + @mock.patch(OPEN_FUNCTION_NAME, autospec=True) + @mock.patch('subprocess.call', autospec=True) + def test_copy_configdrive_to_disk(self, call_mock, open_mock): + device = '/dev/sda' + configdrive = 'configdrive' + script = standby._path_to_script('shell/copy_configdrive_to_disk.sh') + command = ['/bin/bash', script, configdrive, device] + call_mock.return_value = 0 + + standby._copy_configdrive_to_disk(configdrive, device) + call_mock.assert_called_once_with(command) + + call_mock.reset_mock() + call_mock.return_value = 1 + + self.assertRaises(errors.ConfigDriveWriteError, + standby._copy_configdrive_to_disk, + configdrive, + device) + + call_mock.assert_called_once_with(command) + + @mock.patch('hashlib.md5', autospec=True) + @mock.patch(OPEN_FUNCTION_NAME, autospec=True) + @mock.patch('requests.get', autospec=True) + def test_download_image(self, requests_mock, open_mock, md5_mock): + image_info = self._build_fake_image_info() + response = requests_mock.return_value + response.status_code = 200 + response.iter_content.return_value = ['some', 'content'] + open_mock.return_value.__enter__ = lambda s: s + open_mock.return_value.__exit__ = mock.Mock() + read_mock = open_mock.return_value.read + read_mock.return_value = 'content' + hexdigest_mock = md5_mock.return_value.hexdigest + hexdigest_mock.return_value = list(image_info['hashes'].values())[0] + + standby._download_image(image_info) + requests_mock.assert_called_once_with(image_info['urls'][0], + stream=True) + write = open_mock.return_value.write + write.assert_any_call('some') + write.assert_any_call('content') + self.assertEqual(write.call_count, 2) + + @mock.patch('requests.get', autospec=True) + def test_download_image_bad_status(self, requests_mock): + image_info = self._build_fake_image_info() + response = requests_mock.return_value + response.status_code = 404 + self.assertRaises(errors.ImageDownloadError, + standby._download_image, + image_info) + + @mock.patch('ironic_python_agent.extensions.standby._verify_image', + autospec=True) + @mock.patch(OPEN_FUNCTION_NAME, autospec=True) + @mock.patch('requests.get', autospec=True) + def test_download_image_verify_fails(self, requests_mock, open_mock, + verify_mock): + image_info = self._build_fake_image_info() + response = requests_mock.return_value + response.status_code = 200 + verify_mock.return_value = False + self.assertRaises(errors.ImageChecksumError, + standby._download_image, + image_info) + + @mock.patch(OPEN_FUNCTION_NAME, autospec=True) + @mock.patch('hashlib.sha1', autospec=True) + @mock.patch('hashlib.md5', autospec=True) + def test_verify_image_success(self, md5_mock, sha1_mock, open_mock): + image_info = self._build_fake_image_info() + image_info['hashes']['sha1'] = image_info['hashes']['md5'] + hexdigest_mock = md5_mock.return_value.hexdigest + hexdigest_mock.return_value = image_info['hashes']['md5'] + hexdigest_mock = sha1_mock.return_value.hexdigest + hexdigest_mock.return_value = image_info['hashes']['sha1'] + image_location = '/foo/bar' + + verified = standby._verify_image(image_info, image_location) + self.assertTrue(verified) + # make sure we only check one hash, even though both are valid + self.assertEqual(md5_mock.call_count + sha1_mock.call_count, 1) + + @mock.patch(OPEN_FUNCTION_NAME, autospec=True) + @mock.patch('hashlib.md5', autospec=True) + def test_verify_image_failure(self, md5_mock, open_mock): + image_info = self._build_fake_image_info() + md5_mock.return_value.hexdigest.return_value = 'wrong hash' + image_location = '/foo/bar' + + verified = standby._verify_image(image_info, image_location) + self.assertFalse(verified) + self.assertEqual(md5_mock.call_count, 1) + + @mock.patch('ironic_python_agent.hardware.get_manager', autospec=True) + @mock.patch('ironic_python_agent.extensions.standby._write_image', + autospec=True) + @mock.patch('ironic_python_agent.extensions.standby._download_image', + autospec=True) + def test_cache_image(self, download_mock, write_mock, hardware_mock): + image_info = self._build_fake_image_info() + download_mock.return_value = None + write_mock.return_value = None + manager_mock = hardware_mock.return_value + manager_mock.get_os_install_device.return_value = 'manager' + async_result = self.agent_extension.cache_image('cache_image', + image_info=image_info) + async_result.join() + download_mock.assert_called_once_with(image_info) + write_mock.assert_called_once_with(image_info, 'manager') + self.assertEqual(self.agent_extension.cached_image_id, + image_info['id']) + self.assertEqual('SUCCEEDED', async_result.command_status) + self.assertEqual(None, async_result.command_result) + + @mock.patch(('ironic_python_agent.extensions.standby.' + '_copy_configdrive_to_disk'), + autospec=True) + @mock.patch(('ironic_python_agent.extensions.standby.configdrive.' + 'write_configdrive'), + autospec=True) + @mock.patch('ironic_python_agent.hardware.get_manager', autospec=True) + @mock.patch('ironic_python_agent.extensions.standby._write_image', + autospec=True) + @mock.patch('ironic_python_agent.extensions.standby._download_image', + autospec=True) + @mock.patch('ironic_python_agent.extensions.standby._configdrive_location', + autospec=True) + def test_prepare_image(self, + location_mock, + download_mock, + write_mock, + hardware_mock, + configdrive_mock, + configdrive_copy_mock): + image_info = self._build_fake_image_info() + location_mock.return_value = 'THE CLOUD' + download_mock.return_value = None + write_mock.return_value = None + manager_mock = hardware_mock.return_value + manager_mock.get_os_install_device.return_value = 'manager' + configdrive_mock.return_value = None + configdrive_copy_mock.return_value = None + + async_result = self.agent_extension.prepare_image('prepare_image', + image_info=image_info, + metadata={}, + files=[]) + async_result.join() + + download_mock.assert_called_once_with(image_info) + write_mock.assert_called_once_with(image_info, 'manager') + configdrive_mock.assert_called_once_with('THE CLOUD', {}, []) + configdrive_copy_mock.assert_called_once_with('THE CLOUD', 'manager') + + self.assertEqual('SUCCEEDED', async_result.command_status) + self.assertEqual(None, async_result.command_result) + + download_mock.reset_mock() + write_mock.reset_mock() + configdrive_mock.reset_mock() + configdrive_copy_mock.reset_mock() + # image is now cached, make sure download/write doesn't happen + async_result = self.agent_extension.prepare_image('prepare_image', + image_info=image_info, + metadata={}, + files=[]) + async_result.join() + + self.assertEqual(download_mock.call_count, 0) + self.assertEqual(write_mock.call_count, 0) + configdrive_mock.assert_called_once_with('THE CLOUD', {}, []) + configdrive_copy_mock.assert_called_once_with('THE CLOUD', 'manager') + + self.assertEqual('SUCCEEDED', async_result.command_status) + self.assertEqual(None, async_result.command_result) + + @mock.patch('subprocess.call', autospec=True) + def test_run_image(self, call_mock): + script = standby._path_to_script('shell/reboot.sh') + command = ['/bin/bash', script] + call_mock.return_value = 0 + + success_result = self.agent_extension.run_image('run_image') + success_result.join() + call_mock.assert_called_once_with(command) + + call_mock.reset_mock() + call_mock.return_value = 1 + + failed_result = self.agent_extension.run_image('run_image') + failed_result.join() + + call_mock.assert_called_once_with(command) + self.assertEqual('FAILED', failed_result.command_status) diff --git a/ironic_python_agent/tests/flow.py b/ironic_python_agent/tests/flow.py deleted file mode 100644 index 5369b049..00000000 --- a/ironic_python_agent/tests/flow.py +++ /dev/null @@ -1,114 +0,0 @@ -# Copyright 2014 Mirantis, 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. - -import time - -import mock -from oslotest import base as test_base -from stevedore import enabled -from stevedore import extension - -from ironic_python_agent import base -from ironic_python_agent import decorators -from ironic_python_agent import errors -from ironic_python_agent import flow - - -FLOW_INFO = [ - {"fake.sleep": {"sleep_info": {"time": 1}}}, - {"fake.sleep": {"sleep_info": {"time": 2}}}, - {"fake.sync_sleep": {"sleep_info": {"time": 3}}}, - {"fake.sleep": {"sleep_info": {"time": 4}}}, - {"fake.sync_sleep": {"sleep_info": {"time": 5}}}, - {"fake.sleep": {"sleep_info": {"time": 6}}}, - {"fake.sleep": {"sleep_info": {"time": 7}}}, -] - - -class FakeExtension(base.BaseAgentExtension): - def __init__(self): - super(FakeExtension, self).__init__('FAKE') - self.command_map['sleep'] = self.sleep - self.command_map['sync_sleep'] = self.sync_sleep - - @decorators.async_command() - def sleep(self, command_name, sleep_info=None): - time.sleep(sleep_info['time']) - - def sync_sleep(self, command_name, sleep_info=None): - time.sleep(sleep_info['time']) - - -class TestFlowExtension(test_base.BaseTestCase): - def setUp(self): - super(TestFlowExtension, self).setUp() - self.agent_extension = flow.FlowExtension() - self.agent_extension.ext_mgr = enabled.EnabledExtensionManager.\ - make_test_instance([extension.Extension('fake', None, - FakeExtension, - FakeExtension())]) - - def test_flow_extension(self): - self.assertEqual(self.agent_extension.name, 'FLOW') - - @mock.patch('time.sleep', autospec=True) - def test_sleep_flow_success(self, sleep_mock): - result = self.agent_extension.start_flow('start_flow', flow=FLOW_INFO) - result.join() - sleep_calls = [mock.call(i) for i in range(1, 8)] - sleep_mock.assert_has_calls(sleep_calls) - - @mock.patch('time.sleep', autospec=True) - def test_sleep_flow_failed(self, sleep_mock): - sleep_mock.side_effect = errors.RESTError() - result = self.agent_extension.start_flow('start_flow', flow=FLOW_INFO) - result.join() - self.assertEqual(base.AgentCommandStatus.FAILED, result.command_status) - self.assertTrue(isinstance(result.command_error, - errors.CommandExecutionError)) - - @mock.patch('time.sleep', autospec=True) - def test_sleep_flow_failed_on_second_command(self, sleep_mock): - sleep_mock.side_effect = [None, Exception('foo'), None, None] - result = self.agent_extension.start_flow('start_flow', - flow=FLOW_INFO[:4]) - result.join() - self.assertEqual(base.AgentCommandStatus.FAILED, result.command_status) - self.assertTrue(isinstance(result.command_error, - errors.CommandExecutionError)) - self.assertEqual(2, sleep_mock.call_count) - - def test_validate_exts_success(self): - flow._validate_exts(self.agent_extension, flow=FLOW_INFO) - - def test_validate_exts_failed_to_find_extension(self): - self.agent_extension.ext_mgr.names = mock.Mock() - self.agent_extension.ext_mgr.names.return_value = ['fake_fake'] - self.assertRaises(errors.RequestedObjectNotFoundError, - flow._validate_exts, self.agent_extension, - flow=FLOW_INFO) - - def test_validate_exts_failed_empty_command_map(self): - fake_ext = self.agent_extension.ext_mgr['fake'].obj - delattr(fake_ext, 'command_map') - self.assertRaises(errors.InvalidCommandParamsError, - flow._validate_exts, self.agent_extension, - flow=FLOW_INFO) - - def test_validate_exts_failed_missing_command(self): - fake_ext = self.agent_extension.ext_mgr['fake'].obj - fake_ext.command_map = {'not_exist': 'fake'} - self.assertRaises(errors.InvalidCommandParamsError, - flow._validate_exts, self.agent_extension, - flow=FLOW_INFO) diff --git a/ironic_python_agent/tests/standby.py b/ironic_python_agent/tests/standby.py deleted file mode 100644 index b8911ba0..00000000 --- a/ironic_python_agent/tests/standby.py +++ /dev/null @@ -1,320 +0,0 @@ -# Copyright 2013 Rackspace, 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. - -import mock -from oslotest import base as test_base -import six - -from ironic_python_agent import errors -from ironic_python_agent import standby - -if six.PY2: - OPEN_FUNCTION_NAME = '__builtin__.open' -else: - OPEN_FUNCTION_NAME = 'builtins.open' - - -class TestStandbyExtension(test_base.BaseTestCase): - def setUp(self): - super(TestStandbyExtension, self).setUp() - self.agent_extension = standby.StandbyExtension() - - def test_standby_extension(self): - self.assertEqual(self.agent_extension.name, 'STANDBY') - - def _build_fake_image_info(self): - return { - 'id': 'fake_id', - 'urls': [ - 'http://example.org', - ], - 'hashes': { - 'md5': 'abc123', - }, - } - - def test_validate_image_info_success(self): - standby._validate_image_info(None, self._build_fake_image_info()) - - def test_validate_image_info_missing_field(self): - for field in ['id', 'urls', 'hashes']: - invalid_info = self._build_fake_image_info() - del invalid_info[field] - - self.assertRaises(errors.InvalidCommandParamsError, - standby._validate_image_info, - invalid_info) - - def test_validate_image_info_invalid_urls(self): - invalid_info = self._build_fake_image_info() - invalid_info['urls'] = 'this_is_not_a_list' - - self.assertRaises(errors.InvalidCommandParamsError, - standby._validate_image_info, - invalid_info) - - def test_validate_image_info_empty_urls(self): - invalid_info = self._build_fake_image_info() - invalid_info['urls'] = [] - - self.assertRaises(errors.InvalidCommandParamsError, - standby._validate_image_info, - invalid_info) - - def test_validate_image_info_invalid_hashes(self): - invalid_info = self._build_fake_image_info() - invalid_info['hashes'] = 'this_is_not_a_dict' - - self.assertRaises(errors.InvalidCommandParamsError, - standby._validate_image_info, - invalid_info) - - def test_validate_image_info_empty_hashes(self): - invalid_info = self._build_fake_image_info() - invalid_info['hashes'] = {} - - self.assertRaises(errors.InvalidCommandParamsError, - standby._validate_image_info, - invalid_info) - - def test_cache_image_success(self): - result = self.agent_extension.cache_image( - 'cache_image', - image_info=self._build_fake_image_info()) - result.join() - - def test_cache_image_invalid_image_list(self): - self.assertRaises(errors.InvalidCommandParamsError, - self.agent_extension.cache_image, - 'cache_image', - image_info={'foo': 'bar'}) - - def test_image_location(self): - image_info = self._build_fake_image_info() - location = standby._image_location(image_info) - self.assertEqual(location, '/tmp/fake_id') - - @mock.patch(OPEN_FUNCTION_NAME, autospec=True) - @mock.patch('subprocess.call', autospec=True) - def test_write_image(self, call_mock, open_mock): - image_info = self._build_fake_image_info() - device = '/dev/sda' - location = standby._image_location(image_info) - script = standby._path_to_script('shell/write_image.sh') - command = ['/bin/bash', script, location, device] - call_mock.return_value = 0 - - standby._write_image(image_info, device) - call_mock.assert_called_once_with(command) - - call_mock.reset_mock() - call_mock.return_value = 1 - - self.assertRaises(errors.ImageWriteError, - standby._write_image, - image_info, - device) - - call_mock.assert_called_once_with(command) - - @mock.patch(OPEN_FUNCTION_NAME, autospec=True) - @mock.patch('subprocess.call', autospec=True) - def test_copy_configdrive_to_disk(self, call_mock, open_mock): - device = '/dev/sda' - configdrive = 'configdrive' - script = standby._path_to_script('shell/copy_configdrive_to_disk.sh') - command = ['/bin/bash', script, configdrive, device] - call_mock.return_value = 0 - - standby._copy_configdrive_to_disk(configdrive, device) - call_mock.assert_called_once_with(command) - - call_mock.reset_mock() - call_mock.return_value = 1 - - self.assertRaises(errors.ConfigDriveWriteError, - standby._copy_configdrive_to_disk, - configdrive, - device) - - call_mock.assert_called_once_with(command) - - @mock.patch('hashlib.md5', autospec=True) - @mock.patch(OPEN_FUNCTION_NAME, autospec=True) - @mock.patch('requests.get', autospec=True) - def test_download_image(self, requests_mock, open_mock, md5_mock): - image_info = self._build_fake_image_info() - response = requests_mock.return_value - response.status_code = 200 - response.iter_content.return_value = ['some', 'content'] - open_mock.return_value.__enter__ = lambda s: s - open_mock.return_value.__exit__ = mock.Mock() - read_mock = open_mock.return_value.read - read_mock.return_value = 'content' - hexdigest_mock = md5_mock.return_value.hexdigest - hexdigest_mock.return_value = list(image_info['hashes'].values())[0] - - standby._download_image(image_info) - requests_mock.assert_called_once_with(image_info['urls'][0], - stream=True) - write = open_mock.return_value.write - write.assert_any_call('some') - write.assert_any_call('content') - self.assertEqual(write.call_count, 2) - - @mock.patch('requests.get', autospec=True) - def test_download_image_bad_status(self, requests_mock): - image_info = self._build_fake_image_info() - response = requests_mock.return_value - response.status_code = 404 - self.assertRaises(errors.ImageDownloadError, - standby._download_image, - image_info) - - @mock.patch('ironic_python_agent.standby._verify_image', autospec=True) - @mock.patch(OPEN_FUNCTION_NAME, autospec=True) - @mock.patch('requests.get', autospec=True) - def test_download_image_verify_fails(self, requests_mock, open_mock, - verify_mock): - image_info = self._build_fake_image_info() - response = requests_mock.return_value - response.status_code = 200 - verify_mock.return_value = False - self.assertRaises(errors.ImageChecksumError, - standby._download_image, - image_info) - - @mock.patch(OPEN_FUNCTION_NAME, autospec=True) - @mock.patch('hashlib.sha1', autospec=True) - @mock.patch('hashlib.md5', autospec=True) - def test_verify_image_success(self, md5_mock, sha1_mock, open_mock): - image_info = self._build_fake_image_info() - image_info['hashes']['sha1'] = image_info['hashes']['md5'] - hexdigest_mock = md5_mock.return_value.hexdigest - hexdigest_mock.return_value = image_info['hashes']['md5'] - hexdigest_mock = sha1_mock.return_value.hexdigest - hexdigest_mock.return_value = image_info['hashes']['sha1'] - image_location = '/foo/bar' - - verified = standby._verify_image(image_info, image_location) - self.assertTrue(verified) - # make sure we only check one hash, even though both are valid - self.assertEqual(md5_mock.call_count + sha1_mock.call_count, 1) - - @mock.patch(OPEN_FUNCTION_NAME, autospec=True) - @mock.patch('hashlib.md5', autospec=True) - def test_verify_image_failure(self, md5_mock, open_mock): - image_info = self._build_fake_image_info() - md5_mock.return_value.hexdigest.return_value = 'wrong hash' - image_location = '/foo/bar' - - verified = standby._verify_image(image_info, image_location) - self.assertFalse(verified) - self.assertEqual(md5_mock.call_count, 1) - - @mock.patch('ironic_python_agent.hardware.get_manager', autospec=True) - @mock.patch('ironic_python_agent.standby._write_image', autospec=True) - @mock.patch('ironic_python_agent.standby._download_image', autospec=True) - def test_cache_image(self, download_mock, write_mock, hardware_mock): - image_info = self._build_fake_image_info() - download_mock.return_value = None - write_mock.return_value = None - manager_mock = hardware_mock.return_value - manager_mock.get_os_install_device.return_value = 'manager' - async_result = self.agent_extension.cache_image('cache_image', - image_info=image_info) - async_result.join() - download_mock.assert_called_once_with(image_info) - write_mock.assert_called_once_with(image_info, 'manager') - self.assertEqual(self.agent_extension.cached_image_id, - image_info['id']) - self.assertEqual('SUCCEEDED', async_result.command_status) - self.assertEqual(None, async_result.command_result) - - @mock.patch('ironic_python_agent.standby._copy_configdrive_to_disk', - autospec=True) - @mock.patch('ironic_python_agent.standby.configdrive.write_configdrive', - autospec=True) - @mock.patch('ironic_python_agent.hardware.get_manager', autospec=True) - @mock.patch('ironic_python_agent.standby._write_image', autospec=True) - @mock.patch('ironic_python_agent.standby._download_image', autospec=True) - @mock.patch('ironic_python_agent.standby._configdrive_location', - autospec=True) - def test_prepare_image(self, - location_mock, - download_mock, - write_mock, - hardware_mock, - configdrive_mock, - configdrive_copy_mock): - image_info = self._build_fake_image_info() - location_mock.return_value = 'THE CLOUD' - download_mock.return_value = None - write_mock.return_value = None - manager_mock = hardware_mock.return_value - manager_mock.get_os_install_device.return_value = 'manager' - configdrive_mock.return_value = None - configdrive_copy_mock.return_value = None - - async_result = self.agent_extension.prepare_image('prepare_image', - image_info=image_info, - metadata={}, - files=[]) - async_result.join() - - download_mock.assert_called_once_with(image_info) - write_mock.assert_called_once_with(image_info, 'manager') - configdrive_mock.assert_called_once_with('THE CLOUD', {}, []) - configdrive_copy_mock.assert_called_once_with('THE CLOUD', 'manager') - - self.assertEqual('SUCCEEDED', async_result.command_status) - self.assertEqual(None, async_result.command_result) - - download_mock.reset_mock() - write_mock.reset_mock() - configdrive_mock.reset_mock() - configdrive_copy_mock.reset_mock() - # image is now cached, make sure download/write doesn't happen - async_result = self.agent_extension.prepare_image('prepare_image', - image_info=image_info, - metadata={}, - files=[]) - async_result.join() - - self.assertEqual(download_mock.call_count, 0) - self.assertEqual(write_mock.call_count, 0) - configdrive_mock.assert_called_once_with('THE CLOUD', {}, []) - configdrive_copy_mock.assert_called_once_with('THE CLOUD', 'manager') - - self.assertEqual('SUCCEEDED', async_result.command_status) - self.assertEqual(None, async_result.command_result) - - @mock.patch('subprocess.call', autospec=True) - def test_run_image(self, call_mock): - script = standby._path_to_script('shell/reboot.sh') - command = ['/bin/bash', script] - call_mock.return_value = 0 - - success_result = self.agent_extension.run_image('run_image') - success_result.join() - call_mock.assert_called_once_with(command) - - call_mock.reset_mock() - call_mock.return_value = 1 - - failed_result = self.agent_extension.run_image('run_image') - failed_result.join() - - call_mock.assert_called_once_with(command) - self.assertEqual('FAILED', failed_result.command_status) -- cgit v1.2.1