From ea492bed7e3e5c338119f8fbfde68f6c41993d47 Mon Sep 17 00:00:00 2001 From: "Yusuf A. Hasan Miyan" Date: Tue, 20 Aug 2024 09:00:55 +0400 Subject: [PATCH] [DEVOPS-499] Refactored group to use the MutationActionConfig --- README.md | 19 +++- api/plugins/action/group.py | 67 +++--------- api/plugins/action/task_definition.py | 149 +++++++++++++------------- api/plugins/module_utils/argspec.py | 4 +- api/plugins/module_utils/gql.py | 30 ++++-- api/plugins/modules/group.py | 44 ++++++++ api/tests/common/__init__.py | 3 + 7 files changed, 175 insertions(+), 141 deletions(-) create mode 100644 api/plugins/modules/group.py diff --git a/README.md b/README.md index 41f5b28..97d435d 100644 --- a/README.md +++ b/README.md @@ -25,21 +25,30 @@ gql-cli https://api.lagoon.amazeeio.cloud/graphql --print-schema \ ## Run unit tests ```sh -docker-compose build test -docker-compose run --rm test units -v --requirements +docker compose build test +docker compose run --rm test units -v --requirements ``` ## Creating the docs +To view the module docs in the terminal, run +```sh +# List modules +docker compose run --rm --entrypoint="" -T lint-docs bash -c \ + 'ansible-doc -t module lagoon.api -l' + +# Specific module (group) +docker compose run --rm --entrypoint="" -T lint-docs bash -c \ + 'ansible-doc -t module lagoon.api.group' +``` + Linting the docs ```sh -docker compose build lint-docs docker compose run --rm lint-docs ``` -Build the docs: +Build & serve the docs: ```sh -docker compose build docs docker compose up -d docs ``` diff --git a/api/plugins/action/group.py b/api/plugins/action/group.py index b520f8b..5e6a6ec 100644 --- a/api/plugins/action/group.py +++ b/api/plugins/action/group.py @@ -1,58 +1,15 @@ -from ..module_utils.api_client import ApiClient -from ansible.errors import AnsibleError -from ansible.module_utils._text import to_native -from ansible.plugins.action import ActionBase +from . import LagoonMutationActionBase, MutationConfig, MutationActionConfig +from ..module_utils.gql import ProxyLookup -class ActionModule(ActionBase): +class ActionModule(LagoonMutationActionBase): - def run(self, tmp=None, task_vars=None): - - if task_vars is None: - task_vars = dict() - - result = super(ActionModule, self).run(tmp, task_vars) - del tmp # tmp no longer has any effect - - self._display.v("Task args: %s" % self._task.args) - - lagoon = ApiClient( - self._templar.template(task_vars.get('lagoon_api_endpoint')).strip(), - self._templar.template(task_vars.get('lagoon_api_token')).strip(), - {'headers': self._task.args.get('headers', {})} - ) - - state = self._task.args.get('state') - name = self._task.args.get('name') - # parent = self._task.args.get('parent') - - if not name: - raise AnsibleError("Missing required parameter 'name'.") - - group = lagoon.group(name) - - if (state == "present"): - if group['id']: - result['changed'] = False - result['group'] = group - else: - try: - result['group'] = lagoon.group_add(name) - except AnsibleError as e: - result['failed'] = True - result['msg'] = to_native(e) - - elif (state == "absent"): - if not group['id']: - result['changed'] = False - else: - result['changed'] = True - try: - result['group'] = lagoon.group_remove(group['id']) - except AnsibleError as e: - result['failed'] = True - result['msg'] = to_native(e) - else: - raise AnsibleError("Invalid state '%s' operation." % (state)) - - return result + actionConfig = MutationActionConfig( + name="group", + add=MutationConfig( + field="addGroup", + proxyLookups=[ProxyLookup(query="groupByName")], + lookupCompareFields=["name"], + ), + delete=MutationConfig(field="deleteGroup"), + ) diff --git a/api/plugins/action/task_definition.py b/api/plugins/action/task_definition.py index 496fc5f..ab60fe1 100644 --- a/api/plugins/action/task_definition.py +++ b/api/plugins/action/task_definition.py @@ -4,77 +4,80 @@ class ActionModule(LagoonMutationActionBase): - actionConfig = MutationActionConfig( - name="task_definition", - # Configuration for adding a new task definition. - add=MutationConfig( - # GraphQL Mutation for adding a new task definition. - field="addAdvancedTaskDefinition", - # GraphQL Mutation for updating an existing task definition. - updateField="updateAdvancedTaskDefinition", - # Additional arguments to be allowed when calling the action - # plugin. These arguments will not be passed to the GraphQL - # mutation, but would instead be used to lookup a project by name - # in one of the proxy lookups. - inputFieldAdditionalArgs=dict(project_name=dict(type="str")), - # Aliases for the input fields - in this case used to maintain - # compatibility with the previous version of the plugin. - inputFieldArgsAliases=dict( - advancedTaskDefinitionArguments=["arguments"], - deployTokenInjection=["deploy_token_injection"], - projectKeyInjection=["project_key_injection"], - type=["task_type"], - ), - # Proxy lookups to be used when looking for existing task - # definitions. A first pass is done through the lookups in the - # order they are provided, to find one that matches the input - # of the plugin. The first one matched is then used to query - # task definitions and compare them with the input, using the - # lookupCompareFields and diffCompareFields. - proxyLookups=[ - # Will use 'id' if provided to lookup a task definition by id. - ProxyLookup(query="advancedTaskDefinitionById"), - # Will use 'environment' id if provided to find all task - # definitions by environment id, then filter by the fields - # in lookupCompareFields. - ProxyLookup(query="advancedTasksForEnvironment"), - # Will use 'project_name' if provided to find all task - # definitions for a project through projectByName, selecting - # the fields in selectFields recursively, then filter by the - # fields in lookupCompareFields. - # This would be similar to the following: - # query { - # projectByName(project_name: "{{ project_name }}") { - # environments { - # advancedTasks { - # id - # ... - # } - # } - # } - # } - ProxyLookup( - query="projectByName", - inputArgFields={"project_name": "name"}, - selectFields=["environments", "advancedTasks"], - ), - ], - # These fields are used to determine whether the task definition - # already exists. - lookupCompareFields=["name"], - # These fields are used to determine whether the task definition - # needs to be updated. If any of the values of these fields are - # different between the existing task definition and the input, - # the task definition will be updated. - diffCompareFields=[ - "permission", - "description", - "service", - "advancedTaskDefinitionArguments", - "deployTokenInjection", - "projectKeyInjection", - ], + actionConfig = MutationActionConfig( + name="task_definition", + # Configuration for adding a new task definition. + add=MutationConfig( + # GraphQL Mutation for adding a new task definition. + field="addAdvancedTaskDefinition", + # GraphQL Mutation for updating an existing task definition. + updateField="updateAdvancedTaskDefinition", + # Additional arguments to be allowed when calling the action + # plugin. These arguments will not be passed to the GraphQL + # mutation, but would instead be used to lookup a project by name + # in one of the proxy lookups. + inputFieldAdditionalArgs=dict(project_name=dict(type="str")), + # Aliases for the input fields - in this case used to maintain + # compatibility with the previous version of the plugin. + inputFieldArgsAliases=dict( + advancedTaskDefinitionArguments=["arguments"], + deployTokenInjection=["deploy_token_injection"], + projectKeyInjection=["project_key_injection"], + type=["task_type"], + ), + # Proxy lookups to be used when looking for existing task + # definitions. A first pass is done through the lookups in the + # order they are provided, to find one that matches the input + # of the plugin. The first one matched is then used to query + # task definitions and compare them with the input, using the + # lookupCompareFields and diffCompareFields. + proxyLookups=[ + # Will use 'id' if provided to lookup a task definition by id. + ProxyLookup(query="advancedTaskDefinitionById"), + # Will use 'environment' id if provided to find all task + # definitions by environment id, then filter by the fields + # in lookupCompareFields. + ProxyLookup(query="advancedTasksForEnvironment"), + # Will use 'project_name' if provided to find all task + # definitions for a project through projectByName, selecting + # the fields in selectFields recursively, then filter by the + # fields in lookupCompareFields. + # This would be similar to the following: + # query { + # projectByName(project_name: "{{ project_name }}") { + # environments { + # advancedTasks { + # id + # ... + # } + # } + # } + # } + ProxyLookup( + query="projectByName", + inputArgFields={"project_name": "name"}, + selectFields=["environments", "advancedTasks"], ), - # Configuration for deleting a task definition. - delete=MutationConfig(field="deleteAdvancedTaskDefinition"), - ) + ], + # These fields are used to determine whether the task definition + # already exists. + lookupCompareFields=["name"], + # These fields are used to determine whether the task definition + # needs to be updated. If any of the values of these fields are + # different between the existing task definition and the input, + # the task definition will be updated. + diffCompareFields=[ + "permission", + "description", + "service", + "advancedTaskDefinitionArguments", + "deployTokenInjection", + "projectKeyInjection", + ], + ), + # Configuration for deleting a task definition. + delete=MutationConfig( + field="deleteAdvancedTaskDefinition", + proxyLookups=[ProxyLookup(query="advancedTaskDefinitionById")], + ), + ) diff --git a/api/plugins/module_utils/argspec.py b/api/plugins/module_utils/argspec.py index b7eac3e..f5b8867 100644 --- a/api/plugins/module_utils/argspec.py +++ b/api/plugins/module_utils/argspec.py @@ -40,7 +40,7 @@ def generate_argspec_from_mutation( for fieldName, arg in mutationField.field.args.items(): argSpec[fieldName] = generate_argspec_for_input_type(arg.type, aliases) - if 'input' in argSpec: + if 'input' in argSpec and additionalArgs: argSpec['input']['options'].update(additionalArgs) elif additionalArgs: argSpec.update(additionalArgs) @@ -54,7 +54,7 @@ def generate_argspec_from_input_object_type( field: GraphQLInputField for fieldName, field in fields.items(): argSpec[fieldName] = generate_argspec_for_input_type(field.type, aliases) - if fieldName in aliases: + if aliases and fieldName in aliases: argSpec[fieldName]['aliases'] = aliases[fieldName] return argSpec diff --git a/api/plugins/module_utils/gql.py b/api/plugins/module_utils/gql.py index 4471450..2cdd12d 100644 --- a/api/plugins/module_utils/gql.py +++ b/api/plugins/module_utils/gql.py @@ -13,9 +13,15 @@ ) from gql.transport.exceptions import TransportQueryError from gql.transport.requests import RequestsHTTPTransport -from graphql import print_ast, GraphQLList, GraphQLOutputType, GraphQLUnionType +from graphql import ( + print_ast, + GraphQLList, + GraphQLOutputType, + GraphQLUnionType, +) from graphql.type.definition import ( is_enum_type, + is_interface_type, is_list_type, is_object_type, is_output_type, @@ -96,6 +102,11 @@ def execute_query(self, query: str, variables: Optional[Dict[str, Any]]={}) -> D self.vvvv(f"GraphQL query result: {res}\n\n") return res except TransportQueryError as e: + # In some cases (groupByName), an error is returned when not found, + # whereas in others it's just an empty result (projectByName). + # Let's keep it consistent. + if 'not found' in str(e): + return e.data self.vvvv(f"GraphQL TransportQueryError: {e}\n\n") return {'error': e} @@ -115,15 +126,23 @@ def execute_query_dynamic(self, *operations: DSLExecutable) -> Dict[str, Any]: full_query = dsl_gql(*operations) self.vvv(f"GraphQL built query: \n{print_ast(full_query)}") + opName = operations[0].selection_set.selections[0].name.value if self.checkMode and isinstance(operations[0], DSLMutation): self.info(f"Check mode enabled, skipping query execution. Query to execute: \n{print_ast(full_query)}") - opName = operations[0].selection_set.selections[0].name.value if opName.startswith('delete'): res = {opName: 'success'} else: res = {opName: {'id': -1 * randint(1, 1000)}} else: - res = self.client.session.execute(full_query) + try: + res = self.client.session.execute(full_query) + except TransportQueryError as e: + # In some cases (groupByName), an error is returned when + # not found, whereas in others it's just an empty result + # (projectByName). Let's keep it consistent. + if 'not found' in str(e): + return e.data + raise e self.vvv(f"GraphQL query result: {res}\n\n") return res @@ -249,7 +268,6 @@ def mutation_field_add_args(self, if not isinstance(mutationField, DSLField): raise TypeError(f"mutationField must be of type DSLField, got {type(mutationField)}.") - if not is_output_type(outputType): raise TypeError(f"outputType must be of type GraphQLOutputType, got {type(outputType)}.") @@ -264,7 +282,7 @@ def mutation_field_add_args(self, set(mutationField.field.args.keys()) & set(inputArgs.keys())) mutationField.args(**{argsIntersect[0]: inputArgs[argsIntersect[0]]}) - elif is_union_type(outputType) or is_object_type(outputType): + elif is_union_type(outputType) or is_object_type(outputType) or is_interface_type(outputType): mutationField.args(**inputArgs) elif is_list_type(outputType): listObj = cast(GraphQLList, outputType) @@ -447,7 +465,7 @@ def field_selector(ds: DSLSchema, return selector elif is_list_type(selectorType): return field_selector(ds, selector, selectorType.of_type, selectFields) - elif is_object_type(selectorType): + elif is_object_type(selectorType) or is_interface_type(selectorType): selectType: DSLType = getattr(ds, selectorType.name) for f in selectFields: if not hasattr(selectType, f): diff --git a/api/plugins/modules/group.py b/api/plugins/modules/group.py new file mode 100644 index 0000000..a2cbdc2 --- /dev/null +++ b/api/plugins/modules/group.py @@ -0,0 +1,44 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +DOCUMENTATION = r""" +module: group +short_description: Manage groups. +description: + - Manages groups. +options: + name: + description: + - The name of the group. + required: true + type: str + parentGroup: + description: + - The name of the fact. + required: false + type: dict + suboptions: + id: + description: The ID of the parent group. + type: int + name: + description: The name of the parent group. + type: str + state: + description: + - Determines if the group should be created or deleted. + type: str + default: present + choices: [ absent, present ] +""" + +EXAMPLES = r""" +- name: Add a Group + lagoon.api.group: + name: saas + +- name: Delete a Group + lagoon.api.group: + name: saas + state: absent +""" diff --git a/api/tests/common/__init__.py b/api/tests/common/__init__.py index 5888d1c..61168fe 100644 --- a/api/tests/common/__init__.py +++ b/api/tests/common/__init__.py @@ -28,12 +28,15 @@ def get_mock_gql_client( query_return_value: any = None, query_dynamic_return_value: any = None) -> GqlClient: client = GqlClient('foo', 'bar') + client.execute_query = MagicMock() if query_return_value: client.execute_query.return_value = query_return_value + client.execute_query_dynamic = MagicMock() if query_dynamic_return_value: client.execute_query_dynamic.return_value = query_dynamic_return_value + client.client.connect_sync = MagicMock() client.client.schema = load_schema() client.client.session = SyncClientSession(client=client.client)