Discussing the Principle of Idempotence in Ansible

Follow the public account “AI Operations Station”

Set as “Starred“, sharing valuable content with you every day!

Idempotence is a concept that often needs to be considered in practical applications, especially in operations management. Rather than understanding idempotence as a comprehensive handling of various exceptional situations, it is easier to approach business requirements by understanding it as the ability to execute normally while considering the impacts of previous executions. Ansible includes numerous modules, most of which ensure the idempotence of operations, meaning that multiple executions of related operations can achieve the same result without side effects. However, there are also modules that do not satisfy the idempotence principle, such as the shell module, raw module, and command module.

Comparison of Idempotent and Non-Idempotent Operations

Scenario description: For example, to delete a temporary file /root/testfile, if we want the operation to maintain the same result under the same conditions and not cause other side effects, we must ensure that this operation can function normally whether the /root/testfile file exists or not.

# When using the raw module to execute a shell command to delete the file, the first deletion is successful, and the second deletion is also successful. However, this result is not ideal in a production environment. For instance, if you restart a service, would you randomly restart the service?

[root@Server-1~]# touch /root/testfile
[root@Server-1~]# ansible localhost -m shell -a "rm -rf testfile"
localhost | CHANGED | rc=0 >>
[root@Server-1~]# ansible localhost -m shell -a "rm -rf testfile"
localhost | CHANGED | rc=0 >>

# When using the file module to delete the file, the first execution successfully deletes the file<span>changed: true</span>, and multiple executions of the file deletion yield the same result without side effects<span>changed: false</span>.

[root@Server-1~]# touch /root/testfile
[root@Server-1~]# ansible localhost -m file -a "path=/root/testfile state=absent"
localhost | CHANGED => {
    "changed": true, 
    "path": "/root/testfile", 
    "state": "absent"
}
[root@Server-1~]# ansible localhost -m file -a "path=/root/testfile state=absent"
localhost | SUCCESS => {
    "changed": false, 
    "path": "/root/testfile", 
    "state": "absent"
}
How does the file module achieve idempotence? Below is the code for the file module when executing absent files (with Chinese comments)

vim /usr/lib/python2.7/site-packages/ansible/modules/files/file.py
.....
def get_state(path):
    ''' Find out current state '''

    b_path = to_bytes(path, errors='surrogate_or_strict')
    try:
        if os.path.lexists(b_path): # If the file exists, return 'file'; if not, return 'absent'
            if os.path.islink(b_path):
                return 'link'
            elif os.path.isdir(b_path):
                return 'directory'
            elif os.stat(b_path).st_nlink > 1:
                return 'hard'

            # could be many other things, but defaulting to file
            return 'file'

        return 'absent'
    except OSError as e:
        if e.errno == errno.ENOENT:  # It may already have been removed
            return 'absent'
        else:
            raise

def ensure_absent(path):
    b_path = to_bytes(path, errors='surrogate_or_strict')
    prev_state = get_state(b_path) # Get the state of the file
    result = {}

    if prev_state != 'absent': # When prev_state='directory' or 'file' is true
        diff = initial_diff(path, 'absent', prev_state)

        if not module.check_mode:
            if prev_state == 'directory': # If prev_state='directory', delete the directory
                try:
                    shutil.rmtree(b_path, ignore_errors=False)
                except Exception as e:
                    raise AnsibleModuleError(results={'msg': "rmtree failed: %s" % to_native(e)})
            else:
                try:
                    os.unlink(b_path) # If prev_state='file', delete the file
                except OSError as e:
                    if e.errno != errno.ENOENT:  # It may already have been removed
                        raise AnsibleModuleError(results={'msg': "unlinking failed: %s " % to_native(e),
                                                          'path': path})

        result.update({'path': path, 'changed': True, 'diff': diff, 'state': 'absent'}) # Successfully deleted the file, action changed, changed=True
    else:
        result.update({'path': path, 'changed': False, 'state': 'absent'}) # If prev_state='absent', action did not change, changed=False, achieving that multiple operations do not change anything.

    return result

def main():

    global module

    module = AnsibleModule(
        argument_spec=dict(
            state=dict(type='str', choices=['absent', 'directory', 'file', 'hard', 'link', 'touch']),
            path=dict(type='path', required=True, aliases=['dest', 'name']),
            _original_basename=dict(type='str'),  # Internal use only, for recursive ops
            recurse=dict(type='bool', default=False),
            force=dict(type='bool', default=False),  # Note: Should not be in file_common_args in future
            follow=dict(type='bool', default=True),  # Note: Different default than file_common_args
            _diff_peek=dict(type='bool'),  # Internal use only, for internal checks in the action plugins
            src=dict(type='path'),  # Note: Should not be in file_common_args in future
            modification_time=dict(type='str'),
            modification_time_format=dict(type='str', default='%Y%m%d%H%M.%S'),
            access_time=dict(type='str'),
            access_time_format=dict(type='str', default='%Y%m%d%H%M.%S'),
        ),
        add_file_common_args=True,
        supports_check_mode=True,
    )

    # When we rewrite basic.py, we will do something similar to this on instantiating an AnsibleModule
    sys.excepthook = _ansible_excepthook
    additional_parameter_handling(module.params)
    params = module.params

    state = params['state']
    recurse = params['recurse']
    force = params['force']
    follow = params['follow']
    path = params['path']
    src = params['src']

    timestamps = {}
    timestamps['modification_time'] = keep_backward_compatibility_on_timestamps(params['modification_time'], state)
    timestamps['modification_time_format'] = params['modification_time_format']
    timestamps['access_time'] = keep_backward_compatibility_on_timestamps(params['access_time'], state)
    timestamps['access_time_format'] = params['access_time_format']

    # short-circuit for diff_peek
    if params['_diff_peek'] is not None:
        appears_binary = execute_diff_peek(to_bytes(path, errors='surrogate_or_strict'))
        module.exit_json(path=path, changed=False, appears_binary=appears_binary)

    if state == 'file':
        result = ensure_file_attributes(path, follow, timestamps)
    elif state == 'directory':
        result = ensure_directory(path, follow, recurse, timestamps)
    elif state == 'link':
        result = ensure_symlink(path, src, follow, force, timestamps)
    elif state == 'hard':
        result = ensure_hardlink(path, src, follow, force, timestamps)
    elif state == 'touch':
        result = execute_touch(path, follow, timestamps)
    elif state == 'absent': 
        result = ensure_absent(path) # Call method def ensure_absent when executing file deletion

    module.exit_json(**result)

if __name__ == '__main__':
    main()

Final Thoughts

That’s all for today’s sharing. Thank you for your patience in reading! Follow me, and see you next time. Respect!

Leave a Comment