From 3862de3f153c4401b7d77dba9e71a6535973ecb4 Mon Sep 17 00:00:00 2001 From: Peter Upton Date: Sun, 12 Mar 2023 06:39:42 -0500 Subject: [PATCH] Removes dependency on StormSSH (#6117) * included storm config parser * Add changelog fragment * Fix changelog fragment * address PR feedback * fix license * add required boilerplate, fix issues found in test output * move __future__ imports * address pr feedback * address test output * address pr feedback --- .../fragments/6117-remove-stormssh-depend.yml | 2 + plugins/module_utils/_stormssh.py | 258 ++++++++++++++++++ plugins/modules/ssh_config.py | 24 +- .../targets/ssh_config/tasks/main.yml | 1 - 4 files changed, 268 insertions(+), 17 deletions(-) create mode 100644 changelogs/fragments/6117-remove-stormssh-depend.yml create mode 100644 plugins/module_utils/_stormssh.py diff --git a/changelogs/fragments/6117-remove-stormssh-depend.yml b/changelogs/fragments/6117-remove-stormssh-depend.yml new file mode 100644 index 0000000000..f84ac6ac2e --- /dev/null +++ b/changelogs/fragments/6117-remove-stormssh-depend.yml @@ -0,0 +1,2 @@ +minor_changes: + - ssh_config - vendored StormSSH's config parser to avoid having to install StormSSH to use the module (https://github.com/ansible-collections/community.general/pull/6117). diff --git a/plugins/module_utils/_stormssh.py b/plugins/module_utils/_stormssh.py new file mode 100644 index 0000000000..ec364b83d4 --- /dev/null +++ b/plugins/module_utils/_stormssh.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is based on +# the config parser from here: https://github.com/emre/storm/blob/master/storm/parsers/ssh_config_parser.py +# Copyright (C) <2013> +# SPDX-License-Identifier: MIT + +from __future__ import (absolute_import, division, print_function) +import os +import re +import traceback +from operator import itemgetter + +__metaclass__ = type + +try: + from paramiko.config import SSHConfig +except ImportError: + SSHConfig = object + HAS_PARAMIKO = False + PARAMIKO_IMPORT_ERROR = traceback.format_exc() +else: + HAS_PARAMIKO = True + PARAMIKO_IMPORT_ERROR = None + + +class StormConfig(SSHConfig): + def parse(self, file_obj): + """ + Read an OpenSSH config from the given file object. + @param file_obj: a file-like object to read the config file from + @type file_obj: file + """ + order = 1 + host = {"host": ['*'], "config": {}, } + for line in file_obj: + line = line.rstrip('\n').lstrip() + if line == '': + self._config.append({ + 'type': 'empty_line', + 'value': line, + 'host': '', + 'order': order, + }) + order += 1 + continue + + if line.startswith('#'): + self._config.append({ + 'type': 'comment', + 'value': line, + 'host': '', + 'order': order, + }) + order += 1 + continue + + if '=' in line: + # Ensure ProxyCommand gets properly split + if line.lower().strip().startswith('proxycommand'): + proxy_re = re.compile(r"^(proxycommand)\s*=*\s*(.*)", re.I) + match = proxy_re.match(line) + key, value = match.group(1).lower(), match.group(2) + else: + key, value = line.split('=', 1) + key = key.strip().lower() + else: + # find first whitespace, and split there + i = 0 + while (i < len(line)) and not line[i].isspace(): + i += 1 + if i == len(line): + raise Exception('Unparsable line: %r' % line) + key = line[:i].lower() + value = line[i:].lstrip() + if key == 'host': + self._config.append(host) + value = value.split() + host = { + key: value, + 'config': {}, + 'type': 'entry', + 'order': order + } + order += 1 + elif key in ['identityfile', 'localforward', 'remoteforward']: + if key in host['config']: + host['config'][key].append(value) + else: + host['config'][key] = [value] + elif key not in host['config']: + host['config'].update({key: value}) + self._config.append(host) + + +class ConfigParser(object): + """ + Config parser for ~/.ssh/config files. + """ + + def __init__(self, ssh_config_file=None): + if not ssh_config_file: + ssh_config_file = self.get_default_ssh_config_file() + + self.defaults = {} + + self.ssh_config_file = ssh_config_file + + if not os.path.exists(self.ssh_config_file): + if not os.path.exists(os.path.dirname(self.ssh_config_file)): + os.makedirs(os.path.dirname(self.ssh_config_file)) + open(self.ssh_config_file, 'w+').close() + os.chmod(self.ssh_config_file, 0o600) + + self.config_data = [] + + def get_default_ssh_config_file(self): + return os.path.expanduser("~/.ssh/config") + + def load(self): + config = StormConfig() + + with open(self.ssh_config_file) as fd: + config.parse(fd) + + for entry in config.__dict__.get("_config"): + if entry.get("host") == ["*"]: + self.defaults.update(entry.get("config")) + + if entry.get("type") in ["comment", "empty_line"]: + self.config_data.append(entry) + continue + + host_item = { + 'host': entry["host"][0], + 'options': entry.get("config"), + 'type': 'entry', + 'order': entry.get("order", 0), + } + + if len(entry["host"]) > 1: + host_item.update({ + 'host': " ".join(entry["host"]), + }) + # minor bug in paramiko.SSHConfig that duplicates + # "Host *" entries. + if entry.get("config") and len(entry.get("config")) > 0: + self.config_data.append(host_item) + + return self.config_data + + def add_host(self, host, options): + self.config_data.append({ + 'host': host, + 'options': options, + 'order': self.get_last_index(), + }) + + return self + + def update_host(self, host, options, use_regex=False): + for index, host_entry in enumerate(self.config_data): + if host_entry.get("host") == host or \ + (use_regex and re.match(host, host_entry.get("host"))): + + if 'deleted_fields' in options: + deleted_fields = options.pop("deleted_fields") + for deleted_field in deleted_fields: + del self.config_data[index]["options"][deleted_field] + + self.config_data[index]["options"].update(options) + + return self + + def search_host(self, search_string): + results = [] + for host_entry in self.config_data: + if host_entry.get("type") != 'entry': + continue + if host_entry.get("host") == "*": + continue + + searchable_information = host_entry.get("host") + for key, value in host_entry.get("options").items(): + if isinstance(value, list): + value = " ".join(value) + if isinstance(value, int): + value = str(value) + + searchable_information += " " + value + + if search_string in searchable_information: + results.append(host_entry) + + return results + + def delete_host(self, host): + found = 0 + for index, host_entry in enumerate(self.config_data): + if host_entry.get("host") == host: + del self.config_data[index] + found += 1 + + if found == 0: + raise ValueError('No host found') + return self + + def delete_all_hosts(self): + self.config_data = [] + self.write_to_ssh_config() + + return self + + def dump(self): + if len(self.config_data) < 1: + return + + file_content = "" + self.config_data = sorted(self.config_data, key=itemgetter("order")) + + for host_item in self.config_data: + if host_item.get("type") in ['comment', 'empty_line']: + file_content += host_item.get("value") + "\n" + continue + host_item_content = "Host {0}\n".format(host_item.get("host")) + for key, value in host_item.get("options").items(): + if isinstance(value, list): + sub_content = "" + for value_ in value: + sub_content += " {0} {1}\n".format( + key, value_ + ) + host_item_content += sub_content + else: + host_item_content += " {0} {1}\n".format( + key, value + ) + file_content += host_item_content + + return file_content + + def write_to_ssh_config(self): + with open(self.ssh_config_file, 'w+') as f: + data = self.dump() + if data: + f.write(data) + return self + + def get_last_index(self): + last_index = 0 + indexes = [] + for item in self.config_data: + if item.get("order"): + indexes.append(item.get("order")) + if len(indexes) > 0: + last_index = max(indexes) + + return last_index diff --git a/plugins/modules/ssh_config.py b/plugins/modules/ssh_config.py index c69f95d22b..676cc84a93 100644 --- a/plugins/modules/ssh_config.py +++ b/plugins/modules/ssh_config.py @@ -8,6 +8,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function + __metaclass__ = type DOCUMENTATION = r''' @@ -101,7 +102,7 @@ options: type: str version_added: 6.1.0 requirements: -- StormSSH +- paramiko ''' EXAMPLES = r''' @@ -160,26 +161,20 @@ hosts_change_diff: ''' import os -import traceback from copy import deepcopy -STORM_IMP_ERR = None -try: - from storm.parsers.ssh_config_parser import ConfigParser - HAS_STORM = True -except ImportError: - HAS_STORM = False - STORM_IMP_ERR = traceback.format_exc() - from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.common.text.converters import to_native +from ansible_collections.community.general.plugins.module_utils._stormssh import ConfigParser, HAS_PARAMIKO, PARAMIKO_IMPORT_ERROR from ansible_collections.community.general.plugins.module_utils.ssh import determine_config_file -class SSHConfig(): +class SSHConfig(object): def __init__(self, module): self.module = module + if not HAS_PARAMIKO: + module.fail_json(msg=missing_required_lib('PARAMIKO'), exception=PARAMIKO_IMPORT_ERROR) self.params = module.params self.user = self.params.get('user') self.group = self.params.get('group') or self.user @@ -265,7 +260,8 @@ class SSHConfig(): try: self.config.write_to_ssh_config() except PermissionError as perm_exec: - self.module.fail_json(msg="Failed to write to %s due to permission issue: %s" % (self.config_file, to_native(perm_exec))) + self.module.fail_json( + msg="Failed to write to %s due to permission issue: %s" % (self.config_file, to_native(perm_exec))) # Make sure we set the permission perm_mode = '0600' if self.config_file == '/etc/ssh/ssh_config': @@ -327,10 +323,6 @@ def main(): ], ) - if not HAS_STORM: - module.fail_json(changed=False, msg=missing_required_lib("stormssh"), - exception=STORM_IMP_ERR) - ssh_config_obj = SSHConfig(module) ssh_config_obj.ensure_state() diff --git a/tests/integration/targets/ssh_config/tasks/main.yml b/tests/integration/targets/ssh_config/tasks/main.yml index d594835bf1..c1bcbc372e 100644 --- a/tests/integration/targets/ssh_config/tasks/main.yml +++ b/tests/integration/targets/ssh_config/tasks/main.yml @@ -6,7 +6,6 @@ - name: Install required libs pip: name: - - stormssh - 'paramiko<3.0.0' state: present extra_args: "-c {{ remote_constraints }}"