DEV Community

Cover image for Mastering Ansible Playbooks: Step by Step Guide
env0 Team for env0

Posted on • Originally published at env0.com

Mastering Ansible Playbooks: Step by Step Guide

Looking to automate server setups with Ansible? This guide will introduce Ansible Playbooks and demonstrate how they work through an example deployment of a Flask application on an Apache server with a PostgreSQL database.

We will be using a fictional company, TechCorp, to demonstrate the setup of two web servers and one database server. We’ll provision the virtual machines using Vagrant and VirtualBox, then configure them effortlessly with Ansible playbooks.

Let’s get started and learn more about Ansible Playbooks!

Video Tutorial

TLDR: You can find the main repo here.

What Are Ansible Playbooks?

Ansible Playbooks are YAML-formatted files that define a series of tasks for Ansible to execute on managed hosts. These tasks are grouped into plays, which target specific hosts in a defined order.

Playbooks serve as a blueprint for configuring and managing systems, allowing you to describe the desired state in a clear, human-readable format. 

Notably, the use of YAML format (Yet Another Markup Language) is important because it is both easy to read and machine-parsable, making it ideal for configuration files. 

This simplicity helps speed up the development and maintenance of automation scripts.

Below are some definitions to understand when working with Playbooks:

  • Plays: Define which hosts to target and set up the environment for the tasks.
  • Tasks: Individual actions to execute, such as installing a package or editing a file
  • Variables: Allow you to store values that can be reused throughout the playbook.
  • Handlers: Special tasks that run only when notified, typically used for restarting services.

Ansible's Execution Model

Ansible Architecture with Playbooks

Ansible operates by connecting to your nodes (managed machines) over SSH and pushing out small programs called 'Ansible modules' to perform tasks. It executes tasks defined in your playbooks sequentially, ensuring each task is completed before moving to the next.

During execution, Ansible works idempotently, making changes only when the system isn't already in the desired state. This minimizes unnecessary modifications and reduces the risk of errors.

To illustrate how an Ansible Playbook fits into the overall Ansible process, let's consider a fictional company, TechCorp. TechCorp will be using playbooks to automate the installation and configuration of software on both the web servers and the database server.

Setting Up the Environment

Before writing and running Ansible Playbooks, we need to set up our environment. Below is a diagram of what we will build.

What we will build diagram

Setting Up Vagrant and VirtualBox

For the sake of our example, we'll use Vagrant and VirtualBox to provision virtual machines (VMs). First, ensure both are installed on your system, using the following guides:

For this demo, I have Vagrant version 2.4.1 and VirtualBox version 7.0.20 r163906 (Qt5.15.2), both running on my Windows machine.

With Vagrant and VirtualBox installed, you can provision the VMs by navigating to the root of your project directory (where your Vagrantfile is located) and running:

vagrant up
Enter fullscreen mode Exit fullscreen mode

This command will set up the control node, two web servers, and one database server as defined in your Vagrantfile found in your repo's root.

Run the following command to check the status of your VMs:

Current machine states:

controlnode               running (virtualbox)
webserver1                running (virtualbox)
webserver2                running (virtualbox)
dbserver                  running (virtualbox)
Enter fullscreen mode Exit fullscreen mode

Below is a screenshot of the VMs in VirtualBox:

VMs in VirtualBox

The Inventory File

An inventory file is a configuration file where you define the hosts and groups of hosts upon which Ansible will operate. You will find it in the root of your repo.

[webservers]
webserver1 ansible_host=192.168.56.101
webserver2 ansible_host=192.168.56.102

[dbservers]
dbserver ansible_host=192.168.56.103
Enter fullscreen mode Exit fullscreen mode
  • [webservers]: A group containing webserver1 and webserver2.
  • [dbservers]: A group containing a DB server.

Configuring SSH Connections

Ansible connects to target machines over SSH. To establish secure communication, you need public/private key pairs. Our vagrant file already creates and distributes these.SSH into the Control NodeGo ahead and run the command below to SSH into the control node from your local machine where you ran Vagrant:

vagrant ssh controlnode
Enter fullscreen mode Exit fullscreen mode

Verify SSH Access from the Control Node to the Other Nodes

From the Control Node, try to SSH into the other nodes using:

ssh vagrant@192.168.56.101
ssh vagrant@192.168.56.102
ssh vagrant@192.168.56.103
Enter fullscreen mode Exit fullscreen mode

If successful, you can proceed to use Ansible to manage these hosts.

Writing Your First Ansible Playbook

At the root of the repo, you will find this playbook called techcorp_playbook.yaml. This is our first and basic playbook to get us started. Here are its contents, followed by a detailed explanation:

---
- name: Configure Web Servers
  hosts: webservers
  become: yes
  vars:
    domain: techcorp.com
  tasks:
    - name: Install Apache
      apt:
        name: apache2
        state: latest
      notify:
        - Restart Apache

    - name: Ensure Apache is running
      service:
        name: apache2
        state: started
        enabled: true

    - name: Deploy Apache config file
      template:
        src: templates/apache.conf.j2
        dest: /etc/apache2/sites-available/{{ domain }}.conf

  handlers:
    - name: Reload Apache
      service:
        name: apache2
        state: reloaded

    - name: Restart Apache
      service:
        name: apache2
        state: restarted

- name: Configure Database Server
  hosts: dbservers
  become: yes
  vars:
    postgresql_version: "14"  # Set a default version

  tasks:
    - name: Install PostgreSQL
      apt:
        name: postgresql
        state: present
        update_cache: yes

    - name: Ensure PostgreSQL is running
      service:
        name: postgresql
        state: started
        enabled: true

    - name: Configure PostgreSQL
      template:
        src: templates/pg_hba.conf.j2
        dest: "/etc/postgresql/{{ postgresql_version }}/main/pg_hba.conf"
      notify:
        - Reload PostgreSQL

  handlers:
    - name: Reload PostgreSQL
      service:
        name: postgresql
        state: reloaded
Enter fullscreen mode Exit fullscreen mode

Using Ansible Modules

Ansible modules are the core components that enable tasks to execute specific actions on target hosts. They are essentially pre-defined units of code that can manage system resources, install software, control services, and perform various other functions. One of the main benefits of using modules is that they simplify complex system interactions, allowing you to perform tasks without writing detailed code for each action.

In our playbook for TechCorp, we're using several essential modules:

  • apt: Manages packages on Debian/Ubuntu systems. It allows installing, updating, or removing software packages using the Advanced Packaging Tool (APT).
  • service: Controls services on remote hosts. It enables you to start, stop, restart, and manage the state of services.
  • template: Transfers files from the control machine to target hosts, with the ability to substitute variables and apply logic using the Jinja2 templating language.
  • command: Executes commands on remote hosts. It runs the command specified without using a shell, which is useful for running simple commands that don't require shell features.

By leveraging modules, we can write concise tasks that perform complex operations, making our playbook both readable and efficient. Modules manage the intricacies of interacting with the operating system, enabling us to focus on the desired end state rather than the underlying implementation details.

Now, let's examine each play and its tasks to see how these modules are used within our playbook.

Explaining the Playbook

First Play: Configure Web Servers

  • Install Apache: Uses the apt module to ensure Apache is installed and up to date.
  • Ensure Apache is running: Starts the Apache service and enables it to start on boot.
  • Deploy Apache config file: Uses the template module to deploy a customized Apache configuration file.
  • Handlers: They are triggered when the Apache package is installed or updated (Install Apache task). This task uses the notify statement to call the appropriate handler, ensuring Apache is restarted or reloaded after configuration changes.

Second Play: Configure Database Server

  • Install PostgreSQL: Installs the latest version of PostgreSQL
  • Ensure PostgreSQL is running: Starts and enables the PostgreSQL service
  • Configure PostgreSQL: Deploys a custom configuration file using the template module
  • Handlers: Reload PostgreSQL as needed

Running Ansible Playbooks

Executing the Playbook

Now it’s time to execute the playbook, using the ansible-playbook command from the control node:

cd ansible/env0-ansible-playbooks-tutorial
ansible-playbook -i inventory techcorp_playbook.yaml
Enter fullscreen mode Exit fullscreen mode

The ansible-playbook command tells Ansible to use the inventory file and execute the tasks defined in techcorp_playbook.yaml file.

