Repository: ansible/ansible

Issue:
ansible-2.5.2 file state=link force=yes no longer works
##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
file module

##### ANSIBLE VERSION
ansible-2.5.2 from RHEL 2.5 channels
*does not happen in 2.5.1*

```
ansible 2.5.2
  config file = /home/rdu/sdodson/.ansible.cfg
  configured module search path = [u'/home/rdu/sdodson/.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.5 (default, Feb 20 2018, 09:19:12) [GCC 4.8.5 20150623 (Red Hat 4.8.5-28)]
```

##### CONFIGURATION
no changes

##### OS / ENVIRONMENT
RHEL 7.5

##### SUMMARY
The file module can no longer create a dangling symlink.

##### STEPS TO REPRODUCE
```
---
- hosts: nodes
  tasks:
    - file:
        src: "/etc/non-existent"
        dest: "/etc/sdodson-test"
        state: link
        force: yes
```


##### EXPECTED RESULTS
A dangling symlink from /etc/sdodson-test to /etc/non-existent
```
# ls -la /etc/sdodson-test
lrwxrwxrwx. 1 root root 17 May  1 09:47 /etc/sdodson-test -> /etc/non-existent
```



##### ACTUAL RESULTS
<!--- What actually happened? If possible run with extra verbosity (-vvvv) -->

<!--- Paste verbatim command output between quotes below -->
```
$ ansible-playbook -i ~/ansible-ose ~/tasks.yml 
...
<ose3-master.example.com> (1, '\r\n{"exception": "  File \\"/tmp/ansible_2IzqkO/ansible_modlib.zip/ansible/module_utils/basic.py\\", line 1042, in selinux_context\\n    ret = selinux.lgetfilecon_raw(to_native(path, errors=\'surrogate_or_strict\'))\\n", "failed": true, "state": "absent", "invocation": {"module_args": {"directory_mode": null, "force": true, "remote_src": null, "path": "/etc/sdodson-test", "owner": null, "follow": true, "group": null, "unsafe_writes": null, "state": "link", "content": null, "serole": null, "diff_peek": null, "setype": null, "dest": "/etc/sdodson-test", "selevel": null, "original_basename": null, "regexp": null, "validate": null, "src": "/etc/non-existent", "seuser": null, "recurse": false, "delimiter": null, "mode": null, "attributes": null, "backup": null}}, "path": "/etc/non-existent", "msg": "path /etc/non-existent does not exist"}\r\n', 'Shared connection to ose3-master.example.com closed.\r\n')
<ose3-master.example.com> ESTABLISH SSH CONNECTION FOR USER: root
<ose3-master.example.com> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o User=root -o ConnectTimeout=10 -o ControlPath=/home/rdu/sdodson/.ansible/cp/049596976b ose3-master.example.com '/bin/sh -c '"'"'rm -f -r /root/.ansible/tmp/ansible-tmp-1525183096.96-196932529753834/ > /dev/null 2>&1 && sleep 0'"'"''
<ose3-master.example.com> (0, '', '')
The full traceback is:
  File "/tmp/ansible_2IzqkO/ansible_modlib.zip/ansible/module_utils/basic.py", line 1042, in selinux_context
    ret = selinux.lgetfilecon_raw(to_native(path, errors='surrogate_or_strict'))

fatal: [ose3-master.example.com]: FAILED! => {
    "changed": false, 
    "invocation": {
        "module_args": {
            "attributes": null, 
            "backup": null, 
            "content": null, 
            "delimiter": null, 
            "dest": "/etc/sdodson-test", 
            "diff_peek": null, 
            "directory_mode": null, 
            "follow": true, 
            "force": true, 
            "group": null, 
            "mode": null, 
            "original_basename": null, 
            "owner": null, 
            "path": "/etc/sdodson-test", 
            "recurse": false, 
            "regexp": null, 
            "remote_src": null, 
            "selevel": null, 
            "serole": null, 
            "setype": null, 
            "seuser": null, 
            "src": "/etc/non-existent", 
            "state": "link", 
            "unsafe_writes": null, 
            "validate": null
        }
    }, 
    "msg": "path /etc/non-existent does not exist", 
    "path": "/etc/non-existent", 
    "state": "absent"
}
        to retry, use: --limit @/home/rdu/sdodson/tasks.retry
```



