Source code for f5.bigip.resource

# coding=utf-8
#
# Copyright 2015-2016 F5 Networks 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.
#

"""This module provides classes that specify how RESTful resources are handled.

THE MOST IMPORTANT THING TO KNOW ABOUT THIS API IS THAT YOU CAN DIRECTLY INFER
REST URIs FROM PYTHON EXPRESSIONS, AND VICE VERSA.

Examples:

 * Expression:     bigip = ManagementRoot('a', 'b', 'c')
 * URI Returned:   https://a/mgmt/

 * Expression:     bigip.tm.ltm
 * URI Returned:   https://a/mgmt/tm/ltm/

 * Expression:     pools1 = bigip.tm.ltm.pools
 * URI Returned:   https://a/mgmt/tm/ltm/pool

 * Expression:     pool_a = pools1.create(partition="Common", name="foo")
 * URI Returned:   https://a/mgmt/tm/ltm/pool/~Common~foo

There are different types of resources published by the BIG-IP® REST Server,
they are represented by the classes in this module.

We refer to a server-provided resource as a "service".  Thus far all URI
referenced resources are "services" in this sense.

We use methods named Create, Refresh, Update, Load, Modify, and Delete to
manipulate BIG-IP® device services.

Methods:

  * create -- uses HTTP POST, creates a new resource and with its own URI on
    the device
  * refresh -- uses HTTP GET, obtains the state of a device resource, and sets
    the representing Python Resource Object tracks device state via its attrs
  * update -- uses HTTP PUT, submits a new configuration to the device resource
     and sets the Resource attrs to the state the device reports
  * load -- uses HTTP GET, obtains the state of an existing resource on the
    device and sets the Resource attrs to that state
  * modify -- uses HTTP PATCH to selectively modify named resources submitted
    as keyword arguments
  * delete -- uses HTTP DELETE, removes the resource from the device, and sets
    self.__dict__ to {'deleted': True}

Available Classes:
  * PathElement -- the most fundamental class it represent URI elements that
    serve only as place-holders.  All other Resources inherit from
    PathElement, though the inheritance may be indirect. PathElement provides
    a constructor to match its call in LazyAttributeMixin.__getattr__. The
    expected behavior is that all resource subclasses depend on this
    constructor to correctly set their self._meta_data['uri'].  See
    _set_meta_data_uri for the logic underlying self._meta_data['uri']
    construction.
  * ResourceBase -- only `refresh` is generally supported in all resource
    types, this class provides `refresh`. ResourceBase objects are usually
    instantiated via setting lazy attributes.
    All ResourceBase objects (except BIG-IPs) have a container (BIG-IPs
    contain themselves).  The container is the object the ResourceBase is an
    attribute of.
  * OrganizingCollection -- These resources support lists of "reference"
    "links". These are json blobs without a Python class representation.
    Example URI_path:  /mgmt/tm/ltm/
  * Collection -- These resources support lists of ResourceBase Objects.
    Example URI_path:  /mgmt/tm/ltm/nat
  * Resource -- These resources are the only resources that support
    `create`, `update`, and `delete` operations.  Because they support HTTP
    post (via _create) they uniquely depend on 2 uri's, a uri that supports
    the creating post, and the returned uri of the newly created resource.
    Example URI_path:  /mgmt/tm/ltm/nat/~Common~testnat1
  * UnnamedResource -- Some resources correspond to URIs that do not have
    unique names, therefore the class does _not_ support create-or-delete,
    and supports a customized 'load' that doesn't require name/partition
    parameters.
"""
try:
    from collections import OrderedDict
except ImportError:
    try:
        from ordereddict import OrderedDict
    except ImportError as exc:
        message = ("Maybe you're using Python < 2.7 and do not have the "
                   "orderreddict external dependency installed.")
        raise exc(message)
import copy
import keyword
import re
import time
import tokenize
try:
    import urlparse
except ImportError:
    from urllib import parse as urlparse

from f5.bigip.mixins import LazyAttributeMixin
from f5.bigip.mixins import ToDictMixin
from f5.sdk_exception import AttemptedMutationOfReadOnly
from f5.sdk_exception import BooleansToReduceHaveSameValue
from f5.sdk_exception import DeviceProvidesIncompatibleKey
from f5.sdk_exception import ExclusiveAttributesPresent
from f5.sdk_exception import GenerationMismatch
from f5.sdk_exception import InvalidForceType
from f5.sdk_exception import InvalidResource
from f5.sdk_exception import KindTypeMismatch
from f5.sdk_exception import MissingRequiredCommandParameter
from f5.sdk_exception import MissingRequiredCreationParameter
from f5.sdk_exception import MissingRequiredReadParameter
from f5.sdk_exception import MissingRequiredRequestsParameter
from f5.sdk_exception import RequestParamKwargCollision
from f5.sdk_exception import UnregisteredKind
from f5.sdk_exception import UnsupportedMethod
from f5.sdk_exception import UnsupportedOperation
from f5.sdk_exception import URICreationCollision
from icontrol.exceptions import iControlUnexpectedHTTPError
from requests.exceptions import ConnectionError
from requests.exceptions import HTTPError
from six import iteritems
from six import iterkeys
from six import itervalues


def _missing_required_parameters(rqset, **kwargs):
    """Helper function to do operation on sets.

    Checks for any missing required parameters.
    Returns non-empty or empty list. With empty
    list being False.

    ::returns list
    """
    key_set = set(list(iterkeys(kwargs)))
    required_minus_received = rqset - key_set
    if required_minus_received != set():
        return list(required_minus_received)


