Repository: ansible/ansible

Issue:
htpasswd module does not properly handle creating files during check mode
##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
htpasswd

##### ANSIBLE VERSION
```
ansible 2.4.1.0
  config file = /etc/ansible/ansible.cfg
  configured module search path = [u'/home/djmattyg007/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python2.7/site-packages/ansible
  executable location = /usr/bin/ansible
  python version = 2.7.13 (default, Dec 21 2016, 07:16:46) [GCC 6.2.1 20160830]
```

##### CONFIGURATION
Not relevant

##### OS / ENVIRONMENT
Both the controller and remote hosts are running Arch Linux.

##### SUMMARY
When in check mode, the `check_file_attrs` function is still called. This calls `module.set_fs_attributes_if_different`, which eventually raises an exception in `module.user_and_group` if the file doesn't exist. This situation can occur when running in check mode when the destination file does not yet exist, even if `create` is set to `yes`.

##### STEPS TO REPRODUCE
Run a playbook in check mode that contains this task:

```yaml
- name: Set up basic auth credentials file
  htpasswd:
    create: yes
    crypt_scheme: apr_md5_crypt
    name: "testuser"
    password: "testpass"
    path: "/tmp/thisfiledoesnotexist.htpasswd"
    state: present
```

##### EXPECTED RESULTS
The check run should complete successfully with a note that the file will be created.

##### ACTUAL RESULTS
I get the following error message:
```
[Errno 2] No such file or directory: '/tmp/thisfiledoesnotexist.htpasswd'
```

This is what I get when I run `ansible-playbook` with `-vvv`:
```
The full traceback is:
  File "/tmp/ansible_ntt3I1/ansible_module_htpasswd.py", line 262, in main
    check_file_attrs(module, changed, msg)
  File "/tmp/ansible_ntt3I1/ansible_module_htpasswd.py", line 186, in check_file_attrs
    if module.set_fs_attributes_if_different(file_args, False):
  File "/tmp/ansible_ntt3I1/ansible_modlib.zip/ansible/module_utils/basic.py", line 1444, in set_fs_attributes_if_different
    file_args['path'], file_args['owner'], changed, diff, expand
  File "/tmp/ansible_ntt3I1/ansible_modlib.zip/ansible/module_utils/basic.py", line 1113, in set_owner_if_different
    orig_uid, orig_gid = self.user_and_group(b_path, expand)
  File "/tmp/ansible_ntt3I1/ansible_modlib.zip/ansible/module_utils/basic.py", line 1018, in user_and_group
    st = os.lstat(b_path)
```


