Repository: ansible-collections/community.general

Issue:
ini_file module can't create empty section
<!--- Verify first that your issue is not already reported on GitHub -->
<!--- Also test if the latest release and devel branch are affected too -->
<!--- Complete *all* sections as described, this form is processed automatically -->

##### SUMMARY
<!--- Explain the problem briefly below -->
Module ini_file can't create an empty section without any options. This is used in some configuration files.

##### ISSUE TYPE
- Bug Report

##### COMPONENT NAME
<!--- Write the short name of the module, plugin, task or feature below, use your best guess if unsure -->
ini_file

##### ANSIBLE VERSION
<!--- Paste verbatim output from "ansible --version" between quotes -->
```paste below
ansible 2.8.6
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/home/vzima/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  executable location = /usr/bin/ansible
  python version = 3.8.3rc1 (default, Apr 30 2020, 07:33:30) [GCC 9.3.0]
```

##### CONFIGURATION
<!--- Paste verbatim output from "ansible-config dump --only-changed" between quotes -->
```paste below
# Nothing
```

##### OS / ENVIRONMENT
<!--- Provide all relevant information below, e.g. target OS versions, network device firmware, etc. -->
Debian testing

##### STEPS TO REPRODUCE
<!--- Describe exactly how to reproduce the problem, using a minimal test-case -->
```
ansible-playbook playbook.yml
```
<!--- Paste example playbooks or commands between quotes below -->
```yaml
---
- hosts: localhost
  tasks:
  - name: "Add empty section"
    ini_file:
      path: "/tmp/empty_file.cfg"
      section: "empty_section"
      state: present
      allow_no_value: yes
```

<!--- HINT: You can paste gist.github.com links for larger files -->

##### EXPECTED RESULTS
<!--- Describe what you expected to happen when running the steps above -->
I'd expect a file `/tmp/empty_file.cfg` to be created with following content:
```ini
[empty_section]
```

##### ACTUAL RESULTS
<!--- Describe what actually happened. If possible run with extra verbosity (-vvvv) -->
File was not created.

If the file and `empty_section` exist, a line `None = None` is appended to the section.

<!--- Paste verbatim command output between quotes -->
```paste below
ansible-playbook 2.8.6
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/home/vzima/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  executable location = /usr/bin/ansible-playbook
  python version = 3.8.3rc1 (default, Apr 30 2020, 07:33:30) [GCC 9.3.0]
Using /etc/ansible/ansible.cfg as config file
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'


PLAYBOOK: playbook.yml ********************************************************************************************************************************************************************************************
1 plays in playbook.yml

PLAY [localhost] **************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ********************************************************************************************************************************************************************************************
task path: /tmp/playbook.yml:2
ok: [localhost]
META: ran handlers

TASK [Add empty section] ******************************************************************************************************************************************************************************************
task path: /tmp/playbook.yml:4
ok: [localhost] => {"changed": false, "msg": "OK", "path": "/tmp/empty_file.cfg"}
META: ran handlers
META: ran handlers

PLAY RECAP ********************************************************************************************************************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
```



