|
| 1 | +#!/usr/bin/python |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | +# SPDX-License-Identifier: GPL-3.0-or-later |
| 4 | + |
| 5 | +# Copyright © Thorsten Glaser <[email protected]> |
| 6 | +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) |
| 7 | + |
| 8 | + |
| 9 | +DOCUMENTATION = r""" |
| 10 | +--- |
| 11 | +module: kea_command |
| 12 | +short_description: Submits generic command to ISC KEA server on target |
| 13 | +description: |
| 14 | + - Submits a command to the JSON API of an ISC KEA server running on the target and obtains the result. |
| 15 | + - This module supports sending arbitrary commands and returns the server response unchecked; |
| 16 | + while it would be possible to write individual modules for specific KEA service commands, |
| 17 | + that approach would not scale, as the FOSS hooks alone provide dozens of commands. |
| 18 | + - Between sending the command and parsing the result status, RV(ignore:changed) will register as V(true) if an error occurs, |
| 19 | + to err on the safe side. |
| 20 | +version_added: '11.3.0' |
| 21 | +author: Thorsten Glaser (@mirabilos) |
| 22 | +options: |
| 23 | + command: |
| 24 | + description: |
| 25 | + - The name of the command to send, for example V(status-get). |
| 26 | + required: true |
| 27 | + type: str |
| 28 | + arguments: |
| 29 | + description: |
| 30 | + - The arguments sent along with the command, if any. |
| 31 | + - Use V({}) to send an empty arguments dict/object instead of omitting it. |
| 32 | + type: dict |
| 33 | + rv_unchanged: |
| 34 | + description: |
| 35 | + - A list of C(result) codes to indicate success but unchanged system state. |
| 36 | + - Set this to V([0]) for most acquisition commands. |
| 37 | + - Use V([3]) for O(command=lease4-del) and similar which have a separate code for this. |
| 38 | + - Any C(result) codes not listed in either O(rv_unchanged) or O(rv_changed) are interpreted as indicating an error result. |
| 39 | + - O(rv_unchanged) has precedence over O(rv_changed) if a result code is in both lists. |
| 40 | + type: list |
| 41 | + elements: int |
| 42 | + default: [] |
| 43 | + rv_changed: |
| 44 | + description: |
| 45 | + - A list of C(result) codes to indicate success and changed system state. |
| 46 | + - Omit this for most acquisition commands. |
| 47 | + - Set it to V([0]) for O(command=lease4-del) and similar which return changed system state that way. |
| 48 | + - Any C(result) codes not listed in either O(rv_unchanged) or O(rv_changed) are interpreted as indicating an error result. |
| 49 | + - O(rv_unchanged) has precedence over O(rv_changed) if a result code is in both lists. |
| 50 | + type: list |
| 51 | + elements: int |
| 52 | + default: [] |
| 53 | + socket: |
| 54 | + description: |
| 55 | + - The full pathname of the Unix Domain Socket to connect to. |
| 56 | + - The default value is suitable for C(kea-dhcp4-server) on Debian trixie. |
| 57 | + - This module directly interfacees via UDS; the HTTP wrappers are not supported. |
| 58 | + type: path |
| 59 | + default: /run/kea/kea4-ctrl-socket |
| 60 | +extends_documentation_fragment: |
| 61 | + - community.general.attributes |
| 62 | + - community.general.attributes.platform |
| 63 | +attributes: |
| 64 | + check_mode: |
| 65 | + support: none |
| 66 | + diff_mode: |
| 67 | + support: none |
| 68 | + platform: |
| 69 | + support: full |
| 70 | + platforms: posix |
| 71 | +""" |
| 72 | + |
| 73 | +EXAMPLES = r""" |
| 74 | +vars: |
| 75 | + ipaddr: "192.168.123.45" |
| 76 | + hwaddr: "00:00:5E:00:53:00" |
| 77 | +tasks: |
| 78 | +
|
| 79 | + # an example for a request acquiring information |
| 80 | + - name: Get KEA DHCP6 status |
| 81 | + kea_command: |
| 82 | + command: status-get |
| 83 | + rv_unchanged: [0] |
| 84 | + socket: /run/kea/kea6-ctrl-socket |
| 85 | + register: kea6_status |
| 86 | + - name: Display registered status result |
| 87 | + ansible.builtin.debug: |
| 88 | + msg: KEA DHCP6 running on PID {{ kea6_status.response.arguments.pid }} |
| 89 | +
|
| 90 | + # an example for requests modifying state |
| 91 | + - name: Remove existing leases for {{ ipaddr }}, if any |
| 92 | + kea_command: |
| 93 | + command: lease4-del |
| 94 | + arguments: |
| 95 | + ip-address: "{{ ipaddr }}" |
| 96 | + rv_changed: [0] |
| 97 | + rv_unchanged: [3] |
| 98 | + - name: Add DHCP lease for {{ ipaddr }} |
| 99 | + kea_command: |
| 100 | + command: lease4-add |
| 101 | + arguments: |
| 102 | + ip-address: "{{ ipaddr }}" |
| 103 | + hw-address: "{{ hwaddr }}" |
| 104 | + rv_changed: [0] |
| 105 | +""" |
| 106 | + |
| 107 | +RETURN = r""" |
| 108 | +response: |
| 109 | + description: The server JSON response. |
| 110 | + returned: when available |
| 111 | + type: dict |
| 112 | +""" |
| 113 | + |
| 114 | +import json |
| 115 | +import os |
| 116 | +import socket |
| 117 | +import traceback |
| 118 | + |
| 119 | +from ansible.module_utils.basic import AnsibleModule |
| 120 | + |
| 121 | + |
| 122 | +# default buffer size for socket I/O |
| 123 | +BUFSIZ = 8192 |
| 124 | + |
| 125 | + |
| 126 | +def _parse_constant(s): |
| 127 | + raise ValueError('Invalid JSON: "%s"' % s) |
| 128 | + |
| 129 | + |
| 130 | +def main(): |
| 131 | + module = AnsibleModule( |
| 132 | + argument_spec=dict( |
| 133 | + command=dict(type='str', required=True), |
| 134 | + arguments=dict(type='dict'), |
| 135 | + rv_unchanged=dict(type='list', elements='int', default=[]), |
| 136 | + rv_changed=dict(type='list', elements='int', default=[]), |
| 137 | + socket=dict(type='path', default='/run/kea/kea4-ctrl-socket'), |
| 138 | + ), |
| 139 | + ) |
| 140 | + |
| 141 | + cmd = {} |
| 142 | + cmd['command'] = module.params['command'] |
| 143 | + if module.params['arguments'] is not None: |
| 144 | + cmd['arguments'] = module.params['arguments'] |
| 145 | + cmdstr = json.dumps(cmd, ensure_ascii=True, allow_nan=False, indent=None, separators=(',', ':'), sort_keys=True) |
| 146 | + rvok = module.params['rv_unchanged'] |
| 147 | + rvch = module.params['rv_changed'] |
| 148 | + sockfn = module.params['socket'] |
| 149 | + |
| 150 | + r = { |
| 151 | + 'changed': False |
| 152 | + } |
| 153 | + rsp = b'' |
| 154 | + |
| 155 | + if not os.path.exists(sockfn): |
| 156 | + r['msg'] = 'socket (%s) does not exist' % sockfn |
| 157 | + module.fail_json(**r) |
| 158 | + |
| 159 | + phase = 'opening' |
| 160 | + try: |
| 161 | + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: |
| 162 | + phase = 'connecting' |
| 163 | + sock.connect(sockfn) |
| 164 | + # better safe in case anything fails… |
| 165 | + r['changed'] = True |
| 166 | + phase = 'writing' |
| 167 | + sock.sendall(cmdstr.encode('ASCII')) |
| 168 | + phase = 'reading' |
| 169 | + while True: |
| 170 | + rspnew = sock.recv(BUFSIZ) |
| 171 | + if len(rspnew) == 0: |
| 172 | + break |
| 173 | + rsp += rspnew |
| 174 | + phase = 'closing' |
| 175 | + except OSError as ex: |
| 176 | + r['msg'] = 'error %s socket (%s): %s' % (phase, sockfn, str(ex)) |
| 177 | + r['exception'] = traceback.format_exc() |
| 178 | + module.fail_json(**r) |
| 179 | + |
| 180 | + # 15 is the length of the minimum response {"response":0} as formatted by KEA |
| 181 | + if len(rsp) < 15: |
| 182 | + r['msg'] = 'unrealistically short response ' + repr(rsp) |
| 183 | + module.fail_json(**r) |
| 184 | + |
| 185 | + try: |
| 186 | + r['response'] = json.loads(rsp, parse_constant=_parse_constant) |
| 187 | + except ValueError as ex: |
| 188 | + r['msg'] = 'error parsing JSON response: ' + str(ex) |
| 189 | + r['exception'] = traceback.format_exc() |
| 190 | + module.fail_json(**r) |
| 191 | + if not isinstance(r['response'], dict): |
| 192 | + r['msg'] = 'bogus JSON response (JSONObject expected)' |
| 193 | + module.fail_json(**r) |
| 194 | + if 'result' not in r['response']: |
| 195 | + r['msg'] = 'bogus JSON response (missing result)' |
| 196 | + module.fail_json(**r) |
| 197 | + res = r['response']['result'] |
| 198 | + if not isinstance(res, int): |
| 199 | + r['msg'] = 'bogus JSON response (non-integer result)' |
| 200 | + module.fail_json(**r) |
| 201 | + |
| 202 | + if res in rvok: |
| 203 | + r['changed'] = False |
| 204 | + elif res not in rvch: |
| 205 | + r['msg'] = 'failure result (code %d)' % res |
| 206 | + module.fail_json(**r) |
| 207 | + |
| 208 | + module.exit_json(**r) |
| 209 | + |
| 210 | + |
| 211 | +if __name__ == '__main__': |
| 212 | + main() |
0 commit comments