Repository: ansible/ansible

Issue:
Allow ini_file module to handle keys without values
##### ISSUE TYPE
 - Feature Idea

##### COMPONENT NAME
ini_file

##### SUMMARY
mysql/mariadb config files are pretty close to what ini file is yet they do allow just keys, without values. Unfortunately ini_file module does not allow anything like that, which prevents using this module to manage mysql/mariadb config file. Imaginary mysql.cnf file can look like this:

     [mysql]
     skip-name-resolve
     max_connections=5000

and it would be useful to be able to create `skip-name-resolve` entry with `ini_file`. Unfortunately neither skipping `value` argument does not help as `None` is then used as value instead.

    - ini_file
         src: .....
         section: "mysqld"
         option: "skip-name-resolve"

would produce

    [mysql]
    skip-name-resolve = None

That would probably additional option to enable such behavior like `allow_no_values`. Also, if that would be possible to have this feature introduced, we'd need option to do that in a loop (so i.e. `null` would no be converted to `None` if `allow_no_values` is on), i.e.:

    - ini_file
         src: .....
         section: "mysqld"
         option: "{{ item.o }}"
         value: "{{ item.v }}"
       with_items:
          - { o: "skip-name-resolve", v: null }
          - { o: "max_connections", v: "500" }

ini_file: add integration test
Start integration tests for ini_file module.

##### SUMMARY
<!--- Describe the change, including rationale and design decisions -->
Use some of the example of the documentation:
- add section + option
- add again same section + option (idempotent)
- add another option
- remove this option
- remove complete section

Use stat result checksum to assert content is as expected.

<!---
If you are fixing an existing issue, please include "Fixes #nnn" in your
commit message and your description; but you should still explain what
the change does.
-->

##### ISSUE TYPE
<!--- Pick one below and delete the rest: -->
 - Test Pull Request


##### COMPONENT NAME
<!--- Name of the module/plugin/module/task -->

##### ANSIBLE VERSION
<!--- Paste verbatim output from “ansible --version” between quotes below -->
```
ansible 2.4.0 (ini_file-integration_tests cc5e4c4aae) last updated 2017/05/10 10:18:36 (GMT +200)                                                                                             
  config file = /etc/ansible/ansible.cfg                                                                                                                                                      
  configured module search path = ['/home/fridim/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']                                                                             
  ansible python module location = /home/fridim/sync/dev/ansible/lib/ansible                                                                                                                  
  executable location = /home/fridim/sync/dev/ansible/bin/ansible                                                                                                                             
  python version = 3.6.1 (default, Mar 27 2017, 00:27:06) [GCC 6.3.1 20170306]   
```


##### ADDITIONAL INFORMATION
<!---
Include additional information to help people understand the change here.
For bugs that don't have a linked bug report, a step-by-step reproduction
of the problem is helpful.
  -->

<!--- Paste verbatim command output below, e.g. before and after your change -->




Discussion:
cc @jpmens @noseka1
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
needs_contributor


Resolving patch:
diff --git a/lib/ansible/modules/files/ini_file.py b/lib/ansible/modules/files/ini_file.py
--- a/lib/ansible/modules/files/ini_file.py
+++ b/lib/ansible/modules/files/ini_file.py
@@ -75,6 +75,13 @@
      type: bool
      default: 'yes'
      version_added: "2.2"
+  allow_no_value:
+     description:
+       - allow option without value and without '=' symbol
+     type: bool
+     required: false
+     default: false
+     version_added: "2.6"
 notes:
    - While it is possible to add an I(option) without specifying a I(value), this makes
      no sense.
@@ -112,18 +119,19 @@
 
 def match_opt(option, line):
     option = re.escape(option)
-    return re.match('( |\t)*%s( |\t)*=' % option, line) \
-        or re.match('#( |\t)*%s( |\t)*=' % option, line) \
-        or re.match(';( |\t)*%s( |\t)*=' % option, line)
+    return re.match('( |\t)*%s( |\t)*(=|$)' % option, line) \
+        or re.match('#( |\t)*%s( |\t)*(=|$)' % option, line) \
+        or re.match(';( |\t)*%s( |\t)*(=|$)' % option, line)
 
 
 def match_active_opt(option, line):
     option = re.escape(option)
-    return re.match('( |\t)*%s( |\t)*=' % option, line)
+    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):
+           state='present', backup=False, no_extra_spaces=False, create=True,
+           allow_no_value=False):
 
     diff = dict(
         before='',
@@ -182,7 +190,10 @@ 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]):
-                            ini_lines.insert(i, assignment_format % (option, value))
+                            if not value and allow_no_value:
+                                ini_lines.insert(i, '%s\n' % option)
+                            else:
+                                ini_lines.insert(i, assignment_format % (option, value))
                             msg = 'option added'
                             changed = True
                             break
