Skip to content

Commit

Permalink
[PR #6117/3862de3f backport][stable-6] Removes dependency on StormSSH (
Browse files Browse the repository at this point in the history
…#6179)

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

(cherry picked from commit 3862de3)

Co-authored-by: Peter Upton <peterupton99@gmail.com>
  • Loading branch information
patchback[bot] and peterupton authored Mar 12, 2023
1 parent 944bc78 commit 8e84b3e
Show file tree
Hide file tree
Showing 4 changed files with 268 additions and 17 deletions.
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,26 +161,20 @@
'''

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
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

0 comments on commit 8e84b3e

Please sign in to comment.