1#
2# This code is part of Ansible, but is an independent component.
3#
4# This particular file snippet, and this file snippet only, is BSD licensed.
5# Modules you write using this snippet, which is embedded dynamically by Ansible
6# still belong to the author of the module, and may assign their own license
7# to the complete work.
8#
9# (c) 2017 Red Hat, Inc.
10#
11# Redistribution and use in source and binary forms, with or without modification,
12# are permitted provided that the following conditions are met:
13#
14#    * Redistributions of source code must retain the above copyright
15#      notice, this list of conditions and the following disclaimer.
16#    * Redistributions in binary form must reproduce the above copyright notice,
17#      this list of conditions and the following disclaimer in the documentation
18#      and/or other materials provided with the distribution.
19#
20# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
21# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
23# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
24# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
25# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
28# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29#
30import json
31import os
32import time
33
34from ansible.module_utils._text import to_text
35from ansible.module_utils.basic import env_fallback
36from ansible.module_utils.connection import Connection, ConnectionError
37from ansible.module_utils.network.common.config import NetworkConfig, dumps
38from ansible.module_utils.network.common.utils import to_list, ComplexList
39from ansible.module_utils.six import iteritems
40from ansible.module_utils.urls import fetch_url
41
42_DEVICE_CONNECTION = None
43
44eos_provider_spec = {
45    'host': dict(),
46    'port': dict(type='int'),
47    'username': dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME'])),
48    'password': dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), no_log=True),
49    'ssh_keyfile': dict(fallback=(env_fallback, ['ANSIBLE_NET_SSH_KEYFILE']), type='path'),
50
51    'authorize': dict(fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'),
52    'auth_pass': dict(no_log=True, fallback=(env_fallback, ['ANSIBLE_NET_AUTH_PASS'])),
53
54    'use_ssl': dict(default=True, type='bool'),
55    'use_proxy': dict(default=True, type='bool'),
56    'validate_certs': dict(default=True, type='bool'),
57    'timeout': dict(type='int'),
58
59    'transport': dict(default='cli', choices=['cli', 'eapi'])
60}
61eos_argument_spec = {
62    'provider': dict(type='dict', options=eos_provider_spec),
63}
64eos_top_spec = {
65    'host': dict(removed_in_version=2.9),
66    'port': dict(removed_in_version=2.9, type='int'),
67    'username': dict(removed_in_version=2.9),
68    'password': dict(removed_in_version=2.9, no_log=True),
69    'ssh_keyfile': dict(removed_in_version=2.9, type='path'),
70
71    'authorize': dict(fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'),
72    'auth_pass': dict(removed_in_version=2.9, no_log=True),
73
74    'use_ssl': dict(removed_in_version=2.9, type='bool'),
75    'validate_certs': dict(removed_in_version=2.9, type='bool'),
76    'timeout': dict(removed_in_version=2.9, type='int'),
77
78    'transport': dict(removed_in_version=2.9, choices=['cli', 'eapi'])
79}
80eos_argument_spec.update(eos_top_spec)
81
82
83def get_provider_argspec():
84    return eos_provider_spec
85
86
87def check_args(module, warnings):
88    pass
89
90
91def load_params(module):
92    provider = module.params.get('provider') or dict()
93    for key, value in iteritems(provider):
94        if key in eos_argument_spec:
95            if module.params.get(key) is None and value is not None:
96                module.params[key] = value
97
98
99def get_connection(module):
100    global _DEVICE_CONNECTION
101    if not _DEVICE_CONNECTION:
102        load_params(module)
103        if is_local_eapi(module):
104            conn = LocalEapi(module)
105        else:
106            connection_proxy = Connection(module._socket_path)
107            cap = json.loads(connection_proxy.get_capabilities())
108            if cap['network_api'] == 'cliconf':
109                conn = Cli(module)
110            elif cap['network_api'] == 'eapi':
111                conn = HttpApi(module)
112        _DEVICE_CONNECTION = conn
113    return _DEVICE_CONNECTION
114
115
116class Cli:
117
118    def __init__(self, module):
119        self._module = module
120        self._device_configs = {}
121        self._session_support = None
122        self._connection = None
123
124    @property
125    def supports_sessions(self):
126        if self._session_support is None:
127            self._session_support = self._get_connection().supports_sessions()
128        return self._session_support
129
130    def _get_connection(self):
131        if self._connection:
132            return self._connection
133        self._connection = Connection(self._module._socket_path)
134
135        return self._connection
136
137    def get_config(self, flags=None):
138        """Retrieves the current config from the device or cache
139        """
140        flags = [] if flags is None else flags
141
142        cmd = 'show running-config '
143        cmd += ' '.join(flags)
144        cmd = cmd.strip()
145
146        try:
147            return self._device_configs[cmd]
148        except KeyError:
149            conn = self._get_connection()
150            try:
151                out = conn.get_config(flags=flags)
152            except ConnectionError as exc:
153                self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace'))
154
155            cfg = to_text(out, errors='surrogate_then_replace').strip()
156            self._device_configs[cmd] = cfg
157            return cfg
158
159    def run_commands(self, commands, check_rc=True):
160        """Run list of commands on remote device and return results
161        """
162        connection = self._get_connection()
163        try:
164            response = connection.run_commands(commands=commands, check_rc=check_rc)
165        except ConnectionError as exc:
166            self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace'))
167        return response
168
169    def load_config(self, commands, commit=False, replace=False):
170        """Loads the config commands onto the remote device
171        """
172        conn = self._get_connection()
173        try:
174            response = conn.edit_config(commands, commit, replace)
175        except ConnectionError as exc:
176            message = getattr(exc, 'err', to_text(exc))
177            if "check mode is not supported without configuration session" in message:
178                self._module.warn("EOS can not check config without config session")
179                response = {'changed': True}
180            else:
181                self._module.fail_json(msg="%s" % message, data=to_text(message, errors='surrogate_then_replace'))
182
183        return response
184
185    def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'):
186        conn = self._get_connection()
187        try:
188            diff = conn.get_diff(candidate=candidate, running=running, diff_match=diff_match, diff_ignore_lines=diff_ignore_lines, path=path,
189                                 diff_replace=diff_replace)
190        except ConnectionError as exc:
191            self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace'))
192        return diff
193
194    def get_capabilities(self):
195        """Returns platform info of the remove device
196        """
197        if hasattr(self._module, '_capabilities'):
198            return self._module._capabilities
199
200        connection = self._get_connection()
201        try:
202            capabilities = connection.get_capabilities()
203        except ConnectionError as exc:
204            self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace'))
205        self._module._capabilities = json.loads(capabilities)
206        return self._module._capabilities
207
208
209class LocalEapi:
210
211    def __init__(self, module):
212        self._module = module
213        self._enable = None
214        self._session_support = None
215        self._device_configs = {}
216
217        host = module.params['provider']['host']
218        port = module.params['provider']['port']
219
220        self._module.params['url_username'] = self._module.params['username']
221        self._module.params['url_password'] = self._module.params['password']
222
223        if module.params['provider']['use_ssl']:
224            proto = 'https'
225        else:
226            proto = 'http'
227
228        module.params['validate_certs'] = module.params['provider']['validate_certs']
229
230        self._url = '%s://%s:%s/command-api' % (proto, host, port)
231
232        if module.params['auth_pass']:
233            self._enable = {'cmd': 'enable', 'input': module.params['auth_pass']}
234        else:
235            self._enable = 'enable'
236
237    @property
238    def supports_sessions(self):
239        if self._session_support is None:
240            response = self.send_request(['show configuration sessions'])
241            self._session_support = 'error' not in response
242        return self._session_support
243
244    def _request_builder(self, commands, output, reqid=None):
245        params = dict(version=1, cmds=commands, format=output)
246        return dict(jsonrpc='2.0', id=reqid, method='runCmds', params=params)
247
248    def send_request(self, commands, output='text'):
249        commands = to_list(commands)
250
251        if self._enable:
252            commands.insert(0, self._enable)
253
254        body = self._request_builder(commands, output)
255        data = self._module.jsonify(body)
256
257        headers = {'Content-Type': 'application/json-rpc'}
258        timeout = self._module.params['timeout']
259        use_proxy = self._module.params['provider']['use_proxy']
260
261        response, headers = fetch_url(
262            self._module, self._url, data=data, headers=headers,
263            method='POST', timeout=timeout, use_proxy=use_proxy
264        )
265
266        if headers['status'] != 200:
267            self._module.fail_json(**headers)
268
269        try:
270            data = response.read()
271            response = self._module.from_json(to_text(data, errors='surrogate_then_replace'))
272        except ValueError:
273            self._module.fail_json(msg='unable to load response from device', data=data)
274
275        if self._enable and 'result' in response:
276            response['result'].pop(0)
277
278        return response
279
280    def run_commands(self, commands, check_rc=True):
281        """Runs list of commands on remote device and returns results
282        """
283        output = None
284        queue = list()
285        responses = list()
286
287        def _send(commands, output):
288            response = self.send_request(commands, output=output)
289            if 'error' in response:
290                err = response['error']
291                self._module.fail_json(msg=err['message'], code=err['code'])
292            return response['result']
293
294        for item in to_list(commands):
295            if is_json(item['command']):
296                item['command'] = str(item['command']).replace('| json', '')
297                item['output'] = 'json'
298
299            if output and output != item['output']:
300                responses.extend(_send(queue, output))
301                queue = list()
302
303            output = item['output'] or 'json'
304            queue.append(item['command'])
305
306        if queue:
307            responses.extend(_send(queue, output))
308
309        for index, item in enumerate(commands):
310            try:
311                responses[index] = responses[index]['output'].strip()
312            except KeyError:
313                pass
314
315        return responses
316
317    def get_config(self, flags=None):
318        """Retrieves the current config from the device or cache
319        """
320        flags = [] if flags is None else flags
321
322        cmd = 'show running-config '
323        cmd += ' '.join(flags)
324        cmd = cmd.strip()
325
326        try:
327            return self._device_configs[cmd]
328        except KeyError:
329            out = self.send_request(cmd)
330            cfg = str(out['result'][0]['output']).strip()
331            self._device_configs[cmd] = cfg
332            return cfg
333
334    def configure(self, commands):
335        """Sends the ordered set of commands to the device
336        """
337        cmds = ['configure terminal']
338        cmds.extend(commands)
339
340        responses = self.send_request(commands)
341        if 'error' in responses:
342            err = responses['error']
343            self._module.fail_json(msg=err['message'], code=err['code'])
344
345        return responses[1:]
346
347    def load_config(self, config, commit=False, replace=False):
348        """Loads the configuration onto the remote devices
349
350        If the device doesn't support configuration sessions, this will
351        fallback to using configure() to load the commands.  If that happens,
352        there will be no returned diff or session values
353        """
354        use_session = os.getenv('ANSIBLE_EOS_USE_SESSIONS', True)
355        try:
356            use_session = int(use_session)
357        except ValueError:
358            pass
359
360        if not all((bool(use_session), self.supports_sessions)):
361            if commit:
362                return self.configure(config)
363            else:
364                self._module.warn("EOS can not check config without config session")
365                result = {'changed': True}
366                return result
367
368        session = 'ansible_%s' % int(time.time())
369        result = {'session': session}
370        commands = ['configure session %s' % session]
371
372        if replace:
373            commands.append('rollback clean-config')
374
375        commands.extend(config)
376
377        response = self.send_request(commands)
378        if 'error' in response:
379            commands = ['configure session %s' % session, 'abort']
380            self.send_request(commands)
381            err = response['error']
382            error_text = []
383            for data in err['data']:
384                error_text.extend(data.get('errors', []))
385            error_text = '\n'.join(error_text) or err['message']
386            self._module.fail_json(msg=error_text, code=err['code'])
387
388        commands = ['configure session %s' % session, 'show session-config diffs']
389        if commit:
390            commands.append('commit')
391        else:
392            commands.append('abort')
393
394        response = self.send_request(commands, output='text')
395        diff = response['result'][1]['output']
396        if len(diff) > 0:
397            result['diff'] = diff
398
399        return result
400
401    # get_diff added here to support connection=local and transport=eapi scenario
402    def get_diff(self, candidate, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'):
403        diff = {}
404
405        # prepare candidate configuration
406        candidate_obj = NetworkConfig(indent=3)
407        candidate_obj.load(candidate)
408
409        if running and diff_match != 'none' and diff_replace != 'config':
410            # running configuration
411            running_obj = NetworkConfig(indent=3, contents=running, ignore_lines=diff_ignore_lines)
412            configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace)
413
414        else:
415            configdiffobjs = candidate_obj.items
416
417        configdiff = dumps(configdiffobjs, 'commands') if configdiffobjs else ''
418        diff['config_diff'] = configdiff if configdiffobjs else {}
419        return diff
420
421
422class HttpApi:
423    def __init__(self, module):
424        self._module = module
425        self._device_configs = {}
426        self._session_support = None
427        self._connection_obj = None
428
429    @property
430    def _connection(self):
431        if not self._connection_obj:
432            self._connection_obj = Connection(self._module._socket_path)
433
434        return self._connection_obj
435
436    @property
437    def supports_sessions(self):
438        if self._session_support is None:
439            self._session_support = self._connection.supports_sessions()
440        return self._session_support
441
442    def run_commands(self, commands, check_rc=True):
443        """Runs list of commands on remote device and returns results
444        """
445        output = None
446        queue = list()
447        responses = list()
448
449        def run_queue(queue, output):
450            try:
451                response = to_list(self._connection.send_request(queue, output=output))
452            except ConnectionError as exc:
453                if check_rc:
454                    raise
455                return to_list(to_text(exc))
456
457            if output == 'json':
458                response = [json.loads(item) for item in response]
459            return response
460
461        for item in to_list(commands):
462            cmd_output = 'text'
463            if isinstance(item, dict):
464                command = item['command']
465                if 'output' in item:
466                    cmd_output = item['output']
467            else:
468                command = item
469
470            # Emulate '| json' from CLI
471            if is_json(command):
472                command = command.rsplit('|', 1)[0]
473                cmd_output = 'json'
474
475            if output and output != cmd_output:
476                responses.extend(run_queue(queue, output))
477                queue = list()
478
479            output = cmd_output
480            queue.append(command)
481
482        if queue:
483            responses.extend(run_queue(queue, output))
484
485        return responses
486
487    def get_config(self, flags=None):
488        """Retrieves the current config from the device or cache
489        """
490        flags = [] if flags is None else flags
491
492        cmd = 'show running-config '
493        cmd += ' '.join(flags)
494        cmd = cmd.strip()
495
496        try:
497            return self._device_configs[cmd]
498        except KeyError:
499            try:
500                out = self._connection.send_request(cmd)
501            except ConnectionError as exc:
502                self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace'))
503
504            cfg = to_text(out).strip()
505            self._device_configs[cmd] = cfg
506            return cfg
507
508    def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'):
509        diff = {}
510
511        # prepare candidate configuration
512        candidate_obj = NetworkConfig(indent=3)
513        candidate_obj.load(candidate)
514
515        if running and diff_match != 'none' and diff_replace != 'config':
516            # running configuration
517            running_obj = NetworkConfig(indent=3, contents=running, ignore_lines=diff_ignore_lines)
518            configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace)
519
520        else:
521            configdiffobjs = candidate_obj.items
522
523        diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else {}
524        return diff
525
526    def load_config(self, config, commit=False, replace=False):
527        """Loads the configuration onto the remote devices
528
529        If the device doesn't support configuration sessions, this will
530        fallback to using configure() to load the commands.  If that happens,
531        there will be no returned diff or session values
532        """
533        return self.edit_config(config, commit, replace)
534
535    def edit_config(self, config, commit=False, replace=False):
536        """Loads the configuration onto the remote devices
537
538        If the device doesn't support configuration sessions, this will
539        fallback to using configure() to load the commands.  If that happens,
540        there will be no returned diff or session values
541        """
542        session = 'ansible_%s' % int(time.time())
543        result = {'session': session}
544        banner_cmd = None
545        banner_input = []
546
547        commands = ['configure session %s' % session]
548        if replace:
549            commands.append('rollback clean-config')
550
551        for command in config:
552            if command.startswith('banner'):
553                banner_cmd = command
554                banner_input = []
555            elif banner_cmd:
556                if command == 'EOF':
557                    command = {'cmd': banner_cmd, 'input': '\n'.join(banner_input)}
558                    banner_cmd = None
559                    commands.append(command)
560                else:
561                    banner_input.append(command)
562                    continue
563            else:
564                commands.append(command)
565
566        try:
567            response = self._connection.send_request(commands)
568        except Exception:
569            commands = ['configure session %s' % session, 'abort']
570            response = self._connection.send_request(commands, output='text')
571            raise
572
573        commands = ['configure session %s' % session, 'show session-config diffs']
574        if commit:
575            commands.append('commit')
576        else:
577            commands.append('abort')
578
579        response = self._connection.send_request(commands, output='text')
580        diff = response[1].strip()
581        if diff:
582            result['diff'] = diff
583
584        return result
585
586    def get_capabilities(self):
587        """Returns platform info of the remove device
588        """
589        try:
590            capabilities = self._connection.get_capabilities()
591        except ConnectionError as exc:
592            self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace'))
593
594        return json.loads(capabilities)
595
596
597def is_json(cmd):
598    return to_text(cmd, errors='surrogate_then_replace').endswith('| json')
599
600
601def is_local_eapi(module):
602    transports = []
603    transports.append(module.params.get('transport', ""))
604    provider = module.params.get('provider')
605    if provider:
606        transports.append(provider.get('transport', ""))
607    return 'eapi' in transports
608
609
610def to_command(module, commands):
611    if is_local_eapi(module):
612        default_output = 'json'
613    else:
614        default_output = 'text'
615
616    transform = ComplexList(dict(
617        command=dict(key=True),
618        output=dict(default=default_output),
619        prompt=dict(type='list'),
620        answer=dict(type='list'),
621        newline=dict(type='bool', default=True),
622        sendonly=dict(type='bool', default=False),
623        check_all=dict(type='bool', default=False),
624    ), module)
625
626    return transform(to_list(commands))
627
628
629def get_config(module, flags=None):
630    flags = None if flags is None else flags
631
632    conn = get_connection(module)
633    return conn.get_config(flags)
634
635
636def run_commands(module, commands, check_rc=True):
637    conn = get_connection(module)
638    return conn.run_commands(to_command(module, commands), check_rc=check_rc)
639
640
641def load_config(module, config, commit=False, replace=False):
642    conn = get_connection(module)
643    return conn.load_config(config, commit, replace)
644
645
646def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'):
647    conn = self.get_connection()
648    return conn.get_diff(candidate=candidate, running=running, diff_match=diff_match, diff_ignore_lines=diff_ignore_lines, path=path, diff_replace=diff_replace)
649
650
651def get_capabilities(module):
652    conn = get_connection(module)
653    return conn.get_capabilities()
654