@@ -197,7 +208,10 @@ def do_ini(module, filename, section=None, option=None, value=None,
                 if state == 'present':
                     # change the existing option line
                     if match_opt(option, line):
-                        newline = assignment_format % (option, value)
+                        if not value and allow_no_value:
+                            newline = '%s\n' % option
+                        else:
+                            newline = assignment_format % (option, value)
                         option_changed = ini_lines[index] != newline
                         changed = changed or option_changed
                         if option_changed:
@@ -228,7 +242,10 @@ def do_ini(module, filename, section=None, option=None, value=None,
 
     if not within_section and option and state == 'present':
         ini_lines.append('[%s]\n' % section)
-        ini_lines.append(assignment_format % (option, value))
+        if not value and allow_no_value:
+            ini_lines.append('%s\n' % option)
+        else:
+            ini_lines.append(assignment_format % (option, value))
         changed = True
         msg = 'section and option added'
 
@@ -259,6 +276,7 @@ def main():
             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, required=False),
             create=dict(type='bool', default=True)
         ),
         add_file_common_args=True,
@@ -272,9 +290,10 @@ def main():
     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']
 
-    (changed, backup_file, diff, msg) = do_ini(module, path, section, option, value, state, backup, no_extra_spaces, create)
+    (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)


Test patch:
diff --git a/test/integration/targets/ini_file/tasks/main.yml b/test/integration/targets/ini_file/tasks/main.yml
--- a/test/integration/targets/ini_file/tasks/main.yml
+++ b/test/integration/targets/ini_file/tasks/main.yml
@@ -115,9 +115,139 @@
   set_fact:
     content5: "{{ lookup('file', output_file) }}"
 
-- name: assert changed
+- name: assert changed and content is empty
   assert:
     that:
       - result5.changed == True
       - result5.msg == 'section removed'
       - content5 == ""
+
+# allow_no_value
+
+- name: test allow_no_value
+  ini_file:
+    path: "{{ output_file }}"
+    section: mysqld
+    option: skip-name
+    allow_no_value: yes
+  register: result6
+
+- name: assert section and option added
+  assert:
+    that:
+      - result6.changed == True
+      - result6.msg == 'section and option added'
+
+- name: test allow_no_value idempotency
+  ini_file:
+    path: "{{ output_file }}"
+    section: mysqld
+    option: skip-name
+    allow_no_value: yes
+  register: result6
+
+- name: assert 'changed' false
+  assert:
+    that:
+      - result6.changed == False
+      - result6.msg == 'OK'
+
+- name: test allow_no_value with loop
+  ini_file:
+    path: "{{ output_file }}"
+    section: mysqld
+    option: "{{ item.o }}"
+    value: "{{ item.v }}"
+    allow_no_value: yes
+  with_items:
+    - { o: "skip-name-resolve", v: null }
+    - { o: "max_connections", v: "500" }
+
+- name: set expected content and get current ini file content
+  set_fact:
+    content7: "{{ lookup('file', output_file) }}"
+    expected7: |-
+      
+      [mysqld]
+      skip-name
+      skip-name-resolve
+      max_connections = 500
+
+- name: Verify content of ini file is as expected
+  assert:
+    that:
+      - content7 == expected7
+
+- name: change option with no value to option with value
+  ini_file:
+    path: "{{ output_file }}"
+    section: mysqld
+    option: skip-name
+    value: myvalue
+  register: result8
+
+- name: set expected content and get current ini file content
+  set_fact:
+    content8: "{{ lookup('file', output_file) }}"
+    expected8: |-
+      
+      [mysqld]
+      skip-name = myvalue
+      skip-name-resolve
+      max_connections = 500
+
+- name: assert 'changed' and msg 'option changed' and content is as expected
+  assert:
+    that:
+      - result8.changed == True
+      - result8.msg == 'option changed'
+      - content8 == expected8
+
+- name: change option with value to option with no value
+  ini_file:
+    path: "{{ output_file }}"
+    section: mysqld
+    option: skip-name
+    allow_no_value: yes
+  register: result9
+
+- name: set expected content and get current ini file content
+  set_fact:
+    content9: "{{ lookup('file', output_file) }}"
+    expected9: |-
+      
+      [mysqld]
+      skip-name
+      skip-name-resolve
+      max_connections = 500
+
+- name: assert 'changed' and msg 'option changed' and content is as expected
+  assert:
+    that:
+      - result9.changed == True
+      - result9.msg == 'option changed'
+      - content9 == expected9
+
+- name: Remove option with no value
+  ini_file:
+    path: "{{ output_file }}"
+    section: mysqld
+    option: skip-name-resolve
+    state: absent
+  register: result10
+
+- name: set expected content and get current ini file content
+  set_fact:
+    content10: "{{ lookup('file', output_file) }}"
+    expected10: |-
+      
+      [mysqld]
+      skip-name
+      max_connections = 500
+
+- name: assert 'changed' and msg 'option changed' and content is as expected
+  assert:
+    that:
+      - result10.changed == True
+      - result10.msg == 'option changed'
+      - content10 == expected10