Discussion:
Files identified in the description:
* [lib/ansible/modules/web_infrastructure/htpasswd.py](https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/web_infrastructure/htpasswd.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 --->

Resolving patch:
diff --git a/lib/ansible/module_utils/basic.py b/lib/ansible/module_utils/basic.py
--- a/lib/ansible/module_utils/basic.py
+++ b/lib/ansible/module_utils/basic.py
@@ -1064,6 +1064,10 @@ def set_context_if_different(self, path, context, changed, diff=None):
 
         if not HAVE_SELINUX or not self.selinux_enabled():
             return changed
+
+        if self.check_file_absent_if_check_mode(path):
+            return True
+
         cur_context = self.selinux_context(path)
         new_context = list(cur_context)
         # Iterate over the current context instead of the
@@ -1102,11 +1106,17 @@ def set_context_if_different(self, path, context, changed, diff=None):
         return changed
 
     def set_owner_if_different(self, path, owner, changed, diff=None, expand=True):
+
+        if owner is None:
+            return changed
+
         b_path = to_bytes(path, errors='surrogate_or_strict')
         if expand:
             b_path = os.path.expanduser(os.path.expandvars(b_path))
-        if owner is None:
-            return changed
+
+        if self.check_file_absent_if_check_mode(b_path):
+            return True
+
         orig_uid, orig_gid = self.user_and_group(b_path, expand)
         try:
             uid = int(owner)
@@ -1137,11 +1147,17 @@ def set_owner_if_different(self, path, owner, changed, diff=None, expand=True):
         return changed
 
     def set_group_if_different(self, path, group, changed, diff=None, expand=True):
+
+        if group is None:
+            return changed
+
         b_path = to_bytes(path, errors='surrogate_or_strict')
         if expand:
             b_path = os.path.expanduser(os.path.expandvars(b_path))
-        if group is None:
-            return changed
+
+        if self.check_file_absent_if_check_mode(b_path):
+            return True
+
         orig_uid, orig_gid = self.user_and_group(b_path, expand)
         try:
             gid = int(group)
@@ -1172,13 +1188,17 @@ def set_group_if_different(self, path, group, changed, diff=None, expand=True):
         return changed
 
     def set_mode_if_different(self, path, mode, changed, diff=None, expand=True):
+
+        if mode is None:
+            return changed
+
         b_path = to_bytes(path, errors='surrogate_or_strict')
         if expand:
             b_path = os.path.expanduser(os.path.expandvars(b_path))
         path_stat = os.lstat(b_path)
 
-        if mode is None:
-            return changed
+        if self.check_file_absent_if_check_mode(b_path):
+            return True
 
         if not isinstance(mode, int):
             try:
@@ -1256,6 +1276,9 @@ def set_attributes_if_different(self, path, attributes, changed, diff=None, expa
         if expand:
             b_path = os.path.expanduser(os.path.expandvars(b_path))
 
+        if self.check_file_absent_if_check_mode(b_path):
+            return True
+
         existing = self.get_file_attributes(b_path)
 
         if existing.get('attr_flags', '') != attributes:
@@ -1450,6 +1473,9 @@ def set_fs_attributes_if_different(self, file_args, changed, diff=None, expand=T
         )
         return changed
 
+    def check_file_absent_if_check_mode(self, file_path):
+        return self.check_mode and not os.path.exists(file_path)
+
     def set_directory_attributes_if_different(self, file_args, changed, diff=None, expand=True):
         return self.set_fs_attributes_if_different(file_args, changed, diff, expand)
 


Test patch:
diff --git a/test/units/module_utils/basic/test_set_mode_if_different.py b/test/units/module_utils/basic/test_set_mode_if_different.py
--- a/test/units/module_utils/basic/test_set_mode_if_different.py
+++ b/test/units/module_utils/basic/test_set_mode_if_different.py
@@ -49,16 +49,18 @@ def mock_lchmod(mocker):
     yield m_lchmod
 
 
-@pytest.mark.parametrize('previous_changes, check_mode, stdin',
-                         product((True, False), (True, False), ({},)),
+@pytest.mark.parametrize('previous_changes, check_mode, exists, stdin',
+                         product((True, False), (True, False), (True, False), ({},)),
                          indirect=['stdin'])
-def test_no_mode_given_returns_previous_changes(am, mock_stats, mock_lchmod, mocker, previous_changes, check_mode):
+def test_no_mode_given_returns_previous_changes(am, mock_stats, mock_lchmod, mocker, previous_changes, check_mode, exists):
     am.check_mode = check_mode
     mocker.patch('os.lstat', side_effect=[mock_stats['before']])
     m_lchmod = mocker.patch('os.lchmod', return_value=None, create=True)
+    m_path_exists = mocker.patch('os.path.exists', return_value=exists)
 
     assert am.set_mode_if_different('/path/to/file', None, previous_changes) == previous_changes
     assert not m_lchmod.called
+    assert not m_path_exists.called
 
 
 @pytest.mark.parametrize('mode, check_mode, stdin',
@@ -71,6 +73,7 @@ def test_mode_changed_to_0660(am, mock_stats, mocker, mode, check_mode):
     am.check_mode = check_mode
     mocker.patch('os.lstat', side_effect=[mock_stats['before'], mock_stats['after'], mock_stats['after']])
     m_lchmod = mocker.patch('os.lchmod', return_value=None, create=True)
+    mocker.patch('os.path.exists', return_value=True)
 
     assert am.set_mode_if_different('/path/to/file', mode, False)
     if check_mode:
@@ -89,6 +92,7 @@ def test_mode_unchanged_when_already_0660(am, mock_stats, mocker, mode, check_mo
     am.check_mode = check_mode
     mocker.patch('os.lstat', side_effect=[mock_stats['after'], mock_stats['after'], mock_stats['after']])
     m_lchmod = mocker.patch('os.lchmod', return_value=None, create=True)
+    mocker.patch('os.path.exists', return_value=True)
 
     assert not am.set_mode_if_different('/path/to/file', mode, False)
     assert not m_lchmod.called
@@ -111,6 +115,7 @@ def _hasattr(obj, name):
     mocker.patch('os.lstat', side_effect=[mock_stats['before'], mock_stats['after']])
     mocker.patch.object(builtins, 'hasattr', side_effect=_hasattr)
     mocker.patch('os.path.islink', return_value=False)
+    mocker.patch('os.path.exists', return_value=True)
     m_chmod = mocker.patch('os.chmod', return_value=None)
 
     assert am.set_mode_if_different('/path/to/file/no_lchmod', 0o660, False)
@@ -137,6 +142,7 @@ def _hasattr(obj, name):
     mocker.patch('os.lstat', side_effect=[mock_stats['before'], mock_stats['after']])
     mocker.patch.object(builtins, 'hasattr', side_effect=_hasattr)
     mocker.patch('os.path.islink', return_value=True)
+    mocker.patch('os.path.exists', return_value=True)
     m_chmod = mocker.patch('os.chmod', return_value=None)
     mocker.patch('os.stat', return_value=mock_stats['after'])