From 8439e62d5944cdd68101b2b1de32329ccf097ae7 Mon Sep 17 00:00:00 2001 From: "Heino H. Gehlsen" Date: Thu, 12 Jan 2023 12:45:59 +0000 Subject: [PATCH] Rewrite lxd_container to use pylxd (due to certificate validation issue #5616) --- plugins/module_utils/lxd.py | 82 ++++++++++++++++++ plugins/modules/lxd_container.py | 142 ++++++++++++++----------------- 2 files changed, 145 insertions(+), 79 deletions(-) diff --git a/plugins/module_utils/lxd.py b/plugins/module_utils/lxd.py index 007de4d8dba..e9e4db097a8 100644 --- a/plugins/module_utils/lxd.py +++ b/plugins/module_utils/lxd.py @@ -132,3 +132,85 @@ def default_key_file(): def default_cert_file(): return os.path.expanduser('~/.config/lxc/client.crt') + + + + + + + +from pylxd import Client as PyLxdClient +from pylxd.exceptions import LXDAPIException, ClientConnectionFailed + +import os + +def pylxd_client(endpoint, client_cert=None, client_key=None, password=None, project=None, timeout=None, verify=True): + try: + # Connecting to the local unix socket + if endpoint is None or endpoint == '/var/lib/lxd/unix.socket' or endpoint == 'unix:/var/lib/lxd/unix.socket': + return PyLxdClient( + timeout=timeout, + project=project, + ) + + # Connecting to some other local socket + elif endpoint.startswith('/'): + return PyLxdClient( + endpoint=endpoint, + timeout=timeout, + project=project, + ) + + # Connecting to remote server + elif endpoint.startswith('https://'): + + if client_cert is None: + client_cert = '~/.config/lxc/client.crt' + if client_key is None: + client_key = '~/.config/lxc/client.key' + + # Expand an initial '~/'-path component + client_cert = os.path.expanduser(client_cert) + client_key = os.path.expanduser(client_key) + + if not os.path.isfile(client_cert): + raise ValueError( + f"Invalid client_cert path: '{client_cert}' does not exist or is not a file." + ) + if not os.path.isfile(client_key): + raise ValueError( + f"Invalid client_key path: '{client_key}' does not exist or is not a file." + ) + + client = PyLxdClient( + endpoint=endpoint, + cert=( client_cert, client_key ), + verify=verify, + timeout=timeout, + project=project, + ) + + if not client.trusted: + if password is None: + raise LXDClientException('The certificate is not yet trusted, but no trusted password provided') + try: + client.authenticate(password) + except LXDAPIException as e: + raise LXDClientException(str(e)) + + return client + + # Invalid url + else: + raise ValueError('Invalid endpoint: ' + endpoint) + + except ClientConnectionFailed as e: + raise LXDClientException( + f"Failed to connect to '{endpoint}' " + str(e) + ' !' + ) +# TODO: Does this actually happen??? +# except TypeError as e: +# # Happens when the verification failed. +# raise LXDClientException( +# f("Failed to connect to '{endpoint}' looks like the SSL verification failed, error was: {e}" +# ) diff --git a/plugins/modules/lxd_container.py b/plugins/modules/lxd_container.py index 30dc855617d..e609c392106 100644 --- a/plugins/modules/lxd_container.py +++ b/plugins/modules/lxd_container.py @@ -401,8 +401,10 @@ import time from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.lxd import LXDClient, LXDClientException -from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible_collections.community.general.plugins.module_utils.lxd import pylxd_client, LXDClientException +#from ansible.module_utils.lxd import pylxd_client, LXDClientException +from pylxd.exceptions import NotFound + # LXD_ANSIBLE_STATES is a map of states that contain values of methods used # when a particular state is evoked. @@ -451,22 +453,15 @@ def __init__(self, module): self.addresses = None self.target = self.module.params['target'] self.wait_for_container = self.module.params['wait_for_container'] + self.ignore_volatile_options = self.module.params.get('ignore_volatile_options') self.type = self.module.params['type'] - # LXD Rest API provides additional endpoints for creating containers and virtual-machines. - self.api_endpoint = None - if self.type == 'container': - self.api_endpoint = '/1.0/containers' - elif self.type == 'virtual-machine': - self.api_endpoint = '/1.0/virtual-machines' - - self.key_file = self.module.params.get('client_key') - if self.key_file is None: - self.key_file = '{0}/.config/lxc/client.key'.format(os.environ['HOME']) - self.cert_file = self.module.params.get('client_cert') - if self.cert_file is None: - self.cert_file = '{0}/.config/lxc/client.crt'.format(os.environ['HOME']) + self.client_key = self.module.params.get('client_key') + self.client_cert = self.module.params.get('client_cert') + self.trust_password = self.module.params.get('trust_password', None) + self.verify = self.module.params.get('verify') + self.debug = self.module._verbosity >= 4 try: @@ -480,13 +475,18 @@ def __init__(self, module): 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 + self.client = pylxd_client( + endpoint=self.url, + client_cert=self.client_cert, + client_key=self.client_key, + password=self.trust_password, + project=self.project, + timeout=self.timeout, + verify=self.verify, ) 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): @@ -496,78 +496,48 @@ def _build_config(self): if param_val is not None: self.config[attr] = param_val - def _get_instance_json(self): - url = '{0}/{1}'.format(self.api_endpoint, self.name) - if self.project: - url = '{0}?{1}'.format(url, urlencode(dict(project=self.project))) - return self.client.do('GET', url, ok_error_codes=[404]) - - def _get_instance_state_json(self): - url = '{0}/{1}/state'.format(self.api_endpoint, self.name) - if self.project: - url = '{0}?{1}'.format(url, urlencode(dict(project=self.project))) - return self.client.do('GET', url, ok_error_codes=[404]) - - @staticmethod - def _instance_json_to_module_state(resp_json): - if resp_json['type'] == 'error': - return 'absent' - return ANSIBLE_LXD_STATES[resp_json['metadata']['status']] - - def _change_state(self, action, force_stop=False): - url = '{0}/{1}/state'.format(self.api_endpoint, self.name) - if self.project: - url = '{0}?{1}'.format(url, urlencode(dict(project=self.project))) - body_json = {'action': action, 'timeout': self.timeout} - if force_stop: - body_json['force'] = True - return self.client.do('PUT', url, body_json=body_json) - def _create_instance(self): - url = self.api_endpoint - url_params = dict() - if self.target: - url_params['target'] = self.target - if self.project: - url_params['project'] = self.project - if url_params: - url = '{0}?{1}'.format(url, urlencode(url_params)) config = self.config.copy() config['name'] = self.name - self.client.do('POST', url, config, wait_for_container=self.wait_for_container) + + match self.type: + case 'container': + self.instance = self.client.containers.create(config=config, wait=True, target=self.target) + case 'virtual-machine': + self.instance = self.client.virtual_machines.create(config=config, wait=True, target=self.target) + +# TODO: Re-add wait_for_container via stuff from Client.do() in pxd.py? Or just use/hijack the wait parameter above? + self.actions.append('create') def _start_instance(self): - self._change_state('start') + self.instance.start(wait=True) self.actions.append('start') def _stop_instance(self): - self._change_state('stop', self.force_stop) + self.instance.stop(wait=True, force=self.force_stop) self.actions.append('stop') def _restart_instance(self): - self._change_state('restart', self.force_stop) + self.instance.restart(wait=True, force=self.force_stop) self.actions.append('restart') def _delete_instance(self): - url = '{0}/{1}'.format(self.api_endpoint, self.name) - if self.project: - url = '{0}?{1}'.format(url, urlencode(dict(project=self.project))) - self.client.do('DELETE', url) + self.instance.delete(wait=True) self.actions.append('delete') def _freeze_instance(self): - self._change_state('freeze') + self.instance.freeze(wait=True) self.actions.append('freeze') def _unfreeze_instance(self): - self._change_state('unfreeze') + self.instance.unfreeze(wait=True) self.actions.append('unfreez') def _instance_ipv4_addresses(self, ignore_devices=None): ignore_devices = ['lo'] if ignore_devices is None else ignore_devices - resp_json = self._get_instance_state_json() + resp_json = self.client.api.instances[self.name].state.get().json() network = resp_json['metadata']['network'] or {} network = dict((k, v) for k, v in network.items() if k not in ignore_devices) or {} addresses = dict((k, [a['address'] for a in v['addresses'] if a['family'] == 'inet']) for k, v in network.items()) or {} @@ -706,22 +676,28 @@ def _apply_instance_configs(self): if self._needs_to_change_instance_config('profiles'): body_json['profiles'] = self.config['profiles'] - url = '{0}/{1}'.format(self.api_endpoint, self.name) - if self.project: - url = '{0}?{1}'.format(url, urlencode(dict(project=self.project))) - self.client.do('PUT', url, body_json=body_json) + self.instance.api.put(json=body_json) self.actions.append('apply_instance_configs') def run(self): """Run the main method.""" try: - if self.trust_password is not None: - self.client.authenticate(self.trust_password) - self.ignore_volatile_options = self.module.params.get('ignore_volatile_options') + try: + match self.type: + case 'container': + self.instance = self.client.containers.get(self.name) + case 'virtual-machine': + self.instance = self.client.virtual_machines.get(self.name) + + self.old_instance_json = self.client.api.instances[self.name].get().json() + + except NotFound: + self.instance = None + self.old_instance_json = { 'metadata': {} } + + self.old_state = ANSIBLE_LXD_STATES[self.instance.status] if self.instance else 'absent' - self.old_instance_json = self._get_instance_json() - self.old_state = self._instance_json_to_module_state(self.old_instance_json) action = getattr(self, LXD_ANSIBLE_STATES[self.state]) action() @@ -732,11 +708,14 @@ def run(self): 'old_state': self.old_state, 'actions': self.actions } - if self.client.debug: - result_json['logs'] = self.client.logs +# TODO: Is such logging needed at all when using the pylxd library + if self.debug: + # Provide a dummy log event + result_json['logs'] = [ '(Currently no client logging after switch to pylxd)' ] if self.addresses is not None: result_json['addresses'] = self.addresses self.module.exit_json(**result_json) + except LXDClientException as e: state_changed = len(self.actions) > 0 fail_params = { @@ -744,8 +723,8 @@ def run(self): 'changed': state_changed, 'actions': self.actions } - if self.client.debug: - fail_params['logs'] = e.kwargs['logs'] + if self.debug: + fail_params['logs'] = e.kwargs['logs'] self.module.fail_json(**fail_params) @@ -814,7 +793,8 @@ def main(): ), url=dict( type='str', - default=ANSIBLE_LXD_DEFAULT_URL + default=ANSIBLE_LXD_DEFAULT_URL, + aliasses=['endpoint'] ), snap_url=dict( type='str', @@ -828,6 +808,10 @@ def main(): type='path', aliases=['cert_file'] ), + verify=dict( + type='bool', + default=True + ), trust_password=dict(type='str', no_log=True) ), supports_check_mode=False,