I am hopefully going to take the second half of my RHCE exam this year, which is EX294. I have taken inspiration from https://www.lisenet.com/2019/ansible-sample-exam-for-ex294/ and https://ziyonotes.uz/rJt6DcqXr, and have decided to provide my own answers and explanations as I myself prepare for the exam.
I’ve also only used 4 VM’s in total, 1 controller and 3 nodes, and not 4 nodes as the practice exam suggests.
Here are the tips that I’ve found helpful so far:
- ansible and ansible-playbook share most of it’s syntax. So if it’s -b to use —become on ansible-playbook, then it’s most likely the same with ansible.
- The most important tip of all – use ansible-doc! ansible-doc <module> (like ansible-doc user) will not only give you a list of attributes that you can use, but if you see almost at the bottom of the page, there are examples!
- Make sure that all of the services that are supposed to come back up at boot, do so! Make the services persistent, if the exam says so!
- Remember that if you enable a service, that it doesn’t mean that the change is immediate! Remember to check the ansible-doc page for the module if there is an immediate attribute that you can use.
- Use the command ansible all -m setup after you have set up your inventory to see all of the variables gathered by the facts module. They might come in handy!
Task 1 – Ansible installation and configuration
Remember that the default location for the original ansible.cfg is /etc/ansible/ansible.cfg, which you can copy over to your local folder (in this case it’s /home/automation/plays/).
ansible.cfg
These are the fields that I’ve changed in my own ansible.cfg file:
1 2 3 4 5 6 |
inventory = /home/automation/plays/inventory forks = 10 roles_path = /home/automation/plays/roles/ host_key_checking = False remote_user = automation become=False |
inventory
1 2 3 4 5 6 7 8 |
[proxy] node1 ansible_host=ip.of.the.host [webserver] node2 ansible_host=ip.of.the.host [database] node3 ansible_host=ip.of.the.host |
Task 2 – Ad-Hoc commands
What they mean by the use of “Ad-Hoc” is your typical ansible one liners. Which is to actually use the command ansible. I’ve placed the following ansible one liners in a file named adhoc. Remember to change the permissions on the file (chmod +x adhoc) before you run it.
adhoc
In this particular case I’m connecting as the user cloud_user, which will then use sudo to perform the commands.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#!/bin/bash ansible -i inventory all -u cloud_user -b -kK -m user -a \ "name=automation" --limit node3 ansible -i inventory all -u cloud_user -b -kK -m authorized_key -a \ "user=automation \ state=present \ exclusive=True \ key=\"ssh-ed25519 AAAADNzaCElZDI1NTE5AAAAIA03SY0CuxcuTSw0gEcNZg8TMLezwjsxnndmn0i1 automation@controller\"" --limit node3 ansible -i inventory all -u cloud_user -b -kK -m lineinfile -a \ "path=/etc/sudoers \ line='automation ALL=(ALL) NOPASSWD: ALL' \ validate=\"/usr/sbin/visudo -cf %s\"" --limit node3 |
The above is exactly the same as the playbook below (which I have named create_automation_user.yml):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
--- - hosts: all become: yes tasks: - name: Creating the user automation user: name: automation - name: Add SSH-key to authorized_keys authorized_key: user: automation state: present exclusive: True key: "{{ item }}" with_file: - ~/.ssh/id_ed25519.pub - name: Add user to sudoers file lineinfile: path: /etc/sudoers line: 'automation ALL=(ALL) NOPASSWD: ALL' validate: /usr/sbin/visudo -cf %s ... |
Task 3 – File content
motd.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
--- - hosts: all become: yes tasks: - name: Change to HAproxy-motd copy: content: "Welcome to HAProxy server" dest: /etc/motd when: "'proxy' in group_names" - name: Change to Apache-motd copy: content: "Welcome to Apache server" dest: /etc/motd when: "'webserver' in group_names" - name: Change to MySQL-motd copy: content: "Welcome to MySQL server" dest: /etc/motd when: "'database' in group_names" ... |
Task 4 – Configure SSH server
sshd.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
--- - hosts: all become: yes tasks: - name: Set Banner to /etc/motd lineinfile: path: /etc/ssh/sshd_config regex: "^Banner" line: "Banner /etc/motd" - name: Disable X11Forwarding lineinfile: path: /etc/ssh/sshd_config regex: "^X11Forwarding" line: "X11Forwarding no" - name: Set MaxAuthTries to 3 lineinfile: path: /etc/ssh/sshd_config regex: "^MaxAuthTries" line: "MaxAuthTries 3" ... |
Task 5 – Ansible vault
secret.yml
Use the following command to create the ansible vault file named secret.yml:
1 |
ansible-vault create secret.yml |
This file should contain:
1 2 |
user_password: devops database_password: devops |
Use the password devops to protect the file you create.
vault_key
Create a regular file named vault_key that contains the following:
1 |
devops |
Task 6 – Users and groups
This is the most difficult task I’ve encountered so far.
user_list.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 |
--- users: - username: alice uid: 1201 - username: vincent uid: 1202 - username: sandy uid: 2201 - username: patrick uid: 2202 ... |
users.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
--- - hosts: all become: yes vars_files: - ./user_list.yml - ./secret.yml tasks: - name: Add users to the webserver hosts if UID starts with 1 user: name: "{{ item.username }}" shell: /bin/bash groups: wheel append: yes uid: "{{ item.uid }}" password: "{{ user_password | password_hash('sha512') }}" with_items: "{{ users }}" when: - "'webserver' in group_names" - "item.uid|string|first == '1'" - name: Add users to the database hosts if UID starts with 2 user: name: "{{ item.username }}" shell: /bin/bash groups: wheel append: yes uid: "{{ item.uid }}" password: "{{ user_password | password_hash('sha512') }}" with_items: "{{ users }}" when: - "'database' in group_names" - "item.uid|string|first == '2'" - name: Create SSH directory for users on webserver hosts file: path: "/home/{{ item.username }}/.ssh/" state: directory owner: "{{ item.username }}" group: "{{ item.username }}" mode: "0700" with_items: "{{ users }}" when: - "'webserver' in group_names" - "item.uid|string|first == '1'" - name: Create SSH directory for users on database hosts file: path: "/home/{{ item.username }}/.ssh/" state: directory owner: "{{ item.username }}" group: "{{ item.username }}" mode: "0700" with_items: "{{ users }}" when: - "'database' in group_names" - "item.uid|string|first == '2'" - name: Copy private SSH-key to users on the webserver hosts copy: src: /home/automation/.ssh/id_ed25519 dest: "/home/{{ item.username }}/.ssh/id_ed25519" owner: "{{ item.username }}" group: "{{ item.username }}" mode: "0600" with_items: "{{ users }}" when: - "'webserver' in group_names" - "item.uid|string|first == '1'" - name: Copy private SSH-key to users on the database hosts copy: src: /home/automation/.ssh/id_ed25519 dest: "/home/{{ item.username }}/.ssh/id_ed25519" owner: "{{ item.username }}" group: "{{ item.username }}" mode: "0600" with_items: "{{ users }}" when: - "'database' in group_names" - "item.uid|string|first == '2'" ... |
Task 7 – Scheduled tasks
regular_tasks.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
--- - hosts: all become: yes tasks: - name: Create crontab-record on proxy hosts cron: name: "Append the output of 'date' to /var/log/time.log" minute: "0" job: "date >> /var/log/time.log" when: - "'proxy' in group_names" ... |
Task 8 – Software repositories
repository.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
--- - hosts: all become: yes tasks: - name: Add yum repository on database hosts yum_repository: name: "mysql80-community" description: "MySQL 8.0 YUM Repo" baseurl: "http://repo.mysql.com/yum/mysql-8.0-community/el/8/x86_64/" gpgkey: "http://repo.mysql.com/RPM-GPG-KEY-mysql" gpgcheck: yes enabled: yes when: - "'database' in group_names" ... |
Task 9 – Create and work with roles
mysql.yml
1 2 3 4 5 6 7 8 9 10 |
--- - hosts: database become: yes vars_files: - secret.yml roles: - sample-mysql ... |
sample-mysql/tasks/main.yml
You can create an empty role, named sample-mysql, by running the commands:
1 2 |
cd /home/automation/plays/roles/ ansible-galaxy init sample-mysql |
I decided to go with the package mysql-server instead of the mysql-community-server that is listed in the Lisenet exam. The contents of the file sample-mysql/tasks/main.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
--- # tasks file for sample-mysql - name: Ensure partition is created parted: device: "/dev/nvme1n1" number: 1 state: present - name: Ensure volumegroup exists lvg: vg: "vg_database" pvs: "/dev/nvme1n1p1" - name: Create logical volume lvol: vg: "vg_database" lv: "lv_mysql" size: "512" - name: Create an XFS filesystem filesystem: fstype: "xfs" dev: "/dev/vg_database/lv_mysql" - name: Mount lv_mysql volume on /mnt/mysql_backups mount: path: "/mnt/mysql_backups" src: "/dev/vg_database/lv_mysql" fstype: "xfs" state: present - name: Ensure mysql-server and firewalld are installed yum: name: "{{ packages }}" state: latest vars: packages: - mysql-server - firewalld - name: Open port 3306 in the firewall firewalld: port: "3306/tcp" permanent: yes immediate: yes state: enabled - name: Ensure that service mysqld and firewalld are enabled and started service: name: "{{ item }}" state: started enabled: yes loop: - mysqld - firewalld - name: Set mysql root password mysql_user: name: root password: "{{ database_password }}" state: present - name: Apply my.cnf from a template template: src: my.cnf.j2 dest: /etc/my.cnf owner: root group: root mode: 0644 ... |
sample-mysql/templates/my.cnf.j2
1 2 3 4 5 6 7 8 9 10 11 12 |
[mysqld] bind_address = {{ ansible_default_ipv4.address }} skip_name_resolve datadir=/var/lib/mysql socket=/var/lib/mysql/mysql.sock symbolic-links=0 sql_mode=NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLES [mysqld_safe] log-error=/var/log/mysqld.log pid-file=/var/run/mysqld/mysqld.pid |
Task 10 – Create and work with roles (some more)
apache.yml
1 2 3 4 5 6 7 8 |
--- - hosts: webserver become: yes roles: - sample-apache ... |
sample-apache/handlers/main.yml
You can create an empty role, named sample-apache, by running the commands:
1 2 |
cd /home/automation/plays/roles/ ansible-galaxy init sample-apache |
As for the contents of the file sample-apache/handlers/main.yml:
1 2 3 4 5 6 7 8 9 10 |
--- # handlers file for sample-apache - name: restart_apache service: name: httpd state: restarted enabled: yes ... |
sample-apache/tasks/main.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
--- # tasks file for sample-apache - name: Ensure the packages httpd, mod_ssl and php are installed yum: name: ['httpd', 'mod_ssl', 'php'] state: latest - name: Ensure that the service httpd is enabled service: name: httpd state: started enabled: yes - name: Ensure the firewall ports 80 and 443 are open firewalld: service: "{{ item }}" permanent: yes immediate: yes state: enabled with_items: - http - https - name: Create index.html from template template: src: index.html.j2 dest: /var/www/html/index.html notify: restart_apache ... |
Task 11: Download roles from Ansible Galaxy and use them
Install the role named geerlingguy.haproxy by running these commands:
1 2 |
cd /home/automation/plays/ ansible-galaxy install geerlingguy.haproxy |
It will install the role in the roles-location specified in ansible.cfg. In this case, /home/automation/plays/roles/.
haproxy.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
--- - name: Configuring HAProxy hosts: proxy become: yes roles: - geerlingguy.haproxy vars: haproxy_frontend_port: "80" haproxy_frontend_mode: "http" haproxy_backend_balance_method: "roundrobin" haproxy_backend_servers: - name: webserver1 address: ip.of.webserver1:80 - name: webserver2 address: ip.of.webserver2:80 tasks: - name: Ensure firewalld is installed yum: name: firewalld state: latest - name: Ensure firewalld is enabled and running service: name: firewalld state: started enabled: yes - name: Ensure firewalld has port 80 opened firewalld: service: http permanent: yes immediate: yes state: enabled ... |
Task 12: Security
We need to make sure that the package named rhel-system-roles is installed on the controller itself (not the nodes!). So run:
1 |
yum install rhel-system-roles |
This will install the roles to the folder /usr/share/ansible/roles/.
ansible.cfg
Because the role is installed in a different location than we have specified in our ansible.cfg, we need to add this path. So the following line in ansible.cfg:
1 |
roles_path = /home/automation/plays/roles/ |
becomes this:
1 |
roles_path = /home/automation/plays/roles/:/usr/share/ansible/roles/ |
If you look in the folder /usr/share/ansible/roles/ now, you’ll see that there is a symlink named linux-system-roles.selinux, which points to rhel-system-roles.selinux. We will be using the symlink in the next configuration file.
selinux.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
--- - name: Enable the boolean httpd_can_network_connect hosts: webserver become: yes vars: selinux_booleans: - name: httpd_can_network_connect state: on persistent: yes roles: - linux-system-roles.selinux ... |
Task 13: Use conditionals to control play execution
sysctl.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
--- - hosts: all become: yes tasks: - name: Set vm.swappiness to 10 if RAM > 2GB sysctl: name: vm.swappiness value: "10" state: present when: "ansible_memtotal_mb >= 2048" - name: Report not enough memory fail: msg: "Server memory less than 2048MB. RAM size {{ ansible_memtotal_mb }}" when: "ansible_memtotal_mb < 2048" ... |
Task 14 – Use archiving
archive.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
--- - hosts: database become: yes tasks: - name: Create the file database_list.txt copy: content: "dev,test,qa,prod" dest: "/mnt/mysql_backups/database_list.txt" - name: Compress the file with gz archive: path: "/mnt/mysql_backups/database_list.txt" dest: "/mnt/mysql_backups/archive.gz" format: gz ... |
Task 15 – Work with Ansible facts
facts.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
--- - hosts: database become: yes tasks: - name: Ensure the directory exists file: path: "/etc/ansible/facts.d/" state: directory recurse: yes - name: Copy content to file in new directory copy: content: "[sample_exam]\nserver_role=mysql\n" dest: "/etc/ansible/facts.d/custom.fact" ... |
Task 16 – Software packages
packages.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
--- - hosts: all become: yes tasks: - name: Install packages on proxy hosts yum: name: ['tcpdump', 'mailx'] state: latest when: "'proxy' in group_names" - name: Install packages on database hosts yum: name: ['lsof', 'mailx'] state: latest when: "'database' in group_names" ... |
Task 17 – Services
target.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 |
--- - hosts: webserver become: yes tasks: - name: Set default target to multi-user file: src: "/lib/systemd/system/multi-user.target" dest: "/etc/systemd/system/default.target" state: link ... |
Task 18 – Create and use templates to create customised configuration files
server_list.j2
1 2 3 |
{% for host in groups.all %} {{ hostvars[host].inventory_hostname }} {% endfor %} |
server_list.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
--- - hosts: all become: yes tasks: - name: Create template template: src: "server_list.j2" dest: "/etc/server_list.txt" owner: automation mode: 0600 setype: "net_conf_t" ... |