User prompt in ansible playbook

Sometimes, it gets essential to prompt the user to confirm if the user is okay with the operations of the playbook. For example, if a playbook is going to perform an action that would cause a service impact, I would probably want to inform the user about the consequences and let him choose to abort or proceed.

With ansible, there are a few ways you can have a user prompt; however, with the out-of-the-box ansible, the prompting is not very flexible. We will be covering both methods in this post.

Method-1: var_prompt in ansible


var_prompt It is excellent for taking passwords as input but is not very flexible regarding yes/no prompts. For example:

The following playbook is an example to show how var_prompt it could be used to prompt the user for confirmation.

– Notice that the var_prompt is called at the start of the play. You cannot call the prompts at the tasks level.
– var_prompt is just storing the value from the user input to the register in the “name:” section of the var_prompt.

A few limitations could make var_prompt not the first choice for all the scenarios. The following are the limitations I could think of:

– var_prompt needs to be called at the start of the play.
– you cannot use variables in the prompt string.
– you will be responsible for the overhead of validating what the user has entered in the prompt; note that the user may enter anything not limited to yes or no.
– Once the user supplies an invalid option(other than a yes/no), there is no simple means to issue a retry. Instead, the playbook needs to fail and restart.
– When the user supplies a “no” or invalid input(“foobar“) the play could be either stopped via meta: end_play or fail module. However, with the fail module, the playbook will fail the entire playbook.

With the meta: end_play, The playbook will gracefully end the current play. The subsequent play will continue to execute.

  - name: "Play for cluster deletion"
    hosts: localhost
    gather_facts: false
    vars_prompt:
    - name: "user_response_cluster_delete"
      prompt: "Do you want to delete the cluster (yes/no)?" #<--This will be visible prompt without vairable
      private: no
    pre_tasks:
    - fail:
        msg: >
          {%- if not user_response_cluster_delete|regex_search('(?i)^yes$|^no$') -%}
              Invalid input provided, input must be yes or no
          {%-else-%}
              User choose to abort the playbook
          {%-endif-%}
      when: not user_response_cluster_delete |regex_search('(?i)^yes$')

    tasks:
    - name: "Some meaningful task"      # <--finally some real business logic start here.
      debug:
        msg: "Hello from a meaningful task!"

  - name: "second play"
    hosts: localhost
    gather_facts: false
    tasks:
    - name: "Some meaningful task from 2nd play"      # <--task from 2nd play
      debug:
        msg: "Hello from a meaningful task of 2nd play!"
 

Problem: There is no way to retry!


Example: With the valid input type(yes and no), the behavior is expected; however, when the user is supplying an invalid input to the prompt like “yesss” then the playbook would fail and exit(again, this is good); however, there is no way we can loop complaining that the supplied response is an “invalid input!" until a valid input is supplied.

ansible-playbook var_prompt.yml

Do you want to delete the cluster (yes/no)?: no

PLAY [Play for cluster deletion] ****************************************************************************************************************************************************************************************************

TASK [fail] *************************************************************************************************************************************************************************************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "User choose to abort the playbook\n"}

PLAY RECAP **************************************************************************************************************************************************************************************************************************
localhost                  : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0   


Method-2: Using an action plugin


The great thing about Ansible is that it’s extendable via custom modules and plugins. We will be writing an action plugin to prompt the playbook users for their confirmation. I have shared the code for the user_prompt action plugin on the Github page. The same is described here with examples.

git clone https://github.com/technekey/ansible-user-prompt.git
cd ansible-user-prompt/


OR

You can use the following code for the action plugin, but you must be careful with the directory instructions described below.

from __future__ import absolute_import, division, print_function

__metaclass__ = type

import json
import os
import sys
import time
import signal

from ansible.plugins.action import ActionBase
from ansible.utils.vars import merge_hash

try:
    from __main__ import display
except ImportError:
    from ansible.utils.display import Display
    display = Display()

class TimeoutError(Exception):
    pass

def _sig_alarm(sig, tb):
    raise TimeoutError("timeout")

