Repository URL to install this package:
|
Version:
6.0.0 ▾
|
# -*- coding: utf-8 -*-
#
# Copyright (c) 2017, F5 Networks Inc.
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import os
try:
from StringIO import StringIO
except ImportError:
from io import StringIO
try:
from BytesIO import BytesIO
except ImportError:
from io import BytesIO
from ansible.module_utils.urls import urlparse
from ansible.module_utils.urls import generic_urlparse
from ansible.module_utils.urls import Request
try:
import json as _json
except ImportError:
import simplejson as _json
from .common import F5ModuleError
"""An F5 REST API URI handler.
Use this module to make calls to an F5 REST server. It is influenced by the same
API that the Python ``requests`` tool uses, but the two are not the same, as the
library here is **much** more simple and targeted specifically to F5's needs.
The ``requests`` design was chosen due to familiarity with the tool. Internally,
the classes contained herein use Ansible native libraries.
The means by which you should use it are similar to ``requests`` basic usage.
Authentication is not handled for you automatically by this library, however it *is*
handled automatically for you in the supporting F5 module_utils code; specifically the
different product module_util files (bigip.py, bigiq.py, etc).
Internal (non-module) usage of this library looks like this.
```
# Create a session instance
mgmt = iControlRestSession()
mgmt.verify = False
server = '1.1.1.1'
port = 443
# Payload used for getting an initial authentication token
payload = {
'username': 'admin',
'password': 'secret',
'loginProviderName': 'tmos'
}
# Create URL to call, injecting server and port
url = f"https://{server}:{port}/mgmt/shared/authn/login"
# Call the API
resp = session.post(url, json=payload)
# View the response
print(resp.json())
# Update the session with the authentication token
session.headers['X-F5-Auth-Token'] = resp.json()['token']['token']
# Create another URL to call, injecting server and port
url = f"https://{server}:{port}/mgmt/tm/ltm/virtual/~Common~virtual1"
# Call the API
resp = session.get(url)
# View the details of a virtual payload
print(resp.json())
```
"""
from ansible.module_utils.six.moves.urllib.error import HTTPError
from .constants import (
LOGOUT, BASE_HEADERS
)
class Response(object):
def __init__(self):
self._content = None
self.status = None
self.headers = dict()
self.url = None
self.reason = None
self.request = None
self.msg = None
@property
def content(self):
return self._content
@property
def raw_content(self):
return self._content
def json(self):
return _json.loads(self._content or 'null')
@property
def ok(self):
if self.status is not None and int(self.status) > 400:
return False
try:
response = self.json()
if 'code' in response and response['code'] > 400:
return False
except ValueError:
pass
return True
class iControlRestSession(object):
"""Represents a session that communicates with a BigIP.
This acts as a loose wrapper around Ansible's ``Request`` class. We're doing
this as interim work until we move to the httpapi connector.
"""
def __init__(self, headers=None, use_proxy=True, force=False, timeout=120,
validate_certs=True, url_username=None, url_password=None,
http_agent=None, force_basic_auth=False, follow_redirects='urllib2',
client_cert=None, client_key=None, cookies=None):
self.request = Request(
headers=headers,
use_proxy=use_proxy,
force=force,
timeout=timeout,
validate_certs=validate_certs,
url_username=url_username,
url_password=url_password,
http_agent=http_agent,
force_basic_auth=force_basic_auth,
follow_redirects=follow_redirects,
client_cert=client_cert,
client_key=client_key,
cookies=cookies
)
self.last_url = None
def get_headers(self, result):
try:
return dict(result.getheaders())
except AttributeError:
return result.headers
def update_response(self, response, result):
response.headers = self.get_headers(result)
response._content = result.read()
response.status = result.getcode()
response.url = result.geturl()
response.msg = "OK (%s bytes)" % response.headers.get('Content-Length', 'unknown')
def send(self, method, url, **kwargs):
response = Response()
# Set the last_url called
#
# This is used by the object destructor to erase the token when the
# ModuleManager exits and destroys the iControlRestSession object
self.last_url = url
body = None
data = kwargs.pop('data', None)
json = kwargs.pop('json', None)
if not data and json is not None:
self.request.headers.update(BASE_HEADERS)
body = _json.dumps(json)
if not isinstance(body, bytes):
body = body.encode('utf-8')
if data:
body = data
if body:
kwargs['data'] = body
try:
result = self.request.open(method, url, **kwargs)
except HTTPError as e:
# Catch HTTPError delivered from Ansible
#
# The structure of this object, in Ansible 2.8 is
#
# HttpError {
# args
# characters_written
# close
# code
# delete
# errno
# file
# filename
# filename2
# fp
# getcode
# geturl
# hdrs
# headers
# info
# msg
# name
# reason
# strerror
# url
# with_traceback
# }
self.update_response(response, e)
return response
self.update_response(response, result)
return response
def delete(self, url, **kwargs):
return self.send('DELETE', url, **kwargs)
def get(self, url, **kwargs):
return self.send('GET', url, **kwargs)
def patch(self, url, data=None, **kwargs):
return self.send('PATCH', url, data=data, **kwargs)
def post(self, url, data=None, **kwargs):
return self.send('POST', url, data=data, **kwargs)
def put(self, url, data=None, **kwargs):
return self.send('PUT', url, data=data, **kwargs)
def __del__(self):
if self.last_url is None:
return
token = self.request.headers.get('X-F5-Auth-Token', None)
if not token:
return
try:
p = generic_urlparse(urlparse(self.last_url))
uri = "https://{0}:{1}{2}{3}".format(
p['hostname'], p['port'], LOGOUT, token
)
self.delete(uri)
except ValueError:
pass
class TransactionContextManager(object):
def __init__(self, client, validate_only=False):
self.client = client
self.validate_only = validate_only
self.transid = None
def __enter__(self):
uri = "https://{0}:{1}/mgmt/tm/transaction/".format(
self.client.provider['server'],
self.client.provider['server_port']
)
resp = self.client.api.post(uri, json={})
if resp.status not in [200]:
raise Exception
try:
response = resp.json()
except ValueError as ex:
raise F5ModuleError(str(ex))
self.transid = response['transId']
self.client.api.request.headers['X-F5-REST-Coordination-Id'] = self.transid
return self.client
def __exit__(self, exc_type, exc_value, exc_tb):
self.client.api.request.headers.pop('X-F5-REST-Coordination-Id')
if exc_tb is None:
uri = "https://{0}:{1}/mgmt/tm/transaction/{2}".format(
self.client.provider['server'],
self.client.provider['server_port'],
self.transid
)
params = dict(
state="VALIDATING",
validateOnly=self.validate_only
)
resp = self.client.api.patch(uri, json=params)
if resp.status not in [200]:
raise Exception
def download_asm_file(client, url, dest, file_size):
"""Download a large ASM file from the remote device
This method handles issues with ASM file endpoints that allow
downloads of ASM objects on the BIG-IP, as well as handles
chunking of large files.
Arguments:
client (object): The F5RestClient connection object.
url (string): The URL to download.
dest (string): The location on (Ansible controller) disk to store the file.
file_size (integer): The size of the remote file.
Returns:
bool: No response on success. Fail otherwise.
"""
with open(dest, 'wb') as fileobj:
chunk_size = 512 * 1024
start = 0
end = chunk_size - 1
size = file_size
# current_bytes = 0
while True:
content_range = "%s-%s/%s" % (start, end, size)
headers = {
'Content-Range': content_range,
'Content-Type': 'application/json'
}
data = {
'headers': headers,
'verify': False,
'stream': False
}
response = client.api.get(url, headers=headers, json=data)
if response.status == 200:
if 'Content-Length' not in response.headers:
error_message = "The Content-Length header is not present."
raise F5ModuleError(error_message)
length = response.headers['Content-Length']
if int(length) > 0:
fileobj.write(response.content)
else:
error = "Invalid Content-Length value returned: %s ," \
"the value should be greater than 0" % length
raise F5ModuleError(error)
# fileobj.write(response.raw_content)
if end == size:
break
start += chunk_size
if start >= size:
break
if (end + chunk_size) > size:
end = size - 1
else:
end = start + chunk_size - 1
def download_file(client, url, dest):
"""Download a file from the remote device
This method handles the chunking needed to download a file from
a given URL on the BIG-IP.
Arguments:
client (object): The F5RestClient connection object.
url (string): The URL to download.
dest (string): The location on (Ansible controller) disk to store the file.
Returns:
bool: True on success. False otherwise.
"""
with open(dest, 'wb') as fileobj:
chunk_size = 512 * 1024
start = 0
end = chunk_size - 1
size = 0
current_bytes = 0
while True:
content_range = "%s-%s/%s" % (start, end, size)
headers = {
'Content-Range': content_range,
'Content-Type': 'application/octet-stream'
}
data = {
'headers': headers,
'verify': False,
'stream': False
}
response = client.api.get(url, headers=headers, json=data)
if response.status == 200:
# If the size is zero, then this is the first time through
# the loop and we don't want to write data because we
# haven't yet figured out the total size of the file.
if size > 0:
current_bytes += chunk_size
fileobj.write(response.raw_content)
# Once we've downloaded the entire file, we can break out of
# the loop
if end == size:
break
crange = response.headers['Content-Range']
# Determine the total number of bytes to read.
if size == 0:
size = int(crange.split('/')[-1]) - 1
# If the file is smaller than the chunk_size, the BigIP
# will return an HTTP 400. Adjust the chunk_size down to
# the total file size...
if chunk_size > size:
end = size
# ...and pass on the rest of the code.
continue
start += chunk_size
if (current_bytes + chunk_size) > size:
end = size
else:
end = start + chunk_size - 1
return True
def upload_file(client, url, src, dest=None):
"""Upload a file to an arbitrary URL.
This method is responsible for correctly chunking an upload request to an
arbitrary file worker URL.
Arguments:
client (object): The F5RestClient connection object.
url (string): The URL to upload a file to.
src (string): The file to be uploaded.
dest (string): The file name to create on the remote device.
Examples:
The ``dest`` may be either an absolute or relative path. The basename
of the path is used as the remote file name upon upload. For instance,
in the example below, ``BIGIP-13.1.0.8-0.0.3.iso`` would be the name
of the remote file.
The specified URL should be the full URL to where you want to upload a
file. BIG-IP has many different URLs that can be used to handle different
types of files. This is why a full URL is required.
>>> from ansible_collections.f5networks.f5_modules.plugins.module_utils.icontrol import upload_client
>>> url = 'https://{0}:{1}/mgmt/cm/autodeploy/software-image-uploads'.format(
... self.client.provider['server'],
... self.client.provider['server_port']
... )
>>> dest = '/path/to/BIGIP-13.1.0.8-0.0.3.iso'
>>> upload_file(self.client, url, dest)
True
Returns:
bool: True on success. False otherwise.
Raises:
F5ModuleError: Raised if ``retries`` limit is exceeded.
"""
if isinstance(src, StringIO) or isinstance(src, BytesIO):
fileobj = src
else:
fileobj = open(src, 'rb')
try:
size = os.stat(src).st_size
is_file = True
except TypeError:
src.seek(0, os.SEEK_END)
size = src.tell()
src.seek(0)
is_file = False
# This appears to be the largest chunk size that iControlREST can handle.
#
# The trade-off you are making by choosing a chunk size is speed, over size of
# transmission. A lower chunk size will be slower because a smaller amount of
# data is read from disk and sent via HTTP. Lots of disk reads are slower and
# There is overhead in sending the request to the BIG-IP.
#
# Larger chunk sizes are faster because more data is read from disk in one
# go, and therefore more data is transmitted to the BIG-IP in one HTTP request.
#
# If you are transmitting over a slow link though, it may be more reliable to
# transmit many small chunks that fewer large chunks. It will clearly take
# longer, but it may be more robust.
chunk_size = 1024 * 7168
start = 0
retries = 0
if dest is None and is_file:
basename = os.path.basename(src)
else:
basename = dest
url = '{0}/{1}'.format(url.rstrip('/'), basename)
while True:
if retries == 3:
# Retries are used here to allow the REST API to recover if you kill
# an upload mid-transfer.
#
# There exists a case where retrying a new upload will result in the
# API returning the POSTed payload (in bytes) with a non-200 response
# code.
#
# Retrying (after seeking back to 0) seems to resolve this problem.
raise F5ModuleError(
"Failed to upload file too many times."
)
try:
file_slice = fileobj.read(chunk_size)
if not file_slice:
break
current_bytes = len(file_slice)
if current_bytes < chunk_size:
end = size
else:
end = start + current_bytes
headers = {
'Content-Range': '%s-%s/%s' % (start, end - 1, size),
'Content-Type': 'application/octet-stream'
}
# Data should always be sent using the ``data`` keyword and not the
# ``json`` keyword. This allows bytes to be sent (such as in the case
# of uploading ISO files.
response = client.api.post(url, headers=headers, data=file_slice)
if response.status != 200:
# When this fails, the output is usually the body of whatever you
# POSTed. This is almost always unreadable because it is a series
# of bytes.
#
# Therefore, we only inform on the returned HTTP error code.
raise F5ModuleError('Error during upload, http error code: {0}'.format(str(response.status)))
start += current_bytes
except F5ModuleError:
# You must seek back to the beginning of the file upon exception.
#
# If this is not done, then you risk uploading a partial file.
fileobj.seek(0)
retries += 1
return True
def tmos_version(client):
uri = "https://{0}:{1}/mgmt/tm/sys/".format(
client.provider['server'],
client.provider['server_port'],
)
resp = client.api.get(uri)
try:
response = resp.json()
except ValueError as ex:
raise F5ModuleError(str(ex))
if 'code' in response and response['code'] in [400, 403]:
if 'message' in response:
raise F5ModuleError(response['message'])
else:
raise F5ModuleError(resp.content)
to_parse = urlparse(response['selfLink'])
query = to_parse.query
version = query.split('=')[1]
return version
def bigiq_version(client):
uri = "https://{0}:{1}/mgmt/shared/resolver/device-groups/cm-shared-all-big-iqs/devices".format(
client.provider['server'],
client.provider['server_port'],
)
query = "?$select=version"
resp = client.api.get(uri + query)
try:
response = resp.json()
except ValueError as ex:
raise F5ModuleError(str(ex))
if 'code' in response and response['code'] in [400, 403]:
if 'message' in response:
raise F5ModuleError(response['message'])
else:
raise F5ModuleError(resp.content)
if 'items' in response:
version = response['items'][0]['version']
return version
raise F5ModuleError(
'Failed to retrieve BIG-IQ version information.'
)
def module_provisioned(client, module_name):
provisioned = modules_provisioned(client)
if module_name in provisioned:
return True
return False
def package_installed(client, package_name):
provisioned = packages_installed(client)
if package_name in provisioned:
return True
return False
def modules_provisioned(client):
"""Returns a list of all provisioned modules
Args:
client: Client connection to the BIG-IP
Returns:
A list of provisioned modules in their short name for.
For example, ['afm', 'asm', 'ltm']
"""
uri = "https://{0}:{1}/mgmt/tm/sys/provision".format(
client.provider['server'],
client.provider['server_port']
)
resp = client.api.get(uri)
try:
response = resp.json()
except ValueError as ex:
raise F5ModuleError(str(ex))
if resp.status not in [200, 201] or 'code' in response and response['code'] not in [200, 201]:
raise F5ModuleError(resp.content)
if 'items' not in response:
return []
return [x['name'] for x in response['items'] if x['level'] != 'none']
def packages_installed(client):
"""Returns a list of installed ATC packages
Args:
client: Client connection to the BIG-IP
Returns:
A list of installed packages in their short name for.
For example, ['as3', 'do', 'ts']
"""
packages = {
"f5-declarative-onboarding": "do",
"f5-appsvcs": "as3",
"f5-appsvcs-templates": "fast",
"f5-cloud-failover": "cfe",
"f5-telemetry": "ts",
"f5-service-discovery": "sd"
}
uri = "https://{0}:{1}/mgmt/shared/iapp/global-installed-packages".format(
client.provider['server'],
client.provider['server_port']
)
resp = client.api.get(uri)
try:
response = resp.json()
except ValueError as ex:
raise F5ModuleError(str(ex))
if 'code' in response and response['code'] == 404:
return []
if resp.status not in [200, 201] or 'code' in response and response['code'] not in [200, 201]:
raise F5ModuleError(resp.content)
if 'items' not in response:
return []
result = [packages[x['appName']] for x in response['items'] if x['appName'] in packages.keys()]
return result