2018-04-25

Ansible Week - Part 4

Ansible is powerful, so let's put together a real-world example of how to use it for installing some software.

We have recently been blessed with a new crypto library aimed at maintaining strong security that doesn't depend on the difficulty of factoring composite numbers.

It's so new, there isn't a package for it, and its installation steps are quirky, to say the least. It boils down to:

  1. Install some pre-requisite tools (OpenSSL, GMP, Python3, gcc)
  2. Create a new user, libpqcrypto
  3. Fetch the software as that user
  4. Create some symlinks
  5. Compile the library

Ansible was made to handle all of this, and to demonstrate the power of Ansible roles in our playbook, we're going to split the prerequisite installation steps out into their own separate pieces, or "roles". Roles are useful for when we want a set of commands we can use and reuse to set up a build environment, even if we end up not using that environment to build this exact libpqcrypto library again in the future.

By putting your work into roles, Ansible allows you to group your tasks into distinct phases and sort them based on your defined dependencies between them. In other words, if you have a role called "fasten-seatbelt", you can define other roles as unique dependencies for it, maybe ones called "sit-in-seat", "have-keys-in-hand", and "buy-a-car". Each of these roles are generalizable into other things, so if you ever write "ride-rollercoaster", you can reuse the tasks of "sit-in-seat", though perhaps without the car-buying dependency.

Maybe we should just build the role and show you.

First, we set up a target host. We've done this before, but for this exact example we're going to install our OS (a Debian-based Linux in this case), patch it to current, add our ansible user, and create our OpenSSH key:

sudo apt install -y openssh-server
sudo groupadd ansible
sudo useradd -g ansible -m ansible
ssh-keygten -t ed25519 -N '' -q -f ~/.ssh/id_ed25519
cd .ssh
cp -p ./id_ed25519.pub ./authorized_keys.new
chmod 0600 ./authorized_keys.new
mv ./authorized_keys.new ./authorized_keys
exit
sudo visudo
#add line:
# ansible ALL=(ALL:ALL) NOPASSWD: SETENV: ALL

Sync this key, ~ansible/.ssh/id_ed25519 to your ansible host and build your inventory file:

[libpqcrypto]
10.0.0.4

[libpqcrypto:vars]
ansible_become_method=sudo
ansible_ssh_user=ansible
ansible_ssh_port=22
ansible_ssh_private_key_file=/path/to/libpqcrypto/id_ed25519

We run ansible -i ./hosts.libpqcrypto -m ping libpqcrypto and our ping got ponged, so we can write our first role. It's a .YML file outlining which packages we want to install before we go about doing anything else with our machine:

---
- name: install compiler and libpqcrypto pre-reqs (apt)
  become: yes
  apt:
  args:
    name: "{{item}}"
    state: present
    cache_valid_time: 86400
    update_cache: yes
  with_items:
    - build-essential
    - gcc
    - libssl-dev
    - libgmp-dev
    - make
    - python3
  when: ansible_pkg_mgr == "apt"

This is just a normal Ansible playbook. We turn it into a role by putting it in an exact location on our Ansible machine: ./roles/libpqcrypto-prereqs/tasks/main.yml.

This creates a role called "libpqcrypto-prereqs" we can reference in our role that will fetch the libpqcrypto source, configure the host to create a user, make some symbolic links, and compile the code as per the instructions on the web site. Let's make another role to do these steps. If our previous role has run successfully, we know we have our compiler and dev libraries on the target host and can just do the other steps. So we make a role, "libpqcrypto-build", and put this into "./roles/libpqcrypto-build/tasks/main.yml":

---
- name: create group
  become: yes
  group:
  args:
    name: libpqcrypto
    state: present

- name: create user
  become: yes
  user:
  args:
    name: libpqcrypto
    createhome: yes
    group: libpqcrypto
    home: /home/libpqcrypto
    shell: /bin/false
    state: present

- name: fetch latest version string
  become: yes
  become_user: libpqcrypto
  get_url:
  args:
    url: https://libpqcrypto.org/libpqcrypto-latest-version.txt
    dest: /home/libpqcrypto/libpqcrypto-latest-version.txt
    validate_certs: false # ouch

- name: read latest version string
  shell: cat /home/libpqcrypto/libpqcrypto-latest-version.txt
  register: version

- name: stat libpqcrypto file
  stat:
  args:
    path: libpqcrypto-{{version.stdout}}.tar.gz
  register: st

- name: fetch libpqcrypto
  become: yes
  become_user: libpqcrypto
  get_url:
  args:
    url: https://libpqcrypto.org/libpqcrypto-{{version.stdout}}.tar.gz
    dest: /home/libpqcrypto/libpqcrypto-{{version.stdout}}.tar.gz
    validate_certs: false
  when: st.stat.exists == False

