Saturday, April 26, 2025

An Ansible Journey 15: Templates

My final post will walk through the process of using a template to deliver customized files that may vary across hosts, operating systems, applications and so on. The principle is as in the prior posts, not overly difficult. It consists of:

  1. Creating the template file(s)
  2. Creating the variables with changing values to insert in those template files
  3. Adding the play to do the work
  4. Restarting any services once the change is made.

Creating the Template Files

For this exercise, we will be customizing two sshd_config files, one on Rocky Linux and one on Ubuntu. The main difference is in the content of each file. The default Rocky Linux file will not work with Ubuntu, and vice versa. 

We start by creating a templates directory under playbooks/roles/base. In that directory, we need to drop a copy of both versions of the files and give them a name that indicates to which OS they belong:

[ansadm@rocky-1 ansible]$ ls -l playbooks/roles/base/templates/
total 12
-rw-rw-r--. 1 ansadm ansadm 4297 Apr 26 16:11 sshd_config_rocky.j2
-rw-rw-r--. 1 ansadm ansadm 3282 Apr 26 16:11 sshd_config_ubuntu.j2

Ansible uses the Jinja2 templating system to manage templates. Jinja2 files use a .j2 extension.

Next, we will add the same directive to both. The portion of the sshd_config file that we are editing is identical in both versions, so I'll show that just once here:

[...]
#Port 22
#AddressFamily any
#ListenAddress 0.0.0.0
#ListenAddress ::

AllowUsers {{ ssh_users }}
[...]

We're adding the AllowUsers directive which expects a list of usernames, separated by spaces. Since this list could change from one host to another, we'll use a variable to store the individual user names.

Variables

In each of the host variable files, we'll add some additional variables to include a list of users, the name of the template files we created above, and the sshd service name:

Rocky Linux Files:

ssh_users: "ansadm rlohman"
ssh_template_file: sshd_config_rocky.j2
ssh_service_name: sshd

Ubuntu File:

ssh_users: "ansadm rlohman"
ssh_template_file: sshd_config_ubuntu.j2
ssh_service_name: ssh


Now, just prior to copying the files out, the placeholder variable in the templates will be swapped with the ssh_users string. The ssh_template_file variable will be used to select the appropriate file to use as a template.

Adding the Play

in the playbooks/roles/base/tasks/main.yml file, we'll add the following play to execute the task:

- name: Generate sshd_config File from Template
  tags: ssh
  template:
    src: "{{ ssh_template_file }}"
    dest: /etc/ssh/sshd_config
    owner: root
    group: root
    mode: 0644
  notify: restart_sshd

There's nothing here we have not already done. The src parameter accepts a variable in place of the actual template file to use. The ownership and permissions are set according to what the configuration file has in the /etc/ssh directory.

Service Restarts

There is one additional item that we'll need to account for. the service name between the two is also different. Ubuntu uses ssh.service, whereas Rocky uses sshd.service. That is handled by our last variable, ssh_service_name, in the restart_sshd handler. We again use the notify directive to call the handler once the change is registered.

And with that, our configuration for sshd is now templatized (read, quickly and easily customized) for our servers.

<PREV - CONTENTS

An Ansible Journney 14: Host Variables and Handlers

This post covers the use of host variables and handlers. these offer a way to reduce the lines of code used to specify an infrastructure, and to extend the functionality of a play or task.

Host Variables

Host variables are defined in a host_vars directory that resides in your playbooks directory. Each file must be named exactly as the hostname (or IP address/FQDN) in your inventory file. In my host_vars directory, I have three files:

[ansadm@rocky-1 ansible]$ ll playbooks/host_vars/
total 12
-rw-rw-r--. 1 ansadm ansadm 91 Apr 26 15:18 192.168.5.250.yml
-rw-rw-r--. 1 ansadm ansadm 87 Apr 26 12:48 192.168.5.251.yml
-rw-rw-r--. 1 ansadm ansadm 88 Apr 26 12:51 192.168.6.30.yml
[ansadm@rocky-1 ansible]$

Although these files all have a .yml extension, the use of "---" on the first line is optional. Within that file, we specify each variable using a key/value pair syntax:

[ansadm@rocky-1 ansible]$ cat playbooks/host_vars/192.168.5.250.yml
---
nginx_package_name: nginx
nginx_service_name: nginx
extra_package_name: pcp-pmda-nginx

Once your variables file is created, you can reference the individual variables using Ansible's variable syntax:

"{{ variable-name }}"

In our web_servers role, our first task instructs the dnf module to install two packages, nginx and pcp-pmda-nginx:

- name: Install Nginx Web Service
  tags: nginx,web
  dnf:
    name:
      - nginx
      -
pcp-pmda-nginx
    state: latest
 

with the host variables file shown above, we can obtain the same result referencing the variable names:

- name: Install Nginx Web Service
  tags: nginx,web
  dnf:
    name:
      - "{{ nginx_package_name }}"
      - "{{ extra_package_name }}"

    state: latest

Upon execution, Ansible will replace "{{ nginx_service_name }}" with "nginx."

In a case where the same variable may have a different value across different operating systems, we could specify the same variable name with different values in each host variable file, and Ansible will perform the correct replacement. Thus, we've reduced the side of the play, and extended it to work in more situations.

Handlers

Handlers offer a way to reuse task functionality. In an earlier post, we built a restart_nginx play that checked variable, nginx_conf, to see if it registered a change. If so, then we called the restart Nginx play using a when clause:

when: nginx_conf.changed

This works well, but there may be other reasons to restart our web server that have nothing to do with the configuration file. Without a handler, we would need to write a second restart Nginx play that is called when a different variable registers the change.

