From 1d3506490f3a38cda8ec8d46216d3f10326d67d2 Mon Sep 17 00:00:00 2001 From: Raymond Chang Date: Tue, 26 Apr 2022 04:20:54 +0800 Subject: [PATCH] New Module: LXD Projects (#4521) * add lxd_project module * documentation improvement and version_added entry * improve documentation * use os.path.expanduser * exclude from use-argspec-type-path test * improve documentation --- .github/BOTMETA.yml | 2 + plugins/modules/cloud/lxd/lxd_project.py | 451 ++++++++++++++++++ plugins/modules/lxd_project.py | 1 + tests/integration/targets/lxd_project/aliases | 1 + .../targets/lxd_project/tasks/main.yml | 140 ++++++ tests/sanity/ignore-2.10.txt | 1 + tests/sanity/ignore-2.11.txt | 1 + tests/sanity/ignore-2.12.txt | 1 + tests/sanity/ignore-2.13.txt | 1 + tests/sanity/ignore-2.14.txt | 1 + tests/sanity/ignore-2.9.txt | 1 + 11 files changed, 601 insertions(+) create mode 100644 plugins/modules/cloud/lxd/lxd_project.py create mode 120000 plugins/modules/lxd_project.py create mode 100644 tests/integration/targets/lxd_project/aliases create mode 100644 tests/integration/targets/lxd_project/tasks/main.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index a7c5666e34..741e2e0ad4 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -312,6 +312,8 @@ files: ignore: hnakamur $modules/cloud/lxd/lxd_profile.py: maintainers: conloos + $modules/cloud/lxd/lxd_project.py: + maintainers: we10710aa $modules/cloud/memset/: maintainers: glitchcrab $modules/cloud/misc/cloud_init_data_facts.py: diff --git a/plugins/modules/cloud/lxd/lxd_project.py b/plugins/modules/cloud/lxd/lxd_project.py new file mode 100644 index 0000000000..d1488272c8 --- /dev/null +++ b/plugins/modules/cloud/lxd/lxd_project.py @@ -0,0 +1,451 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# 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: lxd_project +short_description: Manage LXD projects +version_added: 4.8.0 +description: + - Management of LXD projects. +author: "Raymond Chang (@we10710aa)" +options: + name: + description: + - Name of the project. + required: true + type: str + description: + description: + - Description of the project. + type: str + config: + description: + - 'The config for the project (for example C({"features.profiles": "true"})). + See U(https://linuxcontainers.org/lxd/docs/master/projects/).' + - If the project already exists and its "config" value in metadata + obtained from + C(GET /1.0/projects/) + U(https://linuxcontainers.org/lxd/docs/master/api/#/projects/project_get) + are different, then this module tries to apply the configurations. + type: dict + new_name: + description: + - A new name of a project. + - If this parameter is specified a project will be renamed to this name. + See U(https://linuxcontainers.org/lxd/docs/master/api/#/projects/project_post). + required: false + type: str + merge_project: + description: + - Merge the configuration of the present project with the new desired configuration, + instead of replacing it. If configuration is the same after merged, no change will be made. + required: false + default: false + type: bool + state: + choices: + - present + - absent + description: + - Define the state of a project. + required: false + default: present + type: str + url: + description: + - The Unix domain socket path or the https URL for the LXD server. + required: false + default: unix:/var/lib/lxd/unix.socket + type: str + snap_url: + description: + - The Unix domain socket path when LXD is installed by snap package manager. + required: false + default: unix:/var/snap/lxd/common/lxd/unix.socket + type: str + client_key: + description: + - The client certificate key file path. + - If not specified, it defaults to C($HOME/.config/lxc/client.key). + required: false + aliases: [ key_file ] + type: path + client_cert: + description: + - The client certificate file path. + - If not specified, it defaults to C($HOME/.config/lxc/client.crt). + required: false + aliases: [ cert_file ] + type: path + trust_password: + description: + - The client trusted password. + - 'You need to set this password on the LXD server before + running this module using the following command: + C(lxc config set core.trust_password ) + See U(https://www.stgraber.org/2016/04/18/lxd-api-direct-interaction/).' + - If I(trust_password) is set, this module send a request for + authentication before sending any requests. + required: false + type: str +notes: + - Projects must have a unique name. If you attempt to create a project + with a name that already existed in the users namespace the module will + simply return as "unchanged". +''' + +EXAMPLES = ''' +# An example for creating a project +- hosts: localhost + connection: local + tasks: + - name: Create a project + community.general.lxd_project: + name: ansible-test-project + state: present + config: {} + description: my new project + +# An example for renaming a project +- hosts: localhost + connection: local + tasks: + - name: Rename ansible-test-project to ansible-test-project-new-name + community.general.lxd_project: + name: ansible-test-project + new_name: ansible-test-project-new-name + state: present + config: {} + description: my new project +''' + +RETURN = ''' +old_state: + description: The old state of the project. + returned: success + type: str + sample: "absent" +logs: + description: The logs of requests and responses. + returned: when ansible-playbook is invoked with -vvvv. + type: list + elements: dict + contains: + type: + description: Type of actions performed, currently only C(sent request). + type: str + sample: "sent request" + request: + description: HTTP request sent to LXD server. + type: dict + contains: + method: + description: Method of HTTP request. + type: str + sample: "GET" + url: + description: URL path of HTTP request. + type: str + sample: "/1.0/projects/test-project" + json: + description: JSON body of HTTP request. + type: str + sample: "(too long to be placed here)" + timeout: + description: Timeout of HTTP request, C(null) if unset. + type: int + sample: null + response: + description: HTTP response received from LXD server. + type: dict + contains: + json: + description: JSON of HTTP response. + type: str + sample: "(too long to be placed here)" +actions: + description: List of actions performed for the project. + returned: success + type: list + elements: str + sample: ["create"] +''' + +from ansible_collections.community.general.plugins.module_utils.lxd import LXDClient, LXDClientException +from ansible.module_utils.basic import AnsibleModule +import os + +# ANSIBLE_LXD_DEFAULT_URL is a default value of the lxd endpoint +ANSIBLE_LXD_DEFAULT_URL = 'unix:/var/lib/lxd/unix.socket' + +# PROJECTS_STATES is a list for states supported +PROJECTS_STATES = [ + 'present', 'absent' +] + +# CONFIG_PARAMS is a list of config attribute names. +CONFIG_PARAMS = [ + 'config', 'description' +] + + +class LXDProjectManagement(object): + def __init__(self, module): + """Management of LXC projects via Ansible. + + :param module: Processed Ansible Module. + :type module: ``object`` + """ + self.module = module + self.name = self.module.params['name'] + self._build_config() + self.state = self.module.params['state'] + self.new_name = self.module.params.get('new_name', None) + + self.key_file = self.module.params.get('client_key') + if self.key_file is None: + self.key_file = os.path.expanduser('~/.config/lxc/client.key') + self.cert_file = self.module.params.get('client_cert') + if self.cert_file is None: + self.cert_file = os.path.expanduser('~/.config/lxc/client.crt') + self.debug = self.module._verbosity >= 4 + + try: + if self.module.params['url'] != ANSIBLE_LXD_DEFAULT_URL: + self.url = self.module.params['url'] + elif os.path.exists(self.module.params['snap_url'].replace('unix:', '')): + self.url = self.module.params['snap_url'] + else: + self.url = self.module.params['url'] + except Exception as e: + self.module.fail_json(msg=e.msg) + + try: + self.client = LXDClient( + self.url, key_file=self.key_file, cert_file=self.cert_file, + debug=self.debug + ) + except LXDClientException as e: + self.module.fail_json(msg=e.msg) + self.trust_password = self.module.params.get('trust_password', None) + self.actions = [] + + def _build_config(self): + self.config = {} + for attr in CONFIG_PARAMS: + param_val = self.module.params.get(attr, None) + if param_val is not None: + self.config[attr] = param_val + + def _get_project_json(self): + return self.client.do( + 'GET', '/1.0/projects/{0}'.format(self.name), + ok_error_codes=[404] + ) + + @staticmethod + def _project_json_to_module_state(resp_json): + if resp_json['type'] == 'error': + return 'absent' + return 'present' + + def _update_project(self): + if self.state == 'present': + if self.old_state == 'absent': + if self.new_name is None: + self._create_project() + else: + self.module.fail_json( + msg='new_name must not be set when the project does not exist and the state is present', + changed=False) + else: + if self.new_name is not None and self.new_name != self.name: + self._rename_project() + if self._needs_to_apply_project_configs(): + self._apply_project_configs() + elif self.state == 'absent': + if self.old_state == 'present': + if self.new_name is None: + self._delete_project() + else: + self.module.fail_json( + msg='new_name must not be set when the project exists and the specified state is absent', + changed=False) + + def _create_project(self): + config = self.config.copy() + config['name'] = self.name + self.client.do('POST', '/1.0/projects', config) + self.actions.append('create') + + def _rename_project(self): + config = {'name': self.new_name} + self.client.do('POST', '/1.0/projects/{0}'.format(self.name), config) + self.actions.append('rename') + self.name = self.new_name + + def _needs_to_change_project_config(self, key): + if key not in self.config: + return False + old_configs = self.old_project_json['metadata'].get(key, None) + return self.config[key] != old_configs + + def _needs_to_apply_project_configs(self): + return ( + self._needs_to_change_project_config('config') or + self._needs_to_change_project_config('description') + ) + + def _merge_dicts(self, source, destination): + """ Return a new dict taht merge two dict, + with values in source dict overwrite destination dict + + Args: + dict(source): source dict + dict(destination): destination dict + Kwargs: + None + Raises: + None + Returns: + dict(destination): merged dict""" + result = destination.copy() + for key, value in source.items(): + if isinstance(value, dict): + # get node or create one + node = result.setdefault(key, {}) + self._merge_dicts(value, node) + else: + result[key] = value + return result + + def _apply_project_configs(self): + """ Selection of the procedure: rebuild or merge + + The standard behavior is that all information not contained + in the play is discarded. + + If "merge_project" is provides in the play and "True", then existing + configurations from the project and new ones defined are merged. + + Args: + None + Kwargs: + None + Raises: + None + Returns: + None""" + old_config = dict() + old_metadata = self.old_project_json['metadata'].copy() + for attr in CONFIG_PARAMS: + old_config[attr] = old_metadata[attr] + + if self.module.params['merge_project']: + config = self._merge_dicts(self.config, old_config) + if config == old_config: + # no need to call api if merged config is the same + # as old config + return + else: + config = self.config.copy() + # upload config to lxd + self.client.do('PUT', '/1.0/projects/{0}'.format(self.name), config) + self.actions.append('apply_projects_configs') + + def _delete_project(self): + self.client.do('DELETE', '/1.0/projects/{0}'.format(self.name)) + self.actions.append('delete') + + def run(self): + """Run the main method.""" + + try: + if self.trust_password is not None: + self.client.authenticate(self.trust_password) + + self.old_project_json = self._get_project_json() + self.old_state = self._project_json_to_module_state( + self.old_project_json) + self._update_project() + + state_changed = len(self.actions) > 0 + result_json = { + 'changed': state_changed, + 'old_state': self.old_state, + 'actions': self.actions + } + if self.client.debug: + result_json['logs'] = self.client.logs + self.module.exit_json(**result_json) + except LXDClientException as e: + state_changed = len(self.actions) > 0 + fail_params = { + 'msg': e.msg, + 'changed': state_changed, + 'actions': self.actions + } + if self.client.debug: + fail_params['logs'] = e.kwargs['logs'] + self.module.fail_json(**fail_params) + + +def main(): + """Ansible Main module.""" + + module = AnsibleModule( + argument_spec=dict( + name=dict( + type='str', + required=True + ), + new_name=dict( + type='str', + ), + config=dict( + type='dict', + ), + description=dict( + type='str', + ), + merge_project=dict( + type='bool', + default=False + ), + state=dict( + choices=PROJECTS_STATES, + default='present' + ), + url=dict( + type='str', + default=ANSIBLE_LXD_DEFAULT_URL + ), + snap_url=dict( + type='str', + default='unix:/var/snap/lxd/common/lxd/unix.socket' + ), + client_key=dict( + type='path', + aliases=['key_file'] + ), + client_cert=dict( + type='path', + aliases=['cert_file'] + ), + trust_password=dict(type='str', no_log=True) + ), + supports_check_mode=False, + ) + + lxd_manage = LXDProjectManagement(module=module) + lxd_manage.run() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/lxd_project.py b/plugins/modules/lxd_project.py new file mode 120000 index 0000000000..5e9d6f4cfb --- /dev/null +++ b/plugins/modules/lxd_project.py @@ -0,0 +1 @@ +./cloud/lxd/lxd_project.py \ No newline at end of file diff --git a/tests/integration/targets/lxd_project/aliases b/tests/integration/targets/lxd_project/aliases new file mode 100644 index 0000000000..ad7ccf7ada --- /dev/null +++ b/tests/integration/targets/lxd_project/aliases @@ -0,0 +1 @@ +unsupported diff --git a/tests/integration/targets/lxd_project/tasks/main.yml b/tests/integration/targets/lxd_project/tasks/main.yml new file mode 100644 index 0000000000..0dc5178930 --- /dev/null +++ b/tests/integration/targets/lxd_project/tasks/main.yml @@ -0,0 +1,140 @@ +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Clean up test project + lxd_project: + name: ansible-test-project + state: absent + +- name: Clean up test project + lxd_project: + name: ansible-test-project-renamed + state: absent + +- name: Create test project + lxd_project: + name: ansible-test-project + config: + features.images: "false" + features.networks: "true" + features.profiles: "true" + limits.cpu: "3" + state: present + register: results + +- name: Check project has been created correctly + assert: + that: + - results is changed + - results.actions is defined + - "'create' in results.actions" + +- name: Create test project again with merge_project set to true + lxd_project: + name: ansible-test-project + merge_project: true + config: + features.images: "false" + features.networks: "true" + features.profiles: "true" + limits.cpu: "3" + state: present + register: results + +- name: Check state is not changed + assert: + that: + - results is not changed + - "{{ results.actions | length }} == 0" + +- name: Create test project again with merge_project set to false + lxd_project: + name: ansible-test-project + merge_project: false + config: + features.images: "false" + features.networks: "true" + features.profiles: "true" + limits.cpu: "3" + state: present + register: results + +- name: Check state is not changed + assert: + that: + - results is changed + - "'apply_projects_configs' in results.actions" + +- name: Update project test => update description + lxd_project: + name: ansible-test-project + merge_project: false + description: "ansible test project" + config: + features.images: "false" + features.networks: "true" + features.profiles: "true" + limits.cpu: "3" + state: present + register: results + +- name: Check state is changed + assert: + that: + - results is changed + - "'apply_projects_configs' in results.actions" + +- name: Update project test => update project config + lxd_project: + name: ansible-test-project + merge_project: false + description: "ansible test project" + config: + features.images: "false" + features.networks: "true" + features.profiles: "true" + limits.cpu: "4" + state: present + register: results + +- name: Check state is changed + assert: + that: + - results is changed + - "'apply_projects_configs' in results.actions" + +- name: Rename project test + lxd_project: + name: ansible-test-project + new_name: ansible-test-project-renamed + merge_project: true + description: "ansible test project" + config: + features.images: "false" + features.networks: "true" + features.profiles: "true" + limits.cpu: "4" + state: present + register: results + +- name: Check state is changed + assert: + that: + - results is changed + - "'rename' in results.actions" + +- name: Clean up test project + lxd_project: + name: ansible-test-project-renamed + state: absent + register: results + +- name: Check project is deleted + assert: + that: + - results is changed + - "'delete' in results.actions" diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index 4d5d390380..999ffac36a 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -7,6 +7,7 @@ plugins/module_utils/cloud.py pylint:bad-option-value # a pylint test that is disabled was modified over time plugins/modules/cloud/lxc/lxc_container.py use-argspec-type-path plugins/modules/cloud/lxc/lxc_container.py validate-modules:use-run-command-not-popen +plugins/modules/cloud/lxd/lxd_project.py use-argspec-type-path # expanduser() applied to constants plugins/modules/cloud/misc/rhevm.py validate-modules:parameter-state-invalid-choice plugins/modules/cloud/rackspace/rax.py use-argspec-type-path # fix needed plugins/modules/cloud/rackspace/rax_files.py validate-modules:parameter-state-invalid-choice diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index ee0ae9beb4..55ce1b6f77 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -6,6 +6,7 @@ .azure-pipelines/scripts/publish-codecov.py metaclass-boilerplate plugins/modules/cloud/lxc/lxc_container.py use-argspec-type-path plugins/modules/cloud/lxc/lxc_container.py validate-modules:use-run-command-not-popen +plugins/modules/cloud/lxd/lxd_project.py use-argspec-type-path # expanduser() applied to constants plugins/modules/cloud/misc/rhevm.py validate-modules:parameter-state-invalid-choice plugins/modules/cloud/rackspace/rax.py use-argspec-type-path # fix needed plugins/modules/cloud/rackspace/rax_files.py validate-modules:parameter-state-invalid-choice diff --git a/tests/sanity/ignore-2.12.txt b/tests/sanity/ignore-2.12.txt index 572451ae21..f111ea954e 100644 --- a/tests/sanity/ignore-2.12.txt +++ b/tests/sanity/ignore-2.12.txt @@ -1,6 +1,7 @@ .azure-pipelines/scripts/publish-codecov.py replace-urlopen plugins/modules/cloud/lxc/lxc_container.py use-argspec-type-path plugins/modules/cloud/lxc/lxc_container.py validate-modules:use-run-command-not-popen +plugins/modules/cloud/lxd/lxd_project.py use-argspec-type-path # expanduser() applied to constants plugins/modules/cloud/misc/rhevm.py validate-modules:parameter-state-invalid-choice plugins/modules/cloud/rackspace/rax.py use-argspec-type-path # fix needed plugins/modules/cloud/rackspace/rax_files.py validate-modules:parameter-state-invalid-choice diff --git a/tests/sanity/ignore-2.13.txt b/tests/sanity/ignore-2.13.txt index 572451ae21..f111ea954e 100644 --- a/tests/sanity/ignore-2.13.txt +++ b/tests/sanity/ignore-2.13.txt @@ -1,6 +1,7 @@ .azure-pipelines/scripts/publish-codecov.py replace-urlopen plugins/modules/cloud/lxc/lxc_container.py use-argspec-type-path plugins/modules/cloud/lxc/lxc_container.py validate-modules:use-run-command-not-popen +plugins/modules/cloud/lxd/lxd_project.py use-argspec-type-path # expanduser() applied to constants plugins/modules/cloud/misc/rhevm.py validate-modules:parameter-state-invalid-choice plugins/modules/cloud/rackspace/rax.py use-argspec-type-path # fix needed plugins/modules/cloud/rackspace/rax_files.py validate-modules:parameter-state-invalid-choice diff --git a/tests/sanity/ignore-2.14.txt b/tests/sanity/ignore-2.14.txt index 572451ae21..f111ea954e 100644 --- a/tests/sanity/ignore-2.14.txt +++ b/tests/sanity/ignore-2.14.txt @@ -1,6 +1,7 @@ .azure-pipelines/scripts/publish-codecov.py replace-urlopen plugins/modules/cloud/lxc/lxc_container.py use-argspec-type-path plugins/modules/cloud/lxc/lxc_container.py validate-modules:use-run-command-not-popen +plugins/modules/cloud/lxd/lxd_project.py use-argspec-type-path # expanduser() applied to constants plugins/modules/cloud/misc/rhevm.py validate-modules:parameter-state-invalid-choice plugins/modules/cloud/rackspace/rax.py use-argspec-type-path # fix needed plugins/modules/cloud/rackspace/rax_files.py validate-modules:parameter-state-invalid-choice diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index dc5d2fcb72..2bc52a2260 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -7,6 +7,7 @@ plugins/module_utils/cloud.py pylint:bad-option-value # a pylint test that is disabled was modified over time plugins/modules/cloud/lxc/lxc_container.py use-argspec-type-path plugins/modules/cloud/lxc/lxc_container.py validate-modules:use-run-command-not-popen +plugins/modules/cloud/lxd/lxd_project.py use-argspec-type-path # expanduser() applied to constants plugins/modules/cloud/rackspace/rax.py use-argspec-type-path plugins/modules/cloud/rackspace/rax_files_objects.py use-argspec-type-path plugins/modules/cloud/rackspace/rax_scaling_group.py use-argspec-type-path # fix needed, expanduser() applied to dict values