diff --git a/README.md b/README.md index 6c7e2f49..c54ca461 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Join our Slack Channel at [Netapp.io](http://netapp.io/slack) - na_ontap_net_subnet - delete fails if ipspace is different than Default. - na_ontap_portset - fixed idempotency issue when `ports` has identical values. - na_ontap_portset - fixed error when trying to remove partial ports from portset if igroups are bound to it. + - na_ontap_quotas - fix another quota operation is currently in progress issue. - na_ontap_quotas - fix idempotency issue on `threshold` option. - na_ontap_snapmirror - support for SSL certificate authentication for both sides when using ONTAP. - na_ontap_snapmirror - fix issue where there was no wait on quiesce before aborting. @@ -63,6 +64,7 @@ Join our Slack Channel at [Netapp.io](http://netapp.io/slack) ### New Module - na_ontap_s3_service - Manage S3 services. + - na_ontap_s3_users - Manage S3 users. ### Minor Changes - na_ontap_aggregate - updated `disk_types` in documentation. diff --git a/changelogs/fragments/DEVOPS-4606.yaml b/changelogs/fragments/DEVOPS-4606.yaml new file mode 100644 index 00000000..a3317404 --- /dev/null +++ b/changelogs/fragments/DEVOPS-4606.yaml @@ -0,0 +1,2 @@ +bugfixes: + - na_ontap_quotas - fix another quota operation is currently in progress issue. \ No newline at end of file diff --git a/meta/runtime.yml b/meta/runtime.yml index a69937bf..ccdc9d10 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -93,6 +93,7 @@ action_groups: - na_ontap_restit - na_ontap_s3_buckets - na_ontap_s3_services + - na_ontap_s3_users - na_ontap_security_certificates - na_ontap_security_config - na_ontap_security_key_manager diff --git a/plugins/module_utils/rest_vserver.py b/plugins/module_utils/rest_vserver.py index b7339300..107841f2 100644 --- a/plugins/module_utils/rest_vserver.py +++ b/plugins/module_utils/rest_vserver.py @@ -52,7 +52,7 @@ def get_vserver_uuid(rest_api, name, module, error_on_none=None): if error: module.fail_json(msg=error) if record is None and error_on_none: - return record, True + module.fail_json(msg="Error: Specified vserver %s not found" % name) if record: return record['uuid'], error return record, error diff --git a/plugins/modules/na_ontap_quotas.py b/plugins/modules/na_ontap_quotas.py index 0aa3ead8..ecfc99ee 100644 --- a/plugins/modules/na_ontap_quotas.py +++ b/plugins/modules/na_ontap_quotas.py @@ -488,8 +488,11 @@ def apply(self): modify_quota = self.na_helper.get_modified_attributes(current, self.parameters) quota_status = self.get_quota_status() if 'set_quota_status' in self.parameters and quota_status is not None: - quota_status_action = self.na_helper.get_modified_attributes({'set_quota_status': quota_status == 'on'}, self.parameters) - + # if 'set_quota_status' == True in create, sometimes there is delay in status update from 'initializing' -> 'on'. + # if quota_status == 'on' and options(set_quota_status == True and activate_quota_on_change == 'resize'), + # sometimes there is delay in status update from 'resizing' -> 'on' + set_quota_status = True if quota_status in ('on', 'resizing', 'initializing') else False + quota_status_action = self.na_helper.get_modified_attributes({'set_quota_status': set_quota_status}, self.parameters) if quota_status_action: modify_quota_status = 'quota-on' if quota_status_action['set_quota_status'] else 'quota-off' if (self.parameters['activate_quota_on_change'] in ['resize', 'reinitialize'] diff --git a/plugins/modules/na_ontap_s3_users.py b/plugins/modules/na_ontap_s3_users.py new file mode 100644 index 00000000..4e41e819 --- /dev/null +++ b/plugins/modules/na_ontap_s3_users.py @@ -0,0 +1,177 @@ +#!/usr/bin/python + +# (c) 2022, NetApp, Inc +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = ''' +module: na_ontap_s3_users +short_description: NetApp ONTAP S3 users +extends_documentation_fragment: + - netapp.ontap.netapp.na_ontap +version_added: 21.19.0 +author: NetApp Ansible Team (@carchi8py) +description: +- Create, delete, or modify S3 users on NetApp ONTAP. + +options: + state: + description: + - Whether the specified S3 user should exist or not. + choices: ['present', 'absent'] + type: str + default: 'present' + + name: + description: + - The name of the S3 user. + type: str + required: true + + vserver: + description: + - Name of the vserver to use. + type: str + required: true + + comment: + description: + - comment about the user + type: str +''' + +EXAMPLES = """ + - name: create or modify s3 user + na_ontap_s3_users: + state: present + name: carchi8py + vserver: ansibleSVM + comment: not enabled + hostname: "{{ netapp_hostname }}" + username: "{{ netapp_username }}" + password: "{{ netapp_password }}" + https: true + validate_certs: false + use_rest: always + + - name: delete s3 user + na_ontap_s3_users: + state: absent + name: carchi8py + vserver: ansibleSVM + hostname: "{{ netapp_hostname }}" + username: "{{ netapp_username }}" + password: "{{ netapp_password }}" + https: true + validate_certs: false + use_rest: always +""" + +RETURN = """ +""" + +import traceback +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +import ansible_collections.netapp.ontap.plugins.module_utils.netapp as netapp_utils +from ansible_collections.netapp.ontap.plugins.module_utils.netapp_module import NetAppModule +from ansible_collections.netapp.ontap.plugins.module_utils.netapp import OntapRestAPI +from ansible_collections.netapp.ontap.plugins.module_utils import rest_generic +from ansible_collections.netapp.ontap.plugins.module_utils import rest_vserver + + +class NetAppOntapS3Users: + def __init__(self): + self.argument_spec = netapp_utils.na_ontap_host_argument_spec() + self.argument_spec.update(dict( + state=dict(required=False, type='str', choices=['present', 'absent'], default='present'), + vserver=dict(required=True, type='str'), + name=dict(required=True, type='str'), + comment=dict(required=False, type='str'), + )) + self.module = AnsibleModule( + argument_spec=self.argument_spec, + supports_check_mode=True + ) + self.svm_uuid = None + self.na_helper = NetAppModule(self.module) + self.parameters = self.na_helper.check_and_set_parameters(self.module) + self.rest_api = OntapRestAPI(self.module) + self.use_rest = self.rest_api.is_rest() + if not self.use_rest: + self.module.fail_json(msg='na_ontap_S3_users is only supported with REST API') + if not self.rest_api.meets_rest_minimum_version(self.use_rest, 9, 8): + self.module.fail_json(msg="ONTAP version must be 9.8 or higher") + + def get_s3_user(self): + self.get_svm_uuid() + api = 'protocols/s3/services/%s/users' % self.svm_uuid + fields = ','.join(('name', + 'comment')) + params = {'name': self.parameters['name'], + 'fields': fields} + record, error = rest_generic.get_one_record(self.rest_api, api, params) + if error: + self.module.fail_json(msg='Error fetching S3 user %s: %s' % (self.parameters['name'], to_native(error)), + exception=traceback.format_exc()) + return record + + def get_svm_uuid(self): + record, error = rest_vserver.get_vserver_uuid(self.rest_api, self.parameters['vserver'], self.module, True) + self.svm_uuid = record + + def create_s3_user(self): + api = 'protocols/s3/services/%s/users' % self.svm_uuid + body = {'name': self.parameters['name']} + if self.parameters.get('comment'): + body['comment'] = self.parameters['comment'] + dummy, error = rest_generic.post_async(self.rest_api, api, body) + if error: + self.module.fail_json(msg='Error creating S3 user %s: %s' % (self.parameters['name'], to_native(error)), + exception=traceback.format_exc()) + + def delete_s3_user(self): + api = 'protocols/s3/services/%s/users' % self.svm_uuid + dummy, error = rest_generic.delete_async(self.rest_api, api, self.parameters['name']) + if error: + self.module.fail_json(msg='Error deleting S3 user %s: %s' % (self.parameters['name'], to_native(error)), + exception=traceback.format_exc()) + + def modify_s3_user(self, modify): + api = 'protocols/s3/services/%s/users' % self.svm_uuid + body = {} + if modify.get('comment'): + body['comment'] = self.parameters['comment'] + dummy, error = rest_generic.patch_async(self.rest_api, api, self.parameters['name'], body) + if error: + self.module.fail_json(msg='Error modifying S3 user %s: %s' % (self.parameters['name'], to_native(error)), + exception=traceback.format_exc()) + + def apply(self): + current = self.get_s3_user() + cd_action, modify = None, None + cd_action = self.na_helper.get_cd_action(current, self.parameters) + if cd_action is None: + modify = self.na_helper.get_modified_attributes(current, self.parameters) + if self.na_helper.changed and not self.module.check_mode: + if cd_action == 'create': + self.create_s3_user() + if cd_action == 'delete': + self.delete_s3_user() + if modify: + self.modify_s3_user(modify) + self.module.exit_json(changed=self.na_helper.changed) + + +def main(): + '''Apply volume operations from playbook''' + obj = NetAppOntapS3Users() + obj.apply() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/na_ontap_vscan_on_access_policy.py b/plugins/modules/na_ontap_vscan_on_access_policy.py index 60eeee44..8011de85 100644 --- a/plugins/modules/na_ontap_vscan_on_access_policy.py +++ b/plugins/modules/na_ontap_vscan_on_access_policy.py @@ -434,8 +434,6 @@ def get_on_access_policy_rest(self): def get_svm_uuid(self): uuid, error = rest_vserver.get_vserver_uuid(self.rest_api, self.parameters['vserver'], self.module, True) - if error: - self.module.fail_json(msg="Error: Specified vserver %s not found" % self.parameters['vserver']) return uuid def create_on_access_policy_rest(self): diff --git a/tests/unit/plugins/modules/test_na_ontap_s3_users.py b/tests/unit/plugins/modules/test_na_ontap_s3_users.py new file mode 100644 index 00000000..65bc79ea --- /dev/null +++ b/tests/unit/plugins/modules/test_na_ontap_s3_users.py @@ -0,0 +1,158 @@ +# (c) 2022, NetApp, Inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +import pytest +import sys + +from ansible_collections.netapp.ontap.tests.unit.compat.mock import patch, call +import ansible_collections.netapp.ontap.plugins.module_utils.netapp as netapp_utils +from ansible_collections.netapp.ontap.tests.unit.plugins.module_utils.ansible_mocks import set_module_args, \ + patch_ansible, create_and_apply, create_module, expect_and_capture_ansible_exception +from ansible_collections.netapp.ontap.tests.unit.framework.mock_rest_and_zapi_requests import get_mock_record, \ + patch_request_and_invoke, register_responses +from ansible_collections.netapp.ontap.tests.unit.framework.rest_factory import rest_responses + +from ansible_collections.netapp.ontap.plugins.modules.na_ontap_s3_users \ + import NetAppOntapS3Users as my_module, main as my_main # module under test + +if not netapp_utils.HAS_REQUESTS and sys.version_info < (2, 7): + pytestmark = pytest.mark.skip('Skipping Unit Tests on 2.6 as requests is not available') + +SRR = rest_responses({ + 's3_user': (200, { + "records": [ + { + "comment": "S3 user", + "name": "carchi8py", + "svm": { + "name": "svm1", + "uuid": "e3cb5c7f-cd20" + } + } + ], + "num_records": 1 + }, None), + 'svm_uuid': (200, {"records": [ + { + 'uuid': 'e3cb5c7f-cd20' + }], "num_records": 1}, None) +}) + +DEFAULT_ARGS = { + 'hostname': 'hostname', + 'username': 'username', + 'password': 'password', + 'name': 'carchi8py', + 'vserver': 'vserver' +} + + +def test_low_version(): + register_responses([ + ('GET', 'cluster', SRR['is_rest_97']) + ]) + error = create_module(my_module, DEFAULT_ARGS, fail=True)['msg'] + print('Info: %s' % error) + msg = 'ONTAP version must be 9.8 or higher' + assert msg in error + + +def test_get_s3_users_none(): + register_responses([ + ('GET', 'cluster', SRR['is_rest_9_10_1']), + ('GET', 'svm/svms', SRR['svm_uuid']), + ('GET', 'protocols/s3/services/e3cb5c7f-cd20/users', SRR['empty_records']) + ]) + set_module_args(DEFAULT_ARGS) + my_obj = my_module() + assert my_obj.get_s3_user() is None + + +def test_get_s3_users_error(): + register_responses([ + ('GET', 'cluster', SRR['is_rest_9_10_1']), + ('GET', 'svm/svms', SRR['svm_uuid']), + ('GET', 'protocols/s3/services/e3cb5c7f-cd20/users', SRR['generic_error']) + ]) + my_module_object = create_module(my_module, DEFAULT_ARGS) + msg = 'Error fetching S3 user carchi8py: calling: protocols/s3/services/e3cb5c7f-cd20/users: got Expected error.' + assert msg in expect_and_capture_ansible_exception(my_module_object.get_s3_user, 'fail')['msg'] + + +def test_create_s3_users(): + register_responses([ + ('GET', 'cluster', SRR['is_rest_9_10_1']), + ('GET', 'svm/svms', SRR['svm_uuid']), + ('GET', 'protocols/s3/services/e3cb5c7f-cd20/users', SRR['empty_records']), + ('POST', 'protocols/s3/services/e3cb5c7f-cd20/users', SRR['empty_good']) + ]) + module_args = { + 'comment': 'this is a s3 user', + } + assert create_and_apply(my_module, DEFAULT_ARGS, module_args)['changed'] + + +def test_create_s3_user_error(): + register_responses([ + ('GET', 'cluster', SRR['is_rest_9_8_0']), + ('POST', 'protocols/s3/services/e3cb5c7f-cd20/users', SRR['generic_error']) + ]) + my_obj = create_module(my_module, DEFAULT_ARGS) + my_obj.parameters['comment'] = 'this is a s3 user' + my_obj.svm_uuid = 'e3cb5c7f-cd20' + error = expect_and_capture_ansible_exception(my_obj.create_s3_user, 'fail')['msg'] + print('Info: %s' % error) + assert 'Error creating S3 user carchi8py: calling: protocols/s3/services/e3cb5c7f-cd20/users: got Expected error.' == error + + +def test_delete_s3_user(): + register_responses([ + ('GET', 'cluster', SRR['is_rest_9_10_1']), + ('GET', 'svm/svms', SRR['svm_uuid']), + ('GET', 'protocols/s3/services/e3cb5c7f-cd20/users', SRR['s3_user']), + ('DELETE', 'protocols/s3/services/e3cb5c7f-cd20/users/carchi8py', SRR['empty_good']) + ]) + module_args = {'state': 'absent'} + assert create_and_apply(my_module, DEFAULT_ARGS, module_args)['changed'] + + +def test_delete_s3_user_error(): + register_responses([ + ('GET', 'cluster', SRR['is_rest_9_8_0']), + ('DELETE', 'protocols/s3/services/e3cb5c7f-cd20/users/carchi8py', SRR['generic_error']) + ]) + my_obj = create_module(my_module, DEFAULT_ARGS) + my_obj.parameters['state'] = 'absent' + my_obj.svm_uuid = 'e3cb5c7f-cd20' + error = expect_and_capture_ansible_exception(my_obj.delete_s3_user, 'fail')['msg'] + print('Info: %s' % error) + assert 'Error deleting S3 user carchi8py: calling: protocols/s3/services/e3cb5c7f-cd20/users/carchi8py: got Expected error.' == error + + +def test_modify_s3_user(): + register_responses([ + ('GET', 'cluster', SRR['is_rest_9_10_1']), + ('GET', 'svm/svms', SRR['svm_uuid']), + ('GET', 'protocols/s3/services/e3cb5c7f-cd20/users', SRR['s3_user']), + ('PATCH', 'protocols/s3/services/e3cb5c7f-cd20/users/carchi8py', SRR['empty_good']) + ]) + module_args = {'comment': 'this is a modify comment'} + assert create_and_apply(my_module, DEFAULT_ARGS, module_args)['changed'] + + +def test_modify_s3_user_error(): + register_responses([ + ('GET', 'cluster', SRR['is_rest_9_8_0']), + ('PATCH', 'protocols/s3/services/e3cb5c7f-cd20/users/carchi8py', SRR['generic_error']) + ]) + my_obj = create_module(my_module, DEFAULT_ARGS) + my_obj.parameters['comment'] = 'this is a modified s3 service' + current = {'comment': 'this is a modified s3 service'} + my_obj.svm_uuid = 'e3cb5c7f-cd20' + error = expect_and_capture_ansible_exception(my_obj.modify_s3_user, 'fail', current)['msg'] + print('Info: %s' % error) + assert 'Error modifying S3 user carchi8py: calling: protocols/s3/services/e3cb5c7f-cd20/users/carchi8py: got Expected error.' == error