To create a handler, we need to start with a directory under the role of our choice called handlers:

playbooks/
    roles/
        web_servers/
            handlers/
                main.yml

As you can see, our handler, like our task book is stored in a file called main.yml. The code for our restart Nginx handler is:

- name: restart_nginx
  service:
    name: "{{ nginx_service_name }}"
    state: restarted

Going back to the play that calls the handler, we'll replace the register: nginx_conf line with a notify directive that calls the handler by name:

 - name: Enable HTTPS in Nginx
  tags: nginx,web
  lineinfile:
    path: "/etc/nginx/nginx.conf"
    line: "        listen       *:443 ssl;"
    insertafter: 'listen       80'
    firstmatch: true
    backup: true
  notify: restart_nginx

The advantage here is that we can call the handler from any code in the web_server role.

 

<PREV - CONTENTS - NEXT>

An Ansible Journey 13: Roles

Roles provide the user will separating out different blocks of code for ease of reading. In this context can be thought of as the role that the target server plays in the environment. They allow you to move code into other files, keeping the playbook cleaner. Fortunately, using roles doesn't restrict you from categorizing work; you can still have, for example, a role called web_servers and still differentiate between say, a web server that is public-facing and a web server for internal use (which may have relaxed security requirements).

In one of the earlier videos, Jay split up his playbook into two two: the bootstrap.yml file and the site.yml. Roles takes this a step further.

Creating roles is very straight-forward, requiring only two general modifications to a playbook with all tasks in one file: directory structure changes and moving code blocks to a new file or files.

Directory Changes

Roles imposes a standard for using roles in your filesystem. In general, no directory is required unless there are files in that directory. Here is an example:

opt/
    ansible/
        inventory/
        playbooks/
            site.yml
            roles/
                base/
                files_servers/
                    tasks/
                        main.yml
                web_servers/
                    files/
                        landing_page.yml
                    tasks/
                        main.yml

Under playbooks we create a directory called roles. That contains a directory for each server role we are defining. The base role is one that would likely apply to all servers, containing such items as the implementation of organizational security policies, support team configurations, etc. Within each role, we have at a minimum, a tasks directory. We can optionally add additional directories, like files, if there are files that we will be transferring to our target servers. Finally, within each tasks directory, we have a file called main.yml. The directory corresponding to a role is required, as is the tasks directory, and the main.yml file.

Code Changes

In the previous model, we created roles by using the [role-name] construct in our playbook:

playbook.yml file
    [base]
        (plays intended for all hosts)
    [web_servers]
        (plays intended for all web servers)
    [db_servers]
        (plays intended for all database servers)
    [file_servers]
    ...

The playbook.yml file will have all plays removed, with each set of plays being defined in its respective main.yml file underneath tasks:

playbook.yml file:

- hosts: all
  become: true
  pre_tasks:
    (any plays that will be run on any server, with each run.*)

- hosts: all
  become: true
  roles:
    - <rolename>
...

main.yml file

- name: Install Nginx Web Service
  tags: nginx,ubuntu,web
  apt:
    name:
      - nginx-core
      - nginx-doc
    state: latest
  when: ansible_distribution == "Ubuntu"

- name: Enable HTTPS in Nginx - Rocky
  tags: nginx,rocky,web
  lineinfile:
    path: "/etc/nginx/nginx.conf"
    line: "        listen       *:443 ssl;"
    insertafter: 'listen       80'
    firstmatch: true
    backup: true
  when: ansible_distribution == "Rocky"
  register: nginx_conf
... 

The playbook.yml file retains each section it did before, but we replace the plays with:

  roles:
    - <rolename>

Then, we take the plays that were in the playbook.yml file and place them into the main.yml file. We'll retain the tags so we can continue to narrow the scope of each run during testing, quick patch activity, etc. One thing to note that is not clear in the example is that whereas the plays are indented two spaces in the playbook.yml file, they start on the left (column 1). This is due to the YAML requirement that all code begin on the left. Note that the first line of our playbook.yml file (---) is not required in the main.yml files.

The main.yml file (and any other collection of plays) in the tasks directory are referred to as "taskbooks."

 

<PREV - CONTENTS - NEXT>

Friday, April 25, 2025

An Ansible Journey 12: Managing Users

Starting Notes

First, I ran across a missing Ansible collection [of modules] while working through this video. Specifically, the authorized_key module was missing. After a short search, I was able to drop the collection on the server with the following command:

ansible-galaxy collection install ansible.posix

Once done, I could deploy the keys.

Next, the author added a line in this video:

changed_when: false

to two of the commands in the bootstrap.yml file. I received errors from Ansible when adding this line, so I removed it. By watching the video, you can see the impact of removing this line, which is rather trivial.

Finally, I've rewritten the entire access and permissions parts. The method employed by the author is good for setting up a small lab, but in my case, I tend to work from a large enterprise perspective, and the manual work required to prepare an Ansible user account simply does not scale well to hundreds or thousands of servers.

User Management

User management is actually very straight forward, and the plays are rather easy to understand. We will start by creating an application user account. It is customary to run applications with service accounts that have restricted permissions. In this case, we are creating a user account to run the billing application. The application and user are part of the accounting group, so we will first be sure that the accounting group exists.

- name: Ensure Group "accounting" Exists
  group:
    name: accounting
    state: present
    gid: 5006

- name: Ensure User svc_billing Exists on Servers
  tags: always
  user:
    name: svc_billing
    groups: accounting
    shell: /sbin/nologin
    state: present

The accounting group in this case is called, well, accounting. We want it to be present, and we manage group ids also, so we'll want to specify that gid.

Next we create our service account, svc_billing. We use groups to force that account to be a member of the accounting group. Since this is a service account, we don't expect anyone to actually use it for a login, so we disable logins for that account.

