mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
4363f8764b
* ini_file - support optional spaces between section names and their surrounding brackets Some ini files have spaces between some of their section names and the brackets that enclose them. This is documented in the 'openssl.cnf(5)' man page. In order to manage files such as /etc/ssl/openssl.cnf with ini_file before now, one would have to include spaces in the section name like this: section: ' crypto_policy ' option: Options value: UnsafeLegacyRenegotiation This change implements matching section headers with such optional spaces. Existing tasks using the workaround above will continue to work, even in cases where spaces in section headers are subsequently removed. * readability improvement in the test content expressions --------- Co-authored-by: Todd Lewis <todd_lewis@unc.edu>
544 lines
20 KiB
Python
544 lines
20 KiB
Python
#!/usr/bin/python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
# Copyright (c) 2012, Jan-Piet Mens <jpmens () gmail.com>
|
|
# Copyright (c) 2015, Ales Nosek <anosek.nosek () gmail.com>
|
|
# Copyright (c) 2017, Ansible Project
|
|
# Copyright (c) 2023, Ansible Project
|
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
from __future__ import absolute_import, division, print_function
|
|
__metaclass__ = type
|
|
|
|
|
|
DOCUMENTATION = r'''
|
|
---
|
|
module: ini_file
|
|
short_description: Tweak settings in INI files
|
|
extends_documentation_fragment:
|
|
- files
|
|
- community.general.attributes
|
|
description:
|
|
- Manage (add, remove, change) individual settings in an INI-style file without having
|
|
to manage the file as a whole with, say, M(ansible.builtin.template) or M(ansible.builtin.assemble).
|
|
- Adds missing sections if they don't exist.
|
|
- This module adds missing ending newlines to files to keep in line with the POSIX standard, even when
|
|
no other modifications need to be applied.
|
|
attributes:
|
|
check_mode:
|
|
support: full
|
|
diff_mode:
|
|
support: full
|
|
options:
|
|
path:
|
|
description:
|
|
- Path to the INI-style file; this file is created if required.
|
|
type: path
|
|
required: true
|
|
aliases: [ dest ]
|
|
section:
|
|
description:
|
|
- Section name in INI file. This is added if O(state=present) automatically when
|
|
a single value is being set.
|
|
- If being omitted, the O(option) will be placed before the first O(section).
|
|
- Omitting O(section) is also required if the config format does not support sections.
|
|
type: str
|
|
option:
|
|
description:
|
|
- If set (required for changing a O(value)), this is the name of the option.
|
|
- May be omitted if adding/removing a whole O(section).
|
|
type: str
|
|
value:
|
|
description:
|
|
- The string value to be associated with an O(option).
|
|
- May be omitted when removing an O(option).
|
|
- Mutually exclusive with O(values).
|
|
- O(value=v) is equivalent to O(values=[v]).
|
|
type: str
|
|
values:
|
|
description:
|
|
- The string value to be associated with an O(option).
|
|
- May be omitted when removing an O(option).
|
|
- Mutually exclusive with O(value).
|
|
- O(value=v) is equivalent to O(values=[v]).
|
|
type: list
|
|
elements: str
|
|
version_added: 3.6.0
|
|
backup:
|
|
description:
|
|
- Create a backup file including the timestamp information so you can get
|
|
the original file back if you somehow clobbered it incorrectly.
|
|
type: bool
|
|
default: false
|
|
state:
|
|
description:
|
|
- If set to V(absent) and O(exclusive) set to V(true) all matching O(option) lines are removed.
|
|
- If set to V(absent) and O(exclusive) set to V(false) the specified O(option=value) lines are removed,
|
|
but the other O(option)s with the same name are not touched.
|
|
- If set to V(present) and O(exclusive) set to V(false) the specified O(option=values) lines are added,
|
|
but the other O(option)s with the same name are not touched.
|
|
- If set to V(present) and O(exclusive) set to V(true) all given O(option=values) lines will be
|
|
added and the other O(option)s with the same name are removed.
|
|
type: str
|
|
choices: [ absent, present ]
|
|
default: present
|
|
exclusive:
|
|
description:
|
|
- If set to V(true) (default), all matching O(option) lines are removed when O(state=absent),
|
|
or replaced when O(state=present).
|
|
- If set to V(false), only the specified O(value)/O(values) are added when O(state=present),
|
|
or removed when O(state=absent), and existing ones are not modified.
|
|
type: bool
|
|
default: true
|
|
version_added: 3.6.0
|
|
no_extra_spaces:
|
|
description:
|
|
- Do not insert spaces before and after '=' symbol.
|
|
type: bool
|
|
default: false
|
|
ignore_spaces:
|
|
description:
|
|
- Do not change a line if doing so would only add or remove spaces before or after the V(=) symbol.
|
|
type: bool
|
|
default: false
|
|
version_added: 7.5.0
|
|
create:
|
|
description:
|
|
- If set to V(false), the module will fail if the file does not already exist.
|
|
- By default it will create the file if it is missing.
|
|
type: bool
|
|
default: true
|
|
allow_no_value:
|
|
description:
|
|
- Allow option without value and without '=' symbol.
|
|
type: bool
|
|
default: false
|
|
modify_inactive_option:
|
|
description:
|
|
- By default the module replaces a commented line that matches the given option.
|
|
- Set this option to V(false) to avoid this. This is useful when you want to keep commented example
|
|
C(key=value) pairs for documentation purposes.
|
|
type: bool
|
|
default: true
|
|
version_added: 8.0.0
|
|
follow:
|
|
description:
|
|
- This flag indicates that filesystem links, if they exist, should be followed.
|
|
- O(follow=true) can modify O(path) when combined with parameters such as O(mode).
|
|
type: bool
|
|
default: false
|
|
version_added: 7.1.0
|
|
notes:
|
|
- While it is possible to add an O(option) without specifying a O(value), this makes no sense.
|
|
- As of community.general 3.2.0, UTF-8 BOM markers are discarded when reading files.
|
|
author:
|
|
- Jan-Piet Mens (@jpmens)
|
|
- Ales Nosek (@noseka1)
|
|
'''
|
|
|
|
EXAMPLES = r'''
|
|
- name: Ensure "fav=lemonade is in section "[drinks]" in specified file
|
|
community.general.ini_file:
|
|
path: /etc/conf
|
|
section: drinks
|
|
option: fav
|
|
value: lemonade
|
|
mode: '0600'
|
|
backup: true
|
|
|
|
- name: Ensure "temperature=cold is in section "[drinks]" in specified file
|
|
community.general.ini_file:
|
|
path: /etc/anotherconf
|
|
section: drinks
|
|
option: temperature
|
|
value: cold
|
|
backup: true
|
|
|
|
- name: Add "beverage=lemon juice" is in section "[drinks]" in specified file
|
|
community.general.ini_file:
|
|
path: /etc/conf
|
|
section: drinks
|
|
option: beverage
|
|
value: lemon juice
|
|
mode: '0600'
|
|
state: present
|
|
exclusive: false
|
|
|
|
- name: Ensure multiple values "beverage=coke" and "beverage=pepsi" are in section "[drinks]" in specified file
|
|
community.general.ini_file:
|
|
path: /etc/conf
|
|
section: drinks
|
|
option: beverage
|
|
values:
|
|
- coke
|
|
- pepsi
|
|
mode: '0600'
|
|
state: present
|
|
|
|
- name: Add "beverage=lemon juice" outside a section in specified file
|
|
community.general.ini_file:
|
|
path: /etc/conf
|
|
option: beverage
|
|
value: lemon juice
|
|
state: present
|
|
'''
|
|
|
|
import io
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import traceback
|
|
|
|
from ansible.module_utils.basic import AnsibleModule
|
|
from ansible.module_utils.common.text.converters import to_bytes, to_text
|
|
|
|
|
|
def match_opt(option, line):
|
|
option = re.escape(option)
|
|
return re.match('([#;]?)( |\t)*(%s)( |\t)*(=|$)( |\t)*(.*)' % option, line)
|
|
|
|
|
|
def match_active_opt(option, line):
|
|
option = re.escape(option)
|
|
return re.match('()( |\t)*(%s)( |\t)*(=|$)( |\t)*(.*)' % option, line)
|
|
|
|
|
|
def update_section_line(option, changed, section_lines, index, changed_lines, ignore_spaces, newline, msg):
|
|
option_changed = None
|
|
if ignore_spaces:
|
|
old_match = match_opt(option, section_lines[index])
|
|
if not old_match.group(1):
|
|
new_match = match_opt(option, newline)
|
|
option_changed = old_match.group(7) != new_match.group(7)
|
|
if option_changed is None:
|
|
option_changed = section_lines[index] != newline
|
|
if option_changed:
|
|
section_lines[index] = newline
|
|
changed = changed or option_changed
|
|
if option_changed:
|
|
msg = 'option changed'
|
|
changed_lines[index] = 1
|
|
return (changed, msg)
|
|
|
|
|
|
def do_ini(module, filename, section=None, option=None, values=None,
|
|
state='present', exclusive=True, backup=False, no_extra_spaces=False,
|
|
ignore_spaces=False, create=True, allow_no_value=False, modify_inactive_option=True, follow=False):
|
|
|
|
if section is not None:
|
|
section = to_text(section)
|
|
if option is not None:
|
|
option = to_text(option)
|
|
|
|
# deduplicate entries in values
|
|
values_unique = []
|
|
[values_unique.append(to_text(value)) for value in values if value not in values_unique and value is not None]
|
|
values = values_unique
|
|
|
|
diff = dict(
|
|
before='',
|
|
after='',
|
|
before_header='%s (content)' % filename,
|
|
after_header='%s (content)' % filename,
|
|
)
|
|
|
|
if follow and os.path.islink(filename):
|
|
target_filename = os.path.realpath(filename)
|
|
else:
|
|
target_filename = filename
|
|
|
|
if not os.path.exists(target_filename):
|
|
if not create:
|
|
module.fail_json(rc=257, msg='Destination %s does not exist!' % target_filename)
|
|
destpath = os.path.dirname(target_filename)
|
|
if not os.path.exists(destpath) and not module.check_mode:
|
|
os.makedirs(destpath)
|
|
ini_lines = []
|
|
else:
|
|
with io.open(target_filename, 'r', encoding="utf-8-sig") as ini_file:
|
|
ini_lines = [to_text(line) for line in ini_file.readlines()]
|
|
|
|
if module._diff:
|
|
diff['before'] = u''.join(ini_lines)
|
|
|
|
changed = False
|
|
|
|
# ini file could be empty
|
|
if not ini_lines:
|
|
ini_lines.append(u'\n')
|
|
|
|
# last line of file may not contain a trailing newline
|
|
if ini_lines[-1] == u"" or ini_lines[-1][-1] != u'\n':
|
|
ini_lines[-1] += u'\n'
|
|
changed = True
|
|
|
|
# append fake section lines to simplify the logic
|
|
# At top:
|
|
# Fake random section to do not match any other in the file
|
|
# Using commit hash as fake section name
|
|
fake_section_name = u"ad01e11446efb704fcdbdb21f2c43757423d91c5"
|
|
|
|
# Insert it at the beginning
|
|
ini_lines.insert(0, u'[%s]' % fake_section_name)
|
|
|
|
# At bottom:
|
|
ini_lines.append(u'[')
|
|
|
|
# If no section is defined, fake section is used
|
|
if not section:
|
|
section = fake_section_name
|
|
|
|
within_section = not section
|
|
section_start = section_end = 0
|
|
msg = 'OK'
|
|
if no_extra_spaces:
|
|
assignment_format = u'%s=%s\n'
|
|
else:
|
|
assignment_format = u'%s = %s\n'
|
|
|
|
option_no_value_present = False
|
|
|
|
non_blank_non_comment_pattern = re.compile(to_text(r'^[ \t]*([#;].*)?$'))
|
|
|
|
before = after = []
|
|
section_lines = []
|
|
|
|
section_pattern = re.compile(to_text(r'^\[\s*%s\s*]' % re.escape(section.strip())))
|
|
|
|
for index, line in enumerate(ini_lines):
|
|
# find start and end of section
|
|
if section_pattern.match(line):
|
|
within_section = True
|
|
section_start = index
|
|
elif line.startswith(u'['):
|
|
if within_section:
|
|
section_end = index
|
|
break
|
|
|
|
before = ini_lines[0:section_start]
|
|
section_lines = ini_lines[section_start:section_end]
|
|
after = ini_lines[section_end:len(ini_lines)]
|
|
|
|
# Keep track of changed section_lines
|
|
changed_lines = [0] * len(section_lines)
|
|
|
|
# Determine whether to consider using commented out/inactive options or only active ones
|
|
if modify_inactive_option:
|
|
match_function = match_opt
|
|
else:
|
|
match_function = match_active_opt
|
|
|
|
# handling multiple instances of option=value when state is 'present' with/without exclusive is a bit complex
|
|
#
|
|
# 1. edit all lines where we have a option=value pair with a matching value in values[]
|
|
# 2. edit all the remaining lines where we have a matching option
|
|
# 3. delete remaining lines where we have a matching option
|
|
# 4. insert missing option line(s) at the end of the section
|
|
|
|
if state == 'present' and option:
|
|
for index, line in enumerate(section_lines):
|
|
if match_function(option, line):
|
|
match = match_function(option, line)
|
|
if values and match.group(7) in values:
|
|
matched_value = match.group(7)
|
|
if not matched_value and allow_no_value:
|
|
# replace existing option with no value line(s)
|
|
newline = u'%s\n' % option
|
|
option_no_value_present = True
|
|
else:
|
|
# replace existing option=value line(s)
|
|
newline = assignment_format % (option, matched_value)
|
|
(changed, msg) = update_section_line(option, changed, section_lines, index, changed_lines, ignore_spaces, newline, msg)
|
|
values.remove(matched_value)
|
|
elif not values and allow_no_value:
|
|
# replace existing option with no value line(s)
|
|
newline = u'%s\n' % option
|
|
(changed, msg) = update_section_line(option, changed, section_lines, index, changed_lines, ignore_spaces, newline, msg)
|
|
option_no_value_present = True
|
|
break
|
|
|
|
if state == 'present' and exclusive and not allow_no_value:
|
|
# override option with no value to option with value if not allow_no_value
|
|
if len(values) > 0:
|
|
for index, line in enumerate(section_lines):
|
|
if not changed_lines[index] and match_function(option, line):
|
|
newline = assignment_format % (option, values.pop(0))
|
|
(changed, msg) = update_section_line(option, changed, section_lines, index, changed_lines, ignore_spaces, newline, msg)
|
|
if len(values) == 0:
|
|
break
|
|
# remove all remaining option occurrences from the rest of the section
|
|
for index in range(len(section_lines) - 1, 0, -1):
|
|
if not changed_lines[index] and match_function(option, section_lines[index]):
|
|
del section_lines[index]
|
|
del changed_lines[index]
|
|
changed = True
|
|
msg = 'option changed'
|
|
|
|
if state == 'present':
|
|
# insert missing option line(s) at the end of the section
|
|
for index in range(len(section_lines), 0, -1):
|
|
# search backwards for previous non-blank or non-comment line
|
|
if not non_blank_non_comment_pattern.match(section_lines[index - 1]):
|
|
if option and values:
|
|
# insert option line(s)
|
|
for element in values[::-1]:
|
|
# items are added backwards, so traverse the list backwards to not confuse the user
|
|
# otherwise some of their options might appear in reverse order for whatever fancy reason ¯\_(ツ)_/¯
|
|
if element is not None:
|
|
# insert option=value line
|
|
section_lines.insert(index, assignment_format % (option, element))
|
|
msg = 'option added'
|
|
changed = True
|
|
elif element is None and allow_no_value:
|
|
# insert option with no value line
|
|
section_lines.insert(index, u'%s\n' % option)
|
|
msg = 'option added'
|
|
changed = True
|
|
elif option and not values and allow_no_value and not option_no_value_present:
|
|
# insert option with no value line(s)
|
|
section_lines.insert(index, u'%s\n' % option)
|
|
msg = 'option added'
|
|
changed = True
|
|
break
|
|
|
|
if state == 'absent':
|
|
if option:
|
|
if exclusive:
|
|
# delete all option line(s) with given option and ignore value
|
|
new_section_lines = [line for line in section_lines if not (match_active_opt(option, line))]
|
|
if section_lines != new_section_lines:
|
|
changed = True
|
|
msg = 'option changed'
|
|
section_lines = new_section_lines
|
|
elif not exclusive and len(values) > 0:
|
|
# delete specified option=value line(s)
|
|
new_section_lines = [i for i in section_lines if not (match_active_opt(option, i) and match_active_opt(option, i).group(7) in values)]
|
|
if section_lines != new_section_lines:
|
|
changed = True
|
|
msg = 'option changed'
|
|
section_lines = new_section_lines
|
|
else:
|
|
# drop the entire section
|
|
if section_lines:
|
|
section_lines = []
|
|
msg = 'section removed'
|
|
changed = True
|
|
|
|
# reassemble the ini_lines after manipulation
|
|
ini_lines = before + section_lines + after
|
|
|
|
# remove the fake section line
|
|
del ini_lines[0]
|
|
del ini_lines[-1:]
|
|
|
|
if not within_section and state == 'present':
|
|
ini_lines.append(u'[%s]\n' % section)
|
|
msg = 'section and option added'
|
|
if option and values:
|
|
for value in values:
|
|
ini_lines.append(assignment_format % (option, value))
|
|
elif option and not values and allow_no_value:
|
|
ini_lines.append(u'%s\n' % option)
|
|
else:
|
|
msg = 'only section added'
|
|
changed = True
|
|
|
|
if module._diff:
|
|
diff['after'] = u''.join(ini_lines)
|
|
|
|
backup_file = None
|
|
if changed and not module.check_mode:
|
|
if backup:
|
|
backup_file = module.backup_local(target_filename)
|
|
|
|
encoded_ini_lines = [to_bytes(line) for line in ini_lines]
|
|
try:
|
|
tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir)
|
|
f = os.fdopen(tmpfd, 'wb')
|
|
f.writelines(encoded_ini_lines)
|
|
f.close()
|
|
except IOError:
|
|
module.fail_json(msg="Unable to create temporary file %s", traceback=traceback.format_exc())
|
|
|
|
try:
|
|
module.atomic_move(tmpfile, target_filename)
|
|
except IOError:
|
|
module.ansible.fail_json(msg='Unable to move temporary \
|
|
file %s to %s, IOError' % (tmpfile, target_filename), traceback=traceback.format_exc())
|
|
|
|
return (changed, backup_file, diff, msg)
|
|
|
|
|
|
def main():
|
|
|
|
module = AnsibleModule(
|
|
argument_spec=dict(
|
|
path=dict(type='path', required=True, aliases=['dest']),
|
|
section=dict(type='str'),
|
|
option=dict(type='str'),
|
|
value=dict(type='str'),
|
|
values=dict(type='list', elements='str'),
|
|
backup=dict(type='bool', default=False),
|
|
state=dict(type='str', default='present', choices=['absent', 'present']),
|
|
exclusive=dict(type='bool', default=True),
|
|
no_extra_spaces=dict(type='bool', default=False),
|
|
ignore_spaces=dict(type='bool', default=False),
|
|
allow_no_value=dict(type='bool', default=False),
|
|
modify_inactive_option=dict(type='bool', default=True),
|
|
create=dict(type='bool', default=True),
|
|
follow=dict(type='bool', default=False)
|
|
),
|
|
mutually_exclusive=[
|
|
['value', 'values']
|
|
],
|
|
add_file_common_args=True,
|
|
supports_check_mode=True,
|
|
)
|
|
|
|
path = module.params['path']
|
|
section = module.params['section']
|
|
option = module.params['option']
|
|
value = module.params['value']
|
|
values = module.params['values']
|
|
state = module.params['state']
|
|
exclusive = module.params['exclusive']
|
|
backup = module.params['backup']
|
|
no_extra_spaces = module.params['no_extra_spaces']
|
|
ignore_spaces = module.params['ignore_spaces']
|
|
allow_no_value = module.params['allow_no_value']
|
|
modify_inactive_option = module.params['modify_inactive_option']
|
|
create = module.params['create']
|
|
follow = module.params['follow']
|
|
|
|
if state == 'present' and not allow_no_value and value is None and not values:
|
|
module.fail_json(msg="Parameter 'value(s)' must be defined if state=present and allow_no_value=False.")
|
|
|
|
if value is not None:
|
|
values = [value]
|
|
elif values is None:
|
|
values = []
|
|
|
|
(changed, backup_file, diff, msg) = do_ini(
|
|
module, path, section, option, values, state, exclusive, backup,
|
|
no_extra_spaces, ignore_spaces, create, allow_no_value, modify_inactive_option, follow)
|
|
|
|
if not module.check_mode and os.path.exists(path):
|
|
file_args = module.load_file_common_arguments(module.params)
|
|
changed = module.set_fs_attributes_if_different(file_args, changed)
|
|
|
|
results = dict(
|
|
changed=changed,
|
|
diff=diff,
|
|
msg=msg,
|
|
path=path,
|
|
)
|
|
if backup_file is not None:
|
|
results['backup_file'] = backup_file
|
|
|
|
# Mission complete
|
|
module.exit_json(**results)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|