Discussion:
Files identified in the description:
* [`plugins/modules/files/ini_file.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/files/ini_file.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @jpmens @noseka1
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Thanks for your report. I am not interested in adding this feature.
@jpmens I'm a bit confused by your answer. Does it mean, you don't want to implement the change, or you won't even merge it, if I implement it?
@ziima it means I am not interested in adding this feature. If you wish to, feel free to, and I would accept the change. :-) 
One comment though: I'm not sure whether it is a good idea to add that to `ini_file`. Maybe a new module, `ini_file_section`, would be better suited, to avoid cluttering the interface of `ini_file` even more. (Maybe `ini_file` should be called `ini_file_entry` to be more precise :) )
@felixfontein I don't intent to change the interface, so it shouldn't affect it. Also it's already hinted in the module documentation, see https://github.com/ansible-collections/community.general/blob/master/plugins/modules/files/ini_file.py#L44
@jpmens I would be willing to try implementing that but, any particular reason not to use `ConfigParser` for the .ini files? It is standard python and available throughout all the supported python versions (`from ansible.module_utils.six.moves import configparser`). I believe it would greatly simplify this module. Would you mind if I write a revamp for the module using that and at the same time trying to fix this problem?
@russoz there was a reason for not using `ConfigParser` back then, but I do not recall what it was. Maybe duplicate keys or no section names, ... ? I don't remember.

I certainly don't mind, but the rewrite would have to support everything people have become used to. I suggest, before you invest time and effort, waiting for others to chime in here?
> @russoz there was a reason for not using `ConfigParser` back then, but I do not recall what it was. Maybe duplicate keys or no section names, ... ? I don't remember.
> 
> I certainly don't mind, but the rewrite would have to support everything people have become used to. I suggest, before you invest time and effort, waiting for others to chime in here?

Any effective way of me getting to know what is "everything people have become used to"?

The current module preserves most of the INI file when working on it, thus preserving a lot of legal or illegal constructs (depending on what kind of INI format definition you are using). Using `ConfigParser` might not do that.
> The current module preserves most of the INI file when working on it, thus preserving a lot of legal or illegal constructs (depending on what kind of INI format definition you are using). Using `ConfigParser` might not do that.

More reason to have a module (this or another) that works within legal boundaries, IMHO.
The problem is, there are no legal boundaries, since there is not the one INI format, but a myriad of similar, but partially incompatible ones :)
@russoz I rewrote the ini_file module to NOT use the ConfigParser back in Ansible 2.0. Back then the ConfigParser was destroying all the comments in the file, which was the reason to get rid of it. Further info at: https://alesnosek.com/blog/2015/08/03/improving-ansibles-ini-file-module/
Ok, I hear you. One thing that might be interesting would be to wrap that logic in a python package by itself, as in something pip-installable, and then have the ansible module just use it. I don't volunteer for that because my time is already stretched thin, but would be willing to review it.
@russoz forcing people to suddenly install a dependency to use something that worked without one before doesn't sound like a great idea :) If someone wants to refactor the code, creating some generic code in module_utils would be the way to go.

Resolving patch:
diff --git a/plugins/modules/files/ini_file.py b/plugins/modules/files/ini_file.py
--- a/plugins/modules/files/ini_file.py
+++ b/plugins/modules/files/ini_file.py
@@ -143,11 +143,8 @@ def do_ini(module, filename, section=None, option=None, value=None,
             os.makedirs(destpath)
         ini_lines = []
     else:
-        ini_file = open(filename, 'r')
-        try:
+        with open(filename, 'r') as ini_file:
             ini_lines = ini_file.readlines()
-        finally:
-            ini_file.close()
 
     if module._diff:
         diff['before'] = ''.join(ini_lines)
@@ -172,7 +169,7 @@ def do_ini(module, filename, section=None, option=None, value=None,
     # Insert it at the beginning
     ini_lines.insert(0, '[%s]' % fake_section_name)
 
-    # At botton:
+    # At bottom:
     ini_lines.append('[')
 
     # If no section is defined, fake section is used
@@ -198,12 +195,14 @@ def do_ini(module, filename, section=None, option=None, value=None,
                     for i in range(index, 0, -1):
                         # search backwards for previous non-blank or non-comment line
                         if not re.match(r'^[ \t]*([#;].*)?$', ini_lines[i - 1]):
-                            if not value and allow_no_value:
-                                ini_lines.insert(i, '%s\n' % option)
-                            else:
+                            if option and value:
                                 ini_lines.insert(i, assignment_format % (option, value))
-                            msg = 'option added'
-                            changed = True
+                                msg = 'option added'
+                                changed = True
+                            elif option and not value and allow_no_value:
+                                ini_lines.insert(i, '%s\n' % option)
+                                msg = 'option added'
+                                changed = True
                             break
                 elif state == 'absent' and not option:
                     # remove the entire section
@@ -249,14 +248,16 @@ def do_ini(module, filename, section=None, option=None, value=None,
     del ini_lines[0]
     del ini_lines[-1:]
 
-    if not within_section and option and state == 'present':
+    if not within_section and state == 'present':
         ini_lines.append('[%s]\n' % section)
-        if not value and allow_no_value:
+        msg = 'section and option added'
+        if option and value:
+            ini_lines.append(assignment_format % (option, value))
+        elif option and not value and allow_no_value:
             ini_lines.append('%s\n' % option)
         else:
-            ini_lines.append(assignment_format % (option, value))
+            msg = 'only section added'
         changed = True
-        msg = 'section and option added'
 
     if module._diff:
         diff['after'] = ''.join(ini_lines)
@@ -311,6 +312,9 @@ def main():
     allow_no_value = module.params['allow_no_value']
     create = module.params['create']
 
+    if state == 'present' and not allow_no_value and not value:
+        module.fail_json("Parameter 'value' must not be empty if state=present and allow_no_value=False")
+
     (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):


Test patch:
diff --git a/tests/integration/targets/ini_file/tasks/main.yml b/tests/integration/targets/ini_file/tasks/main.yml
--- a/tests/integration/targets/ini_file/tasks/main.yml
+++ b/tests/integration/targets/ini_file/tasks/main.yml
@@ -36,7 +36,7 @@
 - name: verify ini_file 'changed' is true
   assert:
     that:
-      - result1.changed == True
+      - result1 is changed
       - result1.msg == 'section and option added'
 
 - name: read content from output file
@@ -68,7 +68,7 @@
 - name: Ensure unchanged
   assert:
     that:
-      - result2.changed == False
+      - result2 is not changed
       - result2.msg == 'OK'
 
 - name: Ensure "beverage=coke" is in section "[drinks]"
@@ -96,7 +96,7 @@
 - name: assert 'changed' is true and content is OK
   assert:
     that:
-      - result3.changed == True
+      - result3 is changed
       - result3.msg == 'option added'
       - content3 == expected3
 
@@ -120,7 +120,7 @@
 - name: assert changed and content is as expected
   assert:
     that:
-      - result4.changed == True
+      - result4 is changed
       - result4.msg == 'option changed'
       - content4 == expected1
 
@@ -143,7 +143,7 @@
 - name: assert changed and content is empty
   assert:
     that:
-      - result5.changed == True
+      - result5 is changed
       - result5.msg == 'section removed'
       - content5 == "\n"
 
@@ -160,7 +160,7 @@
 - name: assert section and option added
   assert:
     that:
-      - result6.changed == True
+      - result6 is changed
       - result6.msg == 'section and option added'
 
 - name: test allow_no_value idempotency
@@ -174,9 +174,42 @@
 - name: assert 'changed' false
   assert:
     that:
-      - result6.changed == False
+      - result6 is not changed
       - result6.msg == 'OK'
 
+- name: test create empty section
+  ini_file:
+    path: "{{ output_file }}"
+    section: new_empty_section
+    allow_no_value: yes
+  register: result6a
+
+- name: assert section added
+  assert:
+    that:
+      - result6a is changed
+      - result6a.msg == 'only section added'
+
+- name: test create empty section idempotency
+  ini_file:
+    path: "{{ output_file }}"
+    section: new_empty_section
+    allow_no_value: yes
+  register: result6a
+
+- name: assert 'changed' false
+  assert:
+    that:
+      - result6a is not changed
+      - result6a.msg == 'OK'
+
+- name: test remove empty section
+  ini_file:
+    state: absent
+    path: "{{ output_file }}"
+    section: new_empty_section
+    allow_no_value: yes
+
 - name: test allow_no_value with loop
   ini_file:
     path: "{{ output_file }}"
@@ -234,7 +267,7 @@
 - name: assert 'changed' and msg 'option changed' and content is as expected
   assert:
     that:
-      - result8.changed == True
+      - result8 is changed
       - result8.msg == 'option changed'
       - content8 == expected8
 
@@ -264,7 +297,7 @@
 - name: assert 'changed' and msg 'option changed' and content is as expected
   assert:
     that:
-      - result9.changed == True
+      - result9 is changed
       - result9.msg == 'option changed'
       - content9 == expected9
 
@@ -293,7 +326,7 @@
 - name: assert 'changed' and msg 'option changed' and content is as expected
   assert:
     that:
-      - result10.changed == True
+      - result10 is changed
       - result10.msg == 'option changed'
       - content10 == expected10