blob: 5d74da1e12af467e43216ec0faeb778f77c3d3f9 [file] [log] [blame]
Petr Ospalý2bfe0f92019-03-26 22:13:00 +01001#!/usr/bin/python
2
3from ansible.module_utils.basic import AnsibleModule
4
5import requests
6import json
7import functools
Petr Ospalý3a6558a2019-04-08 08:39:41 +02008import time
Petr Ospalý2bfe0f92019-03-26 22:13:00 +01009
10DOCUMENTATION = """
11---
12module: rancher1_api
13short_description: Client library for rancher API
14description:
15 - This module modifies a rancher 1.6 using it's API (v1).
Petr Ospalý9dee2012019-04-05 09:57:03 +020016 - It supports some rancher features by the virtue of a 'mode'.
17 - 'modes' hide from you some necessary cruft and expose you to the only
18 important and interestig variables wich must be set. The mode mechanism
19 makes this module more easy to use and you don't have to create an
20 unnecessary boilerplate for the API.
21 - Only a few modes are/will be implemented so far - as they are/will be
22 needed. In the future the 'raw' mode can be added to enable you to craft
23 your own API requests, but that would be on the same level of a user
24 experience as running curl commands, and because the rancher 1.6 is already
25 obsoleted by the project, it would be a wasted effort.
Petr Ospalý2bfe0f92019-03-26 22:13:00 +010026options:
27 rancher:
28 description:
29 - The domain name or the IP address and the port of the rancher
30 where API is exposed.
31 - For example: http://10.0.0.1:8080
32 required: true
33 aliases:
34 - server
Petr Ospalý9dee2012019-04-05 09:57:03 +020035 - rancher_server
Petr Ospalý2bfe0f92019-03-26 22:13:00 +010036 - rancher_api
37 - api
Petr Ospalý9dee2012019-04-05 09:57:03 +020038 account_key:
Petr Ospalý2bfe0f92019-03-26 22:13:00 +010039 description:
Petr Ospalý9dee2012019-04-05 09:57:03 +020040 - The public and secret part of the API key-pair separated by colon.
41 - You can find all your keys in web UI.
42 - For example:
43 B1716C4133D3825051CB:3P2eb3QhokFKYUiXRNZLxvGNSRYgh6LHjuMicCHQ
Petr Ospalý2bfe0f92019-03-26 22:13:00 +010044 required: false
Petr Ospalý9dee2012019-04-05 09:57:03 +020045 mode:
Petr Ospalý2bfe0f92019-03-26 22:13:00 +010046 description:
Petr Ospalý9dee2012019-04-05 09:57:03 +020047 - A recognized mode how to deal with some concrete configuration task
48 in rancher API to ease the usage.
49 - The implemented modes so far are:
50 'settings':
51 Many options under <api_server>/v1/settings API url and some can
52 be seen also under advanced options in the web UI.
53 'access_control':
54 It setups user and password for the account (defaults to 'admin')
55 and it enables the local authentication - so the web UI and API
56 will require username/password (UI) or apikey (API).
Petr Ospalý2bfe0f92019-03-26 22:13:00 +010057 required: true
58 aliases:
Petr Ospalý9dee2012019-04-05 09:57:03 +020059 - rancher_mode
60 - api_mode
61 choices:
Petr Ospalý2bfe0f92019-03-26 22:13:00 +010062 - settings
Petr Ospalý9dee2012019-04-05 09:57:03 +020063 - access_control
64 data:
Petr Ospalý2bfe0f92019-03-26 22:13:00 +010065 description:
Petr Ospalý9dee2012019-04-05 09:57:03 +020066 - Dictionary with key/value pairs. The actual names and meaning of pairs
67 depends on the used mode.
Petr Ospalý72b09b12019-04-08 04:55:47 +020068 - settings mode:
69 option - Option/path in JSON API (url).
70 value - A new value to replace the current one.
71 - access_control mode:
72 account_id - The unique ID of the account - the default rancher admin
73 has '1a1'. Better way would be to just create arbitrary username and
74 set credentials for that, but due to time constraints, the route with
75 an ID is simpler. The designated '1a1' could be hardcoded and hidden
76 but if the user will want to use some other account (there are many),
77 then it can be just changed to some other ID.
78 password - A new password in a plaintext.
Petr Ospalý2bfe0f92019-03-26 22:13:00 +010079 required: true
80 timeout:
81 description:
82 - How long in seconds to wait for a response before raising error
83 required: false
84 default: 10.0
85"""
86
Petr Ospalý9dee2012019-04-05 09:57:03 +020087default_timeout = 10.0
88
89
90class ModeError(Exception):
91 pass
92
Petr Ospalý2bfe0f92019-03-26 22:13:00 +010093
94def _decorate_rancher_api_request(request_method):
95
96 @functools.wraps(request_method)
97 def wrap_request(*args, **kwargs):
98
99 response = request_method(*args, **kwargs)
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200100 authorized = True
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100101
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200102 if response.status_code == 401:
103 authorized = False
104 elif response.status_code != requests.codes.ok:
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100105 response.raise_for_status()
106
107 try:
108 json_data = response.json()
109 except Exception:
110 json_data = None
111
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200112 return json_data, authorized
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100113
114 return wrap_request
115
116
117@_decorate_rancher_api_request
Petr Ospalý9dee2012019-04-05 09:57:03 +0200118def get_rancher_api_value(url, headers=None, timeout=default_timeout,
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100119 username=None, password=None):
120
121 if username and password:
122 return requests.get(url, headers=headers,
123 timeout=timeout,
124 allow_redirects=False,
125 auth=(username, password))
126 else:
127 return requests.get(url, headers=headers,
128 timeout=timeout,
129 allow_redirects=False)
130
131
132@_decorate_rancher_api_request
Petr Ospalý9dee2012019-04-05 09:57:03 +0200133def set_rancher_api_value(url, payload, headers=None, timeout=default_timeout,
Petr Ospalý72b09b12019-04-08 04:55:47 +0200134 username=None, password=None, method='PUT'):
135
136 if method == 'PUT':
137 request_set_method = requests.put
138 elif method == 'POST':
139 request_set_method = requests.post
140 else:
141 raise ModeError('ERROR: Wrong request method: %s' % str(method))
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100142
143 if username and password:
Petr Ospalý72b09b12019-04-08 04:55:47 +0200144 return request_set_method(url, headers=headers,
145 timeout=timeout,
146 allow_redirects=False,
147 data=json.dumps(payload),
148 auth=(username, password))
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100149 else:
Petr Ospalý72b09b12019-04-08 04:55:47 +0200150 return request_set_method(url, headers=headers,
151 timeout=timeout,
152 allow_redirects=False,
153 data=json.dumps(payload))
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100154
155
Petr Ospalý9dee2012019-04-05 09:57:03 +0200156def create_rancher_api_url(server, mode, option):
157 request_url = server.strip('/') + '/v1/'
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100158
Petr Ospalý9dee2012019-04-05 09:57:03 +0200159 if mode == 'raw':
160 request_url += option.strip('/')
161 elif mode == 'settings':
162 request_url += 'settings/' + option.strip('/')
163 elif mode == 'access_control':
164 request_url += option.strip('/')
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100165
Petr Ospalý9dee2012019-04-05 09:57:03 +0200166 return request_url
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100167
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100168
Petr Ospalý9dee2012019-04-05 09:57:03 +0200169def get_keypair(keypair):
170 if keypair:
171 keypair = keypair.split(':')
172 if len(keypair) == 2:
173 return keypair[0], keypair[1]
174
175 return None, None
176
177
Petr Ospalý72b09b12019-04-08 04:55:47 +0200178def mode_access_control(api_url, data=None, headers=None,
179 timeout=default_timeout, access_key=None,
180 secret_key=None, dry_run=False):
181
182 # returns true if local auth was enabled or false if passwd changed
183 def is_admin_enabled(json_data, password):
184 try:
185 if json_data['type'] == "localAuthConfig" and \
186 json_data['accessMode'] == "unrestricted" and \
187 json_data['username'] == "admin" and \
188 json_data['password'] == password and \
189 json_data['enabled']:
190 return True
191 except Exception:
192 pass
193
194 try:
195 if json_data['type'] == "error" and \
196 json_data['code'] == "IncorrectPassword":
197 return False
198 except Exception:
199 pass
200
201 # this should never happen
202 raise ModeError('ERROR: Unknown status of the local authentication')
203
204 def create_localauth_payload(password):
205 payload = {
206 "enabled": True,
207 "accessMode": "unrestricted",
208 "username": "admin",
209 "password": password
210 }
211
212 return payload
213
214 def get_admin_password_id():
215 # assemble request URL
216 request_url = api_url + 'accounts/' + data['account_id'].strip('/') \
217 + '/credentials'
218
219 # API get current value
220 try:
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200221 json_response, authorized = \
222 get_rancher_api_value(request_url,
223 username=access_key,
224 password=secret_key,
225 headers=headers,
226 timeout=timeout)
Petr Ospalý72b09b12019-04-08 04:55:47 +0200227 except requests.HTTPError as e:
228 raise ModeError(str(e))
229 except requests.Timeout as e:
230 raise ModeError(str(e))
231
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200232 if not authorized or not json_response:
Petr Ospalý72b09b12019-04-08 04:55:47 +0200233 raise ModeError('ERROR: BAD RESPONSE (GET) - no json value in '
234 + 'the response')
235
236 try:
237 for item in json_response['data']:
238 if item['type'] == 'password' and \
239 item['accountId'] == data['account_id']:
240 return item['id']
241 except Exception:
242 pass
243
244 return None
245
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200246 def remove_password(password_id, action):
247 if action == 'deactivate':
248 action_status = 'deactivating'
249 elif action == 'remove':
250 action_status = 'removing'
251
252 request_url = api_url + 'passwords/' + password_id + \
253 '/?action=' + action
254
255 try:
256 json_response, authorized = \
257 set_rancher_api_value(request_url,
258 {},
259 username=access_key,
260 password=secret_key,
261 headers=headers,
262 method='POST',
263 timeout=timeout)
264 except requests.HTTPError as e:
265 raise ModeError(str(e))
266 except requests.Timeout as e:
267 raise ModeError(str(e))
268
269 if not authorized or not json_response:
270 raise ModeError('ERROR: BAD RESPONSE (POST) - no json value in '
271 + 'the response')
272
273 if json_response['state'] != action_status:
274 raise ModeError("ERROR: Failed to '%s' the password: %s" %
275 (action, password_id))
276
Petr Ospalý72b09b12019-04-08 04:55:47 +0200277 # check if data contains all required fields
278 try:
279 if not isinstance(data['account_id'], str) or data['account_id'] == '':
280 raise ModeError("ERROR: 'account_id' must contain an id of the "
281 + "affected account")
282 except KeyError:
283 raise ModeError("ERROR: Mode 'access_control' requires the field: "
284 + "'account_id': %s" % str(data))
285 try:
286 if not isinstance(data['password'], str) or data['password'] == '':
287 raise ModeError("ERROR: 'password' must contain some password")
288 except KeyError:
289 raise ModeError("ERROR: Mode 'access_control' requires the field: "
290 + "'password': %s" % str(data))
291
292 # assemble request URL
293 request_url = api_url + 'localauthconfigs'
294
295 # API get current value
296 try:
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200297 json_response, authorized = \
298 get_rancher_api_value(request_url,
299 username=access_key,
300 password=secret_key,
301 headers=headers,
302 timeout=timeout)
Petr Ospalý72b09b12019-04-08 04:55:47 +0200303 except requests.HTTPError as e:
304 raise ModeError(str(e))
305 except requests.Timeout as e:
306 raise ModeError(str(e))
307
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200308 if not authorized or not json_response:
Petr Ospalý72b09b12019-04-08 04:55:47 +0200309 raise ModeError('ERROR: BAD RESPONSE (GET) - no json value in the '
310 + 'response')
311
312 # we will check if local auth is enabled or not
313 enabled = False
314 try:
315 for item in json_response['data']:
316 if item['type'] == 'localAuthConfig' and \
317 item['accessMode'] == 'unrestricted' and \
318 item['enabled']:
319 enabled = True
320 break
321 except Exception:
322 enabled = False
323
324 if dry_run:
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200325 # we will not set anything and only signal potential change
Petr Ospalý72b09b12019-04-08 04:55:47 +0200326 if enabled:
327 changed = False
328 else:
329 changed = True
330 else:
331 # we will try to enable again with the same password
332 localauth_payload = create_localauth_payload(data['password'])
Petr Ospalý72b09b12019-04-08 04:55:47 +0200333 try:
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200334 json_response, authorized = \
335 set_rancher_api_value(request_url,
336 localauth_payload,
337 username=access_key,
338 password=secret_key,
339 headers=headers,
340 method='POST',
341 timeout=timeout)
Petr Ospalý72b09b12019-04-08 04:55:47 +0200342 except requests.HTTPError as e:
343 raise ModeError(str(e))
344 except requests.Timeout as e:
345 raise ModeError(str(e))
346
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200347 # here we ignore authorized status - we will try to reset password
Petr Ospalý72b09b12019-04-08 04:55:47 +0200348 if not json_response:
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200349 raise ModeError('ERROR: BAD RESPONSE (POST) - no json value in '
Petr Ospalý72b09b12019-04-08 04:55:47 +0200350 + 'the response')
351
352 # we check if the admin was already set or not...
353 if enabled and is_admin_enabled(json_response, data['password']):
354 # it was enabled before and password is the same - no change
355 changed = False
356 elif is_admin_enabled(json_response, data['password']):
357 # we enabled it for the first time
358 changed = True
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200359 # ...and reset password if needed (unauthorized access)
Petr Ospalý72b09b12019-04-08 04:55:47 +0200360 else:
361 # local auth is enabled but the password differs
362 # we must reset the admin's password
363 password_id = get_admin_password_id()
364
365 if password_id is None:
366 raise ModeError("ERROR: admin's password is set, but we "
367 + "cannot identify it")
368
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200369 # One of the way to reset the password is to remove it first
370 # TODO - refactor this
371 remove_password(password_id, 'deactivate')
372 time.sleep(2)
373 remove_password(password_id, 'remove')
374 time.sleep(1)
375
376 try:
377 json_response, authorized = \
378 set_rancher_api_value(request_url,
379 localauth_payload,
380 username=access_key,
381 password=secret_key,
382 headers=headers,
383 method='POST',
384 timeout=timeout)
385 except requests.HTTPError as e:
386 raise ModeError(str(e))
387 except requests.Timeout as e:
388 raise ModeError(str(e))
389
390 # finally we signal the change
391 changed = True
Petr Ospalý72b09b12019-04-08 04:55:47 +0200392
393 if changed:
394 msg = "Local authentication is enabled, admin has assigned password"
395 else:
396 msg = "Local authentication was already enabled, admin's password " \
397 + "is unchanged"
398
399 return changed, msg
400
401
Petr Ospalý9dee2012019-04-05 09:57:03 +0200402def mode_settings(api_url, data=None, headers=None, timeout=default_timeout,
403 access_key=None, secret_key=None, dry_run=False):
404
405 def is_valid_rancher_api_option(json_data):
406
407 try:
408 api_activeValue = json_data['activeValue']
409 api_source = json_data['source']
410 except Exception:
411 return False
412
413 if api_activeValue is None and api_source is None:
414 return False
415
416 return True
417
418 def create_rancher_api_payload(json_data, new_value):
419
420 payload = {}
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100421 differs = False
422
Petr Ospalý9dee2012019-04-05 09:57:03 +0200423 try:
424 api_id = json_data['id']
425 api_activeValue = json_data['activeValue']
426 api_name = json_data['name']
427 api_source = json_data['source']
428 except Exception:
429 raise ValueError
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100430
Petr Ospalý9dee2012019-04-05 09:57:03 +0200431 payload.update({"activeValue": api_activeValue,
432 "id": api_id,
433 "name": api_name,
434 "source": api_source,
435 "value": new_value})
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100436
Petr Ospalý9dee2012019-04-05 09:57:03 +0200437 if api_activeValue != new_value:
438 differs = True
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100439
Petr Ospalý9dee2012019-04-05 09:57:03 +0200440 return differs, payload
441
442 # check if data contains all required fields
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100443 try:
Petr Ospalý9dee2012019-04-05 09:57:03 +0200444 if not isinstance(data['option'], str) or data['option'] == '':
Petr Ospalý72b09b12019-04-08 04:55:47 +0200445 raise ModeError("ERROR: 'option' must contain a name of the "
446 + "option")
Petr Ospalý9dee2012019-04-05 09:57:03 +0200447 except KeyError:
Petr Ospalý72b09b12019-04-08 04:55:47 +0200448 raise ModeError("ERROR: Mode 'settings' requires the field: 'option': "
449 + "%s" % str(data))
Petr Ospalý9dee2012019-04-05 09:57:03 +0200450 try:
451 if not isinstance(data['value'], str) or data['value'] == '':
452 raise ModeError("ERROR: 'value' must contain a value")
453 except KeyError:
Petr Ospalý72b09b12019-04-08 04:55:47 +0200454 raise ModeError("ERROR: Mode 'settings' requires the field: 'value': "
455 + "%s" % str(data))
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100456
Petr Ospalý9dee2012019-04-05 09:57:03 +0200457 # assemble request URL
458 request_url = api_url + 'settings/' + data['option'].strip('/')
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100459
Petr Ospalý9dee2012019-04-05 09:57:03 +0200460 # API get current value
461 try:
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200462 json_response, authorized = \
463 get_rancher_api_value(request_url,
464 username=access_key,
465 password=secret_key,
466 headers=headers,
467 timeout=timeout)
Petr Ospalý9dee2012019-04-05 09:57:03 +0200468 except requests.HTTPError as e:
469 raise ModeError(str(e))
470 except requests.Timeout as e:
471 raise ModeError(str(e))
472
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200473 if not authorized or not json_response:
Petr Ospalý72b09b12019-04-08 04:55:47 +0200474 raise ModeError('ERROR: BAD RESPONSE (GET) - no json value in the '
475 + 'response')
Petr Ospalý9dee2012019-04-05 09:57:03 +0200476
477 if is_valid_rancher_api_option(json_response):
478 valid = True
479 try:
480 differs, payload = create_rancher_api_payload(json_response,
481 data['value'])
482 except ValueError:
Petr Ospalý72b09b12019-04-08 04:55:47 +0200483 raise ModeError('ERROR: INVALID JSON - missing json values in '
484 + 'the response')
Petr Ospalý9dee2012019-04-05 09:57:03 +0200485 else:
486 valid = False
487
488 if valid and differs and dry_run:
489 # ansible dry-run mode
490 changed = True
491 elif valid and differs:
492 # API set new value
493 try:
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200494 json_response, authorized = \
495 set_rancher_api_value(request_url,
496 payload,
497 username=access_key,
498 password=secret_key,
499 headers=headers,
500 timeout=timeout)
Petr Ospalý9dee2012019-04-05 09:57:03 +0200501 except requests.HTTPError as e:
502 raise ModeError(str(e))
503 except requests.Timeout as e:
504 raise ModeError(str(e))
505
Petr Ospalý3a6558a2019-04-08 08:39:41 +0200506 if not authorized or not json_response:
Petr Ospalý72b09b12019-04-08 04:55:47 +0200507 raise ModeError('ERROR: BAD RESPONSE (PUT) - no json value in '
508 + 'the response')
Petr Ospalý9dee2012019-04-05 09:57:03 +0200509 else:
510 changed = True
511 else:
512 changed = False
513
514 if changed:
515 msg = "Option '%s' is now set to the new value: %s" \
516 % (data['option'], data['value'])
517 else:
518 msg = "Option '%s' is unchanged." % (data['option'])
519
520 return changed, msg
521
522
523def mode_handler(server, rancher_mode, data=None, timeout=default_timeout,
524 account_key=None, dry_run=False):
525
526 changed = False
527 msg = 'UNKNOWN: UNAPPLICABLE MODE'
528
529 # check API key-pair
530 if account_key:
531 access_key, secret_key = get_keypair(account_key)
532 if not (access_key and secret_key):
533 raise ModeError('ERROR: INVALID API KEY-PAIR')
534
535 # all requests share these headers
536 http_headers = {'Content-Type': 'application/json',
537 'Accept': 'application/json'}
538
539 # assemble API url
540 api_url = server.strip('/') + '/v1/'
541
542 if rancher_mode == 'settings':
543 changed, msg = mode_settings(api_url, data=data,
544 headers=http_headers,
545 timeout=timeout,
546 access_key=access_key,
547 secret_key=secret_key,
548 dry_run=dry_run)
549 elif rancher_mode == 'access_control':
Petr Ospalý72b09b12019-04-08 04:55:47 +0200550 changed, msg = mode_access_control(api_url, data=data,
551 headers=http_headers,
552 timeout=timeout,
553 access_key=access_key,
554 secret_key=secret_key,
555 dry_run=dry_run)
Petr Ospalý9dee2012019-04-05 09:57:03 +0200556
557 return changed, msg
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100558
559
560def main():
561 module = AnsibleModule(
562 argument_spec=dict(
563 rancher=dict(type='str', required=True,
564 aliases=['server',
565 'rancher_api',
Petr Ospalý9dee2012019-04-05 09:57:03 +0200566 'rancher_server',
567 'api']),
568 account_key=dict(type='str', required=False),
569 mode=dict(required=True,
570 choices=['settings', 'access_control'],
571 aliases=['api_mode']),
572 data=dict(type='dict', required=True),
573 timeout=dict(type='float', default=default_timeout),
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100574 ),
575 supports_check_mode=True
576 )
577
Petr Ospalý9dee2012019-04-05 09:57:03 +0200578 rancher_server = module.params['rancher']
579 rancher_account_key = module.params['account_key']
580 rancher_mode = module.params['mode']
581 rancher_data = module.params['data']
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100582 rancher_timeout = module.params['timeout']
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100583
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100584 try:
Petr Ospalý9dee2012019-04-05 09:57:03 +0200585 changed, msg = mode_handler(rancher_server,
586 rancher_mode,
587 data=rancher_data,
588 account_key=rancher_account_key,
589 timeout=rancher_timeout,
590 dry_run=module.check_mode)
591 except ModeError as e:
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100592 module.fail_json(msg=str(e))
Petr Ospalý2bfe0f92019-03-26 22:13:00 +0100593
594 module.exit_json(changed=changed, msg=msg)
595
596
597if __name__ == '__main__':
598 main()