Once the playbook has been successfully completed, you can open a browser on your local machine and go to 192.168.56.101 or 192.168.56.102 to access the default Apache2 page on both web servers.

Default Apache2 Page

Understanding Playbook Execution

When you run the ansible-playbook command, Ansible initiates a series of actions to carry out the tasks defined in your playbook:

  • Connection to target machines: Ansible connects to each target machine listed in your inventory file. It uses SSH for this connection, leveraging the user accounts and SSH keys you've set up earlier.
  • Sequential task execution: On each host, Ansible executes the tasks sequentially as they appear in the playbook. This ensures that dependencies are respected and that each step is completed before moving on to the next.
  • Idempotent operations: Ansible modules are designed to be idempotent, meaning that running them multiple times won't cause unintended changes if the system is already in the desired state. This is crucial for maintaining consistency across all systems.
  • Handler notifications: If a task changes the state of a host—for example, by installing a package or modifying a configuration file—Ansible marks the task as "changed." Any handlers associated with that task via the notify directive are triggered but will run only after all tasks in the play have been executed.
  • Handler execution: At the end of each play, Ansible executes any pending handlers. This is when services are reloaded or restarted based on the changes made during the tasks.
  • Play recap: Upon completion, Ansible provides a summary of the playbook execution, indicating which tasks were successful, which made changes, and if any failed.

Interpreting the Output

Ansible provides a detailed output of the playbook execution. The output indicates:

  • ok: The task was already in the desired state or completed successfully without making changes.
  • changed: The task made changes to the system to reach the desired state.
  • unreachable: Ansible could not connect to the host.
  • failed: The task did not complete successfully.
  • skipped, rescued, ignored: Provide additional information about task execution flow.

Here is an example of how this output may look:

PLAY [Configure Web Servers] *****************************************************

TASK [Gathering Facts] ***********************************************************
ok: [webserver1]
ok: [webserver2]

TASK [Install Apache] ************************************************************
changed: [webserver1]
changed: [webserver2]

...

PLAY [Configure Database Server] *************************************************

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

TASK [Install PostgreSQL] ********************************************************
changed: [dbserver]

...

PLAY RECAP ***********************************************************************
dbserver                   : ok=5    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
webserver1                 : ok=5    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
webserver2                 : ok=5    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
Enter fullscreen mode Exit fullscreen mode

The output confirms that all tasks were executed successfully on the web servers and database server, as indicated by failed=0 across all hosts in the play recap. This means there were no failures, and all tasks either completed successfully or made necessary changes.

This concludes the first section of our example, where we successfully set up the web and database servers. Now, let's move on to the bonus section, where I'll show you how to enhance the playbook with more advanced features to take this setup to the next level.

Bonus: Enhancing Playbooks with Advanced Features

Remember that bonus I promised? Well, I’ve created an advanced playbook that takes our simple setup to the next level.

This playbook sets up a Flask app, retrieves data from our PostgreSQL database, and displays it on the screen. You can check out the full code in the techcorp_playbook_advanced.yaml file in the root of the repo.

You can run this playbook from the control node with:

ansible-playbook -i inventory techcorp_playbook_advanced.yaml
Enter fullscreen mode Exit fullscreen mode

Once you’ve done that, you can go to either web server's IP address—192.168.56.101 or 192.168.56.102—in your browser, and you'll see the following screen:

Getting Items from Database

This indicates the Flask app running and retrieving items from the database. Now, let’s explore some of the advanced features of this playbook below.

Implementing Conditional Tasks

Conditional tasks allow you to execute tasks only when certain conditions are met. In the advanced playbook, we check if the default Apache site exists before attempting to disable it:

- name: Check if default Apache site exists
  stat:
    path: /etc/apache2/sites-enabled/000-default.conf
  register: default_site

- name: Disable default Apache site
  command: a2dissite 000-default.conf
  when: default_site.stat.exists
  notify: Reload Apache
Enter fullscreen mode Exit fullscreen mode

In this example:

  • Check if default Apache site exists: Uses the stat module to check for the existence of the default site configuration file and registers the result in default_site.
  • Disable default Apache site: Runs the a2dissite command only if default_site.stat.exists is ‘True’. This prevents errors if the site is already disabled or doesn't exist.

Using Handlers and Notifications

