1#!/usr/bin/python
2#
3# This file is part of Ansible
4#
5# Ansible is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# Ansible is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
17#
18
19ANSIBLE_METADATA = {'metadata_version': '1.1',
20                    'status': ['preview'],
21                    'supported_by': 'community'}
22
23DOCUMENTATION = """
24---
25module: ce_config
26version_added: "2.4"
27author: "QijunPan (@QijunPan)"
28short_description: Manage Huawei CloudEngine configuration sections.
29description:
30  - Huawei CloudEngine configurations use a simple block indent file syntax
31    for segmenting configuration into sections.  This module provides
32    an implementation for working with CloudEngine configuration sections in
33    a deterministic way.  This module works with CLI transports.
34notes:
35  - Recommended connection is C(network_cli).
36  - This module also works with C(local) connections for legacy playbooks.
37options:
38  lines:
39    description:
40      - The ordered set of commands that should be configured in the
41        section.  The commands must be the exact same commands as found
42        in the device current-configuration.  Be sure to note the configuration
43        command syntax as some commands are automatically modified by the
44        device config parser.
45  parents:
46    description:
47      - The ordered set of parents that uniquely identify the section or hierarchy
48        the commands should be checked against.  If the parents argument
49        is omitted, the commands are checked against the set of top
50        level or global commands.
51  src:
52    description:
53      - The I(src) argument provides a path to the configuration file
54        to load into the remote system.  The path can either be a full
55        system path to the configuration file if the value starts with /
56        or relative to the root of the implemented role or playbook.
57        This argument is mutually exclusive with the I(lines) and
58        I(parents) arguments.
59  before:
60    description:
61      - The ordered set of commands to push on to the command stack if
62        a change needs to be made.  This allows the playbook designer
63        the opportunity to perform configuration commands prior to pushing
64        any changes without affecting how the set of commands are matched
65        against the system.
66  after:
67    description:
68      - The ordered set of commands to append to the end of the command
69        stack if a change needs to be made.  Just like with I(before) this
70        allows the playbook designer to append a set of commands to be
71        executed after the command set.
72  match:
73    description:
74      - Instructs the module on the way to perform the matching of
75        the set of commands against the current device config.  If
76        match is set to I(line), commands are matched line by line.  If
77        match is set to I(strict), command lines are matched with respect
78        to position.  If match is set to I(exact), command lines
79        must be an equal match.  Finally, if match is set to I(none), the
80        module will not attempt to compare the source configuration with
81        the current-configuration on the remote device.
82    default: line
83    choices: ['line', 'strict', 'exact', 'none']
84  replace:
85    description:
86      - Instructs the module on the way to perform the configuration
87        on the device.  If the replace argument is set to I(line) then
88        the modified lines are pushed to the device in configuration
89        mode.  If the replace argument is set to I(block) then the entire
90        command block is pushed to the device in configuration mode if any
91        line is not correct.
92    default: line
93    choices: ['line', 'block']
94  backup:
95    description:
96      - This argument will cause the module to create a full backup of
97        the current C(current-configuration) from the remote device before any
98        changes are made. If the C(backup_options) value is not given,
99        the backup file is written to the C(backup) folder in the playbook
100        root directory. If the directory does not exist, it is created.
101    type: bool
102    default: 'no'
103  config:
104    description:
105      - The module, by default, will connect to the remote device and
106        retrieve the current current-configuration to use as a base for comparing
107        against the contents of source.  There are times when it is not
108        desirable to have the task get the current-configuration for
109        every task in a playbook.  The I(config) argument allows the
110        implementer to pass in the configuration to use as the base
111        config for comparison.
112  defaults:
113    description:
114      - The I(defaults) argument will influence how the current-configuration
115        is collected from the device.  When the value is set to true,
116        the command used to collect the current-configuration is append with
117        the all keyword.  When the value is set to false, the command
118        is issued without the all keyword.
119    type: bool
120    default: 'no'
121  save:
122    description:
123      - The C(save) argument instructs the module to save the
124        current-configuration to saved-configuration.  This operation is performed
125        after any changes are made to the current running config.  If
126        no changes are made, the configuration is still saved to the
127        startup config.  This option will always cause the module to
128        return changed.
129    type: bool
130    default: 'no'
131  backup_options:
132    description:
133      - This is a dict object containing configurable options related to backup file path.
134        The value of this option is read only when C(backup) is set to I(yes), if C(backup) is set
135        to I(no) this option will be silently ignored.
136    suboptions:
137      filename:
138        description:
139          - The filename to be used to store the backup configuration. If the the filename
140            is not given it will be generated based on the hostname, current time and date
141            in format defined by <hostname>_config.<current-date>@<current-time>
142      dir_path:
143        description:
144          - This option provides the path ending with directory name in which the backup
145            configuration file will be stored. If the directory does not exist it will be first
146            created and the filename is either the value of C(filename) or default filename
147            as described in C(filename) options description. If the path value is not given
148            in that case a I(backup) directory will be created in the current working directory
149            and backup configuration will be copied in C(filename) within I(backup) directory.
150        type: path
151    type: dict
152    version_added: "2.8"
153"""
154
155EXAMPLES = """
156# Note: examples below use the following provider dict to handle
157#       transport and authentication to the node.
158
159- name: CloudEngine config test
160  hosts: cloudengine
161  connection: local
162  gather_facts: no
163  vars:
164    cli:
165      host: "{{ inventory_hostname }}"
166      port: "{{ ansible_ssh_port }}"
167      username: "{{ username }}"
168      password: "{{ password }}"
169      transport: cli
170
171  tasks:
172  - name: "Configure top level configuration and save it"
173    ce_config:
174      lines: sysname {{ inventory_hostname }}
175      save: yes
176      provider: "{{ cli }}"
177
178  - name: "Configure acl configuration and save it"
179    ce_config:
180      lines:
181        - rule 10 permit source 1.1.1.1 32
182        - rule 20 permit source 2.2.2.2 32
183        - rule 30 permit source 3.3.3.3 32
184        - rule 40 permit source 4.4.4.4 32
185        - rule 50 permit source 5.5.5.5 32
186      parents: acl 2000
187      before: undo acl 2000
188      match: exact
189      provider: "{{ cli }}"
190
191  - name: "Configure acl configuration and save it"
192    ce_config:
193      lines:
194        - rule 10 permit source 1.1.1.1 32
195        - rule 20 permit source 2.2.2.2 32
196        - rule 30 permit source 3.3.3.3 32
197        - rule 40 permit source 4.4.4.4 32
198      parents: acl 2000
199      before: undo acl 2000
200      replace: block
201      provider: "{{ cli }}"
202
203  - name: configurable backup path
204    ce_config:
205      lines: sysname {{ inventory_hostname }}
206      provider: "{{ cli }}"
207      backup: yes
208      backup_options:
209        filename: backup.cfg
210        dir_path: /home/user
211"""
212
213RETURN = """
214updates:
215  description: The set of commands that will be pushed to the remote device
216  returned: Only when lines is specified.
217  type: list
218  sample: ['...', '...']
219backup_path:
220  description: The full path to the backup file
221  returned: when backup is yes
222  type: str
223  sample: /playbooks/ansible/backup/ce_config.2016-07-16@22:28:34
224"""
225from ansible.module_utils.basic import AnsibleModule
226from ansible.module_utils.connection import ConnectionError, Connection
227from ansible.module_utils.network.common.config import NetworkConfig as _NetworkConfig
228from ansible.module_utils.network.common.config import dumps, ConfigLine, ignore_line
229from ansible.module_utils.network.cloudengine.ce import get_config, run_commands, exec_command, cli_err_msg
230from ansible.module_utils.network.cloudengine.ce import ce_argument_spec, load_config
231from ansible.module_utils.network.cloudengine.ce import check_args as ce_check_args
232import re
233
234
235def check_args(module, warnings):
236    ce_check_args(module, warnings)
237
238
239def not_user_view(prompt):
240    return prompt is not None and prompt.strip().startswith("[")
241
242
243def command_level(command):
244    regex_level = re.search(r"^(\s*)\S+", command)
245    if regex_level is not None:
246        level = str(regex_level.group(1))
247        return len(level)
248    return 0
249
250
251def _load_config(module, config):
252    """Sends configuration commands to the remote device
253    """
254    connection = Connection(module._socket_path)
255    rc, out, err = exec_command(module, 'mmi-mode enable')
256    if rc != 0:
257        module.fail_json(msg='unable to set mmi-mode enable', output=err)
258    rc, out, err = exec_command(module, 'system-view immediately')
259    if rc != 0:
260        module.fail_json(msg='unable to enter system-view', output=err)
261    current_view_prompt = system_view_prompt = connection.get_prompt()
262
263    for index, cmd in enumerate(config):
264        level = command_level(cmd)
265        current_view_prompt = connection.get_prompt()
266        rc, out, err = exec_command(module, cmd)
267        if rc != 0:
268            print_msg = cli_err_msg(cmd.strip(), err)
269            # re-try command max 3 times
270            for i in (1, 2, 3):
271                current_view_prompt = connection.get_prompt()
272                if current_view_prompt != system_view_prompt and not_user_view(current_view_prompt):
273                    exec_command(module, "quit")
274                    current_view_prompt = connection.get_prompt()
275                    # if current view is system-view, break.
276                    if current_view_prompt == system_view_prompt and level > 0:
277                        break
278                elif current_view_prompt == system_view_prompt or not not_user_view(current_view_prompt):
279                    break
280                rc, out, err = exec_command(module, cmd)
281                if rc == 0:
282                    print_msg = None
283                    break
284            if print_msg is not None:
285                module.fail_json(msg=print_msg)
286
287
288def get_running_config(module):
289    contents = module.params['config']
290    if not contents:
291        command = "display current-configuration "
292        if module.params['defaults']:
293            command += 'include-default'
294        resp = run_commands(module, command)
295        contents = resp[0]
296    return NetworkConfig(indent=1, contents=contents)
297
298
299def get_candidate(module):
300    candidate = NetworkConfig(indent=1)
301    if module.params['src']:
302        config = module.params['src']
303        candidate.load(config)
304    elif module.params['lines']:
305        parents = module.params['parents'] or list()
306        candidate.add(module.params['lines'], parents=parents)
307    return candidate
308
309
310def run(module, result):
311    match = module.params['match']
312    replace = module.params['replace']
313
314    candidate = get_candidate(module)
315
316    if match != 'none':
317        before = get_running_config(module)
318        path = module.params['parents']
319        configobjs = candidate.difference(before, match=match, replace=replace, path=path)
320    else:
321        configobjs = candidate.items
322
323    if configobjs:
324        out_type = "commands"
325        if module.params["src"] is not None:
326            out_type = "raw"
327        commands = dumps(configobjs, out_type).split('\n')
328
329        if module.params['lines']:
330            if module.params['before']:
331                commands[:0] = module.params['before']
332
333            if module.params['after']:
334                commands.extend(module.params['after'])
335
336        command_display = []
337        for per_command in commands:
338            if per_command.strip() not in ['quit', 'return', 'system-view']:
339                command_display.append(per_command)
340
341        result['commands'] = command_display
342        result['updates'] = command_display
343
344        if not module.check_mode:
345            if module.params['parents'] is not None:
346                load_config(module, commands)
347            else:
348                _load_config(module, commands)
349        if match != "none":
350            after = get_running_config(module)
351            path = module.params["parents"]
352            if path is not None and match != 'line':
353                before_objs = before.get_block(path)
354                after_objs = after.get_block(path)
355                update = []
356                if len(before_objs) == len(after_objs):
357                    for b_item, a_item in zip(before_objs, after_objs):
358                        if b_item != a_item:
359                            update.append(a_item.text)
360                else:
361                    update = [item.text for item in after_objs]
362                if len(update) == 0:
363                    result["changed"] = False
364                    result['updates'] = []
365                else:
366                    result["changed"] = True
367                    result['updates'] = update
368            else:
369                configobjs = after.difference(before, match=match, replace=replace, path=path)
370                if len(configobjs) > 0:
371                    result["changed"] = True
372                else:
373                    result["changed"] = False
374                    result['updates'] = []
375        else:
376            result['changed'] = True
377
378
379class NetworkConfig(_NetworkConfig):
380
381    def add(self, lines, parents=None):
382        ancestors = list()
383        offset = 0
384        obj = None
385
386        # global config command
387        if not parents:
388            for line in lines:
389                # handle ignore lines
390                if ignore_line(line):
391                    continue
392
393                item = ConfigLine(line)
394                item.raw = line
395                self.items.append(item)
396
397        else:
398            for index, p in enumerate(parents):
399                try:
400                    i = index + 1
401                    obj = self.get_block(parents[:i])[0]
402                    ancestors.append(obj)
403
404                except ValueError:
405                    # add parent to config
406                    offset = index * self._indent
407                    obj = ConfigLine(p)
408                    obj.raw = p.rjust(len(p) + offset)
409                    if ancestors:
410                        obj._parents = list(ancestors)
411                        ancestors[-1]._children.append(obj)
412                    self.items.append(obj)
413                    ancestors.append(obj)
414
415            # add child objects
416            for line in lines:
417                # handle ignore lines
418                if ignore_line(line):
419                    continue
420
421                # check if child already exists
422                for child in ancestors[-1]._children:
423                    if child.text == line:
424                        break
425                else:
426                    offset = len(parents) * self._indent
427                    item = ConfigLine(line)
428                    item.raw = line.rjust(len(line) + offset)
429                    item._parents = ancestors
430                    ancestors[-1]._children.append(item)
431                    self.items.append(item)
432
433
434def main():
435    """ main entry point for module execution
436    """
437    backup_spec = dict(
438        filename=dict(),
439        dir_path=dict(type='path')
440    )
441    argument_spec = dict(
442        src=dict(type='path'),
443
444        lines=dict(aliases=['commands'], type='list'),
445        parents=dict(type='list'),
446
447        before=dict(type='list'),
448        after=dict(type='list'),
449
450        match=dict(default='line', choices=['line', 'strict', 'exact', 'none']),
451        replace=dict(default='line', choices=['line', 'block']),
452        config=dict(),
453        defaults=dict(type='bool', default=False),
454
455        backup=dict(type='bool', default=False),
456        backup_options=dict(type='dict', options=backup_spec),
457        save=dict(type='bool', default=False),
458    )
459
460    argument_spec.update(ce_argument_spec)
461
462    mutually_exclusive = [('lines', 'src'),
463                          ('parents', 'src')]
464
465    required_if = [('match', 'strict', ['lines']),
466                   ('match', 'exact', ['lines']),
467                   ('replace', 'block', ['lines'])]
468
469    module = AnsibleModule(argument_spec=argument_spec,
470                           mutually_exclusive=mutually_exclusive,
471                           required_if=required_if,
472                           supports_check_mode=True)
473
474    warnings = list()
475    check_args(module, warnings)
476
477    result = dict(changed=False, warnings=warnings)
478
479    if module.params['backup']:
480        result['__backup__'] = get_config(module)
481
482    if any((module.params['src'], module.params['lines'])):
483        run(module, result)
484
485    if module.params['save']:
486        if not module.check_mode:
487            run_commands(module, ['return', 'mmi-mode enable', 'save'])
488            result["changed"] = True
489    run_commands(module, ['return', 'undo mmi-mode enable'])
490
491    module.exit_json(**result)
492
493
494if __name__ == '__main__':
495    main()
496