From 336594cdfe8772b04b78d8592286411cbb17f5bd Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 30 Oct 2024 08:10:06 -1000 Subject: [PATCH] dcnm_bootflash module (#311) * WIP: Initial commit bootflash_info.py - Initial work to retrieve bootflash info. dcnm_bootflash.py - initial work on skeleton - Query(): sort of working. playbooks/roles/dcnm_bootflash - base files added tests/integration/targets/dcnm_bootflash/* - base files added module_utils/common/api/v1/... /bootflash/bootflash.py - Initial endpoints done * Renaming the module to dcnm_switch_bootflash The original requested name was dcnm_switch_bootflash, but I was using dcnm_bootflash. Have changed the name in all relevamt places. * Refactor _get() * Rename module to dcnm_bootflash After discussing with Shangxin, renaming the module to dcnm_bootflash. Have changed the name in all relevant places. * Fix NDFC-added leading space in ipAddr 1. bootflash_info.py - build_matches(): strip leading space from ipAddr value in NDFC response. - Update and/or add docstrings throughout. - Add match property to return the current matching switch+file information. - Remove unused @Properties.add_params class decorator. 2. dcnm_bootflash.py - Query(): Rewrite to use self.have. 3. tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_query.yaml - Rewrite assertions to avoid jinja2 warnings. - Update some of the REQUIREMENTS section. * Deleted(): Initial work to handle deleted state. * BootflashFiles(): Handle switch migration mode. Add infrastructure to easily determine whether files can be deleted on each switch and raise ValueError with appropriate error message if any files cannot be deleted. I suspect there will be more reasons than Migration mode so add capability to handle additional scenarios in the future. * BootflashFiles(): simplify validate_commit_parameters() * BootflashFiles().commit() update docstring. * IT: integration test for deleted state. This requires that files be manually created on switch-under-test bootflash. e.g. echo foo > foo.txt There's just no good, low-overhead, way to add files to a switch bootflash. dcnm_file_upload + dcnm_image_upgrade is (IMHO) way too slow for an integration test. Better just to get on the switch CLI and use echo... * Remove stray pylint directive. * BootflashFiles(): Appease pylint. * BootflashFiles(): Update class docstring. * BootflashFiles(): Add partition property. 1. Add partition property 2. Fix payload to use partition property (rather than file_path) for "partition" value. * BootflashFiles(): Remove unused attributes. BootflashFiles().__init__() - Remove self.response_dict and self.result_dict. - Alphabetize remaining attributes. * dcnm_bootflash.py: simplify error message handling. * Modify playbook structure based on discussion with team Also implement idempotence for files in root directory of flash devices. Idempotence cannot currently be supported for files in directories since the bootflash-info endpoint does not support listing files within directories. * BootflashInfo(): Call bootflash-discovery 1. EpBootflashDiscovery(): new endpoint class. 2. BootflashInfo(): To ensure we have current bootflash information, send EpBootflashDiscovery request before sending EpBootflashInfo request. 3. BootflashInfo(): Refactor stripping of ipAddr in build_matches() info strip_ipaddr_leading_space() 4. BootflashInfo(): change all occurances of bootflash_type to supervisor (new supervisor property returns associated key bootflash_type). 5. BootflashInfo(): update docstrings. 6. imagemgnt.py: Remove EpBootflashInfo() from this file. It already exists in imagemgnt/bootflash/bootflash.py * BootflashFiles(): bootflash_type -> supervisor 1. BootflashFiles(): Rename all occurances of bootflash_type to supervisor. 2. BootflashFiles(): Run through linters. * dcnm_bootflash.py: update DOCUMENTATION, more 1. Update DOCUMENTATION to note that currently we support only files in a partition's root directory. 2. Update EXAMPLES section to remove subdirectories. 3. Update docstrings throughout. 4. Common(): fix error messages to reference the correct vars. 3. * IT: dcnm_bootflash_deleted.yaml: fix filepath Remove directories from all filepath values since we do not support deleting files in directories. * Add method docstrings. * UT: Rename test file to reflect these are bootflash tests. 1. Rename EpBootFlashInfo() everywhere to EpBootflashInfo() 2. Rename test file to reflect that it contains tests for the bootflash endpoints. * BootflashInfo(): Update class and method docstrings * BootflashInfo().validate_refresh_parameters() refactor. * BootflashInfo().__init__(): initialize filter properties * dcnm_bootflash.py: share parse_target() 1. Deleted().parse_target() move to Common() 2. Query(): leverage parse_target() * IT: Update tests to reflect recent commits * dcnm_bootflash.py: Refactor out ParseTarget() 1. ParseTarget(): new class to parse a target dict. 2. dcnm_bootflash.py: Query() and Delete(): Leverage new ParseTargets() class. 3. BootflashInfo(): Only set results.action, results.check_mode, and results.state. Leave it to the caller to set results.current_*. 4. BootflashInfo().populate_property(): Refactor out build_match() 5. BootflashInfo(): Run through linters. 6. ParseTarget(): new class to parse a single target e.g. bootflash:/my.txt into its constituent bootflash-files API parameters. 7. dcnm_bootflash.py: leverage ParseTarget() 8. dcnm_bootflash.py: Remove Common().get_have() in favor of using BootflashInfo() in Query() and Deleted() 9. dcnm_bootflash_query.py - Update integration test expectations based on above changes. * BootflashInfo(): hardening. BootflashInfo().populate_property() return None if property is not found in self.match. Previously we raised ValueError(). * IT: dcnm_bootflash_delete.yaml: update 1. Update asserts based on previous commits. 2. Add expected results for each task. * Support file globbing Modifications to support file globbing syntax for matching files e.g.: # match all .txt files on bootflash: filepath: bootflash:/*.txt # match log files for July 2024 on all partitions filepath: *:/202407??.log * BootflashInfo(): update class docstring, more... 1. Update class docstring with Usage section. 2. Rename two methods: - filter_supervisor_matches() -> match_filter_supervisor() - filter_switch_matches() -> match_filter_switch() 3. New method - match_filter_filepath(): refactored out of build_matches() 4. Rename validate_refresh_parameters().raise_exception() to: - validate_refresh_parameters().raise_value_error_if_not_set() * IT: Update to test wildcards for partition and filepath. * BootflashInfo(): Add method docstring. 2. BootflashInfo().match_filter_filepath(): Add method docstring. * TargetToParams(): Add method docstrings. * FileInfoToTarget(): Fix class docstring * FileInfoToTarget(): Really fix class docstring... The last commit didn't account for recent additions to the infomation of the target dict. Fixed. * FileInfoToTarget().commit(): Update docstring. * Clean up diff results. Optimize payload. * BootflashFiles(): Add method docstrings, more... 1. BootflashFiles(): Add method docstrings 2. Run through linters * Fix sanity errors * BootflashFiles(): Fix var names. 1. BootflashFiles().ep_bootflash_info should be ep_bootflash_files. Fixed. 2. change file_name, file_path, and file_size, to filename, filepath, and filesize. * UT: BootflashFiles().__init__() add unit test. * UT: BootflashInfo().__init__() add unit test. 1. BootflashInfo() - Add class decorator @Properties.add_rest_send 2. BootflashInfo().__init__() - self.action : change from "bootflash_query" to "bootflash_info" - Initialize self._matches rather than self.matches. - Initialize self._rest_send to None * IT: BootflashInfo(): update assert to match action BootflashInfo().action was modified in an earlier commit. Updating the corresponding IT to reflect thsi change. * BootflashFiles().target.setter: raise TypeError 1. BootflashFiles().target modified to raise TypeError if value is not a dict. 2. BootflashFiles(): Update docstrings. * refactor and cleanup 1. bootflash_info.py - Align imports after renaming conversion classes. - Rename conversion class instance from file_info_to_target to convert_file_info_to_target. 2. dcnm_bootflash.py - Deleted().commit(): refactor. - Query().commit(): Remove unused code. - Align imports after renaming conversion classes. 3. convert_file_info_to_target.py - Rename from file_info_to_target.py - FileInfoToTarget(): rename to ConvertFileInfoToTarget() - Update docstrings. 4. convert_target_to_params.py - Rename from target_to_params.py - TargetToParams() rename to ConvertTargetToParams() 5. test_bootflash_files.py - Rename from test_dcnm_bootflash_files.py 6. test_bootflash_info.py - Rename from test_dcnm_bootflash_info.py - Update class instance name to reflect above changes. 6. New unit test files - test_convert_file_into_to_target.py - test_convert_target_to_params.py * UT: BootflashInfo(): test_bootflash_info_00110 1. Add unit test test_bootflash_info_00110 ### Summary - Verify exception is raised if rest_send is not set. ### Test - ValueError is raised when rest_send is not set. 2. BootflashInfo().validate_refresh_parameters().raise_value_error_if_not_set(): ValueError() -> ValueError(msg) 3. Bootflash().switches: Update docstring. * UT: BootflashInfo().refresh() unit tests 1. test_bootflash_info_00100 - Happy path 2. test_bootflash_info_00120 - ValueError is raised when results is not set. 3. test_bootflash_info_00130 - ValueError is raised when switch_details is not set. * UT: Fixtures and utils for bootflash_info unit tests. * utils.py: Fix sanity errors Fix unused imports and too many blank lines. * UT: BootflashInfo(): 88% coverage. 1. BootflashInfo(): Remove multiple unused getter properties. An earlier version of BootflashInfo() provided getter properties for individual items in the file_info dict. Functionality of BootflashInfo() changed from this earlier version such that these getter properties are no longer used. 2. Add unit tests. - test_bootflash_info_00140 - test_bootflash_info_00150 - test_bootflash_info_00200 - test_bootflash_info_00210 - test_bootflash_info_00300 - test_bootflash_info_00310 - test_bootflash_info_00320 - test_bootflash_info_00400 * UT: BootflashInfo(): Add missing assert for 00210 1. test_bootflash_info_00210 was missing the requisite assert. * UT: BootflashInfo(): 91% coverage 1. BootflashInfo(): add test: - test_bootflash_info_00220 2. BootflashInfo().build_matches(): Remove conditional that would never be hit. * BootflashInfo(): remove matches.setter, more... 1. BootflashInfo(): remove unused matches.setter. instance._matches is set internally, so only matches.getter is needed. 2. BootflashInfo().build_matches(): remove check for ip_address since it's verified previously. 3. BootflashInfo(): Update docstrings and comments. * UT: BootflashInfo(): Add test_bootflash_info_00230 1. BootflashInfo(): - test_bootflash_info_00230 2. test_bootflash_info_00100 - Remove 14 of 20 asserts as these are very expensive. Reduced the test time from 30 to 8.4 seconds. * UT: BootflashInfo(): 93% coverage. 1. Add test cases for switch_details.setter: - test_bootflash_info_00500 - test_bootflash_info_00510 * UT: BootflashInfo(): 100% coverage 1. BootflashInfo().switches.setter: improve error messages. 2. test_bootflash_info.py: Add tests for BootflashInfo().switches.setter. - test_bootflash_info_00600 - test_bootflash_info_00610 - test_bootflash_info_00620 * BootflashFiles(): Do not use payload for diff Payload does not have much useful information, so use diff_current to populate the diff. * ConvertTargetToParams(): Add Usage to class docstring * dcnm_bootflash.py: remove unused import * BootflashFiles: Add unit test, more... 1. BootflashFiles: test_bootflash_files_00100 - Verify commit happy path. 2. utils.py: - Remove unused fixtures * UT: BootflashInfo(): remove unused imports. 1. test_bootflash_info.py: remove unused imports. * UT: ConvertFileInfoToTarget(): 100% coverage, more 1. test_convert_file_info_to_target.py - 100% test coverage for ConvertFileInfoToTarget() 2. ConvertFileInfoToTarget() - validate_commit_parameters(): new method - Update docstrings. - commit(): Raise ValueError if ":/" is nto in the resulting filepath. - date.getter: Fix error message. * UT: Fix PurePosixPath version differences Depending on the version of PurePosixPath, the error message differs (see below). Fix by using a general .* regex for the Error detail: portion of the message. argument should be a str or an os.PathLike object where __fspath__ returns a str, not 'int' Versus: expected str, bytes or os.PathLike object, not int' * UT: ConvertTargetToParams: 100% coverage, more... 1. convert_target_to_params.py - Update class docstring with example for file in directory. - ConvertTargetToParams().partition.setter: validate partition - ConvertTargetToParams().supervisor.setter: validate supervisor. 2. targets_BootflashFiles.json - rename to targets.json 3. test_bootflash_files.py - Update import for targets 4. test_convert_file_info_to_target.py - Cleanup docstrings 5. test_convert_target_to_params.py - Add testcases for 100% coverage. 6. utils.py - rename targets_bootflash_files() to targets() * UT: BootflashFiles(): 75% coverage. 1. test_bootflash_files.py - test_bootflash_files_00200 - Verify add_file() raises ValueError if switch mode is either "migration" or "inconsistent". 2. BootflashFiles().add_file() - Improve error message. * UT: BootflashFiles(): 77% coverage. 1. BootflashFiles(). refresh_switch_details() - test_bootflash_files_00300 - Verify ValueError raised if switch_details is not set. - test_bootflash_files_00310 - Verify ValueError raised if rest_send is not set. * IT: Add playbook to create bootflash files. 1. create_files.yaml - Use cisco.nxos.nxos_command module to create files on NX-OS switches. * IT: Add spine1 and spine2 to dcnm_hosts.yaml Add spine1 and spine2 to the inventory for use by create_files.yaml * IT: dcnm_bootflash_query.yaml improvements. 1. Add test case for wildcard query. 2. Retain and expand test case for specific query. 3. Fix assertions. 4. Remove most file objects from Expected output for brevity. * IT: expand dcnm_bootflash_deleted.yaml 1. Delete dcnm_bootflash_deleted.yaml and expand into two test cases. - dcnm_bootflash_deleted_specific.yaml - dcnm_bootflash_deleted_wildcard.yaml 2. dcnm_tests.yaml - Add file vars for dcnm_bootflash tests. - Update list of test cases to include those in 1 above. 3. dcnm_bootflash_query.yaml - Minor update to comments. * IT: dcnm_bootflash_query.yaml update run time. * UT: BootflashFiles(): 94% coverage. * UT: BootflashFiles(): 99% coverage. more... 1. BootflashFiles(): - Update method docstrings. - filesize: Remove unused property. - target.setter: Require target to include filepath, ip_address, serial_number, and supervisor. 2. test_bootflash_files.py - 99% coverage. - Move target dictionaries into targets.json * dcnm_bootflash Common(): remove unused properties. * UT: dcnm_bootflash.py: 29% coverage. * Common().__init__() hardening, more... 1. dcnm_bootflash.py Common().__init__(): added more verifications. 2. test_dcnm_bootflash_common.py - 40% coverage. 3. utils.py - Update params_deleted.config to include targets list. * Common().get_want(): Improve error messages, more 1. dcnm_bootflash.py - Common().get_want(): Improve error messages. - main(): Add TypeError to except tuple when instantiating Log() * UT: Common(): 100% coverage 1. test_bootflash_common.py - 100% coverage of Common() in dcnm_bootflash.py - Remove unused commented imports. * UT: dcnm_bootflash.py: 66% coverage 1. dcnm_bootflash.py - Common().get_want(): Use copy.deepcopy() 2. test_bootflash_deleted.py - Deleted(): Initial unit tests * Deleted(): 68% coverage. 1. test_bootflash_deleted.py - test_bootflash_deleted_02000 - populate_files_to_delete() negative test * BootflashFiles().target: fix exception type 1. BootflashFiles().target should raise TypeError if value is not a dictionary. 2. test_bootflash_files.py - Update corresponding unit tests. * Deleted().update_bootflash_files(): hardening 1. dcnm_bootflash.py - Deleted().update_bootflash_files(): add try/except block around - convert_target_to_params - bootflash_files property setters 2. test_bootflash_deleted.py - Add unit tests for the above * UT: dcnm_bootflash.py: 71% coverage. 1. test_bootflash_deleted.py - Deleted(): 100% coverage * UT: Query(): initial unit tests 1. test_bootflash_query.py - 87% coverage for dcnm_bootlfash.py - test_bootflash_query_00000 - test_bootflash_query_00010 - test_bootflash_query_01000 * Common().__init__(): Fix target handling. more... 1. dcnm_bootflash.py - Common()__init__() - Fix crash when users doesn't include global target. - Query().register_null_result() - new method. 2. Update unit tests to align with changes to Common() above. 3. test_bootflash_query.py - test_bootflash_query_01010 - Add test 4. Run everything through black/isort. * dcnm_bootflash.py: Fix pylint error. * IT: Move query tests into separate files. 1. Move query-state tests into separate files to match deleted-state tests. * IT: Fix deleted-state tests idempotency asserts 1. result.response was changed a couple commits ago. Modifying integration tests to align with those changes. * IT: Remove hard-coded filenames, more... 1. tests/integration/targets/dcnm_bootflash/defaults/main.yaml - Add default filenames here. User can override in dcnm_tests.yaml - Add default wildcard_filepath here. User can override in dcnm_tests.yaml 2. Update inventory to use switch1 and switch2 instead of spine1 and spine2 3. tests/integration/targets/dcnm_bootflash/tests/*.yaml - Change spine1 and spine2 to switch1 and switch2 everywhere - Update REQUIREMENTS section with current set of vars and mention that the default vars can be overridden by uncommenting in dcnm_tests.yaml 4. playbooks/roles/dcnm_bootflash/*.yaml - Update to reflect the above changes. - Remove all vars not used in the dcnm_bootflash integration tests. * IT: create_files.yaml - read vars from file. There doesn't appear to be a way to add create_files as a testcase within dcnm_bootflash role. For now, create_files.yaml is a separate playbook that uses ansible.builtin.include_vars to read nxos_vars.yaml to determine what files to create. 1. playbooks/roles/dcnm_bootflash/create_files.yaml - modify to read vars from nxos_vars.yaml 2. playbooks/roles/dcnm_bootflash/nxos_vars.yaml - contains dictionary nxos_vars defining files to create. * dcnm_bootflash.py: Add wildcard examples. * Fix yamllint error * Fix typo in EXAMPLES section. * Add Docs * Doc updates --------- Co-authored-by: mwiebe --- README.md | 1 + docs/cisco.dcnm.dcnm_bootflash_module.rst | 330 ++++++ docs/cisco.dcnm.dcnm_image_policy_module.rst | 1 + docs/cisco.dcnm.dcnm_image_upload_module.rst | 1 + docs/cisco.dcnm.dcnm_interface_module.rst | 1 + docs/cisco.dcnm.dcnm_links_module.rst | 1 + docs/cisco.dcnm.dcnm_vpc_pair_module.rst | 1 + .../roles/dcnm_bootflash/create_files.yaml | 28 + .../roles/dcnm_bootflash/dcnm_hosts.yaml | 27 + .../roles/dcnm_bootflash/dcnm_tests.yaml | 37 + playbooks/roles/dcnm_bootflash/nxos_vars.yaml | 9 + plugins/module_utils/bootflash/__init__.py | 0 .../module_utils/bootflash/bootflash_files.py | 707 +++++++++++ .../module_utils/bootflash/bootflash_info.py | 620 ++++++++++ .../bootflash/convert_file_info_to_target.py | 426 +++++++ .../bootflash/convert_target_to_params.py | 282 +++++ .../rest/discovery/__init__.py | 0 .../rest/discovery/discovery.py | 125 ++ .../rest/imagemgnt/bootflash/__init__.py | 0 .../rest/imagemgnt/bootflash/bootflash.py | 164 +++ .../rest/imagemgnt/imagemgnt.py | 43 - plugins/modules/dcnm_bootflash.py | 744 ++++++++++++ plugins/modules/dcnm_maintenance_mode.py | 2 +- .../targets/dcnm_bootflash/defaults/main.yaml | 11 + .../targets/dcnm_bootflash/meta/main.yaml | 1 + .../targets/dcnm_bootflash/tasks/dcnm.yaml | 20 + .../targets/dcnm_bootflash/tasks/main.yaml | 2 + .../dcnm_bootflash_deleted_specific.yaml | 330 ++++++ .../dcnm_bootflash_deleted_wildcard.yaml | 311 +++++ .../tests/dcnm_bootflash_query_specific.yaml | 279 +++++ .../tests/dcnm_bootflash_query_wildcard.yaml | 269 +++++ tests/sanity/ignore-2.10.txt | 1 + tests/sanity/ignore-2.11.txt | 1 + tests/sanity/ignore-2.12.txt | 1 + tests/sanity/ignore-2.13.txt | 1 + tests/sanity/ignore-2.14.txt | 1 + tests/sanity/ignore-2.15.txt | 1 + tests/sanity/ignore-2.16.txt | 1 + tests/sanity/ignore-2.9.txt | 1 + ...agemanagement_rest_imagemgnt_bootflash.py} | 11 +- .../modules/dcnm/dcnm_bootflash/fixture.py | 50 + .../fixtures/configs_Deleted.json | 125 ++ .../fixtures/configs_Query.json | 195 ++++ .../file_info_ConvertFileInfoToTarget.json | 50 + .../fixtures/payloads_BootflashFiles.json | 108 ++ .../fixtures/responses_EpAllSwitches.json | 479 ++++++++ .../responses_EpBootflashDiscovery.json | 75 ++ .../fixtures/responses_EpBootflashFiles.json | 21 + .../fixtures/responses_EpBootflashInfo.json | 708 +++++++++++ .../dcnm/dcnm_bootflash/fixtures/targets.json | 108 ++ .../targets_ConvertFileInfoToTarget.json | 15 + .../dcnm_bootflash/test_bootflash_common.py | 419 +++++++ .../dcnm_bootflash/test_bootflash_deleted.py | 413 +++++++ .../dcnm_bootflash/test_bootflash_files.py | 1033 +++++++++++++++++ .../dcnm_bootflash/test_bootflash_info.py | 828 +++++++++++++ .../dcnm_bootflash/test_bootflash_query.py | 223 ++++ .../test_convert_file_info_to_target.py | 259 +++++ .../test_convert_target_to_params.py | 346 ++++++ .../unit/modules/dcnm/dcnm_bootflash/utils.py | 211 ++++ 59 files changed, 10409 insertions(+), 49 deletions(-) create mode 100644 docs/cisco.dcnm.dcnm_bootflash_module.rst create mode 100644 playbooks/roles/dcnm_bootflash/create_files.yaml create mode 100644 playbooks/roles/dcnm_bootflash/dcnm_hosts.yaml create mode 100644 playbooks/roles/dcnm_bootflash/dcnm_tests.yaml create mode 100644 playbooks/roles/dcnm_bootflash/nxos_vars.yaml create mode 100644 plugins/module_utils/bootflash/__init__.py create mode 100644 plugins/module_utils/bootflash/bootflash_files.py create mode 100644 plugins/module_utils/bootflash/bootflash_info.py create mode 100644 plugins/module_utils/bootflash/convert_file_info_to_target.py create mode 100644 plugins/module_utils/bootflash/convert_target_to_params.py create mode 100644 plugins/module_utils/common/api/v1/imagemanagement/rest/discovery/__init__.py create mode 100644 plugins/module_utils/common/api/v1/imagemanagement/rest/discovery/discovery.py create mode 100644 plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/bootflash/__init__.py create mode 100644 plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/bootflash/bootflash.py create mode 100644 plugins/modules/dcnm_bootflash.py create mode 100644 tests/integration/targets/dcnm_bootflash/defaults/main.yaml create mode 100644 tests/integration/targets/dcnm_bootflash/meta/main.yaml create mode 100644 tests/integration/targets/dcnm_bootflash/tasks/dcnm.yaml create mode 100644 tests/integration/targets/dcnm_bootflash/tasks/main.yaml create mode 100644 tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_deleted_specific.yaml create mode 100644 tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_deleted_wildcard.yaml create mode 100644 tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_query_specific.yaml create mode 100644 tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_query_wildcard.yaml rename tests/unit/module_utils/common/api/{test_api_v1_imagemanagement_rest_imagemgnt.py => test_api_v1_imagemanagement_rest_imagemgnt_bootflash.py} (83%) create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixture.py create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixtures/configs_Deleted.json create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixtures/configs_Query.json create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixtures/file_info_ConvertFileInfoToTarget.json create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixtures/payloads_BootflashFiles.json create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpAllSwitches.json create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashDiscovery.json create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashFiles.json create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashInfo.json create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixtures/targets.json create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/fixtures/targets_ConvertFileInfoToTarget.json create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_common.py create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_deleted.py create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_files.py create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_info.py create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_query.py create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/test_convert_file_info_to_target.py create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/test_convert_target_to_params.py create mode 100644 tests/unit/modules/dcnm/dcnm_bootflash/utils.py diff --git a/README.md b/README.md index 32ef31d0c..cd1171364 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Name | Description ### Modules Name | Description --- | --- +[cisco.dcnm.dcnm_bootflash](https://github.com/CiscoDevNet/ansible-dcnm/blob/main/docs/cisco.dcnm.dcnm_bootflash_module.rst)|Bootflash management for Nexus switches. [cisco.dcnm.dcnm_fabric](https://github.com/CiscoDevNet/ansible-dcnm/blob/main/docs/cisco.dcnm.dcnm_fabric_module.rst)|Manage creation and configuration of NDFC fabrics. [cisco.dcnm.dcnm_image_policy](https://github.com/CiscoDevNet/ansible-dcnm/blob/main/docs/cisco.dcnm.dcnm_image_policy_module.rst)|Image policy management for Nexus Dashboard Fabric Controller [cisco.dcnm.dcnm_image_upgrade](https://github.com/CiscoDevNet/ansible-dcnm/blob/main/docs/cisco.dcnm.dcnm_image_upgrade_module.rst)|Image management for Nexus switches diff --git a/docs/cisco.dcnm.dcnm_bootflash_module.rst b/docs/cisco.dcnm.dcnm_bootflash_module.rst new file mode 100644 index 000000000..415ce4083 --- /dev/null +++ b/docs/cisco.dcnm.dcnm_bootflash_module.rst @@ -0,0 +1,330 @@ +.. _cisco.dcnm.dcnm_bootflash_module: + + +************************* +cisco.dcnm.dcnm_bootflash +************************* + +**Bootflash management for Nexus switches.** + + +Version added: 3.6.0 + +.. contents:: + :local: + :depth: 1 + + +Synopsis +-------- +- Delete, query bootflash files. + + + + +Parameters +---------- + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterChoices/DefaultsComments
+
+ config + +
+ dictionary + / required +
+
+ +
Configuration parameters for the module.
+
+
+ switches + +
+ list + / elements=dictionary +
+
+ +
List of dictionaries containing switches on which query or delete operations are executed.
+
+
+ ip_address + +
+ string + / required +
+
+ +
The ip address of a switch.
+
+
+ targets + +
+ list + / elements=dictionary +
+
+ Default:
[]
+
+
List of dictionaries containing options for files to be deleted or queried.
+
+
+ filepath + +
+ string + / required +
+
+ +
The path to the file to be deleted or queried. Only files in the root directory of the partition are currently supported.
+
+
+ supervisor + +
+ string +
+
+
    Choices: +
  • active ←
  • +
  • standby
  • +
+
+
Either active or standby. The supervisor containing the filepath.
+
+
+ targets + +
+ list + / elements=dictionary +
+
+ Default:
[]
+
+
List of dictionaries containing options for files to be deleted or queried.
+
+
+ filepath + +
+ string + / required +
+
+ +
The path to the file to be deleted or queried.
+
+
+ supervisor + +
+ string +
+
+
    Choices: +
  • active ←
  • +
  • standby
  • +
+
+
Either active or standby. The supervisor containing the filepath.
+
+
+ state + +
+ string +
+
+
    Choices: +
  • deleted
  • +
  • query ←
  • +
+
+
The state of the feature or object after module completion
+
+
+ + + + +Examples +-------- + +.. code-block:: yaml + + # This module supports the following states: + # + # deleted: + # Delete files from the bootflash of one or more switches. + # + # If an image is in use by a device, the module will fail. Use + # dcnm_image_upgrade module, state deleted, to detach image policies + # containing images to be deleted. + # + # query: + # + # Return information for one or more files. + # + # Delete two files from each of three switches. + + - name: Delete two files from each of two switches + cisco.dcnm.dcnm_bootflash: + state: deleted + config: + targets: + - filepath: bootflash:/foo.txt + supervisor: active + - filepath: bootflash:/bar.txt + supervisor: standby + switches: + - ip_address: 192.168.1.1 + - ip_address: 192.168.1.2 + - ip_address: 192.168.1.3 + + # Delete two files from switch 192.168.1.1 and switch 192.168.1.2: + # - foo.txt on the active supervisor's bootflash: device. + # - bar.txt on the standby supervisor's bootflash: device. + # Delete potentially multiple files from switch 192.168.1.3: + # - All txt files on the standby supervisor's bootflash: device + # that match the pattern 202401??.txt, e.g. 20240123.txt. + # Delete potentially multiple files from switch 192.168.1.4: + # - All txt files on all flash devices on active supervisor. + + - name: Delete files + cisco.dcnm.dcnm_bootflash: + state: deleted + config: + targets: + - filepath: bootflash:/foo.txt + supervisor: active + - filepath: bootflash:/bar.txt + supervisor: standby + switches: + - ip_address: 192.168.1.1 + - ip_address: 192.168.1.2 + - ip_address: 192.168.1.3 + targets: + - filepath: bootflash:/202401??.txt + supervisor: standby + - ip_address: 192.168.1.4 + targets: + - filepath: "*:/*.txt" + supervisor: active + register: result + - name: print result + ansible.builtin.debug: + var: result + + # Query the controller for information about one file on three switches. + # Since the default for supervisor is "active", the module will query the + # active supervisor's bootflash: device. + + - name: Query file on three switches + cisco.dcnm.dcnm_bootflash: + state: query + config: + targets: + - filepath: bootflash:/foo.txt + switches: + - ip_address: 192.168.1.1 + - ip_address: 192.168.1.2 + - ip_address: 192.168.1.3 + register: result + - name: print result + ansible.builtin.debug: + var: result + + + + +Status +------ + + +Authors +~~~~~~~ + +- Allen Robel (@quantumonion) diff --git a/docs/cisco.dcnm.dcnm_image_policy_module.rst b/docs/cisco.dcnm.dcnm_image_policy_module.rst index e7ae7b0a6..5e0f3b250 100644 --- a/docs/cisco.dcnm.dcnm_image_policy_module.rst +++ b/docs/cisco.dcnm.dcnm_image_policy_module.rst @@ -44,6 +44,7 @@ Parameters + Default:
[]
List of dictionaries containing image policy parameters
diff --git a/docs/cisco.dcnm.dcnm_image_upload_module.rst b/docs/cisco.dcnm.dcnm_image_upload_module.rst index 4291d9395..98dbc4565 100644 --- a/docs/cisco.dcnm.dcnm_image_upload_module.rst +++ b/docs/cisco.dcnm.dcnm_image_upload_module.rst @@ -45,6 +45,7 @@ Parameters + Default:
[]
A dictionary of images and other related information that is required to download the same.
diff --git a/docs/cisco.dcnm.dcnm_interface_module.rst b/docs/cisco.dcnm.dcnm_interface_module.rst index d17493f1d..dcb07cf7f 100644 --- a/docs/cisco.dcnm.dcnm_interface_module.rst +++ b/docs/cisco.dcnm.dcnm_interface_module.rst @@ -65,6 +65,7 @@ Parameters + Default:
[]
A dictionary of interface operations
diff --git a/docs/cisco.dcnm.dcnm_links_module.rst b/docs/cisco.dcnm.dcnm_links_module.rst index d23d78274..e96b990e9 100644 --- a/docs/cisco.dcnm.dcnm_links_module.rst +++ b/docs/cisco.dcnm.dcnm_links_module.rst @@ -44,6 +44,7 @@ Parameters + Default:
[]
A list of dictionaries containing Links information.
diff --git a/docs/cisco.dcnm.dcnm_vpc_pair_module.rst b/docs/cisco.dcnm.dcnm_vpc_pair_module.rst index 6325d19ff..1ee095097 100644 --- a/docs/cisco.dcnm.dcnm_vpc_pair_module.rst +++ b/docs/cisco.dcnm.dcnm_vpc_pair_module.rst @@ -44,6 +44,7 @@ Parameters + Default:
[]
A list of dictionaries containing VPC switch pair information
diff --git a/playbooks/roles/dcnm_bootflash/create_files.yaml b/playbooks/roles/dcnm_bootflash/create_files.yaml new file mode 100644 index 000000000..5eca022e6 --- /dev/null +++ b/playbooks/roles/dcnm_bootflash/create_files.yaml @@ -0,0 +1,28 @@ +--- +- gather_facts: false + hosts: + - switch1 + tasks: + - name: Load nxos_vars + ansible.builtin.include_vars: nxos_vars.yaml + - name: SETUP - Create files on {{ switch1 }} + cisco.nxos.nxos_command: + commands: + - echo 1 > bootflash:/{{ nxos_vars.switch1_file1 }} + - echo 1 > bootflash:/{{ nxos_vars.switch1_file2 }} + - echo 1 > bootflash:/{{ nxos_vars.switch1_file3 }} + - echo 1 > bootflash:/{{ nxos_vars.switch1_file4 }} + +- gather_facts: false + hosts: + - switch2 + tasks: + - name: Load nxos_vars + ansible.builtin.include_vars: nxos_vars.yaml + - name: SETUP - Create files on {{ switch2 }} + cisco.nxos.nxos_command: + commands: + - echo 1 > bootflash:/{{ nxos_vars.switch2_file1 }} + - echo 1 > bootflash:/{{ nxos_vars.switch2_file2 }} + - echo 1 > bootflash:/{{ nxos_vars.switch2_file3 }} + - echo 1 > bootflash:/{{ nxos_vars.switch2_file4 }} diff --git a/playbooks/roles/dcnm_bootflash/dcnm_hosts.yaml b/playbooks/roles/dcnm_bootflash/dcnm_hosts.yaml new file mode 100644 index 000000000..c00d2f8ec --- /dev/null +++ b/playbooks/roles/dcnm_bootflash/dcnm_hosts.yaml @@ -0,0 +1,27 @@ +all: + vars: + ansible_user: "admin" + ansible_password: "password-secret" + ansible_python_interpreter: python + ansible_httpapi_validate_certs: False + ansible_httpapi_use_ssl: True + children: + dcnm: + vars: + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: cisco.dcnm.dcnm + hosts: + dcnm-instance.example.com + nxos: + vars: + ansible_connection: ansible.netcommon.network_cli + ansible_network_os: cisco.nxos.nxos + ansible_become: true + ansible_become_method: enable + children: + switch1: + hosts: + 192.168.1.1 + switch2: + hosts: + 192.168.1.2 diff --git a/playbooks/roles/dcnm_bootflash/dcnm_tests.yaml b/playbooks/roles/dcnm_bootflash/dcnm_tests.yaml new file mode 100644 index 000000000..ed2a3167a --- /dev/null +++ b/playbooks/roles/dcnm_bootflash/dcnm_tests.yaml @@ -0,0 +1,37 @@ +--- +# This playbook can be used to execute integration tests for +# the role located in: +# +# tests/integration/targets/dcnm_bootflash +# +# Modify the hosts and vars sections with details for your testing +# setup and uncomment the testcase you want to run. +# +- hosts: dcnm + gather_facts: no + connection: ansible.netcommon.httpapi + + vars: + # testcase: dcnm_bootflash_deleted_specific + # testcase: dcnm_bootflash_deleted_wildcard + # testcase: dcnm_bootflash_query_specific + # testcase: dcnm_bootflash_query_wildcard + switch_username: admin + switch_password: "password-secret" + switch1: 192.168.1.2 + switch2: 192.168.1.3 + # The vars below are included in the role's defaults/main.yaml + # If it is desired to override the defaults, uncomment and + # modify these. + # switch1_file1: air.ndfc_ut + # switch1_file2: earth.ndfc_ut + # switch1_file3: fire.ndfc_ut + # switch1_file4: water.ndfc_ut + # switch2_file1: black.ndfc_ut + # switch2_file2: blue.ndfc_ut + # switch2_file3: green.ndfc_ut + # switch2_file4: red.ndfc_ut + # wildcard_filepath: "*:/*.ndfc_ut" + + roles: + - dcnm_bootflash diff --git a/playbooks/roles/dcnm_bootflash/nxos_vars.yaml b/playbooks/roles/dcnm_bootflash/nxos_vars.yaml new file mode 100644 index 000000000..69fd5eb94 --- /dev/null +++ b/playbooks/roles/dcnm_bootflash/nxos_vars.yaml @@ -0,0 +1,9 @@ +nxos_vars: + switch1_file1: air.ndfc_ut + switch1_file2: earth.ndfc_ut + switch1_file3: fire.ndfc_ut + switch1_file4: water.ndfc_ut + switch2_file1: black.ndfc_ut + switch2_file2: blue.ndfc_ut + switch2_file3: green.ndfc_ut + switch2_file4: red.ndfc_ut diff --git a/plugins/module_utils/bootflash/__init__.py b/plugins/module_utils/bootflash/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/bootflash/bootflash_files.py b/plugins/module_utils/bootflash/bootflash_files.py new file mode 100644 index 000000000..6e299344b --- /dev/null +++ b/plugins/module_utils/bootflash/bootflash_files.py @@ -0,0 +1,707 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.api.v1.imagemanagement.rest.imagemgnt.bootflash.bootflash import \ + EpBootflashFiles +from ansible_collections.cisco.dcnm.plugins.module_utils.common.conversion import \ + ConversionUtils +from ansible_collections.cisco.dcnm.plugins.module_utils.common.properties import \ + Properties + + +@Properties.add_rest_send +@Properties.add_results +class BootflashFiles: + """ + ### Summary + Delete files from bootflash devices. + + ### Raises + - ``ValueError`` if: + - ``rest_send`` is not set before calling commit() + - ``results`` is not set before calling commit() + - ``switch_details`` is not set before calling commit() + - payload.deleteFiles is empty when calling commit() + - ``filename`` is not set before calling add_file() + - ``filepath`` is not set before calling add_file() + - ``ip_address`` is not set before calling add_file() + - ``supervisor`` is not set before calling add_file() + - ``switch_details`` is not set before calling add_file() + - ``TypeError`` if: + - ``switch_details`` is not an instance of ``SwitchDetails``. + - ip_address to serial_number conversion fails. + + ### Usage + + ```python + sender = Sender() + sender.ansible_module = ansible_module + rest_send = RestSend(ansible_module.params) + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + results = Results() + + instance = BootflashFiles() + instance.results = Results() + + # BootflashFiles() uses SwitchDetails() to convert + # switch ip addresses to serial numbers (which is + # required by the NDFC API). + instance.switch_details = SwitchDetails() + + # We pass switch_details.results a separate instance of + # results because we are not interested in its results. + instance.switch_details.results = Results() + + # Delete a file in the root directory of the bootflash + # on the active supervisor of switch 192.168.1.1: + instance.supervisor = "active" + instance.filename = "nxos_image.bin" + instance.filepath = "bootflash:/mydir" + instance.ip_address = "192.168.1.1" + instance.partition = "bootflash:" + # optional + # instance.target = target_dict # see target property for details + instance.add_file() + instance.commit() + ``` + + ### Payload Structure + + The structure of the request body to delete bootflash files. + + ```json + { + "deleteFiles": [ + { + "serialNumber": "ABO1234567C", + "partition": "bootflash:", + "files": [ + { + "filePath": "bootflash:", + "fileName": "20210922_230124_poap_3543_init.log", + "bootflashType": "active" + } + ] + } + ] + } + ``` + """ + + def __init__(self): + self.class_name = self.__class__.__name__ + + self.action = "bootflash_delete" + self.conversion = ConversionUtils() + # self.diff is keyed on switch ip_address and is updated + # in self.update_diff(). + self.diff = {} + self.ep_bootflash_files = EpBootflashFiles() + + self.ok_to_delete_files_reason = None + self.mandatory_target_keys = [ + "filepath", + "ip_address", + "serial_number", + "supervisor", + ] + self.payload = {"deleteFiles": []} + self.switch_details_refreshed = False + + self._filename = None + self._filepath = None + self._ip_address = None + self._partition = None + self._rest_send = None + self._results = None + self._supervisor = None + self._switch_details = None + self._target = None + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED BootflashQuery(): " + msg += f"action {self.action}, " + self.log.debug(msg) + + def refresh_switch_details(self): + """ + ### Summary + If switch details are not already refreshed, refresh them. + + ### Raises + - ``ValueError`` if: + - ``switch_details`` is not set. + - ``rest_send`` is not set. + """ + method_name = inspect.stack()[0][3] + + def raise_exception(property_name): + msg = f"{self.class_name}.{method_name}: " + msg += f"{property_name} must be set before calling {method_name}." + raise ValueError(f"{msg}") + + if self.switch_details is None: + raise_exception("switch_details") + # pylint: disable=no-member + if self.rest_send is None: + raise_exception("rest_send") + + if self.switch_details_refreshed is False: + self.switch_details.rest_send = self.rest_send + self.switch_details.refresh() + self.switch_details_refreshed = True + + def ip_address_to_serial_number(self, ip_address): + """ + ### Summary + Convert ip_address to serial_number. + + ### Raises + - ``ValueError`` if: + - switch_details is not set. + """ + method_name = inspect.stack()[0][3] + + self.refresh_switch_details() + + self.switch_details.filter = ip_address + try: + serial_number = self.switch_details.serial_number + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"{error}" + raise ValueError(msg) from error + return serial_number + + def ok_to_delete_files(self, ip_address): + """ + ### Summary + - Return True if files can be deleted on the switch with ip_address. + - Return False otherwise. + + ### Raises + None + """ + self.refresh_switch_details() + bad_modes = ["inconsistent", "migration"] + self.switch_details.filter = ip_address + if self.switch_details.mode in bad_modes: + reason = f"switch mode is {self.switch_details.mode}" + self.ok_to_delete_files_reason = reason + return False + return True + + def validate_commit_parameters(self) -> None: + """ + ### Summary + Verify that mandatory prerequisites are met before calling commit. + + ### Raises + - ``ValueError`` if: + - rest_send is not set. + - results is not set. + - switch_details is not set. + - payload is not set. + """ + # pylint: disable=no-member + method_name = inspect.stack()[0][3] + + def raise_exception(property_name): + msg = f"{self.class_name}.{method_name}: " + msg += f"{property_name} must be set before calling commit()." + raise ValueError(f"{msg}") + + if not self.rest_send: + raise_exception("rest_send") + if not self.results: + raise_exception("results") + if not self.switch_details: + raise_exception("switch_details") + + def commit(self): + """ + ### Summary + Send the payload to delete files. + + ### Raises + - ``ValueError`` if: + - Mandatory parameters are not set. + + ### Notes + - pylint: disable=no-member is needed due to the results property + being dynamically created by the @Properties.add_results decorator. + """ + # pylint: disable=no-member + self.validate_commit_parameters() + + self.results.action = self.action + self.results.check_mode = self.rest_send.check_mode + self.results.state = self.rest_send.state + + self.delete_files() + + def delete_files(self): + """ + ### Summary + Delete files that have been added with add_files(). + + ### Raises + None + """ + # pylint: disable=no-member + if self.payload["deleteFiles"]: + self.rest_send.path = self.ep_bootflash_files.path + self.rest_send.verb = self.ep_bootflash_files.verb + self.rest_send.payload = self.payload + self.rest_send.commit() + self.results.response_current = copy.deepcopy( + self.rest_send.response_current + ) + self.results.result_current = copy.deepcopy(self.rest_send.result_current) + else: + self.results.result_current = {"success": True, "changed": False} + self.results.response_current = { + "MESSAGE": "No files to delete.", + "RETURN_CODE": 200, + } + + self.results.diff_current = copy.deepcopy(self.diff) + self.results.register_task_result() + + def validate_prerequisites_for_add_file(self): + """ + ### Summary + Verify that mandatory prerequisites are met before calling add_file() + + ### Raises + - ``ValueError`` if: + - ``filename`` is not set. + - ``filepath`` is not set. + - ``ip_address`` is not set. + - ``supervisor`` is not set. + - ``switch_details`` is not set. + - ``target`` is not set. + """ + method_name = inspect.stack()[0][3] + + def raise_exception(property_name): + msg = f"{self.class_name}.{method_name}: " + msg += f"{property_name} must be set before calling add_file()." + raise ValueError(f"{msg}") + + if not self.filename: + raise_exception("filename") + if not self.filepath: + raise_exception("filepath") + if not self.ip_address: + raise_exception("ip_address") + if not self.supervisor: + raise_exception("supervisor") + if not self.switch_details: + raise_exception("switch_details") + if not self.target: + raise_exception("target") + + def partition_and_serial_number_exist_in_payload(self): + """ + ### Summary + - Return True if the partition and serialNumber associated with the + file exist in the payload. + - Return False otherwise. + + ### Raises + None + + ### payload Structure + + "deleteFiles": [ + { + "files": [ + { + "bootflashType": "active", + "fileName": "bar.txt", + "filePath": "bootflash:" + } + ], + "partition": "bootflash:", + "serialNumber": "FOX2109PGCS" + }, + { + "files": [ + { + "bootflashType": "active", + "fileName": "black.txt", + "filePath": "bootflash:" + } + ], + "partition": "bootflash:", + "serialNumber": "FOX2109PGD0" + } + ] + """ + found = False + for item in self.payload["deleteFiles"]: + serial_number = item.get("serialNumber") + partition = item.get("partition") + if serial_number != self.ip_address_to_serial_number(self.ip_address): + continue + if partition != self.partition: + continue + found = True + break + return found + + def add_file_to_existing_payload(self): + """ + ### Summary + Add a file to the payload if the following are true: + - The serialNumber and partition associated with the file exist in + the payload. + - The file does not already exist in the files list for that + serialNumber and partition. + + ### Raises + None + + ### Details + We are looking at the following structure. + + ```json + { + "deleteFiles": [ + { + "files": [ + { + "bootflashType": "active", + "fileName": "air.txt", + "filePath": "bootflash:" + }, + { + "bootflashType": "active", + "fileName": "earth.txt", + "filePath": "bootflash:" + }, + ], + "partition": "bootflash:", + "serialNumber": "FOX2109PGCS" + }, + ] + } + """ + for item in self.payload["deleteFiles"]: + serial_number = item.get("serialNumber") + partition = item.get("partition") + if serial_number != self.ip_address_to_serial_number(self.ip_address): + continue + if partition != self.partition: + continue + files = item.get("files") + for file in files: + if ( + file.get("fileName") == self.filename + and file.get("bootflashType") == self.supervisor + ): + return + files.append( + { + "bootflashType": self.supervisor, + "fileName": self.filename, + "filePath": self.filepath, + } + ) + item.update({"files": files}) + + def add_file_to_payload(self): + """ + ### Summary + Add a file to the payload if the serialNumber and partition do not + yet exist in the payload. + + ### Raises + None + """ + if not self.partition_and_serial_number_exist_in_payload(): + add_payload = { + "serialNumber": self.ip_address_to_serial_number(self.ip_address), + "partition": self.partition, + "files": [ + { + "bootflashType": self.supervisor, + "fileName": self.filename, + "filePath": self.filepath, + } + ], + } + self.payload["deleteFiles"].append(add_payload) + else: + self.add_file_to_existing_payload() + + def add_file(self): + """ + ### Summary + Add a file to the payload. + + ### Raises + - ``ValueError`` if: + - The switch does not allow file deletion. + """ + method_name = inspect.stack()[0][3] + self.validate_prerequisites_for_add_file() + + if not self.ok_to_delete_files(self.ip_address): + msg = f"{self.class_name}.{method_name}: " + msg += f"Cannot delete files on switch {self.ip_address}. " + msg += f"Reason: {self.ok_to_delete_files_reason}." + raise ValueError(msg) + + self.add_file_to_payload() + self.update_diff() + + def update_diff(self): + """ + ### Summary + Update ``diff`` with ``target``. + + ### Raises + None + + ### Notes + - ``target`` has already been validated to be set (not None) in + ``validate_prerequisites_for_add_file()``. + - ``target`` has already been validated to be a dictionary and to + contain ``ip_address`` in ``target.setter``. + """ + ip_address = self.target.get("ip_address") + if ip_address not in self.diff: + self.diff[ip_address] = [] + self.diff[ip_address].append(self.target) + + @property + def filepath(self): + """ + ### Summary + Return the current ``filepath``. + + ``filepath`` is the path to the file to be deleted. + + ### Raises + None + + ### Associated key + ``filePath`` + + ### Example values + - ``bootflash:`` + - ``bootflash:/mydir/mysubdir/`` + """ + return self._filepath + + @filepath.setter + def filepath(self, value): + self._filepath = value + + @property + def filename(self): + """ + ### Summary + Return the current ``filename``. + + ``filename`` is the name of the file to be deleted. + + ### Raises + None + + ### Associated key + ``fileName`` + + ### Example value + ``n9000-epld.10.2.5.M.img`` + """ + return self._filename + + @filename.setter + def filename(self, value): + self._filename = value + + @property + def ip_address(self): + """ + ### Summary + The ip address of the switch on which ``filename`` resides. + + ### Raises + None + + ### Associated key + ``serialNumber`` (ip_address is converted to serialNumber) + + ### Example value + ``192.168.1.2`` + """ + return self._ip_address + + @ip_address.setter + def ip_address(self, value): + self._ip_address = value + + @property + def partition(self): + """ + ### Summary + The partition on which ``filename`` resides. + + ### Raises + None + + ### Associated key + ``partition`` + + ### Example value + ``bootflash:`` + """ + return self._partition + + @partition.setter + def partition(self, value): + self._partition = value + + @property + def supervisor(self): + """ + ### Summary + Return the current ``supervisor``. + + ``supervisor`` is the switch supervisor card (active or standby) + on which ``filename`` resides. + + ### Raises + None + + ### Associated key + ``bootflashType`` + + ### Example values + - ``active`` + - ``standby`` + """ + return self._supervisor + + @supervisor.setter + def supervisor(self, value): + self._supervisor = value + + @property + def switch_details(self): + """ + ### Summary + An instance of the ``SwitchDetails()`` class. + + ### Raises + - ``TypeError`` if ``switch_details`` is not an instance of + ``SwitchDetails``. + """ + return self._switch_details + + @switch_details.setter + def switch_details(self, value): + method_name = inspect.stack()[0][3] + _class_have = None + _class_need = "SwitchDetails" + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be an instance of {_class_need}. " + msg += f"Got value {value} of type {type(value).__name__}." + try: + _class_have = value.class_name + except AttributeError as error: + msg += f" Error detail: {error}." + raise TypeError(msg) from error + if _class_have != _class_need: + raise TypeError(msg) + self._switch_details = value + + @property + def target(self): + """ + ### Summary + ``target`` is a dictionary that is used to set the diff passed to + Results. + + ``target`` is appended to a list of targets in + ``BootflashFiles().add_file()``, so must be passed for each file + to be deleted. See Usage example in the class docstring. + + ### ``target`` Structure + ```json + { + "date": "2023-09-19 22:20:07", + "device_name": "cvd-1212-spine", + "filepath": "bootflash:/n9000-epld.10.2.5.M.img", + "ip_address": "192.168.1.1", + "serial_number": "BDY3814QDD0", + "size": "218233885", + "supervisor": "active" + } + ``` + + ### Raises + - ``TypeError`` if: + - ``target`` is not a dictionary. + - ``ValueError`` if: + - ``target`` is missing a mandatory key. + + ### Associated key + None + + ### Notes + 1. Since (at least with the dcnm_bootflash module) the + user references switches using ip_address, and the NDFC + bootflash-files payload includes only serialNumber, we + decided to use ``target`` as the diff since it contains the + ip_address and serial_number (as well as the size, date + etc, which are potentially more useful than the info in + the payload. + 2. ``BootflashFiles()`` requires that the ``ip_address`` key + be present in target, since it uses ``ip_address`` as the key + for the diff. Of the other fields, we also require that filepath, + serial_number and supervisor are present since they add value + to the diff. The other fields shown above SHOULD be included + but their absence will not raise an error. + """ + return self._target + + @target.setter + def target(self, value): + method_name = inspect.stack()[0][3] + + msg = f"{self.class_name}.{method_name}: " + + if not isinstance(value, dict): + msg += "target must be a dictionary. " + msg += f"Got type {type(value).__name__} for value {value}." + raise TypeError(msg) + for key in self.mandatory_target_keys: + if value.get(key) is None: + msg += f"{key} key missing from value {value}." + raise ValueError(msg) + self._target = value diff --git a/plugins/module_utils/bootflash/bootflash_info.py b/plugins/module_utils/bootflash/bootflash_info.py new file mode 100644 index 000000000..71cc58483 --- /dev/null +++ b/plugins/module_utils/bootflash/bootflash_info.py @@ -0,0 +1,620 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import logging +from pathlib import PurePosixPath + +from ansible_collections.cisco.dcnm.plugins.module_utils.bootflash.convert_file_info_to_target import \ + ConvertFileInfoToTarget +from ansible_collections.cisco.dcnm.plugins.module_utils.common.api.v1.imagemanagement.rest.discovery.discovery import \ + EpBootflashDiscovery +from ansible_collections.cisco.dcnm.plugins.module_utils.common.api.v1.imagemanagement.rest.imagemgnt.bootflash.bootflash import \ + EpBootflashInfo +from ansible_collections.cisco.dcnm.plugins.module_utils.common.conversion import \ + ConversionUtils +from ansible_collections.cisco.dcnm.plugins.module_utils.common.properties import \ + Properties +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results + + +@Properties.add_rest_send +@Properties.add_results +class BootflashInfo: + """ + ### Summary + Retrieve and filter bootflash contents. + + ### Raises + - ``ValueError`` if: + - params is not set. + - switches is not set. + - ``TypeError`` if: + - switches is not a list. + - switches contains anything other than strings. + + ### Usage + We start with list of targets, where target is a dictionary containing + a filepath and a supervisor key: + + ```python + targets = [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + }, + { + "filepath": "bootflash:/abc.txt", + "supervisor": "standby" + } + ] + ``` + + 1. Create an instance of BootflashInfo() and set the switches + property to a list of switch ip addresses. + 2. Set instance.switch_details to the SwitchDetails() class and + pass it a separate instance of Results() since we don't want + to save the results of the switch details query. + 3. Define a list of switch IP addresses and pass this to the + ``instance.switches`` property. + 4. Call ``instance.refresh()`` to retrieve switch details for each of the + switches in the switches. This is used to convert switch ip address + to serial_number, which is required by the bootflash-info endpoint, + defined in ``EpBootflashInfo()``. + 5. We then call ``instance.refresh_bootflash_info()`` to retrieve + bootflash contents for each switch in the switches list. + 6. We can then filter the results by switch (``filter_switch``), + supervisor (``filter_supervisor``), and filepath (``filter_filepath``). + 7. ``filter_filepath`` supports file globbing. Below, we are filtering + for any file on any partition with a three-letter name and a .txt + extension. e.g. bootflash:/abc.txt. + 8. We call ``instance.build_matches()`` to build a list of files matching + the filters. + 9. We call ``instance.results.register_task_result()`` to register the + results, which creates instance.results.diff, a list of dictionaries + keyed on the switch ip address. Each dictionary contains a list of + matches for that switch. The matches are dictionaries containing the + bootflash information for the file. + + ```python + sender = Sender() + sender.ansible_module = ansible_module + rest_send = RestSend(ansible_module.params) + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + instance = BootflashInfo() + instance.results = Results() + + # BootflashInfo() uses SwitchDetails() to convert + # switch ip addresses to switch serial numbers since + # the NDFC API selects switches by serial number. + instance.switch_details = SwitchDetails() + + # We pass switch_details.results a separate instance of + # results because we are not interested in its results. + instance.switch_details.results = Results() + instance.switches = ["192.168.1.1", "192.168.1.2"] + instance.refresh() + + # Filters can be added indenpendently of each other. + # The more filters added, the more specific the results. + # ``filter_switch`` is limited to the switches in the + # ``instance.switches`` list, since this is the information + # that ``instance.refresh`` caches when ``instance.refresh`` + # is called. + + instance.filter_switch = "192.168.1.1" + instance.filter_supervisor = "active" + # filter_filepath supports file globbing. + # The below means "Any file on any partition with a three-letter + # name and a .txt extension." e.g. bootflash:/abc.txt + instance.filter_filepath = "*:/???.txt" + + instance.build_matches() + instance.results.register_task_result() + + # The results can be printed by accessing instance.results.diff. + # instance.results.diff is a list of dictionaries. Each dictionary + # is keyed on the switch ip address and contains a list of matches for + # that switch. The matches are dictionaries containing the bootflash + # information for the file. + + print(f"{json.dumps(instance.results.diff, sort_keys=True, indent=4)}") + + ``` + + ### instance.results.diff Structure + + ```json + "diff": [ + { + "172.22.150.112": [ + { + "date": "2024-08-06 16:14:59", + "device_name": "cvd-1211-spine", + "filepath": "bootflash:/bling.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "size": "2", + "supervisor": "active" + } + ], + "172.22.150.113": [ + { + "date": "2024-08-06 16:15:59", + "device_name": "cvd-1212-spine", + "filepath": "bootflash:/blong.txt", + "ip_address": "172.22.150.113", + "serial_number": "FOX2109PGD0", + "size": "2", + "supervisor": "active" + } + ], + "sequence_number": 1 + } + ] + ``` + """ + + def __init__(self): + self.class_name = self.__class__.__name__ + + self.action = "bootflash_info" + self.bootflash_data_map = {} + self.conversion = ConversionUtils() + self.convert_file_info_to_target = ConvertFileInfoToTarget() + self.ep_bootflash_discovery = EpBootflashDiscovery() + self.ep_bootflash_info = EpBootflashInfo() + self.info_dict = {} + self._matches = [] + + # Used to collect individual responses and results for each + # switch in self.switches. Keyed on switch ip_address. + # Updated in refresh_bootflash_info(). + self.diff_dict = {} + self.response_dict = {} + self.result_dict = {} + + self._rest_send = None + self._results = None + self._switch_details = None + self._switches = None + + self._filter_filepath = None + self._filter_supervisor = None + self._filter_switch = None + + self.valid_supervisor = ["active", "standby"] + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED BootflashQuery(): " + msg += f"action {self.action}, " + self.log.debug(msg) + + def validate_refresh_parameters(self) -> None: + """ + ### Summary + Verify that mandatory prerequisites are met before calling refresh. + + ### Raises + - ``ValueError`` if: + - rest_send is not set. + - results is not set. + - switch_details is not set. + - switches is not set. + """ + # pylint: disable=no-member + method_name = inspect.stack()[0][3] + + def raise_value_error_if_not_set(property_name): + msg = f"{self.class_name}.{method_name}: " + msg += f"{property_name} must be set prior to calling refresh." + raise ValueError(msg) + + if self.rest_send is None: + raise_value_error_if_not_set("rest_send") + if self.results is None: + raise_value_error_if_not_set("results") + if self.switch_details is None: + raise_value_error_if_not_set("switch_details") + if self.switches is None: + raise_value_error_if_not_set("switches") + + # pylint: disable=no-member + def refresh(self): + """ + ### Summary + Retrieve switch details for each of the switches in self.switches. + + This is used to convert switch ip address to serial_number, which is + required by EpBootflashInfo(). + + ### Raises + - ``ValueError`` if: + - switches is not set. + + ### Notes + - pylint: disable=no-member is needed due to the results property + being dynamically created by the @Properties.add_results decorator. + """ + # pylint: disable=no-member + self.validate_refresh_parameters() + + self.results.action = self.action + self.results.check_mode = self.rest_send.check_mode + self.results.state = self.rest_send.state + + self.switch_details.rest_send = self.rest_send + self.switch_details.results = Results() + self.switch_details.refresh() + + self.refresh_bootflash_info() + + def refresh_bootflash_info(self): + """ + ### Summary + Retrieve bootflash information for each switch in self.switches. + + ### Raises + None + """ + method_name = inspect.stack()[0][3] + self.info_dict = {} + self.response_dict = {} + self.result_dict = {} + for switch in self.switches: + self.switch_details.filter = switch + try: + serial_number = self.switch_details.serial_number + except ValueError as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"serial_number not found for switch {switch}. " + msg += f"Error detail {error}" + raise ValueError(msg) from error + + # rediscover bootflash contents for the switch + self.ep_bootflash_discovery.serial_number = serial_number + self.rest_send.path = self.ep_bootflash_discovery.path + self.rest_send.verb = self.ep_bootflash_discovery.verb + self.rest_send.commit() + + # retrieve bootflash information for the switch + self.ep_bootflash_info.serial_number = serial_number + self.rest_send.path = self.ep_bootflash_info.path + self.rest_send.verb = self.ep_bootflash_info.verb + self.rest_send.commit() + + self.info_dict[switch] = copy.deepcopy( + self.rest_send.response_current.get("DATA", {}) + ) + self.response_dict[switch] = copy.deepcopy(self.rest_send.response_current) + self.result_dict[switch] = copy.deepcopy(self.rest_send.result_current) + + def validate_prerequisites_for_build_matches(self): + """ + ### Summary + Verify that mandatory prerequisites are met before calling + ``build_matches()``. + + ### Raises + - ``ValueError`` if: + - ``refresh`` has not been called. + - ``filter_switch`` is not set. + - ``filter_file`` is not set. + """ + method_name = inspect.stack()[0][3] + + if not self.info: + msg = f"{self.class_name}.{method_name}: " + msg += "refresh must be called before retrieving bootflash " + msg += "properties." + raise ValueError(msg) + + def match_filter_filepath(self, target): + """ + ### Summary + - Return True if the target's ``filepath`` matches + ``filter_filepath``. + - Return False otherwise. + + ### Raises + None + """ + if not self.filter_filepath: + return False + posix = PurePosixPath(target.get("filepath")) + if not posix.match(self.filter_filepath): + return False + return True + + def match_filter_supervisor(self, target): + """ + ### Summary + - Return True if the target's ``bootflash_type`` matches + ``filter_supervisor``. + - Return False otherwise. + + ### Raises + None + """ + if not self.filter_supervisor: + return False + if target.get("supervisor", None) != self.filter_supervisor: + return False + return True + + def match_filter_switch(self, target): + """ + ### Summary + - Return True if the target's ``ip_address`` matches + ``filter_switch``. + - Return False otherwise. + + ### Raises + None + """ + if not self.filter_switch: + return False + if target.get("ip_address", None) != self.filter_switch: + return False + return True + + def build_matches(self) -> None: + """ + ### Summary + Build a list of matches from the info_dict. + + ### Raises + None + """ + method_name = inspect.stack()[0][3] + + self.validate_prerequisites_for_build_matches() + self._matches = [] + + if self.filter_switch not in self.info: + msg = f"{self.class_name}.{method_name}: " + msg += f"filter_switch {self.filter_switch} not found in info." + self.log.debug(msg) + return + + data = self.info.get(self.filter_switch, {}) + self.bootflash_data_map = data.get("bootFlashDataMap", {}) + + for partition in self.bootflash_data_map: + for file_info in self.bootflash_data_map[partition]: + self.convert_file_info_to_target.file_info = file_info + self.convert_file_info_to_target.commit() + target = self.convert_file_info_to_target.target + # no need to test match_filter_switch since we have + # already filtered on the switch above. + if not self.match_filter_filepath(target): + continue + if not self.match_filter_supervisor(target): + continue + self._matches.append(target) + + diff = {} + for match in self._matches: + # convert_file_info_to_target() ensures that match contains + # ip_address. + ip_address = match.get("ip_address", None) + if ip_address not in diff: + diff[ip_address] = [] + diff[ip_address].append(match) + self.diff_dict = diff + + @property + def filter_filepath(self): + """ + ### Summary + Return the current ``filter_filepath``. + + ``filter_filepath`` is a file path used to filter the results + of the query. This can include file globbing. + + ### Raises + None + + ### Examples + + - All txt files in the bootflash directory + - instance.filter_filepath = "bootflash:/*.txt" + - All txt files on all flash devices + - instance.filter_filepath = "*:/*.txt" + """ + return self._filter_filepath + + @filter_filepath.setter + def filter_filepath(self, value): + msg = "ENTERED BootflashQuery.filter_filepath.setter: " + msg += f"value {value}" + self.log.debug(msg) + self._filter_filepath = value + + @property + def filter_supervisor(self): + """ + ### Summary + Return the current ``filter_supervisor``. + + ``filter_supervisor`` is either "active" or "standby" and represents + the state of the supervisor which hosts ``filepath``. + + ### Raises + - ``ValueError`` if: + - value is not one of the valid_supervisor values + "active" or "standby". + + ### Example + instance.filter_supervisor = "active" + """ + return self._filter_supervisor + + @filter_supervisor.setter + def filter_supervisor(self, value): + if value not in self.valid_supervisor: + msg = f"{self.class_name}.filter_supervisor.setter: " + msg += f"value {value} is not a valid value for supervisor. " + msg += f"Valid values: {','.join(self.valid_supervisor)}." + raise ValueError(msg) + self._filter_supervisor = value + + @property + def filter_switch(self): + """ + ### Summary + Return the current ``filter_switch``. + + ``filter_switch`` is a switch ipv4 address used to filter the results + of the query. + + ### Raises + None + """ + return self._filter_switch + + @filter_switch.setter + def filter_switch(self, value): + self._filter_switch = value + + @property + def info(self): + """ + ### Summary + Return the info_dict instance + """ + return self.info_dict + + @property + def matches(self): + """ + ### Summary + Return a list of file_info dicts that match the query filters. + + ### Raises + None + + ### Associated key + None + + ### Example value + The leading space with ipAddr's value in pre-3.2.1e Nexus Dashboard responses + is stripped in build_matches() so you won't have to worry about it. + + ```python + matches = [ + { + "bootflash_type": "active", + "date": "Sep 19 22:20:07 2023", + "deviceName": "cvd-1212-spine", + "fileName": "n9000-epld.10.2.5.M.img", + "filePath": "bootflash:", + "ipAddr": "172.22.150.113", + "name": "bootflash:", + "serialNumber": "BDY3814QDD0", + "size": "218233885" + } + ] + ``` + """ + self.build_matches() + return self._matches + + # @matches.setter + # def matches(self, value): + # self._matches = value + + @property + def switch_details(self): + """ + ### Summary + Return the switch_details instance + + ### Raises + - ``TypeError`` if ``switch_details`` is not an instance of + ``SwitchDetails()``. + """ + return self._switch_details + + @switch_details.setter + def switch_details(self, value): + method_name = inspect.stack()[0][3] + _class_have = None + _class_need = "SwitchDetails" + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be an instance of {_class_need}. " + msg += f"Got value {value} of type {type(value).__name__}." + try: + _class_have = value.class_name + except AttributeError as error: + msg += f" Error detail: {error}." + raise TypeError(msg) from error + if _class_have != _class_need: + raise TypeError(msg) + self._switch_details = value + + @property + def switches(self): + """ + ### Summary + A list of switch ip addresses. + + ### Raises + - ``TypeError`` if: + - switches is not a list. + - switches contains anything other than strings. + - ``ValueError`` if: + - switches list is empty. + + ### Example + + ```python + + instance = BootflashInfo() + instance.switches = ["192.168.1.1", "192.168.1.2"] + switches = instance.switches + ``` + """ + return self._switches + + @switches.setter + def switches(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, list): + msg = f"{self.class_name}.{method_name}: " + msg += "switches must be a list. " + msg += f"got {type(value).__name__} for " + msg += f"value {value}." + raise TypeError(msg) + if len(value) == 0: + msg = f"{self.class_name}.{method_name}: " + msg += "switches must be a list with at least one ip address. " + msg += f"got {value}." + raise ValueError(msg) + for item in value: + if not isinstance(item, str): + msg = f"{self.class_name}.{method_name}: " + msg += "switches must be a list of ip addresses. " + msg += f"got type {type(item).__name__} for " + msg += f"value {item}." + raise TypeError(msg) + self._switches = value diff --git a/plugins/module_utils/bootflash/convert_file_info_to_target.py b/plugins/module_utils/bootflash/convert_file_info_to_target.py new file mode 100644 index 000000000..0eb9d7208 --- /dev/null +++ b/plugins/module_utils/bootflash/convert_file_info_to_target.py @@ -0,0 +1,426 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import inspect +import logging +from datetime import datetime +from pathlib import PurePosixPath + + +class ConvertFileInfoToTarget: + """ + ### Summary + Build a ``target`` dictionary from a ``file_info`` dictionary. + + ### Raises + + ### ``file_info`` Dictionary (from bootflash-info endpoint response) + ```json + { + "bootflash_type": "active", + "date": "Sep 19 22:20:07 2023", + "deviceName": "cvd-1212-spine", + "fileName": "n9000-epld.10.2.5.M.img", + "filePath": "bootflash:", + "ipAddr": " 192.168.1.1", + "name": "bootflash:", + "serialNumber": "BDY3814QDD0", + "size": "218233885" + } + ``` + + ### ``target`` Dictionary + ```json + { + "date": "2023-09-19 22:20:07", + "device_name": "cvd-1212-spine", + "filepath": "bootflash:/n9000-epld.10.2.5.M.img", + "ip_address": "192.168.1.1", + "serial_number": "BDY3814QDD0", + "size": "218233885", + "supervisor": "active" + } + ``` + + ### Usage + ```python + instance = ConvertFileInfoToTarget() + instance.file_info = { + "bootflash_type": "active", + "date": "Sep 19 22:20:07 2023", + "deviceName": "cvd-1212-spine", + "fileName": "n9000-epld.10.2.5.M.img", + "filePath": "bootflash:", + "ipAddr": " 192.168.1.1", + "name": "bootflash:", + "serialNumber": "BDY3814QDD0", + "size": "218233885" + } + instance.commit() + print(instance.target) + ``` + + ### Output + ```json + { + "date": "2023-09-19 22:20:07", + "device_name": "cvd-1212-spine", + "filepath": "bootflash:/n9000-epld.10.2.5.M.img", + "ip_address": "192.168.1.1", + "serial_number": "BDY3814QDD0", + "size": "218233885", + "supervisor": "active" + } + ``` + """ + + def __init__(self) -> None: + self.class_name = self.__class__.__name__ + self.action = "convert_file_info_to_target" + self.timestamp_format = "%b %d %H:%M:%S %Y" + + self._file_info = None + self._filename = None + self._filepath = None + self._ip_address = None + self._serial_number = None + self._supervisor = None + self._target = None + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED ConvertFileInfoToTarget(): " + self.log.debug(msg) + + def validate_commit_parameters(self) -> None: + """ + ### Summary + Validate that the parameters required to build the target dictionary + are present. + + ### Raises + - ``ValueError`` if: + - ``file_info`` is not set. + """ + method_name = inspect.stack()[0][3] + + def raise_error(msg): + raise ValueError(f"{self.class_name}.{method_name}: {msg}") + + if self.file_info is None: + msg = "file_info must be set before calling commit()." + raise_error(msg) + + def commit(self) -> None: + """ + ### Summary + Given ``file_info``, which is the information for a single file from + the bootflash-info endpoint response, build a ``target`` dictionary + containing: + + 1. A Posix path ``filepath`` from the ``file_info`` dictionary. + 2. Rename ``bootflash_type`` to ``supervisor`` in the target + dictionary. + 3. Convert the ``date`` value to a more easily digestable format + (YYYY-MM-DD HH:MM:SS). + 4. Rename ipAddr to ip_address and strip the leading space that + NDFC adds. + 5. Rename serialNumber to serial_number and add to the target + dictionary. + 6. Add size to the target dictionary. + + ### Raises + - ``ValueError`` if: + - ``file_info`` is not set. + - ``target`` cannot be built from ``file_info``. + + ### ``file_info`` (from bootflash-info endpoint response) + ```json + { + "bootflash_type": "active", + "date": "Sep 19 22:20:07 2023", + "deviceName": "cvd-1212-spine", + "fileName": "n9000-epld.10.2.5.M.img", + "filePath": "bootflash:", + "ipAddr": " 192.168.1.1", + "name": "bootflash:", + "serialNumber": "BDY3814QDD0", + "size": "218233885" + } + ``` + + ### ``target`` Structure + ```json + { + "date": "2023-09-19 22:20:07", + "device_name": "cvd-1212-spine", + "filepath": "bootflash:/n9000-epld.10.2.5.M.img", + "ip_address": "192.168.1.1", + "serial_number": "BDY3814QDD0", + "size": "218233885", + "supervisor": "active" + } + ``` + + """ + method_name = inspect.stack()[0][3] + self.validate_commit_parameters() + + def raise_error(msg): + raise ValueError(f"{self.class_name}.{method_name}: {msg}") + + try: + posixpath = PurePosixPath(self.name, self.filename) + except (TypeError, ValueError) as error: + msg = "Could not build PosixPath from name and filename. " + msg += f"name: {self.name}, filename: {self.filename}. " + msg += f"Error detail: {error}" + raise_error(msg) + + if ":/" not in str(posixpath): + msg = f"Invalid filepath {str(posixpath)} constructed from " + msg += f"name: {self.name}, filename: {self.filename}. " + msg += "Missing ':/' in the path." + raise_error(msg) + + try: + self.target = { + "date": str(self.date), + "device_name": self.device_name, + "filepath": str(PurePosixPath(posixpath)), + "ip_address": self.ip_address, + "serial_number": self.serial_number, + "size": self.size, + "supervisor": self.supervisor, + } + except (TypeError, ValueError) as error: + msg = "Could not build target from file_info. " + msg = f"{self.file_info}. " + msg += f"Error detail: {error}" + raise_error(msg) + + def _get(self, key): + """ + ### Summary + Get the value of a key from the ``file_info`` dictionary. + + ### Raises + - ``ValueError`` if: + - ``file_info`` has not been set before calling _get. + - ``key`` is not in the target dictionary. + """ + method_name = inspect.stack()[0][3] + + def raise_error(msg): + raise ValueError(f"{self.class_name}.{method_name}: {msg}") + + if self.file_info is None: + msg = "file_info must be set before calling ``_get()``." + raise_error(msg) + + if key not in self.file_info: + msg = f"Missing key {key} in file_info: {self.file_info}." + raise_error(msg) + + return self.file_info.get(key) + + @property + def file_info(self): + """ + ### Summary + A single file dictionary from the bootflash-info endpoint response. + + ### Raises + - ``ValueError`` if: + - ``file_info`` is not a dictionary. + - ``file_info`` does not contain the requisite keys. + + ### Expected Structure + This class uses the following keys from the file_info dictionary: + - fileName + - filePath + - bootflash_type + + ### Example + ```json + { + "bootflash_type": "active", + "date": "Sep 19 22:20:07 2023", + "deviceName": "cvd-1212-spine", + "fileName": "n9000-epld.10.2.5.M.img", + "filePath": "bootflash:", + "ipAddr": "192.168.1.1", + "name": "bootflash:", + "serialNumber": "BDY3814QDD0", + "size": "218233885" + } + ``` + """ + return self._file_info + + @file_info.setter + def file_info(self, value): + self._file_info = value + + @property + def date(self): + """ + ### Summary + The value of ``date`` from the ``file_info`` dictionary + converted to a ``datetime`` object. The string representation of + this object will be "YYYY-MM-DD HH:MM:SS". + + ### Raises + - ``ValueError`` if: + - ``file_info`` has not been set before accessing. + - ``date`` is not in the ``file_info`` dictionary. + - ``date`` cannot be converted to a datetime object. + """ + method_name = inspect.stack()[0][3] + try: + _date = datetime.strptime(self._get("date"), self.timestamp_format) + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Could not convert date to datetime object. " + msg += f"date: {self._get('date')}. " + msg += f"Error detail: {error}." + raise ValueError(msg) from error + return _date + + @property + def device_name(self): + """ + ### Summary + The value of ``deviceName`` from the ``file_info`` dictionary. + + ### Raises + ``ValueError`` if: + - ``file_info`` has not been set before accessing. + - ``deviceName`` is not in the ``file_info`` dictionary. + """ + return self._get("deviceName") + + @property + def filename(self): + """ + ### Summary + The value of ``fileName`` from the ``file_info`` dictionary. + + ### Raises + ``ValueError`` if: + - ``file_info`` has not been set before accessing. + - ``fileName`` is not in the ``file_info`` dictionary. + """ + return self._get("fileName") + + @property + def filepath(self): + """ + ### Summary + The value of ``filePath`` from the ``file_info`` dictionary. + + ### Raises + ``ValueError`` if: + - ``file_info`` has not been set before accessing. + - ``filePath`` is not in the ``file_info`` dictionary. + """ + return self._get("filePath") + + @property + def ip_address(self): + """ + ### Summary + The stripped value of ``ipAddr`` from the ``file_info`` dictionary. + + ### Raises + ``ValueError`` if: + - ``file_info`` has not been set before accessing. + - ``ipAddr`` is not in the ``file_info`` dictionary. + """ + return self._get("ipAddr").strip() + + @property + def name(self): + """ + ### Summary + The value of ``name`` from the ``file_info`` dictionary. + + ### Raises + ``ValueError`` if: + - ``file_info`` has not been set before accessing. + - ``name`` is not in the ``file_info`` dictionary. + """ + return self._get("name") + + @property + def serial_number(self): + """ + ### Summary + The value of ``serialNumber`` from the ``file_info`` dictionary. + + ### Raises + ``ValueError`` if: + - ``file_info`` has not been set before accessing. + - ``serialNumber`` is not in the ``file_info`` dictionary. + """ + return self._get("serialNumber") + + @property + def size(self): + """ + ### Summary + The value of ``size`` from the ``file_info`` dictionary. + + ### Raises + ``ValueError`` if: + - ``file_info`` has not been set before accessing. + - ``size`` is not in the ``file_info`` dictionary. + """ + return self._get("size") + + @property + def target(self): + """ + ### Summary + The target dictionary built from the ``file_info`` dictionary. + + ### Raises + ``ValueError`` if: + - ``commit()`` has not been called before accessing. + """ + if self._target is None: + msg = f"{self.class_name}.target: " + msg += "target has not been built. Call commit() before accessing." + raise ValueError(msg) + return self._target + + @target.setter + def target(self, value): + self._target = value + + @property + def supervisor(self): + """ + ### Summary + The value of ``bootflash_type`` from the ``file_info`` dictionary. + + ### Raises + ``ValueError`` if: + - ``file_info`` has not been set before accessing. + - ``bootflash_type`` is not in the ``file_info`` dictionary. + """ + return self._get("bootflash_type") diff --git a/plugins/module_utils/bootflash/convert_target_to_params.py b/plugins/module_utils/bootflash/convert_target_to_params.py new file mode 100644 index 000000000..fad8abeab --- /dev/null +++ b/plugins/module_utils/bootflash/convert_target_to_params.py @@ -0,0 +1,282 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import inspect +import logging + + +class ConvertTargetToParams: + """ + ### Summary + Parse ``target`` into its consituent API parameters. + + ### Raises + - ``ValueError`` if: + - ``filepath`` is not set in the target dict. + - ``supervisor`` is not set in the target dict. + + ### Usage + ```python + # Example 1, file in directory. + target = { + "filepath": "bootflash:/myDir/foo.txt", + "supervisor": "active" + } + instance = ConvertTargetToParams() + instance.target = target + instance.commit() + print(instance.partition) # bootflash: + print(instance.filepath) # bootflash:/myDir/ + print(instance.filename) # foo.txt + print(instance.supervisor) # active + + # Example 2, file in root of bootflash partition. + target = { + "filepath": "bootflash:/foo.txt", + "supervisor": "active" + } + instance.target = target + instance.commit() + print(instance.partition) # bootflash: + print(instance.filepath) # bootflash: + print(instance.filename) # foo.txt + print(instance.supervisor) # active + + ``` + """ + + def __init__(self) -> None: + self.class_name = self.__class__.__name__ + self.action = "convert_target_to_params" + self.committed = False + self.valid_supervisor = ["active", "standby"] + + self._filename = None + self._filepath = None + self._target = None + self._partition = None + self._supervisor = None + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED ConvertTargetToParams(): " + self.log.debug(msg) + + def commit(self): + """ + ### Summary + Commit the target to be parsed. + + ### Raises + - ``ValueError`` if: + - target is not set before calling commit. + """ + if self.target is None: + msg = f"{self.class_name}.commit: " + msg += "target must be set before calling commit." + raise ValueError(msg) + + self.parse_target() + self.committed = True + + def parse_target(self) -> None: + """ + ### Summary + Parse target into its consituent API parameters. + + ### Raises + - ``ValueError`` if: + - ``filepath`` is not set in the target dict. + - ``supervisor`` is not set in the target dict. + + ### Target Structure + { + filepath: bootflash:/myDir/foo.txt + supervisor: active + } + + Set the following API parameters from the above structure: + + - self.partition: bootflash: + - self.filepath: bootflash:/myDir/ + - self.filename: foo.txt + - self.supervisor: active + + ### Notes + - While this method is written to support files in directories, the + NDFC API does not support listing files within a directory. Hence, + we currently support only files in the root directory of the + partition. + - If the file is located in the root directory of the partition, + the filepath MUST NOT have a trailing slash. + i.e. filepath == "bootflash:/" will NOT match. It MUST be + "bootflash:". + - If the file is located in a directory, the filepath MUST + have a trailing slash. i.e. filepath == "bootflash:/myDir" + will NOT match since NDFC is not smart enough to add the + slash between the filepath and filename and, using the example + in Target Structure above, it will reconstruct the path as + bootflash:/myDirfoo.txt which, of course, will not match + (or worse yet, match and delete the wrong file). + """ + method_name = inspect.stack()[0][3] + + def raise_error(msg): + raise ValueError(f"{self.class_name}.{method_name}: {msg}") + + if self.target.get("filepath", None) is None: + msg = "Expected filepath in target dict. " + msg += f"Got {self.target}." + raise_error(msg) + if self.target.get("supervisor", None) is None: + msg = "Expected supervisor in target dict. " + msg += f"Got {self.target}." + raise_error(msg) + + parts = self.target.get("filepath").split("/") + self.partition = parts[0] + # If len(parts) == 2, the file is located in the root directory of the + # partition. In this case we DO NOT want to add a trailing slash to + # the filepath. i.e. filepath == "bootflash:/" will NOT match. + self.filepath = "/".join(parts[0:-1]) + # If there's one or more directory levels in the path we DO need to + # add a trailing slash to filepath. + if len(parts) > 2: + # Input: bootflash:/myDir/foo.txt + # parts: ['bootflash:', 'myDir', 'foo.txt'] + # Result: self.filepath == bootflash:/myDir/ + self.filepath = "/".join(parts[0:-1]) + "/" + self.filename = parts[-1] + self.supervisor = self.target.get("supervisor") + + @property + def filename(self): + """ + ### Summary + Return the filename parsed from ``target``. + + ### Raises + ``ValueError`` if: + - ``commit()`` has not been called before accessing this property. + """ + method_name = inspect.stack()[0][3] + if not self.committed: + msg = f"{self.class_name}.{method_name}: " + msg += f"commit() must be called before accessing {method_name}." + raise ValueError(msg) + return self._filename + + @filename.setter + def filename(self, value): + self._filename = value + + @property + def filepath(self): + """ + ### Summary + Return the filepath parsed from ``target``. + + ### Raises + ``ValueError`` if: + - ``commit()`` has not been called before accessing this property. + """ + method_name = inspect.stack()[0][3] + if not self.committed: + msg = f"{self.class_name}.{method_name}: " + msg += f"commit() must be called before accessing {method_name}." + raise ValueError(msg) + return self._filepath + + @filepath.setter + def filepath(self, value): + self._filepath = value + + @property + def target(self): + """ + ### Summary + The target to be parsed. This is a dictionary with the following + structure: + + ```json + { + "filepath": "bootflash:/myDir/foo.txt", + "supervisor": "active" + } + ``` + """ + return self._target + + @target.setter + def target(self, value): + self._target = value + + @property + def partition(self): + """ + ### Summary + Return the partition parsed from ``target``. + + ### Raises + ``ValueError`` if: + - ``commit()`` has not been called before accessing this property. + """ + method_name = inspect.stack()[0][3] + if not self.committed: + msg = f"{self.class_name}.{method_name}: " + msg += f"commit() must be called before accessing {method_name}." + raise ValueError(msg) + return self._partition + + @partition.setter + def partition(self, value): + method_name = inspect.stack()[0][3] + if not str(value).endswith(":"): + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid partition: {value}. " + msg += "Expected partition to end with a colon." + raise ValueError(msg) + self._partition = value + + @property + def supervisor(self): + """ + ### Summary + Return the supervisor parsed from ``target``. This is the state + (active or standby) of the supervisor that hosts the file described + in ``target``. + + ### Raises + ``ValueError`` if: + - ``commit()`` has not been called before accessing this property. + """ + method_name = inspect.stack()[0][3] + if not self.committed: + msg = f"{self.class_name}.{method_name}: " + msg += f"commit() must be called before accessing {method_name}." + raise ValueError(msg) + return self._supervisor + + @supervisor.setter + def supervisor(self, value): + method_name = inspect.stack()[0][3] + if value not in self.valid_supervisor: + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid supervisor: {value}. " + msg += f"Expected one of: {','.join(self.valid_supervisor)}." + raise ValueError(msg) + self._supervisor = value diff --git a/plugins/module_utils/common/api/v1/imagemanagement/rest/discovery/__init__.py b/plugins/module_utils/common/api/v1/imagemanagement/rest/discovery/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/common/api/v1/imagemanagement/rest/discovery/discovery.py b/plugins/module_utils/common/api/v1/imagemanagement/rest/discovery/discovery.py new file mode 100644 index 000000000..1df650009 --- /dev/null +++ b/plugins/module_utils/common/api/v1/imagemanagement/rest/discovery/discovery.py @@ -0,0 +1,125 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.api.v1.imagemanagement.rest.rest import \ + Rest + + +class Discovery(Rest): + """ + ## api.v1.imagemanagement.rest.discovery.Discovery() + + ### Description + Common methods and properties for Discovery() subclasses + + ### Path + ``/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/`` + """ + + def __init__(self): + super().__init__() + self.class_name = self.__class__.__name__ + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.discovery = f"{self.rest}/discovery/" + msg = "ENTERED api.v1.imagemanagement.rest.Discovery()" + self.log.debug(msg) + + +class EpBootflashDiscovery(Discovery): + """ + ## api.v1.imagemanagement.rest.discovery.EpBootflashDiscovery() + + ### Description + Return endpoint information for ``bootflash-discovery``. + + The ``bootflash-discovery`` endpoint initiates a rediscovery of the + latest bootflash contents for the switch specified with ``serial_number``. + + ### Raises + - ``ValueError`` if: + - ``serial_number`` is not set. + + ### Path + - ``../api/v1/imagemanagement/rest/discovery/bootflash-discovery`` + + ### Verb + - GET + + ### Parameters + - path: retrieve the path for the endpoint + - verb: retrieve the verb for the endpoint + - serial_number: set the endpoint query string + + ### Usage + ```python + instance = EpBootflashFiles() + instance.serial_number = "1234567890" + path = instance.path + verb = instance.verb + ``` + """ + + def __init__(self): + super().__init__() + self.class_name = self.__class__.__name__ + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.serial_number = None + msg = "ENTERED api.v1.imagemanagement.rest.discovery." + msg += "EpBootflashDiscovery()" + self.log.debug(msg) + + @property + def path(self): + """ + ### Summary + The endpoint path. + + ### Raises + - ``ValueError`` if: + - ``serial_number`` is not set. + """ + if self.serial_number is None: + msg = f"{self.class_name}.path: serial_number is required." + raise ValueError(msg) + return f"{self.discovery}/bootflash-discovery?serialNumber={self.serial_number}" + + @property + def serial_number(self): + """ + ### Summary + The serial number of the switch hosting the bootflash to be rediscovered. + + ### Raises + None + """ + return self._serial_number + + @serial_number.setter + def serial_number(self, value): + self._serial_number = value + + @property + def verb(self): + """ + ### Summary + The endpoint verb. + """ + return "GET" diff --git a/plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/bootflash/__init__.py b/plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/bootflash/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/bootflash/bootflash.py b/plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/bootflash/bootflash.py new file mode 100644 index 000000000..b517df887 --- /dev/null +++ b/plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/bootflash/bootflash.py @@ -0,0 +1,164 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.api.v1.imagemanagement.rest.imagemgnt.imagemgnt import \ + ImageMgnt + + +class Bootflash(ImageMgnt): + """ + ## api.v1.imagemanagement.rest.imagemgt.bootFlash + + ### Description + Common methods and properties for Bootflash() subclasses + + ### Path + ``/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash`` + """ + + def __init__(self): + super().__init__() + self.class_name = self.__class__.__name__ + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.bootflash = f"{self.image_mgmt}/bootFlash" + msg = "ENTERED api.v1.imagemanagement.rest.imagemgnt.Bootflash()" + self.log.debug(msg) + + +class EpBootflashFiles(Bootflash): + """ + ## api.v1.imagemanagement.rest.imagemgnt.bootflash.EpBootFlashFiles() + + ### Description + Return endpoint information for bootflash-files. + + ### Raises + - None + + ### Path + - ``../api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-files`` + + ### Verb + - DELETE + + ### Parameters + - path: retrieve the path for the endpoint + - verb: retrieve the verb for the endpoint + + ### Usage + ```python + instance = EpBootflashFiles() + path = instance.path + verb = instance.verb + ``` + """ + + def __init__(self): + super().__init__() + self.class_name = self.__class__.__name__ + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED api.v1.imagemanagement.rest.imagemgnt." + msg += "BootflashFiles.EpBootflashFiles()" + self.log.debug(msg) + + @property + def path(self): + return f"{self.bootflash}/bootflash-files" + + @property + def verb(self): + return "DELETE" + + +class EpBootflashInfo(Bootflash): + """ + ## api.v1.imagemanagement.rest.imagemgnt.bootflash.EpBootflashInfo() + + ### Description + Return endpoint information for bootflash-info. + + ### Raises + - ``ValueError`` if: + - ``serial_number`` is not set. + + ### Path + - ``../api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber={serial_number}`` + + ### Verb + - DELETE + + ### Parameters + - path: retrieve the path for the endpoint + - verb: retrieve the verb for the endpoint + + ### Usage + ```python + instance = EpBootflashInfo() + instance.serial_number = "1234567890" + path = instance.path + verb = instance.verb + ``` + """ + + def __init__(self): + super().__init__() + self.class_name = self.__class__.__name__ + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED api.v1.imagemanagement.rest.imagemgnt." + msg += "BootflashFiles.EpBootflashInfo()" + self.log.debug(msg) + + @property + def path(self): + """ + ### Summary + The endpoint path. + + ### Raises + - ``ValueError`` if: + - ``serial_number`` is not set. + """ + if self.serial_number is None: + raise ValueError("serial_number is required") + return f"{self.bootflash}/bootflash-info?serialNumber={self.serial_number}" + + @property + def serial_number(self): + """ + ### Summary + The serial number of the switch hosting the bootflash devices. + + ### Raises + None + """ + return self._serial_number + + @serial_number.setter + def serial_number(self, value): + self._serial_number = value + + @property + def verb(self): + """ + ### Summary + The endpoint verb. + """ + return "GET" diff --git a/plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/imagemgnt.py b/plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/imagemgnt.py index 2ced72e4b..87a70488f 100644 --- a/plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/imagemgnt.py +++ b/plugins/module_utils/common/api/v1/imagemanagement/rest/imagemgnt/imagemgnt.py @@ -40,46 +40,3 @@ def __init__(self): self.log = logging.getLogger(f"dcnm.{self.class_name}") self.image_mgmt = f"{self.rest}/imagemgnt" self.log.debug("ENTERED api.v1.imagemanagement.rest.imagemgnt.ImageMgnt()") - - -class EpBootFlashInfo(ImageMgnt): - """ - ## api.v1.imagemanagement.rest.imagemgnt.EpBootFlashInfo() - - ### Description - Return endpoint information for bootflash-info. - - ### Raises - - None - - ### Path - - ``/api/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info`` - - ### Verb - - GET - - ### Parameters - - path: retrieve the path for the endpoint - - verb: retrieve the verb for the endpoint - - ### Usage - ```python - instance = EpBootFlashInfo() - path = instance.path - verb = instance.verb - ``` - """ - - def __init__(self): - super().__init__() - self.class_name = self.__class__.__name__ - self.log = logging.getLogger(f"dcnm.{self.class_name}") - self.log.debug("ENTERED api.v1.ImageMgnt.EpBootFlash()") - - @property - def path(self): - return f"{self.image_mgmt}/bootFlash/bootflash-info" - - @property - def verb(self): - return "GET" diff --git a/plugins/modules/dcnm_bootflash.py b/plugins/modules/dcnm_bootflash.py new file mode 100644 index 000000000..317670a79 --- /dev/null +++ b/plugins/modules/dcnm_bootflash.py @@ -0,0 +1,744 @@ +#!/usr/bin/python +# +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# pylint: disable=wrong-import-position +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +DOCUMENTATION = """ +--- +module: dcnm_bootflash +short_description: Bootflash management for Nexus switches. +version_added: "3.6.0" +description: + - Delete, query bootflash files. +author: Allen Robel (@quantumonion) +options: + state: + description: + - The state of the feature or object after module completion + type: str + choices: + - deleted + - query + default: query + config: + description: + - Configuration parameters for the module. + type: dict + required: true + suboptions: + targets: + description: + - List of dictionaries containing options for files to be deleted or queried. + type: list + elements: dict + default: [] + required: false + suboptions: + filepath: + description: + - The path to the file to be deleted or queried. + type: str + required: true + supervisor: + description: + - Either active or standby. The supervisor containing the filepath. + type: str + required: false + choices: + - active + - standby + default: active + switches: + description: + - List of dictionaries containing switches on which query or delete operations are executed. + type: list + elements: dict + suboptions: + ip_address: + description: + - The ip address of a switch. + type: str + required: true + targets: + description: + - List of dictionaries containing options for files to be deleted or queried. + type: list + elements: dict + default: [] + required: false + suboptions: + filepath: + description: + - The path to the file to be deleted or queried. Only files in the root directory of the partition are currently supported. + type: str + required: true + supervisor: + description: + - Either active or standby. The supervisor containing the filepath. + type: str + required: false + choices: + - active + - standby + default: active + +""" + +EXAMPLES = """ +# This module supports the following states: +# +# deleted: +# Delete files from the bootflash of one or more switches. +# +# If an image is in use by a device, the module will fail. Use +# dcnm_image_upgrade module, state deleted, to detach image policies +# containing images to be deleted. +# +# query: +# +# Return information for one or more files. +# +# Delete two files from each of three switches. + +- name: Delete two files from each of two switches + cisco.dcnm.dcnm_bootflash: + state: deleted + config: + targets: + - filepath: bootflash:/foo.txt + supervisor: active + - filepath: bootflash:/bar.txt + supervisor: standby + switches: + - ip_address: 192.168.1.1 + - ip_address: 192.168.1.2 + - ip_address: 192.168.1.3 + +# Delete two files from switch 192.168.1.1 and switch 192.168.1.2: +# - foo.txt on the active supervisor's bootflash: device. +# - bar.txt on the standby supervisor's bootflash: device. +# Delete potentially multiple files from switch 192.168.1.3: +# - All txt files on the standby supervisor's bootflash: device +# that match the pattern 202401??.txt, e.g. 20240123.txt. +# Delete potentially multiple files from switch 192.168.1.4: +# - All txt files on all flash devices on active supervisor. + +- name: Delete files + cisco.dcnm.dcnm_bootflash: + state: deleted + config: + targets: + - filepath: bootflash:/foo.txt + supervisor: active + - filepath: bootflash:/bar.txt + supervisor: standby + switches: + - ip_address: 192.168.1.1 + - ip_address: 192.168.1.2 + - ip_address: 192.168.1.3 + targets: + - filepath: bootflash:/202401??.txt + supervisor: standby + - ip_address: 192.168.1.4 + targets: + - filepath: "*:/*.txt" + supervisor: active + register: result +- name: print result + ansible.builtin.debug: + var: result + +# Query the controller for information about one file on three switches. +# Since the default for supervisor is "active", the module will query the +# active supervisor's bootflash: device. + +- name: Query file on three switches + cisco.dcnm.dcnm_bootflash: + state: query + config: + targets: + - filepath: bootflash:/foo.txt + switches: + - ip_address: 192.168.1.1 + - ip_address: 192.168.1.2 + - ip_address: 192.168.1.3 + register: result +- name: print result + ansible.builtin.debug: + var: result + +""" + +import copy +import inspect +import logging + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dcnm.plugins.module_utils.bootflash.bootflash_files import \ + BootflashFiles +from ansible_collections.cisco.dcnm.plugins.module_utils.bootflash.bootflash_info import \ + BootflashInfo +from ansible_collections.cisco.dcnm.plugins.module_utils.bootflash.convert_target_to_params import \ + ConvertTargetToParams +from ansible_collections.cisco.dcnm.plugins.module_utils.common.log_v2 import \ + Log +from ansible_collections.cisco.dcnm.plugins.module_utils.common.properties import \ + Properties +from ansible_collections.cisco.dcnm.plugins.module_utils.common.response_handler import \ + ResponseHandler +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_v2 import \ + RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.common.sender_dcnm import \ + Sender +from ansible_collections.cisco.dcnm.plugins.module_utils.common.switch_details import \ + SwitchDetails + + +@Properties.add_rest_send +class Common: + """ + Common methods for all states + """ + + def __init__(self, params): + self.class_name = self.__class__.__name__ + method_name = inspect.stack()[0][3] + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + # Initialize the Results() object temporarily here + # in case we hit any errors below. We will reinitialize + # below after we are sure we have valid params. This is + # to avoid Results() being null in main() if we hit an + # error here. + self.results = Results() + self.results.state = "query" + self.results.check_mode = False + + self.params = params + + def raise_error(msg): + raise ValueError(f"{self.class_name}.{method_name}: {msg}") + + self.check_mode = self.params.get("check_mode", None) + if self.check_mode is None: + msg = "params is missing mandatory key: check_mode." + raise_error(msg) + + if self.check_mode not in [True, False]: + msg = "check_mode must be True or False. " + msg += f"Got {self.check_mode}." + raise_error(msg) + + self._valid_states = ["deleted", "query"] + + self.state = self.params.get("state", None) + if self.state is None: + msg = "params is missing mandatory key: state." + raise_error(msg) + if self.state not in self._valid_states: + msg = f"Invalid state: {self.state}. " + msg += f"Expected one of: {','.join(self._valid_states)}." + raise_error(msg) + + self.config = self.params.get("config", None) + if not isinstance(self.config, dict): + msg = "Expected dict for config. " + msg += f"Got {type(self.config).__name__}." + raise_error(msg) + + self.targets = self.config.get("targets", None) + if not isinstance(self.targets, list): + self.targets = [] + + if len(self.targets) > 0: + for item in self.targets: + if not isinstance(item, dict): + msg = "Expected list of dict for params.config.targets. " + msg += f"Got list element of type {type(item).__name__}." + raise_error(msg) + + self.switches = self.config.get("switches", None) + if not isinstance(self.switches, list): + msg = "Expected list of dict for params.config.switches. " + msg += f"Got {type(self.switches).__name__}." + raise_error(msg) + + for item in self.switches: + if not isinstance(item, dict): + msg = "Expected list of dict for params.config.switches. " + msg += f"Got list element of type {type(item).__name__}." + raise_error(msg) + + self._rest_send = None + + self.bootflash_info = BootflashInfo() + self.convert_target_to_params = ConvertTargetToParams() + self.results = Results() + self.results.state = self.state + self.results.check_mode = self.check_mode + self.want = [] + + msg = f"ENTERED Common().{method_name}: " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + def get_want(self) -> None: + """ + ### Summary + 1. Validate the playbook configs + 2. Convert the validated configs to the structure required by the + the Delete() and Query() classes. + 3. Update self.want with this list of payloads + + If a switch in the switches list does not have a targets key, add the + targets key with the value of the global targets list from the + playbook. Else, use the switch's targets info (i.e. the switch's + targets info overrides the global targets info). + + ### Raises + - ValueError if: + - ``ip_address`` is missing from a switch dict. + - ``filepath`` is missing from a target dict. + - TypeError if: + - The value of ``targets`` is not a list of dictionaries. + + ### ``want`` Structure + - A list of dictionaries. Each dictionary contains the following keys: + - ip_address: The ip address of the switch. + - targets: A list of dictionaries. Each dictionary contains the + following keys: + - filepath: The path to the file to be deleted or queried. + - supervisor: The supervisor containing the filepath. + + ### Example ``want`` Structure + ```json + [ + { + "ip_address": "192.168.1.1", + "targets": [ + { + "filepath": "bootflash:/foo.txt", + "supervisor": "active" + }, + { + "filepath": "bar", + "supervisor": "standby" + } + ] + } + ] + ``` + + """ + method_name = inspect.stack()[0][3] + msg = f"ENTERED {self.class_name}.{method_name}" + self.log.debug(msg) + + def raise_value_error(msg): + raise ValueError(f"{self.class_name}.{method_name}: {msg}") + + def raise_type_error(msg): + raise TypeError(f"{self.class_name}.{method_name}: {msg}") + + for switch in self.switches: + if switch.get("ip_address", None) is None: + msg = "Expected ip_address in switch dict. " + msg += f"Got {switch}." + raise_value_error(msg) + + if switch.get("targets", None) is None: + switch["targets"] = self.targets + if not isinstance(switch["targets"], list): + msg = "Expected list of dictionaries for switch['targets']. " + msg += f"Got {type(switch['targets']).__name__}." + raise_type_error(msg) + + for target in switch["targets"]: + if target.get("filepath", None) is None: + msg = "Expected filepath in target dict. " + msg += f"Got {target}." + raise_value_error(msg) + if target.get("supervisor", None) is None: + msg = "Expected supervisor in target dict. " + msg += f"Got {target}." + raise_value_error(msg) + self.want.append(copy.deepcopy(switch)) + + +class Deleted(Common): + """ + ### Summary + Handle deleted state + + ### Raises + - ValueError if: + - ``Common.__init__()`` raises TypeError or ValueError. + """ + + def __init__(self, params): + self.class_name = self.__class__.__name__ + method_name = inspect.stack()[0][3] + try: + super().__init__(params) + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error during super().__init__(). " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + + self.bootflash_files = BootflashFiles() + self.files_to_delete = {} + + msg = f"ENTERED {self.class_name}().{method_name}: " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + def populate_files_to_delete(self, switch) -> None: + """ + ### Summary + Populate the ``files_to_delete`` dictionary with files + the user intends to delete. + + ### Raises + - ``ValueError`` if: + - ``supervisor`` is not one of: + - active + - standby + + ### ``files_to_delete`` Structure + files_to_delete is a dictionary containing + - key: switch ip address. + - value: a list of dictionaries containing the files to delete. + + ### ``files_to_delete`` Example + ```json + { + "172.22.150.112": [ + { + "date": "2024-08-05 19:23:24", + "device_name": "cvd-1211-spine", + "filepath": "bootflash:/foo.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "size": "2", + "supervisor": "active" + } + ] + } + ``` + """ + method_name = inspect.stack()[0][3] + self.bootflash_info.filter_switch = switch["ip_address"] + if switch["ip_address"] not in self.files_to_delete: + self.files_to_delete[switch["ip_address"]] = [] + + for target in switch["targets"]: + self.bootflash_info.filter_filepath = target.get("filepath") + try: + self.bootflash_info.filter_supervisor = target.get("supervisor") + except ValueError as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error assigning BootflashInfo.filter_supervisor. " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + self.files_to_delete[switch["ip_address"]].extend( + self.bootflash_info.matches + ) + + def update_bootflash_files(self, ip_address, target) -> None: + """ + ### Summary + Call ``BootflashFiles().add_file()`` to add the file associated with + ``ip_address`` and ``target`` to the list of files to be deleted. + + ### Raises + - ``TypeError`` if: + - ``target`` is not a dictionary. + - ``ValueError`` if: + - ``BootflashFiles().add_file`` raises ``ValueError``. + """ + method_name = inspect.stack()[0][3] + + try: + self.convert_target_to_params.target = target + self.convert_target_to_params.commit() + except ValueError as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error converting target to params. " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + + try: + self.bootflash_files.filename = self.convert_target_to_params.filename + self.bootflash_files.filepath = self.convert_target_to_params.filepath + self.bootflash_files.ip_address = ip_address + self.bootflash_files.partition = self.convert_target_to_params.partition + self.bootflash_files.supervisor = self.convert_target_to_params.supervisor + # we want to use the target as the diff, rather than the + # payload, because it contains better information than + # the payload. See BootflashFiles() class docstring and + # BootflashFiles().target property docstring. + self.bootflash_files.target = target + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error assigning BootflashFiles properties. " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + + try: + self.bootflash_files.add_file() + except ValueError as error: + msg = f"{self.class_name}.{inspect.stack()[0][3]}: " + msg += "Error adding file to bootflash_files. " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + + def commit(self) -> None: + """ + ### Summary + Delete the specified files if they exist. + + ### Raises + None. While this method does not directly raise exceptions, it + calls other methods that may raise the following exceptions: + + - ControllerResponseError + - TypeError + - ValueError + """ + # Populate self.switches + self.get_want() + + # Prepare BootflashInfo() + self.bootflash_info.results = Results() + self.bootflash_info.rest_send = self.rest_send # pylint: disable=no-member + self.bootflash_info.switch_details = SwitchDetails() + + # Retrieve bootflash contents for the user's switches. + switch_list = [] + for switch in self.switches: + switch_list.append(switch["ip_address"]) + self.bootflash_info.switches = switch_list + self.bootflash_info.refresh() + + # Prepare BootflashFiles() + self.results.state = self.state + self.results.check_mode = self.check_mode + self.bootflash_files.results = self.results + self.bootflash_files.rest_send = self.rest_send + self.bootflash_files.switch_details = SwitchDetails() + self.bootflash_files.switch_details.results = Results() + + # Update BootflashFiles() with the files to delete + self.files_to_delete = {} + for switch in self.switches: + self.populate_files_to_delete(switch) + for ip_address, targets in self.files_to_delete.items(): + for target in targets: + self.update_bootflash_files(ip_address, target) + + # Delete the files + self.bootflash_files.commit() + + +class Query(Common): + """ + ### Summary + Handle query state. + + ### Raises + - ValueError if: + - ``Common.__init__()`` raises TypeError or ValueError. + """ + + def __init__(self, params): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.action = "bootflash_info" + method_name = inspect.stack()[0][3] + + try: + super().__init__(params) + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error during super().__init__(). " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + + msg = f"ENTERED {self.class_name}().{method_name}: " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + def register_null_result(self) -> None: + """ + ### Summary + Register a null result when there are no switches to query. + + ### Raises + None + """ + response_dict = {} + response_dict["0.0.0.0"] = {} + response_dict["0.0.0.0"]["DATA"] = "No switches to query." + response_dict["0.0.0.0"]["MESSAGE"] = "OK" + response_dict["0.0.0.0"]["RETURN_CODE"] = 200 + result_dict = {} + result_dict["0.0.0.0"] = {} + result_dict["0.0.0.0"]["found"] = False + result_dict["0.0.0.0"]["success"] = True + self.results.response_current = response_dict + self.results.result_current = result_dict + self.results.action = self.action + self.results.register_task_result() + + def commit(self) -> None: + """ + ### Summary + query the bootflash on all switches in self.switches + and register the results. + + ### Raises + None. While this method does not directly raise exceptions, it + calls other methods that may raise the following exceptions: + + - ControllerResponseError + - TypeError + - ValueError + + """ + method_name = inspect.stack()[0][3] + msg = f"ENTERED {self.class_name}.{method_name}: " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + self.results.state = self.state + self.results.check_mode = self.check_mode + + # Populate and validate self.switches + self.get_want() + + if len(self.switches) == 0: + msg = f"{self.class_name}.{method_name}: " + msg += "No switches to query." + self.log.debug(msg) + self.register_null_result() + return + + # Prepare BootflashInfo() + self.bootflash_info.results = self.results + self.bootflash_info.rest_send = self.rest_send + self.bootflash_info.switch_details = SwitchDetails() + + # Retrieve bootflash contents for the user's switches. + switches_to_query = [] + for switch in self.switches: + switches_to_query.append(switch["ip_address"]) + self.bootflash_info.switches = switches_to_query + self.bootflash_info.refresh() + + # Update results (result and response) + self.results.response_current = self.bootflash_info.response_dict + self.results.result_current = self.bootflash_info.result_dict + + # Update results (diff) + # Use the file info from the controller as the diff. + diff_current = {} + for switch in self.switches: + ip_address = switch.get("ip_address") + self.bootflash_info.filter_switch = ip_address + if ip_address not in diff_current: + diff_current[ip_address] = [] + + for target in switch["targets"]: + self.bootflash_info.filter_filepath = target.get("filepath") + self.bootflash_info.filter_supervisor = target.get("supervisor") + diff_current[ip_address].extend(self.bootflash_info.matches) + + self.results.diff_current = diff_current + self.results.register_task_result() + + +def main(): + """ + main entry point for module execution + """ + + argument_spec = { + "config": { + "required": True, + "type": "dict", + }, + "state": { + "default": "query", + "choices": ["deleted", "query"], + }, + } + ansible_module = AnsibleModule( + argument_spec=argument_spec, supports_check_mode=True + ) + + params = copy.deepcopy(ansible_module.params) + params["check_mode"] = ansible_module.check_mode + + # Logging setup + try: + log = Log() + log.commit() + except (TypeError, ValueError) as error: + ansible_module.fail_json(str(error)) + + sender = Sender() + sender.ansible_module = ansible_module + rest_send = RestSend(params) + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + # pylint: disable=attribute-defined-outside-init + try: + task = None + if params["state"] == "deleted": + task = Deleted(params) + if params["state"] == "query": + task = Query(params) + if task is None: + ansible_module.fail_json(f"Invalid state: {params['state']}") + task.rest_send = rest_send + task.commit() + except (TypeError, ValueError) as error: + ansible_module.fail_json(f"{error}", **task.results.failed_result) + + task.results.build_final_result() + + if True in task.results.failed: # pylint: disable=unsupported-membership-test + msg = "Module failed." + ansible_module.fail_json(msg, **task.results.final_result) + ansible_module.exit_json(**task.results.final_result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/dcnm_maintenance_mode.py b/plugins/modules/dcnm_maintenance_mode.py index 45176ce2e..7fe09ef5b 100644 --- a/plugins/modules/dcnm_maintenance_mode.py +++ b/plugins/modules/dcnm_maintenance_mode.py @@ -22,7 +22,7 @@ --- module: dcnm_maintenance_mode short_description: Manage Maintenance Mode Configuration of NX-OS Switches. -version_added: "3.5.0" +version_added: "3.6.0" author: Allen Robel (@quantumonion) description: - Enable Maintenance or Normal Mode. diff --git a/tests/integration/targets/dcnm_bootflash/defaults/main.yaml b/tests/integration/targets/dcnm_bootflash/defaults/main.yaml new file mode 100644 index 000000000..e7a64dc8b --- /dev/null +++ b/tests/integration/targets/dcnm_bootflash/defaults/main.yaml @@ -0,0 +1,11 @@ +--- +testcase: "*" +switch1_file1: air.ndfc_ut +switch1_file2: earth.ndfc_ut +switch1_file3: fire.ndfc_ut +switch1_file4: water.ndfc_ut +switch2_file1: black.ndfc_ut +switch2_file2: blue.ndfc_ut +switch2_file3: green.ndfc_ut +switch2_file4: red.ndfc_ut +wildcard_filepath: "*:/*.ndfc_ut" diff --git a/tests/integration/targets/dcnm_bootflash/meta/main.yaml b/tests/integration/targets/dcnm_bootflash/meta/main.yaml new file mode 100644 index 000000000..32cf5dda7 --- /dev/null +++ b/tests/integration/targets/dcnm_bootflash/meta/main.yaml @@ -0,0 +1 @@ +dependencies: [] diff --git a/tests/integration/targets/dcnm_bootflash/tasks/dcnm.yaml b/tests/integration/targets/dcnm_bootflash/tasks/dcnm.yaml new file mode 100644 index 000000000..e419fc865 --- /dev/null +++ b/tests/integration/targets/dcnm_bootflash/tasks/dcnm.yaml @@ -0,0 +1,20 @@ +--- +- name: collect dcnm test cases + find: + paths: "{{ role_path }}/tests" + patterns: "{{ testcase }}.yaml" + connection: local + register: dcnm_cases + +- set_fact: + test_cases: + files: "{{ dcnm_cases.files }}" + +- name: set test_items + set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}" + +- name: run test cases (connection=httpapi) + include_tasks: "{{ test_case_to_run }}" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/dcnm_bootflash/tasks/main.yaml b/tests/integration/targets/dcnm_bootflash/tasks/main.yaml new file mode 100644 index 000000000..fbcfa5803 --- /dev/null +++ b/tests/integration/targets/dcnm_bootflash/tasks/main.yaml @@ -0,0 +1,2 @@ +--- +- { include_tasks: dcnm.yaml, tags: ['dcnm'] } diff --git a/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_deleted_specific.yaml b/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_deleted_specific.yaml new file mode 100644 index 000000000..5d72db667 --- /dev/null +++ b/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_deleted_specific.yaml @@ -0,0 +1,330 @@ +################################################################################ +# RUNTIME +################################################################################ +# +# Recent run times (MM:SS.ms): +# 01:24.59 +# +################################################################################ +# Description +################################################################################ +# +# Test deletion of files on bootflash using explicit file paths. +# +################################################################################# +# SETUP +################################################################################ +# +# 1. Two switches are required for this test; switch1 and switch2. +# 2. Files must be created on bootflash manually as NDFC does not +# provide a quick way to create files on bootflash. To create +# the files manually, we have two choices. +# A. Do the following directly on the switches: +# - On switch1, create a file named foo.txt +# switch# echo 1 > air.ndfc_ut +# switch# echo 1 > earth.ndfc_ut +# switch# echo 1 > fire.ndfc_ut +# switch# echo 1 > water.ndfc_ut +# - On switch2, create a file named bar.txt +# switch# echo 1 > black.ndfc_ut +# switch# echo 1 > blue.ndfc_ut +# switch# echo 1 > green.ndfc_ut +# switch# echo 1 > red.ndfc_ut +# B. Or, use the following playbook: +# ./playbooks/dcnm_bootflash/create_files.yaml +# This uses the cisco.nxos.nxos_command module to create the files. +# See the example inventory file in playbooks/roles/dcnm_bootflash/dcnm_hosts.yaml +# which includes inventory entries for switch1 and switch2 and change to +# match your setup. +# TEST +# 3. Ensure dcnm_tests.yaml has uncommented the following: +# testcase: dcnm_bootflash_deleted +# 4. Ensure all other testcase fields are commented. +# 5. Run the role with the following command: +# ansible-playbook dcnm_tests.yaml -i dcnm_hosts.yaml +# +# CLEANUP +# 6. No cleanup required +# +################################################################################ +# REQUIREMENTS +################################################################################ +# +# Example vars for dcnm_bootflash integration tests. +# Add to cisco/dcnm/playbooks/dcnm_tests.yaml +# switchX_fileY vars are included in the role's defaults/main.yaml +# so do not need to be added here unless you want to override them. +# wildcard_filepath is also included in the role's defaults/main.yaml +# so does not need to be added here unless you want to override it. +# +# vars: +# testcase: dcnm_bootflash_query +# username: admin +# password: "foobar" +# switch_username: admin +# switch_password: "foobar" +# switch1_file1: air.ndfc_ut +# switch1_file2: earth.ndfc_ut +# switch1_file3: fire.ndfc_ut +# switch1_file4: water.ndfc_ut +# switch2_file1: black.ndfc_ut +# switch2_file2: blue.ndfc_ut +# switch2_file3: green.ndfc_ut +# switch2_file4: red.ndfc_ut +# wildcard_filepath: "*:/*.ndfc_ut" +# +################################################################################# +# DELETED - TEST - Delete files +################################################################################ +# Expected result +# ok: [172.22.150.244] => { +# "result": { +# "changed": true, +# "diff": [ +# { +# "172.22.150.112": [ +# { +# "date": "2024-08-13 01:00:25", +# "device_name": "cvd-1211-spine", +# "filepath": "bootflash:/air.ndfc_ut", +# "ip_address": "172.22.150.112", +# "serial_number": "FOX2109PGCS", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:00:25", +# "device_name": "cvd-1211-spine", +# "filepath": "bootflash:/earth.ndfc_ut", +# "ip_address": "172.22.150.112", +# "serial_number": "FOX2109PGCS", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:00:25", +# "device_name": "cvd-1211-spine", +# "filepath": "bootflash:/fire.ndfc_ut", +# "ip_address": "172.22.150.112", +# "serial_number": "FOX2109PGCS", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:00:25", +# "device_name": "cvd-1211-spine", +# "filepath": "bootflash:/water.ndfc_ut", +# "ip_address": "172.22.150.112", +# "serial_number": "FOX2109PGCS", +# "size": "2", +# "supervisor": "active" +# } +# ], +# "172.22.150.113": [ +# { +# "date": "2024-08-13 01:01:14", +# "device_name": "cvd-1212-spine", +# "filepath": "bootflash:/black.ndfc_ut", +# "ip_address": "172.22.150.113", +# "serial_number": "FOX2109PGD0", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:01:14", +# "device_name": "cvd-1212-spine", +# "filepath": "bootflash:/blue.ndfc_ut", +# "ip_address": "172.22.150.113", +# "serial_number": "FOX2109PGD0", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:01:15", +# "device_name": "cvd-1212-spine", +# "filepath": "bootflash:/green.ndfc_ut", +# "ip_address": "172.22.150.113", +# "serial_number": "FOX2109PGD0", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:01:15", +# "device_name": "cvd-1212-spine", +# "filepath": "bootflash:/red.ndfc_ut", +# "ip_address": "172.22.150.113", +# "serial_number": "FOX2109PGD0", +# "size": "2", +# "supervisor": "active" +# } +# ], +# "sequence_number": 1 +# } +# ], +# "failed": false, +# "metadata": [ +# { +# "action": "bootflash_delete", +# "check_mode": false, +# "sequence_number": 1, +# "state": "deleted" +# } +# ], +# "response": [ +# { +# "DATA": "File(s) Deleted Successfully. \nDeleted files: [air.ndfc_ut, earth.ndfc_ut, water.ndfc_ut, fire.ndfc_ut][green.ndfc_ut, blue.ndfc_ut, black.ndfc_ut, red.ndfc_ut]", +# "MESSAGE": "OK", +# "METHOD": "DELETE", +# "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-files", +# "RETURN_CODE": 200, +# "sequence_number": 1 +# } +# ], +# "result": [ +# { +# "changed": true, +# "sequence_number": 1, +# "success": true +# } +# ] +# } +# } +################################################################################ +# DELETED - TEST - Delete files +################################################################################ + +- name: DELETED - TEST - Delete specific ndfc_ut files on active supervisor bootflash. + cisco.dcnm.dcnm_bootflash: &delete_files + state: deleted + config: + switches: + - ip_address: "{{ switch1 }}" + targets: + - filepath: "bootflash:/{{ switch1_file1 }}" + supervisor: active + - filepath: "bootflash:/{{ switch1_file2 }}" + supervisor: active + - filepath: "bootflash:/{{ switch1_file3 }}" + supervisor: active + - filepath: "bootflash:/{{ switch1_file4 }}" + supervisor: active + - ip_address: "{{ switch2 }}" + targets: + - filepath: "bootflash:/{{ switch2_file1 }}" + supervisor: active + - filepath: "bootflash:/{{ switch2_file2 }}" + supervisor: active + - filepath: "bootflash:/{{ switch2_file3 }}" + supervisor: active + - filepath: "bootflash:/{{ switch2_file4 }}" + supervisor: active + register: result + +- debug: + var: result + +- name: Prepare assert values filepath deletion + set_fact: + switch1_filepath1: "bootflash:/{{ switch1_file1 }}" + switch1_filepath2: "bootflash:/{{ switch1_file2 }}" + switch1_filepath3: "bootflash:/{{ switch1_file3 }}" + switch1_filepath4: "bootflash:/{{ switch1_file4 }}" + switch2_filepath1: "bootflash:/{{ switch2_file1 }}" + switch2_filepath2: "bootflash:/{{ switch2_file2 }}" + switch2_filepath3: "bootflash:/{{ switch2_file3 }}" + switch2_filepath4: "bootflash:/{{ switch2_file4 }}" + +- assert: + that: + - result.diff[0][switch1][0].filepath == switch1_filepath1 + - result.diff[0][switch1][1].filepath == switch1_filepath2 + - result.diff[0][switch1][2].filepath == switch1_filepath3 + - result.diff[0][switch1][3].filepath == switch1_filepath4 + - result.diff[0][switch2][0].filepath == switch2_filepath1 + - result.diff[0][switch2][1].filepath == switch2_filepath2 + - result.diff[0][switch2][2].filepath == switch2_filepath3 + - result.diff[0][switch2][3].filepath == switch2_filepath4 + - result.changed == true + - result.failed == false + - (result.diff | length) == 1 + - (result.diff[0] | length) == 3 + - (result.response | length) == 1 + - result.response[0].MESSAGE == "OK" + - result.response[0].METHOD == "DELETE" + - result.response[0].RETURN_CODE == 200 + - (result.metadata | length) == 1 + - result.metadata[0].action == "bootflash_delete" + - result.metadata[0].check_mode == False + - result.metadata[0].sequence_number == 1 + - result.metadata[0].state == "deleted" + - (result.result | length) == 1 + - result.result[0].changed == true + - result.result[0].sequence_number == 1 + - result.result[0].success == true + +################################################################################ +# DELETED - TEST - Delete files. Idempotence. +################################################################################ +# Expected result +# ok: [172.22.150.244] => { +# "result": { +# "changed": false, +# "diff": [ +# { +# "sequence_number": 1 +# } +# ], +# "failed": false, +# "metadata": [ +# { +# "action": "bootflash_delete", +# "check_mode": false, +# "sequence_number": 1, +# "state": "deleted" +# } +# ], +# "response": [ +# { +# "MESSAGE": "No files to delete.", +# "RETURN_CODE": 200, +# "sequence_number": 1 +# } +# ], +# "result": [ +# { +# "changed": false, +# "sequence_number": 1, +# "success": true +# } +# ] +# } +# } +################################################################################# +- name: DELETED - TEST - Delete specific ndfc_ut files on active supervisor bootflash. Idempotence. + cisco.dcnm.dcnm_bootflash: *delete_files + register: result + +- debug: + var: result + +- assert: + that: + - result.changed == false + - result.failed == false + - (result.diff | length) == 1 + - switch1 not in result.diff[0] + - switch2 not in result.diff[0] + - result.diff[0].sequence_number == 1 + - (result.metadata | length) == 1 + - result.metadata[0].action == "bootflash_delete" + - result.metadata[0].check_mode == False + - result.metadata[0].sequence_number == 1 + - result.metadata[0].state == "deleted" + - (result.response | length) == 1 + - result.response[0].MESSAGE == "No files to delete." + - result.response[0].RETURN_CODE == 200 + - result.response[0].sequence_number == 1 + - (result.result | length) == 1 + - result.result[0].changed == false + - result.result[0].success == true + - result.result[0].sequence_number == 1 diff --git a/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_deleted_wildcard.yaml b/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_deleted_wildcard.yaml new file mode 100644 index 000000000..6fbb2a3d9 --- /dev/null +++ b/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_deleted_wildcard.yaml @@ -0,0 +1,311 @@ +################################################################################ +# RUNTIME +################################################################################ +# +# Recent run times (MM:SS.ms): +# 01:19.79 +# +################################################################################ +# Description +################################################################################ +# +# Test deletion of all ndfc_ut files on all devices using wildcard file paths. +# +################################################################################ +# SETUP +################################################################################ +# +# 1. Two switches are required for this test; switch1 and switch2. +# 2. Files must be created on bootflash manually as NDFC does not +# provide a quick way to create files on bootflash. To create +# the files manually, we have two choices. +# A. Do the following directly on the switches: +# - On switch1, create a file named foo.txt +# switch# echo 1 > air.ndfc_ut +# switch# echo 1 > earth.ndfc_ut +# switch# echo 1 > fire.ndfc_ut +# switch# echo 1 > water.ndfc_ut +# - On switch2, create a file named bar.txt +# switch# echo 1 > black.ndfc_ut +# switch# echo 1 > blue.ndfc_ut +# switch# echo 1 > green.ndfc_ut +# switch# echo 1 > red.ndfc_ut +# B. Or, use the following playbook: +# ./playbooks/dcnm_bootflash/create_files.yaml +# This uses the cisco.nxos.nxos_command module to create the files. +# See the example inventory file in playbooks/roles/dcnm_bootflash/dcnm_hosts.yaml +# which includes inventory entries for switch1 and switch2 and change to +# match your setup. +# TEST +# 3. Ensure dcnm_tests.yaml has uncommented the following: +# testcase: dcnm_bootflash_deleted +# 4. Ensure all other testcase fields are commented. +# 5. Run the role with the following command: +# ansible-playbook dcnm_tests.yaml -i dcnm_hosts.yaml +# +# CLEANUP +# 6. No cleanup required +# +################################################################################ +# REQUIREMENTS +################################################################################ +# +# Example vars for dcnm_bootflash integration tests. +# Add to cisco/dcnm/playbooks/dcnm_tests.yaml +# switchX_fileY vars are included in the role's defaults/main.yaml +# so do not need to be added here unless you want to override them. +# wildcard_filepath is also included in the role's defaults/main.yaml +# so does not need to be added here unless you want to override it. +# +# vars: +# testcase: dcnm_bootflash_query +# username: admin +# password: "foobar" +# switch_username: admin +# switch_password: "foobar" +# switch1_file1: air.ndfc_ut +# switch1_file2: earth.ndfc_ut +# switch1_file3: fire.ndfc_ut +# switch1_file4: water.ndfc_ut +# switch2_file1: black.ndfc_ut +# switch2_file2: blue.ndfc_ut +# switch2_file3: green.ndfc_ut +# switch2_file4: red.ndfc_ut +# wildcard_filepath: "*:/*.ndfc_ut" +# +################################################################################# +# DELETED - TEST - Delete files +################################################################################ +# Expected result +# ok: [172.22.150.244] => { +# "result": { +# "changed": true, +# "diff": [ +# { +# "172.22.150.112": [ +# { +# "date": "2024-08-13 01:00:25", +# "device_name": "cvd-1211-spine", +# "filepath": "bootflash:/air.ndfc_ut", +# "ip_address": "172.22.150.112", +# "serial_number": "FOX2109PGCS", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:00:25", +# "device_name": "cvd-1211-spine", +# "filepath": "bootflash:/earth.ndfc_ut", +# "ip_address": "172.22.150.112", +# "serial_number": "FOX2109PGCS", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:00:25", +# "device_name": "cvd-1211-spine", +# "filepath": "bootflash:/fire.ndfc_ut", +# "ip_address": "172.22.150.112", +# "serial_number": "FOX2109PGCS", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:00:25", +# "device_name": "cvd-1211-spine", +# "filepath": "bootflash:/water.ndfc_ut", +# "ip_address": "172.22.150.112", +# "serial_number": "FOX2109PGCS", +# "size": "2", +# "supervisor": "active" +# } +# ], +# "172.22.150.113": [ +# { +# "date": "2024-08-13 01:01:14", +# "device_name": "cvd-1212-spine", +# "filepath": "bootflash:/black.ndfc_ut", +# "ip_address": "172.22.150.113", +# "serial_number": "FOX2109PGD0", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:01:14", +# "device_name": "cvd-1212-spine", +# "filepath": "bootflash:/blue.ndfc_ut", +# "ip_address": "172.22.150.113", +# "serial_number": "FOX2109PGD0", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:01:15", +# "device_name": "cvd-1212-spine", +# "filepath": "bootflash:/green.ndfc_ut", +# "ip_address": "172.22.150.113", +# "serial_number": "FOX2109PGD0", +# "size": "2", +# "supervisor": "active" +# }, +# { +# "date": "2024-08-13 01:01:15", +# "device_name": "cvd-1212-spine", +# "filepath": "bootflash:/red.ndfc_ut", +# "ip_address": "172.22.150.113", +# "serial_number": "FOX2109PGD0", +# "size": "2", +# "supervisor": "active" +# } +# ], +# "sequence_number": 1 +# } +# ], +# "failed": false, +# "metadata": [ +# { +# "action": "bootflash_delete", +# "check_mode": false, +# "sequence_number": 1, +# "state": "deleted" +# } +# ], +# "response": [ +# { +# "DATA": "File(s) Deleted Successfully. \nDeleted files: [air.ndfc_ut, earth.ndfc_ut, water.ndfc_ut, fire.ndfc_ut][green.ndfc_ut, blue.ndfc_ut, black.ndfc_ut, red.ndfc_ut]", +# "MESSAGE": "OK", +# "METHOD": "DELETE", +# "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-files", +# "RETURN_CODE": 200, +# "sequence_number": 1 +# } +# ], +# "result": [ +# { +# "changed": true, +# "sequence_number": 1, +# "success": true +# } +# ] +# } +# } +################################################################################ +# DELETED - TEST - Delete files +################################################################################ + +- name: DELETED - TEST - Delete {{ wildcard_filepath }} on active supervisor of switches. + cisco.dcnm.dcnm_bootflash: &delete_files + state: deleted + config: + targets: + - filepath: "{{ wildcard_filepath }}" + supervisor: active + switches: + - ip_address: "{{ switch1 }}" + - ip_address: "{{ switch2 }}" + register: result + +- debug: + var: result + +- name: Prepare assert values filepath deletion + set_fact: + switch1_filepath1: "bootflash:/{{ switch1_file1 }}" + switch1_filepath2: "bootflash:/{{ switch1_file2 }}" + switch1_filepath3: "bootflash:/{{ switch1_file3 }}" + switch2_filepath1: "bootflash:/{{ switch2_file1 }}" + switch2_filepath2: "bootflash:/{{ switch2_file2 }}" + switch2_filepath3: "bootflash:/{{ switch2_file3 }}" + +- assert: + that: + - result.diff[0][switch1][0].filepath == switch1_filepath1 + - result.diff[0][switch1][1].filepath == switch1_filepath2 + - result.diff[0][switch1][2].filepath == switch1_filepath3 + - result.diff[0][switch2][0].filepath == switch2_filepath1 + - result.diff[0][switch2][1].filepath == switch2_filepath2 + - result.diff[0][switch2][2].filepath == switch2_filepath3 + - result.changed == true + - result.failed == false + - (result.diff | length) == 1 + - (result.diff[0] | length) == 3 + - (result.response | length) == 1 + - result.response[0].MESSAGE == "OK" + - result.response[0].METHOD == "DELETE" + - result.response[0].RETURN_CODE == 200 + - (result.metadata | length) == 1 + - result.metadata[0].action == "bootflash_delete" + - result.metadata[0].check_mode == False + - result.metadata[0].sequence_number == 1 + - result.metadata[0].state == "deleted" + - (result.result | length) == 1 + - result.result[0].changed == true + - result.result[0].sequence_number == 1 + - result.result[0].success == true + +################################################################################ +# DELETED - TEST - Delete files. Idempotence. +################################################################################ +# Expected result +# ok: [172.22.150.244] => { +# "result": { +# "changed": false, +# "diff": [ +# { +# "sequence_number": 1 +# } +# ], +# "failed": false, +# "metadata": [ +# { +# "action": "bootflash_delete", +# "check_mode": false, +# "sequence_number": 1, +# "state": "deleted" +# } +# ], +# "response": [ +# { +# "MESSAGE": "No files to delete.", +# "RETURN_CODE": 200, +# "sequence_number": 1 +# } +# ], +# "result": [ +# { +# "changed": false, +# "sequence_number": 1, +# "success": true +# } +# ] +# } +# } +################################################################################# +- name: DELETED - TEST - Delete files. Idempotence. + cisco.dcnm.dcnm_bootflash: *delete_files + register: result + +- debug: + var: result + +- assert: + that: + - result.changed == false + - result.failed == false + - (result.diff | length) == 1 + - switch1 not in result.diff[0] + - switch2 not in result.diff[0] + - result.diff[0].sequence_number == 1 + - (result.metadata | length) == 1 + - result.metadata[0].action == "bootflash_delete" + - result.metadata[0].check_mode == False + - result.metadata[0].sequence_number == 1 + - result.metadata[0].state == "deleted" + - (result.response | length) == 1 + - result.response[0].MESSAGE == "No files to delete." + - result.response[0].RETURN_CODE == 200 + - result.response[0].sequence_number == 1 + - (result.result | length) == 1 + - result.result[0].changed == false + - result.result[0].success == true + - result.result[0].sequence_number == 1 diff --git a/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_query_specific.yaml b/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_query_specific.yaml new file mode 100644 index 000000000..6b750f741 --- /dev/null +++ b/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_query_specific.yaml @@ -0,0 +1,279 @@ +################################################################################ +# RUNTIME +################################################################################ +# +# Recent run times (MM:SS.ms): +# 00:34.523 +# +################################################################################ +# SETUP +################################################################################ +# +# 1. Two switches are required for this test; switch1 and switch2. +# 2. Files must be created on bootflash manually as NDFC does not +# provide a quick way to create files on bootflash. To create +# the files manually, we have two choices. +# A. Do the following directly on the switches: +# - On switch1, create a file named foo.txt +# switch# echo 1 > air.ndfc_ut +# switch# echo 1 > earth.ndfc_ut +# switch# echo 1 > fire.ndfc_ut +# switch# echo 1 > water.ndfc_ut +# - On switch2, create a file named bar.txt +# switch# echo 1 > black.ndfc_ut +# switch# echo 1 > blue.ndfc_ut +# switch# echo 1 > green.ndfc_ut +# switch# echo 1 > red.ndfc_ut +# B. Or, use the following playbook: +# ./playbooks/dcnm_bootflash/create_files.yaml +# This uses the cisco.nxos.nxos_command module to create the files. +# See the example inventory file in playbooks/roles/dcnm_bootflash/dcnm_hosts.yaml +# which includes inventory entries for switch1 and switch2 and change to +# match your setup. +# TEST +# 3. Ensure dcnm_tests.yaml has uncommented the following: +# testcase: dcnm_bootflash_deleted +# 4. Ensure all other testcase fields are commented. +# 5. Run the role with the following command: +# ansible-playbook dcnm_tests.yaml -i dcnm_hosts.yaml +# +# CLEANUP +# 6. No cleanup required +# +################################################################################ +# REQUIREMENTS +################################################################################ +# +# Example vars for dcnm_bootflash integration tests. +# Add to cisco/dcnm/playbooks/dcnm_tests.yaml +# switchX_fileY vars are included in the role's defaults/main.yaml +# so do not need to be added here unless you want to override them. +# wildcard_filepath is also included in the role's defaults/main.yaml +# so does not need to be added here unless you want to override it. +# +# vars: +# testcase: dcnm_bootflash_query +# username: admin +# password: "foobar" +# switch_username: admin +# switch_password: "foobar" +# switch1_file1: air.ndfc_ut +# switch1_file2: earth.ndfc_ut +# switch1_file3: fire.ndfc_ut +# switch1_file4: water.ndfc_ut +# switch2_file1: black.ndfc_ut +# switch2_file2: blue.ndfc_ut +# switch2_file3: green.ndfc_ut +# switch2_file4: red.ndfc_ut +# wildcard_filepath: "*:/*.ndfc_ut" +# +################################################################################ +# QUERY - TEST - Query files +################################################################################ +# Expected result +# Objects removed from response for brevity. +# ok: [172.22.150.244] => { +# "result": { +# "changed": false, +# "diff": [ +# { +# "172.22.150.112": [ +# { +# "bootflash_type": "active", +# "date": "Aug 03 01:37:06 2024", +# "deviceName": "cvd-1211-spine", +# "fileName": "air.txt", +# "filePath": "bootflash:", +# "ipAddr": "172.22.150.112", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGCS", +# "size": "4" +# } +# ], +# "172.22.150.113": [ +# { +# "bootflash_type": "active", +# "date": "Aug 03 01:38:10 2024", +# "deviceName": "cvd-1212-spine", +# "fileName": "black.txt", +# "filePath": "bootflash:", +# "ipAddr": "172.22.150.113", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGD0", +# "size": "4" +# } +# ], +# "sequence_number": 1 +# } +# ], +# "failed": false, +# "metadata": [ +# { +# "action": "bootflash_info", +# "check_mode": false, +# "sequence_number": 1, +# "state": "query" +# } +# ], +# "response": [ +# { +# "172.22.150.112": { +# "DATA": { +# "bootFlashDataMap": { +# "bootflash:": [ +# { +# "bootflash_type": "active", +# "date": "Aug 12 23:17:57 2024", +# "deviceName": "cvd-1211-spine", +# "fileName": "air.ndfc_ut", +# "filePath": "bootflash:", +# "ipAddr": " 172.22.150.112", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGCS", +# "size": "2" +# } +# ] +# }, +# "bootFlashSpaceMap": { +# "bootflash:": { +# "bootflash_type": "active", +# "deviceName": "cvd-1211-spine", +# "freeSpace": 12995145728, +# "ipAddr": " 172.22.150.112", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGCS", +# "totalSpace": 21685153792, +# "usedSpace": 8690008064 +# } +# }, +# "partitions": [ +# "bootflash:" +# ], +# "requiredSpace": "NA" +# }, +# "MESSAGE": "OK", +# "METHOD": "GET", +# "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGCS", +# "RETURN_CODE": 200 +# }, +# "172.22.150.113": { +# "DATA": { +# "bootFlashDataMap": { +# "bootflash:": [ +# { +# "bootflash_type": "active", +# "date": "Jul 07 01:20:21 2024", +# "deviceName": "cvd-1212-spine", +# "fileName": "black.ndfc_ut", +# "filePath": "bootflash:.rpmstore/", +# "ipAddr": " 172.22.150.113", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGD0", +# "size": "2" +# } +# ] +# }, +# "bootFlashSpaceMap": { +# "bootflash:": { +# "bootflash_type": "active", +# "deviceName": "cvd-1212-spine", +# "freeSpace": 10630582272, +# "ipAddr": " 172.22.150.113", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGD0", +# "totalSpace": 21685153792, +# "usedSpace": 11054571520 +# } +# }, +# "partitions": [ +# "bootflash:" +# ], +# "requiredSpace": "NA" +# }, +# "MESSAGE": "OK", +# "METHOD": "GET", +# "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGD0", +# "RETURN_CODE": 200 +# }, +# "sequence_number": 1 +# } +# ], +# "result": [ +# { +# "172.22.150.112": { +# "found": true, +# "success": true +# }, +# "172.22.150.113": { +# "found": true, +# "success": true +# }, +# "sequence_number": 1 +# } +# ] +# } +# } + +- name: QUERY - TEST - Query specfic files on specific partitions of active supervisor + cisco.dcnm.dcnm_bootflash: + state: query + config: + switches: + - ip_address: "{{ switch1 }}" + targets: + - filepath: "bootflash:/{{ switch1_file2 }}" + supervisor: active + - filepath: "bootflash:/{{ switch1_file3 }}" + supervisor: active + - filepath: "bootflash:/{{ switch1_file4 }}" + supervisor: active + - ip_address: "{{ switch2 }}" + targets: + - filepath: "bootflash:/{{ switch2_file2 }}" + supervisor: active + - filepath: "bootflash:/{{ switch2_file3 }}" + supervisor: active + - filepath: "bootflash:/{{ switch2_file4 }}" + supervisor: active + register: result + +- debug: + var: result + +- name: Prepare assert values for specific query + set_fact: + switch1_filepath1: "bootflash:/{{ switch1_file2 }}" + switch1_filepath2: "bootflash:/{{ switch1_file3 }}" + switch1_filepath3: "bootflash:/{{ switch1_file4 }}" + switch2_filepath1: "bootflash:/{{ switch2_file2 }}" + switch2_filepath2: "bootflash:/{{ switch2_file3 }}" + switch2_filepath3: "bootflash:/{{ switch2_file4 }}" + +- assert: + that: + - result.diff[0][switch1][0].filepath == switch1_filepath1 + - result.diff[0][switch1][1].filepath == switch1_filepath2 + - result.diff[0][switch1][2].filepath == switch1_filepath3 + - result.diff[0][switch2][0].filepath == switch2_filepath1 + - result.diff[0][switch2][1].filepath == switch2_filepath2 + - result.diff[0][switch2][2].filepath == switch2_filepath3 + - result.changed == false + - result.failed == false + - (result.diff | length) == 1 + - (result.response | length) == 1 + - result.response[0][switch1].RETURN_CODE == 200 + - result.response[0][switch2].RETURN_CODE == 200 + - result.response[0][switch1].MESSAGE == "OK" + - result.response[0][switch2].MESSAGE == "OK" + - result.response[0][switch1].METHOD == "GET" + - result.response[0][switch2].METHOD == "GET" + - (result.metadata | length) == 1 + - result.metadata[0].action == "bootflash_info" + - result.metadata[0].check_mode == false + - result.metadata[0].sequence_number == 1 + - result.metadata[0].state == "query" + - (result.result | length) == 1 + - result.result[0][switch1].found == true + - result.result[0][switch2].found == true + - result.result[0][switch1].success == true + - result.result[0][switch2].success == true diff --git a/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_query_wildcard.yaml b/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_query_wildcard.yaml new file mode 100644 index 000000000..810664763 --- /dev/null +++ b/tests/integration/targets/dcnm_bootflash/tests/dcnm_bootflash_query_wildcard.yaml @@ -0,0 +1,269 @@ +################################################################################ +# RUNTIME +################################################################################ +# +# Recent run times (MM:SS.ms): +# 00:34.523 +# +################################################################################ +# SETUP +################################################################################ +# +# 1. Two switches are required for this test; switch1 and switch2. +# 2. Files must be created on bootflash manually as NDFC does not +# provide a quick way to create files on bootflash. To create +# the files manually, we have two choices. +# A. Do the following directly on the switches: +# - On switch1, create a file named foo.txt +# switch# echo 1 > air.ndfc_ut +# switch# echo 1 > earth.ndfc_ut +# switch# echo 1 > fire.ndfc_ut +# switch# echo 1 > water.ndfc_ut +# - On switch2, create a file named bar.txt +# switch# echo 1 > black.ndfc_ut +# switch# echo 1 > blue.ndfc_ut +# switch# echo 1 > green.ndfc_ut +# switch# echo 1 > red.ndfc_ut +# B. Or, use the following playbook: +# ./playbooks/dcnm_bootflash/create_files.yaml +# This uses the cisco.nxos.nxos_command module to create the files. +# See the example inventory file in playbooks/roles/dcnm_bootflash/dcnm_hosts.yaml +# which includes inventory entries for switch1 and switch2 and change to +# match your setup. +# TEST +# 3. Ensure dcnm_tests.yaml has uncommented the following: +# testcase: dcnm_bootflash_deleted +# 4. Ensure all other testcase fields are commented. +# 5. Run the role with the following command: +# ansible-playbook dcnm_tests.yaml -i dcnm_hosts.yaml +# +# CLEANUP +# 6. No cleanup required +# +################################################################################ +# REQUIREMENTS +################################################################################ +# +# Example vars for dcnm_bootflash integration tests. +# Add to cisco/dcnm/playbooks/dcnm_tests.yaml +# switchX_fileY vars are included in the role's defaults/main.yaml +# so do not need to be added here unless you want to override them. +# wildcard_filepath is also included in the role's defaults/main.yaml +# so does not need to be added here unless you want to override it. +# +# vars: +# testcase: dcnm_bootflash_query +# username: admin +# password: "foobar" +# switch_username: admin +# switch_password: "foobar" +# switch1_file1: air.ndfc_ut +# switch1_file2: earth.ndfc_ut +# switch1_file3: fire.ndfc_ut +# switch1_file4: water.ndfc_ut +# switch2_file1: black.ndfc_ut +# switch2_file2: blue.ndfc_ut +# switch2_file3: green.ndfc_ut +# switch2_file4: red.ndfc_ut +# wildcard_filepath: "*:/*.ndfc_ut" +# +################################################################################ +# QUERY - TEST - Query files +################################################################################ +# Expected result +# Objects removed from response for brevity. +# ok: [172.22.150.244] => { +# "result": { +# "changed": false, +# "diff": [ +# { +# "172.22.150.112": [ +# { +# "bootflash_type": "active", +# "date": "Aug 03 01:37:06 2024", +# "deviceName": "cvd-1211-spine", +# "fileName": "air.txt", +# "filePath": "bootflash:", +# "ipAddr": "172.22.150.112", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGCS", +# "size": "4" +# } +# ], +# "172.22.150.113": [ +# { +# "bootflash_type": "active", +# "date": "Aug 03 01:38:10 2024", +# "deviceName": "cvd-1212-spine", +# "fileName": "black.txt", +# "filePath": "bootflash:", +# "ipAddr": "172.22.150.113", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGD0", +# "size": "4" +# } +# ], +# "sequence_number": 1 +# } +# ], +# "failed": false, +# "metadata": [ +# { +# "action": "bootflash_info", +# "check_mode": false, +# "sequence_number": 1, +# "state": "query" +# } +# ], +# "response": [ +# { +# "172.22.150.112": { +# "DATA": { +# "bootFlashDataMap": { +# "bootflash:": [ +# { +# "bootflash_type": "active", +# "date": "Aug 12 23:17:57 2024", +# "deviceName": "cvd-1211-spine", +# "fileName": "air.ndfc_ut", +# "filePath": "bootflash:", +# "ipAddr": " 172.22.150.112", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGCS", +# "size": "2" +# } +# ] +# }, +# "bootFlashSpaceMap": { +# "bootflash:": { +# "bootflash_type": "active", +# "deviceName": "cvd-1211-spine", +# "freeSpace": 12995145728, +# "ipAddr": " 172.22.150.112", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGCS", +# "totalSpace": 21685153792, +# "usedSpace": 8690008064 +# } +# }, +# "partitions": [ +# "bootflash:" +# ], +# "requiredSpace": "NA" +# }, +# "MESSAGE": "OK", +# "METHOD": "GET", +# "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGCS", +# "RETURN_CODE": 200 +# }, +# "172.22.150.113": { +# "DATA": { +# "bootFlashDataMap": { +# "bootflash:": [ +# { +# "bootflash_type": "active", +# "date": "Jul 07 01:20:21 2024", +# "deviceName": "cvd-1212-spine", +# "fileName": "black.ndfc_ut", +# "filePath": "bootflash:.rpmstore/", +# "ipAddr": " 172.22.150.113", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGD0", +# "size": "2" +# } +# ] +# }, +# "bootFlashSpaceMap": { +# "bootflash:": { +# "bootflash_type": "active", +# "deviceName": "cvd-1212-spine", +# "freeSpace": 10630582272, +# "ipAddr": " 172.22.150.113", +# "name": "bootflash:", +# "serialNumber": "FOX2109PGD0", +# "totalSpace": 21685153792, +# "usedSpace": 11054571520 +# } +# }, +# "partitions": [ +# "bootflash:" +# ], +# "requiredSpace": "NA" +# }, +# "MESSAGE": "OK", +# "METHOD": "GET", +# "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGD0", +# "RETURN_CODE": 200 +# }, +# "sequence_number": 1 +# } +# ], +# "result": [ +# { +# "172.22.150.112": { +# "found": true, +# "success": true +# }, +# "172.22.150.113": { +# "found": true, +# "success": true +# }, +# "sequence_number": 1 +# } +# ] +# } +# } + +- name: QUERY - TEST - Query {{ wildcard_filepath }} on active supervisor of switches. + cisco.dcnm.dcnm_bootflash: + state: query + config: + targets: + - filepath: "{{ wildcard_filepath }}" + supervisor: active + switches: + - ip_address: "{{ switch1 }}" + - ip_address: "{{ switch2 }}" + register: result + +- debug: + var: result + +- name: Prepare assert values for wildcard query + set_fact: + switch1_filepath1: "bootflash:/{{ switch1_file1 }}" + switch1_filepath2: "bootflash:/{{ switch1_file2 }}" + switch1_filepath3: "bootflash:/{{ switch1_file3 }}" + switch2_filepath1: "bootflash:/{{ switch2_file1 }}" + switch2_filepath2: "bootflash:/{{ switch2_file2 }}" + switch2_filepath3: "bootflash:/{{ switch2_file3 }}" + +- assert: + that: + - result.diff[0][switch1][0].filepath == switch1_filepath1 + - result.diff[0][switch1][1].filepath == switch1_filepath2 + - result.diff[0][switch1][2].filepath == switch1_filepath3 + - result.diff[0][switch2][0].filepath == switch2_filepath1 + - result.diff[0][switch2][1].filepath == switch2_filepath2 + - result.diff[0][switch2][2].filepath == switch2_filepath3 + - result.changed == false + - result.failed == false + - (result.diff | length) == 1 + - (result.diff[0] | length) == 3 + - (result.response | length) == 1 + - result.response[0][switch1].RETURN_CODE == 200 + - result.response[0][switch2].RETURN_CODE == 200 + - result.response[0][switch1].MESSAGE == "OK" + - result.response[0][switch2].MESSAGE == "OK" + - result.response[0][switch1].METHOD == "GET" + - result.response[0][switch2].METHOD == "GET" + - (result.metadata | length) == 1 + - result.metadata[0].action == "bootflash_info" + - result.metadata[0].check_mode == false + - result.metadata[0].sequence_number == 1 + - result.metadata[0].state == "query" + - (result.result | length) == 1 + - result.result[0][switch1].found == true + - result.result[0][switch2].found == true + - result.result[0][switch1].success == true + - result.result[0][switch2].success == true diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index 60d9043d3..ee4c8f167 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -16,5 +16,6 @@ plugins/modules/dcnm_vpc_pair.py validate-modules:missing-gplv3-license # GPLv3 plugins/modules/dcnm_image_upload.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_fabric.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_maintenance_mode.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module +plugins/modules/dcnm_bootflash.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_rest.py import-2.6!skip plugins/modules/dcnm_rest.py import-2.7!skip diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index 4723c583b..7c05502f0 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -16,6 +16,7 @@ plugins/modules/dcnm_vpc_pair.py validate-modules:missing-gplv3-license # GPLv3 plugins/modules/dcnm_image_upload.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_fabric.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_maintenance_mode.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module +plugins/modules/dcnm_bootflash.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_rest.py import-2.6!skip plugins/modules/dcnm_rest.py import-2.7!skip plugins/httpapi/dcnm.py import-2.7!skip diff --git a/tests/sanity/ignore-2.12.txt b/tests/sanity/ignore-2.12.txt index 334160f16..060f03b58 100644 --- a/tests/sanity/ignore-2.12.txt +++ b/tests/sanity/ignore-2.12.txt @@ -16,6 +16,7 @@ plugins/modules/dcnm_vpc_pair.py validate-modules:missing-gplv3-license # GPLv3 plugins/modules/dcnm_image_upload.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_fabric.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_maintenance_mode.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module +plugins/modules/dcnm_bootflash.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_rest.py import-2.6!skip plugins/modules/dcnm_rest.py import-2.7!skip plugins/httpapi/dcnm.py import-3.8!skip diff --git a/tests/sanity/ignore-2.13.txt b/tests/sanity/ignore-2.13.txt index b535a3144..3ee2c8ce0 100644 --- a/tests/sanity/ignore-2.13.txt +++ b/tests/sanity/ignore-2.13.txt @@ -17,6 +17,7 @@ plugins/httpapi/dcnm.py validate-modules:missing-gplv3-license # GPLv3 license h plugins/modules/dcnm_vpc_pair.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_fabric.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_maintenance_mode.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module +plugins/modules/dcnm_bootflash.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_rest.py import-2.7!skip plugins/httpapi/dcnm.py import-3.8!skip plugins/httpapi/dcnm.py import-3.9!skip diff --git a/tests/sanity/ignore-2.14.txt b/tests/sanity/ignore-2.14.txt index 15705d33b..7353cc942 100644 --- a/tests/sanity/ignore-2.14.txt +++ b/tests/sanity/ignore-2.14.txt @@ -17,6 +17,7 @@ plugins/httpapi/dcnm.py validate-modules:missing-gplv3-license # GPLv3 license h plugins/modules/dcnm_vpc_pair.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_fabric.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_maintenance_mode.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module +plugins/modules/dcnm_bootflash.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_rest.py import-2.7!skip plugins/httpapi/dcnm.py import-3.9!skip plugins/httpapi/dcnm.py import-3.10!skip diff --git a/tests/sanity/ignore-2.15.txt b/tests/sanity/ignore-2.15.txt index 15705d33b..7353cc942 100644 --- a/tests/sanity/ignore-2.15.txt +++ b/tests/sanity/ignore-2.15.txt @@ -17,6 +17,7 @@ plugins/httpapi/dcnm.py validate-modules:missing-gplv3-license # GPLv3 license h plugins/modules/dcnm_vpc_pair.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_fabric.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_maintenance_mode.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module +plugins/modules/dcnm_bootflash.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_rest.py import-2.7!skip plugins/httpapi/dcnm.py import-3.9!skip plugins/httpapi/dcnm.py import-3.10!skip diff --git a/tests/sanity/ignore-2.16.txt b/tests/sanity/ignore-2.16.txt index 20cfc7582..20a78e8c8 100644 --- a/tests/sanity/ignore-2.16.txt +++ b/tests/sanity/ignore-2.16.txt @@ -17,3 +17,4 @@ plugins/httpapi/dcnm.py validate-modules:missing-gplv3-license # GPLv3 license h plugins/modules/dcnm_vpc_pair.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_fabric.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_maintenance_mode.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module +plugins/modules/dcnm_bootflash.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index 60d9043d3..ee4c8f167 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -16,5 +16,6 @@ plugins/modules/dcnm_vpc_pair.py validate-modules:missing-gplv3-license # GPLv3 plugins/modules/dcnm_image_upload.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_fabric.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_maintenance_mode.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module +plugins/modules/dcnm_bootflash.py validate-modules:missing-gplv3-license # GPLv3 license header not found in the first 20 lines of the module plugins/modules/dcnm_rest.py import-2.6!skip plugins/modules/dcnm_rest.py import-2.7!skip diff --git a/tests/unit/module_utils/common/api/test_api_v1_imagemanagement_rest_imagemgnt.py b/tests/unit/module_utils/common/api/test_api_v1_imagemanagement_rest_imagemgnt_bootflash.py similarity index 83% rename from tests/unit/module_utils/common/api/test_api_v1_imagemanagement_rest_imagemgnt.py rename to tests/unit/module_utils/common/api/test_api_v1_imagemanagement_rest_imagemgnt_bootflash.py index ab0785d15..9f1f8adec 100644 --- a/tests/unit/module_utils/common/api/test_api_v1_imagemanagement_rest_imagemgnt.py +++ b/tests/unit/module_utils/common/api/test_api_v1_imagemanagement_rest_imagemgnt_bootflash.py @@ -17,8 +17,8 @@ __metaclass__ = type -from ansible_collections.cisco.dcnm.plugins.module_utils.common.api.v1.imagemanagement.rest.imagemgnt.imagemgnt import \ - EpBootFlashInfo +from ansible_collections.cisco.dcnm.plugins.module_utils.common.api.v1.imagemanagement.rest.imagemgnt.bootflash.bootflash import \ + EpBootflashInfo from ansible_collections.cisco.dcnm.tests.unit.module_utils.common.common_utils import \ does_not_raise @@ -28,12 +28,13 @@ def test_ep_image_mgnt_00010(): """ ### Class - - EpBootFlashInfo + - EpBootflashInfo ### Summary - Verify path and verb """ with does_not_raise(): - instance = EpBootFlashInfo() - assert instance.path == f"{PATH_PREFIX}/bootFlash/bootflash-info" + instance = EpBootflashInfo() + instance.serial_number = "1234567890" + assert instance.path == f"{PATH_PREFIX}/bootFlash/bootflash-info?serialNumber=1234567890" assert instance.verb == "GET" diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixture.py b/tests/unit/modules/dcnm/dcnm_bootflash/fixture.py new file mode 100644 index 000000000..bb3730787 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixture.py @@ -0,0 +1,50 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json +import os +import sys + +fixture_path = os.path.join(os.path.dirname(__file__), "fixtures") + + +def load_fixture(filename): + """ + load test inputs from json files + """ + path = os.path.join(fixture_path, f"{filename}.json") + + try: + with open(path, encoding="utf-8") as file_handle: + data = file_handle.read() + except IOError as exception: + msg = f"Exception opening test input file {filename}.json : " + msg += f"Exception detail: {exception}" + print(msg) + sys.exit(1) + + try: + fixture = json.loads(data) + except json.JSONDecodeError as exception: + msg = "Exception reading JSON contents in " + msg += f"test input file {filename}.json : " + msg += f"Exception detail: {exception}" + print(msg) + sys.exit(1) + + return fixture diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/configs_Deleted.json b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/configs_Deleted.json new file mode 100644 index 000000000..9b45111c4 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/configs_Deleted.json @@ -0,0 +1,125 @@ +{ + "TEST_NOTES": [ + "Mocked playbook configurations for BootflashFiles() unit tests.", + "tests/unit/modules/dcnm/dcnm_bootflash" + ], + "test_bootflash_deleted_00010a": { + "TEST_NOTES": [ + "Negative test.", + "targets is a list of non-dict." + ], + "targets": ["NOT_A_DICT"], + "switches": [ + { + "ip_address": "172.22.150.112" + } + ] + }, + "test_bootflash_deleted_01000a": { + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + }, + { + "ip_address": "172.22.150.113" + } + ] + }, + "test_bootflash_deleted_02000a": { + "TEST_NOTES": [ + "Negative test.", + "supervisor value is invalid." + ], + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "foo" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + } + ] + }, + "test_bootflash_deleted_03200a": { + "TEST_NOTES": [ + "Valid config." + ], + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + } + ] + }, + "test_bootflash_deleted_03210a": { + "TEST_NOTES": [ + "Valid config." + ], + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + } + ] + }, + "test_bootflash_files_00100a": { + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + }, + { + "ip_address": "172.22.150.112" + } + ] + }, + "test_bootflash_files_00220a": { + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + } + ] + }, + "test_bootflash_files_00400a": { + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + } + ] + } +} diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/configs_Query.json b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/configs_Query.json new file mode 100644 index 000000000..9fdc7e35b --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/configs_Query.json @@ -0,0 +1,195 @@ +{ + "TEST_NOTES": [ + "Mocked playbook configurations for query-state tests.", + "tests/unit/modules/dcnm/dcnm_bootflash" + ], + "test_bootflash_common_00200a": { + "targets": [ + { + "filepath": "bootflash:/foo.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "192.168.1.2" + } + ] + }, + "test_bootflash_common_00210a": { + "TEST_NOTES": [ + "Negative test.", + "switches[0].ip_address is misspelled." + ], + "targets": [ + { + "filepath": "bootflash:/foo.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address_misspelled": "192.168.1.2" + } + ] + }, + "test_bootflash_common_00220a": { + "TEST_NOTES": [ + "Negative test.", + "switches[0].targets is not a list." + ], + "targets": [ + { + "filepath": "bootflash:/foo.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "192.168.1.2", + "targets": "NOT_A_LIST" + } + ] + }, + "test_bootflash_common_00230a": { + "TEST_NOTES": [ + "Negative test.", + "switches[0].targets.filepath is misspelled." + ], + "targets": [ + { + "filepath": "bootflash:/foo.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "192.168.1.2", + "targets": [ + { + "filepath_misspelled": "bootflash:/foo.txt", + "supervisor": "active" + } + ] + } + ] + }, + "test_bootflash_common_00240a": { + "TEST_NOTES": [ + "Negative test.", + "switches[0].targets.supervisor is misspelled." + ], + "targets": [ + { + "filepath": "bootflash:/foo.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "192.168.1.2", + "targets": [ + { + "filepath": "bootflash:/foo.txt", + "supervisor_misspelled": "active" + } + ] + } + ] + }, + "test_bootflash_info_00150a": { + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "192.168.1.2" + }, + { + "ip_address": "192.168.1.3" + } + ] + }, + "test_bootflash_info_00210a": { + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + } + ] + }, + "test_bootflash_info_00220a": { + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + }, + { + "ip_address": "172.22.150.113" + } + ] + }, + "test_bootflash_info_00230a": { + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + } + ] + }, + "test_bootflash_query_00010a": { + "TEST_NOTES": [ + "Negative test.", + "targets is a list of non-dict." + ], + "targets": ["NOT_A_DICT"], + "switches": [ + { + "ip_address": "172.22.150.112" + } + ] + }, + "test_bootflash_query_01000a": { + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [ + { + "ip_address": "172.22.150.112" + }, + { + "ip_address": "172.22.150.113" + } + ] + }, + "test_bootflash_query_01010a": { + "targets": [ + { + "filepath": "bootflash:/*.txt", + "supervisor": "active" + } + ], + "switches": [] + } +} diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/file_info_ConvertFileInfoToTarget.json b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/file_info_ConvertFileInfoToTarget.json new file mode 100644 index 000000000..7c80a50bd --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/file_info_ConvertFileInfoToTarget.json @@ -0,0 +1,50 @@ +{ + "TEST_NOTES": [ + "Mocked playbook configurations for query-state tests.", + "tests/unit/modules/dcnm/dcnm_bootflash" + ], + "test_convert_file_info_to_target_00100a": { + "bootflash_type": "active", + "date": "Sep 19 22:20:07 2023", + "deviceName": "cvd-1212-spine", + "fileName": "n9000-epld.10.2.5.M.img", + "filePath": "bootflash:", + "ipAddr": " 192.168.1.1", + "name": "bootflash:", + "serialNumber": "BDY3814QDD0", + "size": "218233885" + }, + "test_convert_file_info_to_target_00120a": { + "bootflash_type": "active", + "date": "Sep 19 22:20:07 2023", + "deviceName": "cvd-1212-spine", + "fileName": "foo", + "filePath": "bootflash:", + "ipAddr": " 192.168.1.1", + "name": 10, + "serialNumber": "BDY3814QDD0", + "size": "218233885" + }, + "test_convert_file_info_to_target_00130a": { + "bootflash_type": "active", + "date": "Sep 19 22:20:07 2023", + "deviceName": "cvd-1212-spine", + "fileName": "foo", + "filePath": "bootflash:", + "ipAddr": " 192.168.1.1", + "name": "bootflash", + "serialNumber": "BDY3814QDD0", + "size": "218233885" + }, + "test_convert_file_info_to_target_00210a": { + "bootflash_type": "active", + "date": "Sep 19 22:20:07 202", + "deviceName": "cvd-1212-spine", + "fileName": "foo", + "filePath": "bootflash:", + "ipAddr": " 192.168.1.1", + "name": "bootflash:", + "serialNumber": "BDY3814QDD0", + "size": "218233885" + } +} diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/payloads_BootflashFiles.json b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/payloads_BootflashFiles.json new file mode 100644 index 000000000..f23c912a8 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/payloads_BootflashFiles.json @@ -0,0 +1,108 @@ +{ + "TEST_NOTES": [ + "Payloads for BootflashFiles() unit test asserts.", + "tests/unit/modules/dcnm/dcnm_bootflash/bootflash_files.py" + ], + "test_bootflash_files_00100a": { + "deleteFiles": [ + { + "files": [ + { + "bootflashType": "active", + "fileName": "black.txt", + "filePath": "bootflash:" + } + ], + "partition": "bootflash:", + "serialNumber": "FOX2109PGCS" + }, + { + "files": [ + { + "bootflashType": "active", + "fileName": "air.txt", + "filePath": "bootflash:" + } + ], + "partition": "bootflash:", + "serialNumber": "FOX2109PGD0" + } + ] + }, + "test_bootflash_files_00200a": { + "deleteFiles": [ + { + "files": [ + { + "bootflashType": "active", + "fileName": "air.txt", + "filePath": "bootflash:/air.txt" + }, + { + "bootflashType": "active", + "fileName": "earth.txt", + "filePath": "bootflash:/earth.txt" + } + ], + "partition": "bootflash:", + "serialNumber": "FOX2109PGCS" + } + ] + }, + "test_bootflash_files_00210a": { + "deleteFiles": [ + { + "serialNumber": "FOX2109PGCS", + "partition": "bootflash:", + "files": [ + { + "bootflashType": "active", + "fileName": "air.txt", + "filePath": "bootflash:/air.txt" + } + ] + }, + { + "serialNumber": "FOX2109PGD0", + "partition": "bootflash:", + "files": [ + { + "bootflashType": "active", + "fileName": "black.txt", + "filePath": "bootflash:/black.txt" + } + ] + } + ] + }, + "test_bootflash_files_00600a": { + "deleteFiles": [ + { + "files": [ + { + "bootflashType": "active", + "fileName": "black.txt", + "filePath": "bootflash:" + } + ], + "partition": "bootflash:", + "serialNumber": "FOX2109PGCS" + } + ] + }, + "test_bootflash_files_00610a": { + "deleteFiles": [ + { + "files": [ + { + "bootflashType": "active", + "fileName": "black.txt", + "filePath": "bootflash:" + } + ], + "partition": "bootflash:", + "serialNumber": "FOX2109PGCS" + } + ] + } +} diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpAllSwitches.json b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpAllSwitches.json new file mode 100644 index 000000000..a0d8cfaa8 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpAllSwitches.json @@ -0,0 +1,479 @@ +{ + "TEST_NOTES": [ + "Mocked SwitchDetails() responses for tests/unit/modules/dcnm/dcnm_bootflash" + ], + "test_bootflash_deleted_01000a": { + "TEST_NOTES": [ + "2x switches, both with systemMode == normal." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + }, + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.113", + "logicalName": "cvd-1212-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGD0", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_deleted_03200a": { + "TEST_NOTES": [ + "172.22.150.112 with systemMode == Migration." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Migration", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Migration" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_deleted_03210a": { + "TEST_NOTES": [ + "172.22.150.112 with systemMode == Inconsistent." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Inconsistent", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Inconsistent" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_files_00100a": { + "TEST_NOTES": [ + "2x switches, both with systemMode == normal." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + }, + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.113", + "logicalName": "cvd-1212-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGD0", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_files_00200a": { + "TEST_NOTES": [ + "1x switch with systemMode == Normal, ip_address == 172.22.150.112, and serialNumber == FOX2109PGCS." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_files_00210a": { + "TEST_NOTES": [ + "2x switches, both with systemMode == normal." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + }, + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.113", + "logicalName": "cvd-1212-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGD0", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_files_00220a": { + "TEST_NOTES": [ + "1x switch with systemMode == Migration." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Migration", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Migration" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_files_00220b": { + "TEST_NOTES": [ + "1x switch with systemMode == Inconsistent." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Inconsistent", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Inconsistent" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_files_00400a": { + "TEST_NOTES": [ + "1x switch missing serialNumber." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_files_00600a": { + "TEST_NOTES": [ + "1x switch with ip_address == 172.22.150.112 and serialNumber == FOX2109PGCS." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_files_00610a": { + "TEST_NOTES": [ + "1x switch with ip_address == 172.22.150.112 and serialNumber == FOX2109PGCS." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00100a": { + "TEST_NOTES": [ + "2x switches, both with systemMode == normal." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + }, + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.113", + "logicalName": "cvd-1212-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGD0", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00150a": { + "TEST_NOTES": [ + "2x switches, both with systemMode == normal.", + "1x switch (172.22.150.112) missing serialNumber." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "switchRole": "spine", + "systemMode": "Normal" + }, + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.113", + "logicalName": "cvd-1212-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGD0", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00210a": { + "TEST_NOTES": [ + "1x switches, with systemMode == normal." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00220a": { + "TEST_NOTES": [ + "2x switches, both with systemMode == normal." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + }, + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.113", + "logicalName": "cvd-1212-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGD0", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00230a": { + "TEST_NOTES": [ + "1x switch, missing ipAddress key." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + }, + "test_bootflash_query_01000a": { + "TEST_NOTES": [ + "2x switches, both with systemMode == normal." + ], + "DATA": [ + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.112", + "logicalName": "cvd-1211-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGCS", + "switchRole": "spine", + "systemMode": "Normal" + }, + { + "fabricName": "VXLAN_EVPN_Fabric", + "freezeMode": null, + "ipAddress": "172.22.150.113", + "logicalName": "cvd-1212-spine", + "mode": "Normal", + "model": "N9K-C9504", + "monitorMode": null, + "serialNumber": "FOX2109PGD0", + "switchRole": "spine", + "systemMode": "Normal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/inventory/allswitches", + "RETURN_CODE": 200 + } +} diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashDiscovery.json b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashDiscovery.json new file mode 100644 index 000000000..b40aaac1d --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashDiscovery.json @@ -0,0 +1,75 @@ +{ + "TEST_NOTES": [ + "Mocked EpBootflashDiscovery() responses for tests/unit/modules/dcnm/dcnm_bootflash" + ], + "test_bootflash_deleted_01000a": { + "DATA": "ok", + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/bootflash-discovery?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_deleted_01000b": { + "DATA": "ok", + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/bootflash-discovery?serialNumber=FOX2109PGD0", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00100a": { + "DATA": "ok", + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/bootflash-discovery?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00100b": { + "DATA": "ok", + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/bootflash-discovery?serialNumber=FOX2109PGD0", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00210a": { + "DATA": "ok", + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/bootflash-discovery?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00220a": { + "DATA": "ok", + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/bootflash-discovery?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00220b": { + "DATA": "ok", + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/bootflash-discovery?serialNumber=FOX2109PGD0", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00230a": { + "DATA": "ok", + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/bootflash-discovery?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_query_01000a": { + "DATA": "ok", + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/bootflash-discovery?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_query_01000b": { + "DATA": "ok", + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/discovery/bootflash-discovery?serialNumber=FOX2109PGD0", + "RETURN_CODE": 200 + } +} diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashFiles.json b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashFiles.json new file mode 100644 index 000000000..2b3c14c23 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashFiles.json @@ -0,0 +1,21 @@ +{ + "TEST_NOTES": [ + "Mocked EpBootflashFiles() responses for tests/unit/modules/dcnm/dcnm_bootflash" + ], + "test_bootflash_deleted_01000a": { + "DATA": "File(s) Deleted Successfully. \nDeleted files: [black.txt][air.txt]", + "MESSAGE": "OK", + "METHOD": "DELETE", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-files", + "RETURN_CODE": 200, + "sequence_number": 1 + }, + "test_bootflash_files_00100a": { + "DATA": "File(s) Deleted Successfully. \nDeleted files: [black.txt][air.txt]", + "MESSAGE": "OK", + "METHOD": "DELETE", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-files", + "RETURN_CODE": 200, + "sequence_number": 1 + } +} diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashInfo.json b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashInfo.json new file mode 100644 index 000000000..474be04c1 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/responses_EpBootflashInfo.json @@ -0,0 +1,708 @@ +{ + "TEST_NOTES": [ + "Mocked EpBootflashInfo() responses for tests/unit/modules/dcnm/dcnm_bootflash" + ], + "test_bootflash_deleted_01000a": { + "TEST_NOTES": [ + "ipAddr is 172.22.150.112", + "bootflash_type is active" + ], + "DATA": { + "bootFlashDataMap": { + "bootflash:": [ + { + "bootflash_type": "active", + "date": "Aug 08 22:50:37 2024", + "deviceName": "cvd-1211-spine", + "fileName": "air.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2" + } + ] + }, + "bootFlashSpaceMap": { + "bootflash:": { + "bootflash_type": "active", + "deviceName": "cvd-1211-spine", + "freeSpace": 12995166208, + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "totalSpace": 21685153792, + "usedSpace": 8689987584 + } + }, + "partitions": [ + "bootflash:" + ], + "requiredSpace": "NA" + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_deleted_01000b": { + "TEST_NOTES": [ + "ipAddr is 172.22.150.113", + "bootflash_type is active" + ], + "DATA": { + "bootFlashDataMap": { + "bootflash:": [ + { + "bootflash_type": "active", + "date": "Sep 12 02:55:23 2022", + "deviceName": "cvd-1212-spine", + "fileName": "black.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "2" + } + ] + }, + "bootFlashSpaceMap": { + "bootflash:": { + "bootflash_type": "active", + "deviceName": "cvd-1212-spine", + "freeSpace": 12995166208, + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "totalSpace": 21685153792, + "usedSpace": 8689987584 + } + }, + "partitions": [ + "bootflash:" + ], + "requiredSpace": "NA" + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGD0", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00100a": { + "DATA": { + "bootFlashDataMap": { + "bootflash:": [ + { + "bootflash_type": "active", + "date": "Aug 06 17:10:08 2024", + "deviceName": "cvd-1211-spine", + "fileName": "EASY-SW-RUNNING-CONFIG", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "4048" + }, + { + "bootflash_type": "active", + "date": "Aug 06 17:10:18 2024", + "deviceName": "cvd-1211-spine", + "fileName": "EASY_SW_INTF_BRIEF", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "5773" + }, + { + "bootflash_type": "active", + "date": "May 18 21:25:10 2023", + "deviceName": "cvd-1211-spine", + "fileName": "INTF_BRIEF_CONFIG", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "5773" + }, + { + "bootflash_type": "active", + "date": "May 18 21:26:15 2023", + "deviceName": "cvd-1211-spine", + "fileName": "MIN-EASY-SW-RUNNING-CONFIG", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "3795" + }, + { + "bootflash_type": "active", + "date": "Aug 08 22:50:37 2024", + "deviceName": "cvd-1211-spine", + "fileName": "air.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2" + }, + { + "bootflash_type": "active", + "date": "Oct 06 01:53:35 2023", + "deviceName": "cvd-1211-spine", + "fileName": "bios_daemon.dbg", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "72300" + }, + { + "bootflash_type": "active", + "date": "Aug 08 22:50:37 2024", + "deviceName": "cvd-1211-spine", + "fileName": "earth.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2" + }, + { + "bootflash_type": "active", + "date": "Aug 06 17:10:25 2024", + "deviceName": "cvd-1211-spine", + "fileName": "ef-minimum-config", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2794" + }, + { + "bootflash_type": "active", + "date": "Aug 08 22:50:37 2024", + "deviceName": "cvd-1211-spine", + "fileName": "fire.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2" + }, + { + "bootflash_type": "active", + "date": "Sep 19 22:19:55 2023", + "deviceName": "cvd-1211-spine", + "fileName": "n9000-epld.10.2.5.M.img", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "218233885" + }, + { + "bootflash_type": "active", + "date": "Sep 19 22:17:44 2023", + "deviceName": "cvd-1211-spine", + "fileName": "nxos64-cs.10.2.5.M.bin", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "1943380992" + }, + { + "bootflash_type": "active", + "date": "Dec 21 02:16:22 2022", + "deviceName": "cvd-1211-spine", + "fileName": "nxos64-cs.10.3.2.F.bin", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2297351168" + }, + { + "bootflash_type": "active", + "date": "Aug 08 22:50:37 2024", + "deviceName": "cvd-1211-spine", + "fileName": "water.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2" + } + ] + }, + "bootFlashSpaceMap": { + "bootflash:": { + "bootflash_type": "active", + "deviceName": "cvd-1211-spine", + "freeSpace": 12995166208, + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "totalSpace": 21685153792, + "usedSpace": 8689987584 + } + }, + "partitions": [ + "bootflash:" + ], + "requiredSpace": "NA" + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00100b": { + "DATA": { + "bootFlashDataMap": { + "bootflash:": [ + { + "bootflash_type": "active", + "date": "Aug 06 17:11:22 2024", + "deviceName": "cvd-1212-spine", + "fileName": "EASY-SW-RUNNING-CONFIG", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "6258" + }, + { + "bootflash_type": "active", + "date": "Aug 06 17:11:32 2024", + "deviceName": "cvd-1212-spine", + "fileName": "EASY_SW_INTF_BRIEF", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "6072" + }, + { + "bootflash_type": "active", + "date": "Apr 11 21:56:31 2022", + "deviceName": "cvd-1212-spine", + "fileName": "INTF_BRIEF_CONFIG", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "6072" + }, + { + "bootflash_type": "active", + "date": "Apr 11 21:58:09 2022", + "deviceName": "cvd-1212-spine", + "fileName": "MIN-EASY-SW-RUNNING-CONFIG", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "3484" + }, + { + "bootflash_type": "active", + "date": "Oct 06 01:53:26 2023", + "deviceName": "cvd-1212-spine", + "fileName": "bios_daemon.dbg", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "58396" + }, + { + "bootflash_type": "active", + "date": "Aug 08 22:50:28 2024", + "deviceName": "cvd-1212-spine", + "fileName": "black.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "12043" + }, + { + "bootflash_type": "active", + "date": "Aug 08 22:51:28 2024", + "deviceName": "cvd-1212-spine", + "fileName": "blue.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "2" + }, + { + "bootflash_type": "active", + "date": "Aug 06 17:11:39 2024", + "deviceName": "cvd-1212-spine", + "fileName": "ef-minimum-config", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "2767" + }, + { + "bootflash_type": "active", + "date": "Aug 08 22:52:28 2024", + "deviceName": "cvd-1212-spine", + "fileName": "green.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "2" + }, + { + "bootflash_type": "active", + "date": "Aug 06 17:14:09 2024", + "deviceName": "cvd-1212-spine", + "fileName": "log_profile.yaml", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "2566" + }, + { + "bootflash_type": "active", + "date": "Sep 19 22:20:07 2023", + "deviceName": "cvd-1212-spine", + "fileName": "n9000-epld.10.2.5.M.img", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "218233885" + }, + { + "bootflash_type": "active", + "date": "Nov 07 20:42:40 2023", + "deviceName": "cvd-1212-spine", + "fileName": "n9000-epld.10.3.1.F.img", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "266383517" + }, + { + "bootflash_type": "active", + "date": "Sep 19 22:17:57 2023", + "deviceName": "cvd-1212-spine", + "fileName": "nxos64-cs.10.2.5.M.bin", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "1943380992" + }, + { + "bootflash_type": "active", + "date": "Nov 07 20:41:41 2023", + "deviceName": "cvd-1212-spine", + "fileName": "nxos64-cs.10.3.1.F.bin", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "2077222400" + }, + { + "bootflash_type": "active", + "date": "Aug 08 22:53:28 2024", + "deviceName": "cvd-1212-spine", + "fileName": "red.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "2" + } + ] + }, + "bootFlashSpaceMap": { + "bootflash:": { + "bootflash_type": "active", + "deviceName": "cvd-1212-spine", + "freeSpace": 10630565888, + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "totalSpace": 21685153792, + "usedSpace": 11054587904 + } + }, + "partitions": [ + "bootflash:" + ], + "requiredSpace": "NA" + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGD0", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00210a": { + "DATA": { + "bootFlashDataMap": { + "bootflash:": [ + { + "bootflash_type": "active", + "date": "Aug 08 22:50:37 2024", + "deviceName": "cvd-1211-spine", + "fileName": "air.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2" + } + ] + }, + "bootFlashSpaceMap": { + "bootflash:": { + "bootflash_type": "active", + "deviceName": "cvd-1212-spine", + "freeSpace": 10630565888, + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "totalSpace": 21685153792, + "usedSpace": 11054587904 + } + }, + "partitions": [ + "bootflash:" + ], + "requiredSpace": "NA" + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00220a": { + "TEST_NOTES": [ + "ipAddr is 172.22.150.112", + "bootflash_type is active" + ], + "DATA": { + "bootFlashDataMap": { + "bootflash:": [ + { + "bootflash_type": "active", + "date": "Aug 08 22:50:37 2024", + "deviceName": "cvd-1211-spine", + "fileName": "air.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2" + } + ] + }, + "bootFlashSpaceMap": { + "bootflash:": { + "bootflash_type": "active", + "deviceName": "cvd-1211-spine", + "freeSpace": 12995166208, + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "totalSpace": 21685153792, + "usedSpace": 8689987584 + } + }, + "partitions": [ + "bootflash:" + ], + "requiredSpace": "NA" + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00220b": { + "TEST_NOTES": [ + "ipAddr is 172.22.150.112", + "bootflash_type is active" + ], + "DATA": { + "bootFlashDataMap": { + "bootflash:": [ + { + "bootflash_type": "active", + "date": "Aug 08 22:50:28 2024", + "deviceName": "cvd-1212-spine", + "fileName": "black.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "12043" + } + ] + }, + "bootFlashSpaceMap": { + "bootflash:": { + "bootflash_type": "active", + "deviceName": "cvd-1212-spine", + "freeSpace": 10630565888, + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "totalSpace": 21685153792, + "usedSpace": 11054587904 + } + }, + "partitions": [ + "bootflash:" + ], + "requiredSpace": "NA" + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGD0", + "RETURN_CODE": 200 + }, + "test_bootflash_info_00230a": { + "TEST_NOTES": [ + "ipAddr is 172.22.150.112", + "bootflash_type is active" + ], + "DATA": { + "bootFlashDataMap": { + "bootflash:": [ + { + "bootflash_type": "active", + "date": "Aug 08 22:50:37 2024", + "deviceName": "cvd-1211-spine", + "fileName": "air.txt", + "filePath": "bootflash:", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2" + } + ] + }, + "bootFlashSpaceMap": { + "bootflash:": { + "bootflash_type": "active", + "deviceName": "cvd-1211-spine", + "freeSpace": 12995166208, + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "totalSpace": 21685153792, + "usedSpace": 8689987584 + } + }, + "partitions": [ + "bootflash:" + ], + "requiredSpace": "NA" + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_query_01000a": { + "TEST_NOTES": [ + "ipAddr is 172.22.150.112", + "bootflash_type is active" + ], + "DATA": { + "bootFlashDataMap": { + "bootflash:": [ + { + "bootflash_type": "active", + "date": "Aug 08 22:50:37 2024", + "deviceName": "cvd-1211-spine", + "fileName": "air.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "size": "2" + } + ] + }, + "bootFlashSpaceMap": { + "bootflash:": { + "bootflash_type": "active", + "deviceName": "cvd-1211-spine", + "freeSpace": 12995166208, + "ipAddr": " 172.22.150.112", + "name": "bootflash:", + "serialNumber": "FOX2109PGCS", + "totalSpace": 21685153792, + "usedSpace": 8689987584 + } + }, + "partitions": [ + "bootflash:" + ], + "requiredSpace": "NA" + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGCS", + "RETURN_CODE": 200 + }, + "test_bootflash_query_01000b": { + "TEST_NOTES": [ + "ipAddr is 172.22.150.113", + "bootflash_type is active" + ], + "DATA": { + "bootFlashDataMap": { + "bootflash:": [ + { + "bootflash_type": "active", + "date": "Sep 12 02:55:23 2022", + "deviceName": "cvd-1212-spine", + "fileName": "black.txt", + "filePath": "bootflash:", + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "size": "2" + } + ] + }, + "bootFlashSpaceMap": { + "bootflash:": { + "bootflash_type": "active", + "deviceName": "cvd-1212-spine", + "freeSpace": 12995166208, + "ipAddr": " 172.22.150.113", + "name": "bootflash:", + "serialNumber": "FOX2109PGD0", + "totalSpace": 21685153792, + "usedSpace": 8689987584 + } + }, + "partitions": [ + "bootflash:" + ], + "requiredSpace": "NA" + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/imagemanagement/rest/imagemgnt/bootFlash/bootflash-info?serialNumber=FOX2109PGD0", + "RETURN_CODE": 200 + } +} diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/targets.json b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/targets.json new file mode 100644 index 000000000..d9d920178 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/targets.json @@ -0,0 +1,108 @@ +{ + "TEST_NOTES": [ + "Target dictionaries for dcnm_bootflash unit tests.", + "tests/unit/modules/dcnm/dcnm_bootflash" + ], + "test_bootflash_files_00100a": { + "filepath": "bootflash:/black.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active" + }, + "test_bootflash_files_00100b": { + "filepath": "bootflash:/air.txt", + "ip_address": "172.22.150.113", + "serial_number": "FOX2109PGD0", + "supervisor": "active" + }, + "test_bootflash_files_00200a": { + "filepath": "bootflash:/air.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active" + }, + "test_bootflash_files_00200b": { + "filepath": "bootflash:/earth.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGD0", + "supervisor": "active" + }, + "test_bootflash_files_00210a": { + "filepath": "bootflash:/air.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active" + }, + "test_bootflash_files_00210b": { + "filepath": "bootflash:/black.txt", + "ip_address": "172.22.150.113", + "serial_number": "FOX2109PGD0", + "supervisor": "active" + }, + "test_bootflash_files_00220a": { + "filepath": "bootflash:/black.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active" + }, + "test_bootflash_files_00230a": { + "filepath": "bootflash:/air.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active" + }, + "test_bootflash_files_00240a": { + "filepath": "bootflash:/air.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active" + }, + "test_bootflash_files_00250a": { + "filepath": "bootflash:/air.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active" + }, + "test_bootflash_files_00260a": { + "filepath": "bootflash:/air.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active" + }, + "test_bootflash_files_00270a": { + "filepath": "bootflash:/air.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active" + }, + "test_bootflash_files_00810a": { + "filepath": "bootflash:/air.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active" + }, + "test_convert_target_to_params_00100a": { + "filepath": "bootflash:/air.txt", + "supervisor": "active" + }, + "test_convert_target_to_params_00110a": { + "filepath": "bootflash:/foo/air.txt", + "supervisor": "active" + }, + "test_convert_target_to_params_00200a": { + "bad_file_path_key": "bootflash:/foo/air.txt", + "supervisor": "active" + }, + "test_convert_target_to_params_00210a": { + "filepath": "bootflash:/air.txt", + "bad_supervisor_key": "active" + }, + "test_convert_target_to_params_00510a": { + "filepath": "bootflash/air.txt", + "supervisor": "actove" + }, + "test_convert_target_to_params_00610a": { + "filepath": "bootflash:/air.txt", + "supervisor": "bad_supervisor_value" + } +} diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/targets_ConvertFileInfoToTarget.json b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/targets_ConvertFileInfoToTarget.json new file mode 100644 index 000000000..84802c81d --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/fixtures/targets_ConvertFileInfoToTarget.json @@ -0,0 +1,15 @@ +{ + "TEST_NOTES": [ + "target dictionaries used for ConvertFileInfoToTarget() unit test asserts.", + "tests/unit/modules/dcnm/dcnm_bootflash/test_convert_file_info_to_target.py" + ], + "test_convert_file_info_to_target_00100a": { + "date": "2023-09-19 22:20:07", + "device_name": "cvd-1212-spine", + "filepath": "bootflash:/n9000-epld.10.2.5.M.img", + "ip_address": "192.168.1.1", + "serial_number": "BDY3814QDD0", + "size": "218233885", + "supervisor": "active" + } +} diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_common.py b/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_common.py new file mode 100644 index 000000000..27c369e43 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_common.py @@ -0,0 +1,419 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# Also, fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-import, protected-access, use-implicit-booleaness-not-comparison, unused-variable + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import copy +import inspect + +import pytest +from ansible_collections.cisco.dcnm.plugins.modules.dcnm_bootflash import \ + Common +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_bootflash.utils import ( + configs_query, does_not_raise, params_deleted, params_query) + + +def test_bootflash_common_00000() -> None: + """ + ### Classes and Methods + - Common() + - __init__() + + ### Summary + __init__() happy path with minimal config. + - Verify class attributes are initialized to expected values. + + ### Test + - Class attributes are initialized to expected values. + - Exceptions are not not raised. + """ + with does_not_raise(): + instance = Common(params_deleted) + assert instance.bootflash_info.class_name == "BootflashInfo" + assert instance.params == params_deleted + assert instance.check_mode is False + assert instance.config == params_deleted.get("config") + assert instance.convert_target_to_params.class_name == "ConvertTargetToParams" + assert instance._rest_send is None + assert instance.results.class_name == "Results" + assert instance.results.check_mode is False + assert instance.results.state == "deleted" + assert instance.state == "deleted" + assert instance.switches == [{"ip_address": "192.168.1.2"}] + assert instance.targets == params_deleted.get("config", {}).get("targets", []) + assert instance.want == [] + assert instance._valid_states == ["deleted", "query"] + + +def test_bootflash_common_00010() -> None: + """ + ### Classes and Methods + - Common() + - __init__() + + ### Summary + ``params`` is missing ``check_mode`` key. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + params = copy.deepcopy(params_deleted) + params.pop("check_mode") + match = r"Common\.__init__:\s+" + match += r"params is missing mandatory key: check_mode\." + with pytest.raises(ValueError, match=match): + instance = Common(params) + + +def test_bootflash_common_00020() -> None: + """ + ### Classes and Methods + - Common() + - __init__() + + ### Summary + ``params`` contains invalid value for ``check_mode``. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + params = copy.deepcopy(params_deleted) + params["check_mode"] = "foo" + match = r"Common\.__init__:\s+" + match += r"check_mode must be True or False\. Got foo\." + with pytest.raises(ValueError, match=match): + instance = Common(params) + + +def test_bootflash_common_00030() -> None: + """ + ### Classes and Methods + - Common() + - __init__() + + ### Summary + ``params`` is missing ``state`` key. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + params = copy.deepcopy(params_deleted) + params.pop("state") + match = r"Common\.__init__:\s+" + match += r"params is missing mandatory key: state\." + with pytest.raises(ValueError, match=match): + instance = Common(params) + + +def test_bootflash_common_00040() -> None: + """ + ### Classes and Methods + - Common() + - __init__() + + ### Summary + ``params`` contains invalid ``state`` key. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + params = copy.deepcopy(params_deleted) + params["state"] = "foo" + match = r"Common.__init__:\s+" + match += r"Invalid state: foo\. Expected one of: deleted,query\." + with pytest.raises(ValueError, match=match): + instance = Common(params) + + +def test_bootflash_common_00050() -> None: + """ + ### Classes and Methods + - Common() + - __init__() + + ### Summary + ``params`` contains invalid ``config`` key. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + params = copy.deepcopy(params_deleted) + params["config"] = "foo" + match = r"Common.__init__:\s+" + match += r"Expected dict for config\. Got str\." + with pytest.raises(ValueError, match=match): + instance = Common(params) + + +def test_bootflash_common_00060() -> None: + """ + ### Classes and Methods + - Common() + - __init__() + + ### Summary + ``params.config.targets`` is missing. + + ### Test + - ``ValueError`` is not raised. + - ``targets`` is initialized to an empty list. + """ + params = copy.deepcopy(params_deleted) + params["config"].pop("targets") + with does_not_raise(): + instance = Common(params) + assert instance.targets == [] + + +def test_bootflash_common_00070() -> None: + """ + ### Classes and Methods + - Common() + - __init__() + + ### Summary + ``params`` ``targets`` key is not a list of dict. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + params = copy.deepcopy(params_deleted) + params["config"]["targets"] = ["foo"] + match = r"Common.__init__:\s+" + match += r"Expected list of dict for params\.config\.targets\.\s+" + match += r"Got list element of type str\." + with pytest.raises(ValueError, match=match): + instance = Common(params) + + +def test_bootflash_common_00080() -> None: + """ + ### Classes and Methods + - Common() + - __init__() + + ### Summary + ``params.config.switches`` is not a list. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + params = copy.deepcopy(params_deleted) + params["config"]["switches"] = "foo" + match = r"Common.__init__:\s+" + match += r"Expected list of dict for params\.config\.switches\. Got str\." + with pytest.raises(ValueError, match=match): + instance = Common(params) + + +def test_bootflash_common_00090() -> None: + """ + ### Classes and Methods + - Common() + - __init__() + + ### Summary + ``params`` ``switches`` key is not a list of dict. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + params = copy.deepcopy(params_deleted) + params["config"]["switches"] = ["foo"] + match = r"Common.__init__:\s+" + match += r"Expected list of dict for params\.config\.switches\.\s+" + match += r"Got list element of type str\." + with pytest.raises(ValueError, match=match): + instance = Common(params) + + +def test_bootflash_common_00200() -> None: + """ + ### Classes and Methods + - Common() + - get_want() + + ### Summary + - Verify get_want() happy path with minimal config. + + ### Test + - instance.want matches expectation. + - Exceptions are not not raised. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + params = copy.deepcopy(params_query) + params["config"] = configs_query(key) + with does_not_raise(): + instance = Common(params) + instance.get_want() + assert instance.want == [ + { + "ip_address": "192.168.1.2", + "targets": [{"filepath": "bootflash:/foo.txt", "supervisor": "active"}], + } + ] + + +def test_bootflash_common_00210() -> None: + """ + ### Classes and Methods + - Common() + - get_want() + + ### Summary + Verify ``get_want()`` behavior when a dictionary in the switches + list is missing the ``ip_address`` parameter. + + ### Setup + - ``configs_query`` contains ``switches[0].ip_address_misspelled`` + rather than the expected ``switches[0].ip_address``. + + ### Test + - ValueError is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + params = copy.deepcopy(params_query) + params["config"] = configs_query(key) + with does_not_raise(): + instance = Common(params) + match = r"Common.get_want:\s+" + match += r"Expected ip_address in switch dict\.\s+" + match += r"Got.*ip_address_misspelled.*\." + with pytest.raises(ValueError, match=match): + instance.get_want() + + +def test_bootflash_common_00220() -> None: + """ + ### Classes and Methods + - Common() + - get_want() + + ### Summary + Verify ``get_want()`` behavior when a switch in config.switches contains + an invalid ``targets`` parameter value. + + ### Setup + - ``configs_query`` contains a switch with local ``targets`` parameter, + and that parameter is a string rather than a list, i.e. + switches[0].targets = "NOT_A_LIST". + + ### Test + - TypeError is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + params = copy.deepcopy(params_query) + params["config"] = configs_query(key) + with does_not_raise(): + instance = Common(params) + match = r"Common.get_want:\s+" + match += r"Expected list of dictionaries for switch\['targets'\]\.\s+" + match += r"Got str\." + with pytest.raises(TypeError, match=match): + instance.get_want() + + +def test_bootflash_common_00230() -> None: + """ + ### Classes and Methods + - Common() + - get_want() + + ### Summary + Verify ``get_want()`` behavior when a switch in config.switches contains + a ``targets`` parameter value that is missing the ``filepath`` key. + + ### Setup + - ``configs_query`` contains a switch with local ``targets`` parameter, + and that parameter is a list, but a dictionary in the list has + misspelled ``filepath`` as ``filepath_misspelled`` i.e. + ``switches[0].targets[0].filepath_misspelled``. + + ### Test + - ValueError is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + params = copy.deepcopy(params_query) + params["config"] = configs_query(key) + with does_not_raise(): + instance = Common(params) + match = r"Common.get_want:\s+" + match += r"Expected filepath in target dict\.\s+" + match += r"Got.*filepath_misspelled.*\." + with pytest.raises(ValueError, match=match): + instance.get_want() + + +def test_bootflash_common_00240() -> None: + """ + ### Classes and Methods + - Common() + - get_want() + + ### Summary + Verify ``get_want()`` behavior when a switch in config.switches contains + a ``targets`` parameter value that is missing the ``supervisor`` key. + + ### Setup + - ``configs_query`` contains a switch with local ``targets`` parameter, + and that parameter is a list of dict, but a dictionary in the list has + misspelled ``supervisor`` as ``supervisor_misspelled`` i.e. + ``switches[0].targets[0].supervisor_misspelled``. + + ### Test + - ValueError is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + params = copy.deepcopy(params_query) + params["config"] = configs_query(key) + with does_not_raise(): + instance = Common(params) + match = r"Common.get_want:\s+" + match += r"Expected supervisor in target dict\.\s+" + match += r"Got.*supervisor_misspelled.*\." + with pytest.raises(ValueError, match=match): + instance.get_want() diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_deleted.py b/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_deleted.py new file mode 100644 index 000000000..5d4c6e25b --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_deleted.py @@ -0,0 +1,413 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# Also, fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-import, protected-access, use-implicit-booleaness-not-comparison, unused-variable + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import copy +import inspect +import json + +import pytest +from ansible_collections.cisco.dcnm.plugins.module_utils.common.response_handler import \ + ResponseHandler +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_v2 import \ + RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.common.sender_file import \ + Sender +from ansible_collections.cisco.dcnm.plugins.module_utils.common.switch_details import \ + SwitchDetails +from ansible_collections.cisco.dcnm.plugins.modules.dcnm_bootflash import \ + Deleted +from ansible_collections.cisco.dcnm.tests.unit.module_utils.common.common_utils import \ + ResponseGenerator +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_bootflash.utils import ( + MockAnsibleModule, configs_deleted, does_not_raise, params_deleted, + responses_ep_all_switches, responses_ep_bootflash_discovery, + responses_ep_bootflash_files, responses_ep_bootflash_info) + + +def test_bootflash_deleted_00000() -> None: + """ + ### Classes and Methods + - Deleted() + - __init__() + + ### Summary + __init__() happy path with minimal config. + - Verify class attributes are initialized to expected values. + + ### Test + - Class attributes are initialized to expected values. + - Exceptions are not not raised. + """ + with does_not_raise(): + instance = Deleted(params_deleted) + # attributes inherited from Common + assert instance.bootflash_info.class_name == "BootflashInfo" + assert instance.params == params_deleted + assert instance.check_mode is False + assert instance.config == params_deleted.get("config") + assert instance.convert_target_to_params.class_name == "ConvertTargetToParams" + assert instance._rest_send is None + assert instance.results.class_name == "Results" + assert instance.results.check_mode is False + assert instance.results.state == "deleted" + assert instance.state == "deleted" + assert instance.switches == [{"ip_address": "192.168.1.2"}] + assert instance.targets == params_deleted.get("config", {}).get("targets", []) + assert instance.want == [] + assert instance._valid_states == ["deleted", "query"] + # attributes specific to Deleted + assert instance.bootflash_files.class_name == "BootflashFiles" + assert instance.files_to_delete == {} + + +def test_bootflash_deleted_00010() -> None: + """ + ### Classes and Methods + - Deleted() + - __init__() + + ### Summary + ``__init__()`` sad path. ``Common().__init__()`` raises exception. + + ### Test + - ``Deleted().__init__()`` catches exception and + re-raises as ``ValueError``. + - Exception message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + params = copy.deepcopy(params_deleted) + params["config"] = configs_deleted(key) + + match = r"Deleted\.__init__:\s+" + match += r"Error during super\(\)\.__init__\(\)\.\s+" + match += r"Error detail: Deleted\.__init__:\s+" + match += r"Expected list of dict for params\.config\.targets\.\s+" + match += r"Got list element of type str\." + with pytest.raises(ValueError, match=match): + instance = Deleted(params) + + +def test_bootflash_deleted_01000() -> None: + """ + ### Classes and Methods + - Deleted() + - commit() + + ### Summary + - ``Deleted().commit()`` happy path. + - config.targets contains one entry. + - config.switches contains two entries. + + ### Test + - No exceptions are raised. + - asserts are successful. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + params = copy.deepcopy(params_deleted) + params["config"] = configs_deleted(f"{key}a") + + def responses(): + yield responses_ep_all_switches(f"{key}a") + yield responses_ep_bootflash_discovery(f"{key}a") + yield responses_ep_bootflash_info(f"{key}a") + yield responses_ep_bootflash_discovery(f"{key}b") + yield responses_ep_bootflash_info(f"{key}b") + yield responses_ep_all_switches(f"{key}a") + yield responses_ep_bootflash_files(f"{key}a") + + gen_responses = ResponseGenerator(responses()) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = Deleted(params) + instance.rest_send = rest_send + instance.commit() + + assert "File(s) Deleted Successfully." in instance.results.response[0]["DATA"] + assert ( + instance.results.diff[0]["172.22.150.112"][0]["filepath"] + == "bootflash:/air.txt" + ) + assert ( + instance.results.diff[0]["172.22.150.113"][0]["filepath"] + == "bootflash:/black.txt" + ) + assert instance.results.response[0]["MESSAGE"] == "OK" + assert instance.results.response[0]["RETURN_CODE"] == 200 + assert instance.results.result[0]["success"] is True + assert instance.results.result[0]["changed"] is True + + +def test_bootflash_deleted_02000() -> None: + """ + ### Classes and Methods + - Deleted() + - populate_files_to_delete() + + ### Summary + - ``Deleted().populate_files_to_delete()`` sad path. + - BootflashInfo().filter_supervisor raises ValueError. + + ### Test + - ValueError is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + params = copy.deepcopy(params_deleted) + params["config"] = configs_deleted(f"{key}a") + + switch = { + "ip_address": params["config"]["switches"][0]["ip_address"], + "targets": params["config"]["targets"], + } + with does_not_raise(): + instance = Deleted(params) + match = r"Deleted\.populate_files_to_delete:\s+" + match += r"Error assigning BootflashInfo\.filter_supervisor\.\s+" + match += r"Error detail:\s+" + match += r"BootflashInfo\.filter_supervisor.setter:\s+" + match += r"value foo is not a valid value for supervisor\.\s+" + match += r"Valid values: active,standby\." + with pytest.raises(ValueError, match=match): + instance.populate_files_to_delete(switch) + + +@pytest.mark.parametrize("missing_param", ["filepath", "supervisor"]) +def test_bootflash_deleted_03000(missing_param) -> None: + """ + ### Classes and Methods + - Deleted() + - update_bootflash_files() + + ### Summary + - ``Deleted().update_bootflash_files()`` sad path. + - ConvertTargetToParams().parse_target raises ValueError + because target is missing a mandatory key. + + ### Setup + - target is manually constructed and target.pop() is used + to remove mandatory keys. + + ### Test + - ValueError is raised. + - Error message matches expectation. + + ### Notes + 1. We test ip_address parameter in test_bootflash_deleted_03100, since + that raises a different error. + """ + target = { + "filepath": "bootflash:/foo.txt", + "ip_address": "192.168.1.1", + "supervisor": "active", + } + with does_not_raise(): + instance = Deleted(params_deleted) + + target.pop(missing_param) + match = r"Deleted\.update_bootflash_files:\s+" + match += r"Error converting target to params\.\s+" + match += r"Error detail:\s+" + match += r"ConvertTargetToParams\.parse_target:\s+" + match += rf"Expected {missing_param} in target dict\. Got.*\." + with pytest.raises(ValueError, match=match): + instance.update_bootflash_files("192.168.1.1", target) + + +def test_bootflash_deleted_03100() -> None: + """ + ### Classes and Methods + - Deleted() + - update_bootflash_files() + + ### Summary + - ``Deleted().update_bootflash_files()`` sad path. + - ConvertTargetToParams().parse_target raises ValueError + because target is missing a mandatory key. + + ### Setup + - target is manually constructed without ip_address key. + + ### Test + - ValueError is raised. + - Error message matches expectation. + """ + target = {"filepath": "bootflash:/foo.txt", "supervisor": "active"} + with does_not_raise(): + instance = Deleted(params_deleted) + + match = r"Deleted\.update_bootflash_files:\s+" + match += r"Error assigning BootflashFiles properties\.\s+" + match += r"Error detail:\s+" + match += r"BootflashFiles\.target:\s+" + match += r"ip_address key missing from value .*\." + with pytest.raises(ValueError, match=match): + instance.update_bootflash_files("192.168.1.1", target) + + +def test_bootflash_deleted_03200() -> None: + """ + ### Classes and Methods + - Deleted() + - update_bootflash_files() + + ### Summary + - ``Deleted().update_bootflash_files()`` sad path. + - BootflashFiles().add_file() raises ValueError + because target switch mode is "Migration". + + ### Setup + - A valid ``target`` is manually constructed. + - ``responses_ep_all_switches`` returns a switch in "Migration" mode. + + ### Test + - update_bootflash_files() re-raises ``ValueError`` + raised by ``BootflashFiles().add_file()``. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + params = copy.deepcopy(params_deleted) + params["config"] = configs_deleted(f"{key}a") + + def responses(): + yield responses_ep_all_switches(f"{key}a") + + gen_responses = ResponseGenerator(responses()) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + target = { + "filepath": "bootflash:/foo.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active", + } + + with does_not_raise(): + instance = Deleted(params) + instance.rest_send = rest_send + instance.bootflash_files.switch_details = SwitchDetails() + instance.bootflash_files.rest_send = rest_send + instance.bootflash_files.switch_details.results = Results() + match = r"Deleted\.update_bootflash_files:\s+" + match += r"Error adding file to bootflash_files\.\s+" + match += r"Error detail:\s+" + match += r"BootflashFiles\.add_file:\s+" + match += r"Cannot delete files on switch 172\.22\.150\.112\.\s+" + match += r"Reason: switch mode is migration." + + with pytest.raises(ValueError, match=match): + instance.update_bootflash_files("172.22.150.112", target) + + +def test_bootflash_deleted_03210() -> None: + """ + ### Classes and Methods + - Deleted() + - update_bootflash_files() + + ### Summary + - ``Deleted().update_bootflash_files()`` sad path. + - BootflashFiles().add_file() raises ValueError + because target switch mode is "Inconsistent". + + ### Setup + - A valid ``target`` is manually constructed. + - ``responses_ep_all_switches`` returns a switch in "Inconsistent" mode. + + ### Test + - update_bootflash_files() re-raises ``ValueError`` + raised by ``BootflashFiles().add_file()``. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + params = copy.deepcopy(params_deleted) + params["config"] = configs_deleted(f"{key}a") + + def responses(): + yield responses_ep_all_switches(f"{key}a") + + gen_responses = ResponseGenerator(responses()) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + target = { + "filepath": "bootflash:/foo.txt", + "ip_address": "172.22.150.112", + "serial_number": "FOX2109PGCS", + "supervisor": "active", + } + + with does_not_raise(): + instance = Deleted(params) + instance.rest_send = rest_send + instance.bootflash_files.switch_details = SwitchDetails() + instance.bootflash_files.rest_send = rest_send + instance.bootflash_files.switch_details.results = Results() + match = r"Deleted\.update_bootflash_files:\s+" + match += r"Error adding file to bootflash_files\.\s+" + match += r"Error detail:\s+" + match += r"BootflashFiles\.add_file:\s+" + match += r"Cannot delete files on switch 172\.22\.150\.112\.\s+" + match += r"Reason: switch mode is inconsistent." + + with pytest.raises(ValueError, match=match): + instance.update_bootflash_files("172.22.150.112", target) diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_files.py b/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_files.py new file mode 100644 index 000000000..a969e9d44 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_files.py @@ -0,0 +1,1033 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# Also, fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-import, protected-access + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import copy +import inspect + +import pytest +from ansible_collections.cisco.dcnm.plugins.module_utils.bootflash.bootflash_files import \ + BootflashFiles +from ansible_collections.cisco.dcnm.plugins.module_utils.bootflash.convert_target_to_params import \ + ConvertTargetToParams +from ansible_collections.cisco.dcnm.plugins.module_utils.common.response_handler import \ + ResponseHandler +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_v2 import \ + RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.common.sender_file import \ + Sender +from ansible_collections.cisco.dcnm.plugins.module_utils.common.switch_details import \ + SwitchDetails +from ansible_collections.cisco.dcnm.tests.unit.module_utils.common.common_utils import \ + ResponseGenerator +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_bootflash.utils import ( + MockAnsibleModule, configs_deleted, does_not_raise, params_deleted, + payloads_bootflash_files, responses_ep_all_switches, + responses_ep_bootflash_files, targets) + + +def test_bootflash_files_00000() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - __init__() + + ### Summary + - Verify class attributes are initialized to expected values. + + ### Test + - Class attributes are initialized to expected values. + - Exceptions are not not raised. + """ + with does_not_raise(): + instance = BootflashFiles() + assert instance.action == "bootflash_delete" + assert instance.class_name == "BootflashFiles" + assert instance.conversion.class_name == "ConversionUtils" + assert instance.diff == {} # pylint: disable=use-implicit-booleaness-not-comparison + assert instance.ep_bootflash_files.class_name == "EpBootflashFiles" + assert instance.mandatory_target_keys == [ + "filepath", + "ip_address", + "serial_number", + "supervisor", + ] + assert instance.ok_to_delete_files_reason is None + assert instance.payload == {"deleteFiles": []} + assert instance.filename is None + assert instance.filepath is None + assert instance.ip_address is None + assert instance.partition is None + assert instance._rest_send is None + assert instance._results is None + assert instance.supervisor is None + assert instance.switch_details is None + assert instance.target is None + + +def test_bootflash_files_00100() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - commit() + - validate_commit_parameters() + + ### Summary + - Verify commit happy path. + + ### Test + - Add two files, one for each of two switches, to be deleted. + - commit is successful. + - Exceptions are not raised. + - Responses match expectations. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + def configs(): + yield configs_deleted(f"{key}a") + + gen_configs = ResponseGenerator(configs()) + + def yield_targets(): + yield targets(f"{key}a") + yield targets(f"{key}b") + + gen_targets = ResponseGenerator(yield_targets()) + + def responses(): + yield responses_ep_all_switches(f"{key}a") + yield responses_ep_bootflash_files(f"{key}a") + + gen_responses = ResponseGenerator(responses()) + + params = copy.deepcopy(params_deleted) + params.update({"config": gen_configs.next}) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switch_details.results = Results() + + convert_target = ConvertTargetToParams() + convert_target.target = gen_targets.next + convert_target.commit() + + instance.filepath = convert_target.filepath + instance.filename = convert_target.filename + instance.ip_address = "172.22.150.112" + instance.partition = convert_target.partition + instance.supervisor = convert_target.supervisor + instance.target = convert_target.target + instance.add_file() + + convert_target = ConvertTargetToParams() + convert_target.target = gen_targets.next + convert_target.commit() + + instance.filepath = convert_target.filepath + instance.filename = convert_target.filename + instance.ip_address = "172.22.150.113" + instance.partition = convert_target.partition + instance.supervisor = convert_target.supervisor + instance.target = convert_target.target + instance.add_file() + + instance.commit() + + assert instance.payload == payloads_bootflash_files(f"{key}a") + assert instance.results.response_current["RETURN_CODE"] == 200 + assert instance.results.result == [ + {"success": True, "changed": True, "sequence_number": 1} + ] + + +def test_bootflash_files_00110() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - commit() + - validate_commit_parameters() + + ### Summary + Verify ``ValueError`` is raised if ``rest_send`` is not set before + calling commit. + + ### Test + - ValueError is raised by validate_commit_parameters(). + - Error message matches expectation. + """ + with does_not_raise(): + instance = BootflashFiles() + instance.results = Results() + instance.switch_details = SwitchDetails() + + match = r"BootflashFiles.validate_commit_parameters:\s+" + match += r"rest_send must be set before calling commit\(\)\." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_bootflash_files_00120() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - commit() + - validate_commit_parameters() + + ### Summary + Verify ``ValueError`` is raised if ``results`` is not set before + calling commit. + + ### Test + - ValueError is raised by validate_commit_parameters(). + - Error message matches expectation. + """ + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = RestSend(params_deleted) + instance.switch_details = SwitchDetails() + + match = r"BootflashFiles.validate_commit_parameters:\s+" + match += r"results must be set before calling commit\(\)\." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_bootflash_files_00130() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - commit() + - validate_commit_parameters() + + ### Summary + Verify ``ValueError`` is raised if ``switch_details`` is not set before + calling commit. + + ### Test + - ValueError is raised by validate_commit_parameters(). + - Error message matches expectation. + """ + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = RestSend(params_deleted) + instance.results = Results() + + match = r"BootflashFiles.validate_commit_parameters:\s+" + match += r"switch_details must be set before calling commit\(\)\." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_bootflash_files_00200() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - add_file() + + ### Summary + Verify that add_file(), when called twice with the same ip_address, + and partition, adds the second file to the payload under the same + serial number (yes, serial number since ip_address is converted to + serial number when the payload is built) and partition as the + first file. + + ### Setup + - Call add_file() twice with same ip_address and partition. + + ### Test + - The second file is added to the payload under the same + serial number and partition. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + def responses(): + yield responses_ep_all_switches(f"{key}a") + + gen_responses = ResponseGenerator(responses()) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params_deleted) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switch_details.results = Results() + + instance.filepath = "bootflash:/air.txt" + instance.filename = "air.txt" + instance.ip_address = "172.22.150.112" + instance.partition = "bootflash:" + instance.supervisor = "active" + instance.target = targets(f"{key}a") + instance.add_file() + + instance.filepath = "bootflash:/earth.txt" + instance.filename = "earth.txt" + instance.ip_address = "172.22.150.112" + instance.partition = "bootflash:" + instance.supervisor = "active" + instance.target = targets(f"{key}b") + instance.add_file() + + assert instance.payload == payloads_bootflash_files(f"{key}a") + + +def test_bootflash_files_00210() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - add_file() + + ### Summary + Verify that add_file(), when called twice with a different ip_address, + and partition, adds the second file under the different + serial number (yes, serial number since ip_address is converted to + serial number when the payload is built) and partition. + + ### Setup + - Call add_file() twice with different ip_address and partition. + - Call add_file() a third time with the same ip_address and partition + as the second call, and with the same filename. This will not + change the payload since it is rejected in + ``add_file_to_existing_payload()``. + + ### Test + - The second file is added to the payload under a different serial + number and partition. + - The third (duplicate) file is not added to the payload. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + def responses(): + yield responses_ep_all_switches(f"{key}a") + + gen_responses = ResponseGenerator(responses()) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params_deleted) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switch_details.results = Results() + + instance.filepath = "bootflash:/air.txt" + instance.filename = "air.txt" + instance.ip_address = "172.22.150.112" + instance.partition = "bootflash:" + instance.supervisor = "active" + instance.target = targets(f"{key}a") + instance.add_file() + + instance.filepath = "bootflash:/black.txt" + instance.filename = "black.txt" + instance.ip_address = "172.22.150.113" + instance.partition = "bootflash:" + instance.supervisor = "active" + instance.target = targets(f"{key}b") + instance.add_file() + + # Try to add the same file again. This will not change the payload since + # it is rejected in add_file_to_existing_payload(). + instance.filepath = "bootflash:/black.txt" + instance.filename = "black.txt" + instance.ip_address = "172.22.150.113" + instance.partition = "bootflash:" + instance.supervisor = "active" + instance.target = targets(f"{key}b") + instance.add_file() + + assert instance.payload == payloads_bootflash_files(f"{key}a") + + +@pytest.mark.parametrize( + "key_responses_ep_all_switches, reason", + [ + ("a", "migration"), + ("b", "inconsistent"), + ], +) +def test_bootflash_files_00220(key_responses_ep_all_switches, reason) -> None: + """ + ### Classes and Methods + - BootflashFiles() + - add_file() + + ### Summary + Verify add_file() raises ValueError if switch mode is either + "migration" or "inconsistent". + + ### Test + - Call add_file() for switch that does not support file deletion + due to being in migration (key == a) or inconsistent (key == b) + mode. + - ValueError is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + def configs(): + yield configs_deleted(f"{key}a") + + gen_configs = ResponseGenerator(configs()) + + def yield_targets(): + yield targets(f"{key}a") + + gen_targets = ResponseGenerator(yield_targets()) + + derived_key = f"{key}{key_responses_ep_all_switches}" + + def responses(): + yield responses_ep_all_switches(derived_key) + + gen_responses = ResponseGenerator(responses()) + + params = copy.deepcopy(params_deleted) + params.update({"config": gen_configs.next}) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switch_details.results = Results() + + convert_target = ConvertTargetToParams() + convert_target.target = gen_targets.next + convert_target.commit() + + instance.filepath = convert_target.filepath + instance.filename = convert_target.filename + instance.ip_address = "172.22.150.112" + instance.partition = convert_target.partition + instance.supervisor = convert_target.supervisor + instance.target = convert_target.target + + match = r"BootflashFiles\.add_file:\s+" + match += r"Cannot delete files on switch 172\.22\.150\.112\.\s+" + match += rf"Reason: switch mode is {reason}\." + with pytest.raises(ValueError, match=match): + instance.add_file() + + +def test_bootflash_files_00230() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - add_file() + - validate_prerequisites_for_add_file() + + ### Summary + Verify ``ValueError`` is raised if ``filename`` is not set before + calling add_file(). + + ### Test + - ValueError is raised by validate_prerequisites_for_add_file(). + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = BootflashFiles() + instance.filepath = "bootflash:/air.txt" + instance.ip_address = "192.168.1.1" + instance.rest_send = RestSend(params_deleted) + instance.results = Results() + instance.supervisor = "active" + instance.switch_details = SwitchDetails() + instance.target = targets(key) + + match = r"BootflashFiles.validate_prerequisites_for_add_file:\s+" + match += r"filename must be set before calling add_file\(\)\." + with pytest.raises(ValueError, match=match): + instance.add_file() + + +def test_bootflash_files_00240() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - add_file() + - validate_prerequisites_for_add_file() + + ### Summary + Verify ``ValueError`` is raised if ``filepath`` is not set before + calling add_file(). + + ### Test + - ValueError is raised by validate_prerequisites_for_add_file(). + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = BootflashFiles() + instance.filename = "air.txt" + instance.ip_address = "192.168.1.1" + instance.rest_send = RestSend(params_deleted) + instance.results = Results() + instance.supervisor = "active" + instance.switch_details = SwitchDetails() + instance.target = targets(key) + + match = r"BootflashFiles.validate_prerequisites_for_add_file:\s+" + match += r"filepath must be set before calling add_file\(\)\." + with pytest.raises(ValueError, match=match): + instance.add_file() + + +def test_bootflash_files_00250() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - add_file() + - validate_prerequisites_for_add_file() + + ### Summary + Verify ``ValueError`` is raised if ``ip_address`` is not set before + calling add_file(). + + ### Test + - ValueError is raised by validate_prerequisites_for_add_file(). + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = BootflashFiles() + instance.filename = "air.txt" + instance.filepath = "bootflash:/air.txt" + instance.rest_send = RestSend(params_deleted) + instance.results = Results() + instance.supervisor = "active" + instance.switch_details = SwitchDetails() + instance.target = targets(key) + + match = r"BootflashFiles.validate_prerequisites_for_add_file:\s+" + match += r"ip_address must be set before calling add_file\(\)\." + with pytest.raises(ValueError, match=match): + instance.add_file() + + +def test_bootflash_files_00260() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - add_file() + - validate_prerequisites_for_add_file() + + ### Summary + Verify ``ValueError`` is raised if ``supervisor`` is not set before + calling add_file(). + + ### Test + - ValueError is raised by validate_prerequisites_for_add_file(). + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = BootflashFiles() + instance.filename = "air.txt" + instance.filepath = "bootflash:/air.txt" + instance.ip_address = "192.168.1.1" + instance.rest_send = RestSend(params_deleted) + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.target = targets(key) + + match = r"BootflashFiles.validate_prerequisites_for_add_file:\s+" + match += r"supervisor must be set before calling add_file\(\)\." + with pytest.raises(ValueError, match=match): + instance.add_file() + + +def test_bootflash_files_00270() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - add_file() + - validate_prerequisites_for_add_file() + + ### Summary + Verify ``ValueError`` is raised if ``switch_details`` is not set before + calling add_file(). + + ### Test + - ValueError is raised by validate_prerequisites_for_add_file(). + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = BootflashFiles() + instance.filename = "air.txt" + instance.filepath = "bootflash:/air.txt" + instance.ip_address = "192.168.1.1" + instance.rest_send = RestSend(params_deleted) + instance.results = Results() + instance.supervisor = "active" + instance.target = targets(key) + + match = r"BootflashFiles.validate_prerequisites_for_add_file:\s+" + match += r"switch_details must be set before calling add_file\(\)\." + with pytest.raises(ValueError, match=match): + instance.add_file() + + +def test_bootflash_files_00280() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - add_file() + - validate_prerequisites_for_add_file() + + ### Summary + Verify ``ValueError`` is raised if ``target`` is not set before + calling add_file(). + + ### Test + - ValueError is raised by validate_prerequisites_for_add_file(). + - Error message matches expectation. + """ + with does_not_raise(): + instance = BootflashFiles() + instance.filename = "air.txt" + instance.filepath = "bootflash:/air.txt" + instance.ip_address = "192.168.1.1" + instance.rest_send = RestSend(params_deleted) + instance.results = Results() + instance.supervisor = "active" + instance.switch_details = SwitchDetails() + + match = r"BootflashFiles.validate_prerequisites_for_add_file:\s+" + match += r"target must be set before calling add_file\(\)\." + with pytest.raises(ValueError, match=match): + instance.add_file() + + +def test_bootflash_files_00300() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - refresh_switch_details() + + ### Summary + Verify ``refresh_switch_details()`` raises ``ValueError`` if + ``switch_details`` is not set. + + ### Test + - Call ``instance.refresh_switch_details()`` without having set + ``BootflashFiles().switch_details``. + - ValueError is raised. + - Error message matches expectation. + """ + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = RestSend(params_deleted) + + match = r"BootflashFiles\.refresh_switch_details:\s+" + match += r"switch_details must be set before calling\s+" + match += r"refresh_switch_details\." + with pytest.raises(ValueError, match=match): + instance.refresh_switch_details() + + +def test_bootflash_files_00310() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - refresh_switch_details() + + ### Summary + Verify ``refresh_switch_details()`` raises ``ValueError`` if + ``rest_send`` is not set. + + ### Test + - Call ``instance.refresh_switch_details()`` without having set + ``BootflashFiles().rest_send``. + - ValueError is raised. + - Error message matches expectation. + """ + with does_not_raise(): + instance = BootflashFiles() + instance.switch_details = SwitchDetails() + + match = r"BootflashFiles\.refresh_switch_details:\s+" + match += r"rest_send must be set before calling\s+" + match += r"refresh_switch_details\." + with pytest.raises(ValueError, match=match): + instance.refresh_switch_details() + + +def test_bootflash_files_00400() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - ip_address_to_serial_number() + + ### Summary + Verify ``ip_address_to_serial_number()`` raises ``ValueError`` if + ``switch_details`` raises TypeError or ValueError when + ``switch_details.serial_number`` is accessed. + + ### Test + - ``EpAllSwitches`` response is modified such that the ``serialNumber`` + key is missing. + - ``ip_address_to_serial_number()`` raises ``ValueError``. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def configs(): + yield configs_deleted(f"{key}a") + + gen_configs = ResponseGenerator(configs()) + + def responses(): + yield responses_ep_all_switches(key) + + gen_responses = ResponseGenerator(responses()) + + params = copy.deepcopy(params_deleted) + params.update({"config": gen_configs.next}) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switch_details.results = Results() + + match = r"BootflashFiles\.ip_address_to_serial_number:\s+" + match += r"SwitchDetails\._get: 172\.22\.150\.112 does not have\s+" + match += r"a key named serialNumber\." + with pytest.raises(ValueError, match=match): + instance.ip_address_to_serial_number("172.22.150.112") + + +def test_bootflash_files_00500() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - delete_files() + + ### Summary + Verify ``delete_files`` generates a synthetic response when there are no + files to delete. + + ### Test + - delete_files() is called directly. + - Since the payload is initialized in ``BootflashFiles().__init__()`` + with an empty list, there are no files to delete when + ``delete_files()`` is called. + - assert that the response and result are as expected. + """ + sender = Sender() + sender.ansible_module = MockAnsibleModule() + rest_send = RestSend(params_deleted) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switch_details.results = Results() + instance.delete_files() + + assert instance.results.response_current["RETURN_CODE"] == 200 + assert instance.results.response_current["MESSAGE"] == "No files to delete." + assert instance.results.result == [ + {"success": True, "changed": False, "sequence_number": 1} + ] + + +def test_bootflash_files_00600() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - partition_and_serial_number_exist_in_payload() + + ### Summary + Verify ``partition_and_serial_number_exist_in_payload`` returns False + if self.partition does not match the partition in the payload. + + ### Setup + - BootflashFiles().payload is set to include a file on partition + bootflash: on switch with ip_address 172.22.150.112. + - BootflashFiles().partition is set to usb1: + - BootflashFiles().ip_address is set to 172.22.150.112. + + ### Test + - Verify that ``partition_and_serial_number_exist_in_payload()`` + returns False. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + yield responses_ep_all_switches(key) + + gen_responses = ResponseGenerator(responses()) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params_deleted) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switch_details.results = Results() + instance.payload = payloads_bootflash_files(key) + instance.ip_address = "172.22.150.112" + instance.partition = "usb1:" + + assert instance.partition_and_serial_number_exist_in_payload() is False + + +def test_bootflash_files_00610() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - partition_and_serial_number_exist_in_payload() + + ### Summary + Verify ``partition_and_serial_number_exist_in_payload`` returns True + if a file in the payload matches the filters ``ip_address`` and + ``partition``. + + ### Setup + - BootflashFiles().payload is set to include a file on partition + bootflash: on switch with ip_address 172.22.150.112. + - BootflashFiles().partition is set to bootflash: + - BootflashFiles().ip_address is set to 172.22.150.112. + + ### Test + - Verify that partition_and_serial_number_exist_in_payload() + returns True. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + yield responses_ep_all_switches(key) + + gen_responses = ResponseGenerator(responses()) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params_deleted) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashFiles() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switch_details.results = Results() + instance.payload = payloads_bootflash_files(key) + instance.ip_address = "172.22.150.112" + instance.partition = "bootflash:" + + assert instance.partition_and_serial_number_exist_in_payload() is True + + +def test_bootflash_files_00700() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - switch_details.setter + + ### Summary + Verify ``switch_details.setter`` raises ``TypeError`` if passed a string + (i.e. not a class instance and not an instance of ``SwitchDetails()``). + + ### Test + - ``TypeError`` is raised. + - Error message matches expectations. + """ + with does_not_raise(): + instance = BootflashFiles() + + match = r"BootflashFiles.switch_details:\s+" + match += r"value must be an instance of SwitchDetails\.\s+" + match += r"Got value foo of type str\.\s+" + match += r"Error detail: 'str' object has no attribute 'class_name'\." + with pytest.raises(TypeError, match=match): + instance.switch_details = "foo" + + +def test_bootflash_files_00710() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - switch_details.setter + + ### Summary + Verify ``switch_details.setter`` raises ``TypeError`` if passed + a class instance other than ``SwitchDetails()``. + + ### Test + - ``TypeError`` is raised. + - Error message matches expectations. + """ + with does_not_raise(): + instance = BootflashFiles() + + match = r"BootflashFiles.switch_details:\s+" + match += r"value must be an instance of SwitchDetails\.\s+" + match += r"Got value .* of type Results\." + with pytest.raises(TypeError, match=match): + instance.switch_details = Results() + + +def test_bootflash_files_00800() -> None: + """ + ### Classes and Methods + - BootflashFiles() + - target.setter + + ### Summary + Verify ``target.setter`` raises ``TypeError`` if passed a value + that is not a dictionary. + + ### Test + - ``TypeError`` is raised. + - Error message matches expectations. + """ + with does_not_raise(): + instance = BootflashFiles() + + match = r"BootflashFiles.target:\s+" + match += r"target must be a dictionary\. Got type str for value foo\." + with pytest.raises(TypeError, match=match): + instance.target = "foo" + + +@pytest.mark.parametrize( + "parameter", ["filepath", "ip_address", "serial_number", "supervisor"] +) +def test_bootflash_files_00810(parameter) -> None: + """ + ### Classes and Methods + - BootflashFiles() + - target.setter + + ### Summary + Verify ``target.setter`` raises ``ValueError`` if passed a dictionary + that is missing a mandatory parameter. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectations. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + target = targets(key) + target.pop(parameter) + + with does_not_raise(): + instance = BootflashFiles() + + match = r"BootflashFiles.target:\s+" + match += rf"{parameter} key missing from value.*\." + with pytest.raises(ValueError, match=match): + instance.target = target diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_info.py b/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_info.py new file mode 100644 index 000000000..ea6daa3d2 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_info.py @@ -0,0 +1,828 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# Also, fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-import, protected-access, use-implicit-booleaness-not-comparison + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import copy +import inspect +import json + +import pytest +from ansible_collections.cisco.dcnm.plugins.module_utils.bootflash.bootflash_info import \ + BootflashInfo +from ansible_collections.cisco.dcnm.plugins.module_utils.common.response_handler import \ + ResponseHandler +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_v2 import \ + RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.common.sender_file import \ + Sender +from ansible_collections.cisco.dcnm.plugins.module_utils.common.switch_details import \ + SwitchDetails +from ansible_collections.cisco.dcnm.tests.unit.module_utils.common.common_utils import \ + ResponseGenerator +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_bootflash.utils import ( + MockAnsibleModule, configs_query, does_not_raise, params_query, + responses_ep_all_switches, responses_ep_bootflash_discovery, + responses_ep_bootflash_info) + + +def test_bootflash_info_00000() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - __init__() + + ### Summary + - Verify class attributes are initialized to expected values. + + ### Test + - Class attributes are initialized to expected values. + - Exceptions are not not raised. + """ + with does_not_raise(): + instance = BootflashInfo() + assert instance.action == "bootflash_info" + assert instance.class_name == "BootflashInfo" + assert instance.conversion.class_name == "ConversionUtils" + assert instance.convert_file_info_to_target.class_name == "ConvertFileInfoToTarget" + assert instance.ep_bootflash_discovery.class_name == "EpBootflashDiscovery" + assert instance.ep_bootflash_info.class_name == "EpBootflashInfo" + assert instance.info_dict == {} + assert instance._matches == [] + + assert instance.diff_dict == {} + assert instance.response_dict == {} + assert instance.result_dict == {} + + assert instance._rest_send is None + assert instance._results is None + assert instance.switch_details is None + assert instance.switches is None + + assert instance.filter_filepath is None + assert instance.filter_supervisor is None + assert instance.filter_switch is None + + assert instance.valid_supervisor == ["active", "standby"] + + +def test_bootflash_info_00100() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - refresh() + - validate_refresh_parameters() + + ### Summary + - Verify refresh happy path. + + ### Test + - Refresh is successful. + - Exceptions are not raised. + - Filters work as expected. + - Responses match expectations. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + def configs(): + yield configs_query(f"{key}a") + + gen_configs = ResponseGenerator(configs()) + + def responses(): + yield responses_ep_all_switches(f"{key}a") + yield responses_ep_bootflash_discovery(f"{key}a") + yield responses_ep_bootflash_info(f"{key}a") + yield responses_ep_bootflash_discovery(f"{key}b") + yield responses_ep_bootflash_info(f"{key}b") + + gen_responses = ResponseGenerator(responses()) + + params = copy.deepcopy(params_query) + params.update({"config": gen_configs.next}) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["172.22.150.112", "172.22.150.113"] + instance.refresh() + instance.filter_switch = "172.22.150.112" + instance.filter_supervisor = "active" + instance.filter_filepath = "bootflash:/fire.txt" + + assert len(instance.matches) == 1 + assert instance.matches[0]["filepath"] == "bootflash:/fire.txt" + assert instance.matches[0]["ip_address"] == "172.22.150.112" + + with does_not_raise(): + instance.filter_switch = "172.22.150.113" + instance.filter_supervisor = "active" + instance.filter_filepath = "bootflash:/*.txt" + + assert len(instance.matches) == 4 + assert instance.matches[0]["date"] == "2024-08-08 22:50:28" + assert instance.matches[1]["filepath"] == "bootflash:/blue.txt" + assert instance.matches[2]["ip_address"] == "172.22.150.113" + assert instance.matches[3]["serial_number"] == "FOX2109PGD0" + assert instance.matches[3]["size"] == "2" + + +def test_bootflash_info_00110() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - refresh() + - validate_refresh_parameters() + + ### Summary + - Verify exception is raised if ``rest_send`` is not set. + + ### Test + - ValueError is raised when ``rest_send`` is not set. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["192.168.1.1"] + + match = r"BootflashInfo\.validate_refresh_parameters: " + match += r"rest_send must be set prior to calling refresh\." + with pytest.raises(ValueError, match=match): + instance.refresh() + + +def test_bootflash_info_00120() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - refresh() + - validate_refresh_parameters() + + ### Summary + - Verify exception is raised if ``results`` is not set. + + ### Test + - ValueError is raised when ``results`` is not set. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.switch_details = SwitchDetails() + instance.switches = ["192.168.1.1"] + + match = r"BootflashInfo\.validate_refresh_parameters: " + match += r"results must be set prior to calling refresh\." + with pytest.raises(ValueError, match=match): + instance.refresh() + + +def test_bootflash_info_00130() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - refresh() + - validate_refresh_parameters() + + ### Summary + - Verify exception is raised if ``switch_details`` is not set. + + ### Test + - ValueError is raised when ``switch_details`` is not set. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + instance.switches = ["192.168.1.1"] + + match = r"BootflashInfo\.validate_refresh_parameters: " + match += r"switch_details must be set prior to calling refresh\." + with pytest.raises(ValueError, match=match): + instance.refresh() + + +def test_bootflash_info_00140() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - refresh() + - validate_refresh_parameters() + + ### Summary + - Verify exception is raised if ``switches`` is not set. + + ### Test + - ValueError is raised when ``switches`` is not set. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + instance.switch_details = SwitchDetails() + + match = r"BootflashInfo\.validate_refresh_parameters: " + match += r"switches must be set prior to calling refresh\." + with pytest.raises(ValueError, match=match): + instance.refresh() + + +def test_bootflash_info_00150() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - refresh() + - validate_refresh_parameters() + + ### Summary + - Verify ``ValueError`` is raised because 172.22.150.112 is missing + serialNumber key in the ep_all_switches response. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectations. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + def configs(): + yield configs_query(f"{key}a") + + gen_configs = ResponseGenerator(configs()) + + def responses(): + yield responses_ep_all_switches(f"{key}a") + + gen_responses = ResponseGenerator(responses()) + + params = copy.deepcopy(params_query) + params.update({"config": gen_configs.next}) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["172.22.150.112", "172.22.150.113"] + match = r"BootflashInfo\.refresh_bootflash_info:\s+" + match += r"serial_number not found for switch 172.22.150.112.\s+" + match += r"Error detail SwitchDetails\._get:\s+" + match += r"172.22.150.112 does not have a key named serialNumber\." + with pytest.raises(ValueError, match=match): + instance.refresh() + + +def test_bootflash_info_00200() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - build_matches() + - validate_prerequisites_for_build_matches() + + ### Summary + - Verify exception is raised if ``build_matches()`` is called + before ``refresh()``. + + ### Test + - ``ValueError`` is raised when ``build_matches()`` called. + - Error message matches expectations. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["172.22.150.112", "172.22.150.113"] + + match = r"BootflashInfo\.validate_prerequisites_for_build_matches:\s+" + match += r"refresh must be called before retrieving bootflash properties\." + with pytest.raises(ValueError, match=match): + instance.build_matches() + + +def test_bootflash_info_00210() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - refresh() + - validate_refresh_parameters() + - build_matches() + + ### Summary + Verify that ``build_matches()`` returns without updating the + ``instance.matches`` list when ``filter_switch`` is not found in the + controller response (i.e. instance.info_dict) retrieved with + instance.info property. + + ### Test + - Refresh is successful. + - Exceptions are not raised. + - ``instance.matches`` list is empty. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + def configs(): + yield configs_query(f"{key}a") + + gen_configs = ResponseGenerator(configs()) + + def responses(): + yield responses_ep_all_switches(f"{key}a") + yield responses_ep_bootflash_discovery(f"{key}a") + yield responses_ep_bootflash_info(f"{key}a") + + gen_responses = ResponseGenerator(responses()) + + params = copy.deepcopy(params_query) + params.update({"config": gen_configs.next}) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["172.22.150.112"] + instance.refresh() + instance.filter_switch = "172.22.150.113" + instance.filter_supervisor = "active" + instance.filter_filepath = "bootflash:/air.txt" + assert instance.matches == [] + + +def test_bootflash_info_00220() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - refresh() + - validate_refresh_parameters() + - build_matches() + + ### Summary + Verify that when ``filter_supervisor`` is set, but does not match any + items in the info_dict, ``build_matches()`` does not update the + matches list. + + ### Test + - Refresh is successful. + - Exceptions are not raised. + - ``instance.matches`` list is empty. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + def configs(): + yield configs_query(f"{key}a") + + gen_configs = ResponseGenerator(configs()) + + def responses(): + yield responses_ep_all_switches(f"{key}a") + yield responses_ep_bootflash_discovery(f"{key}a") + yield responses_ep_bootflash_info(f"{key}a") + yield responses_ep_bootflash_discovery(f"{key}b") + yield responses_ep_bootflash_info(f"{key}b") + + gen_responses = ResponseGenerator(responses()) + + params = copy.deepcopy(params_query) + params.update({"config": gen_configs.next}) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["172.22.150.112", "172.22.150.113"] + instance.refresh() + with does_not_raise(): + instance.filter_switch = "172.22.150.112" + instance.filter_supervisor = "standby" + instance.filter_filepath = "bootflash:/*.txt" + assert len(instance.matches) == 0 + with does_not_raise(): + instance.filter_switch = "172.22.150.113" + instance.filter_supervisor = "standby" + instance.filter_filepath = "bootflash:/*.txt" + assert len(instance.matches) == 0 + + +def test_bootflash_info_00230() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - refresh() + - validate_refresh_parameters() + - build_matches() + + ### Summary + Verify that ``ValueError`` is raised by ``ConvertFileInfoToTarget()`` if an + ``EpBootflashInfo`` response is missing the ``ipAddr`` key. + + ### Test + - Refresh is successful. + - ``ValueError`` is raised by ``ConvertFileInfoToTarget()`` because + the response from ``EpBootflashInfo`` is missing the ``ipAddr`` key. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + def configs(): + yield configs_query(f"{key}a") + + gen_configs = ResponseGenerator(configs()) + + def responses(): + yield responses_ep_all_switches(f"{key}a") + yield responses_ep_bootflash_discovery(f"{key}a") + yield responses_ep_bootflash_info(f"{key}a") + + gen_responses = ResponseGenerator(responses()) + + params = copy.deepcopy(params_query) + params.update({"config": gen_configs.next}) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = rest_send + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["172.22.150.112"] + instance.refresh() + with does_not_raise(): + instance.filter_switch = "172.22.150.112" + instance.filter_supervisor = "active" + instance.filter_filepath = "bootflash:/*.txt" + + match = r"ConvertFileInfoToTarget\.commit:\s+.*" + match += r"Error detail: ConvertFileInfoToTarget\._get:\s+" + match += r"Missing key ipAddr in file_info:.*\." + with pytest.raises(ValueError, match=match): + instance.matches # pylint: disable=pointless-statement + + +@pytest.mark.parametrize( + "filter_filepath,filepath,expected", + [ + (None, "bootflash:/black.txt", False), + ("bootflash:/black.txt", "bootflash:/black.txt", True), + ("bootflash:/black.txt", "bootflash:/blue.txt", False), + ("bootflash:/*.txt", "bootflash:/black.txt", True), + ("bootflash:/*.txt", "bootflash:/blue.txt", True), + ("*:/*.txt", "usb1:/green.txt", True), + ("usb1:/*.txt", "bootflash:/red.txt", False), + ("bootflash:/*", "bootflash:/foo", True), + ("bootflash:/???.txt", "bootflash:/foo.txt", True), + ("bootflash:/???.txt", "bootflash:/foobar.txt", False), + ("bootflash:/???1.*", "bootflash:/foo1.txt", True), + ("*:/*", "blahdevice:/blahfile.bing.bang.bong", True), + ("*:/*", "/b", False), + ], +) +def test_bootflash_info_00300(filter_filepath, filepath, expected) -> None: + """ + ### Classes and Methods + - BootflashInfo() + - match_filter_filepath() + - filter_filepath.setter + - filepath.setter + + ### Summary + Verify that ``match_filter_filepath()`` returns appropriate value + (True or False) for various inputs. + + ### Test + - Exception is not raised. + - True is returned when ``filter_filepath`` matches ``filepath``. + - False is returned when ``filter_filepath`` does not + match ``filepath``. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["172.22.150.112"] + if filter_filepath is not None: + instance.filter_filepath = filter_filepath + + target = {"filepath": filepath, "ip_address": "192.168.1.1", "supervisor": "active"} + assert instance.match_filter_filepath(target) == expected + + +@pytest.mark.parametrize( + "filter_supervisor,supervisor,expected", + [ + (None, "active", False), + ("active", "active", True), + ("active", "standby", False), + ("standby", "standby", True), + ("standby", "active", False), + ], +) +def test_bootflash_info_00310(filter_supervisor, supervisor, expected) -> None: + """ + ### Classes and Methods + - BootflashInfo() + - match_filter_supervisor() + - filter_supervisor.setter + - supervisor.setter + + ### Summary + Verify that ``match_filter_supervisor()`` returns appropriate value + (True or False) for various inputs. + + ### Test + - Exception is not raised. + - True is returned when ``filter_supervisor`` matches ``supervisor``. + - False is returned when ``filter_supervisor`` does not + match ``supervisor``. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["172.22.150.112"] + if filter_supervisor is not None: + instance.filter_supervisor = filter_supervisor + + target = { + "filepath": "bootflash:/foo.txt", + "ip_address": "192.168.1.1", + "supervisor": supervisor, + } + assert instance.match_filter_supervisor(target) == expected + + +@pytest.mark.parametrize( + "filter_switch,switch,expected", + [ + (None, "192.168.1.1", False), + ("192.168.1.1", "192.168.1.1", True), + ("192.168.1.1", "192.168.1.2", False), + ("192.168.1.1", "foo", False), + (["standby"], "192.168.1.2", False), + ], +) +def test_bootflash_info_00320(filter_switch, switch, expected) -> None: + """ + ### Classes and Methods + - BootflashInfo() + - match_filter_switch() + - filter_switch.setter + + ### Summary + Verify that ``match_filter_switch()`` returns appropriate value + (True or False) for various inputs. + + ### Test + - Exception is not raised. + - True is returned when ``filter_supervisor`` matches ``supervisor``. + - False is returned when ``filter_supervisor`` does not + match ``supervisor``. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["172.22.150.112"] + if filter_switch is not None: + instance.filter_switch = filter_switch + + target = { + "filepath": "bootflash:/foo.txt", + "ip_address": switch, + "supervisor": "active", + } + assert instance.match_filter_switch(target) == expected + + +def test_bootflash_info_00400() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - filter_supervisor.setter + + ### Summary + Verify that ``filter_supervisor.setter`` raises ``ValueError`` + if the value is not in ``valid_supervisor``. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectations. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + instance.switch_details = SwitchDetails() + instance.switches = ["172.22.150.112"] + match = r"BootflashInfo\.filter_supervisor\.setter:\s+" + match += r"value foo is not a valid value for supervisor\.\s+" + match += r"Valid values: active,standby\." + with pytest.raises(ValueError, match=match): + instance.filter_supervisor = "foo" + + +def test_bootflash_info_00500() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - switch_details.setter + + ### Summary + Verify that ``switch_details.setter`` raises ``TypeError`` + if the value is not an instance if ``SwitchDetails()`` + and the value is not a class instance. + + ### Test + - ``TypeError`` is raised. + - Error message matches expectations. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + instance.switches = ["172.22.150.112"] + match = r"BootflashInfo.switch_details:\s+" + match += r"value must be an instance of SwitchDetails\.\s+" + match += r"Got value foo of type str\.\s+" + match += r"Error detail: 'str' object has no attribute 'class_name'\." + with pytest.raises(TypeError, match=match): + instance.switch_details = "foo" + + +def test_bootflash_info_00510() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - switch_details.setter + + ### Summary + Verify that ``switch_details.setter`` raises ``TypeError`` + if the value is not an instance if ``SwitchDetails()`` + and the value is a class instance with ``class_name`` + attribute. + + ### Test + - ``TypeError`` is raised. + - Error message matches expectations. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + instance.switches = ["172.22.150.112"] + match = r"BootflashInfo.switch_details:\s+" + match += r"value must be an instance of SwitchDetails\.\s+" + match += r"Got value .* of type Results\." + with pytest.raises(TypeError, match=match): + instance.switch_details = Results() + + +def test_bootflash_info_00600() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - switches.setter + + ### Summary + Verify that ``switches.setter`` raises ``TypeError`` + if the value is not a list + + ### Test + - ``TypeError`` is raised. + - Error message matches expectations. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + match = r"BootflashInfo\.switches:\s+" + match += r"switches must be a list\. got str for value foo\." + with pytest.raises(TypeError, match=match): + instance.switches = "foo" + + +def test_bootflash_info_00610() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - switches.setter + + ### Summary + Verify that ``switches.setter`` raises ``ValueError`` + if the value is an empty list. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectations. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + match = r"BootflashInfo.switches:\s+" + match += r"switches must be a list with at least one ip address\.\s+" + match += r"got \[\]\." + with pytest.raises(ValueError, match=match): + instance.switches = [] + + +def test_bootflash_info_00620() -> None: + """ + ### Classes and Methods + - BootflashInfo() + - switches.setter + + ### Summary + Verify that ``switches.setter`` raises ``TypeError`` + if the value is a list containing a non-string. + + ### Test + - ``TypeError`` is raised. + - Error message matches expectations. + """ + with does_not_raise(): + instance = BootflashInfo() + instance.rest_send = RestSend({}) + instance.results = Results() + match = r"BootflashInfo\.switches:\s+" + match += r"switches must be a list of ip addresses\.\s+" + match += r"got type int for value 10\." + with pytest.raises(TypeError, match=match): + instance.switches = ["192.168.1.1", 10] diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_query.py b/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_query.py new file mode 100644 index 000000000..6db1b1711 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/test_bootflash_query.py @@ -0,0 +1,223 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# Also, fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-import, protected-access, use-implicit-booleaness-not-comparison, unused-variable + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import copy +import inspect + +import pytest +from ansible_collections.cisco.dcnm.plugins.module_utils.common.response_handler import \ + ResponseHandler +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_v2 import \ + RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.common.sender_file import \ + Sender +from ansible_collections.cisco.dcnm.plugins.modules.dcnm_bootflash import Query +from ansible_collections.cisco.dcnm.tests.unit.module_utils.common.common_utils import \ + ResponseGenerator +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_bootflash.utils import ( + MockAnsibleModule, configs_query, does_not_raise, params_query, + responses_ep_all_switches, responses_ep_bootflash_discovery, + responses_ep_bootflash_info) + + +def test_bootflash_query_00000() -> None: + """ + ### Classes and Methods + - Query() + - __init__() + + ### Summary + __init__() happy path with minimal config. + - Verify class attributes are initialized to expected values. + + ### Test + - Class attributes are initialized to expected values. + - Exceptions are not not raised. + """ + with does_not_raise(): + instance = Query(params_query) + # attributes inherited from Common + assert instance.bootflash_info.class_name == "BootflashInfo" + assert instance.params == params_query + assert instance.check_mode is False + assert instance.config == params_query.get("config") + assert instance.convert_target_to_params.class_name == "ConvertTargetToParams" + assert instance._rest_send is None + assert instance.results.class_name == "Results" + assert instance.results.check_mode is False + assert instance.results.state == "query" + assert instance.state == "query" + assert instance.switches == [{"ip_address": "192.168.1.2"}] + assert instance.targets == params_query.get("config", {}).get("targets", []) + assert instance.want == [] + assert instance._valid_states == ["deleted", "query"] + # attributes specific to Query + assert instance.class_name == "Query" + + +def test_bootflash_query_00010() -> None: + """ + ### Classes and Methods + - Query() + - __init__() + + ### Summary + ``__init__()`` sad path. ``Common().__init__()`` raises exception. + + ### Test + - ``Query().__init__()`` catches exception and + re-raises as ``ValueError``. + - Exception message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + params = copy.deepcopy(params_query) + params["config"] = configs_query(key) + + match = r"Query\.__init__:\s+" + match += r"Error during super\(\)\.__init__\(\)\.\s+" + match += r"Error detail: Query\.__init__:\s+" + match += r"Expected list of dict for params\.config\.targets\.\s+" + match += r"Got list element of type str\." + with pytest.raises(ValueError, match=match): + instance = Query(params) + + +def test_bootflash_query_01000() -> None: + """ + ### Classes and Methods + - Query() + - commit() + + ### Summary + - ``Query().commit()`` happy path. + - config.targets contains one entry. + - config.switches contains two entries. + + ### Test + - No exceptions are raised. + - asserts are successful. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + params = copy.deepcopy(params_query) + params["config"] = configs_query(f"{key}a") + + def responses(): + yield responses_ep_all_switches(f"{key}a") + yield responses_ep_bootflash_discovery(f"{key}a") + yield responses_ep_bootflash_info(f"{key}a") + yield responses_ep_bootflash_discovery(f"{key}b") + yield responses_ep_bootflash_info(f"{key}b") + yield responses_ep_all_switches(f"{key}a") + yield responses_ep_bootflash_info(f"{key}a") + + gen_responses = ResponseGenerator(responses()) + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = Query(params) + instance.rest_send = rest_send + instance.commit() + + assert ( + instance.results.diff[0]["172.22.150.112"][0]["filepath"] + == "bootflash:/air.txt" + ) + assert ( + instance.results.diff[0]["172.22.150.113"][0]["filepath"] + == "bootflash:/black.txt" + ) + assert instance.results.metadata[0]["action"] == "bootflash_info" + assert instance.results.metadata[0]["check_mode"] is False + assert instance.results.metadata[0]["sequence_number"] == 1 + assert instance.results.metadata[0]["state"] == "query" + assert instance.results.response[0]["172.22.150.112"]["MESSAGE"] == "OK" + assert instance.results.response[0]["172.22.150.112"]["RETURN_CODE"] == 200 + assert instance.results.response[0]["172.22.150.113"]["MESSAGE"] == "OK" + assert instance.results.response[0]["172.22.150.113"]["RETURN_CODE"] == 200 + assert instance.results.result[0]["172.22.150.112"]["success"] is True + assert instance.results.result[0]["172.22.150.112"]["found"] is True + assert instance.results.result[0]["172.22.150.113"]["success"] is True + assert instance.results.result[0]["172.22.150.113"]["found"] is True + + +def test_bootflash_query_01010() -> None: + """ + ### Classes and Methods + - Query() + - commit() + + ### Summary + - ``Query().commit()`` happy path with no switches. + - config.targets contains one entry. + - config.switches contains no entries. + + ### Test + - No exceptions are raised. + - asserts are successful. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}" + + params = copy.deepcopy(params_query) + params["config"] = configs_query(f"{key}a") + + sender = Sender() + sender.ansible_module = MockAnsibleModule() + rest_send = RestSend(params) + rest_send.unit_test = True + rest_send.timeout = 1 + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + with does_not_raise(): + instance = Query(params) + instance.rest_send = rest_send + instance.commit() + + assert len(instance.results.diff) == 1 + assert instance.results.diff[0]["sequence_number"] == 1 + assert instance.results.metadata[0]["action"] == "bootflash_info" + assert instance.results.metadata[0]["check_mode"] is False + assert instance.results.metadata[0]["sequence_number"] == 1 + assert instance.results.metadata[0]["state"] == "query" + assert instance.results.response[0]["0.0.0.0"]["MESSAGE"] == "OK" + assert instance.results.response[0]["0.0.0.0"]["DATA"] == "No switches to query." + assert instance.results.response[0]["0.0.0.0"]["RETURN_CODE"] == 200 + assert instance.results.result[0]["0.0.0.0"]["success"] is True + assert instance.results.result[0]["0.0.0.0"]["found"] is False diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/test_convert_file_info_to_target.py b/tests/unit/modules/dcnm/dcnm_bootflash/test_convert_file_info_to_target.py new file mode 100644 index 000000000..5c2eeef39 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/test_convert_file_info_to_target.py @@ -0,0 +1,259 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# Also, fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-import, protected-access, use-implicit-booleaness-not-comparison + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import inspect +from datetime import datetime + +import pytest +from ansible_collections.cisco.dcnm.plugins.module_utils.bootflash.convert_file_info_to_target import \ + ConvertFileInfoToTarget +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_bootflash.utils import ( + does_not_raise, file_info, targets_convert_file_info_to_target) + + +def test_convert_file_info_to_target_00000() -> None: + """ + ### Classes and Methods + - ConvertFileInfoToTarget() + - __init__() + + ### Summary + - Verify class attributes are initialized to expected values. + + ### Test + - Class attributes are initialized to expected values. + - Exceptions are not not raised. + """ + with does_not_raise(): + instance = ConvertFileInfoToTarget() + assert instance.action == "convert_file_info_to_target" + assert instance.class_name == "ConvertFileInfoToTarget" + + assert instance._file_info is None + assert instance._filename is None + assert instance._filepath is None + assert instance._ip_address is None + assert instance._serial_number is None + assert instance._supervisor is None + assert instance._target is None + assert instance.timestamp_format == "%b %d %H:%M:%S %Y" + + +def test_convert_file_info_to_target_00100() -> None: + """ + ### Classes and Methods + - ConvertFileInfoToTarget() + - commit() + + ### Summary + - Verify commit() happy path. + - Given a file_info dict, verify that a properly-constructed + target dict is built and that individual getter properties return + expected values. + + ### Test + - Exceptions are not not raised. + - target dict is built as expected. + - Individual getter properties return expected values. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = ConvertFileInfoToTarget() + instance.file_info = file_info(f"{key}") + instance.commit() + + assert instance.target == targets_convert_file_info_to_target(key) + assert instance.date == datetime(2023, 9, 19, 22, 20, 7) + assert instance.device_name == "cvd-1212-spine" + assert instance.filepath == "bootflash:" + assert instance.ip_address == "192.168.1.1" + assert instance.name == "bootflash:" + assert instance.size == "218233885" + assert instance.serial_number == "BDY3814QDD0" + assert instance.supervisor == "active" + + +def test_convert_file_info_to_target_00110() -> None: + """ + ### Classes and Methods + - ConvertFileInfoToTarget() + - commit() + + ### Summary + - Verify ``commit()`` raises exception when called without first + setting ``file_info``. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + with does_not_raise(): + instance = ConvertFileInfoToTarget() + match = r"ConvertFileInfoToTarget\.validate_commit_parameters:\s+" + match += r"file_info must be set before calling commit\(\)\." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_convert_file_info_to_target_00120() -> None: + """ + ### Classes and Methods + - ConvertFileInfoToTarget() + - commit() + + ### Summary + - Verify ``commit()`` raises ``ValueError`` when ``PurePosixPath`` + raises ``TypeError``. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = ConvertFileInfoToTarget() + instance.file_info = file_info(f"{key}") + + # Depending on the version of PurePosixPath, the Error detail may vary. + match = r"ConvertFileInfoToTarget.commit:\s+" + match += r"Could not build PosixPath from name and filename\.\s+" + match += r"name: 10, filename: foo\.\s+" + match += r"Error detail:.*" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_convert_file_info_to_target_00130() -> None: + """ + ### Classes and Methods + - ConvertFileInfoToTarget() + - commit() + + ### Summary + - Verify ``commit()`` raises ``ValueError`` when filepath does not + contain ":/". + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = ConvertFileInfoToTarget() + instance.file_info = file_info(f"{key}") + + match = r"ConvertFileInfoToTarget.commit:\s+" + match += r"Invalid filepath bootflash\/foo constructed from\s+" + match += r"name: bootflash, filename: foo\.\s+" + match += r"Missing ':\/' in the path\." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_convert_file_info_to_target_00200() -> None: + """ + ### Classes and Methods + - ConvertFileInfoToTarget() + - date + + ### Summary + - Verify ``ValueError`` is raised if ``file_info`` has not been set + before accessing getter properties, like ``date``. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + with does_not_raise(): + instance = ConvertFileInfoToTarget() + + match = r"ConvertFileInfoToTarget\._get:\s+" + match += r"file_info must be set before calling ``_get\(\)``\." + with pytest.raises(ValueError, match=match): + instance.date # pylint: disable=pointless-statement + + +def test_convert_file_info_to_target_00210() -> None: + """ + ### Classes and Methods + - ConvertFileInfoToTarget() + - date + + ### Summary + - Verify ``ValueError`` is raised if date cannot convert file_info.date + to ``YYYY-MM-DD HH-MM-SS`` format. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = ConvertFileInfoToTarget() + + match = r"ConvertFileInfoToTarget.date:\s+" + match += r"Could not convert date to datetime object\.\s+" + match += r"date: Sep 19 22:20:07 202\.\s+" + match += r"Error detail:\s+" + match += ( + r"time data 'Sep 19 22:20:07 202' does not match format '%b %d %H:%M:%S %Y'\." + ) + with pytest.raises(ValueError, match=match): + instance.file_info = file_info(f"{key}") + instance.date # pylint: disable=pointless-statement + + +def test_convert_file_info_to_target_00300() -> None: + """ + ### Classes and Methods + - ConvertFileInfoToTarget() + - target + + ### Summary + - Verify ``ValueError`` is raised if ``target`` is accessed before + calling commit. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + with does_not_raise(): + instance = ConvertFileInfoToTarget() + + match = r"ConvertFileInfoToTarget.target:\s+" + match += r"target has not been built\.\s+" + match += r"Call commit\(\) before accessing\." + with pytest.raises(ValueError, match=match): + instance.target # pylint: disable=pointless-statement diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/test_convert_target_to_params.py b/tests/unit/modules/dcnm/dcnm_bootflash/test_convert_target_to_params.py new file mode 100644 index 000000000..aa7e8a8cb --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/test_convert_target_to_params.py @@ -0,0 +1,346 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# Also, fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-import, protected-access, use-implicit-booleaness-not-comparison + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import inspect + +import pytest +from ansible_collections.cisco.dcnm.plugins.module_utils.bootflash.convert_target_to_params import \ + ConvertTargetToParams +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_bootflash.utils import ( + does_not_raise, targets) + + +def test_convert_target_to_params_00000() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - __init__() + + ### Summary + - Verify class attributes are initialized to expected values. + + ### Test + - Class attributes are initialized to expected values. + - Exceptions are not not raised. + """ + with does_not_raise(): + instance = ConvertTargetToParams() + assert instance.action == "convert_target_to_params" + assert instance.class_name == "ConvertTargetToParams" + + assert instance._filename is None + assert instance._filepath is None + assert instance._target is None + assert instance._partition is None + assert instance._supervisor is None + assert instance.committed is False + + +def test_convert_target_to_params_00100() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - commit() + + ### Summary + - Verify commit() happy path. + - File located in top-level (root) of bootflash. + + ### Test + - Given a property-constructed target dict, getter properties are set + to expected values. + - Exceptions are not not raised. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = ConvertTargetToParams() + instance.target = targets(f"{key}") + instance.commit() + + assert instance.target == targets(f"{key}") + assert instance.filename == "air.txt" + assert instance.filepath == "bootflash:" + assert instance.partition == "bootflash:" + assert instance.supervisor == "active" + + +def test_convert_target_to_params_00110() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - commit() + + ### Summary + - Verify commit() happy path. + - File located in directory on bootflash. + + ### Test + - Given a property-constructed target dict, getter properties are set + to expected values. + - Exceptions are not not raised. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = ConvertTargetToParams() + instance.target = targets(f"{key}") + instance.commit() + + assert instance.target == targets(f"{key}") + assert instance.filename == "air.txt" + assert instance.filepath == "bootflash:/foo/" + assert instance.partition == "bootflash:" + assert instance.supervisor == "active" + + +def test_convert_target_to_params_00120() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - commit() + + ### Summary + Verify ``ValueError`` is raised when commit() is called prior to setting + ``target``. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + with does_not_raise(): + instance = ConvertTargetToParams() + + match = r"ConvertTargetToParams\.commit:\s+" + match += r"target must be set before calling commit\." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_convert_target_to_params_00200() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - commit() + - parse_target() + + ### Summary + Verify ``parse_target()`` raises ``ValueError`` if ``target`` is missing + ``filepath`` key. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = ConvertTargetToParams() + instance.target = targets(f"{key}") + + match = r"ConvertTargetToParams\.parse_target:\s+" + match += r"Expected filepath in target dict. Got.*\." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_convert_target_to_params_00210() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - commit() + - parse_target() + + ### Summary + Verify ``parse_target()`` raises ``ValueError`` if ``target`` is missing + ``supervisor`` key. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = ConvertTargetToParams() + instance.target = targets(f"{key}") + + match = r"ConvertTargetToParams\.parse_target:\s+" + match += r"Expected supervisor in target dict. Got.*\." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_convert_target_to_params_00300() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - filename + + ### Summary + Verify ``filename`` raises ``ValueError`` if accessed before ``commit()`` + is called. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + with does_not_raise(): + instance = ConvertTargetToParams() + + match = r"ConvertTargetToParams.filename:\s+" + match += r"commit\(\) must be called before accessing filename\." + with pytest.raises(ValueError, match=match): + instance.filename # pylint: disable=pointless-statement + + +def test_convert_target_to_params_00400() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - filepath + + ### Summary + Verify ``filepath`` raises ``ValueError`` if accessed before ``commit()`` + is called. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + with does_not_raise(): + instance = ConvertTargetToParams() + + match = r"ConvertTargetToParams.filepath:\s+" + match += r"commit\(\) must be called before accessing filepath\." + with pytest.raises(ValueError, match=match): + instance.filepath # pylint: disable=pointless-statement + + +def test_convert_target_to_params_00500() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - partition + + ### Summary + Verify ``partition`` raises ``ValueError`` if accessed before ``commit()`` + is called. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + with does_not_raise(): + instance = ConvertTargetToParams() + + match = r"ConvertTargetToParams.partition:\s+" + match += r"commit\(\) must be called before accessing partition\." + with pytest.raises(ValueError, match=match): + instance.partition # pylint: disable=pointless-statement + + +def test_convert_target_to_params_00510() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - partition + + ### Summary + Verify ``partition`` raises ``ValueError`` passed a value not ending in ":". + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = ConvertTargetToParams() + instance.target = targets(f"{key}") + + match = r"ConvertTargetToParams\.partition:\s+" + match += r"Invalid partition: bootflash\.\s+" + match += r"Expected partition to end with a colon." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_convert_target_to_params_00600() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - supervisor + + ### Summary + Verify ``supervisor`` raises ``ValueError`` if accessed before ``commit()`` + is called. + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + with does_not_raise(): + instance = ConvertTargetToParams() + + match = r"ConvertTargetToParams.supervisor:\s+" + match += r"commit\(\) must be called before accessing supervisor\." + with pytest.raises(ValueError, match=match): + instance.supervisor # pylint: disable=pointless-statement + + +def test_convert_target_to_params_00610() -> None: + """ + ### Classes and Methods + - ConvertTargetToParams() + - supervisor + + ### Summary + Verify ``supervisor`` raises ``ValueError`` if not valid (i.e. not one of + "active" or "standby"). + + ### Test + - ``ValueError`` is raised. + - Error message matches expectation. + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + with does_not_raise(): + instance = ConvertTargetToParams() + instance.target = targets(f"{key}") + + match = r"ConvertTargetToParams\.supervisor:\s+" + match += r"Invalid supervisor: bad_supervisor_value\.\s+" + match += r"Expected one of: active,standby\." + with pytest.raises(ValueError, match=match): + instance.commit() diff --git a/tests/unit/modules/dcnm/dcnm_bootflash/utils.py b/tests/unit/modules/dcnm/dcnm_bootflash/utils.py new file mode 100644 index 000000000..607c6011d --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_bootflash/utils.py @@ -0,0 +1,211 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from contextlib import contextmanager + +import pytest +from ansible_collections.ansible.netcommon.tests.unit.modules.utils import \ + AnsibleFailJson +from ansible_collections.cisco.dcnm.plugins.module_utils.common.response_handler import \ + ResponseHandler +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_bootflash.fixture import \ + load_fixture + +params_query = { + "state": "query", + "config": { + "switches": [{"ip_address": "192.168.1.2"}], + "targets": [{"filepath": "bootflash:/testfile", "supervisor": "active"}], + }, + "check_mode": False, +} + + +params_deleted = { + "state": "deleted", + "config": { + "switches": [{"ip_address": "192.168.1.2"}], + "targets": [{"filepath": "bootflash:/testfile", "supervisor": "active"}], + }, + "check_mode": False, +} + + +class MockAnsibleModule: + """ + Mock the AnsibleModule class + """ + + check_mode = False + + params = params_query + argument_spec = { + "config": {"required": True, "type": "dict"}, + "state": { + "default": "query", + "choices": ["deleted", "query"], + }, + } + supports_check_mode = True + + @property + def state(self): + """ + return the state + """ + return self.params["state"] + + @state.setter + def state(self, value): + """ + set the state + """ + self.params["state"] = value + + @staticmethod + def fail_json(msg, **kwargs) -> AnsibleFailJson: + """ + mock the fail_json method + """ + raise AnsibleFailJson(msg, kwargs) + + def public_method_for_pylint(self): + """ + Add one public method to appease pylint + """ + + +# See the following for explanation of why fixtures are explicitely named +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html + + +@pytest.fixture(name="response_handler") +def response_handler_fixture(): + """ + mock ResponseHandler() + """ + return ResponseHandler() + + +@contextmanager +def does_not_raise(): + """ + A context manager that does not raise an exception. + """ + yield + + +def configs_deleted(key: str) -> dict: + """ + Return playbook configs for Deleted + """ + data_file = "configs_Deleted" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def configs_query(key: str) -> dict: + """ + Return playbook configs for Query + """ + data_file = "configs_Query" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def file_info(key: str) -> dict: + """ + Return file_info for ConvertFileInfoToTarget().file_info.setter + """ + data_file = "file_info_ConvertFileInfoToTarget" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def payloads_bootflash_files(key: str) -> dict: + """ + Return payloads for BootflashFiles() + """ + data_file = "payloads_BootflashFiles" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_ep_all_switches(key: str) -> dict: + """ + Return EpAllSwitches() responses. + """ + data_file = "responses_EpAllSwitches" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_ep_bootflash_discovery(key: str) -> dict: + """ + Return EpBootflashDiscovery() responses. + """ + data_file = "responses_EpBootflashDiscovery" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_ep_bootflash_files(key: str) -> dict: + """ + Return EpBootflashFiles() responses. + """ + data_file = "responses_EpBootflashFiles" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_ep_bootflash_info(key: str) -> dict: + """ + Return EpBootflashInfo() responses. + """ + data_file = "responses_EpBootflashInfo" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def targets(key: str) -> dict: + """ + Return target dictionaries for BootflashFiles unit tests. + """ + data_file = "targets" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def targets_convert_file_info_to_target(key: str) -> dict: + """ + Return target dictionaries used for ConvertFileInfoToTarget unit test asserts. + """ + data_file = "targets_ConvertFileInfoToTarget" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data