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:
@@ -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.
|
@@ -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"""
|
||||
|
||||
|
@@ -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()
|
||||
|
||||
|
@@ -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__,
|
||||
|
@@ -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'
|
||||
|
@@ -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"""
|
||||
|
||||
|
33
sushy/resources/taskservice/mappings.py
Normal file
33
sushy/resources/taskservice/mappings.py
Normal 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
|
||||
}
|
89
sushy/resources/taskservice/task.py
Normal file
89
sushy/resources/taskservice/task.py
Normal 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)
|
143
sushy/resources/taskservice/taskmonitor.py
Normal file
143
sushy/resources/taskservice/taskmonitor.py
Normal 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)
|
@@ -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
|
||||
|
26
sushy/tests/unit/json_samples/task.json
Normal file
26
sushy/tests/unit/json_samples/task.json
Normal 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"
|
||||
}
|
@@ -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'
|
||||
|
@@ -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
|
||||
|
0
sushy/tests/unit/resources/taskservice/__init__.py
Normal file
0
sushy/tests/unit/resources/taskservice/__init__.py
Normal file
74
sushy/tests/unit/resources/taskservice/test_task.py
Normal file
74
sushy/tests/unit/resources/taskservice/test_task.py
Normal 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)
|
167
sushy/tests/unit/resources/taskservice/test_taskmonitor.py
Normal file
167
sushy/tests/unit/resources/taskservice/test_taskmonitor.py
Normal 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)
|
@@ -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')
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user