class ActionModule(ActionBase):
   
    # Colours
    pure_red = "\033[0;31m"
    dark_green = "\033[0;32m"
    bold = "\033[1m"
    underline = "\033[4m"
    italic = "\033[3m"
    darken = "\033[2m"
    reset_colour = '\033[0m'

    #by default colored prompt is disabled
    colored_enabled = True
    #user should provide these values 
    user_input_args = ['prompt','passing_response','abort_response','timeout']

    #these are the default values
    default_prompt = "Do you want to continue with the playbook execution?"
    default_passing_response = ['y','Y','yes','YES']
    default_abort_response = ['n','N','no','NO']
    default_timeout = 300
        
    BYPASS_HOST_LOOP = True

    def run(self, tmp=None, task_vars=None):
        if task_vars is None:
            task_vars = dict()

        for arg in self._task.args:
            if arg not in self.user_input_args:
                return {"failed": True, "msg": f"{arg} is not a valid option in user_prompt"}
        
        #if the user has not provided prompt, fallback to default prompt
        if self._task.args.get('prompt') == None:
            prompt = self.default_prompt
        else:
            prompt = self._task.args.get('prompt')
     
        # if the user has not provided the passing responses, fallback to default passing responses
        if self._task.args.get('passing_response') == None:
            passing_response = self.default_passing_response
        else:
            # the input for passing_response must be a list
            if not isinstance(self._task.args.get('passing_response'),  list):
                return {"failed": True, "msg": f"passing_response must be a list(passed value if of type {str(type(self._task.args.get('passing_response')))} )"}
            # there must be at least one element in the list
            if len(self._task.args.get('passing_response')) <= 0:
                return {"failed": True, "msg": f"passing_response must be a list with at least on element."}

            passing_response = self._task.args.get('passing_response')

        # if the user has not provided the abort responses, fallback to default abort responses
        if self._task.args.get('abort_response') == None:
            abort_response = self.default_abort_response
        else:
            # the input for abort_response must be a list
            if not isinstance(self._task.args.get('abort_response'),  list):
                return {"failed": True, "msg": f"abort_response must be a list(passed value if of type {str(type(self._task.args.get('abort_response')))} )"}
            # there must be at least one element in the list
            if len(self._task.args.get('abort_response')) <= 0:
                return {"failed": True, "msg": f"abort_response must be a list with at least on element."}

            abort_response = self._task.args.get('abort_response')

        # if the user has not provided the timeout, fallback to default timeout
        if self._task.args.get('timeout') == None:
            timeout = self.default_timeout
        else:
           #ensure that the value of timeout is an integer.
           try:
               timeout = self._task.args.get('timeout')
               int(timeout)
           except ValueError:
               return {"failed": True, "msg": f"The provided timeout value must be an integer, user provided {self._task.args.get('timeout')}"}

        result = super(ActionModule, self).run(tmp, task_vars)
        #set the default values
        result.update(
            dict(
                changed=False,
                failed=False,
                msg='',
                skipped=False
            )
        )        

        #this is done to prevent EOF error while reading from stdin
        sys.stdin = open("/dev/tty")
        signal.signal(signal.SIGALRM, _sig_alarm)
        try:
            signal.alarm(timeout)
            while True:
                ANSIBLE_FORCE_COLOR=True
                if self.colored_enabled:
                    user_response = input(f'{self.dark_green}{prompt} {passing_response} {abort_response}\r\n[Enter your response]:{self.reset_colour}')
                else:
                    user_response = input(f'{prompt} {passing_response} {abort_response}\r\n[Enter your response]:')
                if user_response in passing_response:

                    return {"failed": False, "msg": f"Prompt response passed"}
                    
                elif user_response in abort_response:
                    return {"failed": True, "msg": "User selected to abort."}
                else:
                    if self.colored_enabled:
                        print(f'{self.pure_red}Invalid response!, expecting one from {str(passing_response)} or {str(abort_response)}{self.reset_colour}')
                    else:
                        print(f'Invalid response!, expecting one from {str(passing_response)} or {str(abort_response)}')

        except TimeoutError:
            return {"failed": True, "msg": f"TimeoutError happened waiting for user response, waited {timeout} seconds"}
            pass

action plugin setup


Our action plugin is written in a simple python program in this example. In order to use the action plugins, you need the following:

  • A directory called “action_plugins” at the same level as your playbook.
  • A directory called “library” at the same level as your playbook.
  • All the business logic of the action plugin is present in the action_plugins/user_prompt.py file.
  • Every action plugin should have a module file with the same name in the directory called “library.”
  • The file in the library directory could be empty or only have comments. We do this to use an action plugin like a pre-existing ansible module.


For example: If my playbook is example-1.yml, then action_plugins and library directory at the same level.

tree
.
├── action_plugins
│   └── user_prompt.py  <-----+      
├── example-1.yml             |
├── example-2.yml             |
├── example-3.yml             |
├── example-4.yml             |----->These two files have same file name, but different content
├── example-5.yml             |
├── library                   |
│   └── user_prompt.py  <-----+
└── var_prompt.yml

2 directories, 8 files


#OR

