Saturday, February 20, 2021

Dirty Ansible: Creative abuse of vars_prompt, for user-friendly playbooks without Tower

 This is part of a (maybe ongoing) series of dirty tips and tricks with Ansible. Is this stuff sanctioned, best-practice or <gasp> an anti-pattern? Considering how gnarly Ansible's trifecta of Jinja/YAML/Python can be, I'm going to answer Mu to that question. In the meantime, we have deliverables to make!

Ansible's CLI wizard: vars_prompt

If you use Tower/AWX and you want your non-techie users to pass arguments into your playbooks, then Surveys are the go-to option. For the rest of us plebs using command-line Ansible, we have vars_prompt to lean on. Simply put, vars_prompt gives you interactive "wizard-style" playbooks, by asking the user to fill in certain values when the playbook begins. Otherwise, a prompt variable behaves the same as any other that you would find in the  vars: section of a playbook.


---
- hosts: localhost
  connection: local

  vars_prompt:
    - name: user
      prompt: Gimme a username, or Enter for default
      private: no
      default: derpy
    - name: pass
      prompt: Gimme a password, we won't show it on console. We promise!
      private: yes

  tasks:
    - name: Print the variables that user provided
      debug:
        msg: "User is {{ user }}, password is {{ pass }}. Oops..."

Above is the boring, "hello world" use-case for vars_prompt. But we can go deeper, and indeed we must- because Pat from Accounting just switched to a Developer role, and they are already requesting SSH access to our Ansible server. They also just asked us "What is a Linux?" Fun.

There are some quirks and nuances to vars_prompt, which are not well-documented:

  1. You CANNOT use variables from host_vars/ or group_vars/ in a prompt entry, because those don't get pulled in until later in the play. However....
  2. You CAN use Special variables in your prompt dialogs, as well as any variables defined locally in the vars: section of your playbook.
  3.  You CAN use a prompt variable in other top-level areas of the playbook, such as the hosts: directive. We will be abusing this in a minute.
  4. If you set a prompt variable at runtime using --extra-vars, the playbook won't bother prompting you for input. If you like your automation, you can keep your automation!
  5. For lengthy prompts that explain a lot of stuff to the user, you can do one of two things: either quote the prompt as one long string and use \n sequences for line breaks, or else carefully use the pipe symbol ( | ) to break the dialog into multiple lines per the YAML spec. You can do one or the other, but not both.

Keep quirks #2 and #3 in mind, as we are going to exploit them in some useful ways.

You want to run this where? Prompt the user for hosts to run against

Every new Ansible user has had at least one minor heart attack from running a playbook with hosts: all against the wrong hosts. It's an easy mistake to make, especially with command-line Ansible... so let's use quirks #2 and #3 to set up some guardrails.


---
# Wait... we could do this all along?

- hosts: "{{ target }}"

  vars_prompt:

    - name: target
      prompt: |
        Welcome to the server extermination playbook.
        Here are the hosts available to exterminate:
        {{ groups | dict2items | json_query('[?key!=`all` && key!=`ungrouped`]') | items2dict | to_nice_yaml }}
        Please provide host(s) or group(s) to exterminate comma-separated,
        or hit Enter for default
      private: no
      default: dev
  
  tasks:

  - name: Dear god what have you just done
    debug:
      msg: "Time to nuke some servers LET'S GOOOOOOO!"

What sorcery is this?

  • The vars: and vars_prompt: sections are evaluated before hosts:, allowing you to prompt the user for which hosts to run against. The usual patterns for targeting hosts and groups still apply.
  • We reference a special variable called groups, which is a dictionary of all of the groups and hosts in your provided inventory. Note that the groups hash always contains two default groups inside, called 'all' and 'ungrouped'. We filter them out with a json_query statement, leaving us with a clean list of our defined groups and their hosts. If your inventory file has no groups or you just want a list of hosts, use {{ groups['all'] }} instead.
  • If the json_query filter scares you, you can also use a combo of dict2items | rejectattr | items | items2dict to filter the groups hash. I'll let you decide which is prettier.

The result is a helpful print-out of the hosts available to run the play against:


[root@rhel8 playbooks]# ansible-playbook -i hosts host_wiper.yml

Welcome to the server extermination playbook.
Here are the hosts available to exterminate:
dev:
- host1
- host2
prod:
- host5
- host6
test:
- host3
- host4

Please provide host(s) or group(s) to exterminate comma-separated,
or hit Enter for default [dev]: prod


PLAY [localhost] ***************************************************************************

TASK [Gathering Facts] *********************************************************************
ok: [localhost]

TASK [Dear god what have you just done] ****************************************************
ok: [localhost] => {}

MSG:

Time to nuke some servers LET'S GOOOOOOO!
...

     Bonus: Set up bash aliases and profiles for easy playbook execution

    Maybe our new developer Pat isn't so good at this command-line stuff, and their question of "How do I Linux?" has you worried. Let's create a path of least resistance, so that all they need to do is type a simple word to kick off their playbook. We can do this with an old-fashioned combination of sudoers, bash aliases, and bash profiles.

     In our /etc/sudoers file, we add an entry to let Pat run our playbook with sudo permissions- but only against a specific set of hosts. No, we aren't letting Pat run anything with Ansible as root. We're dirty here, but we aren't insane.

    [root@rhel8 ~]# cat /etc/sudoers
    
    pat localhost=/usr/bin/ansible -i /etc/ansible/inventory/pat_hosts /etc/ansible/playbooks/host_wiper.yml
    

    In Pat's home folder, we create a bash alias for the exact same command in her .bash_profile or .bashrc, while also leaving a helpful note for how to invoke it:

    
    [root@rhel8 ~]# cat /home/pat/.bash_profile
    
    alias dothething="sudo /usr/bin/ansible -i /etc/ansible/inventory/pat_hosts /etc/ansible/playbooks/host_wiper.yml"
    
    # Give Pat some guidance on how to Do the Thing!
    echo -e "\nWelcome, Pat!"
    echo -e "As requested, we have created a playbook that lets you wipe your own servers."
    echo -e "To start it off, just type the below word:"
    echo -e "dothething"
    echo -e "Don't do anything we wouldn't do! Call us if you have any questions.\n"
    

    Now when Pat logs in with Putty, they can follow the happy path to running their "maintenance" playbook. You can verify the alias is present, by logging in / switching to their user and typing the alias command.

    
    [root@rhel8 ~]# su - pat
    
    Welcome, Pat!
    As requested, we have created an Ansible playbook that lets you wipe your own servers.
    To start it off, just type the below word:
      dothething
    Don't do anything we wouldn't do! Call us if you have any questions.
    
    [pat@rhel8 ~]# alias -p | grep dothething
    alias dothething='sudo /usr/bin/ansible -i /etc/ansible/inventory/pat_hosts /etc/ansible/playbooks/host_wiper.yml'
    
    [pat@rhel8 ~]# dothething
    Please provide a password: Welcome to the server extermination playbook. Here are the hosts available to exterminate: dev: - host1 - host2 prod: - host5 - host6 test: - host3 - host4 Please provide host(s) or group(s) to exterminate comma-separated, or hit Enter for default [dev]: host1,host2,prod To be continued...

    I think that's enough damage for now. While getting your hands dirty, try to keep your nose clean!