[docs]class PathElement(LazyAttributeMixin): """Base class to represent a URI path element that does not contain data. The BIG-IP® iControl REST API has URIs that are made up of path components that do not return data when they are queried. This class represents those elements and does not support any of the CURDLE methods that the other objects do. """ def __init__(self, container): self._meta_data = { 'container': container, 'bigip': container._meta_data['bigip'], 'icr_session': container._meta_data['icr_session'], 'icontrol_version': container._meta_data['icontrol_version'] } self._set_meta_data_uri() # Supported versions for each class will be defined here. # List can be modified downstream in each sub-class self._meta_data['minimum_version'] = '11.5.0' # Commands you can run on a resource or collection, we define it here self._meta_data['allowed_commands'] = [] # Define required command parameters self._meta_data['required_command_parameters'] = set() # You can't have more than one of the attributes in any of these sets. self._meta_data['exclusive_attributes'] = [] # As some objects do not support Stats we need this setting, # by default Stats are enabled on all endpoints. Override in # subclass as required self._meta_data['object_has_stats'] = True # Some endpoints have mandatory parameters that can be one of x, # this means that all or at least 1 need to be present during create self._meta_data['minimum_additional_parameters'] = set() def _set_meta_data_uri(self): base_uri = self._get_base_uri() endpoint = base_uri.replace('_', '-') final_uri = self._build_final_uri(endpoint) self._meta_data['uri'] = final_uri def _get_base_uri(self): if isinstance(self, Collection): return self._format_collection_name() return self._format_resource_name() def _build_final_uri(self, endpoint): return self._meta_data['container']._meta_data['uri'] + endpoint + '/' def _format_collection_name(self): """Formats a name from Collection format Collections are of two name formats based on their actual URI representation in the REST service. 1. For cases where the actual URI of a collection is singular, for example, /mgmt/tm/ltm/node The name of the collection, as exposed to the user, will be made plural. For example, mgmt.tm.ltm.nodes 2. For cases where the actual URI of a collection is plural, for example, /mgmt/cm/shared/licensing/pools/ The name of the collection, as exposed to the user, will remain plural, but will have an `_s` appended to it. For example, mgmt.cm.shared.licensing.pools_s This method is responsible for undoing the user provided plurality. It ensures that the URI that is being sent to the REST service is correctly plural, or plural plus. Returns: A string representation of the user formatted Collection with its plurality identifier removed appropriately. """ base_uri = self._format_resource_name() if base_uri[-2:] == '_s': endind = 2 else: endind = 1 return base_uri[:-endind] def _format_resource_name(self): """Formats the name of a Resource When the names of Resources are used to create URIs, their names are automatically cast to lowercase to match the actual URI expected by the REST service. There are certain URIs, however, that must not be cast in this way. For these URIs, this method should be overloaded in the Resource class itself to specify how to handle the Resource name. An example of this difference in behavior, refer to the BIG-IQ or iWorkflow APIs such as, /mgmt/shared/resolver/device-groups/cm-bigip-allBigIpDevices/ Returns: A string representation of the Resource as it should be represented when contructing the final URI used to reach that Resource. """ return self.__class__.__name__.lower() def _check_command_parameters(self, **kwargs): """Params given to exec_cmd should satisfy required params. :params: kwargs :raises: MissingRequiredCommandParameter """ rset = self._meta_data['required_command_parameters'] check = _missing_required_parameters(rset, **kwargs) if check: error_message = 'Missing required params: %s' % check raise MissingRequiredCommandParameter(error_message) def _check_force_arg(self, force): if not isinstance(force, bool): raise InvalidForceType("force parameter must be type bool") return force def _check_generation(self): """Check that the generation on the BIG-IP® matches the object This will do a get to the objects URI and check that the generation returned in the JSON matches the one the object currently has. If it does not it will raise the `GenerationMismatch` exception. """ session = self._meta_data['bigip']._meta_data['icr_session'] response = session.get(self._meta_data['uri']) current_gen = response.json().get('generation', None) if current_gen is not None and current_gen != self.generation: error_message = ("The generation of the object on the BigIP " + "(" + str(current_gen) + ")" + " does not match the current object" + "(" + str(self.generation) + ")") raise GenerationMismatch(error_message) def _handle_requests_params(self, kwargs): """Validate parameters that will be passed to the requests verbs. This method validates that there is no conflict in the names of the requests_params passed to the function and the other kwargs. It also ensures that the required request parameters for the object are added to the request params that are passed into the verbs. An example of the latter is ensuring that a certain version of the API is always called to add 'ver=11.6.0' to the url. """ requests_params = kwargs.pop('requests_params', {}) for param in requests_params: if param in kwargs: error_message = 'Requests Parameter %r collides with a load'\ ' parameter of the same name.' % param raise RequestParamKwargCollision(error_message) # If we have an icontrol version we need to add 'ver' to params if self._meta_data['icontrol_version']: params = requests_params.pop('params', {}) params.update({'ver': self._meta_data['icontrol_version']}) requests_params.update({'params': params}) return requests_params def _check_exclusive_parameters(self, **kwargs): """Check for mutually exclusive attributes in kwargs. :raises ExclusiveAttributesPresent """ if len(self._meta_data['exclusive_attributes']) > 0: attr_set = set(list(iterkeys(kwargs))) ex_set = set(self._meta_data['exclusive_attributes'][0]) common_set = sorted(attr_set.intersection(ex_set)) if len(common_set) > 1: cset = ', '.join(common_set) error = 'Mutually exclusive arguments submitted. ' \ 'The following arguments cannot be set ' \ 'together: "%s".' % cset raise ExclusiveAttributesPresent(error) @property def raw(self): """Display the attributes that the current object has and their values. :returns: A dictionary of attributes and their values """ return self.__dict__
[docs]class ResourceBase(PathElement, ToDictMixin): """Base class for all BIG-IP® iControl REST API endpoints. The BIG-IP® is represented by an object that converts device-published uri's into Python objects. Each uri maps to a Python object. The mechanism for instantiating these objects is the __getattr__ Special Function in the LazyAttributeMixin. When a registered attribute is `dot` referenced, on the device object (e.g. ``bigip.ltm`` or simply ``bigip``), an appropriate object is instantiated and attributed to the referencing object: .. code-block:: python bigip.ltm = LTM(bigip) bigip.ltm.nats nat1 = bigip.ltm.nats.nat.create('Foo', 'Bar', '0.1.2.3', '1.2.3.4') This can be shortened to just the last line: .. code-block:: python nat1 = bigip.ltm.nats.nat.create('Foo', 'Bar', '0.1.2.3', '1.2.3.4') Critically this enforces a convention relating device published uris to API objects, in a hierarchy similar to the uri paths. I.E. the uri corresponding to a ``Nats`` object is ``mgmt/tm/ltm/nat/``. If you query the BIG-IP's uri (e.g. print(bigip._meta_data['uri']) ), you'll see that it ends in: ``/mgmt/tm/``, if you query the ``ltm`` object's uri (e.g. print(bigip.ltm._meta_data['uri']) ) you'll see it ends in ``/mgmt/tm/ltm/``. In general the objects build a required `self._meta_data['uri']` attribute by: 1. Inheriting this class. 2. calling super(Subclass, self).__init__(container) 3. self.uri = self.container_uri['uri'] + '/' + self.__class__.__name__ The net result is a succinct mapping between uri's and objects, that represents objects in a hierarchical relationship similar to the device's uri path hierarchy. """ def _modify(self, **patch): """Wrapped with modify, override in a subclass to customize.""" requests_params, patch_uri, session, read_only = \ self._prepare_put_or_patch(patch) self._check_for_boolean_pair_reduction(patch) read_only_mutations = [] for attr in read_only: if attr in patch: read_only_mutations.append(attr) if read_only_mutations: msg = 'Attempted to mutate read-only attribute(s): %s' \ % read_only_mutations raise AttemptedMutationOfReadOnly(msg) patch = self._prepare_request_json(patch) response = session.patch(patch_uri, json=patch, **requests_params) self._local_update(response.json())
[docs] def modify(self, **patch): """Modify the configuration of the resource on device based on patch """ self._modify(**patch)
def _check_for_boolean_pair_reduction(self, kwargs): """Check if boolean pairs should be reduced in this resource.""" if 'reduction_forcing_pairs' in self._meta_data: for key1, key2 in self._meta_data['reduction_forcing_pairs']: kwargs = self._reduce_boolean_pair(kwargs, key1, key2) return kwargs def _prepare_put_or_patch(self, kwargs): """Retrieve the appropriate request items for put or patch calls.""" requests_params = self._handle_requests_params(kwargs) update_uri = self._meta_data['uri'] session = self._meta_data['bigip']._meta_data['icr_session'] read_only = self._meta_data.get('read_only_attributes', []) return requests_params, update_uri, session, read_only def _prepare_request_json(self, kwargs): """Prepare request args for sending to device as JSON.""" # Check for python keywords in dict kwargs = self._check_for_python_keywords(kwargs) # Check for the key 'check' in kwargs if 'check' in kwargs: od = OrderedDict() od['check'] = kwargs['check'] kwargs.pop('check') od.update(kwargs) return od return kwargs def _iter_list_for_dicts(self, check_list): """Iterate over list to find dicts and check for python keywords.""" list_copy = copy.deepcopy(check_list) for index, elem in enumerate(check_list): if isinstance(elem, dict): list_copy[index] = self._check_for_python_keywords(elem) elif isinstance(elem, list): list_copy[index] = self._iter_list_for_dicts(elem) else: list_copy[index] = elem return list_copy def _check_for_python_keywords(self, kwargs): """When Python keywords seen, mutate to remove trailing underscore.""" kwargs_copy = copy.deepcopy(kwargs) for key, val in iteritems(kwargs): if isinstance(val, dict): kwargs_copy[key] = self._check_for_python_keywords(val) elif isinstance(val, list): kwargs_copy[key] = self._iter_list_for_dicts(val) else: if key.endswith('_'): strip_key = key.rstrip('_') if keyword.iskeyword(strip_key): kwargs_copy[strip_key] = val kwargs_copy.pop(key) return kwargs_copy def _check_keys(self, rdict): """Call this from _local_update to validate response keys disallowed server-response json keys: 1. The string-literal '_meta_data' 2. strings that are not valid Python 2.7 identifiers 3. strings beginning with '__'. :param rdict: from response.json() :raises: DeviceProvidesIncompatibleKey :returns: checked response rdict """ if '_meta_data' in rdict: error_message = "Response contains key '_meta_data' which is "\ "incompatible with this API!!\n Response json: %r" % rdict raise DeviceProvidesIncompatibleKey(error_message) for x in rdict: if not re.match(tokenize.Name, x): error_message = "Device provided %r which is disallowed"\ " because it's not a valid Python 2.7 identifier." % x raise DeviceProvidesIncompatibleKey(error_message) elif keyword.iskeyword(x): # If attribute is keyword, append underscore to attribute name rdict[x + '_'] = rdict[x] rdict.pop(x) elif x.startswith('__'): error_message = "Device provided %r which is disallowed"\ ", it mangles into a Python non-public attribute." % x raise DeviceProvidesIncompatibleKey(error_message) return rdict def _local_update(self, rdict): """Call this with a response dictionary to update instance attrs. If the response has only valid keys, stash meta_data, replace __dict__, and reassign meta_data. :param rdict: response attributes derived from server JSON """ sanitized = self._check_keys(rdict) temp_meta = self._meta_data self.__dict__ = sanitized self._meta_data = temp_meta def _update(self, **kwargs): """wrapped with update, override that in a subclass to customize""" requests_params, update_uri, session, read_only = \ self._prepare_put_or_patch(kwargs) read_only_mutations = [] for attr in read_only: if attr in kwargs: read_only_mutations.append(attr) if read_only_mutations: msg = 'Attempted to mutate read-only attribute(s): %s' \ % read_only_mutations raise AttemptedMutationOfReadOnly(msg) # Get the current state of the object on BIG-IP® and check the # generation Use pop here because we don't want force in the data_dict force = self._check_force_arg(kwargs.pop('force', True)) if not force: # generation has a known server-side error self._check_generation() kwargs = self._check_for_boolean_pair_reduction(kwargs) # Save the meta data so we can add it back into self after we # load the new object. temp_meta = self.__dict__.pop('_meta_data') # Need to remove any of the Collection objects from self.__dict__ # because these are subCollections and _meta_data and # other non-BIG-IP® attrs are not removed from the subCollections # See issue #146 for details tmp = dict() for key, value in iteritems(self.__dict__): # In Python2 versions we were changing a dictionary in place, # but this cannot be done with an iterator as an error is raised. # So instead we create a temporary holder for the modified dict # and then re-assign it afterwards. if isinstance(value, Collection): pass else: tmp[key] = value self.__dict__ = tmp data_dict = self.to_dict() # Remove any read-only attributes from our data_dict before we update # the data dict with the attributes. If they pass in read-only attrs # in the method call we are going to let BIG-IP® let them know about it # when it fails for attr in read_only: data_dict.pop(attr, '') data_dict.update(kwargs) data_dict = self._prepare_request_json(data_dict) # Handles ConnectionAborted errors # # @see https://github.com/F5Networks/f5-ansible/issues/317 # @see https://github.com/requests/requests/issues/2364 for _ in range(0, 30): try: response = session.put(update_uri, json=data_dict, **requests_params) self._meta_data = temp_meta self._local_update(response.json()) break except iControlUnexpectedHTTPError: response = session.get(update_uri, **requests_params) self._meta_data = temp_meta self._local_update(response.json()) raise except ConnectionError as ex: if 'Connection aborted' in str(ex): time.sleep(1) continue else: raise
[docs] def update(self, **kwargs): """Update the configuration of the resource on the BIG-IP®. This method uses HTTP PUT alter the resource state on the BIG-IP®. The attributes of the instance will be packaged as a dictionary. That dictionary will be updated with kwargs. It is then submitted as JSON to the device. Various edge cases are handled: * read-only attributes that are unchangeable are removed Args: kwargs (dict): Arbitrary number of keyword arguments. Keys and associated values to alter on the device. If kwargs has a ``requests_params`` key the corresponding dict will be passed to the underlying ``requests.session.put`` method where it will be handled according to that API. """ # Need to implement checking for valid params here. self._update(**kwargs)
def _refresh(self, **kwargs): """wrapped by `refresh` override that in a subclass to customize""" requests_params = self._handle_requests_params(kwargs) refresh_session = self._meta_data['bigip']._meta_data['icr_session'] if self._meta_data['uri'].endswith('/stats/'): # Slicing off the trailing slash here for Stats enpoints because # iWorkflow doesn't consider those `stats` URLs valid if they # include the trailing slash. # # Other than that, functionality does not change uri = self._meta_data['uri'][0:-1] else: uri = self._meta_data['uri'] response = refresh_session.get(uri, **requests_params) self._local_update(response.json())
[docs] def refresh(self, **kwargs): """Use this to make the device resource be represented by self. This method makes an HTTP GET query against the device service. This method is run for its side-effects on self. If successful the instance attribute __dict__ is replaced with the dict representing the device state. To figure out what that state is, run a subsequest query of the object like this: As with all CURDLE methods use a "requests_params" dict to pass parameters to requests.session.HTTPMETHOD. See test_requests_params.py for an example. >>> resource_obj.refresh() >>> print(resource_obj.raw) """ self._refresh(**kwargs)
def load(self, **kwargs): error_message = "Only Resources support 'load'." raise InvalidResource(error_message)
[docs] def create(self, **kwargs): """Implement this by overriding it in a subclass of `Resource` Raises: InvalidResource: If method is used. """ error_message = "Only Resources support 'create'." raise InvalidResource(error_message)
[docs] def delete(self, **kwargs): """Implement this by overriding it in a subclass of `Resource` Raises: InvalidResource: If method is used. """ error_message = "Only Resources support 'delete'." raise InvalidResource(error_message)
def _stamp_out_core(self): container = self._meta_data['container'] core = self.__class__.__new__(self.__class__) core.__init__(container) return core def _produce_instance(self, response): '''Generate a new self, which is an instance of the self.''' new_instance = self._stamp_out_core() # Post-process the response new_instance._local_update(response.json()) # Allow for example files, which are KindTypeMismatches if hasattr(new_instance, 'selfLink'): if new_instance.kind != new_instance._meta_data[ 'required_json_kind'] \ and new_instance.kind != "tm:transaction:commandsstate" \ and 'example' not in new_instance.selfLink.split('/')[-1]: error_message = "For instances of type '%r' the corresponding" \ " kind must be '%r' but creation returned JSON with kind: %r" \ % (new_instance.__class__.__name__, new_instance._meta_data[ 'required_json_kind'], new_instance.kind) raise KindTypeMismatch(error_message) else: if new_instance.kind != new_instance._meta_data[ 'required_json_kind'] \ and new_instance.kind != "tm:transaction:commandsstate": error_message = "For instances of type '%r' the corresponding" \ " kind must be '%r' but creation returned JSON with kind: %r" \ % (new_instance.__class__.__name__, new_instance._meta_data[ 'required_json_kind'], new_instance.kind) raise KindTypeMismatch(error_message) # Update the object to have the correct functional uri. new_instance._activate_URI(new_instance.selfLink) return new_instance def _reduce_boolean_pair(self, config_dict, key1, key2): """Ensure only one key with a boolean value is present in dict. :param config_dict: dict -- dictionary of config or kwargs :param key1: string -- first key name :param key2: string -- second key name :raises: BooleansToReduceHaveSameValue """ if key1 in config_dict and key2 in config_dict \ and config_dict[key1] == config_dict[key2]: msg = 'Boolean pair, %s and %s, have same value: %s. If both ' \ 'are given to this method, they cannot be the same, as this ' \ 'method cannot decide which one should be True.' \ % (key1, key2, config_dict[key1]) raise BooleansToReduceHaveSameValue(msg) elif key1 in config_dict and not config_dict[key1]: config_dict[key2] = True config_dict.pop(key1) elif key2 in config_dict and not config_dict[key2]: config_dict[key1] = True config_dict.pop(key2) return config_dict @property def attrs(self): no_meta_dict = {k: v for k, v in iteritems(self.__dict__) if k != '_meta_data'} return no_meta_dict
[docs]class OrganizingCollection(ResourceBase): """Base class for objects that collect resources under them. ``OrganizingCollection`` objects fulfill the following functions, * represent a uri path fragment immediately 'below' ``/mgmt/tm`` * provide a list of dictionaries that contain uri's to other resources on the device. """
[docs] def get_collection(self, **kwargs): """Call to obtain a list of the reference dicts in the instance `items` Returns: list: List of self.items """ self.refresh(**kwargs) return self.items
[docs]class Collection(ResourceBase): """Base class for objects that collect a list of ``Resources`` The Collection Resource is responsible for providing a list of Python objects, where each object represents a unique URI, the URI contains the URI of the Collection at the front of its path, and the 'kind' of the URI-associated-JSON has been registered with the attribute registry of the Collection subclass. .. note:: Any subclass of this base class must have ``s`` at the end of its name unless it ends in ``s`` then it must have ``_s``. """
[docs] def get_collection(self, **kwargs): """Get an iterator of Python ``Resource`` objects that represent URIs. The returned objects are Pythonic `Resource`s that map to the most recently `refreshed` state of uris-resources published by the device. In order to instantiate the correct types, the concrete subclass must populate its registry with acceptable types, based on the `kind` field returned by the REST server. .. note:: This method implies a single REST transaction with the Collection subclass URI. :raises: UnregisteredKind :returns: list of reference dicts and Python ``Resource`` objects """ list_of_contents = [] self.refresh(**kwargs) if 'items' in self.__dict__: for item in self.items: # It's possible to have non-"kind" JSON returned. We just # append the corresponding dict. PostProcessing is the caller's # responsibility. if 'kind' not in item: list_of_contents.append(item) continue kind = item['kind'] if kind in self._meta_data['attribute_registry']: # If it has a kind, it must be registered. instance = self._meta_data['attribute_registry'][kind](self) instance._local_update(item) instance._activate_URI(instance.selfLink) list_of_contents.append(instance) else: error_message = '%r is not registered!' % kind raise UnregisteredKind(error_message) return list_of_contents
def _delete_collection(self, **kwargs): """wrapped with delete_collection, override that in a sublcass to customize """ error_message = "The request must include \"requests_params\": {\"params\": \"options=<glob pattern>\"} as kwarg" try: if kwargs['requests_params']['params'].split('=')[0] != 'options': raise MissingRequiredRequestsParameter(error_message) except KeyError: raise requests_params = self._handle_requests_params(kwargs) delete_uri = self._meta_data['uri'] session = self._meta_data['bigip']._meta_data['icr_session'] session.delete(delete_uri, **requests_params)
[docs] def delete_collection(self, **kwargs): """One can not simply delete a collection. This is to support odata usage via the options request parameter: ``requests_params={'params': 'options=glob_pattern'}`` where glob_pattern can be used to delete one or all of a particular collection. Not submitting the requests params will fail, and specifying patterns that match default resources will fail as well. """ self._delete_collection(**kwargs)
[docs]class Resource(ResourceBase): """Base class to represent a Configurable Resource on the device. .. warning:: Objects instantiated from subclasses of Resource do NOT contain a URI (self._meta_data['uri']) at instantiation! Resource objects provide the interface for the Creation of new services on the device. Once a new service has been created, (via ``self.create`` or ``self.load``), the instance constructs its URI and stores it as ``self._meta_data['uri']``. It is an error to attempt to call :meth:`~f5.bigip.resource.Resource.create` or :meth:`~f5.bigip.resource.Resource.load` on an instance more than once. ``self._meta_data['uri']`` MUST not be changed after creation or load. .. note:: creation query args, and creation hash fragments are stored as separate _meta_data values. By "Configurable" we mean that submitting JSON via the PUT method to the URI managed by subclasses of Resource, changes the state of the corresponding service on the device. It also means that the URI supports `DELETE`. """ def __init__(self, container): """Call to create a client side object to represent a service URI. Call _create or _load for a Resource resource to have a self._meta_data['uri']! """ super(Resource, self).__init__(container) self._meta_data.pop('uri') # Creation fails without these. self._meta_data['required_creation_parameters'] = {'name', } # Refresh fails without these. self._meta_data['required_load_parameters'] = {'name', } # You can't set these attributes, only 'read' them. self._meta_data['read_only_attributes'] = [] self._meta_data['reduction_forcing_pairs'] = \ [ ('enabled', 'disabled'), ('online', 'offline'), ('vlansEnabled', 'vlansDisabled') ] def _activate_URI(self, selfLinkuri): """Call this with a selfLink, after it's returned in _create or _load. Each instance is tightly bound to a particular service URI. When that service is created by this library, or loaded from the device, the URI is set to self._meta_data['uri']. This operation can only occur once, any subsequent attempt to manipulate self._meta_data['uri'] is probably a mistake. self.selfLink references a value that is returned as a JSON value from the device. This value contains "localhost" as the domain or the uri. "localhost" is only conceivably useful if the client library is run on the device itself, so it is replaced with the domain this API used to communicate with the device. self.selfLink correctly contains a complete uri, that is only _now_ (post create or load) available to self. Now that the complete URI is available to self, it is now possible to reference subcollections, as attributes of self! e.g. a resource with a uri path like: "/mgmt/tm/ltm/pool/~Common~pool_collection1/members" The mechanism used to enable this change is to set the `allowed_lazy_attributes` _meta_data key to hold values of the `attribute_registry` _meta_data key. Finally we stash the corrected `uri`, returned hash_fragment, query args, and of course allowed_lazy_attributes in _meta_data. :param selfLinkuri: the server provided selfLink (contains localhost) :raises: URICreationCollision """ # netloc local alias uri = urlparse.urlsplit(str(self._meta_data['bigip']._meta_data['uri'])) # attrs local alias attribute_reg = self._meta_data.get('attribute_registry', {}) attrs = list(itervalues(attribute_reg)) attrs = self._assign_stats(attrs) (scheme, domain, path, qarg, frag) = urlparse.urlsplit(selfLinkuri) path_uri = urlparse.urlunsplit((scheme, uri.netloc, path, '', '')) if not path_uri.endswith('/'): path_uri = path_uri + '/' qargs = urlparse.parse_qs(qarg) self._meta_data.update({'uri': path_uri, 'creation_uri_qargs': qargs, 'creation_uri_frag': frag, 'allowed_lazy_attributes': attrs}) def _assign_stats(self, attrs): if self._meta_data['object_has_stats']: attrs.append(Stats) return attrs def _check_create_parameters(self, **kwargs): """Params given to create should satisfy required params. :params: kwargs :raises: MissingRequiredCreateParameter """ rset = self._meta_data['required_creation_parameters'] check = _missing_required_parameters(rset, **kwargs) if check: error_message = 'Missing required params: %s' % check raise MissingRequiredCreationParameter(error_message) def _minimum_one_is_missing(self, **kwargs): """Helper function to do operation on sets Verify if at least one of the elements is present in **kwargs. If no items of rqset are contained in **kwargs the function raises exception. This check will only trigger if rqset is not empty. Raises: MissingRequiredCreationParameter """ rqset = self._meta_data['minimum_additional_parameters'] if rqset: kwarg_set = set(iterkeys(kwargs)) if kwarg_set.isdisjoint(rqset): args = sorted(rqset) error_message = 'This resource requires at least one of the ' \ 'mandatory additional ' \ 'parameters to be provided: %s' % ', '.join(args) raise MissingRequiredCreationParameter(error_message) def _create(self, **kwargs): """wrapped by `create` override that in subclasses to customize""" if 'uri' in self._meta_data: error = "There was an attempt to assign a new uri to this "\ "resource, the _meta_data['uri'] is %s and it should"\ " not be changed." % (self._meta_data['uri']) raise URICreationCollision(error) self._check_exclusive_parameters(**kwargs) requests_params = self._handle_requests_params(kwargs) self._minimum_one_is_missing(**kwargs) self._check_create_parameters(**kwargs) kwargs = self._check_for_python_keywords(kwargs) # Reduce boolean pairs as specified by the meta_data entry below for key1, key2 in self._meta_data['reduction_forcing_pairs']: kwargs = self._reduce_boolean_pair(kwargs, key1, key2) # Make convenience variable with short names for this method. _create_uri = self._meta_data['container']._meta_data['uri'] session = self._meta_data['bigip']._meta_data['icr_session'] kwargs = self._prepare_request_json(kwargs) # Invoke the REST operation on the device. response = session.post(_create_uri, json=kwargs, **requests_params) # Make new instance of self result = self._produce_instance(response) return result
[docs] def create(self, **kwargs): r"""Create the resource on the BIG-IP®. Uses HTTP POST to the `collection` URI to create a resource associated with a new unique URI on the device. .. Subclasses can customize this functionality by defining a `create` method that wraps and calls this method with appropriate arguments. Note this is the one of two fundamental Resource operations that returns a different uri (in the returned object) than the uri the operation was called on. The returned uri can be accessed as Object.selfLink, the actual uri used by REST operations on the object is Object._meta_data['uri']. The _meta_data['uri'] is the same as Object.selfLink with the substring 'localhost' replaced with the value of Object._meta_data['bigip']._meta_data['hostname'], and without query args, or hash fragments. Args: \*\*kwargs (dict): Arbitrary number of keyword arguments. All the key-values needed to create the resource. If kwargs has a ``requests_params`` key the corresponding dict will be passed to the underlying ``requests.session.post`` method where it will be handled according to that API. Returns: Resource: A python object that represents the object's configuration and state on the BIG-IP®. """ return self._create(**kwargs)
def _check_load_parameters(self, **kwargs): """Params given to load should at least satisfy required params. :params: kwargs :raises: MissingRequiredReadParameter """ rset = self._meta_data['required_load_parameters'] check = _missing_required_parameters(rset, **kwargs) if check: check.sort() error_message = 'Missing required params: %s' % check raise MissingRequiredReadParameter(error_message) def _load(self, **kwargs): """wrapped with load, override that in a subclass to customize""" if 'uri' in self._meta_data: error = "There was an attempt to assign a new uri to this "\ "resource, the _meta_data['uri'] is %s and it should"\ " not be changed." % (self._meta_data['uri']) raise URICreationCollision(error) requests_params = self._handle_requests_params(kwargs) self._check_load_parameters(**kwargs) kwargs['uri_as_parts'] = True refresh_session = self._meta_data['bigip']._meta_data['icr_session'] base_uri = self._meta_data['container']._meta_data['uri'] kwargs.update(requests_params) for key1, key2 in self._meta_data['reduction_forcing_pairs']: kwargs = self._reduce_boolean_pair(kwargs, key1, key2) kwargs = self._check_for_python_keywords(kwargs) response = refresh_session.get(base_uri, **kwargs) # Make new instance of self return self._produce_instance(response)
[docs] def load(self, **kwargs): r"""Load an already configured service into this instance. This method uses HTTP GET to obtain a resource from the BIG-IP®. The URI of the target service is constructed from the instance's container and ``**kwargs``. ``kwargs`` typically requires the keys ``name`` and ``partition``. this may, or may not, be true for a specific service. Args: \*\*kwargs (dict): Arbitrary number of keyword arguments. If kwargs has a ``requests_params`` key the corresponding dict will be passed to the underlying ``requests.session.get`` method where it will be handled according to that API. Use the method above to pass query args Returns: Resource: A Resource Instance with a populated ``_meta_data['uri']`` """ return self._load(**kwargs)
def _delete(self, **kwargs): """wrapped with delete, override that in a subclass to customize """ requests_params = self._handle_requests_params(kwargs) delete_uri = self._meta_data['uri'] session = self._meta_data['bigip']._meta_data['icr_session'] # Check the generation for match before delete force = self._check_force_arg(kwargs.pop('force', True)) if not force: self._check_generation() response = session.delete(delete_uri, **requests_params) if response.status_code == 200: self.__dict__ = {'deleted': True}
[docs] def delete(self, **kwargs): r"""Delete the resource on the BIG-IP®. Uses HTTP DELETE to delete the resource on the BIG-IP®. After this method is called, and status_code 200 response is received ``instance.__dict__`` is replace with ``{'deleted': True}`` Args: \*\*kwargs (dict): Arbitrary number of keyword arguments. The only current use is to pass kwargs to the requests API. If kwargs has a ``requests_params`` key the corresponding dict will be passed to the underlying ``requests.session.delete`` method where it will be handled according to that API. Use the method above to pass query args. """ # Need to implement checking for ? here. self._delete(**kwargs)
# Need to implement correct teardown here.
[docs] def exists(self, **kwargs): r"""Check for the existence of the named object on the BIG-IP Sends an HTTP GET to the URI of the named object and if it fails with a :exc:~requests.HTTPError` exception it checks the exception for status code of 404 and returns :obj:`False` in that case. If the GET is successful it returns :obj:`True`. For any other errors are raised as-is. Args: \*\*kwargs (dict): Arbitrary number of keyword arguments. If kwargs has a ``requests_params`` key the corresponding dict will be passed to the underlying ``requests.session.get`` method where it will be handled according to that API. Use the method above to pass query args. Returns: bool: True is the object exists. False otherwise. Raises: requests.HTTPError: Any HTTP error that was not status code 404. """ return self._exists(**kwargs)
def _exists(self, **kwargs): """wrapped with exists, override that in a subclass to customize """ requests_params = self._handle_requests_params(kwargs) self._check_load_parameters(**kwargs) kwargs['uri_as_parts'] = True session = self._meta_data['bigip']._meta_data['icr_session'] base_uri = self._meta_data['container']._meta_data['uri'] kwargs.update(requests_params) try: session.get(base_uri, **kwargs) except HTTPError as err: if err.response.status_code == 404: return False else: raise return True
[docs]class UnnamedResource(ResourceBase): """This makes a resource object work if there is no name. These objects do not support create or delete and are often found as Resources that are under an organizing collection. For example the ``mgmt/tm/sys/global-settings`` is one of these and has a kind of ``tm:sys:global-settings:global-settingsstate`` and the URI does not match the kind. """
[docs] def create(self, **kwargs): """Create is not supported for unnamed resources Raises: UnsupportedMethod: If the method is used """ raise UnsupportedMethod( "%s does not support the create method" % self.__class__.__name__ )
[docs] def delete(self, **kwargs): """Delete is not supported for unnamed resources Raises: UnsupportedMethod: If the method is used """ raise UnsupportedMethod( "%s does not support the delete method" % self.__class__.__name__ )
def load(self, **kwargs): newinst = self._stamp_out_core() newinst._refresh(**kwargs) return newinst
[docs]class Stats(UnnamedResource): """For stats resources."""
[docs] def modify(self, **kwargs): """Modify is not supported for stats resources Raises: UnsupportedMethod: If the method is used """ raise UnsupportedMethod( "%s does not support the modify method" % self.__class__.__name__ )
def load(self, **kwargs): # TODO(pjbreaux) add try-except and custom exception here. return super(Stats, self).load(**kwargs)
[docs]class AsmResource(Resource): """ASM Resource class represents a configurable ASM endpoint on the device. ASM resources are unique in BIG-IP® in the sense that their direct URI endpoints are hash based IDs of the resources. The IDs are generated by BIG-IP® when the objects are created. Moreover, the ASM resources do not have 'generation' property, therefore some of the other methods needed to be adjusted to accommodate that. """ def __init__(self, container): """Call to create a client side object to represent a service URI. Call _create or _load for a Resource to have a self._meta_data['uri']! """ super(AsmResource, self).__init__(container) # Asm endpoints require object 'id' which is a hash created by BIGIP # when object is created. self._meta_data['required_load_parameters'] = {'id', } # No ASM endpoint supports Stats self._meta_data['object_has_stats'] = False def _load(self, **kwargs): """wrapped with load, override that in a subclass to customize""" if 'uri' in self._meta_data: error = "There was an attempt to assign a new uri to this "\ "resource, the _meta_data['uri'] is %s and it should"\ " not be changed." % (self._meta_data['uri']) raise URICreationCollision(error) requests_params = self._handle_requests_params(kwargs) self._check_load_parameters(**kwargs) kwargs['uri_as_parts'] = True refresh_session = self._meta_data['bigip']._meta_data['icr_session'] uri = self._meta_data['container']._meta_data['uri'] endpoint = kwargs.pop('id', '') # Popping name kwarg as it will cause the uri to be invalid. We only # require id parameter kwargs.pop('name', '') base_uri = uri + endpoint + '/' kwargs.update(requests_params) for key1, key2 in self._meta_data['reduction_forcing_pairs']: kwargs = self._reduce_boolean_pair(kwargs, key1, key2) response = refresh_session.get(base_uri, **kwargs) # Make new instance of self return self._produce_instance(response)
[docs] def load(self, **kwargs): r"""Load an already configured service into this instance. This method uses HTTP GET to obtain a resource from the BIG-IP®. The URI of the target service is constructed from the instance's container and ``\*\*kwargs``. kwargs typically for ASM requires "id" in majority of cases, as object links in ASM are using hash(id) instead of names, this may, or may not, be true for a specific service. Args: \*\*kwargs (dict): Arbitrary number of keyword arguments. If kwargs has a ``requests_params`` key the corresponding dict will be passed to the underlying ``requests.session.get`` method where it will be handled according to that API. Returns: Resource: ``Resource`` instance with a populated _meta_data['uri'] """ return self._load(**kwargs)
def _delete(self, **kwargs): """Wrapped with delete, override that in a subclass to customize """ requests_params = self._handle_requests_params(kwargs) delete_uri = self._meta_data['uri'] session = self._meta_data['bigip']._meta_data['icr_session'] response = session.delete(delete_uri, **requests_params) if response.status_code == 200 or 201: self.__dict__ = {'deleted': True}
[docs] def delete(self, **kwargs): """Delete the ASM resource on the BIG-IP®. Uses HTTP DELETE to delete the ASM resource on the BIG-IP®. After this method is called, and status_code 200 or 201 response is received ``instance.__dict__`` is replace with ``{'deleted': True}`` Args: kwargs (dict): Arbitrary number of keyword arguments. If kwargs has a ``requests_params`` key the corresponding dict will be passed to the underlying ``requests.session.delete`` method where it will be handled according to that API. """ # Need to implement checking for ? here. self._delete(**kwargs)
# Need to implement correct teardown here.
[docs] def exists(self, **kwargs): r"""Check for the existence of the ASM object on the BIG-IP Sends an HTTP GET to the URI of the ASM object and if it fails with a :exc:~requests.HTTPError` exception it checks the exception for status code of 404 and returns :obj:`False` in that case. If the GET is successful it returns :obj:`True`. For any other errors are raised as-is. Args: \*\*kwargs (dict): Arbitrary number of keyword arguments. Keyword arguments required to get objects If kwargs has a ``requests_param`` key the corresponding dict will be passed to the underlying ``requests.session.get`` method where it will be handled according to that API. Returns: bool: True is the object exists: False otherwise. Raises: requests.HTTPError: Any HTTP error that was not status code 404. """ requests_params = self._handle_requests_params(kwargs) self._check_load_parameters(**kwargs) kwargs['uri_as_parts'] = True session = self._meta_data['bigip']._meta_data['icr_session'] uri = self._meta_data['container']._meta_data['uri'] endpoint = kwargs.pop('id', '') # Popping name kwarg as it will cause the uri to be invalid kwargs.pop('name', '') base_uri = uri + endpoint + '/' kwargs.update(requests_params) try: session.get(base_uri, **kwargs) except HTTPError as err: if err.response.status_code == 404: return False else: raise return True
[docs] def update(self, **kwargs): """Update is not supported for ASM Resources Raises: UnsupportedOperation: If method is used. """ raise UnsupportedOperation( "%s does not support the update method" % self.__class__.__name__ )
[docs]class AsmTaskResource(AsmResource): """ASM Task Resource class represents an ASM Tasks endpoint on the device. Tasks resources do not support create() method in the strict sense, as they require an HTTP POST with an empty json{} to prompt BIGIP to create them, therefore a new method fetch() was created. """ def __init__(self, container): """Call to create a client side object to represent a service URI. Call _fetch for a Resource to have a self._meta_data['uri']! """ super(AsmTaskResource, self).__init__(container) def _fetch(self): """wrapped by `fetch` override that in subclasses to customize""" if 'uri' in self._meta_data: error = "There was an attempt to assign a new uri to this "\ "resource, the _meta_data['uri'] is %s and it should"\ " not be changed." % (self._meta_data['uri']) raise URICreationCollision(error) # Make convenience variable with short names for this method. _create_uri = self._meta_data['container']._meta_data['uri'] session = self._meta_data['bigip']._meta_data['icr_session'] # Invoke the REST operation on the device. response = session.post(_create_uri, json={}) # Make new instance of self return self._produce_instance(response)
[docs] def fetch(self): """Fetch the ASM resource on the BIG-IP®. This is a heavily modified version of create, that does not allow any arguments when executing. It uses an emtpy json{} HTTP POST to prompt the BIG-IP® to create the object, mainly used by 'Tasks' endpoint. """ return self._fetch()
[docs] def create(self, **kwargs): """Create is not supported for Task ASM resources :raises: UnsupportedOperation """ raise UnsupportedOperation( "%s does not support the create method" % self.__class__.__name__ )
[docs] def modify(self, **kwargs): """Modify is not supported for Task ASM resources :raises: UnsupportedOperation """ raise UnsupportedOperation( "%s does not support the modify method" % self.__class__.__name__ )
[docs]class TaskResource(Resource): def __init__(self, container): """Call to create a client side object to represent a service URI. Call _create or _load for a Resource to have a self._meta_data['uri']! """ super(TaskResource, self).__init__(container) # Asm endpoints require object 'id' which is a hash created by BIGIP # when object is created. self._meta_data['required_load_parameters'] = {'id', } # No ASM endpoint supports Stats self._meta_data['object_has_stats'] = False def _load(self, **kwargs): """wrapped with load, override that in a subclass to customize""" if 'uri' in self._meta_data: error = "There was an attempt to assign a new uri to this " \ "resource, the _meta_data['uri'] is %s and it should" \ " not be changed." % (self._meta_data['uri']) raise URICreationCollision(error) requests_params = self._handle_requests_params(kwargs) self._check_load_parameters(**kwargs) kwargs['uri_as_parts'] = True refresh_session = self._meta_data['bigip']._meta_data['icr_session'] uri = self._meta_data['container']._meta_data['uri'] endpoint = kwargs.pop('id', '') # Popping name kwarg as it will cause the uri to be invalid. We only # require id parameter kwargs.pop('name', '') base_uri = uri + endpoint + '/' kwargs.update(requests_params) for key1, key2 in self._meta_data['reduction_forcing_pairs']: kwargs = self._reduce_boolean_pair(kwargs, key1, key2) response = refresh_session.get(base_uri, **kwargs) # Make new instance of self return self._produce_instance(response)
[docs] def load(self, **kwargs): """Load an already configured service into this instance. This method uses HTTP GET to obtain a resource from the BIG-IP®. .. The URI of the target service is constructed from the instance's container and **kwargs. kwargs typically for ASM requires "id" in majority of cases, as object links in ASM are using hash(id) instead of names, this may, or may not, be true for a specific service. :param kwargs: typically contains "id" NOTE: If kwargs has a 'requests_params' key the corresponding dict will be passed to the underlying requests.session.get method where it will be handled according to that API. THIS IS HOW TO PASS QUERY-ARGS! :returns: a Resource Instance (with a populated _meta_data['uri']) """ return self._load(**kwargs)
def _delete(self, **kwargs): """wrapped with delete, override that in a subclass to customize """ requests_params = self._handle_requests_params(kwargs) delete_uri = self._meta_data['uri'] session = self._meta_data['bigip']._meta_data['icr_session'] response = session.delete(delete_uri, **requests_params) if response.status_code == 200 or 201: self.__dict__ = {'deleted': True}
[docs] def delete(self, **kwargs): """Delete the Task resource on the BIG-IP®. Uses HTTP DELETE to delete the Task resource on the BIG-IP®. After this method is called, and status_code 200 or 201 response is received ``instance.__dict__`` is replace with ``{'deleted': True}`` :param kwargs: The only current use is to pass kwargs to the requests API. If kwargs has a 'requests_params' key the corresponding dict will be passed to the underlying requests.session.delete method where it will be handled according to that API. THIS IS HOW TO PASS QUERY-ARGS! """ # Need to implement checking for ? here. self._delete(**kwargs)
# Need to implement correct teardown here.
[docs] def exists(self, **kwargs): r"""Check for the existence of the Task object on the BIG-IP Sends an HTTP GET to the URI of the ASM object and if it fails with a :exc:~requests.HTTPError` exception it checks the exception for status code of 404 and returns :obj:`False` in that case. If the GET is successful it returns :obj:`True`. For any other errors are raised as-is. Args: \*\*kwargs (dict): Arbitrary number of keyword arguments. Keyword arguments required to get objects. If kwargs has a ``requests_params`` key the corresponding dict will be passed to the underlying ``requests.session.get`` method where it will be handled according to that API. Returns: bool: True if the objects exists. False otherwise. Raises: requests.HTTPError: Any HTTP error that was not status code 404. """ requests_params = self._handle_requests_params(kwargs) self._check_load_parameters(**kwargs) kwargs['uri_as_parts'] = True session = self._meta_data['bigip']._meta_data['icr_session'] uri = self._meta_data['container']._meta_data['uri'] endpoint = kwargs.pop('id', '') # Popping name kwarg as it will cause the uri to be invalid kwargs.pop('name', '') base_uri = uri + endpoint + '/' kwargs.update(requests_params) try: session.get(base_uri, **kwargs) except HTTPError as err: if err.response.status_code == 404: return False else: raise return True
[docs] def update(self, **kwargs): """Update is not supported for ASM Resources Raises: UnsupportedOperation: If method is used. """ raise UnsupportedOperation( "%s does not support the update method" % self.__class__.__name__ )