diff --git a/.gitea/workflows/.gitkeep b/.gitea/workflows/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.gitea/workflows/pull-request-open.yml b/.gitea/workflows/pull-request-open.yml new file mode 100644 index 0000000..575895a --- /dev/null +++ b/.gitea/workflows/pull-request-open.yml @@ -0,0 +1,67 @@ +--- +name: pull-requests-open +on: + pull_request: + types: + - opened + - edited + - synchronize + branches: + - main + - develop + +jobs: + commit-history-check: + name: Check commit compliance + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Install commitizen + run: pip3 install commitizen + shell: bash + working-directory: ${{ gitea.workspace }} + + - name: Verify commit message compliance + run: | + echo "cz check --rev-range origin/${{ gitea.event.pull_request.base.ref }}.." + cz check --rev-range origin/${{ gitea.event.pull_request.base.ref }}.. + shell: bash + working-directory: ${{ gitea.workspace }} + + run-tests: + name: Run tests + runs-on: ubuntu-latest + needs: commit-history-check + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install requirements + run: pip3 install -r tests/requirements.txt + shell: bash + working-directory: ${{ gitea.workspace }} + + - name: Setup testing environment + run: ansible-galaxy collection install ${{ gitea.workspace }} + shell: bash + working-directory: ${{ gitea.workspace }} + + - name: Run ansible unit tests + run: ansible-test units --coverage + shell: bash + working-directory: /root/.ansible/collections/ansible_collections/ednz_cloud/infomaniak + + - name: Print coverage informations + run: ansible-test coverage report + shell: bash + working-directory: /root/.ansible/collections/ansible_collections/ednz_cloud/infomaniak diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..e05f041 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,54 @@ +--- +name: release +on: + push: + branches: + - main + +jobs: + do-release: + if: "!startsWith(github.event.head_commit.message, 'bump:')" + runs-on: ubuntu-latest + name: Bump version and create changelog with commitizen + steps: + - name: Get secrets from vault + id: import-secrets + uses: hashicorp/vault-action@v3 + with: + url: "https://vault.ednz.fr" + method: approle + roleId: ${{ secrets.VAULT_APPROLE_ID }} + secretId: ${{ secrets.VAULT_APPROLE_SECRET_ID }} + secrets: | + kv/data/applications/gitea/users/actions username | GITEA_ACTIONS_USERNAME ; + kv/data/applications/gitea/users/actions token_write | GITEA_ACTIONS_TOKEN ; + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.import-secrets.outputs.GITEA_ACTIONS_TOKEN }} + + - name: Install commitizen + run: pip3 install commitizen + shell: bash + working-directory: ${{ gitea.workspace }} + + - name: Configure git credentials + uses: oleksiyrudenko/gha-git-credentials@v2 + with: + global: true + name: "Gitea-Actions Bot" + email: "gitea-actions@ednz.fr" + actor: ${{ steps.import-secrets.outputs.GITEA_ACTIONS_USERNAME }} + token: ${{ steps.import-secrets.outputs.GITEA_ACTIONS_TOKEN }} + + - name: Do release + run: cz -nr 21 bump --yes + shell: bash + working-directory: ${{ gitea.workspace }} + + - name: Push release + run: git push && git push --tags + shell: bash + working-directory: ${{ gitea.workspace }} diff --git a/.gitignore b/.gitignore index cb534dd..2ae2ad8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,14 @@ # ignore molecule/testinfra pycache **/__pycache__ + +# ignore vscode files .vscode -roles/ednz_cloud.* -vault_config.yml -consul_config.yml -**/certificates/** -**/secrets/credentials.yml -**/secrets/credentials.decrypt.yml -**/secrets/vault.yml -**/.ansible-vault + +# ignore test output (auto-generated) +tests/output/** + +# ignore virtual environments +.env +.venv +env +venv diff --git a/galaxy.yml b/galaxy.yml index 47e2822..0310ea4 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: ednz_cloud -name: hashistack -version: 0.7.0 +name: infomaniak +version: 0.0.0 readme: README.md authors: - Bertrand Lanson @@ -11,9 +11,9 @@ license_file: "LICENSE" # A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character # requirements as 'namespace' and 'name' -tags: ["tools"] +tags: ["infomaniak", "cloud"] dependencies: {} -repository: https://git.ednz.fr/ansible-collections/hashistack +repository: https://git.ednz.fr/ansible-collections/infomaniak documentation: http://docs.example.com homepage: http://example.com issues: http://example.com/issue/tracker @@ -22,7 +22,6 @@ issues: http://example.com/issue/tracker # uses 'fnmatch' to match the files or directories. Some directories and files like 'galaxy.yml', '*.pyc', '*.retry', # and '.git' are always filtered. Mutually exclusive with 'manifest' build_ignore: - - assets** - .gitea** # A dict controlling use of manifest directives used in building the collection artifact. The key 'directives' is a # list of MANIFEST.in style diff --git a/molecule/.gitkeep b/molecule/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/molecule/public_cloud_config_info/converge.yml b/molecule/public_cloud_config_info/converge.yml new file mode 100644 index 0000000..23ebaae --- /dev/null +++ b/molecule/public_cloud_config_info/converge.yml @@ -0,0 +1,14 @@ +--- +- name: Converge + hosts: all + become: true + tasks: + - name: "Run module" + ednz_cloud.infomaniak.public_cloud_config_info: + api_token: "{{ lookup('ansible.builtin.env', 'IK_API_TOKEN') }}" + account_id: "{{ lookup('ansible.builtin.env', 'IK_ACCOUNT_ID') }}" + register: _result + + - name: "Print result" + ansible.builtin.debug: + msg: "{{ _result }}" diff --git a/molecule/public_cloud_config_info/molecule.yml b/molecule/public_cloud_config_info/molecule.yml new file mode 100644 index 0000000..23fa83b --- /dev/null +++ b/molecule/public_cloud_config_info/molecule.yml @@ -0,0 +1,37 @@ +--- +dependency: + name: galaxy + options: + requirements-file: ./requirements.yml +driver: + name: docker +platforms: + - name: instance + image: geerlingguy/docker-${MOLECULE_TEST_OS}-ansible + command: "" + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup + cgroupns_mode: host + privileged: true + pre_build_image: true +provisioner: + name: ansible + config_options: + defaults: + remote_tmp: /tmp/.ansible +verifier: + name: ansible +scenario: + name: public_cloud_config_info + test_sequence: + - dependency + - cleanup + - destroy + - syntax + - create + - prepare + - converge + - idempotence + - verify + - cleanup + - destroy diff --git a/molecule/public_cloud_config_info/prepare.yml b/molecule/public_cloud_config_info/prepare.yml new file mode 100644 index 0000000..f917aaa --- /dev/null +++ b/molecule/public_cloud_config_info/prepare.yml @@ -0,0 +1,9 @@ +--- +- name: Converge + hosts: all + become: true + tasks: + - name: "Install python requirements" + ansible.builtin.pip: + name: requests + executable: pip3 diff --git a/molecule/public_cloud_config_info/requirements.yml b/molecule/public_cloud_config_info/requirements.yml new file mode 100644 index 0000000..9180507 --- /dev/null +++ b/molecule/public_cloud_config_info/requirements.yml @@ -0,0 +1,4 @@ +--- +# requirements file for molecule +collections: + - name: ednz_cloud.infomaniak diff --git a/plugins/.gitkeep b/plugins/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..6260634 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,31 @@ +# Collections Plugins Directory + +This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that +is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that +would contain module utils and modules respectively. + +Here is an example directory of the majority of plugins currently supported by Ansible: + +``` +└── plugins + ├── action + ├── become + ├── cache + ├── callback + ├── cliconf + ├── connection + ├── filter + ├── httpapi + ├── inventory + ├── lookup + ├── module_utils + ├── modules + ├── netconf + ├── shell + ├── strategy + ├── terminal + ├── test + └── vars +``` + +A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/2.15/plugins/plugins.html). diff --git a/plugins/module_utils/infomaniak_api_client.py b/plugins/module_utils/infomaniak_api_client.py new file mode 100644 index 0000000..d7e442a --- /dev/null +++ b/plugins/module_utils/infomaniak_api_client.py @@ -0,0 +1,52 @@ +import requests +import json + +INFOMANIAK_API_URL = "https://api.infomaniak.com" + + +class InfomaniakAPIClient: + def __init__(self, api_version, api_token): + self.base_url = f"{INFOMANIAK_API_URL}/{api_version}" + self.headers = { + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _request(self, method, endpoint, data=None, params=None): + url = f"{self.base_url}{endpoint}" + try: + response = requests.request( + method=method, + url=url, + headers=self.headers, + json=data, + params=params, + timeout=10, + ) + response.raise_for_status() + try: + return response.json() + except json.JSONDecodeError: + raise Exception(f"Invalid JSON response: {response.text}") + except requests.exceptions.HTTPError as http_err: + error_content = response.content if response else "No response" + raise Exception( + f"HTTP error occurred: {http_err}, Response content: {error_content}" + ) + except requests.exceptions.RequestException as req_err: + raise Exception(f"Request error occurred: {req_err}") + except Exception as err: + raise Exception(f"An error occurred: {err}") + + def get(self, endpoint, params=None): + return self._request("GET", endpoint, params=params) + + def post(self, endpoint, data=None): + return self._request("POST", endpoint, data=data) + + def put(self, endpoint, data=None): + return self._request("PUT", endpoint, data=data) + + def delete(self, endpoint): + return self._request("DELETE", endpoint) diff --git a/plugins/modules/public_cloud_config_info.py b/plugins/modules/public_cloud_config_info.py new file mode 100644 index 0000000..030ffc5 --- /dev/null +++ b/plugins/modules/public_cloud_config_info.py @@ -0,0 +1,110 @@ +#!/usr/bin/python + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: ednz_cloud.infomaniak.public_cloud_config_info + +short_description: Retrieves the public cloud configuration from the Infomaniak API. + +version_added: "0.1.0" + +description: + - This module queries the Infomaniak API to retrieve the public cloud configuration for a given account. + - It uses the provided API token for authentication and the account ID as a query parameter. + +requirements: + - C(requests) + +options: + api_token: + description: The API token used to authenticate with the Infomaniak API. + required: true + type: str + account_id: + description: The account ID for which to query the public cloud configuration. + required: true + type: str + +author: + - Bertrand Lanson (@ednxzu) +""" + +EXAMPLES = r""" +# Example: Retrieve public cloud configuration for account ID 965060 +- name: Get public cloud configuration + public_cloud_config_info: + api_token: "your_api_token" + account_id: "123456" +""" + +RETURN = r""" +config: + description: + - The public cloud configuration for the specified account. + type: dict + returned: always + sample: + free_tier: 300 + free_tier_used: 24.34 + account_resource_level: 2 + valid_from: 1707584356 + valid_to: 1717192799 + project_count: 2 +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.ednz_cloud.infomaniak.plugins.module_utils.infomaniak_api_client import ( + InfomaniakAPIClient, +) + + +def get_public_cloud_config(client: InfomaniakAPIClient, account_id: str): + endpoint = "/public_clouds/config" + params = {"account_id": account_id} + response = client.get(endpoint, params=params) + + if response.get("result") != "success": + raise Exception(f"API request failed with result: {response.get('result')}") + + return response.get("data", {}) + + +def run_module(): + module_args = dict( + api_token=dict(type="str", required=True, no_log=True), + account_id=dict(type="str", required=True), + ) + + result = dict(changed=False, config={}) + + module = AnsibleModule(argument_spec=module_args, supports_check_mode=True) + + api_token = module.params["api_token"] + account_id = module.params["account_id"] + + if module.check_mode: + module.exit_json( + changed=False, + msg="Check mode: No changes made, would retrieve public cloud config.", + ) + + client = InfomaniakAPIClient(api_version="1", api_token=api_token) + + try: + config_data = get_public_cloud_config(client, account_id) + result["config"] = config_data + module.exit_json(**result) + except Exception as e: + module.fail_json(msg=str(e)) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..01e96bc --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,5 @@ +ansible==10.3.0 +requests==2.32.3 +pytest==8.3.3 +pytest-xdist==3.6.1 +coverage==7.3.2 diff --git a/tests/unit/plugins/modules/test_public_cloud_config_info.py b/tests/unit/plugins/modules/test_public_cloud_config_info.py new file mode 100644 index 0000000..23c88ce --- /dev/null +++ b/tests/unit/plugins/modules/test_public_cloud_config_info.py @@ -0,0 +1,194 @@ +import unittest +import pytest +import json +from unittest.mock import patch, MagicMock, Mock +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils import basic +from ansible.module_utils.common.text.converters import to_bytes +from ansible_collections.ednz_cloud.infomaniak.plugins.modules import ( + public_cloud_config_info, +) + + +def set_module_args(args): + if "_ansible_remote_tmp" not in args: + args["_ansible_remote_tmp"] = "/tmp" + if "_ansible_keep_remote_files" not in args: + args["_ansible_keep_remote_files"] = False + + args = json.dumps({"ANSIBLE_MODULE_ARGS": args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class AnsibleExitJson(Exception): + def __init__(self, kwargs): + self.kwargs = kwargs + + +def exit_json(*args, **kwargs): + if "changed" not in kwargs: + kwargs["changed"] = False + raise AnsibleExitJson(kwargs) + + +def fail_json(*args, **kwargs): + raise SystemExit(json.dumps(kwargs)) + + +class TestPublicCloudConfigInfoModule(unittest.TestCase): + """ + Unit tests for the public_cloud_config_info Ansible module. + """ + + @patch( + "ansible_collections.ednz_cloud.infomaniak.plugins.modules.public_cloud_config_info.AnsibleModule" + ) + @patch( + "ansible_collections.ednz_cloud.infomaniak.plugins.modules.public_cloud_config_info.InfomaniakAPIClient" + ) + def test_successful_retrieval(self, mock_client_class, mock_ansible_module): + """ + Test a successful retrieval of public cloud configuration. + """ + api_response_data = { + "result": "success", + "data": { + "free_tier": 300, + "free_tier_used": 24.34, + "account_resource_level": 2, + "valid_from": 1707584356, + "valid_to": 1717192799, + "project_count": 2, + }, + } + + mock_client_instance = MagicMock() + mock_client_instance.get.return_value = api_response_data + mock_client_class.return_value = mock_client_instance + + mock_module_instance = MagicMock() + mock_module_instance.params = { + "api_token": "mock_api_token", + "account_id": "mock_account_id", + } + mock_module_instance.check_mode = False + mock_ansible_module.return_value = mock_module_instance + + mock_module_instance.exit_json = MagicMock() + + public_cloud_config_info.run_module() + + mock_client_class.assert_called_with( + api_version="1", api_token="mock_api_token" + ) + + mock_client_instance.get.assert_called_once_with( + "/public_clouds/config", params={"account_id": "mock_account_id"} + ) + + mock_module_instance.exit_json.assert_called_once_with( + changed=False, config=api_response_data["data"] + ) + + def test_missing_required_params(self): + """ + Test behavior when required parameters are missing. + """ + set_module_args( + { + "api_token": "mock_api_token", + # 'account_id' is missing + } + ) + + with patch.object(AnsibleModule, "fail_json", side_effect=fail_json): + with pytest.raises(SystemExit) as e: + public_cloud_config_info.main() + + captured_output = json.loads(e.value.args[0]) + + assert "msg" in captured_output + assert "missing required arguments: account_id" in captured_output["msg"] + + def test_check_mode(self): + """ + Test the behavior in check mode, where no changes should be made. + """ + set_module_args( + { + "api_token": "mock_api_token", + "account_id": "mock_account_id", + "_ansible_check_mode": True, + } + ) + + with patch.object( + AnsibleModule, "exit_json", side_effect=exit_json + ) as mock_exit_json: + with pytest.raises(AnsibleExitJson) as e: + public_cloud_config_info.main() + + mock_exit_json.assert_called_once_with( + changed=False, + msg="Check mode: No changes made, would retrieve public cloud config.", + ) + + @patch( + "ansible_collections.ednz_cloud.infomaniak.plugins.modules.public_cloud_config_info.InfomaniakAPIClient" + ) + def test_network_timeout(self, mock_client_class): + """ + Test behavior when a network timeout occurs. + """ + # Simulate a network timeout error + mock_client_instance = MagicMock() + mock_client_instance.get.side_effect = Exception("Request timeout") + mock_client_class.return_value = mock_client_instance + + set_module_args( + { + "api_token": "mock_api_token", + "account_id": "mock_account_id", + } + ) + + with patch.object( + AnsibleModule, "fail_json", side_effect=fail_json + ) as mock_fail_json: + with pytest.raises(SystemExit) as e: + public_cloud_config_info.main() + + captured_output = json.loads(e.value.args[0]) + assert "msg" in captured_output + assert captured_output["msg"] == "Request timeout" + mock_fail_json.assert_called_once() + + @patch( + "ansible_collections.ednz_cloud.infomaniak.plugins.modules.public_cloud_config_info.InfomaniakAPIClient" + ) + def test_invalid_json_response(self, mock_client_class): + """ + Test behavior when the API returns an invalid JSON response. + """ + mock_client_instance = MagicMock() + mock_client_instance.get.side_effect = Exception("Invalid JSON response") + mock_client_class.return_value = mock_client_instance + + # Set module args with the required parameters + set_module_args( + { + "api_token": "mock_api_token", + "account_id": "mock_account_id", + } + ) + + with patch.object( + AnsibleModule, "fail_json", side_effect=fail_json + ) as mock_fail_json: + with pytest.raises(SystemExit) as e: + public_cloud_config_info.main() + + captured_output = json.loads(e.value.args[0]) + assert "msg" in captured_output + assert captured_output["msg"] == "Invalid JSON response" + mock_fail_json.assert_called_once()