Home CentOS Ansible: Quick Start Guide for FreeBSD, CentOS and Ubuntu

Ansible: Quick Start Guide for FreeBSD, CentOS and Ubuntu

by Kliment Andreev
5.8K views

In this post/howto, I’ll explain how to install Ansible as control and managed node on FreeBSD 12, CentOS 8 and Ubuntu 18. Then, I’ll explain how to create SSH keys so the nodes can communicate and some basic tasks. Then, I’ll show you how to create a playbook to install the latest updates and also install an Apache server with the default settings. Finally, I’ll show an example of how to use variables.

Control and managed nodes

A control node is where you execute all of your Ansible commands and eventually keep your playbooks, configs, inventory etc. It’s pretty much your workstation. A managed node is where the actual playbooks are executed. These are the main concepts and the terminology.
In my case, I have 4 VMs/instances. The main one which is the control node and 3 managed nodes.

CentOS 8

If you want to install Ansible on the control node, you have to install Python 3.x first.

sudo dnf install python3

This will also install pip. Type python3 to test, CTRL-D to exit and then install ansible.

sudo pip3 install ansible

Type ansible to test.

Ubuntu

Ubuntu comes with python installed, but not with pip. Install pip with:

sudo apt install python3-pip

Then install ansible with:

sudo pip3 install ansible

Type ansible to test.

FreeBSD

FreeBSD comes with python installed but pip is not. Check the version and then install the same pip version.

ls -l /usr/local/bin/python*

If your output is for example python37, install the same pip version.

pkg install py37-pip

Then install ansible.

pip install ansible

Type ansible to test.

SSH keys

While Ansible can use standard *nix username/password authentication, it’s recommended that you use SSH keys to communicate from control node to the managed nodes. For that, you’ll have to create your SSH keys. Let’s say you have an account on your control node and the username is admin. You also want to use the user ansible on the managed nodes. It really doesn’t matter what usernames you are going to choose. You can always override the keys to use, but in this case, I’ll create a key on the control node and send it to all managed nodes.
On the control node, regardless of your OS, do:

ssh-keygen -b 4096

This will create a subfolder .ssh with two files: id_rsa and id_rsa.pub. The former is your private key and the later is your public key.
Copy the key to your managed nodes.

ssh-copy-id [email protected]

This will copy my key for the user admin to the node X under the ansible user. Then test the passwordless connection.

ssh [email protected]

Mind that the use of FQDN (nodex.andreev.local) vs. hostname (nodex) is important. For SSH these two are different. Once you log to the managed node, the node will be added to the list of known hosts in the file .ssh/known_hosts.

Inventory and the config file

Ansible uses the inventory files to execute an action against using the options and parameters specified in the config file. The config file is /etc/ansible/ansible.cfg for CentOS and Ubuntu and /usr/local/etc/ansible/ansible.cfg for FreeBSD. You can also put the inventory file in the same directory and name it as you wish, but you have to specify the inventory as a parameter on the command line or an entry in the config file. In addition, you can have your config file in your current directory or under the .ansible directory in your home folder. ansible.cfg in the current directory has a precedence over .ansible.cfg in the home directory which has a precedence over /etc/ansible/ansible.cfg. Here is how that looks.
Let’s list all the inventory.

ansible --list-hosts all

You’ll get a message that there is no inventory file. Let’s create one, we’ll name it inventory.txt.

[freebsd]
node1.andreev.local
[centos]
node2.andreev.local
[ubuntu]
node3.andreev.local
[bsd]
node1.andreev.local
[linux]
node2.andreev.local
node3.andreev.local

If we specify the inventory file, we’ll get this.

ansible --list-hosts all -i inventory.txt
  hosts (3):
    node1.andreev.local
    node2.andreev.local
    node3.andreev.local

If we create a config file, we can tell ansible where to look for the inventory. Create a file ansible.cfg in the same directory.

[defaults]
inventory=/home/<somewhere>/inventory.txt

If you do ansible –list-hosts all now, you’ll get the same result as before, but without specifying the inventory file.
Or something like this.

ansible all -m shell -a "uname -a"
node1.andreev.local | CHANGED | rc=0 >>
FreeBSD node1.andreev.local 12.1-RELEASE FreeBSD 12.1-RELEASE r354233 GENERIC  amd64
node2.andreev.local | CHANGED | rc=0 >>
Linux node2.andreev.local 4.18.0-193.14.2.el8_2.x86_64 #1 SMP Sun Jul 26 03:54:29 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
node3.andreev.local | CHANGED | rc=0 >>
Linux node3.andreev.local 4.15.0-129-generic #132-Ubuntu SMP Thu Dec 10 14:02:26 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

By default, ansible runs on the managed node with the currently logged user that executes the playbook on the control node. If you get an error saying that the previous command cannot connect to the host, you have to specify the same user that you used when you test the connection with ssh -user-@managednode. So, edit ansible.cfg and add this line.

