Ansible recurse and follow quirks
I'm reading Ansible's builtin.file
sources for, uhm, reasons,
and the use of follow
stood out to my eyes. Reading on, not only that. I feel
like the ansible codebase needs a serious review, at least in essential core
modules like this one.
In the file module documentation it says:
This flag indicates that filesystem links, if they exist, should be followed.
In the recursive_set_attributes implementation instead, follow means "follow symlinks to directories", but if a symlink to a file is found, it does not get followed, kind of.
What happens is that ansible will try to change the mode of the symlink, which
makes sense on some operating systems. And it does try to use lchmod
if
present. Buf if not, this happens:
# Attempt to set the perms of the symlink but be # careful not to change the perms of the underlying # file while trying underlying_stat = os.stat(b_path) os.chmod(b_path, mode) new_underlying_stat = os.stat(b_path) if underlying_stat.st_mode != new_underlying_stat.st_mode: os.chmod(b_path, stat.S_IMODE(underlying_stat.st_mode))
So it tries doing chmod on the symlink, and if that changed the mode of the actual file, switch it back.
I would have appreciated a comment documenting on which systems a hack like this makes sense. As it is, it opens a very short time window in which a symlink attack can make a system file vulerable, and an exception thrown by the second stat will make it vulnerable permanently.
What about follow
following links during recursion: how does it avoid loops?
I don't see a cache of (device, inode)
pairs visited. Let's try:
fatal: [localhost]: FAILED! => {"changed": false, "details": "maximum recursion depth exceeded", "gid": 1000, "group": "enrico", "mode": "0755", "msg": "mode must be in octal or symbolic form", "owner": "enrico", "path": "/tmp/test/test1", "size": 0, "state": "directory", "uid": 1000}
Ok, it, uhm, delegates handling that to the Python stack size. I guess it means
that a ln -s .. foo
in a directory that gets recursed will always fail the
task. Fun!
More quirks
Turning a symlink into a hardlink is considered a noop if the symlink points to the same file:
--- - hosts: localhost tasks: - name: create test file file: path: /tmp/testfile state: touch - name: create test link file: path: /tmp/testlink state: link src: /tmp/testfile - name: turn it into a hard link file: path: /tmp/testlink state: hard src: /tmp/testfile
gives:
$ ansible-playbook test3.yaml [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all' PLAY [localhost] ************************************************************************************************************************************************************************************************************ TASK [Gathering Facts] ****************************************************************************************************************************************************************************************************** ok: [localhost] TASK [create test file] ***************************************************************************************************************************************************************************************************** changed: [localhost] TASK [create test link] ***************************************************************************************************************************************************************************************************** changed: [localhost] TASK [turn it into a hard link] ********************************************************************************************************************************************************************************************* ok: [localhost] PLAY RECAP ****************************************************************************************************************************************************************************************************************** localhost : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
More quirks
Converting a directory into a hardlink should work, but it doesn't because unlink is used instead of rmdir:
--- - hosts: localhost tasks: - name: create test dir file: path: /tmp/testdir state: directory - name: turn it into a symlink file: path: /tmp/testdir state: hard src: /tmp/ force: yes
gives:
$ ansible-playbook test4.yaml [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all' PLAY [localhost] ************************************************************************************************************************************************************************************************************ TASK [Gathering Facts] ****************************************************************************************************************************************************************************************************** ok: [localhost] TASK [create test dir] ****************************************************************************************************************************************************************************************************** changed: [localhost] TASK [turn it into a symlink] *********************************************************************************************************************************************************************************************** fatal: [localhost]: FAILED! => {"changed": false, "gid": 1000, "group": "enrico", "mode": "0755", "msg": "Error while replacing: [Errno 21] Is a directory: b'/tmp/testdir'", "owner": "enrico", "path": "/tmp/testdir", "size": 0, "state": "directory", "uid": 1000} PLAY RECAP ****************************************************************************************************************************************************************************************************************** localhost : ok=2 changed=1 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
More quirks
This is hard to test, but it looks like if source and destination are hardlinks to the same inode numbers, but on different filesystems, the operation is considered a successful noop: https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/file.py#L821
It should probably be something like:
if (st1.st_dev, st1.st_ino) == (st2.st_dev, st2.st_ino):