Now we'll move on to an administrative user account. That account will need extra authority to execute commands. Since permissions are maintained in our fictitious company at the group level, we need to be sure the group is available first, and that is has the appropriate sudo permissions.

 - name: Ensure Group "support" Exists
  tags: always
  group:
    name: support
    state: present
    gid: 5007

- name: Ensure sudoers File is in Place
  tags: always
  copy:
    src: /opt/ansible/files/sudoers/sudoer_support
    dest: /etc/sudoers.d/support
    owner: root
    group: root

For this to work, we'll need to create the file to copy over with the actual sudo entry. The path and filename are above. The content consists of a single line:

%support ALL=(ALL) NOPASSWD: ALL

Now that we are confident our group will be available, we can create our user account:

 - name: Ensure Admin User Account jdoe is Available
  tags: always
  user:
    name: jdoe
    groups: support
    state: present

- name: Add SSH key for User jdoe
  tags: always
  authorized_key:
    user: jdoe
    key: "ssh-rsa AAAAC3dsfSDF9a79kll0[...]REsd9fdfkjRGLDSFGJ jdoe"

Here, we create the jdoe user, assign and tag the account as a system account. We also assign them to the support group. We also take their public key and add to the authorized_key files in any accounts that are created. We assume jdoe has just become part of the organization, and issued the ssh-keygen command to create their own public/private key pair, and pasted their public key in the play.

 

<PREV - CONTENTS - NEXT>

 

 


Thursday, April 24, 2025

An Ansible Journey 11: Managing Services and Editing Files

The primary purpose of this post is to demonstrate how to manage systemctl services. This is useful when deploying a new service and when modifying a service's configuration. Since we need some kind of change that will trigger Ansible to restart the service when done, we also introduce making a change to a text file.

Restarting a Service

The block required for service management is short and not at all complicated:

  - name: Start Nginx Service
    tags: nginx,web,rocky
    service:
      name: nginx
      state: started
      enabled: true
 

We start with name and tags directives, then call the service module. We pass into that module the name of the service as a name directive, and the state (action) we want. Note that just as on the command line, the ".service" is not required at the end of the service name. If you are familiar with systemd, you'll know that services must be both started (so the application runs) and enabled (so it can be started the next time the server boots).

If the service is currently running, and we need to make a change to that service, in many cases, the service must be restarted to pick up the change. When restarting, we don't need to specify the 'enabled' directive. We do, however, need a trigger for the restart. The following can replace the last two lines of the example above:

       state: restarted
    when: nginx_conf.changed

The when directive should be lined up with the 'service:' module line. The when directive in the last line checks the value of a variable, nginx_conf to see if it has been set. If so, the state directive above it will be triggered. We'll discuss how to set that variable below. One final word about the nginx_conf variable before we get there: It is not uncommon to make multiple changes to a file at the same time. When Ansible inspects this variable, it has only one value, and that value reflects the last change that was made. So, if you attempt two different changes to the file at once, the first change is committed, and the second change is not required to be committed (due to impotency), Ansible will not trigger the restart. This is because as mentioned above, the variable only reflects the last change attempt.

Editing a File

We switch now to editing files. If you are familiar with sed and regex expressions, you will find the lineinfile module familiar. It contains many similar constructs, plus many more. The following will modify an existing line in an existing file:

  - name: Enable HTTPS in Nginx - Rocky
    tags: nginx,rocky,web
    lineinfile:
      path: "/etc/httpd/conf.d/httpd.conf"
      regexp: '^ServerAdmin'
      line: "ServerAdmin rlohman@erehwon.net"
    when: ansible_distribution == "Rocky"
    register: httpd_conf

In this  example, we start out by calling the lineinfile module. That will require three parameters: path, regexp, and line. The path parameter tells us in which file the change will be made. The regexp command tells Ansible to search for the string "ServerAdmin," but only when that string starts at the first character in the line (so a line that has one or more spaces in front of Serveradmin will be ignored). Finally, the line parameter instructs Ansible what to change that line to.  The example above was not used in the site.yml file; it is strictly an example to illustrate how to specify changing a line. As such, I'll over the register directive below in the block that I actually used.

Because maintaining configurations is so common, I'll add provide another with a different approach:

  - name: Enable HTTPS in Nginx - Rocky
    tags: nginx,rocky,web
    lineinfile:
      path: "/etc/nginx/nginx.conf"
      line: "        listen       *:443 ssl;"
      insertafter: 'listen       80'
      firstmatch: true
      backup: true
    when: ansible_distribution == "Rocky"
    register: nginx_conf


In this block, we're adding a new line (and new capability) to our configuration file for Nginx. We continue to use the lineinfile module, as well as the path and line directives. These all work identically to those above. In this case, we're modifying our nginx.conf file to allow it to accept HTTPS requests (over port 443) in addition to the standard unencrypted port, 80. I wanted to add this configuration instruction immediately after the one that tells Nginx to open port 80, so I perform a regex search on the line I'm looking for that contains "listen       80" and because I searched using the insertafter directive, my line will be inserted on the next line below the one I searched for. This directive has a complement, insertbefore which works similarly.

The firstmatch directive tells ansible to only add the line after the first occurrence of "listen       80." This is handy as sometimes we may edit a file in which our search string appears more than once (not the case, here, as our search string will only appear once in the nginx.conf file).

The backup directive tells Ansible to make a backup copy of the file if and only if it is going to modify that file. If the desired change is already done, no backup will be created.

This takes us to a new directive, register. Register creates a variable called nginx_conf. It also will modify the value of that variable to indicate that a change was made if the file was modified. This is the variable that we inspected above with this directive:

when: nginx_conf.changed  

This nginx_conf.changed will evaluate to true if a change was made to the file, and in the desired action (restarting the service) will be triggered.

A final note on this example: Your landing page will likely still not be accessible over the HTTPS port, 443, when following this tutorial. This is because Nginx needs to know what certificate to use when processing inbound SSL/TLS connections. We have not specified that so the browser will give an error. That said, running the playbook after adding these plays will still trigger Ansible to restart nginx (which is, after all, the goal of this post).

A Word About Cruft

In the sysadmin world, cruft can be a problem. Cruft is considered anything that remains after some work was performed that isn't necessary for the server or service to operate. In the post on Managing Files (#10), I noted that the .zip file we decompressed was no longer on the server, even though Ansible pulled it down from a website. Had that file been left behind, it would be considered "cruft." In addition, when we made the change above, adding the https configuration line, Ansible left that file sitting in the same directory as the configuration file we changed. Ansible left that file behind as we may need it to revert the change if we inadvertently broke something with our change. That said, it too, is considered cruft.

Cruft can also be a line in a file. For example, we often make small changes to directives in files, by copying a line we want to change, commenting out the original line, and then making the change to the un-commented line. The commented line, just like the backup file, is cruft.

Cruft is generally considered a bad thing in systems administration. It can populate directories with numerous backup files that make looking for a specific file more difficult. Likewise, a configuration file with many commented versions of different configuration parameters can be difficult to read, and may even cause errors when performing a regex search as a prerequisite to committing a change.

There are a number of strategies to keep cruft at a minimum, and they should be employed by the sysadmin. Ansible is a proper tool to use, as is wrapper scripts that may perform some cleanup after the playbook has completed successfully.

