diff --git a/.config/dictionary.txt b/.config/dictionary.txt index e469ccf1..8dc43434 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -55,3 +55,10 @@ auths Passw AUTHS EDAHTTP +refspec +Alina +Buzachis +alinabuzachis +hdrs +testuser +testsecret diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 37f0fe24..a736a8d8 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -136,6 +136,7 @@ jobs: ansible_collections/ansible/eda/.tox/**/log/ ansible_collections/ansible/eda/.tox/**/coverage.xml ansible_collections/ansible/eda/tests/output/reports/coverage.xml + ansible_collections/ansible/eda/tests/output/junit/*.xml - name: Report failure if git reports dirty status run: | @@ -148,6 +149,7 @@ jobs: # https://github.com/actions/toolkit/issues/193 check: if: always() + environment: check permissions: id-token: write checks: read @@ -185,6 +187,20 @@ jobs: - name: Check for expected number of coverage reports run: .github/check-coverage.sh + # Single uploads inside check job for codecov to allow use to retry + # it when it fails without running tests again. Fails often enough! + - name: Upload junit xml reports + # PRs from forks might not have access to the secret + if: env.CODECOV_TOKEN + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN || env.CODECOV_TOKEN }} + uses: codecov/test-results-action@v1 + with: + name: ${{ matrix.name }} + files: "*/tests/output/junit/*.xml" + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload coverage data uses: codecov/codecov-action@v4 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ace19b40..4e57abdb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: hooks: - id: shellcheck - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v8.13.1 + rev: v8.13.3 hooks: - id: cspell - repo: https://github.com/pycqa/isort @@ -36,8 +36,33 @@ repos: hooks: - id: black language_version: python3 + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.1 + hooks: + - id: mypy + # empty args needed in order to match mypy cli behavior + args: [] + additional_dependencies: + - aiohttp + - aiokafka + - ansible-core>=2.15 + - asyncmock + - azure-servicebus + - dpath + - kafka-python-ng; python_version >= "3.12" + - kafka-python; python_version < "3.12" + - psycopg[binary,pool] # extras needed to avoid install failure on macos-aarch64 + - pytest + - types-PyYAML + - types-aiobotocore[cloudtrail,sqs] + - types-botocore + - types-mock + - types-requests + - watchdog + - xxhash + - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.5.6" + rev: "v0.6.0" hooks: - id: ruff args: [ @@ -63,17 +88,19 @@ repos: - aiobotocore - aiohttp - aiokafka + - ansible-core - asyncmock - azure-servicebus + - botocore - dpath - kafka-python - psycopg - pytest - pyyaml - requests + - types-aiobotocore - watchdog - xxhash - - ansible-core - repo: local hooks: - id: ansible-test-sanity diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d719aa2..00896ec6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,8 +57,7 @@ tox -e py ### Integration tests -Integration tests require the addition of [docker](https://docs.docker.com/engine/install/) or [podman](https://podman.io/getting-started/installation) and [docker-compose](https://docs.docker.com/compose/install/). - +Integration tests require the addition of [docker](https://docs.docker.com/engine/install/) or [podman](https://podman.io/getting-started/installation). Then install the collection directly from your local repo and execute the tests: diff --git a/extensions/eda/plugins/event_source/alertmanager.py b/extensions/eda/plugins/event_source/alertmanager.py index ffd1f032..b4efd5d6 100644 --- a/extensions/eda/plugins/event_source/alertmanager.py +++ b/extensions/eda/plugins/event_source/alertmanager.py @@ -141,7 +141,7 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: if __name__ == "__main__": """MockQueue if running directly.""" - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" async def put(self: "MockQueue", event: dict) -> None: diff --git a/extensions/eda/plugins/event_source/aws_cloudtrail.py b/extensions/eda/plugins/event_source/aws_cloudtrail.py index c09e716b..6f6b680e 100644 --- a/extensions/eda/plugins/event_source/aws_cloudtrail.py +++ b/extensions/eda/plugins/event_source/aws_cloudtrail.py @@ -36,8 +36,8 @@ from datetime import datetime from typing import Any -from aiobotocore.client import BaseClient from aiobotocore.session import get_session +from botocore.client import BaseClient def _cloudtrail_event_to_dict(event: dict) -> dict: @@ -46,7 +46,7 @@ def _cloudtrail_event_to_dict(event: dict) -> dict: return event -def _get_events(events: list[dict], last_event_ids: list) -> list: +def _get_events(events: list[dict], last_event_ids: list[str]) -> list: event_time = None event_ids = [] result = [] @@ -60,7 +60,7 @@ def _get_events(events: list[dict], last_event_ids: list) -> list: elif event_time == event["EventTime"]: event_ids.append(event["EventId"]) result.append(event) - return result, event_time, event_ids + return [result, event_time, event_ids] async def _get_cloudtrail_events(client: BaseClient, params: dict) -> list[dict]: @@ -89,7 +89,7 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: async with session.create_client("cloudtrail", **connection_args(args)) as client: event_time = None - event_ids = [] + event_ids: list[str] = [] while True: if event_time is not None: params["StartTime"] = event_time @@ -128,7 +128,7 @@ def connection_args(args: dict[str, Any]) -> dict[str, Any]: if __name__ == "__main__": """MockQueue if running directly.""" - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" async def put(self: "MockQueue", event: dict) -> None: diff --git a/extensions/eda/plugins/event_source/aws_sqs_queue.py b/extensions/eda/plugins/event_source/aws_sqs_queue.py index 7ee5ba95..66c85d7c 100644 --- a/extensions/eda/plugins/event_source/aws_sqs_queue.py +++ b/extensions/eda/plugins/event_source/aws_sqs_queue.py @@ -30,6 +30,7 @@ from aiobotocore.session import get_session +# pylint: disable=too-many-locals async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: """Receive events via an AWS SQS queue.""" logger = logging.getLogger() @@ -58,18 +59,25 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: while True: # This loop won't spin really fast as there is # essentially a sleep in the receive_message call - response = await client.receive_message( + response_msg = await client.receive_message( QueueUrl=queue_url, WaitTimeSeconds=wait_seconds, ) - if "Messages" in response: - for msg in response["Messages"]: - meta = {"MessageId": msg["MessageId"]} + if "Messages" in response_msg: + for entry in response_msg["Messages"]: + if ( + not isinstance(entry, dict) or "MessageId" not in entry + ): # pragma: no cover + err_msg = ( + f"Unexpected response {response_msg}, missing MessageId." + ) + raise ValueError(err_msg) + meta = {"MessageId": entry["MessageId"]} try: - msg_body = json.loads(msg["Body"]) + msg_body = json.loads(entry["Body"]) except json.JSONDecodeError: - msg_body = msg["Body"] + msg_body = entry["Body"] await queue.put({"body": msg_body, "meta": meta}) await asyncio.sleep(0) @@ -77,7 +85,7 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: # Need to remove msg from queue or else it'll reappear await client.delete_message( QueueUrl=queue_url, - ReceiptHandle=msg["ReceiptHandle"], + ReceiptHandle=entry["ReceiptHandle"], ) else: logger.debug("No messages in queue") @@ -106,7 +114,7 @@ def connection_args(args: dict[str, Any]) -> dict[str, Any]: if __name__ == "__main__": # MockQueue if running directly - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" async def put(self: "MockQueue", event: dict) -> None: diff --git a/extensions/eda/plugins/event_source/azure_service_bus.py b/extensions/eda/plugins/event_source/azure_service_bus.py index 940e6ec6..f00d14c5 100644 --- a/extensions/eda/plugins/event_source/azure_service_bus.py +++ b/extensions/eda/plugins/event_source/azure_service_bus.py @@ -66,10 +66,10 @@ async def main( if __name__ == "__main__": """MockQueue if running directly.""" - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" - async def put_nowait(self: "MockQueue", event: dict) -> None: + def put_nowait(self: "MockQueue", event: dict) -> None: """Print the event.""" print(event) # noqa: T201 diff --git a/extensions/eda/plugins/event_source/file.py b/extensions/eda/plugins/event_source/file.py index 09403d98..df5809e9 100644 --- a/extensions/eda/plugins/event_source/file.py +++ b/extensions/eda/plugins/event_source/file.py @@ -18,7 +18,7 @@ import pathlib import yaml -from watchdog.events import RegexMatchingEventHandler +from watchdog.events import FileSystemEvent, RegexMatchingEventHandler from watchdog.observers import Observer @@ -63,18 +63,18 @@ class Handler(RegexMatchingEventHandler): def __init__(self: "Handler", **kwargs) -> None: # noqa: ANN003 RegexMatchingEventHandler.__init__(self, **kwargs) - def on_created(self: "Handler", event: dict) -> None: + def on_created(self: "Handler", event: FileSystemEvent) -> None: if event.src_path in files: send_facts(queue, event.src_path) - def on_deleted(self: "Handler", event: dict) -> None: + def on_deleted(self: "Handler", event: FileSystemEvent) -> None: pass - def on_modified(self: "Handler", event: dict) -> None: + def on_modified(self: "Handler", event: FileSystemEvent) -> None: if event.src_path in files: send_facts(queue, event.src_path) - def on_moved(self: "Handler", event: dict) -> None: + def on_moved(self: "Handler", event: FileSystemEvent) -> None: pass observer = Observer() diff --git a/extensions/eda/plugins/event_source/file_watch.py b/extensions/eda/plugins/event_source/file_watch.py index 6d643b62..2c28306e 100644 --- a/extensions/eda/plugins/event_source/file_watch.py +++ b/extensions/eda/plugins/event_source/file_watch.py @@ -20,8 +20,9 @@ import asyncio import concurrent.futures +from typing import Any -from watchdog.events import RegexMatchingEventHandler +from watchdog.events import FileSystemEvent, RegexMatchingEventHandler from watchdog.observers import Observer @@ -36,10 +37,10 @@ def watch( class Handler(RegexMatchingEventHandler): """A handler for file system events.""" - def __init__(self: "Handler", **kwargs: dict) -> None: + def __init__(self: "Handler", **kwargs: FileSystemEvent) -> None: RegexMatchingEventHandler.__init__(self, **kwargs) - def on_created(self: "Handler", event: dict) -> None: + def on_created(self: "Handler", event: FileSystemEvent) -> None: loop.call_soon_threadsafe( queue.put_nowait, { @@ -50,7 +51,7 @@ def on_created(self: "Handler", event: dict) -> None: }, ) - def on_deleted(self: "Handler", event: dict) -> None: + def on_deleted(self: "Handler", event: FileSystemEvent) -> None: loop.call_soon_threadsafe( queue.put_nowait, { @@ -61,7 +62,7 @@ def on_deleted(self: "Handler", event: dict) -> None: }, ) - def on_modified(self: "Handler", event: dict) -> None: + def on_modified(self: "Handler", event: FileSystemEvent) -> None: loop.call_soon_threadsafe( queue.put_nowait, { @@ -72,7 +73,7 @@ def on_modified(self: "Handler", event: dict) -> None: }, ) - def on_moved(self: "Handler", event: dict) -> None: + def on_moved(self: "Handler", event: FileSystemEvent) -> None: loop.call_soon_threadsafe( queue.put_nowait, { @@ -106,10 +107,10 @@ async def main(queue: asyncio.Queue, args: dict) -> None: if __name__ == "__main__": """MockQueue if running directly.""" - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" - async def put_nowait(self: "MockQueue", event: dict) -> None: + def put_nowait(self: "MockQueue", event: dict) -> None: """Print the event.""" print(event) # noqa: T201 diff --git a/extensions/eda/plugins/event_source/generic.py b/extensions/eda/plugins/event_source/generic.py index e3636d80..43228e3d 100644 --- a/extensions/eda/plugins/event_source/generic.py +++ b/extensions/eda/plugins/event_source/generic.py @@ -190,7 +190,7 @@ def _create_data( self: Generic, index: int, ) -> dict: - data = {} + data: dict[str, str | int] = {} if self.my_args.create_index: data[self.my_args.create_index] = index if self.blob: @@ -215,7 +215,7 @@ async def main( # pylint: disable=R0914 if __name__ == "__main__": - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" async def put(self: MockQueue, event: dict) -> None: diff --git a/extensions/eda/plugins/event_source/journald.py b/extensions/eda/plugins/event_source/journald.py index 969c6f26..67087e0e 100644 --- a/extensions/eda/plugins/event_source/journald.py +++ b/extensions/eda/plugins/event_source/journald.py @@ -33,7 +33,7 @@ from systemd import journal # type: ignore -async def main(queue: asyncio.Queue, args: dict[str, Any]) -> str: # noqa: D417 +async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: # noqa: D417 """Read journal entries and add them to the provided queue. Args: @@ -76,10 +76,10 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> str: # noqa: D417 Entry point of the program. """ - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A mock implementation of a queue that prints the event.""" - async def put(self: str, event: str) -> str: + async def put(self, event: str) -> None: """Add the event to the queue and print it.""" print(event) # noqa: T201 diff --git a/extensions/eda/plugins/event_source/kafka.py b/extensions/eda/plugins/event_source/kafka.py index c26e057b..21282457 100644 --- a/extensions/eda/plugins/event_source/kafka.py +++ b/extensions/eda/plugins/event_source/kafka.py @@ -124,11 +124,13 @@ async def receive_msg( logger = logging.getLogger() async for msg in kafka_consumer: - event = {} + event: dict[str, Any] = {} # Process headers try: - headers = {header[0]: header[1].decode(encoding) for header in msg.headers} + headers: dict[str, str] = { + header[0]: header[1].decode(encoding) for header in msg.headers + } event["meta"] = {} event["meta"]["headers"] = headers except UnicodeError: @@ -156,7 +158,7 @@ async def receive_msg( if __name__ == "__main__": """MockQueue if running directly.""" - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" async def put(self: "MockQueue", event: dict) -> None: diff --git a/extensions/eda/plugins/event_source/pg_listener.py b/extensions/eda/plugins/event_source/pg_listener.py index b551a7e4..2cd66aa9 100644 --- a/extensions/eda/plugins/event_source/pg_listener.py +++ b/extensions/eda/plugins/event_source/pg_listener.py @@ -99,7 +99,7 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: conninfo=args["dsn"], autocommit=True, ) as conn: - chunked_cache = {} + chunked_cache: dict[str, Any] = {} cursor = conn.cursor() for channel in args["channels"]: await cursor.execute(f"LISTEN {channel};") @@ -118,7 +118,7 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: async def _handle_chunked_message( - data: dict, + data: dict[str, Any], chunked_cache: dict, queue: asyncio.Queue, ) -> None: @@ -169,7 +169,7 @@ async def _handle_chunked_message( if __name__ == "__main__": # MockQueue if running directly - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" async def put(self: "MockQueue", event: dict) -> None: diff --git a/extensions/eda/plugins/event_source/range.py b/extensions/eda/plugins/event_source/range.py index 01609c6d..1862c51d 100644 --- a/extensions/eda/plugins/event_source/range.py +++ b/extensions/eda/plugins/event_source/range.py @@ -30,7 +30,7 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: if __name__ == "__main__": # MockQueue if running directly - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" async def put(self: "MockQueue", event: dict) -> None: diff --git a/extensions/eda/plugins/event_source/tick.py b/extensions/eda/plugins/event_source/tick.py index eb368150..da6227dd 100644 --- a/extensions/eda/plugins/event_source/tick.py +++ b/extensions/eda/plugins/event_source/tick.py @@ -31,7 +31,7 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: if __name__ == "__main__": """MockQueue if running directly.""" - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" async def put(self: "MockQueue", event: dict) -> None: diff --git a/extensions/eda/plugins/event_source/url_check.py b/extensions/eda/plugins/event_source/url_check.py index f02ca55e..cfefce51 100644 --- a/extensions/eda/plugins/event_source/url_check.py +++ b/extensions/eda/plugins/event_source/url_check.py @@ -69,7 +69,7 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: if __name__ == "__main__": """MockQueue if running directly.""" - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" async def put(self: "MockQueue", event: dict) -> None: diff --git a/extensions/eda/plugins/event_source/webhook.py b/extensions/eda/plugins/event_source/webhook.py index 4e3478e1..aa93e1c5 100644 --- a/extensions/eda/plugins/event_source/webhook.py +++ b/extensions/eda/plugins/event_source/webhook.py @@ -74,7 +74,7 @@ async def webhook(request: web.Request) -> web.Response: return web.Response(text=endpoint) -def _parse_token(request: web.Request) -> (str, str): +def _parse_token(request: web.Request) -> tuple[str, str]: scheme, token = request.headers["Authorization"].strip().split(" ") if scheme != "Bearer": raise web.HTTPUnauthorized(text="Only Bearer type is accepted") @@ -147,6 +147,9 @@ def _get_ssl_context(args: dict[str, Any]) -> ssl.SSLContext | None: password = args.get("password", None) context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) try: + if not isinstance(certfile, str): # pragma: no-cover + msg = f"certfile is not a string, got a {type(certfile)} instead." + raise ValueError(msg) context.load_cert_chain(certfile, keyfile, password) except Exception: logger.exception("Failed to load certificates. Check they are valid") @@ -218,7 +221,7 @@ async def main(queue: asyncio.Queue, args: dict[str, Any]) -> None: if __name__ == "__main__": """MockQueue if running directly.""" - class MockQueue: + class MockQueue(asyncio.Queue[Any]): """A fake queue.""" async def put(self: MockQueue, event: dict) -> None: diff --git a/meta/runtime.yml b/meta/runtime.yml index 9170d810..13fef1cd 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -2,3 +2,9 @@ # https://access.redhat.com/support/policy/updates/ansible-automation-platform # https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html#ansible-core-support-matrix requires_ansible: ">=2.15.0" # AAP 2.4 or newer +action_groups: + eda: + - credential + - credential_info + - credential_type + - credential_type_info diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py new file mode 100644 index 00000000..06e79f6c --- /dev/null +++ b/plugins/module_utils/common.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +def to_list_of_dict(result): + if result is None: + return [] + if not isinstance(result, list): + return [result] + return result diff --git a/plugins/module_utils/controller.py b/plugins/module_utils/controller.py index 8733ab5b..43e3f853 100644 --- a/plugins/module_utils/controller.py +++ b/plugins/module_utils/controller.py @@ -81,6 +81,12 @@ def fail_wanted_one(self, response, endpoint, query_params): msg = f"Request to {display_endpoint} returned {response.json['count']} items, expected 1" raise EDAError(msg) + def get_exactly_one(self, endpoint, name=None, **kwargs): + return self.get_one_or_many(endpoint, name=name, allow_none=False, **kwargs) + + def resolve_name_to_id(self, endpoint, name): + return self.get_exactly_one(endpoint, name)["id"] + def get_one_or_many( self, endpoint, @@ -142,8 +148,20 @@ def create_if_needed( existing_item, new_item, endpoint, + on_create=None, item_type="unknown", ): + # This will exit from the module on its own + # If the method successfully creates an item and on_create param is + # defined, + # the on_create parameter will be called as a method passing in + # this object and the json from the response + # This will return one of two things: + # 1. None if the existing_item is already defined (so no create + # needs to happen) + # 2. The response from EDA from calling the post on the endpoint. + # It's up to you to process the response and exit from the module + # Note: common error codes from the EDA API can cause the module to fail response = None if not endpoint: msg = f"Unable to create new {item_type}, missing endpoint" @@ -177,6 +195,15 @@ def create_if_needed( msg = f"Unable to create {item_type} {item_name}: {response.status}" raise EDAError(msg) + # If we have an on_create method and we actually changed something we can call on_create + if on_create is not None and self.result["changed"]: + on_create(self, response["json"]) + else: + if response is not None: + last_data = response["json"] + return last_data + return + def _encrypted_changed_warning(self, field, old, warning=False): if not warning: return @@ -248,7 +275,18 @@ def update_if_needed( new_item, endpoint, item_type, + on_update=None, ): + # This will exit from the module on its own + # If the method successfully updates an item and on_update param is + # defined, + # the on_update parameter will be called as a method passing in this + # object and the json from the response + # This will return one of two things: + # 1. None if the existing_item does not need to be updated + # 2. The response from EDA from patching to the endpoint. It's up + # to you to process the response and exit from the module. + # Note: common error codes from the EDA API can cause the module to fail response = None if existing_item is None: raise RuntimeError( @@ -292,7 +330,20 @@ def update_if_needed( raise EDAError(response.json["__all__"]) msg = f"Unable to update {item_type} {item_name}" raise EDAError(msg) - return self.result + + # If we change something and have an on_change call it + if on_update is not None and self.result["changed"]: + if response is None: + last_data = existing_item + else: + last_data = response["json"] + on_update(self, last_data) + else: + if response is None: + last_data = existing_item + else: + last_data = response["json"] + return last_data def create_or_update_if_needed( self, @@ -300,6 +351,8 @@ def create_or_update_if_needed( new_item, endpoint=None, item_type="unknown", + on_create=None, + on_update=None, ): if existing_item: return self.update_if_needed( @@ -307,12 +360,14 @@ def create_or_update_if_needed( new_item, endpoint, item_type=item_type, + on_update=on_update, ) return self.create_if_needed( existing_item, new_item, endpoint, item_type=item_type, + on_create=on_create, ) def delete_if_needed(self, existing_item, endpoint, on_delete=None): diff --git a/plugins/modules/credential.py b/plugins/modules/credential.py new file mode 100644 index 00000000..4233a611 --- /dev/null +++ b/plugins/modules/credential.py @@ -0,0 +1,200 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# 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: credential +author: + - "Nikhil Jain (@jainnikhil30)" + - "Alina Buzachis (@alinabuzachis)" +short_description: Manage credentials in EDA Controller +description: + - This module allows the user to create, update or delete a credential in EDA controller. +version_added: 2.0.0 +options: + name: + description: + - Name of the credential. + type: str + required: true + new_name: + description: + - Setting this option will change the existing name (lookup via name). + type: str + inputs: + description: + - Credential inputs where the keys are var names used in templating. + type: dict + credential_type_name: + description: + - The name of the credential type. + type: str + organization_name: + description: + - The name of the organization. + type: int + aliases: + - org_name + description: + description: + - Description of the credential. + type: str + state: + description: + - Desired state of the resource. + default: "present" + choices: ["present", "absent"] + type: str +extends_documentation_fragment: + - ansible.eda.eda_controller.auths +""" + + +EXAMPLES = """ +- name: Create an EDA Credential + ansible.eda.credential: + name: "Example Credential" + description: "Example credential description" + inputs: + field1: "field1" + credential_type_name: "GitLab Personal Access Token" + +- name: Delete an EDA Credential + ansible.eda.credential: + name: "Example Credential" + state: absent +""" + + +RETURN = """ +id: + description: ID of the credential. + returned: when exists + type: int + sample: 24 +""" + + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.arguments import AUTH_ARGSPEC +from ..module_utils.client import Client +from ..module_utils.controller import Controller +from ..module_utils.errors import EDAError + + +def lookup(module, controller, endpoint, name): + result = None + try: + result = controller.resolve_name_to_id(endpoint, name) + except EDAError as e: + module.fail_json(msg=f"Failed to lookup resource: {e}") + return result + + +def main(): + argument_spec = dict( + name=dict(type="str", required=True), + new_name=dict(type="str"), + description=dict(type="str"), + inputs=dict(type="dict"), + credential_type_name=dict(type="str"), + organization_name=dict(type="int", aliases=["org_name"]), + state=dict(choices=["present", "absent"], default="present"), + ) + + argument_spec.update(AUTH_ARGSPEC) + + required_if = [("state", "present", ("name", "credential_type_name", "inputs"))] + + module = AnsibleModule( + argument_spec=argument_spec, required_if=required_if, supports_check_mode=True + ) + + client = Client( + host=module.params.get("controller_host"), + username=module.params.get("controller_username"), + password=module.params.get("controller_password"), + timeout=module.params.get("request_timeout"), + validate_certs=module.params.get("validate_certs"), + ) + + controller = Controller(client, module) + + name = module.params.get("name") + new_name = module.params.get("new_name") + state = module.params.get("state") + + credential_params = {} + if module.params.get("description"): + credential_params["description"] = module.params["description"] + + if module.params.get("inputs"): + credential_params["inputs"] = module.params["inputs"] + + credential_type_id = None + if module.params.get("credential_type_name"): + credential_type_id = lookup( + module, + controller, + "credential-types", + module.params["credential_type_name"], + ) + + if credential_type_id: + credential_params["credential_type_id"] = credential_type_id + + organization_id = None + if module.params.get("organization_id"): + organization_id = lookup( + module, controller, "organizations", module.params["organization_name"] + ) + + if organization_id: + credential_params["organization_id"] = organization_id + + # Attempt to look up credential based on the provided name + try: + credential = controller.get_one_or_many("eda-credentials", name=name) + except EDAError as e: + module.fail_json(msg=f"Failed to get credential: {e}") + + if state == "absent": + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + try: + result = controller.delete_if_needed(credential, endpoint="eda-credentials") + module.exit_json(**result) + except EDAError as e: + module.fail_json(msg=f"Failed to delete credential: {e}") + + credential_params["name"] = ( + new_name + if new_name + else (controller.get_item_name(credential) if credential else name) + ) + + # If the state was present and we can let the module build or update the + # existing credential, this will return on its own + try: + result = controller.create_or_update_if_needed( + credential, + credential_params, + endpoint="eda-credentials", + item_type="credential", + ) + module.exit_json(**result) + except EDAError as e: + module.fail_json(msg=f"Failed to create/update credential: {e}") + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/credential_info.py b/plugins/modules/credential_info.py new file mode 100644 index 00000000..3d4ffb3d --- /dev/null +++ b/plugins/modules/credential_info.py @@ -0,0 +1,118 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# 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: credential_info +author: + - Alina Buzachis (@alinabuzachis) +short_description: List credentials in the EDA Controller +description: + - List credentials in the EDA controller. +version_added: 2.0.0 +options: + name: + description: + - The name of the credential. + type: str + required: false +extends_documentation_fragment: + - ansible.eda.eda_controller.auths +""" + + +EXAMPLES = """ + - name: Get information about a credential + ansible.eda.credential_info: + name: "Test" + + - name: List all credentials + ansible.eda.credential_info: +""" + + +RETURN = """ +credentials: + description: Information about credentials. + returned: always + type: list + elements: dict + sample: [ + { + "created_at": "2024-08-14T08:57:55.151787Z", + "credential_type": { + "id": 1, + "kind": "scm", + "name": "Source Control", + "namespace": "scm" + }, + "description": "This is a test credential", + "id": 24, + "inputs": { + "password": "$encrypted$", + "username": "testuser" + }, + "managed": false, + "modified_at": "2024-08-14T08:57:56.324925Z", + "name": "New Test Credential", + "organization": { + "description": "The default organization", + "id": 1, + "name": "Default" + }, + "references": null + } + ] +""" + + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.arguments import AUTH_ARGSPEC +from ..module_utils.client import Client +from ..module_utils.common import to_list_of_dict +from ..module_utils.controller import Controller +from ..module_utils.errors import EDAError + + +def main(): + argument_spec = dict( + name=dict(type="str", required=False), + ) + + argument_spec.update(AUTH_ARGSPEC) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + client = Client( + host=module.params.get("controller_host"), + username=module.params.get("controller_username"), + password=module.params.get("controller_password"), + timeout=module.params.get("request_timeout"), + validate_certs=module.params.get("validate_certs"), + ) + + name = module.params.get("name") + controller = Controller(client, module) + + # Attempt to look up credential based on the provided name + try: + result = controller.get_one_or_many( + "eda-credentials", name=name, want_one=False + ) + except EDAError as e: + module.fail_json(msg=f"Failed to get credential: {e}") + + module.exit_json(credentials=to_list_of_dict(result)) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/credential_type.py b/plugins/modules/credential_type.py new file mode 100644 index 00000000..db75019c --- /dev/null +++ b/plugins/modules/credential_type.py @@ -0,0 +1,173 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# 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: credential_type +author: + - Alina Buzachis (@alinabuzachis) +short_description: Manage credential types in EDA Controller +description: + - This module allows the user to create, update or delete a credential type in EDA controller. +version_added: 2.0.0 +options: + name: + description: + - The name of the credential type. + type: str + required: true + description: + description: + - The description of the credential type to give more detail about it. + type: str + new_name: + description: + - Setting this option will change the existing name. + type: str + inputs: + description: + - Inputs of the credential type. + type: dict + injectors: + description: + - Injectors of the credential type. + type: dict + state: + description: + - Desired state of the resource. + default: "present" + choices: ["present", "absent"] + type: str +extends_documentation_fragment: + - ansible.eda.eda_controller.auths +""" + + +EXAMPLES = """ + - name: Create a credential type + ansible.eda.credential_type: + name: "Test" + state: present + description: "A test credential type" + inputs: + fields: + - id: "Field1" + type: "string" + label: "Label1" + injectors: + extra_vars: + field1: "field1" + + - name: Delete a credential type + ansible.eda.credential_type: + name: "Test" + state: absent +""" + + +RETURN = """ +id: + description: ID of the credential type. + returned: when exists + type: int + sample: 37 +""" + + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.arguments import AUTH_ARGSPEC +from ..module_utils.client import Client +from ..module_utils.controller import Controller +from ..module_utils.errors import EDAError + + +def main(): + argument_spec = dict( + name=dict(type="str", required=True), + new_name=dict(type="str"), + description=dict(type="str"), + inputs=dict(type="dict"), + injectors=dict(type="dict"), + state=dict(choices=["present", "absent"], default="present"), + ) + + argument_spec.update(AUTH_ARGSPEC) + + required_if = [("state", "present", ("inputs", "injectors"))] + + module = AnsibleModule( + argument_spec=argument_spec, required_if=required_if, supports_check_mode=True + ) + + client = Client( + host=module.params.get("controller_host"), + username=module.params.get("controller_username"), + password=module.params.get("controller_password"), + timeout=module.params.get("request_timeout"), + validate_certs=module.params.get("validate_certs"), + ) + + name = module.params.get("name") + new_name = module.params.get("new_name") + description = module.params.get("description") + inputs = module.params.get("inputs") + injectors = module.params.get("injectors") + state = module.params.get("state") + + credential_type_params = {} + if description: + credential_type_params["description"] = description + if inputs: + credential_type_params["inputs"] = inputs + if injectors: + credential_type_params["injectors"] = injectors + + controller = Controller(client, module) + + # Attempt to look up credential_type based on the provided name + try: + credential_type = controller.get_one_or_many("credential-types", name=name) + except EDAError as e: + module.fail_json(msg=f"Failed to get credential type: {e}") + + if state == "absent": + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + try: + result = controller.delete_if_needed( + credential_type, endpoint="credential-types" + ) + module.exit_json(**result) + except EDAError as e: + module.fail_json(msg=f"Failed to delete credential type: {e}") + + credential_type_params["name"] = ( + new_name + if new_name + else (controller.get_item_name(credential_type) if credential_type else name) + ) + + # If the state was present and we can let the module build or update the existing credential type, + # this will return on its own + try: + result = controller.create_or_update_if_needed( + credential_type, + credential_type_params, + endpoint="credential-types", + item_type="credential type", + ) + module.exit_json(**result) + except EDAError as e: + module.fail_json(msg=f"Failed to create/update credential type: {e}") + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/credential_type_info.py b/plugins/modules/credential_type_info.py new file mode 100644 index 00000000..11b2c6d5 --- /dev/null +++ b/plugins/modules/credential_type_info.py @@ -0,0 +1,116 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# 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: credential_type_info +author: + - Alina Buzachis (@alinabuzachis) +short_description: List credential types in EDA Controller +description: + - List credential types in EDA controller. +version_added: 2.0.0 +options: + name: + description: + - The name of the credential type. + type: str + required: false +extends_documentation_fragment: + - ansible.eda.eda_controller.auths +""" + + +EXAMPLES = """ + - name: Get information about a credential type + ansible.eda.credential_type_info: + name: "Test" + + - name: List all credential types + ansible.eda.credential_type_info: +""" + + +RETURN = """ +credential_types: + description: Information about the credential types. + returned: always + type: list + elements: dict + sample: [{ + "created_at": "2024-08-14T08:30:14.806638Z", + "description": "A test credential type", + "id": 37, + "injectors": { + "extra_vars": { + "field1": "field1" + } + }, + "inputs": { + "fields": [ + { + "id": "field1", + "label": "Field 5", + "type": "string" + } + ] + }, + "kind": "cloud", + "managed": false, + "modified_at": "2024-08-14T08:30:14.807549Z", + "name": "Example", + "namespace": null + }] +""" + + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.arguments import AUTH_ARGSPEC +from ..module_utils.client import Client +from ..module_utils.common import to_list_of_dict +from ..module_utils.controller import Controller +from ..module_utils.errors import EDAError + + +def main(): + argument_spec = dict( + name=dict(type="str", required=False), + ) + + argument_spec.update(AUTH_ARGSPEC) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + client = Client( + host=module.params.get("controller_host"), + username=module.params.get("controller_username"), + password=module.params.get("controller_password"), + timeout=module.params.get("request_timeout"), + validate_certs=module.params.get("validate_certs"), + ) + + name = module.params.get("name") + controller = Controller(client, module) + + # Attempt to look up credential_type based on the provided name + try: + result = controller.get_one_or_many( + "credential-types", name=name, want_one=False + ) + except EDAError as e: + module.fail_json(msg=f"Failed to get credential type: {e}") + + module.exit_json(credential_types=to_list_of_dict(result)) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/decision_environment.py b/plugins/modules/decision_environment.py new file mode 100644 index 00000000..fa6149ed --- /dev/null +++ b/plugins/modules/decision_environment.py @@ -0,0 +1,195 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# 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: decision_environment +author: + - Nikhil Jain (@jainnikhil30) + - Abhijeet Kasurde (@akasurde) +short_description: Create, update or delete decision environment in EDA Controller +description: + - This module allows user to create, update or delete decision environment in a EDA controller. +version_added: '2.0.0' +options: + name: + description: + - The name of the decision environment. + type: str + required: true + new_name: + description: + - Setting this option will change the existing name. + type: str + description: + description: + - The description of the decision environment. + type: str + image_url: + description: + - Image URL of the decision environment. + type: str + credential: + description: + - Name of the credential to associate with the decision environment. + type: str + state: + description: + - Desired state of the resource. + default: "present" + choices: ["present", "absent"] + type: str +extends_documentation_fragment: + - ansible.eda.eda_controller.auths +""" + +EXAMPLES = """ +- name: Create EDA Decision Environment + ansible.eda.decision_environment: + controller_host: https://my_eda_host/ + controller_username: admin + controller_password: MySuperSecretPassw0rd + name: "Example Decision Environment" + description: "Example Decision Environment description" + image_url: "quay.io/test" + credential: "Example Credential" + state: present + +- name: Update the name of the Decision Environment + ansible.eda.decision_environment: + controller_host: https://my_eda_host/ + controller_username: admin + controller_password: MySuperSecretPassw0rd + name: "Example Decision Environment" + new_name: "Latest Example Decision Environment" + state: present + +- name: Delete the the Decision Environment + ansible.eda.decision_environment: + controller_host: https://my_eda_host/ + controller_username: admin + controller_password: MySuperSecretPassw0rd + name: "Example Decision Environment" + state: absent +""" + +RETURN = """ +id: + description: ID of the decision environment + returned: when exists + type: int + sample: 37 +""" + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.arguments import AUTH_ARGSPEC +from ..module_utils.client import Client +from ..module_utils.controller import Controller +from ..module_utils.errors import EDAError + + +def main(): + argument_spec = dict( + name=dict(required=True), + new_name=dict(), + description=dict(), + image_url=dict(), + credential=dict(), + state=dict(choices=["present", "absent"], default="present"), + ) + + argument_spec.update(AUTH_ARGSPEC) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + client = Client( + host=module.params.get("controller_host"), + username=module.params.get("controller_username"), + password=module.params.get("controller_password"), + timeout=module.params.get("request_timeout"), + validate_certs=module.params.get("validate_certs"), + ) + + decision_environment_endpoint = "decision-environments" + controller = Controller(client, module) + + decision_environment_name = module.params.get("name") + new_name = module.params.get("new_name") + description = module.params.get("description") + image_url = module.params.get("image_url") + state = module.params.get("state") + credential = module.params.get("credential") + ret = {} + + try: + decision_environment_type = controller.get_one_or_many( + decision_environment_endpoint, name=decision_environment_name + ) + except EDAError as eda_err: + module.fail_json(msg=str(eda_err)) + + if state == "absent": + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + try: + ret = controller.delete_if_needed( + decision_environment_type, endpoint=decision_environment_endpoint + ) + except EDAError as eda_err: + module.fail_json(msg=str(eda_err)) + module.exit_json(**ret) + + decision_environment_type_params = {} + if description: + decision_environment_type_params["description"] = description + if image_url: + decision_environment_type_params["image_url"] = image_url + + credential_type = None + if credential: + try: + credential_type = controller.get_one_or_many( + "eda-credentials", name=credential + ) + except EDAError as eda_err: + module.fail_json(msg=str(eda_err)) + + if credential_type is not None: + # this is resolved earlier, so save an API call and don't do it again + # in the loop above + decision_environment_type_params["credential"] = credential_type["id"] + + if new_name: + decision_environment_type_params["name"] = new_name + elif decision_environment_type: + decision_environment_type_params["name"] = controller.get_item_name( + decision_environment_type + ) + else: + decision_environment_type_params["name"] = decision_environment_name + + # If the state was present and we can let the module build or update + # the existing decision environment type, + # this will return on its own + try: + ret = controller.create_or_update_if_needed( + decision_environment_type, + decision_environment_type_params, + endpoint=decision_environment_endpoint, + item_type="decision environment type", + ) + except EDAError as eda_err: + module.fail_json(msg=str(eda_err)) + + module.exit_json(**ret) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/decision_environment_info.py b/plugins/modules/decision_environment_info.py new file mode 100644 index 00000000..588250c9 --- /dev/null +++ b/plugins/modules/decision_environment_info.py @@ -0,0 +1,108 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# 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: decision_environment_info +author: + - Abhijeet Kasurde (@akasurde) +short_description: List a decision environment in EDA Controller +description: + - This module allows user to list a decision environment in a EDA controller. +version_added: '2.0.0' +options: + name: + description: + - The name of the decision environment. + type: str +extends_documentation_fragment: + - ansible.eda.eda_controller.auths +""" + +EXAMPLES = """ +- name: List all EDA Decision Environments + ansible.eda.decision_environment_info: + controller_host: https://my_eda_host/ + controller_username: admin + controller_password: MySuperSecretPassw0rd + +- name: List a particular EDA Decision Environments + ansible.eda.decision_environment_info: + controller_host: https://my_eda_host/ + controller_username: admin + controller_password: MySuperSecretPassw0rd + name: Example +""" + +RETURN = """ +decision_environments: + description: List of dict containing information about decision environments + returned: when exists + type: list + sample: [ + { + "created_at": "2024-08-15T21:12:52.218969Z", + "description": "Example decision environment description", + "eda_credential_id": null, + "id": 35, + "image_url": "https://quay.io/repository/ansible/eda-server", + "modified_at": "2024-08-15T21:12:52.218994Z", + "name": "Example Decision environment", + "organization_id": 1 + } + ] +""" + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.arguments import AUTH_ARGSPEC +from ..module_utils.client import Client +from ..module_utils.common import to_list_of_dict +from ..module_utils.controller import Controller +from ..module_utils.errors import EDAError + + +def main(): + argument_spec = dict( + name=dict(), + ) + + argument_spec.update(AUTH_ARGSPEC) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + client = Client( + host=module.params.get("controller_host"), + username=module.params.get("controller_username"), + password=module.params.get("controller_password"), + timeout=module.params.get("request_timeout"), + validate_certs=module.params.get("validate_certs"), + ) + + decision_environment_endpoint = "decision-environments" + controller = Controller(client, module) + + decision_environment_name = module.params.get("name") + ret = {} + + try: + ret = controller.get_one_or_many( + decision_environment_endpoint, + name=decision_environment_name, + want_one=False, + ) + except EDAError as eda_err: + module.fail_json(msg=str(eda_err)) + + module.exit_json(decision_environments=to_list_of_dict(ret)) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/project.py b/plugins/modules/project.py new file mode 100644 index 00000000..ba47fdc8 --- /dev/null +++ b/plugins/modules/project.py @@ -0,0 +1,181 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# 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: project +author: + - Nikhil Jain (@jainnikhil30) + - Abhijeet Kasurde (@akasurde) +short_description: Create, update or delete project in EDA Controller +description: + - This module allows user to create, update or delete project in a EDA controller. +version_added: '2.0.0' +options: + name: + description: + - The name of the project. + type: str + required: true + new_name: + description: + - Setting this option will change the existing name. + type: str + description: + description: + - The description of the project. + type: str + url: + description: + - The git URL of the project. + type: str + credential: + description: + - The name of the credential to associate with the project. + type: str + state: + description: + - Desired state of the resource. + default: "present" + choices: ["present", "absent"] + type: str +extends_documentation_fragment: + - ansible.eda.eda_controller.auths +""" + +EXAMPLES = """ +- name: Create EDA Projects + ansible.eda.project: + controller_host: https://my_eda_host/ + controller_username: admin + controller_password: MySuperSecretPassw0rd + name: "Example Project" + description: "Example project description" + url: "https://example.com/project1" + state: present + +- name: Update the name of the project + ansible.eda.project: + controller_host: https://my_eda_host/ + controller_username: admin + controller_password: MySuperSecretPassw0rd + name: "Example Project" + new_name: "Latest Example Project" + description: "Example project description" + url: "https://example.com/project1" + state: present + +- name: Delete the project + ansible.eda.project: + controller_host: https://my_eda_host/ + controller_username: admin + controller_password: MySuperSecretPassw0rd + name: "Example Project" + state: absent +""" + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.arguments import AUTH_ARGSPEC +from ..module_utils.client import Client +from ..module_utils.controller import Controller +from ..module_utils.errors import EDAError + + +def main(): + argument_spec = dict( + name=dict(required=True), + new_name=dict(), + description=dict(), + url=dict(), + credential=dict(), + state=dict(choices=["present", "absent"], default="present"), + ) + + argument_spec.update(AUTH_ARGSPEC) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + client = Client( + host=module.params.get("controller_host"), + username=module.params.get("controller_username"), + password=module.params.get("controller_password"), + timeout=module.params.get("request_timeout"), + validate_certs=module.params.get("validate_certs"), + ) + + project_endpoint = "projects" + controller = Controller(client, module) + + project_name = module.params.get("name") + new_name = module.params.get("new_name") + description = module.params.get("description") + url = module.params.get("url") + state = module.params.get("state") + credential = module.params.get("credential") + ret = {} + + try: + project_type = controller.get_one_or_many(project_endpoint, name=project_name) + except EDAError as eda_err: + module.fail_json(msg=str(eda_err)) + + if state == "absent": + # If the state was absent we can let the module delete it if needed, the module will handle exiting from this + try: + ret = controller.delete_if_needed(project_type, endpoint=project_endpoint) + except EDAError as eda_err: + module.fail_json(msg=str(eda_err)) + module.exit_json(**ret) + + project_type_params = {} + if description: + project_type_params["description"] = description + if url: + project_type_params["url"] = url + + credential_id = None + if credential: + try: + credential_id = controller.get_one_or_many( + "eda-credentials", name=credential + ) + except EDAError as eda_err: + module.fail_json(msg=str(eda_err)) + + if credential_id is not None: + # this is resolved earlier, so save an API call and don't do it again + # in the loop above + project_type_params["eda_credential_id"] = credential_id["id"] + + if new_name: + project_type_params["name"] = new_name + elif project_type: + project_type_params["name"] = controller.get_item_name(project_type) + else: + project_type_params["name"] = project_name + + # If the state was present and we can let the module build or update the existing project type, + # this will return on its own + try: + ret = controller.create_or_update_if_needed( + project_type, + project_type_params, + endpoint=project_endpoint, + item_type="project type", + ) + except EDAError as eda_err: + module.fail_json(msg=str(eda_err)) + + module.exit_json(**ret) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/project_info.py b/plugins/modules/project_info.py new file mode 100644 index 00000000..858d45a9 --- /dev/null +++ b/plugins/modules/project_info.py @@ -0,0 +1,124 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# 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: project_info +author: + - Abhijeet Kasurde (@akasurde) +short_description: List projects in EDA Controller +description: + - This module allows user to list project in a EDA controller. +version_added: '2.0.0' +options: + name: + description: + - The name of the project. + - Return information about particular project available on EDA Controller. + type: str +extends_documentation_fragment: + - ansible.eda.eda_controller.auths +""" + +EXAMPLES = """ +- name: List a particular project + ansible.eda.project_info: + controller_host: https://my_eda_host/ + controller_username: admin + controller_password: MySuperSecretPassw0rd + name: "Example" + register: r + +- name: List all projects + ansible.eda.project_info: + controller_host: https://my_eda_host/ + controller_username: admin + controller_password: MySuperSecretPassw0rd + register: r +""" + +RETURN = """ +projects: + description: List of dicts containing information about projects + returned: success + type: list + sample: [ + { + "created_at": "2024-08-12T20:35:28.367702Z", + "description": "", + "eda_credential_id": null, + "git_hash": "417b4dbe9b3472fd64212ef8233b865585e5ade3", + "id": 17, + "import_error": null, + "import_state": "completed", + "modified_at": "2024-08-12T20:35:28.367724Z", + "name": "Sample Example Project", + "organization_id": 1, + "proxy": "", + "scm_branch": "", + "scm_refspec": "", + "scm_type": "git", + "signature_validation_credential_id": null, + "url": "https://github.com/ansible/ansible-ui", + "verify_ssl": true + }, + ] +""" # NOQA + +# pylint: disable=wrong-import-position, +from ansible.module_utils.basic import AnsibleModule + +# pylint: disable=relative-beyond-top-level +from ..module_utils.arguments import AUTH_ARGSPEC +from ..module_utils.client import Client +from ..module_utils.controller import Controller +from ..module_utils.errors import EDAError + + +def main(): + argument_spec = dict( + name=dict(), + ) + + argument_spec.update(AUTH_ARGSPEC) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + client = Client( + host=module.params.get("controller_host"), + username=module.params.get("controller_username"), + password=module.params.get("controller_password"), + timeout=module.params.get("request_timeout"), + validate_certs=module.params.get("validate_certs"), + ) + + project_endpoint = "projects" + controller = Controller(client, module) + + project_name = module.params.get("name") + + ret = {} + + try: + ret = controller.get_one_or_many( + project_endpoint, name=project_name, want_one=False + ) + except EDAError as eda_err: + module.fail_json(msg=str(eda_err)) + + if ret is None: + ret = [] + if not isinstance(ret, list): + ret = [ret] + module.exit_json(projects=ret) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index b5d00fa4..23018d44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,38 @@ include_trailing_comma = true force_grid_wrap = 0 use_parentheses = true ensure_newline_before_comments = true -line_length = 120 + +[tool.mypy] +python_version = "3.9" +color_output = true +error_summary = true + +# TODO: Remove temporary skips and close https://github.com/ansible/event-driven-ansible/issues/258 +# strict = true +# disallow_untyped_calls = true +# disallow_untyped_defs = true +# disallow_any_generics = true +# disallow_any_unimported = True +# warn_redundant_casts = True +# warn_return_any = True +# warn_unused_configs = True + +# site-packages is here to help vscode mypy integration getting confused +exclude = "(build|dist|test/local-content|site-packages|~/.pyenv|examples/playbooks/collections|plugins/modules)" +# https://github.com/python/mypy/issues/12664 +incremental = false +namespace_packages = true +explicit_package_bases = true + +[[tool.mypy.overrides]] +module = [ + # Dependencies not following pep-561 yet: + "aiokafka.*", # https://github.com/aio-libs/aiokafka/issues/980 + "ansible.*", # https://github.com/ansible/ansible/issues/83801 + "asyncmock", # https://github.com/timsavage/asyncmock/issues/8 + "kafka.*", # https://github.com/dpkp/kafka-python/issues/2446 +] +ignore_missing_imports = true [tool.pylint.MASTER] # Temporary ignore until we are able to address issues on these: diff --git a/test_requirements.txt b/test_requirements.txt index c66df362..e2ced797 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -9,3 +9,4 @@ pytest-timeout>=2.0.1 requests>=2.31.0 ansible-rulebook>=1.0.0 tox>=4.15.1 +podman-compose diff --git a/tests/integration/event_source_kafka/test_kafka_source.py b/tests/integration/event_source_kafka/test_kafka_source.py index 57390934..3d8e1ff4 100644 --- a/tests/integration/event_source_kafka/test_kafka_source.py +++ b/tests/integration/event_source_kafka/test_kafka_source.py @@ -21,9 +21,12 @@ def kafka_certs(): def kafka_broker(): cwd = os.path.join(TESTS_PATH, "event_source_kafka") print(cwd) - result = subprocess.run(["docker-compose", "up", "-d"], cwd=cwd, check=True) + # Keep --quiet-pull here is it does spam CI/CD console + result = subprocess.run( + ["podman-compose", "up", "--quiet-pull", "-d"], cwd=cwd, check=True + ) yield result - subprocess.run(["docker-compose", "down", "-v"], cwd=cwd, check=True) + subprocess.run(["podman-compose", "down", "-v"], cwd=cwd, check=True) @pytest.fixture(scope="session") diff --git a/tests/integration/event_source_url_check/test_url_check_source.py b/tests/integration/event_source_url_check/test_url_check_source.py index d304299c..9331b72a 100644 --- a/tests/integration/event_source_url_check/test_url_check_source.py +++ b/tests/integration/event_source_url_check/test_url_check_source.py @@ -1,6 +1,7 @@ import http.server import os import threading +from typing import Any, Generator import pytest @@ -20,7 +21,7 @@ def log_message(self, format, *args): @pytest.fixture(scope="function") -def init_webserver(): +def init_webserver() -> Generator[Any, Any, Any]: handler = HttpHandler port: int = 8000 httpd = http.server.HTTPServer(("", port), handler) diff --git a/tests/integration/event_source_webhook/test_webhook_source.py b/tests/integration/event_source_webhook/test_webhook_source.py index efe36254..9ec77d5c 100644 --- a/tests/integration/event_source_webhook/test_webhook_source.py +++ b/tests/integration/event_source_webhook/test_webhook_source.py @@ -15,6 +15,8 @@ def wait_for_events(proc: subprocess.Popen, timeout: float = 15.0): Requires the process to be running in debug mode. """ start = time.time() + if not proc.stdout: # pragma: no cover + return while stdout := proc.stdout.readline().decode(): if "Waiting for events" in stdout: break diff --git a/tests/integration/targets/credential/tasks/main.yml b/tests/integration/targets/credential/tasks/main.yml new file mode 100644 index 00000000..7952cfa1 --- /dev/null +++ b/tests/integration/targets/credential/tasks/main.yml @@ -0,0 +1,197 @@ +--- +- block: + - set_fact: + credential_defaults: &credential_defaults + controller_username: "{{ controller_username }}" + controller_password: "{{ controller_password }}" + controller_host: "{{ controller_host }}" + validate_certs: false + + - name: Generate a random_string for the test + set_fact: + random_string: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: random_string is not defined + + - name: Generate a ID for the test + set_fact: + test_id: "{{ random_string | to_uuid }}" + when: test_id is not defined + + - name: Define variables for credential and project + set_fact: + credential_name: "Test_Credential_{{ test_id }}" + new_credential_name: "New_Test_Credential_{{ test_id }}" + credential_type_name: "Test_CredentialType_{{ test_id }}" + + # CREATE + - name: Create credential type + ansible.eda.credential_type: + <<: *credential_defaults + name: "{{ credential_type_name }}" + state: present + description: "A test credential type" + inputs: + fields: + - id: "field1" + type: "string" + label: "Field 5" + injectors: + extra_vars: + field1: "field1" + + - name: Create credential in check mode + ansible.eda.credential: + <<: *credential_defaults + state: present + name: "{{ credential_name }}" + description: "This is a test credential" + credential_type_name: "{{ credential_type_name }}" + inputs: + field1: "field1" + check_mode: true + register: _result + + - name: Check credential creation in check mode + assert: + that: + - _result.changed + + - name: Create credential + ansible.eda.credential: + <<: *credential_defaults + state: present + name: "{{ credential_name }}" + description: "This is a test credential" + credential_type_name: "{{ credential_type_name }}" + inputs: + field1: "field1" + register: _result + + - name: Check credential creation + assert: + that: + - _result.changed + + - name: Create credential again + ansible.eda.credential: + <<: *credential_defaults + state: present + name: "{{ credential_name }}" + description: "This is a test credential" + credential_type_name: "{{ credential_type_name }}" + inputs: + field1: "field1" + register: _result + + # [WARNING]: The field inputs of unknown 3 has encrypted data and may inaccurately report task is changed. + - name: Check credential is not created again + assert: + that: + - not _result.changed + ignore_errors: true + + - name: Get info about a credential + ansible.eda.credential_info: + <<: *credential_defaults + name: "{{ credential_name }}" + + # UPDATE + - name: Update credential name + ansible.eda.credential: + <<: *credential_defaults + state: present + name: "{{ new_credential_name }}" + description: "This is a test credential" + credential_type_name: "{{ credential_type_name }}" + inputs: + field1: "field1" + register: _result + + - name: Check credential update + assert: + that: + - _result.changed + + - name: Update credential again + ansible.eda.credential: + <<: *credential_defaults + state: present + name: "{{ new_credential_name }}" + description: "This is a test credential" + credential_type_name: "{{ credential_type_name }}" + inputs: + field1: "field1" + register: _result + + # [WARNING]: The field inputs of unknown 3 has encrypted data and may inaccurately report task is changed. + - name: Check credential is not updated again + assert: + that: + - not _result.changed + ignore_errors: true + + - name: Get info about credential + ansible.eda.credential_info: + <<: *credential_defaults + name: "{{ new_credential_name }}" + + - name: List all credentials + ansible.eda.credential_info: + <<: *credential_defaults + + # DELETE + - name: Delete operation type without required name parameter + ansible.eda.credential: + <<: *credential_defaults + state: absent + ignore_errors: true + register: _result + + - name: Check if credential name is required + assert: + that: + - _result.failed + - "'missing required arguments: name' in _result.msg" + + - name: Delete non-existing credential in check mode + ansible.eda.credential: + <<: *credential_defaults + name: "Example2" + state: absent + check_mode: true + register: _result + + - name: Check if delete non-existing credential in check mode + assert: + that: + - not _result.changed + + - name: Delete credential + ansible.eda.credential: + <<: *credential_defaults + name: "{{ new_credential_name }}" + state: absent + register: _result + + - name: Check if delete non-existing credential + assert: + that: + - _result.changed + + always: + - name: Clean up - credential + ansible.eda.credential: + <<: *credential_defaults + name: "{{ item }}" + state: absent + loop: + - "{{ credential_name }}" + - "{{ new_credential_name }}" + ignore_errors: true + + - name: Clean up - credential type + ansible.eda.credential_type: + <<: *credential_defaults + name: "{{ credential_type_name }}" + state: absent + ignore_errors: true diff --git a/tests/integration/targets/credential_type/tasks/main.yml b/tests/integration/targets/credential_type/tasks/main.yml new file mode 100644 index 00000000..2f250936 --- /dev/null +++ b/tests/integration/targets/credential_type/tasks/main.yml @@ -0,0 +1,188 @@ +--- +- block: + - set_fact: + credential_defaults: &credential_defaults + controller_username: "{{ controller_username }}" + controller_password: "{{ controller_password }}" + controller_host: "{{ controller_host }}" + validate_certs: false + + - name: Generate a random_string for the test + set_fact: + random_string: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: random_string is not defined + + - name: Generate a ID for the test + set_fact: + test_id: "{{ random_string | to_uuid }}" + when: test_id is not defined + + - name: Define variables for credential and project + set_fact: + credential_type_name: "Test_CredentialType_{{ test_id }}" + new_credential_type_name: "New_Test_CredentialType_{{ test_id }}" + + # CREATE + - name: Create credential type in check mode + ansible.eda.credential_type: + <<: *credential_defaults + name: "{{ credential_type_name }}" + state: present + description: "A test credential type" + inputs: + fields: + - id: "field1" + type: "string" + label: "Field 5" + injectors: + extra_vars: + field1: "field1" + check_mode: true + register: _result + + - name: Check credential type creation in check mode + assert: + that: + - _result.changed + + - name: Create credential type + ansible.eda.credential_type: + <<: *credential_defaults + name: "{{ credential_type_name }}" + state: present + description: "A test credential type" + inputs: + fields: + - id: "field1" + type: "string" + label: "Field 5" + injectors: + extra_vars: + field1: "field1" + register: _result + + - name: Check credential type creation + assert: + that: + - _result.changed + + - name: Create credential type again + ansible.eda.credential_type: + <<: *credential_defaults + name: "{{ credential_type_name }}" + state: present + description: "A test credential type" + inputs: + fields: + - id: "field1" + type: "string" + label: "Field 5" + injectors: + extra_vars: + field1: "field1" + register: _result + + - name: Check credential type is not created again + assert: + that: + - not _result.changed + + - name: Get info about a credential type + ansible.eda.credential_type_info: + <<: *credential_defaults + name: "{{ credential_type_name }}" + + # UPDATE + - name: Update credential type name + ansible.eda.credential_type: + <<: *credential_defaults + name: "{{ credential_type_name }}" + new_name: "{{ new_credential_type_name }}" + state: present + description: "A test credential type" + inputs: + fields: + - id: "field1" + type: "string" + label: "Field 5" + injectors: + extra_vars: + field1: "field1" + register: _result + + - name: Check credential type update + assert: + that: + - _result.changed + + - name: Update credential type again + ansible.eda.credential_type: + <<: *credential_defaults + name: "{{ new_credential_type_name }}" + new_name: "{{ new_credential_type_name }}" + state: present + description: "A test credential type" + inputs: + fields: + - id: "field1" + type: "string" + label: "Field 5" + injectors: + extra_vars: + field1: "field1" + register: _result + + - name: Check credential type is not updated again + assert: + that: + - not _result.changed + + # DELETE + - name: Delete operation type without required name parameter + ansible.eda.credential_type: + <<: *credential_defaults + state: absent + ignore_errors: true + register: _result + + - name: Check if credential type name is required + assert: + that: + - _result.failed + - "'missing required arguments: name' in _result.msg" + + - name: Delete non-existing credential type in check mode + ansible.eda.credential_type: + <<: *credential_defaults + name: "Example2" + state: absent + check_mode: true + register: _result + + - name: Check if delete non-existing credential type in check mode + assert: + that: + - not _result.changed + + - name: Delete credential type + ansible.eda.credential_type: + <<: *credential_defaults + name: "{{ new_credential_type_name }}" + state: absent + register: _result + + - name: Check if delete non-existing credential type + assert: + that: + - _result.changed + + always: + - name: Clean up - credential type + ansible.eda.credential_type: + <<: *credential_defaults + name: "{{ item }}" + state: absent + loop: + - "{{ credential_type_name }}" + - "{{ new_credential_type_name }}" + ignore_errors: true diff --git a/tests/integration/targets/decision_environment/tasks/create.yml b/tests/integration/targets/decision_environment/tasks/create.yml new file mode 100644 index 00000000..30124a5a --- /dev/null +++ b/tests/integration/targets/decision_environment/tasks/create.yml @@ -0,0 +1,81 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Create decision environment in check mode + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}" + description: "Example decision environment description" + image_url: "{{ image_url }}" + state: present + check_mode: true + register: r + +- name: Check decision environment in check mode + assert: + that: + - r.changed + +- name: Create decision environment + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}" + description: "Example decision environment description" + image_url: "{{ image_url }}" + state: present + register: r + +- name: Check decision environment creation + assert: + that: + - r.changed + +- name: Create decision environment again + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}" + description: "Example decision environment description" + image_url: "{{ image_url }}" + state: present + register: r + +- name: Check decision environment is not created again + assert: + that: + - not r.changed + +- name: Delete decision environment in check mode + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}" + state: absent + check_mode: true + register: r + +- name: Check if decision environment deleted in check mode + assert: + that: + - r.changed + +- name: Delete decision environment + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}" + state: absent + register: r + +- name: Check if delete decision environment + assert: + that: + - r.changed diff --git a/tests/integration/targets/decision_environment/tasks/delete.yml b/tests/integration/targets/decision_environment/tasks/delete.yml new file mode 100644 index 00000000..9e24c206 --- /dev/null +++ b/tests/integration/targets/decision_environment/tasks/delete.yml @@ -0,0 +1,45 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Delete operation without required name parameter + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + state: absent + ignore_errors: true + register: r + +- name: Check if decision environment name is required + assert: + that: + - r.failed + - "'missing required arguments: name' in r.msg" + +- name: Delete non-existing decision environment in check mode + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: Example + state: absent + check_mode: true + register: r + +- name: Check if delete non-existing decision environment in check mode + assert: + that: + - not r.changed + +- name: Delete non-existing decision environment + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: Example + state: absent + register: r + +- name: Check if delete non-existing project + assert: + that: + - not r.changed diff --git a/tests/integration/targets/decision_environment/tasks/main.yml b/tests/integration/targets/decision_environment/tasks/main.yml new file mode 100644 index 00000000..0d6b0331 --- /dev/null +++ b/tests/integration/targets/decision_environment/tasks/main.yml @@ -0,0 +1,35 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- block: + - name: Generate a random_string for the test + set_fact: + random_string: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: random_string is not defined + + - name: Generate a ID for the test + set_fact: + test_id: "{{ random_string | to_uuid }}" + when: test_id is not defined + + - name: Define variables for credential and decision environment + set_fact: + decision_env_name: "Test_Decision_Env_{{ test_id }}" + image_url: "https://quay.io/repository/ansible/eda-server" + + - include_tasks: create.yml + - include_tasks: delete.yml + - include_tasks: update.yml + always: + - name: Clean up - decision environment + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ item }}" + state: absent + loop: + - "{{ decision_env_name }}" + - "{{ decision_env_name }}_new" + ignore_errors: true diff --git a/tests/integration/targets/decision_environment/tasks/update.yml b/tests/integration/targets/decision_environment/tasks/update.yml new file mode 100644 index 00000000..046801c1 --- /dev/null +++ b/tests/integration/targets/decision_environment/tasks/update.yml @@ -0,0 +1,67 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Create decision environment + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}" + description: "Example decision environment description" + image_url: "{{ image_url }}" + state: present + register: r + +- name: Check decision environment creation + assert: + that: + - r.changed + +- name: Update decision environment name + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}" + new_name: "{{ decision_env_name }}_new" + description: "Example decision environment description" + image_url: "{{ image_url }}" + state: present + register: r + +- name: Check decision environment update + assert: + that: + - r.changed + +- name: Update decision environment again + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}_new" + new_name: "{{ decision_env_name }}_new" + description: "Example decision environment description" + image_url: "{{ image_url }}" + state: present + register: r + +- name: Check decision environment is not updated again + assert: + that: + - not r.changed + +- name: Delete updated decision environment + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}_new" + state: absent + register: r + +- name: Check if delete decision environment + assert: + that: + - r.changed diff --git a/tests/integration/targets/decision_environment_info/tasks/list.yml b/tests/integration/targets/decision_environment_info/tasks/list.yml new file mode 100644 index 00000000..299ae39a --- /dev/null +++ b/tests/integration/targets/decision_environment_info/tasks/list.yml @@ -0,0 +1,75 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Create decision environment + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}" + description: "Example decision environment description" + image_url: "{{ image_url }}" + state: present + register: r + +- name: Check decision environment creation + assert: + that: + - r.changed + +- name: List all decision environments in check mode + ansible.eda.decision_environment_info: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + check_mode: true + register: r + +- name: Check if list decision environments in check mode + assert: + that: + - not r.changed + - "'decision_environments' in r" + +- name: List all decision environments + ansible.eda.decision_environment_info: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + register: r + +- name: Check if list decision environments + assert: + that: + - not r.changed + - "'decision_environments' in r" + +- name: List a particular decision environment + ansible.eda.decision_environment_info: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}" + register: r + +- name: Check if list decision environments + assert: + that: + - not r.changed + - "'decision_environments' in r" + - "r['decision_environments'][0]['name'] == decision_env_name" + +- name: Delete decision environment + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ decision_env_name }}" + state: absent + register: r + +- name: Check if delete decision environment + assert: + that: + - r.changed diff --git a/tests/integration/targets/decision_environment_info/tasks/main.yml b/tests/integration/targets/decision_environment_info/tasks/main.yml new file mode 100644 index 00000000..59a5fcf3 --- /dev/null +++ b/tests/integration/targets/decision_environment_info/tasks/main.yml @@ -0,0 +1,32 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- block: + - name: Generate a random_string for the test + set_fact: + random_string: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: random_string is not defined + + - name: Generate a ID for the test + set_fact: + test_id: "{{ random_string | to_uuid }}" + when: test_id is not defined + + - name: Define variables for credential and decision environment + set_fact: + decision_env_name: "Test_Decision_Env_{{ test_id }}" + image_url: "https://quay.io/repository/ansible/eda-server" + + - include_tasks: list.yml + always: + - name: Clean up - decision environment + ansible.eda.decision_environment: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ item }}" + state: absent + loop: + - "{{ decision_env_name }}" + ignore_errors: true diff --git a/tests/integration/targets/project/tasks/create.yml b/tests/integration/targets/project/tasks/create.yml new file mode 100644 index 00000000..32d74ba5 --- /dev/null +++ b/tests/integration/targets/project/tasks/create.yml @@ -0,0 +1,81 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Create project in check mode + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}" + url: "{{ url }}" + description: "Example project description" + state: present + check_mode: true + register: r + +- name: Check project creation in check mode + assert: + that: + - r.changed + +- name: Create project + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}" + url: "{{ url }}" + description: "Example project description" + state: present + register: r + +- name: Check project creation + assert: + that: + - r.changed + +- name: Create project again + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}" + url: "{{ url }}" + description: "Example project description" + state: present + register: r + +- name: Check project is not created again + assert: + that: + - not r.changed + +- name: Delete project in check mode + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}" + state: absent + check_mode: true + register: r + +- name: Check if delete project in check mode + assert: + that: + - r.changed + +- name: Delete project + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}" + state: absent + register: r + +- name: Check if delete project + assert: + that: + - r.changed diff --git a/tests/integration/targets/project/tasks/delete.yml b/tests/integration/targets/project/tasks/delete.yml new file mode 100644 index 00000000..9656269c --- /dev/null +++ b/tests/integration/targets/project/tasks/delete.yml @@ -0,0 +1,45 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Delete operation without required name parameter + ansible.eda.project: + controller_host: "{{ controller_host }}" + state: absent + ignore_errors: true + register: r + +- name: Check if project name is required + assert: + that: + - r.failed + - "'missing required arguments: name' in r.msg" + +- name: Delete non-existing project in check mode + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}" + state: absent + check_mode: true + register: r + +- name: Check if delete non-existing project in check mode + assert: + that: + - not r.changed + +- name: Delete non-existing project + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}_ee" + state: absent + register: r + +- name: Check if delete non-existing project + assert: + that: + - not r.changed diff --git a/tests/integration/targets/project/tasks/main.yml b/tests/integration/targets/project/tasks/main.yml new file mode 100644 index 00000000..dfbf38bb --- /dev/null +++ b/tests/integration/targets/project/tasks/main.yml @@ -0,0 +1,35 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- block: + - name: Generate a random_string for the test + set_fact: + random_string: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: random_string is not defined + + - name: Generate a ID for the test + set_fact: + test_id: "{{ random_string | to_uuid }}" + when: test_id is not defined + + - name: Define variables for credential and decision environment + set_fact: + project_name: "test_project_{{ test_id }}" + url: "https://example.com/ansible/eda-server" + + - include_tasks: create.yml + - include_tasks: delete.yml + - include_tasks: update.yml + always: + - name: Clean up - project + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ item }}" + state: absent + loop: + - "{{ project_name }}" + - "{{ project_name }}_new" + ignore_errors: true diff --git a/tests/integration/targets/project/tasks/update.yml b/tests/integration/targets/project/tasks/update.yml new file mode 100644 index 00000000..b6d8117c --- /dev/null +++ b/tests/integration/targets/project/tasks/update.yml @@ -0,0 +1,67 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Create project + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}" + url: "{{ url }}" + description: "Example project description" + state: present + register: r + +- name: Check project creation + assert: + that: + - r.changed + +- name: Update project name + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}" + url: "{{ url }}" + new_name: "{{ project_name }}_new" + description: "Example project description" + state: present + register: r + +- name: Check project update + assert: + that: + - r.changed + +- name: Update project again + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}_new" + new_name: "{{ project_name }}_new" + description: "Example project description" + url: "https://example.com/project1" + state: present + register: r + +- name: Check project is not updated again + assert: + that: + - not r.changed + +- name: Delete updated project + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ project_name }}_new" + state: absent + register: r + +- name: Check if delete project + assert: + that: + - r.changed diff --git a/tests/integration/targets/project_info/tasks/create.yml b/tests/integration/targets/project_info/tasks/create.yml new file mode 100644 index 00000000..eb92b83e --- /dev/null +++ b/tests/integration/targets/project_info/tasks/create.yml @@ -0,0 +1,19 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Create a project + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: Example + description: "Example project description" + url: "https://example.com/project1" + state: present + register: r + +- name: Check project creation + assert: + that: + - r.changed diff --git a/tests/integration/targets/project_info/tasks/main.yml b/tests/integration/targets/project_info/tasks/main.yml new file mode 100644 index 00000000..53efce56 --- /dev/null +++ b/tests/integration/targets/project_info/tasks/main.yml @@ -0,0 +1,70 @@ +--- +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- block: + - include_tasks: create.yml + - name: List all projects in the given EDA Controller in check mode + ansible.eda.project_info: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + check_mode: true + register: r + + - name: Check if all the projects are returned in check mode + assert: + that: + - "'projects' in r" + + - name: List all projects in the given EDA Controller + ansible.eda.project_info: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + register: r + + - name: Check if all the projects are returned + assert: + that: + - "'projects' in r" + + - name: List a particular project in the given EDA Controller + ansible.eda.project_info: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "Example" + register: r + + - name: Check if the project is returned + assert: + that: + - "'projects' in r" + - "'Example' in r['projects'][0]['name']" + + - name: List a non-existing particular project in the given EDA Controller + ansible.eda.project_info: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "Example2" + register: r + + - name: Check if all the projects are returned + assert: + that: + - "'projects' in r" + - "r['projects'] == []" + + always: + - name: Clean up - project + ansible.eda.project: + controller_host: "{{ controller_host }}" + controller_username: "{{ controller_user }}" + validate_certs: "{{ validate_certs }}" + name: "{{ item }}" + state: absent + loop: + - Example + ignore_errors: true diff --git a/tests/unit/plugins/module_utils/test_client.py b/tests/unit/plugins/module_utils/test_client.py new file mode 100644 index 00000000..11d8db89 --- /dev/null +++ b/tests/unit/plugins/module_utils/test_client.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json +from unittest.mock import Mock, patch +from urllib.error import HTTPError, URLError + +import pytest +from ansible_collections.ansible.eda.plugins.module_utils.client import ( # type: ignore + Client, +) +from ansible_collections.ansible.eda.plugins.module_utils.errors import ( # type: ignore + AuthError, + EDAHTTPError, +) + +ENDPOINT = "/api/test_endpoint" +QUERY_PARAMS = {"param": "value"} +ID = 1 +DATA = {"key": "value"} +JSON_DATA = '{"key": "value"}' + + +@pytest.fixture +def mock_response(): + response = Mock() + response.status = 200 + response.read.return_value = JSON_DATA.encode("utf-8") + response.headers = {"content-type": "application/json"} + return response + + +@pytest.fixture +def mock_error_response(): + response = Mock() + response.status = 401 + response.read.return_value = b"Unauthorized" + response.headers = {} + return response + + +@pytest.fixture +def mock_http_error(): + return HTTPError( + url="http://example.com", code=401, msg="Unauthorized", hdrs={}, fp=None + ) + + +@pytest.fixture +def mock_url_error(): + return URLError("URL error") + + +@pytest.fixture +def client(): + with patch( + "ansible_collections.ansible.eda.plugins.module_utils.client.Request" + ) as mock_request: + mock_request_instance = Mock() + mock_request.return_value = mock_request_instance + client_instance = Client( + host="http://mocked-url.com", + username="mocked_user", + password="mocked_pass", + timeout=10, + validate_certs=True, + ) + yield client_instance, mock_request_instance + + +@pytest.mark.parametrize( + "method, status_code, expected_response, exception_type, exception_message, headers, data", + [ + ("get", 200, DATA, None, None, {}, None), + ("post", 201, DATA, None, None, {"Content-Type": "application/json"}, DATA), + ("patch", 200, DATA, None, None, {"Content-Type": "application/json"}, DATA), + ("delete", 204, {}, None, None, {}, None), + ( + "post", + 401, + None, + AuthError, + "Failed to authenticate with the instance: 401 Unauthorized", + {"Content-Type": "application/json"}, + DATA, + ), + ("get", 404, DATA, None, None, {}, None), + ("get", None, None, EDAHTTPError, "URL error", {}, None), + ], +) +def test_client_methods( + method, + status_code, + expected_response, + exception_type, + exception_message, + headers, + data, + client, + mock_response, + mock_http_error, + mock_url_error, +): + client_instance, mock_request_instance = client + mock_request_instance.open = Mock() + + if exception_type: + if exception_type == AuthError: + mock_request_instance.open.side_effect = mock_http_error + with pytest.raises(exception_type, match=exception_message): + getattr(client_instance, method)(ENDPOINT, data=data, headers=headers) + elif exception_type == EDAHTTPError: + mock_request_instance.open.side_effect = mock_url_error + with pytest.raises(exception_type, match=exception_message): + getattr(client_instance, method)(ENDPOINT, data=data, headers=headers) + else: + mock_response.status = status_code + mock_response.read.return_value = json.dumps(expected_response).encode("utf-8") + mock_request_instance.open.return_value = mock_response + + response = getattr(client_instance, method)( + ENDPOINT, data=data, headers=headers + ) + assert response.status == status_code + assert response.json == expected_response diff --git a/tests/unit/plugins/module_utils/test_controller.py b/tests/unit/plugins/module_utils/test_controller.py new file mode 100644 index 00000000..431774dd --- /dev/null +++ b/tests/unit/plugins/module_utils/test_controller.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from unittest.mock import MagicMock, Mock + +import pytest +from ansible_collections.ansible.eda.plugins.module_utils.controller import ( # type: ignore + Controller, +) +from ansible_collections.ansible.eda.plugins.module_utils.errors import ( # type: ignore + EDAError, +) + +ENDPOINT = "test_endpoint" + + +@pytest.fixture +def mock_client(): + return Mock() + + +@pytest.fixture +def mock_module(): + module = Mock() + module.params = {"update_secrets": True} + module.check_mode = False + return module + + +@pytest.fixture +def controller(mock_client, mock_module): + return Controller(client=mock_client, module=mock_module) + + +@pytest.mark.parametrize( + "existing_item, new_item, mock_response, expected_result, expected_calls", + [ + # create_if_needed without existing item + ( + None, + {"name": "Test"}, + Mock(status=201, json={"id": 1}), + {"changed": True, "id": 1}, + 1, # Expected number of post calls + ), + # create_if_needed with existing item + ( + {"id": 1, "url": "http://test.com/api/item/1"}, + {"name": "Test"}, + None, + None, + 0, # Expected number of post calls + ), + ], +) +def test_create_if_needed( + mock_client, + controller, + existing_item, + new_item, + mock_response, + expected_result, + expected_calls, +): + if mock_response: + mock_client.post.return_value = mock_response + result = controller.create_if_needed(existing_item, new_item, ENDPOINT) + assert result == expected_result + assert mock_client.post.call_count == expected_calls + if expected_calls > 0: + mock_client.post.assert_called_with(ENDPOINT, **{"data": new_item}) + + +@pytest.mark.parametrize( + "existing_item, mock_response, expected_result, expected_calls", + [ + # delete_if_needed with an existing item + ( + {"id": 1, "name": "test_item"}, + Mock(status=204, json={}), + {"changed": True, "id": 1}, + 1, # Expected number of delete calls + ), + # delete_if_needed without an existing item + ( + None, + None, + {"changed": False}, + 0, # Expected number of delete calls + ), + ], +) +def test_delete_if_needed( + mock_client, + controller, + existing_item, + mock_response, + expected_result, + expected_calls, +): + if mock_response: + mock_client.delete.return_value = mock_response + + result = controller.delete_if_needed(existing_item, ENDPOINT) + assert result == expected_result + assert mock_client.delete.call_count == expected_calls + if expected_calls > 0: + mock_client.delete.assert_called_with(ENDPOINT, **{"id": existing_item["id"]}) + + +def test_update_if_needed_with_existing_item(mock_client, controller): + existing_item = {"id": 1, "name": "Test1"} + new_item = {"name": "Test2"} + response = Mock(status=200, json={"id": 1, "name": "Test2"}) + mock_client.patch.return_value = response + result = controller.update_if_needed( + existing_item, new_item, ENDPOINT, "resource type" + ) + mock_client.patch.assert_called_with(ENDPOINT, **{"data": new_item, "id": 1}) + assert result["changed"] is True + assert result["id"] == 1 + + +def test_get_endpoint(mock_client, controller): + response = Mock(status=200, json={"count": 1, "results": [{"id": 1}]}) + mock_client.get.return_value = response + result = controller.get_endpoint(ENDPOINT) + mock_client.get.assert_called_with(ENDPOINT) + assert result == response + + +def test_post_endpoint(mock_client, controller): + response = Mock(status=201, json={"id": 1}) + mock_client.post.return_value = response + result = controller.post_endpoint(ENDPOINT) + mock_client.post.assert_called_with(ENDPOINT) + assert result == response + + +def test_patch_endpoint_check_mode(controller): + controller.module.check_mode = True + result = controller.patch_endpoint(ENDPOINT) + assert result["changed"] is True + + +def test_get_name_field_from_endpoint(): + assert Controller.get_name_field_from_endpoint("users") == "username" + assert Controller.get_name_field_from_endpoint("unknown") == "name" + + +@pytest.mark.parametrize( + "item, expected_name, should_raise", + [ + ({"name": "test_item"}, "test_item", False), + ({"username": "test_user"}, "test_user", False), + ({}, None, True), + ], +) +def test_get_item_name(controller, item, expected_name, should_raise): + if should_raise: + with pytest.raises(EDAError): + controller.get_item_name(item) + else: + assert controller.get_item_name(item) == expected_name + + +def test_has_encrypted_values(): + assert Controller.has_encrypted_values({"key": "$encrypted$"}) is True + assert Controller.has_encrypted_values({"key": "value"}) is False + + +def test_fail_wanted_one(mock_client, controller): + response = MagicMock() + response.json.return_value = {"count": 2, "results": [{"id": 1}, {"id": 2}]} + mock_client.build_url.return_value.geturl.return_value = "http://example.com/api" + mock_client.host = "http://example.com" + with pytest.raises(EDAError, match="expected 1"): + controller.fail_wanted_one(response, "endpoint", {}) + + +def test_fields_could_be_same(): + assert ( + Controller.fields_could_be_same({"key": "$encrypted$"}, {"key": "value"}) + is True + ) + assert ( + Controller.fields_could_be_same({"key1": "value1"}, {"key2": "value2"}) is False + ) + + +@pytest.mark.parametrize( + "old, new, expected", + [ + ({"key": "$encrypted$"}, {"key": "value"}, True), + ({"key": "value"}, {"key": "value"}, False), + ], +) +def test_objects_could_be_different(controller, old, new, expected): + assert controller.objects_could_be_different(old, new) is expected diff --git a/tox.ini b/tox.ini index 64bf0f15..30811e46 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ # Recommended usage of this file is detailed in https://github.com/ansible/eda-partner-testing/blob/main/README.md. # The linter paths can be changed, but may result in false passes. # {posargs} in this case would be the path to collection root relative from the .github/workflows dir (`../..`) -# cspell: ignore TOXPYTHON setenv passenv REQPASS PYTHONPYCACHEPREFIX PYTHONIOENCODING PYTHONBREAKPOINT notest envdir envname toxworkdir +# cspell: ignore TOXPYTHON setenv passenv REQPASS PYTHONPYCACHEPREFIX PYTHONIOENCODING PYTHONBREAKPOINT notest envdir envname toxworkdir junitxml [tox] envlist = lint, darglint, unit, sanity, integration, coverage @@ -76,7 +76,7 @@ commands = # risky: not safe for development it affects user setup ansible-galaxy collection install . # use same coverage location as ansible-test: - coverage run --data-file=tests/output/coverage/integration.coverage -m pytest tests/integration -vvv -s {posargs} + coverage run --data-file=tests/output/coverage/integration.coverage -m pytest tests/integration -vvv -s --junitxml=tests/output/junit/integration.xml {posargs} [testenv:unit,py{39,310,311,312,313}-unit] description = @@ -96,7 +96,7 @@ description = py{39,310,311,312,313}: with {basepython} commands = ansible --version - ansible-test sanity -v --requirements --coverage --truncate 0 {posargs} + ansible-test sanity -v --requirements --coverage --truncate 0 --junit {posargs} [testenv:coverage] description = Produce final coverage report for GHA