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"); 
3027from  urllib .parse  import  urljoin 
3128
3229from  ansible .module_utils .basic  import  AnsibleModule 
30+ from  ansible .module_utils .common .text .converters  import  to_text 
3331
3432from  cm_client  import  ApiClient , Configuration 
3533from  cm_client .rest  import  ApiException , RESTClientObject 
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
4641class  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" ]),
0 commit comments