From 9abc3dbec428f5a4681614ce16e2be467fe46c36 Mon Sep 17 00:00:00 2001 From: saichint Date: Tue, 5 Jun 2018 21:39:38 -0700 Subject: [PATCH] nxos_rpm module (#40849) * Add nxos_rpm feature * fix timing issues * fix timing issues * shippable fix * review comment * review comments fixes * typo fix --- lib/ansible/modules/network/nxos/nxos_rpm.py | 305 ++++++++++++++++++ .../targets/nxos_rpm/defaults/main.yaml | 2 + .../targets/nxos_rpm/meta/main.yml | 2 + .../targets/nxos_rpm/tasks/cli.yaml | 27 ++ .../targets/nxos_rpm/tasks/main.yaml | 3 + .../targets/nxos_rpm/tasks/nxapi.yaml | 33 ++ .../targets/nxos_rpm/tests/common/sanity.yaml | 95 ++++++ 7 files changed, 467 insertions(+) create mode 100644 lib/ansible/modules/network/nxos/nxos_rpm.py create mode 100644 test/integration/targets/nxos_rpm/defaults/main.yaml create mode 100644 test/integration/targets/nxos_rpm/meta/main.yml create mode 100644 test/integration/targets/nxos_rpm/tasks/cli.yaml create mode 100644 test/integration/targets/nxos_rpm/tasks/main.yaml create mode 100644 test/integration/targets/nxos_rpm/tasks/nxapi.yaml create mode 100644 test/integration/targets/nxos_rpm/tests/common/sanity.yaml diff --git a/lib/ansible/modules/network/nxos/nxos_rpm.py b/lib/ansible/modules/network/nxos/nxos_rpm.py new file mode 100644 index 00000000000..fc8e49985bb --- /dev/null +++ b/lib/ansible/modules/network/nxos/nxos_rpm.py @@ -0,0 +1,305 @@ +#!/usr/bin/python +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'network'} + + +DOCUMENTATION = ''' +--- +module: nxos_rpm +extends_documentation_fragment: nxos +version_added: "2.7" +short_description: Install patch or feature rpms on Cisco NX-OS devices. +description: + - Install software maintenance upgrade (smu) RPMS and + 3rd party RPMS on Cisco NX-OS devices. +author: Sai Chintalapudi (@saichint) +notes: + - Tested against NXOSv 7.0(3)I2(5), 7.0(3)I4(6), 7.0(3)I5(3), + 7.0(3)I6(1), 7.0(3)I7(3) + - For patches, the minimum platform version needed is 7.0(3)I2(5) + - For feature rpms, the minimum platform version needed is 7.0(3)I6(1) + - The module manages the entire RPM lifecycle (Add, activate, commit, deactivate, remove) +options: + pkg: + description: + - Name of the RPM package. + required: true + file_system: + description: + - The remote file system of the device. If omitted, + devices that support a file_system parameter will use + their default values. + default: bootflash + aggregate: + description: List of RPM/patch definitions. + state: + description: + - If the state is present, the rpm will be installed, + If the state is absent, it will be removed. + default: present + choices: ['present', 'absent'] +''' + +EXAMPLES = ''' +- nxos_rpm: + pkg: "nxos.sample-n9k_ALL-1.0.0-7.0.3.I7.3.lib32_n9000.rpm" +''' + +RETURN = ''' +commands: + description: commands sent to the device + returned: always + type: list + sample: ["install add bootflash:nxos.sample-n9k_ALL-1.0.0-7.0.3.I7.3.lib32_n9000.rpm forced", + "install activate nxos.sample-n9k_ALL-1.0.0-7.0.3.I7.3.lib32_n9000 forced", + "install commit nxos.sample-n9k_ALL-1.0.0-7.0.3.I7.3.lib32_n9000"] +''' + + +import time + +from copy import deepcopy + +from ansible.module_utils.network.nxos.nxos import load_config, run_commands +from ansible.module_utils.network.nxos.nxos import nxos_argument_spec, check_args +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.network.common.utils import remove_default_spec + + +def execute_show_command(command, module): + iteration = 0 + cmds = [{ + 'command': command, + 'output': 'text', + }] + + while iteration < 10: + body = run_commands(module, cmds)[0] + if body: + return body + else: + time.sleep(2) + iteration += 1 + + +def remote_file_exists(module, dst, file_system): + command = 'dir {0}:/{1}'.format(file_system, dst) + body = execute_show_command(command, module) + if 'No such file' in body: + return False + return True + + +def config_cmd_operation(module, cmd): + iteration = 0 + while iteration < 10: + msg = load_config(module, [cmd], True) + if msg: + if 'another install operation is in progress' in msg[0].lower(): + time.sleep(2) + iteration += 1 + else: + return + else: + return + + +def validate_operation(module, show_cmd, cfg_cmd, pkg, pkg_not_present): + iteration = 0 + while iteration < 10: + body = execute_show_command(show_cmd, module) + if pkg_not_present: + if pkg not in body: + return + else: + if pkg in body: + return + time.sleep(2) + iteration += 1 + + err = 'Operation "{0}" Failed'.format(cfg_cmd) + module.fail_json(msg=err) + + +def add_operation(module, show_cmd, file_system, full_pkg, pkg): + cmd = 'install add {0}:{1}'.format(file_system, full_pkg) + config_cmd_operation(module, cmd) + validate_operation(module, show_cmd, cmd, pkg, False) + return cmd + + +def activate_operation(module, show_cmd, pkg): + cmd = 'install activate {0} forced'.format(pkg) + config_cmd_operation(module, cmd) + validate_operation(module, show_cmd, cmd, pkg, False) + return cmd + + +def commit_operation(module, show_cmd, pkg): + cmd = 'install commit {0}'.format(pkg) + config_cmd_operation(module, cmd) + validate_operation(module, show_cmd, cmd, pkg, False) + return cmd + + +def deactivate_operation(module, show_cmd, pkg, flag): + cmd = 'install deactivate {0} forced'.format(pkg) + config_cmd_operation(module, cmd) + validate_operation(module, show_cmd, cmd, pkg, flag) + return cmd + + +def remove_operation(module, show_cmd, pkg): + cmd = 'install remove {0} forced'.format(pkg) + config_cmd_operation(module, cmd) + validate_operation(module, show_cmd, cmd, pkg, True) + return cmd + + +def install_remove_rpm(module, full_pkg, file_system, state): + commands = [] + + splitted_pkg = full_pkg.split('.') + pkg = '.'.join(splitted_pkg[0:-1]) + + show_inactive = 'show install inactive' + show_active = 'show install active' + show_commit = 'show install committed' + show_patches = 'show install patches' + + if state == 'present': + inactive_body = execute_show_command(show_inactive, module) + active_body = execute_show_command(show_active, module) + + if pkg not in inactive_body and pkg not in active_body: + commands.append(add_operation(module, show_inactive, file_system, full_pkg, pkg)) + + if pkg not in active_body: + commands.append(activate_operation(module, show_active, pkg)) + + commit_body = execute_show_command(show_commit, module) + if pkg not in commit_body: + patch_body = execute_show_command(show_patches, module) + if pkg in patch_body: + # this is an smu + commands.append(commit_operation(module, show_active, pkg)) + else: + err = 'Operation "install activate {0} forced" Failed'.format(pkg) + module.fail_json(msg=err) + + else: + commit_body = execute_show_command(show_commit, module) + active_body = execute_show_command(show_active, module) + + if pkg in commit_body and pkg in active_body: + commands.append(deactivate_operation(module, show_active, pkg, True)) + commit_body = execute_show_command(show_commit, module) + if pkg in commit_body: + # This is smu/patch rpm + commands.append(commit_operation(module, show_inactive, pkg)) + commands.append(remove_operation(module, show_inactive, pkg)) + + elif pkg in commit_body: + # This is smu/patch rpm + commands.append(commit_operation(module, show_inactive, pkg)) + commands.append(remove_operation(module, show_inactive, pkg)) + + elif pkg in active_body: + # This is smu/patch rpm + commands.append(deactivate_operation(module, show_inactive, pkg, False)) + commands.append(remove_operation(module, show_inactive, pkg)) + + else: + inactive_body = execute_show_command(show_inactive, module) + if pkg in inactive_body: + commands.append(remove_operation(module, show_inactive, pkg)) + + return commands + + +def main(): + element_spec = dict( + pkg=dict(type='str'), + file_system=dict(type='str', default='bootflash'), + state=dict(choices=['absent', 'present'], default='present') + ) + + aggregate_spec = deepcopy(element_spec) + aggregate_spec['pkg'] = dict(required=True) + + # remove default in aggregate spec, to handle common arguments + remove_default_spec(aggregate_spec) + + argument_spec = dict( + aggregate=dict(type='list', elements='dict', options=aggregate_spec) + ) + + argument_spec.update(element_spec) + argument_spec.update(nxos_argument_spec) + + required_one_of = [['pkg', 'aggregate']] + mutually_exclusive = [['pkg', 'aggregate']] + + module = AnsibleModule(argument_spec=argument_spec, + required_one_of=required_one_of, + mutually_exclusive=mutually_exclusive, + supports_check_mode=False) + + warnings = list() + results = {'changed': False, 'commands': [], 'warnings': warnings} + + aggregate = module.params.get('aggregate') + objects = [] + if aggregate: + for item in aggregate: + for key in item: + if item.get(key) is None: + item[key] = module.params[key] + + d = item.copy() + objects.append(d) + else: + objects.append({ + 'pkg': module.params['pkg'], + 'file_system': module.params['file_system'], + 'state': module.params['state'] + }) + + for obj in objects: + if obj['state'] == 'present': + remote_exists = remote_file_exists(module, obj['pkg'], file_system=obj['file_system']) + + if not remote_exists: + module.fail_json( + msg="The requested package doesn't exist on the device" + ) + + cmds = install_remove_rpm(module, obj['pkg'], obj['file_system'], obj['state']) + + if cmds: + results['changed'] = True + results['commands'].extend(cmds) + + module.exit_json(**results) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/nxos_rpm/defaults/main.yaml b/test/integration/targets/nxos_rpm/defaults/main.yaml new file mode 100644 index 00000000000..5f709c5aac1 --- /dev/null +++ b/test/integration/targets/nxos_rpm/defaults/main.yaml @@ -0,0 +1,2 @@ +--- +testcase: "*" diff --git a/test/integration/targets/nxos_rpm/meta/main.yml b/test/integration/targets/nxos_rpm/meta/main.yml new file mode 100644 index 00000000000..ae741cbdc71 --- /dev/null +++ b/test/integration/targets/nxos_rpm/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - prepare_nxos_tests diff --git a/test/integration/targets/nxos_rpm/tasks/cli.yaml b/test/integration/targets/nxos_rpm/tasks/cli.yaml new file mode 100644 index 00000000000..9b62eaba65e --- /dev/null +++ b/test/integration/targets/nxos_rpm/tasks/cli.yaml @@ -0,0 +1,27 @@ +--- +- name: collect common test cases + find: + paths: "{{ role_path }}/tests/common" + patterns: "{{ testcase }}.yaml" + connection: local + register: test_cases + +- name: collect cli test cases + find: + paths: "{{ role_path }}/tests/cli" + patterns: "{{ testcase }}.yaml" + connection: local + register: cli_cases + +- set_fact: + test_cases: + files: "{{ test_cases.files }} + {{ cli_cases.files }}" + +- name: set test_items + set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}" + +- name: run test cases (connection=network_cli) + include: "{{ test_case_to_run }} ansible_connection=network_cli connection={{ cli }}" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/test/integration/targets/nxos_rpm/tasks/main.yaml b/test/integration/targets/nxos_rpm/tasks/main.yaml new file mode 100644 index 00000000000..4b0f8c64d90 --- /dev/null +++ b/test/integration/targets/nxos_rpm/tasks/main.yaml @@ -0,0 +1,3 @@ +--- +- { include: cli.yaml, tags: ['cli'] } +- { include: nxapi.yaml, tags: ['nxapi'] } diff --git a/test/integration/targets/nxos_rpm/tasks/nxapi.yaml b/test/integration/targets/nxos_rpm/tasks/nxapi.yaml new file mode 100644 index 00000000000..04c99602e6b --- /dev/null +++ b/test/integration/targets/nxos_rpm/tasks/nxapi.yaml @@ -0,0 +1,33 @@ +--- +- name: collect common test cases + find: + paths: "{{ role_path }}/tests/common" + patterns: "{{ testcase }}.yaml" + connection: local + register: test_cases + +- name: collect nxapi test cases + find: + paths: "{{ role_path }}/tests/nxapi" + patterns: "{{ testcase }}.yaml" + connection: local + register: nxapi_cases + +- set_fact: + test_cases: + files: "{{ test_cases.files }} + {{ nxapi_cases.files }}" + +- name: set test_items + set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}" + +- name: run test cases (connection=httpapi) + include: "{{ test_case_to_run }} ansible_connection=httpapi connection={{ nxapi }}" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run + +- name: run test cases (connection=local) + include: "{{ test_case_to_run }} ansible_connection=local connection={{ nxapi }}" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/test/integration/targets/nxos_rpm/tests/common/sanity.yaml b/test/integration/targets/nxos_rpm/tests/common/sanity.yaml new file mode 100644 index 00000000000..d570249f5b6 --- /dev/null +++ b/test/integration/targets/nxos_rpm/tests/common/sanity.yaml @@ -0,0 +1,95 @@ +--- +- debug: msg="START connection={{ ansible_connection }} nxos_rpm sanity test" +- debug: msg="Using provider={{ connection.transport }}" + when: ansible_connection == "local" + +- set_fact: smu_run="false" +- set_fact: smu_run="true" + when: ((platform is search('N9K')) and (imagetag and (imagetag is version_compare('I2', 'ge')))) + +- set_fact: sdk_run="false" +- set_fact: sdk_run="true" + when: ((platform is search('N9K')) and (imagetag and (imagetag is version_compare('I6', 'ge')))) + +# The smu and nxsdk packages MUST be present on the device before the tests are run. +# The smu patch must be built to match the image version under test +# Only run this test after replacing the pkg with proper rpm files + +- debug: msg="***WARNING*** Remove meta end_play to verify this module ***WARNING***" + +- meta: end_play + +- block: + - name: Install smu RPM + nxos_rpm: &tsmurpm + pkg: "nxos.sample-n9k_ALL-1.0.0-7.0.3.I7.3.lib32_n9000.rpm" + register: result + + - assert: &true1 + that: + - "result.changed == true" + + - name: Check Idempotence + nxos_rpm: *tsmurpm + register: result + + - assert: &false1 + that: + - "result.changed == false" + + - name: Remove smu RPM + nxos_rpm: &rsmurpm + pkg: "nxos.sample-n9k_ALL-1.0.0-7.0.3.I7.3.lib32_n9000.rpm" + state: absent + register: result + + - assert: *true1 + + - name: Check Idempotence + nxos_rpm: *rsmurpm + register: result + + - assert: *false1 + + when: smu_run + +# healthMonitor-1.0-1.5.0.x86_64.rpm is avaibale at https://github.com/CiscoDevNet/NX-SDK/tree/master/rpm/RPMS +- block: + - name: Install nxsdk RPM(aggregate) + nxos_rpm: &tsdkrpm + aggregate: + - { pkg: "healthMonitor-1.0-1.5.0.x86_64.rpm", file_system: "bootflash" } + - { pkg: "customCliApp-1.0-1.0.0.x86_64.rpm" } + register: result + + - assert: &true2 + that: + - "result.changed == true" + + - name: Check Idempotence + nxos_rpm: *tsdkrpm + register: result + + - assert: &false2 + that: + - "result.changed == false" + + - name: Remove nxsdk RPM(aggregate) + nxos_rpm: &rsdkrpm + aggregate: + - { pkg: "healthMonitor-1.0-1.5.0.x86_64.rpm" } + - { pkg: "customCliApp-1.0-1.0.0.x86_64.rpm" } + state: absent + register: result + + - assert: *true2 + + - name: Check Idempotence + nxos_rpm: *rsdkrpm + register: result + + - assert: *false2 + + when: sdk_run + +- debug: msg="END connection={{ ansible_connection }} nxos_rpm sanity test"