ls -lrt 
total 32
-rw-rw-r-- 1 technekey technekey  505 Jul 27 18:12 example-4.yml
-rw-rw-r-- 1 technekey technekey  597 Jul 27 18:12 example-3.yml
-rw-rw-r-- 1 technekey technekey  488 Jul 27 18:13 example-2.yml
-rw-rw-r-- 1 technekey technekey  477 Jul 27 18:16 example-5.yml
drwxrwxr-x 2 technekey technekey 4096 Jul 28 11:11 library
-rw-rw-r-- 1 technekey technekey  323 Jul 28 12:33 example-1.yml
-rw-rw-r-- 1 technekey technekey 1123 Jul 28 13:17 var_prompt.yml
drwxrwxr-x 2 technekey technekey 4096 Jul 28 13:30 action_plugins

Action plugin usage

Here is a few examples of use cases of the action plugin.

Example-1: The most straightforward usage
---
- name: A sample playbook showing user prompt
  hosts: localhost
  gather_facts: False
  tasks:
  - name: "Prompt for user response"
    user_prompt:

  - name: "Task next to the prompt"
    debug:
      msg: "Hello, user agrees to run me!"
ansible-playbook example-1.yml 

PLAY [A sample playbook showing user prompt] ****************************************************************************************************************************************************************************************

TASK [Prompt for user response] *****************************************************************************************************************************************************************************************************
Do you want to continue with the playbook execution? ['y', 'Y', 'yes', 'YES'] ['n', 'N', 'no', 'NO']
[Enter your response]:yes
ok: [localhost]

TASK [Task next to the prompt] ******************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "msg": "Hello, user agrees to run me!"
}

PLAY RECAP **************************************************************************************************************************************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Example-2: Using custom values for allowed values


In this example, we are supplying a custom prompt, timeout, custom values to pass the prompt, and custom values to fail the user prompt.

---
- name: A sample playbook showing custom input configured for prompt string and allowing/failing the prompt
  hosts: localhost
  gather_facts: False
  tasks: 
  - name: "Prompt for user response"
    user_prompt:
      prompt: "Do you want to continue with the playbook execution??? This once more...... ?(y/n)?"
      passing_response: ['y','yes']
      abort_response: ['n','no']
      timeout: 300

  - name: "Task next to the prompt"
    debug:
      msg: "Hello, user agrees to run me!"
ansible-playbook example-2.yml 

PLAY [A sample playbook showing custom input configured for prompt string and allowing/failing the prompt] **************************************************************************************************************************

TASK [Prompt for user response] *****************************************************************************************************************************************************************************************************
Do you want to continue with the playbook execution??? This once more...... ?(y/n)? ['y', 'yes'] ['n', 'no']
[Enter your response]:no
fatal: [localhost]: FAILED! => {"changed": false, "msg": "User selected to abort."}

NO MORE HOSTS LEFT ******************************************************************************************************************************************************************************************************************

PLAY RECAP **************************************************************************************************************************************************************************************************************************
localhost                  : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0   

Example-3: Showing timeout(default is 300 sec)
ansible-playbook  example-2.yml 
PLAY [A sample playbook showing custom input configured for prompt string and allowing/failing the prompt] **************************************************************************************************************************

TASK [Prompt for user response] *****************************************************************************************************************************************************************************************************
Do you want to continue with the playbook execution??? This once more...... ?(y/n)? ['y', 'yes'] ['n', 'no']
[Enter your response]:fatal: [localhost]: FAILED! => {"changed": false, "msg": "TimeoutError happened waiting for user response, waited 10 seconds"}

NO MORE HOSTS LEFT ******************************************************************************************************************************************************************************************************************

PLAY RECAP **************************************************************************************************************************************************************************************************************************
localhost                  : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0   


Example-4: Using variables in the prompt
---
- name: A sample playbook showing that prompt displaying the variable values
  hosts: localhost
  gather_facts: False
  tasks: 
  - name: "Prompt for user response"
    user_prompt:
      prompt: "Do you want to continue with {{action}} of {{user}} user?(y/n)?"
      passing_response: ['y','yes']
      abort_response: ['n','no']
    vars:
      action: "deletion"
      user: "foobar"

  - name: "Task next to the prompt"
    debug:
      msg: "DELETING THE USER OH!!"
ansible-playbook  example-5.yml 

PLAY [A sample playbook showing that prompt displaying the variable values] *********************************************************************************************************************************************************

TASK [Prompt for user response] *****************************************************************************************************************************************************************************************************
Do you want to continue with deletion of foobar user?(y/n)? ['y', 'yes'] ['n', 'no']
[Enter your response]:n
fatal: [localhost]: FAILED! => {"changed": false, "msg": "User selected to abort."}

NO MORE HOSTS LEFT ******************************************************************************************************************************************************************************************************************

PLAY RECAP **************************************************************************************************************************************************************************************************************************
localhost                  : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0   

Summary:


var_prompt is excellent, but sometimes people need more control over the prompt. For such occasions, this action plugin could help you. If you think there is a bug or scope for improvement, feel free to reach me.

Leave a Comment

Your email address will not be published.

Scroll to Top