Handlers are triggered by the notify directive when a task changes. Multiple tasks can notify the same handler, which will run only once after completing all tasks. 

In the advanced playbook, handlers are extensively used to efficiently manage service restarts and reloads. For example, several tasks notify the Reload Apache handler:

- name: Create Apache configuration file for techcorp.com
  template:
    src: templates/apache_flask.conf.j2
    dest: /etc/apache2/sites-available/techcorp.com.conf
  notify: Reload Apache

- name: Configure Apache to serve Flask app
  template:
    src: templates/apache_flask.conf.j2
    dest: /etc/apache2/sites-available/{{ domain }}.conf
  notify: Reload Apache
Enter fullscreen mode Exit fullscreen mode

The corresponding handler is defined as:

handlers:
  - name: Reload Apache
    systemd:
      name: apache2
      state: reloaded
Enter fullscreen mode Exit fullscreen mode

This ensures that Apache is reloaded only once after all required tasks have been executed, optimizing resource usage.

Managing Variables and Templates

Working with Variables

Variables make your playbooks dynamic and reusable. In the advanced playbook, we define variables for the domain name and PostgreSQL version:

vars:
  domain: techcorp.com
  postgresql_version: 14
Enter fullscreen mode Exit fullscreen mode

These variables are then used throughout the playbook to ensure consistency and make updates easier. Below are a couple of examples of using them:

  - name: Enable Apache site
      command: a2ensite {{ domain }}.conf
      notify:
        - Reload Apache
...
    - name: Configure PostgreSQL
      template:
        src: templates/pg_hba.conf.j2
        dest: /etc/postgresql/{{ postgresql_version }}/main/pg_hba.conf
      notify:
        - Reload PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Host variables can be defined at various levels, including global, host-specific, and play-specific scopes.

Using Templates for Configuration Files

Templates allow you to create dynamic configuration files using variables and control structures. They are written in Jinja2 templating language, which integrates seamlessly with Ansible.

Apache Configuration Template (apache_flask.conf.j2)

You can find this template under the templates folder in your repo, and here is its content:

<VirtualHost *:80>
    ServerName {{ domain }}
    ServerAlias www.{{ domain }}
    WSGIDaemonProcess {{ domain }} user=www-data group=www-data threads=5
    WSGIScriptAlias / /var/www/{{ domain }}/app.wsgi

    <Directory /var/www/{{ domain }}>
        WSGIProcessGroup {{ domain }}
        WSGIApplicationGroup %{GLOBAL}
        Order deny,allow
        Allow from all
    </Directory>
</VirtualHost>
Enter fullscreen mode Exit fullscreen mode

Note that this template uses the ‘{{ domain }}’ variable to customize the Apache configuration for the specified domain.

To deploy the template, simply use the task below:

- name: Create Apache configuration file for techcorp.com
  template:
    src: templates/apache_flask.conf.j2
    dest: /etc/apache2/sites-available/techcorp.com.conf
  notify: Reload Apache
Enter fullscreen mode Exit fullscreen mode

PostgreSQL Configuration Template (pg_hba.conf.j2)

This template is another one found under the templates folder in your repo.

# "local" is for Unix domain socket connections only
local   all             all                                     peer

# IPv4 local connections:
host    all             all             127.0.0.1/32            md5

# IPv6 local connections:
host    all             all             ::1/128                 md5

# Allow connections from webservers
host    all             all             192.168.56.0/24         md5

# Allow replication connections from localhost, by a user with the
# replication privilege.
local   replication     all                                     peer
host    replication     all             127.0.0.1/32            md5
host    replication     all             ::1/128                 md5
Enter fullscreen mode Exit fullscreen mode

As the name suggests, it was created to configure PostgreSQL's client authentication settings, to allow secure connections from both local and remote clients. 

The configuration above allows local connections over Unix sockets using peer authentication, permits TCP/IP connections from localhost (both IPv4 and IPv6) with MD5 password authentication, and importantly, enables remote connections from the web servers in the 192.168.56.0/24 subnet using MD5 authentication.

This ensures that your web servers can securely connect to the PostgreSQL database server while enforcing strong authentication methods, thereby maintaining the security and functionality of your application infrastructure.

Here is where we deploy the template in the playbook:

- name: Configure PostgreSQL
  template:
    src: templates/pg_hba.conf.j2
    dest: "/etc/postgresql/{{ postgresql_version }}/main/pg_hba.conf"
  notify:
    - Reload PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Applying to the Example

In our advanced  TechCorp example, using variables and templates provides several key benefits:

  • Consistency: The domain variable ensures that all references to the website's domain, such as in Apache configurations, remain consistent throughout the playbook.
  • Maintainability: If the domain or other values change, updating the variable in one place applies the change everywhere in the playbook, making maintenance much easier.
  • Reusability: The templates for Apache and PostgreSQL configurations can be reused across different environments or projects with minimal adjustments, allowing for quick adaptation to new setups.

Organizing Complex Playbooks

As a further enhancement to our example, we could also do the following.

Working with Multiple Plays

Separating plays for different server roles improves readability and maintenance.

Including and Importing Tasks

You can break down tasks into separate files for better modularity and reuse:

- name: Common Server Configuration
  hosts: all
  tasks:
    - import_tasks: tasks/common.yaml

- name: Web Server Configuration
  hosts: webservers
  tasks:
    - import_tasks: tasks/webservers.yaml

- name: Database Server Configuration
  hosts: dbserver
  tasks:
    - import_tasks: tasks/dbserver.yaml
Enter fullscreen mode Exit fullscreen mode

Roles and Best Practices

Roles allow you to group tasks, variables, files, and handlers into reusable components. Roles are beyond the scope of this blog post and warrant a separate entry.

A Possible Directory Structure:

roles/
  webserver/
    tasks/
    templates/
    handlers/
    vars/
  dbserver/
    tasks/
    templates/
    handlers/
    vars/
Enter fullscreen mode Exit fullscreen mode

Enhancing the Example with Roles in the Playbook

Refactor your playbook to use roles:

- name: Configure TechCorp Infrastructure
  hosts: all
  become: yes
  roles:
    - common
    - { role: webserver, when: "'webservers' in group_names" }
    - { role: dbserver, when: "'dbserver' in group_names" }
Enter fullscreen mode Exit fullscreen mode

This approach improves maintainability and scalability as your infrastructure grows.

Troubleshooting and Optimization

Debugging Playbooks

Increase verbosity to get more detailed output for troubleshooting:

ansible-playbook -i inventory techcorp_playbook.yaml -vvv
Enter fullscreen mode Exit fullscreen mode

Perform a syntax check before running the playbook:

ansible-playbook -i inventory techcorp_playbook.yaml --syntax-check
Enter fullscreen mode Exit fullscreen mode

Optimizing Task Execution

Limit Execution to Specific Hosts:

ansible-playbook -i inventory techcorp_playbook.yaml --limit webserver1
Enter fullscreen mode Exit fullscreen mode

Disable Fact Gathering if not needed, to speed up execution:

ANSIBLE_GATHERING=explicit ansible-playbook -i inventory techcorp_playbook.yaml
Enter fullscreen mode Exit fullscreen mode

The ANSIBLE_GATHERING=explicit environment variable sets the gathering mode to "explicit" for the duration of this command.

In "explicit" mode, Ansible will only gather facts when explicitly requested in the playbook or with the gather_facts: true directive.

Security Considerations

Use Ansible Vault to encrypt sensitive data:

ansible-vault encrypt vars/secrets.yaml
Enter fullscreen mode Exit fullscreen mode

This command encrypts the vars/secrets.yaml file, ensuring sensitive information like passwords, API keys, and confidential variables are secure. Only users with the vault password can access and decrypt the contents of this file.

Ensuring Authorized Access:

  • Access Control: It's essential to manage who has access to your playbooks and encrypted files. Limit permissions to authorized users only.
  • Version Control: Be cautious when committing encrypted files to version control systems. While the content is encrypted, access to the encrypted files should still be restricted.

Ansible-Vault is beyond the scope of this article, but worth mentioning here for a full-picture view.

Ansible Playbooks and env0

Integrating Ansible Playbooks with env0 enhances your automation capabilities. env0 is a platform that helps manage infrastructure automation and Infrastructure as Code (IaC) workflows.

Benefits of Using env0 with Ansible Playbooks

  • Collaboration: Easily work with team members on playbooks.
  • Governance: Implement policies to control automation processes.
  • Scalability: Manage infrastructure growth efficiently.

