2020-03-09 10:11:07 +01:00
|
|
|
#!/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
|
|
|
|
# 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: ini_file
|
|
|
|
short_description: Tweak settings in INI files
|
|
|
|
extends_documentation_fragment: files
|
|
|
|
description:
|
|
|
|
- Manage (add, remove, change) individual settings in an INI-style file without having
|
2020-06-29 14:59:15 +02:00
|
|
|
to manage the file as a whole with, say, M(ansible.builtin.template) or M(ansible.builtin.assemble).
|
2020-03-09 10:11:07 +01:00
|
|
|
- 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.
|
|
|
|
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 I(dest).
|
|
|
|
type: path
|
|
|
|
required: true
|
|
|
|
aliases: [ dest ]
|
|
|
|
section:
|
|
|
|
description:
|
|
|
|
- Section name in INI file. This is added if C(state=present) automatically when
|
|
|
|
a single value is being set.
|
|
|
|
- If left empty or set to C(null), the I(option) will be placed before the first I(section).
|
|
|
|
- Using C(null) is also required if the config format does not support sections.
|
|
|
|
type: str
|
|
|
|
required: true
|
|
|
|
option:
|
|
|
|
description:
|
|
|
|
- If set (required for changing a I(value)), this is the name of the option.
|
|
|
|
- May be omitted if adding/removing a whole I(section).
|
|
|
|
type: str
|
|
|
|
value:
|
|
|
|
description:
|
|
|
|
- The string value to be associated with an I(option).
|
|
|
|
- May be omitted when removing an I(option).
|
|
|
|
type: str
|
|
|
|
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: no
|
|
|
|
state:
|
|
|
|
description:
|
|
|
|
- If set to C(absent) the option or section will be removed if present instead of created.
|
|
|
|
type: str
|
|
|
|
choices: [ absent, present ]
|
|
|
|
default: present
|
|
|
|
no_extra_spaces:
|
|
|
|
description:
|
|
|
|
- Do not insert spaces before and after '=' symbol.
|
|
|
|
type: bool
|
|
|
|
default: no
|
|
|
|
create:
|
|
|
|
description:
|
|
|
|
- If set to C(no), 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: yes
|
|
|
|
allow_no_value:
|
|
|
|
description:
|
|
|
|
- Allow option without value and without '=' symbol.
|
|
|
|
type: bool
|
|
|
|
default: no
|
|
|
|
notes:
|
|
|
|
- While it is possible to add an I(option) without specifying a I(value), this makes no sense.
|
|
|
|
- As of Ansible 2.3, the I(dest) option has been changed to I(path) as default, but I(dest) still works as well.
|
2021-05-26 07:06:23 +02:00
|
|
|
- As of community.general 3.2.0, UTF-8 BOM markers are discarded when reading files.
|
2020-03-09 10:11:07 +01:00
|
|
|
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
|
2020-07-13 21:50:31 +02:00
|
|
|
community.general.ini_file:
|
2020-03-09 10:11:07 +01:00
|
|
|
path: /etc/conf
|
|
|
|
section: drinks
|
|
|
|
option: fav
|
|
|
|
value: lemonade
|
|
|
|
mode: '0600'
|
|
|
|
backup: yes
|
|
|
|
|
|
|
|
- name: Ensure "temperature=cold is in section "[drinks]" in specified file
|
2020-07-13 21:50:31 +02:00
|
|
|
community.general.ini_file:
|
2020-03-09 10:11:07 +01:00
|
|
|
path: /etc/anotherconf
|
|
|
|
section: drinks
|
|
|
|
option: temperature
|
|
|
|
value: cold
|
|
|
|
backup: yes
|
|
|
|
'''
|
|
|
|
|
2021-05-22 22:20:37 +02:00
|
|
|
import io
|
2020-03-09 10:11:07 +01:00
|
|
|
import os
|
|
|
|
import re
|
|
|
|
import tempfile
|
|
|
|
import traceback
|
|
|
|
|
|
|
|
from ansible.module_utils.basic import AnsibleModule
|
2021-06-27 10:00:01 +02:00
|
|
|
from ansible.module_utils.common.text.converters import to_bytes, to_text
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
|
|
|
|
def match_opt(option, line):
|
|
|
|
option = re.escape(option)
|
2021-03-08 06:57:40 +01:00
|
|
|
return re.match('[#;]?( |\t)*%s( |\t)*(=|$)' % option, line)
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
|
|
|
|
def match_active_opt(option, line):
|
|
|
|
option = re.escape(option)
|
|
|
|
return re.match('( |\t)*%s( |\t)*(=|$)' % option, line)
|
|
|
|
|
|
|
|
|
|
|
|
def do_ini(module, filename, section=None, option=None, value=None,
|
|
|
|
state='present', backup=False, no_extra_spaces=False, create=True,
|
|
|
|
allow_no_value=False):
|
|
|
|
|
2021-06-27 10:00:01 +02:00
|
|
|
if section is not None:
|
|
|
|
section = to_text(section)
|
|
|
|
if option is not None:
|
|
|
|
option = to_text(option)
|
|
|
|
if value is not None:
|
|
|
|
value = to_text(value)
|
|
|
|
|
2020-03-09 10:11:07 +01:00
|
|
|
diff = dict(
|
|
|
|
before='',
|
|
|
|
after='',
|
|
|
|
before_header='%s (content)' % filename,
|
|
|
|
after_header='%s (content)' % filename,
|
|
|
|
)
|
|
|
|
|
|
|
|
if not os.path.exists(filename):
|
|
|
|
if not create:
|
|
|
|
module.fail_json(rc=257, msg='Destination %s does not exist !' % filename)
|
|
|
|
destpath = os.path.dirname(filename)
|
|
|
|
if not os.path.exists(destpath) and not module.check_mode:
|
|
|
|
os.makedirs(destpath)
|
|
|
|
ini_lines = []
|
|
|
|
else:
|
2021-05-22 22:20:37 +02:00
|
|
|
with io.open(filename, 'r', encoding="utf-8-sig") as ini_file:
|
2021-06-27 10:00:01 +02:00
|
|
|
ini_lines = [to_text(line) for line in ini_file.readlines()]
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
if module._diff:
|
2021-06-27 10:00:01 +02:00
|
|
|
diff['before'] = u''.join(ini_lines)
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
changed = False
|
|
|
|
|
|
|
|
# ini file could be empty
|
|
|
|
if not ini_lines:
|
2021-06-27 10:00:01 +02:00
|
|
|
ini_lines.append(u'\n')
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
# last line of file may not contain a trailing newline
|
2021-06-27 10:00:01 +02:00
|
|
|
if ini_lines[-1] == u"" or ini_lines[-1][-1] != u'\n':
|
|
|
|
ini_lines[-1] += u'\n'
|
2020-03-09 10:11:07 +01:00
|
|
|
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
|
2021-06-27 10:00:01 +02:00
|
|
|
fake_section_name = u"ad01e11446efb704fcdbdb21f2c43757423d91c5"
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
# Insert it at the beginning
|
2021-06-27 10:00:01 +02:00
|
|
|
ini_lines.insert(0, u'[%s]' % fake_section_name)
|
2020-03-09 10:11:07 +01:00
|
|
|
|
2020-12-28 10:47:01 +01:00
|
|
|
# At bottom:
|
2021-06-27 10:00:01 +02:00
|
|
|
ini_lines.append(u'[')
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
# If no section is defined, fake section is used
|
|
|
|
if not section:
|
|
|
|
section = fake_section_name
|
|
|
|
|
|
|
|
within_section = not section
|
|
|
|
section_start = 0
|
|
|
|
msg = 'OK'
|
|
|
|
if no_extra_spaces:
|
2021-06-27 10:00:01 +02:00
|
|
|
assignment_format = u'%s=%s\n'
|
2020-03-09 10:11:07 +01:00
|
|
|
else:
|
2021-06-27 10:00:01 +02:00
|
|
|
assignment_format = u'%s = %s\n'
|
|
|
|
|
|
|
|
non_blank_non_comment_pattern = re.compile(to_text(r'^[ \t]*([#;].*)?$'))
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
for index, line in enumerate(ini_lines):
|
2021-06-27 10:00:01 +02:00
|
|
|
if line.startswith(u'[%s]' % section):
|
2020-03-09 10:11:07 +01:00
|
|
|
within_section = True
|
|
|
|
section_start = index
|
2021-06-27 10:00:01 +02:00
|
|
|
elif line.startswith(u'['):
|
2020-03-09 10:11:07 +01:00
|
|
|
if within_section:
|
|
|
|
if state == 'present':
|
|
|
|
# insert missing option line at the end of the section
|
|
|
|
for i in range(index, 0, -1):
|
|
|
|
# search backwards for previous non-blank or non-comment line
|
2021-06-27 10:00:01 +02:00
|
|
|
if not non_blank_non_comment_pattern.match(ini_lines[i - 1]):
|
2021-08-08 18:34:34 +02:00
|
|
|
if option and value is not None:
|
2020-03-09 10:11:07 +01:00
|
|
|
ini_lines.insert(i, assignment_format % (option, value))
|
2020-12-28 10:47:01 +01:00
|
|
|
msg = 'option added'
|
|
|
|
changed = True
|
2021-08-08 18:34:34 +02:00
|
|
|
elif option and value is None and allow_no_value:
|
2020-12-28 10:47:01 +01:00
|
|
|
ini_lines.insert(i, '%s\n' % option)
|
|
|
|
msg = 'option added'
|
|
|
|
changed = True
|
2020-03-09 10:11:07 +01:00
|
|
|
break
|
|
|
|
elif state == 'absent' and not option:
|
|
|
|
# remove the entire section
|
|
|
|
del ini_lines[section_start:index]
|
|
|
|
msg = 'section removed'
|
|
|
|
changed = True
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
if within_section and option:
|
|
|
|
if state == 'present':
|
|
|
|
# change the existing option line
|
|
|
|
if match_opt(option, line):
|
2021-08-08 18:34:34 +02:00
|
|
|
if value is None and allow_no_value:
|
2021-06-27 10:00:01 +02:00
|
|
|
newline = u'%s\n' % option
|
2020-03-09 10:11:07 +01:00
|
|
|
else:
|
|
|
|
newline = assignment_format % (option, value)
|
|
|
|
option_changed = ini_lines[index] != newline
|
|
|
|
changed = changed or option_changed
|
|
|
|
if option_changed:
|
|
|
|
msg = 'option changed'
|
|
|
|
ini_lines[index] = newline
|
|
|
|
if option_changed:
|
|
|
|
# remove all possible option occurrences from the rest of the section
|
|
|
|
index = index + 1
|
|
|
|
while index < len(ini_lines):
|
|
|
|
line = ini_lines[index]
|
2021-06-27 10:00:01 +02:00
|
|
|
if line.startswith(u'['):
|
2020-03-09 10:11:07 +01:00
|
|
|
break
|
|
|
|
if match_active_opt(option, line):
|
|
|
|
del ini_lines[index]
|
|
|
|
else:
|
|
|
|
index = index + 1
|
|
|
|
break
|
|
|
|
elif state == 'absent':
|
|
|
|
# delete the existing line
|
|
|
|
if match_active_opt(option, line):
|
|
|
|
del ini_lines[index]
|
|
|
|
changed = True
|
|
|
|
msg = 'option changed'
|
|
|
|
break
|
|
|
|
|
|
|
|
# remove the fake section line
|
|
|
|
del ini_lines[0]
|
|
|
|
del ini_lines[-1:]
|
|
|
|
|
2020-12-28 10:47:01 +01:00
|
|
|
if not within_section and state == 'present':
|
2021-06-27 10:00:01 +02:00
|
|
|
ini_lines.append(u'[%s]\n' % section)
|
2020-12-28 10:47:01 +01:00
|
|
|
msg = 'section and option added'
|
2021-03-08 06:57:40 +01:00
|
|
|
if option and value is not None:
|
2020-12-28 10:47:01 +01:00
|
|
|
ini_lines.append(assignment_format % (option, value))
|
2021-03-08 06:57:40 +01:00
|
|
|
elif option and value is None and allow_no_value:
|
2021-06-27 10:00:01 +02:00
|
|
|
ini_lines.append(u'%s\n' % option)
|
2020-03-09 10:11:07 +01:00
|
|
|
else:
|
2020-12-28 10:47:01 +01:00
|
|
|
msg = 'only section added'
|
2020-03-09 10:11:07 +01:00
|
|
|
changed = True
|
|
|
|
|
|
|
|
if module._diff:
|
2021-06-27 10:00:01 +02:00
|
|
|
diff['after'] = u''.join(ini_lines)
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
backup_file = None
|
|
|
|
if changed and not module.check_mode:
|
|
|
|
if backup:
|
|
|
|
backup_file = module.backup_local(filename)
|
|
|
|
|
2021-06-27 10:00:01 +02:00
|
|
|
encoded_ini_lines = [to_bytes(line) for line in ini_lines]
|
2020-03-09 10:11:07 +01:00
|
|
|
try:
|
|
|
|
tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir)
|
2021-06-27 10:00:01 +02:00
|
|
|
f = os.fdopen(tmpfd, 'wb')
|
|
|
|
f.writelines(encoded_ini_lines)
|
2020-03-09 10:11:07 +01:00
|
|
|
f.close()
|
|
|
|
except IOError:
|
|
|
|
module.fail_json(msg="Unable to create temporary file %s", traceback=traceback.format_exc())
|
|
|
|
|
|
|
|
try:
|
|
|
|
module.atomic_move(tmpfile, filename)
|
|
|
|
except IOError:
|
|
|
|
module.ansible.fail_json(msg='Unable to move temporary \
|
|
|
|
file %s to %s, IOError' % (tmpfile, 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', required=True),
|
|
|
|
option=dict(type='str'),
|
|
|
|
value=dict(type='str'),
|
|
|
|
backup=dict(type='bool', default=False),
|
|
|
|
state=dict(type='str', default='present', choices=['absent', 'present']),
|
|
|
|
no_extra_spaces=dict(type='bool', default=False),
|
|
|
|
allow_no_value=dict(type='bool', default=False),
|
|
|
|
create=dict(type='bool', default=True)
|
|
|
|
),
|
|
|
|
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']
|
|
|
|
state = module.params['state']
|
|
|
|
backup = module.params['backup']
|
|
|
|
no_extra_spaces = module.params['no_extra_spaces']
|
|
|
|
allow_no_value = module.params['allow_no_value']
|
|
|
|
create = module.params['create']
|
|
|
|
|
2021-03-08 06:57:40 +01:00
|
|
|
if state == 'present' and not allow_no_value and value is None:
|
2021-08-08 18:34:34 +02:00
|
|
|
module.fail_json("Parameter 'value' must be defined if state=present and allow_no_value=False")
|
2020-12-28 10:47:01 +01:00
|
|
|
|
2020-03-09 10:11:07 +01:00
|
|
|
(changed, backup_file, diff, msg) = do_ini(module, path, section, option, value, state, backup, no_extra_spaces, create, allow_no_value)
|
|
|
|
|
|
|
|
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()
|