Skip to content

Commit 1937d04

Browse files
authored
Update logging and error handling for CM API modules (#168)
* Update logging and error handling for consistent debugging experience * Add ssl_ca_cert parameter for custom SSL certificate validation * Update return field for cm_version_info * Update to normalize redirects during endpoint discovery * Update logging to use the root logger and add a specific logger for cloudera.cluster Signed-off-by: Webster Mudge <wmudge@cloudera.com>
1 parent acc6b1d commit 1937d04

File tree

7 files changed

+251
-212
lines changed

7 files changed

+251
-212
lines changed

plugins/doc_fragments/cm_options.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
# See the License for the specific language governing permissions and
1616
# limitations under the License.
1717

18+
1819
class ModuleDocFragment(object):
19-
DOCUMENTATION = r'''
20+
DOCUMENTATION = r"""
2021
options:
2122
host:
2223
description:
@@ -55,6 +56,14 @@ class ModuleDocFragment(object):
5556
type: bool
5657
required: False
5758
default: True
59+
ssl_ca_cert:
60+
description:
61+
- Path to SSL CA certificate to use for validation.
62+
type: path
63+
required: False
64+
aliases:
65+
- tls_cert
66+
- ssl_cert
5867
username:
5968
description:
6069
- Username for access to the CM API endpoint.
@@ -80,4 +89,4 @@ class ModuleDocFragment(object):
8089
type: str
8190
required: False
8291
default: ClouderaFoundry
83-
'''
92+
"""

plugins/module_utils/cm_utils.py

Lines changed: 101 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
#!/usr/bin/env python
2-
# -*- coding: utf-8 -*-
3-
41
# Copyright 2023 Cloudera, Inc. All Rights Reserved.
52
#
63
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -30,6 +27,7 @@
3027
from urllib.parse import urljoin
3128

3229
from ansible.module_utils.basic import AnsibleModule
30+
from ansible.module_utils.common.text.converters import to_text
3331

3432
from cm_client import ApiClient, Configuration
3533
from cm_client.rest import ApiException, RESTClientObject
@@ -39,57 +37,65 @@
3937
__credits__ = ["frisch@cloudera.com"]
4038
__maintainer__ = ["wmudge@cloudera.com"]
4139

42-
"""
43-
A common Ansible Module for API access to Cloudera Manager.
44-
"""
4540

4641
class ClouderaManagerModule(object):
42+
"""Base Ansible Module for API access to Cloudera Manager."""
43+
4744
@classmethod
4845
def handle_process(cls, f):
49-
"""Wrapper to handle log capture and common HTTP errors"""
46+
"""Wrapper to handle API, retry, and HTTP errors."""
5047

5148
@wraps(f)
5249
def _impl(self, *args, **kwargs):
53-
try:
54-
self._initialize_client()
55-
result = f(self, *args, **kwargs)
50+
def _add_log(err):
5651
if self.debug:
57-
self.log_out = self._get_log()
58-
self.log_lines.append(self.log_out.splitlines())
59-
return result
52+
log = self.log_capture.getvalue()
53+
err.update(debug=log, debug_lines=log.split("\n"))
54+
return err
55+
56+
try:
57+
self.initialize_client()
58+
return f(self, *args, **kwargs)
6059
except ApiException as ae:
61-
body = ae.body.decode("utf-8")
62-
if body != "":
63-
body = json.loads(body)
64-
self.module.fail_json(
65-
msg="API error: " + str(ae.reason), status_code=ae.status, body=body
60+
err = dict(
61+
msg="API error: " + to_text(ae.reason),
62+
status_code=ae.status,
63+
body=ae.body.decode("utf-8"),
6664
)
65+
if err["body"] != "":
66+
try:
67+
err.update(body=json.loads(err["body"]))
68+
except Exception as te:
69+
pass
70+
71+
self.module.fail_json(**_add_log(err))
6772
except MaxRetryError as maxe:
68-
self.module.fail_json(msg="Request error: " + str(maxe.reason))
73+
err = dict(
74+
msg="Request error: " + to_text(maxe.reason), url=to_text(maxe.url)
75+
)
76+
self.module.fail_json(**_add_log(err))
6977
except HTTPError as he:
70-
self.module.fail_json(msg="HTTP request: " + str(he))
78+
err = dict(msg="HTTP request: " + str(he))
79+
self.module.fail_json(**_add_log(err))
7180

7281
return _impl
7382

74-
"""A base Cloudera Manager (CM) module class"""
75-
7683
def __init__(self, module):
7784
# Set common parameters
7885
self.module = module
79-
self.url = self._get_param("url", None)
80-
self.force_tls = self._get_param("force_tls")
81-
self.host = self._get_param("host")
82-
self.port = self._get_param("port")
83-
self.version = self._get_param("version")
84-
self.username = self._get_param("username")
85-
self.password = self._get_param("password")
86-
self.verify_tls = self._get_param("verify_tls")
87-
self.debug = self._get_param("debug")
88-
self.agent_header = self._get_param("agent_header")
86+
self.url = self.get_param("url", None)
87+
self.force_tls = self.get_param("force_tls")
88+
self.host = self.get_param("host")
89+
self.port = self.get_param("port")
90+
self.version = self.get_param("version")
91+
self.username = self.get_param("username")
92+
self.password = self.get_param("password")
93+
self.verify_tls = self.get_param("verify_tls")
94+
self.ssl_ca_cert = self.get_param("ssl_ca_cert")
95+
self.debug = self.get_param("debug")
96+
self.agent_header = self.get_param("agent_header")
8997

9098
# Initialize common return values
91-
self.log_out = None
92-
self.log_lines = []
9399
self.changed = False
94100

95101
# Configure the core CM API client parameters
@@ -99,69 +105,71 @@ def __init__(self, module):
99105
config.verify_ssl = self.verify_tls
100106
config.debug = self.debug
101107

102-
# Configure logging
103-
_log_format = (
108+
# Configure custom validation certificate
109+
if self.ssl_ca_cert:
110+
config.ssl_ca_cert = self.ssl_ca_cert
111+
112+
# Create a common logging format
113+
log_format = (
104114
"%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s"
105115
)
116+
117+
# Configure the urllib3 logger
118+
self.logger = logging.getLogger("cloudera.cluster")
119+
106120
if self.debug:
107-
self._setup_logger(logging.DEBUG, _log_format)
108-
self.logger.debug("CM API agent: %s", self.agent_header)
109-
else:
110-
self._setup_logger(logging.ERROR, _log_format)
121+
root_logger = logging.getLogger()
122+
root_logger.setLevel(logging.DEBUG)
123+
root_logger.propagate = True
124+
125+
self.log_capture = io.StringIO()
126+
handler = logging.StreamHandler(self.log_capture)
127+
128+
formatter = logging.Formatter(log_format)
129+
handler.setFormatter(formatter)
130+
131+
root_logger.addHandler(handler)
132+
133+
self.logger.debug("CM API agent: %s", self.agent_header)
111134

112135
if self.verify_tls is False:
113136
disable_warnings(InsecureRequestWarning)
114137

115-
def _get_param(self, param, default=None):
116-
"""Fetches an Ansible input parameter if it exists, else returns optional default or None"""
138+
def get_param(self, param, default=None):
139+
"""
140+
Fetches an Ansible input parameter if it exists, else returns optional
141+
default or None.
142+
"""
117143
if self.module is not None:
118144
return self.module.params[param] if param in self.module.params else default
119145
return default
120146

121-
def _setup_logger(self, log_level, log_format):
122-
"""Configures the logging of the HTTP activity"""
123-
self.logger = logging.getLogger("urllib3")
124-
self.logger.setLevel(log_level)
125-
126-
self.__log_capture = io.StringIO()
127-
handler = logging.StreamHandler(self.__log_capture)
128-
handler.setLevel(log_level)
129-
130-
formatter = logging.Formatter(log_format)
131-
handler.setFormatter(formatter)
132-
133-
self.logger.addHandler(handler)
134-
135-
def _get_log(self):
136-
"""Retrieves the contents of the captured log"""
137-
contents = self.__log_capture.getvalue()
138-
self.__log_capture.truncate(0)
139-
return contents
140-
141-
def _initialize_client(self):
142-
"""Configures and creates the API client"""
147+
def initialize_client(self):
148+
"""Creates the CM API client"""
143149
config = Configuration()
144150

145151
# If provided a CML endpoint URL, use it directly
146152
if self.url:
147153
config.host = self.url
148154
# Otherwise, run discovery on missing parts
149155
else:
150-
config.host = self._discover_endpoint(config)
156+
config.host = self.discover_endpoint(config)
151157

152158
# Create and set the API Client
153159
self.api_client = ApiClient()
154160

155161
def get_auth_headers(self, config):
156-
"""Constructs a Basic Auth header dictionary from the Configuration.
157-
This dictionary can be used directly with the API client's REST client."""
162+
"""
163+
Constructs a Basic Auth header dictionary from the Configuration. This
164+
dictionary can be used directly with the API client's REST client.
165+
"""
158166
headers = dict()
159167
auth = config.auth_settings().get("basic")
160168
headers[auth["key"]] = auth["value"]
161169
return headers
162170

163-
def _discover_endpoint(self, config):
164-
"""Discovers the scheme and version of a potential Cloudara Manager host"""
171+
def discover_endpoint(self, config):
172+
"""Discovers the scheme and version of a potential Cloudara Manager host."""
165173
# Get the authentication headers and REST client
166174
headers = self.get_auth_headers(config)
167175
rest = RESTClientObject()
@@ -173,7 +181,15 @@ def _discover_endpoint(self, config):
173181
rendered = rest.pool_manager.request(
174182
"GET", pre_rendered.url, headers=headers.copy()
175183
)
176-
rendered_url = rendered.geturl()
184+
185+
# Normalize to handle redirects
186+
try:
187+
rendered_url = rendered.url
188+
except Exception:
189+
rendered_url = rendered.geturl()
190+
191+
if rendered_url == "/":
192+
rendered_url = pre_rendered.url
177193

178194
# Discover API version if not set
179195
if not self.version:
@@ -213,20 +229,17 @@ def call_api(self, path, method, query=None, field="items", body=None):
213229
_preload_content=False,
214230
)
215231

