mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
ssh_config: new module (#1640)
* [WIP] ssh_config: new module Fixes: #1562 Signed-off-by: Abhijeet Kasurde <akasurde@redhat.com> * Update tests/integration/targets/ssh_config/aliases Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/system/ssh_config.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/system/ssh_config.py Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
parent
f3e640d5a0
commit
5a52b573fe
9 changed files with 507 additions and 0 deletions
1
plugins/modules/ssh_config.py
Symbolic link
1
plugins/modules/ssh_config.py
Symbolic link
|
@ -0,0 +1 @@
|
|||
./system/ssh_config.py
|
314
plugins/modules/system/ssh_config.py
Normal file
314
plugins/modules/system/ssh_config.py
Normal file
|
@ -0,0 +1,314 @@
|
|||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2015, Björn Andersson
|
||||
# Copyright: (c) 2021, Ansible Project
|
||||
# Copyright: (c) 2021, Abhijeet Kasurde <akasurde@redhat.com>
|
||||
# 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 = r'''
|
||||
---
|
||||
module: ssh_config
|
||||
short_description: Manage SSH config for user
|
||||
version_added: '2.0.0'
|
||||
description:
|
||||
- Configures SSH hosts with special C(IdentityFile)s and hostnames.
|
||||
author:
|
||||
- Björn Andersson (@gaqzi)
|
||||
- Abhijeet Kasurde (@Akasurde)
|
||||
options:
|
||||
state:
|
||||
description:
|
||||
- Whether a host entry should exist or not.
|
||||
default: present
|
||||
choices: [ 'present', 'absent' ]
|
||||
type: str
|
||||
user:
|
||||
description:
|
||||
- Which user account this configuration file belongs to.
|
||||
- If none given and I(ssh_config_file) is not specified, C(/etc/ssh/ssh_config) is used.
|
||||
- If a user is given, C(~/.ssh/config) is used.
|
||||
- Mutually exclusive with I(ssh_config_file).
|
||||
type: str
|
||||
group:
|
||||
description:
|
||||
- Which group this configuration file belongs to.
|
||||
- If none given, I(user) is used.
|
||||
type: str
|
||||
host:
|
||||
description:
|
||||
- The endpoint this configuration is valid for.
|
||||
- Can be an actual address on the internet or an alias that will
|
||||
connect to the value of I(hostname).
|
||||
required: true
|
||||
type: str
|
||||
hostname:
|
||||
description:
|
||||
- The actual host to connect to when connecting to the host defined.
|
||||
type: str
|
||||
port:
|
||||
description:
|
||||
- The actual port to connect to when connecting to the host defined.
|
||||
type: str
|
||||
remote_user:
|
||||
description:
|
||||
- Specifies the user to log in as.
|
||||
type: str
|
||||
identity_file:
|
||||
description:
|
||||
- The path to an identity file (SSH private key) that will be used
|
||||
when connecting to this host.
|
||||
- File need to exist and have mode C(0600) to be valid.
|
||||
type: path
|
||||
user_known_hosts_file:
|
||||
description:
|
||||
- Sets the user known hosts file option.
|
||||
type: str
|
||||
strict_host_key_checking:
|
||||
description:
|
||||
- Whether to strictly check the host key when doing connections to the remote host.
|
||||
choices: [ 'yes', 'no', 'ask' ]
|
||||
type: str
|
||||
proxycommand:
|
||||
description:
|
||||
- Sets the C(ProxyCommand) option.
|
||||
type: str
|
||||
ssh_config_file:
|
||||
description:
|
||||
- SSH config file.
|
||||
- If I(user) and this option are not specified, C(/etc/ssh/ssh_config) is used.
|
||||
- Mutually exclusive with I(user).
|
||||
type: path
|
||||
requirements:
|
||||
- StormSSH
|
||||
notes:
|
||||
- Supports check mode.
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Add a host in the configuration
|
||||
community.general.ssh_config:
|
||||
user: akasurde
|
||||
host: "example.com"
|
||||
hostname: "github.com"
|
||||
identity_file: "/home/akasurde/.ssh/id_rsa"
|
||||
port: '2223'
|
||||
state: present
|
||||
|
||||
- name: Delete a host from the configuration
|
||||
community.general.ssh_config:
|
||||
ssh_config_file: "{{ ssh_config_test }}"
|
||||
host: "example.com"
|
||||
state: absent
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
hosts_added:
|
||||
description: A list of host added.
|
||||
returned: success
|
||||
type: list
|
||||
sample: ["example.com"]
|
||||
hosts_removed:
|
||||
description: A list of host removed.
|
||||
returned: success
|
||||
type: list
|
||||
sample: ["example.com"]
|
||||
hosts_changed:
|
||||
description: A list of host changed.
|
||||
returned: success
|
||||
type: list
|
||||
sample: ["example.com"]
|
||||
hosts_change_diff:
|
||||
description: A list of host diff changes.
|
||||
returned: on change
|
||||
type: list
|
||||
sample: [
|
||||
{
|
||||
"example.com": {
|
||||
"new": {
|
||||
"hostname": "github.com",
|
||||
"identityfile": ["/tmp/test_ssh_config/fake_id_rsa"],
|
||||
"port": "2224"
|
||||
},
|
||||
"old": {
|
||||
"hostname": "github.com",
|
||||
"identityfile": ["/tmp/test_ssh_config/fake_id_rsa"],
|
||||
"port": "2224"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
'''
|
||||
|
||||
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._text import to_native
|
||||
|
||||
|
||||
class SSHConfig():
|
||||
def __init__(self, module):
|
||||
self.module = module
|
||||
self.params = module.params
|
||||
self.user = self.params.get('user')
|
||||
self.group = self.params.get('group') or self.user
|
||||
self.host = self.params.get('host')
|
||||
self.config_file = self.params.get('ssh_config_file')
|
||||
self.identity_file = self.params['identity_file']
|
||||
self.check_ssh_config_path()
|
||||
try:
|
||||
self.config = ConfigParser(self.config_file)
|
||||
except FileNotFoundError:
|
||||
self.module.fail_json(msg="Failed to find %s" % self.config_file)
|
||||
self.config.load()
|
||||
|
||||
def check_ssh_config_path(self):
|
||||
if self.user:
|
||||
self.config_file = os.path.join(os.path.expanduser('~%s' % self.user), '.ssh', 'config')
|
||||
elif self.config_file is None:
|
||||
self.config_file = '/etc/ssh/ssh_config'
|
||||
|
||||
# See if the identity file exists or not, relative to the config file
|
||||
if os.path.exists(self.config_file) and self.identity_file is not None:
|
||||
dirname = os.path.dirname(self.config_file)
|
||||
self.identity_file = os.path.join(dirname, self.identity_file)
|
||||
|
||||
if not os.path.exists(self.identity_file):
|
||||
self.module.fail_json(msg='IdentityFile %s does not exist' % self.params['identity_file'])
|
||||
|
||||
def ensure_state(self):
|
||||
hosts_result = self.config.search_host(self.host)
|
||||
state = self.params['state']
|
||||
args = dict(
|
||||
hostname=self.params.get('hostname'),
|
||||
port=self.params.get('port'),
|
||||
identity_file=self.params.get('identity_file'),
|
||||
user=self.params.get('remote_user'),
|
||||
strict_host_key_checking=self.params.get('strict_host_key_checking'),
|
||||
user_known_hosts_file=self.params.get('user_known_hosts_file'),
|
||||
proxycommand=self.params.get('proxycommand'),
|
||||
)
|
||||
|
||||
config_changed = False
|
||||
hosts_changed = []
|
||||
hosts_change_diff = []
|
||||
hosts_removed = []
|
||||
hosts_added = []
|
||||
|
||||
if hosts_result:
|
||||
for host in hosts_result:
|
||||
if state == 'absent':
|
||||
# Delete host from the configuration
|
||||
config_changed = True
|
||||
hosts_removed.append(host['host'])
|
||||
self.config.delete_host(host['host'])
|
||||
else:
|
||||
# Update host in the configuration
|
||||
changed, options = self.change_host(host['options'], **args)
|
||||
|
||||
if changed:
|
||||
config_changed = True
|
||||
self.config.update_host(host['host'], options)
|
||||
hosts_changed.append(host['host'])
|
||||
hosts_change_diff.append({
|
||||
host['host']: {
|
||||
'old': host['options'],
|
||||
'new': options,
|
||||
}
|
||||
})
|
||||
elif state == 'present':
|
||||
changed, options = self.change_host(dict(), **args)
|
||||
|
||||
if changed:
|
||||
config_changed = True
|
||||
hosts_added.append(self.host)
|
||||
self.config.add_host(self.host, options)
|
||||
|
||||
if config_changed and not self.module.check_mode:
|
||||
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)))
|
||||
# Make sure we set the permission
|
||||
perm_mode = '0600'
|
||||
if self.config_file == '/etc/ssh/ssh_config':
|
||||
perm_mode = '0644'
|
||||
self.module.set_mode_if_different(self.config_file, perm_mode, False)
|
||||
# Make sure the file is owned by the right user and group
|
||||
self.module.set_owner_if_different(self.config_file, self.user, False)
|
||||
self.module.set_group_if_different(self.config_file, self.group, False)
|
||||
|
||||
self.module.exit_json(changed=config_changed,
|
||||
hosts_changed=hosts_changed,
|
||||
hosts_removed=hosts_removed,
|
||||
hosts_change_diff=hosts_change_diff,
|
||||
hosts_added=hosts_added)
|
||||
|
||||
@staticmethod
|
||||
def change_host(options, **kwargs):
|
||||
options = deepcopy(options)
|
||||
changed = False
|
||||
for k, v in kwargs.items():
|
||||
if '_' in k:
|
||||
k = k.replace('_', '')
|
||||
|
||||
if not v:
|
||||
if options.get(k):
|
||||
del options[k]
|
||||
changed = True
|
||||
elif options.get(k) != v and not (isinstance(options.get(k), list) and v in options.get(k)):
|
||||
options[k] = v
|
||||
changed = True
|
||||
|
||||
return changed, options
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
group=dict(default=None, type='str'),
|
||||
host=dict(type='str', required=True),
|
||||
hostname=dict(type='str'),
|
||||
identity_file=dict(type='path'),
|
||||
port=dict(type='str'),
|
||||
proxycommand=dict(type='str', default=None),
|
||||
remote_user=dict(type='str'),
|
||||
ssh_config_file=dict(default=None, type='path'),
|
||||
state=dict(type='str', default='present', choices=['present', 'absent']),
|
||||
strict_host_key_checking=dict(
|
||||
default=None,
|
||||
choices=['yes', 'no', 'ask']
|
||||
),
|
||||
user=dict(default=None, type='str'),
|
||||
user_known_hosts_file=dict(type='str', default=None),
|
||||
),
|
||||
supports_check_mode=True,
|
||||
mutually_exclusive=[
|
||||
['user', 'ssh_config_file'],
|
||||
],
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
5
tests/integration/targets/ssh_config/aliases
Normal file
5
tests/integration/targets/ssh_config/aliases
Normal file
|
@ -0,0 +1,5 @@
|
|||
destructive
|
||||
shippable/posix/group2
|
||||
skip/python2.6 # stromssh only supports python3
|
||||
skip/python2.7 # stromssh only supports python3
|
||||
skip/freebsd # stromssh installation fails on freebsd
|
0
tests/integration/targets/ssh_config/files/fake_id_rsa
Normal file
0
tests/integration/targets/ssh_config/files/fake_id_rsa
Normal file
184
tests/integration/targets/ssh_config/tasks/main.yml
Normal file
184
tests/integration/targets/ssh_config/tasks/main.yml
Normal file
|
@ -0,0 +1,184 @@
|
|||
# Test code for ssh_config module
|
||||
# Copyright: (c) 2021, Abhijeet Kasurde (@Akasurde) <akasurde@redhat.com>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
- name: Install required libs
|
||||
pip:
|
||||
name: stormssh
|
||||
state: present
|
||||
|
||||
- set_fact:
|
||||
output_dir_test: '{{ output_dir }}/test_ssh_config'
|
||||
|
||||
- set_fact:
|
||||
ssh_config_test: '{{ output_dir_test }}/ssh_config_test'
|
||||
ssh_private_key: '{{ output_dir_test }}/fake_id_rsa'
|
||||
|
||||
- name: create a temporary directory
|
||||
file:
|
||||
path: "{{ output_dir_test }}"
|
||||
state: directory
|
||||
|
||||
- name: Copy sample config file
|
||||
copy:
|
||||
src: 'files/ssh_config_test'
|
||||
dest: '{{ ssh_config_test }}'
|
||||
|
||||
- name: Copy sample private key file
|
||||
copy:
|
||||
src: 'files/fake_id_rsa'
|
||||
dest: '{{ ssh_private_key }}'
|
||||
|
||||
- name: Fail for required argument
|
||||
community.general.ssh_config:
|
||||
ssh_config_file: "{{ ssh_config_test }}"
|
||||
ignore_errors: yes
|
||||
register: host_required
|
||||
|
||||
- name: Check if ssh_config fails for required parameter host
|
||||
assert:
|
||||
that:
|
||||
- not host_required.changed
|
||||
|
||||
- name: Add a host in check mode
|
||||
community.general.ssh_config:
|
||||
ssh_config_file: "{{ ssh_config_test }}"
|
||||
host: "example.com"
|
||||
hostname: github.com
|
||||
identity_file: '{{ ssh_private_key }}'
|
||||
port: '2223'
|
||||
state: present
|
||||
register: host_add
|
||||
check_mode: yes
|
||||
|
||||
- name: Check if changes are made in check mode
|
||||
assert:
|
||||
that:
|
||||
- host_add.changed
|
||||
- "'example.com' in host_add.hosts_added"
|
||||
- host_add.hosts_changed is defined
|
||||
- host_add.hosts_removed is defined
|
||||
|
||||
- name: Add a host
|
||||
community.general.ssh_config:
|
||||
ssh_config_file: "{{ ssh_config_test }}"
|
||||
host: "example.com"
|
||||
hostname: github.com
|
||||
identity_file: '{{ ssh_private_key }}'
|
||||
port: '2223'
|
||||
state: present
|
||||
register: host_add
|
||||
|
||||
- name: Check if changes are made
|
||||
assert:
|
||||
that:
|
||||
- host_add.changed
|
||||
- "'example.com' in host_add.hosts_added"
|
||||
- host_add.hosts_changed is defined
|
||||
- host_add.hosts_removed is defined
|
||||
|
||||
- name: Add same host again for idempotency
|
||||
community.general.ssh_config:
|
||||
ssh_config_file: "{{ ssh_config_test }}"
|
||||
host: "example.com"
|
||||
hostname: github.com
|
||||
identity_file: '{{ ssh_private_key }}'
|
||||
port: '2223'
|
||||
state: present
|
||||
register: host_add_again
|
||||
|
||||
- name: Check for idempotency
|
||||
assert:
|
||||
that:
|
||||
- not host_add_again.changed
|
||||
- host_add.hosts_changed is defined
|
||||
- host_add.hosts_removed is defined
|
||||
- host_add.hosts_added is defined
|
||||
|
||||
- name: Update host
|
||||
community.general.ssh_config:
|
||||
ssh_config_file: "{{ ssh_config_test }}"
|
||||
host: "example.com"
|
||||
hostname: github.com
|
||||
identity_file: '{{ ssh_private_key }}'
|
||||
port: '2224'
|
||||
state: present
|
||||
register: host_update
|
||||
|
||||
- name: Check for update operation
|
||||
assert:
|
||||
that:
|
||||
- host_update.changed
|
||||
- host_update.hosts_changed is defined
|
||||
- "'example.com' in host_update.hosts_changed"
|
||||
- host_update.hosts_removed is defined
|
||||
- host_update.hosts_added is defined
|
||||
- host_update.hosts_change_diff is defined
|
||||
|
||||
- name: Update host again
|
||||
community.general.ssh_config:
|
||||
ssh_config_file: "{{ ssh_config_test }}"
|
||||
host: "example.com"
|
||||
hostname: github.com
|
||||
identity_file: '{{ ssh_private_key }}'
|
||||
port: '2224'
|
||||
state: present
|
||||
register: host_update
|
||||
|
||||
- name: Check update operation for idempotency
|
||||
assert:
|
||||
that:
|
||||
- not host_update.changed
|
||||
- host_update.hosts_changed is defined
|
||||
- host_update.hosts_removed is defined
|
||||
- host_update.hosts_added is defined
|
||||
- host_update.hosts_change_diff is defined
|
||||
|
||||
- name: Delete a host
|
||||
community.general.ssh_config:
|
||||
ssh_config_file: "{{ ssh_config_test }}"
|
||||
host: "example.com"
|
||||
state: absent
|
||||
register: host_delete
|
||||
|
||||
- name: Check if changes are made
|
||||
assert:
|
||||
that:
|
||||
- host_delete.changed
|
||||
- "'example.com' in host_delete.hosts_removed"
|
||||
- host_delete.hosts_changed is defined
|
||||
- host_delete.hosts_added is defined
|
||||
|
||||
- name: Delete same host again for idempotency
|
||||
community.general.ssh_config:
|
||||
ssh_config_file: "{{ ssh_config_test }}"
|
||||
host: "example.com"
|
||||
hostname: github.com
|
||||
state: absent
|
||||
register: host_delete_again
|
||||
|
||||
- name: Check for idempotency
|
||||
assert:
|
||||
that:
|
||||
- not host_delete_again.changed
|
||||
- host_delete_again.hosts_changed is defined
|
||||
- host_delete_again.hosts_removed is defined
|
||||
- host_delete_again.hosts_added is defined
|
||||
|
||||
- name: Check if user and ssh_config_file are mutually exclusive
|
||||
community.general.ssh_config:
|
||||
ssh_config_file: "{{ ssh_config_test }}"
|
||||
user: root
|
||||
host: "example.com"
|
||||
hostname: github.com
|
||||
identity_file: '{{ ssh_private_key }}'
|
||||
port: '2223'
|
||||
state: present
|
||||
register: mut_ex
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Check mutual exclusive test - user and ssh_config_file
|
||||
assert:
|
||||
that:
|
||||
- not mut_ex.changed
|
||||
- "'parameters are mutually exclusive' in mut_ex.msg"
|
|
@ -371,6 +371,7 @@ plugins/modules/system/puppet.py validate-modules:undocumented-parameter
|
|||
plugins/modules/system/runit.py validate-modules:doc-default-does-not-match-spec
|
||||
plugins/modules/system/runit.py validate-modules:parameter-type-not-in-doc
|
||||
plugins/modules/system/runit.py validate-modules:undocumented-parameter
|
||||
plugins/modules/system/ssh_config.py use-argspec-type-path # Required since module uses other methods to specify path
|
||||
plugins/modules/system/timezone.py pylint:blacklisted-name
|
||||
plugins/modules/system/xfconf.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/system/xfconf.py validate-modules:return-syntax-error
|
||||
|
|
|
@ -371,6 +371,7 @@ plugins/modules/system/puppet.py validate-modules:undocumented-parameter
|
|||
plugins/modules/system/runit.py validate-modules:doc-default-does-not-match-spec
|
||||
plugins/modules/system/runit.py validate-modules:parameter-type-not-in-doc
|
||||
plugins/modules/system/runit.py validate-modules:undocumented-parameter
|
||||
plugins/modules/system/ssh_config.py use-argspec-type-path # Required since module uses other methods to specify path
|
||||
plugins/modules/system/timezone.py pylint:blacklisted-name
|
||||
plugins/modules/system/xfconf.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/system/xfconf.py validate-modules:return-syntax-error
|
||||
|
|
|
@ -318,6 +318,7 @@ plugins/modules/system/puppet.py validate-modules:undocumented-parameter
|
|||
plugins/modules/system/runit.py validate-modules:doc-default-does-not-match-spec
|
||||
plugins/modules/system/runit.py validate-modules:parameter-type-not-in-doc
|
||||
plugins/modules/system/runit.py validate-modules:undocumented-parameter
|
||||
plugins/modules/system/ssh_config.py use-argspec-type-path # Required since module uses other methods to specify path
|
||||
plugins/modules/system/timezone.py pylint:blacklisted-name
|
||||
plugins/modules/system/xfconf.py validate-modules:return-syntax-error
|
||||
plugins/modules/web_infrastructure/jenkins_plugin.py use-argspec-type-path
|
||||
|
|
Loading…
Reference in a new issue