Skip to content

Commit 93d8678

Browse files
authored
Merge pull request #75 from marcelmamula/hdbsql
sap_hdbsql: Add error handling and secure flags for hdbsql
2 parents 9bf7d41 + 8c04a72 commit 93d8678

2 files changed

Lines changed: 181 additions & 52 deletions

File tree

plugins/modules/sap_hdbsql.py

Lines changed: 97 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@
7676
type: list
7777
elements: str
7878
notes:
79-
- Does not support C(check_mode). Always reports that the state has changed even if no changes have been made.
79+
- Does not support C(check_mode).
80+
- If filepath is used, changed is true. If query is used, changed is true if it is not a SELECT.
81+
- Avoid using login shell flags (like 'become_flags' with value of '-i') when become_user is a SAP admin.
82+
- If a login shell is required, manually reset the environment in the task using the environment keyword.
8083
author:
8184
- Rainer Leber (@rainerleber)
8285
'''
@@ -156,12 +159,37 @@
156159
from ansible.module_utils.common.text.converters import to_native
157160

158161

159-
def csv_to_list(rawcsv):
160-
reader_raw = csv.DictReader(StringIO(rawcsv))
161-
reader = [dict((k, v.strip()) for k, v in row.items()) for row in reader_raw]
162+
def csv_to_list(raw_csv):
163+
if not raw_csv.strip():
164+
return []
165+
166+
reader_raw = csv.DictReader(StringIO(raw_csv))
167+
# Using to_native and strip to ensure clean dictionary keys/values
168+
reader = [dict((to_native(k), to_native(v).strip()) for k, v in row.items()) for row in reader_raw]
162169
return list(reader)
163170

164171

172+
def run_hdb_command(module, full_cmd):
173+
rc, out_raw, err = module.run_command(full_cmd)
174+
175+
if rc != 0:
176+
err_msg = to_native(err).lower()
177+
178+
if "authentication failed" in err_msg or "invalid username or password" in err_msg:
179+
module.fail_json(msg="SAP HANA Authentication Failed. Please check credentials/userstore.", rc=rc, stderr=err)
180+
181+
elif "insufficient privilege" in err_msg or "258:" in err_msg:
182+
module.fail_json(msg="SAP HANA Authorization Error: The user has insufficient privileges to perform this action.", rc=rc, stderr=err)
183+
184+
elif "connect failed" in err_msg:
185+
module.fail_json(msg="SAP HANA Connection Failed. Check host, instance, and port.", rc=rc, stderr=err)
186+
187+
else:
188+
module.fail_json(msg="SQL Execution Error", rc=rc, stderr=err, cmd=full_cmd)
189+
190+
return out_raw
191+
192+
165193
def main():
166194
module = AnsibleModule(
167195
argument_spec=dict(
@@ -178,68 +206,88 @@ def main():
178206
filepath=dict(type='list', elements='path', required=False),
179207
autocommit=dict(type='bool', default=True),
180208
),
181-
required_one_of=[('query', 'filepath'), ('sid', 'instance')],
209+
required_one_of=[('query', 'filepath')],
182210
required_if=[('userstore', False, ['password'])],
183211
supports_check_mode=False,
184212
)
185-
rc, out, err, out_raw = [0, [], "", ""]
186213

187214
params = module.params
215+
output = []
216+
has_changed = False
188217

189-
sid = params['sid']
190-
bin_path = params['bin_path']
191-
instance = params['instance']
192-
user = params['user']
193-
userstore = params['userstore']
194-
password = params['password']
195-
autocommit = params['autocommit']
196-
host = params['host']
197-
database = params['database']
198-
encrypted = params['encrypted']
199-
200-
filepath = params['filepath']
201-
query = params['query']
218+
# Determine if module will show as changed.
219+
# If filepaths are provided, we assume changes will be made, as files typically contain DDL or DML statements.
220+
# If only queries are provided, we check if any of them are not SELECT statements. If at least one is not a SELECT, we assume changes will be made.
221+
if params['filepath']:
222+
has_changed = True
202223

224+
elif params['query']:
225+
for q in params['query']:
226+
# Strip whitespace and check if it starts with SELECT
227+
if not q.strip().upper().startswith("SELECT"):
228+
has_changed = True
229+
break
230+
231+
# Construct Binary Path
232+
bin_path = params['bin_path']
203233
if bin_path is None:
204-
bin_path = "/usr/sap/{sid}/HDB{instance}/exe/hdbsql".format(sid=sid.upper(), instance=instance)
234+
if not params['sid']:
235+
module.fail_json(msg="Parameter 'sid' is required to determine default bin_path if 'bin_path' is not provided.")
236+
237+
bin_path = "/usr/sap/{sid}/HDB{instance}/exe/hdbsql".format(
238+
sid=params['sid'].upper(),
239+
instance=params['instance']
240+
)
205241

206242
try:
207243
command = [module.get_bin_path(bin_path, required=True)]
208244
except Exception as e:
209-
module.fail_json(msg='Failed to find hdbsql at the expected path "{0}".Please check SID and instance number: "{1}"'.format(bin_path, to_native(e)))
245+
module.fail_json(msg='Executable binary hdbsql not found at {0}: {1}'.format(bin_path, to_native(e)))
210246

211-
if encrypted is True:
212-
command.extend(['-attemptencrypt'])
213-
if autocommit is False:
247+
# Build Base Command
248+
if params['encrypted']:
249+
# -e: Enforce encryption (don't fall back)
250+
# -ssltrustcert: Trust the server certificate
251+
# -sslcreatecert: Create a local certificate if necessary
252+
command.extend(['-e', '-ssltrustcert', '-sslcreatecert'])
253+
254+
if not params['autocommit']:
214255
command.extend(['-z'])
215-
if host is not None:
216-
command.extend(['-n', host])
217-
if database is not None:
218-
command.extend(['-d', database])
219-
# -x Suppresses additional output, such as the number of selected rows in a result set.
220-
if userstore:
221-
command.extend(['-x', '-U', user])
256+
257+
if params['host']:
258+
command.extend(['-n', params['host']])
259+
260+
if params['database']:
261+
command.extend(['-d', params['database']])
262+
263+
if params['userstore']:
264+
command.extend(['-x', '-U', params['user']])
265+
222266
else:
223-
command.extend(['-x', '-i', instance, '-u', user, '-p', password])
224-
225-
if filepath is not None:
226-
command.extend(['-E 3', '-I'])
227-
for p in filepath:
228-
# makes a command like hdbsql -i 01 -u SYSTEM -p secret123# -I /tmp/HANA_CPU_UtilizationPerCore_2.00.020+.txt,
229-
# iterates through files and append the output to var out.
230-
query_command = command + [p]
231-
(rc, out_raw, err) = module.run_command(query_command)
232-
out.append(csv_to_list(out_raw))
233-
if query is not None:
234-
for q in query:
235-
# makes a command like hdbsql -i 01 -u SYSTEM -p secret123# "select user_name from users",
236-
# iterates through multiple commands and append the output to var out.
267+
command.extend(['-x', '-i', params['instance'], '-u', params['user'], '-p', params['password']])
268+
269+
# Process Queries
270+
if params['query']:
271+
for q in params['query']:
237272
query_command = command + [q]
238-
(rc, out_raw, err) = module.run_command(query_command)
239-
out.append(csv_to_list(out_raw))
240-
changed = True
273+
out_raw = run_hdb_command(module, query_command)
274+
try:
275+
output.append(csv_to_list(out_raw))
276+
except Exception as e:
277+
module.fail_json(msg="Failed to parse CSV output: {0}".format(to_native(e)))
278+
279+
# Process Files
280+
# Note: File processing adds extra arguments so it has to execute after query processing.
281+
if params['filepath']:
282+
for p in params['filepath']:
283+
file_query_command = command + ['-E', '3', '-I', p]
284+
out_raw = run_hdb_command(module, file_query_command)
285+
try:
286+
output.append(csv_to_list(out_raw))
287+
except Exception as e:
288+
module.fail_json(msg="Failed to parse output from file {0}: {1}".format(p, to_native(e)))
241289

242-
module.exit_json(changed=changed, rc=rc, query_result=out, stderr=err)
290+
module.exit_json(changed=has_changed, query_result=output)
243291

244292

245293
if __name__ == '__main__':

tests/unit/plugins/modules/test_sap_hdbsql.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def test_sap_hdbsql(self):
5353
'user': "SYSTEM",
5454
'password': "1234Qwer",
5555
'database': "HDB",
56-
'query': "SELECT * FROM users;"
56+
'query': ["SELECT * FROM users;"]
5757
}
5858
with patch.object(basic.AnsibleModule, 'run_command') as run_command:
5959
run_command.return_value = 0, 'username,name\n testuser,test user \n myuser, my user \n', ''
@@ -76,7 +76,7 @@ def test_hana_userstore_query(self):
7676
'user': "SYSTEM",
7777
'userstore': True,
7878
'database': "HDB",
79-
'query': "SELECT * FROM users;"
79+
'query': ["SELECT * FROM users;"]
8080
}
8181
with patch.object(basic.AnsibleModule, 'run_command') as run_command:
8282
run_command.return_value = 0, 'username,name\n testuser,test user \n myuser, my user \n', ''
@@ -99,7 +99,88 @@ def test_hana_failed_no_passwd(self):
9999
'host': "localhost",
100100
'user': "SYSTEM",
101101
'database': "HDB",
102-
'query': "SELECT * FROM users;"
102+
'query': ["SELECT * FROM users;"]
103103
}
104104
with set_module_args(args):
105105
self.module.main()
106+
107+
def test_changed_status_select(self):
108+
"""Verify SELECT query returns changed=False"""
109+
args = {
110+
'sid': "HDB",
111+
'instance': "01",
112+
'password': "pwd",
113+
'query': ["SELECT * FROM users;"]
114+
}
115+
with patch.object(basic.AnsibleModule, 'run_command') as run_command:
116+
run_command.return_value = 0, 'col1\nval1', ''
117+
with self.assertRaises(AnsibleExitJson) as result:
118+
with set_module_args(args):
119+
self.module.main()
120+
self.assertFalse(result.exception.args[0]['changed'])
121+
122+
def test_changed_status_update(self):
123+
"""Verify UPDATE query returns changed=True"""
124+
args = {
125+
'sid': "HDB",
126+
'instance': "01",
127+
'password': "pwd",
128+
'query': ["UPDATE users SET name='test';"]
129+
}
130+
with patch.object(basic.AnsibleModule, 'run_command') as run_command:
131+
run_command.return_value = 0, '', '' # Updates often return empty string
132+
with self.assertRaises(AnsibleExitJson) as result:
133+
with set_module_args(args):
134+
self.module.main()
135+
self.assertTrue(result.exception.args[0]['changed'])
136+
137+
def test_authentication_failure(self):
138+
"""Verify specific error message for Auth Failure"""
139+
args = {
140+
'sid': "HDB",
141+
'instance': "01",
142+
'password': "wrong",
143+
'query': ["SELECT 1 FROM DUMMY;"]
144+
}
145+
with patch.object(basic.AnsibleModule, 'run_command') as run_command:
146+
run_command.return_value = 10, '', 'Authentication failed'
147+
with self.assertRaises(AnsibleFailJson) as result:
148+
with set_module_args(args):
149+
self.module.main()
150+
self.assertIn("Authentication Failed", result.exception.args[0]['msg'])
151+
152+
def test_insufficient_privilege(self):
153+
"""Verify specific error message for Privilege Error (Error 258)"""
154+
args = {
155+
'sid': "HDB",
156+
'instance': "01",
157+
'password': "pwd",
158+
'query': ["DROP TABLE important_stuff;"]
159+
}
160+
with patch.object(basic.AnsibleModule, 'run_command') as run_command:
161+
run_command.return_value = 1, '', '* 258: insufficient privilege'
162+
with self.assertRaises(AnsibleFailJson) as result:
163+
with set_module_args(args):
164+
self.module.main()
165+
self.assertIn("Authorization Error", result.exception.args[0]['msg'])
166+
167+
def test_encrypted_connection_flags(self):
168+
"""Verify that encryption flags are correctly added to the command"""
169+
args = {
170+
'sid': "HDB",
171+
'instance': "01",
172+
'password': "pwd",
173+
'encrypted': True,
174+
'query': ["SELECT 1 FROM DUMMY;"]
175+
}
176+
with patch.object(basic.AnsibleModule, 'run_command') as run_command:
177+
run_command.return_value = 0, '1', ''
178+
with self.assertRaises(AnsibleExitJson):
179+
with set_module_args(args):
180+
self.module.main()
181+
182+
# Get the actual command executed
183+
executed_cmd = run_command.call_args[0][0]
184+
self.assertIn('-e', executed_cmd)
185+
self.assertIn('-ssltrustcert', executed_cmd)
186+
self.assertIn('-sslcreatecert', executed_cmd)

0 commit comments

Comments
 (0)