Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Removes dependency on StormSSH #6117

Merged
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/6117-remove-stormssh-depend.yml
Original file line number Diff line number Diff line change
@@ -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).
258 changes: 258 additions & 0 deletions plugins/module_utils/_stormssh.py
Original file line number Diff line number Diff line change
@@ -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> <Emre Yilmaz>
# 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
24 changes: 8 additions & 16 deletions plugins/modules/ssh_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import absolute_import, division, print_function

__metaclass__ = type

DOCUMENTATION = r'''
Expand Down Expand Up @@ -101,7 +102,7 @@
type: str
version_added: 6.1.0
requirements:
- StormSSH
- paramiko
'''

EXAMPLES = r'''
Expand Down Expand Up @@ -160,24 +161,16 @@
'''

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:
def __init__(self, module):
self.module = module
self.params = module.params
Expand All @@ -187,6 +180,8 @@ def __init__(self, module):
self.config_file = self.params.get('ssh_config_file')
self.identity_file = self.params['identity_file']
self.check_ssh_config_path()
if not HAS_PARAMIKO:
module.fail_json(msg=missing_required_lib('PARAMIKO'), exception=PARAMIKO_IMPORT_ERROR)
try:
self.config = ConfigParser(self.config_file)
except FileNotFoundError:
Expand Down Expand Up @@ -265,7 +260,8 @@ def ensure_state(self):
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':
Expand Down Expand Up @@ -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()

Expand Down
1 change: 0 additions & 1 deletion tests/integration/targets/ssh_config/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
- name: Install required libs
pip:
name:
- stormssh
- 'paramiko<3.0.0'
state: present
extra_args: "-c {{ remote_constraints }}"
Expand Down