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
Jonathan Kamens 8a9b98273d
Add ignore_spaces option to ini_file to ignore spacing changes (#7273)
* Add `ignore_spaces` option to `ini_file` to ignore spacing changes

Add a new `ignore_spaces` option to the `ini_file` module which, if
true, prevents the module from changing a line in a file if the only
thing that would change by doing so is whitespace before or after the
`=`.

Also add test cases for this new functionality. There were previously
no tests for `ini_file` at all, and this doesn't attempt to fix that,
but it does add tests to ensure that the new behavior implemented here
as well as the old behavior in the affected code are correct.

Fixes #7202.

* Add changelog fragment

* pep8 / pylint

* remove unused import

* fix typo in comment in integration test file

* Add symlink tests to main.yml

It appears that #6546 added symlink tests but neglected to add them to
main.yml so they weren't being executed.

* ini_file symlink tests; create output files in correct location

* Add integration tests for ini_file ignore_spaces

* PR feedback
2023-09-17 13:22:22 +02:00

523 lines
19 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.
- Before Ansible 2.0, comments are discarded when the source file is read, and therefore will not show up in the destination file.
- Since Ansible 2.3, 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.
- Before Ansible 2.3 this option was only usable as O(dest).
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 left empty, being omitted, or being set to V(null), the O(option) will be placed before the first O(section).
- Using V(null) 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
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 Ansible 2.3, the O(dest) option has been changed to O(path) as default, but O(dest) still works as well.
- 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'''
# Before Ansible 2.3, option 'dest' was used instead of 'path'
- 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
'''
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, 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 = []
for index, line in enumerate(ini_lines):
# find start and end of section
if line.startswith(u'[%s]' % section):
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)
# 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_opt(option, line):
match = match_opt(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_opt(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_opt(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(6) 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),
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']
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, 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()