remote_user=<user>

We can target only the group linux which consists of Linux hosts only in the inventory file.

ansible linux -m shell -a "date"
node3.andreev.local | CHANGED | rc=0 >>
Fri Jan  8 15:23:40 UTC 2021
node2.andreev.local | CHANGED | rc=0 >>
Fri Jan  8 10:23:40 EST 2021

Playbooks

The playbooks are the blueprints of the automation tasks. Instead of running the ansible command to execute each task separately, we combine these tasks in a YAML file and execute them sequentially. Here are 3 playbooks that update each of our managed nodes. There is no update module for FreeBSD, so we use the shell command.

# freebsd-update.yml
---
  - hosts: freebsd
    become: yes
    tasks:
      - name: Fetch all packages
        shell: freebsd-update fetch
      - name: Install FreeBSD updates
        shell: freebsd-update install
      - name: Reboot
        reboot:

For CentOS we’ll use yum.

# centos-update.yml
---
  - hosts: centos
    become: yes
    tasks:
      - name: Update all packages
        yum: name=* state=latest
      - name: Reboot
        reboot:

…and for Ubuntu we’ll use apt.

# ubuntu-update.yml
---
  - hosts: ubuntu
    become: yes
    tasks:
      - name: Update all packages
        apt: name=* state=latest
      - name: Reboot
        reboot:

Save these files with a YAML extension and you can execute them with the following command.

ansible-playbook <filename>

All of them will probably fail. That’s because your ansible user on the managed nodes is required a password when executing a sudo command. In order to fix that, you’ll have to add a line in the sudoers file. Edit this file using the visudo command.

visudo

…and then add this line right before the @includedir <...> which is the last line in the file.

ansible ALL=(ALL) NOPASSWD:ALL

Where ansible is the user that runs the playbooks on the managed nodes. FreeBSD doesn’t come with sudo preinstalled, so you’ll have to install it first on the managed node.

pkg install sudo

These playbooks will update the OS and the packages for the Linux. For FreeBSD, it will update only the OS. Here is another example of playbooks that will install Apache server in a default configuration and change the ServerName and ServerAdmin lines. We’ll also install PHP and test our server.
If you have a firewall enabled, make sure you open it up first on CentOS. Ubuntu and FreeBSD do not come with the firewall enabled.

firewall-cmd --zone=public --permanent --add-service=http
firewall-cmd --reload

For FreeBSD, the playbook looks like this.

# freebsd-apache.yml
---
  - hosts: freebsd
    become: yes
    tasks:
      - name: Install apache and php
        pkgng:
          name:
            - apache24
            - php74
            - mod_php74
          state:  present
      - name: Start on reboot
        service: name=apache24 enabled=yes
      - name: Copy index.php
        copy:
          src: ../files/index.php
          dest: /usr/local/www/apache24/data
          mode: 0755
      - name: Copy mod_php.conf
        copy:
          src: ../files/mod_php.conf
          dest: /usr/local/etc/apache24/modules.d
          mode: 0755
      - name: Start apache now
        service: name=apache24 state=started

For CentOS, it looks like this.

# centos-apache.yml
---
  - hosts: centos
    become: yes
    tasks:
      - name: Install apache and php
        yum:
          name:
            - httpd
            - php
          state:  present
      - name: Start apache now and on reboot
        service: name=httpd state=started enabled=yes
      - name: Copy index.php
        copy:
          src: ../files/index.php
          dest: /var/www/html
          mode: 0755

…and for Ubuntu it looks like this.

# ubuntu-apache.yml
---
  - hosts: ubuntu
    become: yes
    tasks:
      - name: Install apache and php
        apt:
          name:
            - apache2
            - php
          state:  present
      - name: Start apache now and on reboot
        service: name=apache2 state=started enabled=yes
      - name: Copy index.php
        copy:
          src: ../files/index.php
          dest: /var/www/html
          mode: 0755

You will also need these two files in a directory called files. In my case it’s one level above the directory where I keep my playbooks.
index.php

<?php
  phpinfo();
?>

001_mod-php.conf

<IfModule dir_module>
    DirectoryIndex index.php index.html
    <FilesMatch "\.php$">
        SetHandler application/x-httpd-php
    </FilesMatch>
    <FilesMatch "\.phps$">
        SetHandler application/x-httpd-php-source
    </FilesMatch>
</IfModule>

The index.php file is the standard test file to test the PHP distributions and the 001_mod-php.conf is needed for FreeBSD only. As you can see the playbooks differ quite a bit for these three OSes. Once you deploy the playbooks, you can test the result by going to http://[nodeX]/index.php.
Looks like this.

Service handlers

Service handlers are used only when a change is made on the managed node. For example, we can restart a service only if the config file was changed. If the file is not changed, then there is no need to restart. Here is an example of a service handler. We’ll restart postfix service only if the main.cf file was changed.

# service-handler.yml
---
- hosts: centos
  become: yes
  tasks:
  - name: Configure main.cf
    lineinfile:
      path: /etc/postfix/main.cf
      regexp: ^#mydomain
      line: 'mydomain = example.com'
    notify: restart postfix

  handlers:
  - name: restart postfix
    service: name=postfix state=restarted

Make sure that the name for the handler is the same (lines 11 and 14), so Ansible knows what service handlers is referred.

Variables

When ansible runs a playbook on a managed node, the first task is to gather info about the managed node. The info is a bunch of settings that we can use in our playbooks. For example, if you execute the following command, you can see the IP address, the CPU model, python version etc.

ansible -m setup <node>

We can use these settings and use them as variables if we need them. For example, this playbook displays the hostname and the IP.

# showip.yml
---
  - hosts: freebsd
    become: yes
    tasks:
      - name: Show the IP address
        debug:
          msg: "The hostname is {{ inventory_hostname}}  and the IP is {{ansible_default_ipv4.address }}"

And if you run the playbook, you’ll see something like this.

TASK [Show the IP address] *********************************************************************************************
ok: [node1.andreev.local] => {
    "msg": "The hostname is node1.andreev.local  and the IP is 192.168.1.211"
}

Here is another example of using variables. In this case, we’ll specify a file and change the ownership and the mode.

# owner.yml
---
  - hosts: centos
    become: yes
    vars:
      filename: "/var/www/html/index.php"
    tasks:
      - name: Change the owner of the file
        file:
          path: "{{ filename }}"
          owner: apache
          group: apache
          mode: '0755'

In case we want to assign an output to a variable, we’ll have to use the keyword register. Here is an example of how to get the output from a command and print it on the screen with the keyword debug.

# variables.yml
---
  - hosts: freebsd

    tasks:
    - name: Get the uptime manually
      command: uptime
      register: var_uptime

    - name: Print the uptime
      debug:
        msg: The uptime is "{{ var_uptime }}"

Roles

Roles let you automatically load related vars_files, tasks, handlers, and other Ansible artifacts based on a known file structure. Once you group your content in roles, you can easily reuse them and share them with other users. The idea is to separate the tasks, handlers and vars in different files. Let’s see this playbook for example. It changes a line in main.cf file, restarts postfix and copies a file under the postfix main directory.

# roles.yml
---
  - hosts: centos
    become: yes

    vars:
      filevd: "/etc/postfix/virtual_domains"
      cfgpostfix: "/etc/postfix/main.cf"


    tasks:
    - name: Configure main.cf
      lineinfile:
        path: "{{ cfgpostfix }}"
        regexp: ^#mydomain
        line: 'mydomain = example.com'
      notify: restart postfix
    - name: Copy virtual_domains
      copy:
        src: ../files/virtual_domains
        dest: "{{ filevd }}"
        mode: 0755

    handlers:
    - name: restart postfix
      service: name=postfix state=restarted

We can rewrite this file by separating the tasks, variables, files and handlers. Run this command.

ansible-galaxy role init postfix

If you look at the file/directory structure of the newly created directory postfix, it looks like this.

tree postfix
postfix
├── defaults
│   └── main.yml
├── files
├── handlers
│   └── main.yml
├── meta
│   └── main.yml
├── README.md
├── tasks
│   └── main.yml
├── templates
├── tests
│   ├── inventory
│   └── test.yml
└── vars
    └── main.yml

Create a file virtual_domains under the files directory.

echo "mydomain.com" > postfix/files/virtual_domains

Move the config for vars, handlers and tasks in the separate main.yml files. For example, this is how my files look like.
postfix/vars/main.yml

---
# vars file for postfix

  filevd: "/etc/postfix/virtual_domains"
  cfgpostfix: "/etc/postfix/main.cf"

As you can see the keyword vars: does not exists. Ansible knows that this file is for vars so there is no need to enter the vars keyboard.
postfix/tasks/main.yml

---
# tasks file for postfix

  - name: Configure main.cf
    lineinfile:
      path: "{{ cfgpostfix }}"
      regexp: ^#mydomain
      line: 'mydomain = example.com'
    notify: restart postfix
  - name: Copy virtual_domains
    copy:
      src: ../files/virtual_domains
      dest: "{{ filevd }}"
      mode: 0755

postfix/handlers/main.yml

---
# handlers file for postfix

  - name: restart postfix
    service: name=postfix state=restarted

Finally, create a file called something.yml that will be your main file. This file has to be outside the postfix directory structure.
In my case it looks like this.
something.yml

# something.yml
---
  - hosts: centos
    become: yes
    roles:
      - postfix

Now, if you execute this playbook, ansible will automatically execute the rest of the dependant playbooks as well.

