1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00
community.general/plugins/modules/ini_file.py
Todd Lewis 4363f8764b
ini_file - support optional spaces around section names (#8075)
* 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>
2024-03-24 18:02:13 +01:00

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