# never use unarchive
- name: untar libpqcrypto
  become: yes
  become_user: libpqcrypto
  shell: tar -xzf /home/libpqcrypto/libpqcrypto-{{version.stdout}}.tar.gz
  args:
    chdir: /home/libpqcrypto/
    creates: /home/libpqcrypto/libpqcrypto-{{version.stdout}}/

- name: create symlinks
  become: yes
  become_user: libpqcrypto
  file:
  args:
    src: /home/libpqcrypto
    dest: /home/libpqcrypto/libpqcrypto-{{version.stdout}}/{{item}}
    owner: libpqcrypto
    group: libpqcrypto
    force: yes
    state: link
  with_items:
    - link-build
    - link-install

- name: remove clang compiler option
  become: yes
  become_user: libpqcrypto
  lineinfile:
  args:
    path: /home/libpqcrypto/libpqcrypto-{{version.stdout}}/compilers/c
    regexp: "^clang.*"
    state: absent
  
- name: timestamp
  shell: date
  register: timestamp

- name: start compile libpqcrypto
  debug:
  args:
    msg: "{{timestamp.stdout}}"

- name: compile libpqcrypto
  become: yes
  become_user: libpqcrypto
  shell: ./do
  args:
    chdir: /home/libpqcrypto/libpqcrypto-{{version.stdout}}

- name: timestamp
  shell: date
  register: timestamp

- name: end compile libpqcrypto
  debug:
  args:
    msg: "{{timestamp.stdout}}"

There's a lot going on here, but you can pretty much tease out what each of these steps is doing to your target host. Many Ansible tasks have an argument called "state" that can be either "present" or "absent". The task doesn't necessarily perform the work if it's already been done, so what we're really setting up is a "configuration outlining the desired state of the system" or a "desired state configuration" for short. This is a term I just now invented all by myself. You're welcome.

We ensure there's a group called "libpqcrypto" and a user in that group with the same name. We fetch the libpqcrypto version string and, optionally, fetch that particular version of the software package if that tarball doesn't exist on the target host. We check the existence of that file with the "stat" module and use a "when:" conditional to tell Ansible to run that task only if it needs to satisfy the condition.

Then we create some symlinks with the "file" module, and then we go off script for a second to make a one-line change to the C compilers setting to remove the clang line. This can be skipped if the host has clang installed. We could tailor this task with a "when:" conditional, either checking for "/usr/bin/clang" on the machine, or by comparing the task against what Ansible determines the machine's OS to be. You can pull the list of values that Ansible checks by running the "setup" module: "ansible -i ./hosts.file -m setup hosts-group-name".

We print the date by creating a "register" called "timestamp" populated with the output of the date command. We do this again when the compile task runs and that gives us a notice of how for long the task in between ran.

Finally, (except for that last timestamp task) we compile the libpqcrypto software by running ./do (with the "shell" module), as the libpqcrypto user (with become_user), in a specific directory (identified with the "chdir" argument).

Great! But how do we actually run this role? We need to point out that this role has a dependency on installing the pre-requisites, so we list them in "./roles/libpqcrypto-build/meta/main.yml":

---
dependencies:
  - { role: libpqcrypto-prereqs }

Then we put put our "libpqcrypto-build" role, complete with its listed pre-req role(s), into a new playbook file, libpqcrypto.yml:

---
- hosts: libpqcrypto
  roles:
    - { role: libpqcrypto-build }
  tasks:
    - group_by:
      args:
        key: "{{ansible_distribution}}"

Note that we never call the libpqcrypto-prereqs role directly. We call one role in the "roles:" section and with its dependency file in .../meta/main.yml Ansible figures out what to do and in which order to do it.

Naturally you can make this a fairly complicated web of dependencies: "car" requires "tires", "tires" requires "hubcap", and so on as I explained earlier. I haven't seen Ansible have a problem with sorting a dependency chain so long as it can all eventually be collapsed into a linear sequence per host.

Note also that we execute our tasks by groups of their distributions. I started doing this in order to avoid having to create specific conditionals for various target hosts:

- hosts: webservers
  roles:
     - { role: debian_stock_config, when: ansible_os_family == 'Debian' }

We can set up different roles on different machines based on how we know they'll need to execute our playbook tasks. If all your machines are homogenous, you can skip grouping your tasks, since you won't have variants. "group_by" is much more powerful than this, since you can use it to create groups on the fly by adjusting the value of "key".

No comments: