Allow monitoring progress of a firmware update

This patch updates the UpdateService.simple_update method so that it
returns a TaskMonitor object.  This allows the firmware update to be
monitored for completion. New TaskMonitor and Task objects are added
according to the Redfish Spec.

Co-Authored-By: Aija Jaunteva <aija.jaunteva@dell.com>

Change-Id: I485d56a9804af723ddb55f8bc26f28a5ebefccc7
This commit is contained in:
Christopher Dearborn
2020-08-12 14:53:47 -04:00
parent 46b5d38d84
commit 3f052a3498
19 changed files with 771 additions and 66 deletions

View File

@@ -0,0 +1,5 @@
---
features:
- |
Added the ability to monitor the progress of a firmware update by changing
the ``simple_update`` operation to return a task monitor object.

View File

@@ -72,6 +72,10 @@ class OEMExtensionNotFoundError(SushyError):
message = 'No %(resource)s OEM extension found by name "%(name)s".'
class MissingHeaderError(SushyError):
message = 'Response to %(target_uri)s did not contain a %(header)s header'
class HTTPError(SushyError):
"""Basic exception for HTTP errors"""

View File

@@ -180,6 +180,7 @@ class Sushy(base.ResourceBase):
if auth is None:
auth = sushy_auth.SessionOrBasicAuth(username=username,
password=password)
self._auth = auth
super(Sushy, self).__init__(
connector or sushy_connector.Connector(base_url, verify=verify),
@@ -187,7 +188,6 @@ class Sushy(base.ResourceBase):
self._public_connector = public_connector or requests
self._language = language
self._base_url = base_url
self._auth = auth
self._auth.set_context(self, self._conn)
self._auth.authenticate()

View File

@@ -24,6 +24,7 @@ import zipfile
import pkg_resources
from sushy import exceptions
from sushy.resources import mappings as res_maps
from sushy.resources import oem
from sushy import utils
@@ -315,7 +316,61 @@ class MappedListField(Field):
return instances
class AbstractJsonReader(object, metaclass=abc.ABCMeta):
class MessageListField(ListField):
"""List of messages with details of settings update status"""
message_id = Field('MessageId', required=True)
"""The key for this message which can be used
to look up the message in a message registry
"""
message = Field('Message')
"""Human readable message, if provided"""
severity = MappedField('Severity',
res_maps.SEVERITY_VALUE_MAP)
"""Severity of the error"""
resolution = Field('Resolution')
"""Used to provide suggestions on how to resolve
the situation that caused the error
"""
_related_properties = Field('RelatedProperties')
"""List of properties described by the message"""
message_args = Field('MessageArgs')
"""List of message substitution arguments for the message
referenced by `message_id` from the message registry
"""
class FieldData(object):
"""Contains data to be used when constructing Fields"""
def __init__(self, status_code, headers, json_doc):
"""Initializes the FieldData instance"""
self._status_code = status_code
self._headers = headers
self._json_doc = json_doc
@property
def status_code(self):
"""The status code"""
return self._status_code
@property
def headers(self):
"""The headers"""
return self._headers
@property
def json_doc(self):
"""The parsed JSON body"""
return self._json_doc
class AbstractDataReader(object, metaclass=abc.ABCMeta):
def set_connection(self, connector, path):
"""Sets mandatory connection parameters
@@ -327,28 +382,33 @@ class AbstractJsonReader(object, metaclass=abc.ABCMeta):
self._path = path
@abc.abstractmethod
def get_json(self):
def get_data(self):
"""Based on data source get data and parse to JSON"""
class JsonDataReader(AbstractJsonReader):
class JsonDataReader(AbstractDataReader):
"""Gets the data from HTTP response given by path"""
def get_json(self):
def get_data(self):
"""Gets JSON file from URI directly"""
data = self._conn.get(path=self._path)
return data.json() if data.content else {}
json_data = data.json() if data.content else {}
return FieldData(data.status_code, data.headers, json_data)
class JsonPublicFileReader(AbstractJsonReader):
class JsonPublicFileReader(AbstractDataReader):
"""Loads the data from the Internet"""
def get_json(self):
def get_data(self):
"""Get JSON file from full URI"""
return self._conn.get(self._path).json()
data = self._conn.get(self._path)
return FieldData(data.status_code, data.headers, data.json())
class JsonArchiveReader(AbstractJsonReader):
class JsonArchiveReader(AbstractDataReader):
"""Gets the data from JSON file in archive"""
def __init__(self, archive_file):
@@ -358,15 +418,16 @@ class JsonArchiveReader(AbstractJsonReader):
"""
self._archive_file = archive_file
def get_json(self):
def get_data(self):
"""Gets JSON file from archive. Currently supporting ZIP only"""
data = self._conn.get(path=self._path)
if data.headers.get('content-type') == 'application/zip':
try:
archive = zipfile.ZipFile(io.BytesIO(data.content))
return json.loads(archive.read(self._archive_file)
.decode(encoding='utf-8'))
json_data = json.loads(archive.read(self._archive_file)
.decode(encoding='utf-8'))
return FieldData(data.status_code, data.headers, json_data)
except (zipfile.BadZipfile, ValueError) as e:
raise exceptions.ArchiveParsingError(
path=self._path, error=e)
@@ -374,8 +435,10 @@ class JsonArchiveReader(AbstractJsonReader):
LOG.error('Support for %(type)s not implemented',
{'type': data.headers['content-type']})
return FieldData(data.status_code, data.headers, None)
class JsonPackagedFileReader(AbstractJsonReader):
class JsonPackagedFileReader(AbstractDataReader):
"""Gets the data from packaged file given by path"""
def __init__(self, resource_package_name):
@@ -385,12 +448,28 @@ class JsonPackagedFileReader(AbstractJsonReader):
"""
self._resource_package_name = resource_package_name
def get_json(self):
def get_data(self):
"""Gets JSON file from packaged file denoted by path"""
with pkg_resources.resource_stream(self._resource_package_name,
self._path) as resource:
return json.loads(resource.read().decode(encoding='utf-8'))
json_data = json.loads(resource.read().decode(encoding='utf-8'))
return FieldData(None, None, json_data)
def get_reader(connector, path, reader=None):
"""Create and configure the reader.
:param connector: A Connector instance
:param path: sub-URI path to the resource.
:param reader: Reader to use to fetch JSON data.
:returns: the reader
"""
if reader is None:
reader = JsonDataReader()
reader.set_connection(connector, path)
return reader
class ResourceBase(object, metaclass=abc.ABCMeta):
@@ -406,7 +485,8 @@ class ResourceBase(object, metaclass=abc.ABCMeta):
path='',
redfish_version=None,
registries=None,
reader=None):
reader=None,
json_doc=None):
"""A class representing the base of any Redfish resource
Invokes the ``refresh()`` method of resource for the first
@@ -418,6 +498,7 @@ class ResourceBase(object, metaclass=abc.ABCMeta):
:param registries: Dict of Redfish Message Registry objects to be
used in any resource that needs registries to parse messages
:param reader: Reader to use to fetch JSON data.
:param json_doc: parsed JSON document in form of Python types.
"""
self._conn = connector
self._path = path
@@ -429,12 +510,9 @@ class ResourceBase(object, metaclass=abc.ABCMeta):
# attribute values are fetched.
self._is_stale = True
if reader is None:
reader = JsonDataReader()
reader.set_connection(connector, path)
self._reader = reader
self._reader = get_reader(connector, path, reader)
self.refresh()
self.refresh(json_doc=json_doc)
def _parse_attributes(self, json_doc):
"""Parse the attributes of a resource.
@@ -447,7 +525,7 @@ class ResourceBase(object, metaclass=abc.ABCMeta):
# Hide the Field object behind the real value
setattr(self, attr, field._load(json_doc, self))
def refresh(self, force=True):
def refresh(self, force=True, json_doc=None):
"""Refresh the resource
Freshly retrieves/fetches the resource attributes and invokes
@@ -460,6 +538,7 @@ class ResourceBase(object, metaclass=abc.ABCMeta):
:param force: if set to False, will only refresh if the resource is
marked as stale, otherwise neither it nor its subresources will
be refreshed.
:param json_doc: parsed JSON document in form of Python types.
:raises: ResourceNotFoundError
:raises: ConnectionError
:raises: HTTPError
@@ -469,7 +548,10 @@ class ResourceBase(object, metaclass=abc.ABCMeta):
if not self._is_stale and not force:
return
self._json = self._reader.get_json()
if json_doc:
self._json = json_doc
else:
self._json = self._reader.get_data().json_doc
LOG.debug('Received representation of %(type)s %(path)s: %(json)s',
{'type': self.__class__.__name__,

View File

@@ -32,6 +32,21 @@ STATE_DEFERRING = 'deferring'
STATE_QUIESCED = 'quiesced'
STATE_UPDATING = 'updating'
# Task state related constants
TASK_STATE_NEW = 'new'
TASK_STATE_STARTING = 'starting'
TASK_STATE_RUNNING = 'running'
TASK_STATE_SUSPENDED = 'suspended'
TASK_STATE_INTERRUPTED = 'interrupted'
TASK_STATE_PENDING = 'pending'
TASK_STATE_STOPPING = 'stopping'
TASK_STATE_COMPLETED = 'completed'
TASK_STATE_KILLED = 'killed'
TASK_STATE_EXCEPTION = 'exception'
TASK_STATE_SERVICE = 'service'
TASK_STATE_CANCELLING = 'cancelling'
TASK_STATE_CANCELLED = 'cancelled'
# Message Registry message parameter type related constants.
PARAMTYPE_STRING = 'string'
PARAMTYPE_NUMBER = 'number'

View File

@@ -62,35 +62,6 @@ class SettingsUpdate(object):
LOG = logging.getLogger(__name__)
class MessageListField(base.ListField):
"""List of messages with details of settings update status"""
message_id = base.Field('MessageId', required=True)
"""The key for this message which can be used
to look up the message in a message registry
"""
message = base.Field('Message')
"""Human readable message, if provided"""
severity = base.MappedField('Severity',
res_maps.SEVERITY_VALUE_MAP)
"""Severity of the error"""
resolution = base.Field('Resolution')
"""Used to provide suggestions on how to resolve
the situation that caused the error
"""
_related_properties = base.Field('RelatedProperties')
"""List of properties described by the message"""
message_args = base.Field('MessageArgs')
"""List of message substitution arguments for the message
referenced by `message_id` from the message registry
"""
class MaintenanceWindowField(base.CompositeField):
maintenance_window_duration_in_seconds = base.Field(
@@ -172,7 +143,7 @@ class SettingsField(base.CompositeField):
'(e.g. System resource)')
return None
messages = MessageListField("Messages")
messages = base.MessageListField("Messages")
"""Represents the results of the last time the values of the Settings
resource were applied to the server"""

View File

@@ -0,0 +1,33 @@
# Copyright (c) 2020 Dell, Inc. or its subsidiaries
#
# 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 sushy.resources import constants as res_cons
TASK_STATE_VALUE_MAP = {
'New': res_cons.TASK_STATE_NEW,
'Starting': res_cons.TASK_STATE_STARTING,
'Running': res_cons.TASK_STATE_RUNNING,
'Suspended': res_cons.TASK_STATE_SUSPENDED,
'Interrupted': res_cons.TASK_STATE_INTERRUPTED,
'Pending': res_cons.TASK_STATE_PENDING,
'Stopping': res_cons.TASK_STATE_STOPPING,
'Completed': res_cons.TASK_STATE_COMPLETED,
'Killed': res_cons.TASK_STATE_KILLED,
'Exception': res_cons.TASK_STATE_EXCEPTION,
'Service': res_cons.TASK_STATE_SERVICE,
'Cancelling': res_cons.TASK_STATE_CANCELLING,
'Cancelled': res_cons.TASK_STATE_CANCELLED
}

View File

@@ -0,0 +1,89 @@
# Copyright (c) 2020 Dell, Inc. or its subsidiaries
#
# 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.
# This is referred from Redfish standard schema.
# https://redfish.dmtf.org/schemas/Task.v1_4_3.json
from http import client as http_client
import logging
from sushy.resources import base
from sushy.resources import mappings as res_maps
from sushy.resources.registry import message_registry
from sushy.resources.taskservice import mappings as task_maps
from sushy import utils
LOG = logging.getLogger(__name__)
class Task(base.ResourceBase):
identity = base.Field('Id', required=True)
"""The Task identity"""
name = base.Field('Name', required=True)
"""The Task name"""
description = base.Field('Description')
"""The Task description"""
task_monitor = base.Field('TaskMonitor')
"""An opaque URL that the client can use to monitor an asynchronous
operation"""
start_time = base.Field('StartTime')
"""Start time of the Task"""
end_time = base.Field('EndTime')
"""End time of the Task"""
percent_complete = base.Field('PercentComplete', adapter=utils.int_or_none)
"""Percentage complete of the Task"""
task_state = base.MappedField('TaskState', task_maps.TASK_STATE_VALUE_MAP)
"""The Task state"""
task_status = base.MappedField('TaskStatus', res_maps.HEALTH_VALUE_MAP)
"""The Task status"""
messages = base.MessageListField("Messages")
"""List of :class:`.MessageListField` with messages from the Task"""
def __init__(self, connector, identity, redfish_version=None,
registries=None, json_doc=None):
"""A class representing a Task
:param connector: A Connector instance
:param identity: The identity of the task
:param redfish_version: The version of RedFish. Used to construct
the object according to schema of the given version.
:param registries: Dict of Redfish Message Registry objects to be
used in any resource that needs registries to parse messages
:param field_data: the data to use populating the fields
"""
super(Task, self).__init__(
connector, identity, redfish_version, registries,
json_doc=json_doc)
@property
def is_processing(self):
"""Indicates if the Task is processing"""
return self.status_code == http_client.ACCEPTED
def parse_messages(self):
"""Parses the messages"""
for m in self.messages:
message_registry.parse_message(self._registries, m)

View File

@@ -0,0 +1,143 @@
# Copyright (c) 2020 Dell, Inc. or its subsidiaries
#
# 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.
# This is referred from Redfish standard schema.
# https://redfish.dmtf.org/schemas/Task.v1_4_3.json
from http import client as http_client
from sushy.resources import base
from sushy.resources.taskservice import task
from sushy import utils
class TaskMonitor(object):
def __init__(self,
connector,
task_monitor,
redfish_version=None,
registries=None,
field_data=None):
"""A class representing a task monitor
:param connector: A Connector instance
:param task_monitor: The task monitor
:param retry_after: The amount of time to wait in seconds before
calling is_processing.
:param redfish_version: The version of RedFish. Used to construct
the object according to schema of the given version.
:param registries: Dict of Redfish Message Registry objects to be
used in any resource that needs registries to parse messages.
"""
self._connector = connector
self._task_monitor = task_monitor
self._redfish_version = redfish_version
self._registries = registries
self._field_data = field_data
self._reader = base.get_reader(connector, task_monitor)
self._task = None
if self._field_data:
# If a body was returned, assume it's a Task on a 202 status code
content_length = int(self._field_data.headers.get(
'Content-Length'))
if (self._field_data.status_code == http_client.ACCEPTED
and content_length > 0):
self._task = task.Task(self._connector, self._task_monitor,
redfish_version=self._redfish_version,
registries=self._registries,
json_doc=self._field_data.json_doc)
else:
self.refresh()
def refresh(self):
"""Refresh the Task
Freshly retrieves/fetches the Task.
:raises: ResourceNotFoundError
:raises: ConnectionError
:raises: HTTPError
"""
self._field_data = self._reader.get_data()
if self._field_data.status_code == http_client.ACCEPTED:
# A Task should have been returned, but wasn't
if int(self._field_data.headers.get('Content-Length')) == 0:
self._task = None
return
# Assume that the body contains a Task since we got a 202
if not self._task:
self._task = task.Task(self._connector, self._task_monitor,
redfish_version=self._redfish_version,
registries=self._registries,
json_doc=self._field_data.json_doc)
else:
self._task.refresh(json_doc=self._field_data.json_doc)
else:
self._task = None
@property
def task_monitor(self):
"""The TaskMonitor URI
:returns: The TaskMonitor URI.
"""
return self._task_monitor
@property
def is_processing(self):
"""Indicates if the task is still processing
:returns: A boolean indicating if the task is still processing.
"""
return self._field_data.status_code == http_client.ACCEPTED
@property
def retry_after(self):
"""The amount of time to sleep before retrying
:returns: The amount of time in seconds to wait before calling
is_processing.
"""
return utils.int_or_none(self._field_data.headers.get('Retry-After'))
@property
def cancellable(self):
"""The amount of time to sleep before retrying
:returns: A Boolean indicating if the Task is cancellable.
"""
allow = self._field_data.headers.get('Allow')
cancellable = False
if allow and allow.upper() == 'DELETE':
cancellable = True
return cancellable
@property
def task(self):
"""The executing task
:returns: The Task being executed.
"""
return self._task
def get_task(self):
return task.Task(self._connector, self._task_monitor,
redfish_version=self._redfish_version,
registries=self._registries)

View File

@@ -19,6 +19,7 @@ import logging
from sushy import exceptions
from sushy.resources import base
from sushy.resources import common
from sushy.resources.taskservice import taskmonitor
from sushy.resources.updateservice import constants as up_cons
from sushy.resources.updateservice import mappings as up_maps
from sushy.resources.updateservice import softwareinventory
@@ -115,7 +116,10 @@ class UpdateService(base.ResourceBase):
def simple_update(self, image_uri, targets=None,
transfer_protocol=up_cons.UPDATE_PROTOCOL_HTTP):
"""Simple Update is used to update software components"""
"""Simple Update is used to update software components.
:returns: A task monitor.
"""
valid_transfer_protocols = self.get_allowed_transfer_protocols()
if transfer_protocol in valid_transfer_protocols:
@@ -144,7 +148,33 @@ class UpdateService(base.ResourceBase):
data = {'ImageURI': image_uri, 'TransferProtocol': transfer_protocol}
if targets:
data['Targets'] = targets
self._conn.post(target_uri, data=data)
rsp = self._conn.post(target_uri, data=data)
json_data = rsp.json() if rsp.content else {}
field_data = base.FieldData(rsp.status_code, rsp.headers, json_data)
header = 'Location'
task_monitor = rsp.headers.get(header)
if not task_monitor:
raise exceptions.MissingHeaderError(target_uri=target_uri,
header=header)
return taskmonitor.TaskMonitor(self._conn,
task_monitor,
redfish_version=self.redfish_version,
registries=self.registries,
field_data=field_data)
def get_task_monitor(self, task_monitor):
"""Used to retrieve a TaskMonitor.
:returns: A task monitor.
"""
return taskmonitor.TaskMonitor(
self._conn,
task_monitor,
redfish_version=self.redfish_version,
registries=self.registries)
@property
@utils.cache_it

View File

@@ -0,0 +1,26 @@
{
"@odata.type":"#Task.v1_4_3.Task",
"Id":"545",
"Name":"Task 545",
"Description": "Task description",
"TaskMonitor":"/taskmon/545",
"TaskState":"Completed",
"StartTime":"2012-03-07T14:44+06:00",
"EndTime":"2012-03-07T14:45+06:00",
"TaskStatus":"OK",
"PercentComplete": 100,
"Messages":[
{
"MessageId":"Base.1.0.PropertyNotWriteable",
"RelatedProperties":[
"SKU"
],
"Message":"Property %1 is read only.",
"MessageArgs":[
"SKU"
],
"Severity":"Warning"
}
],
"@odata.id":"/redfish/v1/TaskService/Tasks/545"
}

View File

@@ -17,9 +17,9 @@ import json
from unittest import mock
from sushy.resources import base as sushy_base
from sushy.resources import constants as res_cons
from sushy.resources.registry import message_registry
from sushy.resources import settings
from sushy.tests.unit import base
@@ -99,7 +99,7 @@ class MessageRegistryTestCase(base.TestCase):
conn, '/redfish/v1/Registries/Test',
redfish_version='1.0.2')
registries = {'Test.1.0.0': registry}
message_field = settings.MessageListField('Foo')
message_field = sushy_base.MessageListField('Foo')
message_field.message_id = 'Test.1.0.0.TooBig'
message_field.message_args = ['arg1', 10]
message_field.severity = None
@@ -120,7 +120,7 @@ class MessageRegistryTestCase(base.TestCase):
conn, '/redfish/v1/Registries/Test',
redfish_version='1.0.2')
registries = {'Test.1.0.0': registry}
message_field = settings.MessageListField('Foo')
message_field = sushy_base.MessageListField('Foo')
message_field.message_id = 'Test.1.0.0.Success'
message_field.severity = res_cons.SEVERITY_OK
message_field.resolution = 'Do nothing'

View File

@@ -16,6 +16,7 @@
import json
from unittest import mock
from sushy.resources.base import FieldData
from sushy.resources.registry import message_registry_file
from sushy.tests.unit import base
@@ -59,9 +60,9 @@ class MessageRegistryFileTestCase(base.TestCase):
def test_get_message_registry_uri(self, mock_reader, mock_msg_reg):
mock_reader_rv = mock.Mock()
mock_reader.return_value = mock_reader_rv
mock_reader_rv.get_json.return_value = {
mock_reader_rv.get_data.return_value = FieldData(200, {}, {
"@odata.type": "#MessageRegistry.v1_1_1.MessageRegistry",
}
})
mock_msg_reg_rv = mock.Mock()
mock_msg_reg.return_value = mock_msg_reg_rv
@@ -78,9 +79,9 @@ class MessageRegistryFileTestCase(base.TestCase):
mock_reader_rv = mock.Mock()
mock_reader.return_value = mock_reader_rv
mock_msg_reg_rv = mock.Mock()
mock_reader_rv.get_json.return_value = {
mock_reader_rv.get_data.return_value = FieldData(200, {}, {
"@odata.type": "#MessageRegistry.v1_1_1.MessageRegistry",
}
})
mock_msg_reg.return_value = mock_msg_reg_rv
self.reg_file.location[0].uri = None
@@ -100,9 +101,9 @@ class MessageRegistryFileTestCase(base.TestCase):
mock_reader_rv = mock.Mock()
mock_reader.return_value = mock_reader_rv
mock_msg_reg_rv = mock.Mock()
mock_reader_rv.get_json.return_value = {
mock_reader_rv.get_data.return_value = FieldData(200, {}, {
"@odata.type": "#MessageRegistry.v1_1_1.MessageRegistry",
}
})
mock_msg_reg.return_value = mock_msg_reg_rv
self.reg_file.location[0].uri = None
self.reg_file.location[0].archive_uri = None

View File

@@ -0,0 +1,74 @@
# 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 http import client as http_client
import json
from unittest import mock
from sushy.resources import constants as res_cons
from sushy.resources.taskservice import task
from sushy.tests.unit import base
class TaskTestCase(base.TestCase):
def setUp(self):
super(TaskTestCase, self).setUp()
self.conn = mock.Mock()
with open('sushy/tests/unit/json_samples/task.json') as f:
self.json_doc = json.load(f)
self.conn.get.return_value.json.return_value = self.json_doc
message_registry = mock.Mock()
message = mock.Mock()
message.message = "Property %1 is read only."
message.number_of_args = 1
message_registry.messages = {"PropertyNotWriteable": message}
self.task = task.Task(
self.conn, '/redfish/v1/TaskService/Tasks/545',
redfish_version='1.4.3',
registries={'Base.1.0': message_registry})
def test__parse_attributes(self):
self.task._parse_attributes(self.json_doc)
self.assertEqual('545', self.task.identity)
self.assertEqual('Task 545', self.task.name)
self.assertEqual('Task description', self.task.description)
self.assertEqual('/taskmon/545', self.task.task_monitor)
self.assertEqual('2012-03-07T14:44+06:00', self.task.start_time)
self.assertEqual('2012-03-07T14:45+06:00', self.task.end_time)
self.assertEqual(100, self.task.percent_complete)
self.assertEqual(res_cons.TASK_STATE_COMPLETED, self.task.task_state)
self.assertEqual(res_cons.HEALTH_OK, self.task.task_status)
self.assertEqual(1, len(self.task.messages))
self.assertEqual('Base.1.0.PropertyNotWriteable',
self.task.messages[0].message_id)
self.assertEqual('Property %1 is read only.',
self.task.messages[0].message)
self.assertEqual(res_cons.SEVERITY_WARNING,
self.task.messages[0].severity)
def test_is_processing_true(self):
self.task.status_code = http_client.ACCEPTED
self.assertTrue(self.task.is_processing)
def test_is_processing_false(self):
self.task.status_code = http_client.OK
self.assertFalse(self.task.is_processing)
def test_parse_messages(self):
self.task.parse_messages()
self.assertEqual('Property SKU is read only.',
self.task.messages[0].message)

View File

@@ -0,0 +1,167 @@
# 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 http import client as http_client
import json
from unittest import mock
from sushy.resources import base as resource_base
from sushy.resources.taskservice import task
from sushy.resources.taskservice import taskmonitor
from sushy.tests.unit import base
class TaskMonitorTestCase(base.TestCase):
def setUp(self):
super(TaskMonitorTestCase, self).setUp()
self.conn = mock.Mock()
with open('sushy/tests/unit/json_samples/task.json') as f:
self.json_doc = json.load(f)
self.conn.get.return_value.json.return_value = self.json_doc
self.field_data = resource_base.FieldData(
http_client.ACCEPTED,
{'Content-Length': 42,
'Location': '/Task/545',
'Retry-After': 20,
'Allow': 'DELETE'},
self.json_doc)
self.task_monitor = taskmonitor.TaskMonitor(
self.conn, '/Task/545',
field_data=self.field_data
)
def test_init_accepted_no_content(self):
field_data = resource_base.FieldData(
http_client.ACCEPTED,
{'Content-Length': 0,
'Location': '/Task/545',
'Retry-After': 20,
'Allow': 'DELETE'},
None)
task_monitor = taskmonitor.TaskMonitor(
self.conn, '/Task/545',
field_data=field_data)
self.assertIsNone(task_monitor.task)
def test_init_accepted_content(self):
self.assertIsNotNone(self.task_monitor._task)
def test_init_no_field_data(self):
self.conn.reset_mock()
self.conn.get.return_value.status_code = 202
self.conn.get.return_value.headers = {'Content-Length': 42}
task_monitor = taskmonitor.TaskMonitor(self.conn, '/Task/545')
self.conn.get.assert_called_with(path='/Task/545')
self.assertEqual(1, self.conn.get.call_count)
self.assertIsNotNone(task_monitor._task)
def test_refresh_no_content(self):
self.conn.reset_mock()
self.conn.get.return_value.status_code = 202
self.conn.get.return_value.headers = {'Content-Length': 0}
self.task_monitor.refresh()
self.conn.get.assert_called_with(path='/Task/545')
self.assertEqual(1, self.conn.get.call_count)
self.assertIsNone(self.task_monitor._task)
def test_refresh_content_no_task(self):
self.conn.reset_mock()
self.conn.get.return_value.status_code = 202
self.conn.get.return_value.headers = {'Content-Length': 42}
self.task_monitor._task = None
self.task_monitor.refresh()
self.conn.get.assert_called_with(path='/Task/545')
self.assertEqual(1, self.conn.get.call_count)
self.assertIsNotNone(self.task_monitor._task)
def test_refresh_content_task(self):
self.conn.reset_mock()
self.conn.get.return_value.status_code = 202
self.conn.get.return_value.headers = {'Content-Length': 42}
self.task_monitor.refresh()
self.conn.get.assert_called_with(path='/Task/545')
self.assertEqual(1, self.conn.get.call_count)
self.assertIsNotNone(self.task_monitor._task)
def test_refresh_done(self):
self.conn.reset_mock()
self.conn.get.return_value.status_code = 200
self.task_monitor.refresh()
self.conn.get.assert_called_once_with(path='/Task/545')
self.assertIsNone(self.task_monitor._task)
def test_task_monitor(self):
self.assertEqual('/Task/545', self.task_monitor.task_monitor)
def test_is_processing(self):
self.assertTrue(self.task_monitor.is_processing)
def test_retry_after(self):
self.assertEqual(20, self.task_monitor.retry_after)
def test_cancellable(self):
self.assertTrue(self.task_monitor.cancellable)
def test_not_cancellable_no_header(self):
field_data = resource_base.FieldData(
http_client.ACCEPTED,
{'Content-Length': 42,
'Location': '/Task/545',
'Retry-After': 20},
self.json_doc)
task_monitor = taskmonitor.TaskMonitor(
self.conn, '/Task/545',
field_data=field_data
)
self.assertFalse(task_monitor.cancellable)
def test_not_cancellable(self):
field_data = resource_base.FieldData(
http_client.ACCEPTED,
{'Content-Length': 42,
'Location': '/Task/545',
'Retry-After': 20,
'Allow': 'GET'},
self.json_doc)
task_monitor = taskmonitor.TaskMonitor(
self.conn, '/Task/545',
field_data=field_data
)
self.assertFalse(task_monitor.cancellable)
def test_task(self):
tm_task = self.task_monitor.task
self.assertIsInstance(tm_task, task.Task)
self.assertEqual('545', tm_task.identity)

View File

@@ -16,6 +16,7 @@ from unittest import mock
from sushy import exceptions
from sushy.resources import constants as res_cons
from sushy.resources.taskservice import taskmonitor
from sushy.resources.updateservice import constants as ups_cons
from sushy.resources.updateservice import softwareinventory
from sushy.resources.updateservice import updateservice
@@ -57,10 +58,23 @@ class UpdateServiceTestCase(base.TestCase):
self.upd_serv._parse_attributes, self.json_doc)
def test_simple_update(self):
self.upd_serv.simple_update(
with open('sushy/tests/unit/json_samples/task.json') as f:
task_json = json.load(f)
task_submitted = mock.Mock()
task_submitted.json.return_value = task_json
task_submitted.status_code = 202
task_submitted.headers = {'Content-Length': 42,
'Location': '/Task/545'}
self.conn.post.return_value = task_submitted
tm = self.upd_serv.simple_update(
image_uri='local.server/update.exe',
targets=['/redfish/v1/UpdateService/FirmwareInventory/BMC'],
transfer_protocol=ups_cons.UPDATE_PROTOCOL_HTTPS)
self.assertIsInstance(tm, taskmonitor.TaskMonitor)
self.assertEqual('/Task/545', tm.task_monitor)
self.upd_serv._conn.post.assert_called_once_with(
'/redfish/v1/UpdateService/Actions/SimpleUpdate',
data={
@@ -68,7 +82,32 @@ class UpdateServiceTestCase(base.TestCase):
'Targets': ['/redfish/v1/UpdateService/FirmwareInventory/BMC'],
'TransferProtocol': 'HTTPS'})
def test_simple_update_missing_location(self):
with open('sushy/tests/unit/json_samples/task.json') as f:
task_json = json.load(f)
task_submitted = mock.Mock()
task_submitted.json.return_value = task_json
task_submitted.status_code = 202
task_submitted.headers = {'Allow': 'GET'}
self.conn.post.return_value = task_submitted
self.assertRaises(
exceptions.MissingHeaderError,
self.upd_serv.simple_update,
image_uri='local.server/update.exe',
targets='/redfish/v1/UpdateService/Actions/SimpleUpdate',
transfer_protocol='HTTPS')
def test_simple_update_backward_compatible_protocol(self):
with open('sushy/tests/unit/json_samples/task.json') as f:
task_json = json.load(f)
task_submitted = mock.Mock()
task_submitted.json.return_value = task_json
task_submitted.status_code = 202
task_submitted.headers = {'Content-Length': 42,
'Location': '/Task/545'}
self.conn.post.return_value = task_submitted
self.upd_serv.simple_update(
image_uri='local.server/update.exe',
targets='/redfish/v1/UpdateService/Actions/SimpleUpdate',
@@ -81,6 +120,14 @@ class UpdateServiceTestCase(base.TestCase):
'TransferProtocol': 'HTTPS'})
def test_simple_update_without_target(self):
with open('sushy/tests/unit/json_samples/task.json') as f:
task_json = json.load(f)
task_submitted = mock.Mock()
task_submitted.json.return_value = task_json
task_submitted.status_code = 202
task_submitted.headers = {'Content-Length': 42,
'Location': '/Task/545'}
self.conn.post.return_value = task_submitted
self.upd_serv.simple_update(
image_uri='local.server/update.exe',
transfer_protocol='HTTPS')

View File

@@ -44,6 +44,12 @@ class UtilsTestCase(base.TestCase):
self.assertEqual(1, utils.int_or_none('1'))
self.assertIsNone(None, utils.int_or_none(None))
def test_bool_or_none_none(self):
self.assertIsNone(utils.bool_or_none(None))
def test_bool_or_none_bool(self):
self.assertEqual(True, utils.bool_or_none(True))
def setUp(self):
super(UtilsTestCase, self).setUp()
self.conn = mock.MagicMock()

View File

@@ -66,6 +66,18 @@ def int_or_none(x):
return int(x)
def bool_or_none(x):
"""Given a value x this method returns either a bool or None
:param x: The value to transform and return
:returns: Either None or x cast to a bool
"""
if x is None:
return None
return bool(x)
def get_sub_resource_path_by(resource, subresource_name, is_collection=False):
"""Helper function to find the subresource path