How to Integrate Ansible with env0

  1. Connect your code repositories: Link your repositories containing Ansible Playbooks to an env0 Ansible template.
  2. Configure projects: Organize your environments within env0 projects where you can call on a template to run.
  3. Run playbooks: Execute playbooks from env0, which uses the ansible-playbook command behind the scenes by deploying an environment.
  4. Monitor and manage: Use env0's dashboard for execution insights, variable management, and output handling.

Emphasizing Playbooks in env0

With env0, your playbooks become more powerful:

  • Parameterized runs: Manage variables across different environments effortlessly.
  • Automated workflows: Integrate playbooks into env0's CI/CD pipeline with custom flows.
  • Role-based access control: Secure your automation with precise permissions.

env0 with Ansible Playbooks in Action

Near the end of the demo video, at the top of this article, I show you the env0 Ansible template and how you can use it to run Ansible playbooks from env0. You can also reference this blog post, The Ultimate Ansible Tutorial: A Step-by-Step Guide, to learn more about Ansible and how to use it with env0 specifically.

To learn more about how env0’s platform can help with governance and automation, check out this guide: The Four Stages of  Infrastructure as Code (IaC) Automation.

Conclusion

Automating infrastructure management with Ansible Playbooks offers significant consistency, efficiency, and scalability benefits. By working through the TechCorp example, we've demonstrated how to set up web and database servers using Ansible Playbooks, incorporating advanced features like variables, templates, and handlers.

Embracing these practices lays a solid foundation for scaling your infrastructure. Automation reduces the likelihood of human error, ensures consistency across environments, and frees up valuable time for more strategic initiatives.

Suggested Next Steps

  • Integrate with CI/CD pipelines: Incorporate Ansible Playbooks into your continuous integration and deployment workflows to automate the delivery process.
  • Explore Ansible Galaxy: Utilize existing roles and playbooks from the Ansible community to accelerate development.
  • Implement roles and secrets: Use roles for better organization and Ansible Vault for managing sensitive information securely.
  • Scale with env0: Consider using Ansible with platforms like env0 to enhance collaboration, governance, and scalability in your automation efforts.

By continuing to refine your skills with Ansible and leveraging tools like env0, you'll be well-equipped to manage complex infrastructures efficiently and effectively.

Frequently Asked Questions

Q: What is the ansible-playbook command used for?

The ansible-playbook command is used to execute Ansible Playbooks. It reads the YAML-formatted playbook files and runs the tasks defined within them against the specified hosts or groups.

Q: How do I run a specific play or task within a playbook?

You can use tags to run specific plays or tasks. Add a tags attribute to your tasks or plays and then run:

ansible-playbook -i inventory techcorp_playbook.yaml --tags "your_tag"
Enter fullscreen mode Exit fullscreen mode




Q: What is an ad hoc command in Ansible, and when should I use it?

An ad hoc command in Ansible is a one-liner used to execute a quick task without requiring a playbook file. It's great for simple tasks like restarting services or checking the state of a system.

Q: Can I run Ansible Playbooks on multiple environments?

Yes, you can manage multiple environments by defining different Ansible inventory files or using dynamic inventory scripts. This allows you to target different sets of hosts without changing your playbooks.

Q: How do I handle secrets and sensitive data in Ansible Playbooks?

Use Ansible Vault to encrypt sensitive variables and files. This ensures that secrets like passwords and API keys are stored securely and are only accessible to authorized users.

Q: What are handlers, and how do they work in Ansible Playbooks?

Handlers are tasks that run only when notified by other tasks. They're typically used to restart or reload services after a configuration change. Handlers ensure that services are only restarted when necessary, optimizing performance.

Q: What is the purpose of an Ansible inventory file?

An Ansible inventory file defines the hosts and groups of hosts on which tasks are executed. It can be a static file or dynamically generated based on your infrastructure.

Q: What are ad hoc tasks versus playbook tasks in Ansible?

Ad hoc tasks are one-off commands executed for quick actions, while playbook tasks are predefined and reusable tasks defined in playbook files. Use ad hoc commands for immediate needs and playbooks for long-term automation.

Top comments (0)