ansible-playbook something.yml

Error handling

Sometimes we want certain changes to be ignored. Sometimes, we know the behavior of certain commands and we know that they might return non-zero code and we want that ignored. For example, consider this part of a playbook.

- hosts: centos
  tasks: 
  - name: Type something that will fail
    command: thiscommanddoesntexist
    ignore_errors: yes

  - name: Run command remotely
    command: /usr/local/bin/somecommand
    register: cmd_result
    changed_when: cmd_result == 2

Ansible would report a task as changed as long as the command (or) script gives zero return code.
In the first part, we know that the task will fail, but we decide to ignore it using the keyword ignore_errors. No matter what the command returns, ignore_errors: yes will never report to ansible that the command failed.
In the second command we can ignore the error based on the output of the command. For example, if the output is 2, the the error will be ignored. If cmd_result is not equal to 2, the task will be marked as changed.
So whenever this condition is true, the task will be marked as changed.

Tags

Tags are used when you have a playbook with several tasks and you need to run only specific parts of it instead of running the entire playbook. You use tags to execute or skip selected tasks. Let’s say we have this playbook that installs Docker on Centos and has multiple tasks. As you can notice in lines 12, 24, 37 and 45 we have a new line with a keyword tags that we use to tag certain tasks. The purpose of this is to include or exclude these tasks from the playbook.

# centos-docker.yml
---
- name: Install docker
  hosts: centos
  become: true

  tasks:
    - name: Install yum utils
      yum:
        name: yum-utils
        state: latest
      tags: install

    - name: Install device-mapper-persistent-data
      yum:
        name: device-mapper-persistent-data
        state: latest
      tags: install

    - name: Install lvm2
      yum:
        name: lvm2
        state: latest
      tags: install

    - name: Add Docker repo
      get_url:
        url: https://download.docker.com/linux/centos/docker-ce.repo
        dest: /etc/yum.repos.d/docer-ce.repo
      become: yes

    - name: Install Docker
      package:
        name: docker-ce
        state: latest
      become: yes
      tags: install

    - name: Start Docker service
      service:
        name: docker
        state: started
        enabled: yes
      become: yes
      tags: start

Now, with the command below, we can execute the playbook and only the tasks tagged with install will be executed.

ansible-playbook centos-docker.yaml --tags install

We can also tell ansible to NOT run those tasks tagged with install.

ansible-playbook centos-docker.yaml --skip-tags install

You can add multiple tags per task, e.g.

tags:
  - cleanup_app
  - cleanup_web

Ansible reserves two tag names for special behavior: always and never. If you assign the always tag to a task or play, Ansible will always run that task or play, unless you specifically skip it (–skip-tags always). If you assign the never tag to a task or play, Ansible will skip that task or play unless you specifically request it (–tags never).

Ansible Vault

Ansible Vault encrypts variables and files so you can protect sensitive content such as passwords or keys rather than leaving it visible as plaintext in playbooks or roles.
First, you have to create a vaulted file where we’ll store the passwords. When you run this command it will ask you to create a password and then an empty file will show up.

ansible-vault create secrets.yml

Add some passwords there and save the file.

mysql_pwd: "DifficultPassword"
ht_pwd: "PasswordXYZ"

If you look at the file now, you’ll see that it’s encrypted and you can’t see the passwords anymore.
If you want to edit the file do ansible-vault edit secrets.yaml and enter the vault password.
Create a small playbook that displays the password.

# centos-vault.yml
---
- hosts: centos
  vars_files:
    - secrets.yml

  tasks:
  - name: Show mysql pwd
    debug:
      msg: "{{ mysql_pwd }}"

If you run the playbook now, ansible will throw an error saying ERROR! Attempting to decrypt but no vault secrets found. You have to specify the parameter –ask-vault-pass and enter the vault password when prompted.

Prompts

In case you need to pause the playbook execution and ask the user for some input such as confirmation or password, use prompts.
If you want the output to echo, use private: no, otherwise what you type won’t show up on the screen. Here is an example of a playbook that asks you to confirm if a file needs to be copied to the node. If you type yes and hit enter, the file will be copied, otherwise it won’t.

# centos-prompt.yml
---
- hosts: centos
  become: yes

  vars_prompt:
    name: upload
    private: no
    prompt: "Do you want to upload xyz.txt?"

  tasks:
  - name: Upload xyz.txt
    copy:
      src: xyz.txt
      dest: /var/log
    when: upload == "yes"

Useful options

ansible-playbook <name> --syntax-check # checks the syntax of the playbook
ansible-playbook <name> --check # does a dry run, reports the will-be changes, but the playbook is not executed
ansible-playbook <name> --step # ask to confirm each-step

Related Articles

Leave a Comment

This website uses cookies to improve your experience. We'll assume you're ok with this, but you can opt-out if you wish. Accept Read More