Discussion:
May be related to https://github.com/ansible/ansible/issues/39553
Setting follow=false seems to work around the problem
The default value of follow was intended to change from False to True for the 2.5.0 release this was a change from previous behavior in 2.4.x. The proposal to do that is here: https://github.com/ansible/proposals/issues/69

However, there was a bug introduced which made it not take effect in 2.5.0 and 2.5.1 which was fixed in the release of 2.5.2. The original PR to change the default behavior happened back in November https://github.com/ansible/ansible/pull/31430

The workaround you identified of setting follow=False will revert to the previous behaviour which should be safe.  I'm leaving this open for now because I think that the combination of state=link follow=True force=True should work but I have to take a look to make sure I'm not missing something before I just go changing it.

In my testing, creating the link works but once the link exists, the second run fails:
```
[pts/0@peru /var/tmp]$ ansible localhost -m file -a 'state=link src=/nonexistent dest=/var/tmp/dir/other force=yes'                                                                                                               (07:51:50)
localhost | SUCCESS => {
    "changed": true,
    "dest": "/var/tmp/dir/other",
    "src": "/nonexistent",
    "state": "absent"
}
[pts/0@peru /var/tmp]$ ansible localhost -m file -a 'state=link src=/nonexistent dest=/var/tmp/dir/other force=yes'                                                                                                               (07:51:55)
localhost | FAILED! => {
    "changed": false,
    "msg": "path /nonexistent does not exist",
    "path": "/nonexistent",
    "state": "absent"
```
@sdodson Does that match with your experience?
@abadger yeah, only happens when the link already exists, based on the traceback i imagine it's trying to apply selinux contexts

```
The full traceback is:
  File "/tmp/ansible_o4tRMM/ansible_modlib.zip/ansible/module_utils/basic.py", line 1042, in selinux_context
    ret = selinux.lgetfilecon_raw(to_native(path, errors='surrogate_or_strict'))
```
@sdodson I think that https://github.com/ansible/ansible/pull/39635  will solve this case but I'm going to get some additional input from other committers before deciding whether to merge it.  The code there is deciding that follow=True can never be correct and therefore it's essentially changing to follow=False behaviour.  I need to verify that others agree with doing that.

Resolving patch:
diff --git a/lib/ansible/modules/files/file.py b/lib/ansible/modules/files/file.py
--- a/lib/ansible/modules/files/file.py
+++ b/lib/ansible/modules/files/file.py
@@ -352,7 +352,6 @@ def main():
         module.exit_json(path=path, changed=changed, diff=diff)
 
     elif state in ('link', 'hard'):
-
         if not os.path.islink(b_path) and os.path.isdir(b_path):
             relpath = path
         else:
@@ -436,7 +435,16 @@ def main():
         if module.check_mode and not os.path.exists(b_path):
             module.exit_json(dest=path, src=src, changed=changed, diff=diff)
 
-        changed = module.set_fs_attributes_if_different(file_args, changed, diff, expand=False)
+        # Whenever we create a link to a nonexistent target we know that the nonexistent target
+        # cannot have any permissions set on it.  Skip setting those and emit a warning (the user
+        # can set follow=False to remove the warning)
+        if (state == 'link' and params['follow'] and os.path.islink(params['path']) and
+                not os.path.exists(file_args['path'])):
+            module.warn('Cannot set fs attributes on a non-existent symlink target. follow should be'
+                        ' set to False to avoid this.')
+        else:
+            changed = module.set_fs_attributes_if_different(file_args, changed, diff, expand=False)
+
         module.exit_json(dest=path, src=src, changed=changed, diff=diff)
 
     elif state == 'touch':
@@ -469,5 +477,6 @@ def main():
 
     module.fail_json(path=path, msg='unexpected position reached')
 
+
 if __name__ == '__main__':
     main()


Test patch:
diff --git a/test/integration/targets/file/tasks/main.yml b/test/integration/targets/file/tasks/main.yml
--- a/test/integration/targets/file/tasks/main.yml
+++ b/test/integration/targets/file/tasks/main.yml
@@ -303,6 +303,15 @@
     that:
       - "file13_result.changed == true"
 
+- name: Prove idempotence of force creation soft link to non existent
+  file: src=/noneexistent dest={{output_dir}}/soft2.txt state=link force=yes
+  register: file13a_result
+
+- name: verify that the link to nonexistent is idempotent
+  assert:
+    that:
+      - "file13a_result.changed == false"
+
 - name: remove directory foobar
   file: path={{output_dir}}/foobar state=absent
   register: file14_result