216-
if 200 >= results[1] <= 299:
217-
data = json.loads(results[0].data.decode("utf-8"))
218-
if field in data:
219-
data = data[field]
220-
return data if type(data) is list else [data]
221-
else:
222-
self.module.fail_json(
223-
msg="Error interacting with CM resource", status_code=results[1]
224-
)
232+
data = json.loads(results[0].data.decode("utf-8"))
233+
if field in data:
234+
data = data[field]
235+
return data if type(data) is list else [data]
225236

226237
@staticmethod
227-
def ansible_module_discovery(argument_spec={}, required_together=[], **kwargs):
228-
"""INTERNAL: Creates the Ansible module argument spec and dependencies for CM API endpoint discovery.
229-
Typically, modules will use the ansible_module method to include direct API endpoint URL support.
238+
def ansible_module_internal(argument_spec={}, required_together=[], **kwargs):
239+
"""
240+
INTERNAL: Creates the Ansible module argument spec and dependencies for
241+
CM API endpoint discovery. Typically, modules will use the
242+
ansible_module method to include direct API endpoint URL support.
230243
"""
231244
return AnsibleModule(
232245
argument_spec=dict(
@@ -238,6 +251,7 @@ def ansible_module_discovery(argument_spec={}, required_together=[], **kwargs):
238251
verify_tls=dict(
239252
required=False, type="bool", default=True, aliases=["tls"]
240253
),
254+
ssl_ca_cert=dict(type="path", aliases=["tls_cert", "ssl_cert"]),
241255
username=dict(required=True, type="str"),
242256
password=dict(required=True, type="str", no_log=True),
243257
debug=dict(
@@ -262,8 +276,11 @@ def ansible_module(
262276
required_together=[],
263277
**kwargs
264278
):
265-
"""Creates the base Ansible module argument spec and dependencies, including discovery and direct endpoint URL support."""
266-
return ClouderaManagerModule.ansible_module_discovery(
279+
"""
280+
Creates the base Ansible module argument spec and dependencies,
281+
including discovery and direct endpoint URL support.
282+
"""
283+
return ClouderaManagerModule.ansible_module_internal(
267284
argument_spec=dict(
268285
**argument_spec,
269286
url=dict(type="str", aliases=["endpoint", "cm_endpoint_url"]),

plugins/modules/assemble_cluster_template.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@
138138
import tempfile
139139

140140
from ansible.module_utils.basic import AnsibleModule
141-
from ansible.module_utils.common.text.converters import to_native, to_text
141+
from ansible.module_utils.common.text.converters import to_native
142142

143143

144144
class AssembleClusterTemplate(object):

0 commit comments

Comments
 (0)