My experience teaches me that backup/saved data in files and on disk tend to lose their value after the next change or two (that is, if you need to roll back to a state that has gone through multiple changes, it is more often than not, likely that the rollback would require so many other fixes, that only a good configuration management system will aid in the rollback.

<PREV - CONTENTS - NEXT>

An Ansible Journey 10: Managing Files

This post deals with two methods of managing files with Ansible. We will perform a copy operation, and a pull a compressed file from the internet, and deploy it to rocky-1 (our Ansible host). 

1. Deploy a file to an Ansible Target

We will add the following play inside our hosts: webservers block:

  - name: Deploy Website Landing Page
    tags: nginx,web,rocky
    copy:
      src: /opt/repository/web/landing_page.html
      dest: /usr/share/nginx/html/index.html
      owner: root
      group: root
      mode: 0644

 

The copy module is used to copy files. When configuring this section, you will want to set the src, dest, owner, group, and mode directives to ensure you're pulling the correct file, and when you place it, it has the correct ownership and permissions to be read by Nginx. Not in the example that the source and destination do not need to be the same filename. 

2. Install a Utility from a Compressed Archive

First, our ansible host is not in our inventory file. Even though this is where Ansible runs, it cannot act as an ansible target unless it is in the inventory file. We simply add the following to the end of the inventory file:

[workstations]
192.168.5.249

Next, we'll update our site.yml file to perform the actions we want. Here are the changes:

- hosts: workstations
  become: true
  tasks:


  - name: Install unzip Package
    tags: workstations
    package:
      name: unzip


  - name: Install TerraForm
    tags: workstations
    unarchive:
      src: https://releases.hashicorp.com/terraform/1.11.4/terraform_1.11.4_linux_amd64.zip
      dest: /usr/local/bin
      remote_src: yes
      mode: 0755
      owner: root
      group: root

The first section (in blue) starts a new section that applies only to workstations. (There's nothing special here with respect to a workstation versus a server. The host I'm, rocky-1 on is a Rocky Linux server, but I'm also working on it as if it were my workstation.)

The Install unzip Package section (in orange) ensures we have the unzip package available, as the package we're installing is actually a .zip file. Like the samba package earlier in the tutorial,  the unzip package has the same name, so we can use the package directive instead of dnf. One important note here. If you are referring back to the learnlinux.tv videos, you'll notice that the author does not add the "tags: workstations" line to this block. I expect there was a change in Ansible functionality between the time his videos were recorded and this tutorial was written, as I needed to add this directive in order to get the block to be executed.

The final section (in green) pulls the TerraForm file from Hashicorp's website and decompresses it into the /usr/local/bin directory. It is the unarchive module that manages selecting the utility (unzip) to use. Once complete, you can (as root) issue an updatedb command on the Ansible host followed by:

locate terraform_1.11.4_linux_amd64.zip

to confirm that there is no .zip file leftover after the block completes. The remote_src: directive tells Ansible that the file we are pulling does not exist on the local host. (Note that it was not required in the copy module above, as the source file to be copied was on the same host as Ansible.) One additional note regarding the "hosts: workstations" block. It must be added either at the bottom of the file, or directly above another hosts block. Adding this hosts section in between two unrelated plays from another hosts section will cause errors because Ansible will think that you want the actions to take place in the parent host section. Bottom line: don't try to nest hosts blocks.

 

<PREV - CONTENTS - NEXT>

Ansible Journey 9: Tags

Ansible tags allow you to run specific portions of your playbook, ignoring others. Since each invocation of ansible-playbook against a playbook requires multiple checks to determine whether or not a play should attempt to exectue, running an entire playbook will require more time than just running a portion of that playbook. Tags allow us to target a specific play or task. This can save significant time, particularly during testing, which may result in numerous executions to test individual changes.


We specify tags using the ‘tags’ directive:


tags: tag_1,tag_2...


To see what tags have been applied to a playbook, use ansible-playbook’s ‘--tags’ paraameter:


ansible-playbook --tags web --ask-become-pass playbooks/site.yml

or

ansible-playbook --tags “web,db” --ask-become-pass playbooks/site.yml


Here are two sections of our site.yaml playbook that apply tags:


- hosts: all
  become: true
  pre_tasks:

  - name: Install Updates - Rocky
    tags: always
    dnf:
      update_only: true
      update_cache: true
[...]

 - hosts: web_servers
  become: true
  tasks:

  - name: Install Nginx Web Service
    tags: nginx,rocky,web
    dnf:
      name:
        - nginx
        - pcp-pmda-nginx
      state: latest
    when: ansible_distribution == "Rocky"

The 'always' tag is a special tag that instructs Ansible to always run a play or task, regardless of whether it is specified. It has a complement, "never." These can both be overridden with "--skip-tags."

 

<PREV - CONTENTS - NEXT>

Ansible Journey 8: Targeting Specific Nodes

In any IT infrastructure, servers will built for different purposes. They often have some things in common, yet their purpose will differ from other hosts in the environment. So far, everything we’ve done treats all hosts identically. That is, whatever we install, remove or change on one host, we do on all hosts. For an effective automation solution, we need to be able to target certain changes to hosts that require those changes, but not to other hosts.

Consider the following environment (the model of our infrastructure for this tutorial):

  • We have one web server that will run Nginx on Rocky Linux.
  • We have one database server that will run MariaDB also on Rocky Linux.
  • We have one file server that will provide access to Windows machines via Samba running on Ubuntu.

To accomplish this, we’ll update our inventory file splitting the hosts into different groups:


[web_servers]
192.168.5.250

[db_servers]
192.168.5.251

[file_servers]
192.168.6.30

 

With that out of the way, we can make updates to our playbook that will use the group names in square brackets above to tell Ansible to only commit the actions to servers under the respective group name. We’ll discuss different plays individually.


- hosts: all
  become: true
  pre_tasks:

  - name: Install Updates - Rocky
    dnf:
      update_only: true
      update_cache: true
    when: ansible_distribution == "Rocky"


Here, we see continued use of 'hosts: all.' It is common to standardize on a single operating system, then deploy different services to different hosts, which use that same operating system. In that respect, we are introducing 'levels' or 'hierarchies' of configuration. At one level (the operating system) we can do the same thing to all servers whether they are Rocky or Ubuntu. In the case above, we are updating the repository cache (update_cache: true) and updating all packages where the operating system is Rocky. We do the same for Ubuntu, using its specific commands:


  - name: Install Updates - Ubuntu
    apt:
      upgrade: dist
      update_cache: true
    when: ansible_distribution == "Ubuntu"


Now we want to perform actions that are specific to the role of the servers (web, database, or file servers). We do that by changing the "all" above to the group name we used in the inventory file. Here’s the first play that will target only servers in the web_servers group:


- hosts: web_servers
  become: true
  tasks:

  - name: Install Nginx Web Service - Rocky
    dnf:
      name:
        - nginx
        - pcp-pmda-nginx
      state: latest

 

We began a new hosts section and provided the group name, web_servers. The remainder is unchanged from prior tutorials. When this cookbook is run, it will update all servers’ OS packages, but will install Nginx only on the server in the web_servers group. Now, since we know the only web server in our environment is a Rocky Linux host, we no longer require the 'when' directive, so that has been removed.

Here are the updates that target database and file servers, with nothing notably different from the prior example:


- hosts: db_servers
  become: true
  tasks:

  - name: Install MariaDB - Database Servers
    dnf:
      name: mariadb
      state: latest

- hosts: file_servers
  become: true
  tasks:

  - name: Install Samba - File Servers
    package:
      name: samba
      state: latest


That wraps up this section, but a few final notes:

You can add additional groups, as well as additional servers underneath each group. The key to remember is that the plays that are constrained by the group name following the hosts directive and will only be applied to the hosts in that group.

You’ll notice that in the first code example above, we swapped the 'tasks' directive with “pre_tasks.” This will be used later.


<PREV - CONTENTS - NEXT>



Wednesday, April 23, 2025

Ansible Journey 7: Improving Your Playbook

 

In this section, we will take the playbook containing six individual plays and consolidate down to just a single play. There are pros and cons to doing this which will be reviewed at the end. There are a number of things we can do to may this playbook shorter; each using a different method.

In the first case, we can eliminate two of the plays by combining the package install plays. both apt and dnf modules allow us to specify more than one package. This is accomplished by turning the name parameter into a list, then specifying each package as a member of the list. The two name lines specified in the dnf plays:

 

name: nginx

name: pcp-pmda-nginx

 

become

 

name:

- nginx

- pcp-pmda-nginx

 

 With that, we can remove one of the dnf modules for a package. Likewise, for apt:


name: nginx-core

name: nginx-doc


become


name:

- nginx-core

- nginx-doc


We have thus reduced the number of plays by 30%.

At this point, I should call out something I did that differs from the author of the videos. I selected two different packages as the second package in each play: Performance Co-Pilot in one and Nginx documentation in the other. Normally, we would stick to the equivalent packages common across both distributions. I started with Rocky Linux and Nginx (the author started with Ubuntu and Apache packages) and the packages available for Nginx do not line up the way Apache packages do across distributions. While it comes across rather odd, the principle is the same.

The second update is to make updating the cache part of the package installation play. This is possible because (as mentioned earlier) both dnf and app modules use the “update_cache” parameter. Since update_cache is a parameter available in both modules, we add it underneath the module name, similar to the state parameter:


- name: Install Nginx Web Service

  dnf:

  name:

    - nginx

    - pcp-pmda-nginx

  state: latest

  update_cache: true

  when: ansible_distribution == "Rocky"


Once done with the apt module, we have reduced the number of plays further down to just 2.

Finally, we consolidate the plays into one. In order for that to work, we need to introduce variables. Variables can be identical across hosts, or they can change from host to host. Following is the format of a variable when used in a play:


{{ variable_name }}


Because there are spaces in between the curly braces and the variable name, we include the entire thing in quotes.

When declared, variables use a simple space-separated set of key/value pairs. These are stored in the inventory file as:

 

hostname_or_ip variable_name_1=value_1 variable_name_2=value_2…


Specifying these in our inventory file becomes:


192.168.5.250 nginx_package=nginx extra_package=pcp-pmda-nginx

192.168.5.251 nginx_package=nginx extra_package=pcp-pmda-nginx

192.168.6.30 nginx_package=nginx-core extra_package=nginx-doc


Since we are specifying the packages per host, and each host can only contain one distrubution, we can eliminate the when clauses we added in the prior post. Then, we replace the package names in our play with their corresponding variables:


- name: Install Nginx Web Service

  package:

  name:

    - "{{ nginx_package }}"

    - "{{ extra_package }}"

  state: latest

  update_cache: true


You’ll notice one additional change was made: we replaced dnf: and apt: with package:. The package module is a generalized package module that arranges with each host what the host uses as a package manager. So, selecting the package manager is now Ansible’s responsibility.


Pros and Cons of Consolidation

Pros:

Through consolidation, our play ends up running slightly faster. This won’t be very apparent in such a simple playbook, but at scale will make a noticeable difference.

The playbook is shorter (with respect to the number of lines), and fewer plays. If we are reviewing the playbook for high-level functionality (we see what it’s doing, but not so much how), we can understand more quickly what is happening.

Cons:

When consolidating plays, we are executing the same amount of work with fewer directives. This means we are hiding some of the details. (Well, not so much hiding, as shifting some of those details to other areas, such as our inventory file.) That can make a playbook harder to read.

The name of each play is printed in the output, followed by the results. When we had six plays, we were able to see the result of each individual play. Having consolidated the plays into one, we see just the single play in the output (See Figure 7.1). If there is a failure, more work will be required to discern exactly where the failure occurred and why.

Figure 7.1: Running multiple actions using a single play.

 

Ultimately, as sysadmins, we will need to make a choice that balances ease of reading and troubleshooting with concise code.

 

<PREV - CONTENTS - NEXT>

Ansible Journey 6: Using the 'when' Clause

Note: This section requires a new server running a different distribution. As such, I've added an Ubuntu server to our mix. We now have ubuntu1 (192.168.6.30) that we will add to our inventory file.

Since we would like to run commands that do effectively the same thing to servers running different distributions, and those distributions use different commands to accomplish the same task, we will need a way to instruct Ansible to run different commands on different distributions. This is where the 'when' clause comes in handy. In fact, there are a few things we need to know:

  1. How do we identify the different distributions?
  2. How do we tell Ansible about the target distribution?
  3. How do we tell Ansible about each command we need to run?

It turns out, we have already done this. Ansible's gather-facts module captures a great deal of data about each host that is a target for Ansible automation. Among the details in the gather-facts output is one called ansible_distribution, which captures the OS's common name. After adding the new IP address to our inventory file, we can tell Ansible to gather facts from our inventory, and use grep to filter out anything except the ansible_distribution keyword:

[ansadm@rocky-1 ansible]$ ansible all -m gather_facts | grep ansible_distribution\"
        "ansible_distribution": "Ubuntu",
        "ansible_distribution": "Rocky",
        "ansible_distribution": "Rocky",

(There are multiple facts that begin with "ansible_distribution." We only want the literal ansible_distribution, excluding things such as ansible_distribution_somevalue, so we add an escaped quote to the end of the string to filter out any thing where the character after 'distribution' is not a quote.) This answers question 1 above. We can use the strings Rocky and Ubuntu to identify which distribution each host is running.

To instruct Ansible to target one distribution or the other, we add the string above to the when clause:

  - name: Install Nginx Web Service
    dnf:
      name: nginx
      state: latest
    when: ansible_distribution == "Rocky"

This would have no effect on our playbook, until after we add the Ubuntu server to the inventory file, as both of our servers, rocky-2 and rocky-3 are Rocky servers. Once we add the Ubuntu server, we'll see the command execute with the Ubuntu server being skipped:

Figure 6.1: Ubuntu servers are skipped for all tasks targeted toward Rocky servers.

We have now addressed the second question, instructing Ansible what to do with the string to target a specific distribution.

We still want to install Nginx on the Ubuntu server, so we'll need to do some additional work which involves answering question 3 above. Since Ubuntu uses a different command to handle package management (apt), we now need two commands to update the cache, and two commands to install Nginx. This will double the size of our playbook, but will then make it distribution-independent (well, at least as long as we use only RHEL and Debian downstream distributions). Here is the example with just the cache update tasks:

 - name: Update RHEL/Rocky/CentOS Repo Cache
    dnf:
      update_cache: true
    when: ansible_distribution == "Rocky"

  - name: Update Debian/Ubuntu Repo Cache
    apt:
      update_cache: true
    when: ansible_distribution == "Ubuntu"

The authors of Ansible maintained the same name for the action (update_cache) between the two distributions, but we do need to tell it which distribution employs which command, thus the dnf and apt commands are used explicitly in each task.

One more change is required to our playbook for this to work. The packages provided by each distribution are rarely named the same. So, we need to be explicit with Ansible about what it is we want to install. Rocky Linux uses "nginx" as the package name, and Ubuntu uses "nginx-core." Following demonstrates the change:

  - name: Install Nginx Web Service
    dnf:
      name: nginx
      state: latest
    when: ansible_distribution == "Rocky"

  - name: Install Nginx Web Service
    apt:
      name: nginx-core
      state: latest
    when: ansible_distribution == "Ubuntu"

Now, when we run our playbook, dnf will be used to update the cache on any Rocky server, apt will be used on any Ubuntu server, and each distribution will be requested to install Nginx using the distribution-specific package name. This shows in the output as Ubuntu servers being skipped over when using the dnf command, and likewise, the Rocky servers will be skipped over when using the apt command. Figure 6.2 shows this (though for additional tasks):

Figure 6.2: Results of running our updated playbook.

<PREV - CONTENTS - NEXT>

Ansible Journey 5: Running an Ansible Playbook

 

Note: As the number of files increases, you will want to manage them by creating a directory structure to store related files. I’ve updated my directory structure to the structure below. the playbooks will be created during this exercise, and the locations will be apparent in the commands shown in examples. The files in global_vars are used later.


opt/

     ansible/

         ansible.cfg

         files/

             sudoers/

                 sudoer_support

         global_vars/

             variables.yml

             vault_passwords

         host_vars/

         inventory/

             inventory

         playbooks/

             alsa_remove.yml

             nginx_web.yml

         README.md

Ansible playbooks are files that contain the details that in prior sections, we specified on the command line. This allows for more complicated actions without the tedium of typing everything in each tie we need to trigger automation by Ansible.


The files are YAML (.yml) files. This file format requires strict adherence to the YAML standard. This is beyond the scope of this tutorial and can be researched further here: https://yaml.org/ Be warned, the website is actually presented as a .yml file, so it looks rather odd.


The following is a basic .yml file that will install the latest version of Nginx from the host’s yum/dnf repository:


---


- hosts: all

  become: true

  tasks:


  - name: Install Nginx Web Service

  dnf:

    name: nginx

    state: latest


This is one play with one task, stored in one playbook (nginx_web.yml). It will execute actions against all hosts in the inventory file, and will request that the commands be executed using sudo. The name of the play (“Install Nginx Web Service”) is used in the output to show the results of that portion of the playbook. A playbook can contain more than one play. Finally, the “state: latest” line ensures that the latest version of the of Nginx will be installed.

NB: From professional experience, this is not really a good idea. Once an updated package is dropped into the repo, this playbook will end up updating Nginx on the server when the playbook is run again. It is very possible that the group maintaining the repository is a different group than those maintaining the playbooks, so unless these teams are in tight sync (which is most often, not the case) the software on the server could be updated before testing, and could cause breakage in a production service. The seasoned systems administrator will recognize this, and takes steps to prevent that from happening.

The playbook is run using the ansible-playbook command. The following shows the output of running this command on the command-line:


Figure 5.1: Running a playbook to install Nginx twice.

Starting out, Nginx is not installed on either server in our inventory. The first thing we notice is that the gather-facts module is executed. This is necessary for a playbook, and Ansible will add this for you behind the scenes. In the Install Nginx Web Service play, we see that there were changes to the targets. Finally, we get a Play Recap that shows a summary of what took place, and the results. The “ok=2” output indicates that two plays were executed (Gathering Facts and Install Nginx Web Service) and did not result in an error. “changed=1” indicates that one change was made (installation of Nginx).

In the second run, Nginx was still installed, so this shows the output when no changes are required.


<PREV - CONTENTS - NEXT>

Tuesday, April 22, 2025

Ansible Journey 4: Running Elevated Ansible Commands

For ansible to be useful, it will require the ability to execute commands as the root user (or equivalent). This page addresses how to given a dedicated ansible user (andadm) the ability to execute commands using the system’s sudo facility. (Other elevated privilege mechanisms are available, however sudo is the default and is perfectly capable of meeting our needs.)

Ansible requires two additional items to be added to the command-line. The first, --become, tells ansible to attempt to obtain elevated privileges. The second, --ask-become-pass instructs Ansible to ask you for the password before attempting the connection. Here is a command that will install Nginx to all systems in the inventory:


[ansadm@rocky-1 ansible]$ ansible all -m dnf -a 'name=nginx state=present' --become --ask-become-pass

BECOME password:                      <== ansadm's password here.

192.168.5.250 | CHANGED => {

   "ansible_facts": {

     "discovered_interpreter_python": "/usr/libexec/platform-python"

   },

   "changed": true,

   "msg": "",

   "rc": 0,

   "results": [

     "Installed: nginx-mod-http-perl-1:1.14.1-9.module+el8.4.0+542+81547229.x86_64",

[...]

     "Installed: nginx-mod-http-xslt-filter-1:1.14.1-9.module+el8.4.0+542+81547229.x86_64"

   ]

}

You can also use the single-character parameters, -b to replace ‘--become’ and -K to replace ‘--ask-become-pass.’


<PREV - CONTENTS - NEXT>

Ansible Journey 3: Running Ansible Commands Manually

Note: If you are also following the videos, you will notice that I skipped video #3. This covers the use of Git, which while useful is not required for the tutorial.

This covers executing an ansible command manually. While this is not a good way to leverage the core features of an Ansible-managed environment, it does provide useful output that can be used for testing, and to illustrate how Ansible functions.

The most basic command is the Ansible ping command. This is not an ICMP ping that we are used to in Unix environments. Rather this is a test performed using Ansible that checks to see if a node can receive and execute commands. Figure 3.1 shows the output (in green) of ansible ping. The first command explicitly states which files Ansible should use when executing the commands. The second demonstrates the same where the files are saved in Ansible’s configuration (discussed below). Of note is the “SUCCESS” string and the word “pong.” If the ping fails, the output will be in a different color, and will provide some details on why it failed.

Figure 3.1: The ansible ping command

 The following parameters can be used at the command line:


ParameterPurpose
allSpecifies that this action will take place on all hosts in the inventory file.
-i <file>Inventory file containing hosts upon which Ansible can operate
-mmodule component that contains the code to support execution of certain commands.
--key-file <file>Specifies the private key file to use.

The ansible.cfg file allows you to set up defaults when running ansible. Figure 3.2 shows this file.


Figure 3.2: A simple ansible.cfg file.

KeyValue
inventorySpecify the inventory file to use; searches local path first.
private_key_fileSpecify the private key to use in the connection

There is a default ansible.cfg and a hosts file that exist under /etc/ansible as defaults (created during installation). These can be overridden using the ansible.cfg file above.

If you want to perform a test and minimize output, you can run ansible against a single file using the limit parameter:

--limit <IP|hostname>


<PREV - CONTENTS - NEXT>

Ansible Journey 2: SSH Connectivity

SSH is the common tool for logging into one host from another. It is found on every version of Linux, and generally installed and set to start when the server starts. This is the default mechanism for Ansible to issue commands for other servers on the network. As such, it is both powerful, and incredibly dangerous. In most cases, we use a password or passphrase to connect from one host to another. If we use either with Ansible to connect to target hosts, we end up defeating the purpose of automation – to manage systems without human intervention.

In the prior post, I mentioned that Ansible works, whether we are in the office, or in bed. It wouldn’t do us much good to get an alert in the middle of the night because Ansible is attempting to execute some action, but is paused while waiting for a password. So, this requires setting up a passwordless authentication system, such that Ansible can make the connection, and issue the command(s) on the target without human intervention. Passwordless authentication removes one security block (the password) so you will need to secure access to the Ansible host and admin account in other ways. This is beyond the scope of this tutorial.

The following bash shell script, when executed on a host, will create that account, it's .ssh directory, and give it the permissions necessary to execute any command as if it were root:

#!/bin/bash

useradd ansadm
ssh_dir=/home/ansadm/.ssh
mkdir $ssh_dir && chmod 700 $ssh_dir

file=/etc/sudoers.d/ansadm
touch $file
chown root:root $file && chmod 440 $file && echo "ansadm ALL=(ALL) NOPASSWD: ALL" > $file

Account creation outside of Ansible is beyond the scope of this tutorial, but I'll cover at a high level what we are doing and why.

When saved as a shell script and given execute permissions, this can be run on any host (in fact, it will need to be run on every host except the ansible cont4ol host) where the ansible admin (ansadm) needs to exectute commands. First, we create the account, and ensure its .ssh directory is available. Next, we create a sudoers file for ansadm, and drop that in place so ansadm can obtain elevated permissions. This is all done in a secure way, such that permissions are not granted until the files are properly secured with permissions and ownership.

We need the .ssh directory to be in place before we use ansible to copy over the ansadm's public key.n This is because the authorized_key module will not create an .ssh directory as of this writing. Then, we drop in the file that gives ansadm permissions to execute any command. AS shown, that file has just one line:

ansadm ALL=(ALL) NOPASSWD: ALL


With that out of the way, we can begin using ansible to automate tasks.


<PREV - CONTENTS - NEXT>

 

Ansible Journey 1: Introduction

The author of the video series states that the purpose of Ansible is to provision hosts. I view Ansible as having that capability, but with a more general purpose. In my view, Ansible is an automation tool. Does it provision hosts? Yes, and that is a great use-case. More generally, Ansible provides a means of automating tasks that go far beyond simple provisioning.


Consider a situation in which one of your services go down. You have a secondary host that can provide the same service, but in a standby configuration. When the service goes down, you need a way to detect that, and bring the service up on the standby host, with no human involvement. This is where Ansible shines. By automating the process of bringing up the service on the standby host with Ansible, you have a fail-over solution that works whether you are in the office, or in bed.


There are two types of hosts I’ll be using: the Ansible host, and the client. Unlike other solutions (Chef, Puppet, etc.) there is no client utility required to receive inbound Ansible requests. Ansible relies completely on SSH for connectivity, and issuing commands.


I will use a single host (rocky_1) as the Ansible host. This server has the Ansible package installed. It will contain all of the files required to manage target hosts, including the configurations, inventories, and groups of commands (playbooks). I also have two target hosts (rocky_2 and rocky_3) that will be configured from the Ansible host.

 

<PREV - CONTENTS - NEXT>

 

An Ansible Journey

Welcome to my Ansible tutorial. This isn’t really meant for you, but rather is a tool for me to document my progress on learning Ansible. One of they keys of learning something quickly is to observe, internalize, practice, then write about what you are learning. I’ve set four days aside to do that before starting a new position. Now, if something along the way helps you out, great. Please feel free to read the posts; I'm happy to have you here, and I really hope you get something out of them.

I’ve based them off of the learnlinux.tv video series on YouTube, a 16-part series. I’ll write at least one post for each video with the exception of #3 which discusses Git. Git is a great tool, but unlike the SSH video, it's not strictly required for the tutorial. That said, in a revenue-generating environment, version control is an absolute must, and should be considered.

Here are the contents (which will be converted to links as I go):


1. Introduction

2. SSH Overview and Setup

3. Running Ad-Hoc Commands

4. Running Elevated Ad-Hoc Commands

5. Writing Our First Playbook

6. The When Conditional

7. Improving Your Playbook

8. Targeting Specific Nodes

9. Tags

10. Managing Files

11. Managing Services

12. Managing Users

13. Roles

14. Host Variablesand Handlers

15. Templates


With that, let’s dive right in by clicking the Introduction link above.

NEXT>