Introduction

  • If you'd like to get have a look at our Medium Article covering this, feel free to check it out here.. Special thanks to our contributor Craig for such a helpful walkthrough.

The primary focus of this guide, apart from showcasing the Syntropy Stack, is to show you how to use it in conjunction with Ansible. There are probably hundreds of excellent “getting started with Ansible” guides out there, so we’ll assume you have at least some basic knowledge of Ansible, though we will go into a bit of detail with some of the Ansible functionality we use. The most important thing to know is that every operation we want Ansible to perform is described in a playbook (which is just a YAML template).

What is Ansible?

Ansible Playbooks offer a repeatable, reusable, simple configuration management and multi-machine deployment system, one that is well suited to deploying complex applications. If you need to execute a task with Ansible more than once, write a playbook and put it under source control. Then you can use the playbook to push out new configurations or confirm the configuration of remote systems.

Getting Started

Clone the syntropy-devops-integrations repo on Github. We’ll be working in the mqtt-mosquitto-nodejs-ansible folder.

If you’d like to see Craig go through the process of setting up his own network, a screen recording with commentary can be found here.

Here’s a checklist of what you’ll need to set up your three nodes.

You need to have a Syntropy Stack account, as well as an active Agent Token.

  • 3 Linux VMs running on separate cloud providers
  • A control node for Ansible — this is just a fancy way of saying “a computer to run Ansible on” (it can be your local machine or a VM). Ansible should already be installed on this machine, if it’s not, do so now.
  • Your control node should also have Python ≥ 3.6 for the Ansible dependencies we’ll be working with

Here’s a simple diagram of the network we’ll be building:

We’ve chosen to use Digital Ocean, AWS, and Google Cloud Platform as the cloud providers for our VMs. You can find more details about why we chose them, and info on setting up your own, in the VM Initialization Walkthrough.

Project Structure

Let’s examine the project structure and what each part does:

├── README.md
├── deploy_broker.yaml
├── deploy_network.yaml
├── deploy_publisher.yaml
├── deploy_subsciber.yaml
├── provision_hosts.yaml
├── roles
│   ├── create_app_image
│   │   └── tasks
│   │       └── main.yaml
│   ├── create_docker_network
│   │   └── tasks
│   │       └── main.yml
│   ├── create_syntropy_network
│   │   └── tasks
│   │       └── main.yml
│   ├── install_wireguard
│   │   ├── files
│   │   │   └── wireguard.conf
│   │   └── tasks
│   │       └── main.yaml
│   ├── launch_mosquitto
│   │   ├── files
│   │   │   └── mosquitto.conf
│   │   └── tasks
│   │       └── main.yml
│   ├── launch_nodejs
│   │   └── tasks
│   │       └── main.yml
│   ├── launch_syntropy_agent
│   │   └── tasks
│   │       └── main.yml
│   ├── tasks
│   │   └── main.yaml
│   └── update_cache
│       └── tasks
│           └── main.yml
├── secrets.yaml
├── src
│   ├── publisher
│   │   ├── Dockerfile
│   │   ├── package.json
│   │   └── publisher.js
│   └── subscriber
│       ├── Dockerfile
│       ├── package.json
│       └── subscriber.js
└── syntropyhosts

Playbooks

These are the YAML files located at the project’s root. We’ll execute these using the ansible-playbook command from the command-line. Let’s see what each one does.

Secrets

The secrets.yaml file contains information you wouldn’t want to commit, such as your API key. Any variables from the secrets file can be loaded into your playbooks using the include_vars (ansible.builtin.include_vars) module.

Roles

This is where the Ansible magic happens! If you aren’t familiar with roles, they’re a handy way to modularize your playbooks. In the same way that a function is a modularized, reusable piece of code for performing a single task, a role contains a task (or a collection of tasks) that should focus on performing a single job/action. We’ll discuss some of our roles in more detail later on.

Src

The src folder contains, you guessed it, the source code for our NodeJS Publisher and Subscriber apps.

Syntropy Hosts

The syntropyhosts file, known as an “inventory” file, contains details about your host VMs, such as the username and IP addresses.

Creating your Network

Authentication

Ensure that you have access to your VMs via SSH and that they’ve been added them to your list of authorized keys so that Ansible has unfettered access to them.

Rename the sample.secrets.yaml file to secrets.yaml and add your Agent Token (generated via Syntropy UI) to the api_key variable.

secrets.yaml
---
api_key: "your_api_key"

Next, we need to generate an API Token (not to be confused with your Agent Token, ie. your API key). To generate an API Token, install the Syntropy CLI.
pip3 install syntropycli

Generate an API Token by logging in using the CLI:
syntropyctl login {syntropy stack user name} { syntropy stack password}

Copy the API token this command outputs and add it to your ENV. We recommend your .bashrc. You’ll need to add the API URL, as well as your username in password.

export SYNTROPY_API_SERVER=https://controller-prod-server.syntropystack.com
export SYNTROPY_API_TOKEN=”your_syntropy_api_token”
export SYNTROPY_PASSWORD=”your_syntropy_password”
export SYNTROPY_USERNAME=”your_syntropy_username”

If you do place it in your .bashrc, remember to source the file from your current terminal window so that the variables are available in your ENV: source ~/.bashrc

You can check your env (on Mac), by simply typing env in your terminal window and hitting return.

Prepare your Inventory

Update the syntropyhosts file to include login credentials for your VMs. This file is known as an inventory in Ansible.

[all:vars]
ansible_ssh_private_key_file=<pem_file_location>

[broker]
broker          ansible_host=<broker_vm_ip>     ansible_connection=ssh  ansible_user=<broker_user>

[publisher]
publisher       ansible_host=<publisher_vm_ip>     ansible_connection=ssh  ansible_user=<publisher_user>

[subscriber]
subscriber      ansible_host=<subscriber_vm_ip>      ansible_connection=ssh  ansible_user=<subscriber_user>

Each host is given a name, designated by the [ ]. If you require a PEM file for SSH authentication, assign it to ansible_ssh_private_key_file. The [all:vars] tells Ansible to make these variables available to all hosts.
ansible_host represents the IP address of your VM, whereas your SSH user is assigned to ansible_user.

Install the Syntropy Galaxy collection and configure Ansible

Ansible has great documentation, so definitely check it out if you’re curious about what it’s capable of, or if you aren’t sure about what something's doing. We’ll be using the Syntropy Ansible Galaxy Collection. Ansible Galaxy houses content created by the Ansible community. Collections contain useful playbooks, roles and modules for us to include in our own playbooks.

This Galaxy collection requires Python ≥ 3.6 for the required dependencies. If you’re working on a Mac, the standard python version installed is usually 2.7, so we’ll be using python3 and pip3.

Check that you have the correction versions installed.

$ python3 --version
Python 3.7.5

Install the collection:

Navigate to your local ansible directory, for example on Mac OS:

Install the Python dependencies.
pip3 install -U -r requirements.txt

To make the log output from your Ansible CLI easier to read, create an Ansible config file and place it in your home directory.
~/.ansible.cfg

And add the following to it:

[defaults]
stdout_callback=yaml
# use stdout_callback when running adhoc commands too
bin_ansible_callbacks = True
interpreter_python = auto_silent
remote_tmp = /tmp/ansible-$USER

Without the config, your log output looks like this:

TASK [create_docker_network : Create Docker network] *****************************************************************
ok: [broker] => {"ansible_facts": {"docker_network": {"Attachable": false, "ConfigFrom": {"Network": ""}, "ConfigOnly": false, "Containers": {"1893dcb898d5299c5dd9cf5a2219f2e2f08956507afc56d86bfe64cb96e18837": {"EndpointID": "7b9ec7d78666c965795c34db03465562e4bb7f5bdfa21de71d5f69824c3b48a7", "IPv4Address": "172.20.0.2/24", "IPv6Address": "", "MacAddress": "02:42:ac:14:00:02", "Name": "mosquitto"}}, "Created": "2021-01-11T17:10:29.613448381Z", "Driver": "bridge", "EnableIPv6": false, "IPAM": {"Config": [{"Subnet": "172.20.0.0/24"}], "Driver": "default", "Options": null}, "Id": "9e6daec0c1bb385fc2d6459655602ab5ce127505e7e6eaea091c9e5af7b5a1f0", "Ingress": false, "Internal": false, "Labels": {}, "Name": "syntropynet", "Options": {}, "Scope": "local"}}, "changed": false, "network": {"Attachable": false, "ConfigFrom": {"Network": ""}, "ConfigOnly": false, "Containers": {"1893dcb898d5299c5dd9cf5a2219f2e2f08956507afc56d86bfe64cb96e18837": {"EndpointID": "7b9ec7d78666c965795c34db03465562e4bb7f5bdfa21de71d5f69824c3b48a7", "IPv4Address": "172.20.0.2/24", "IPv6Address": "", "MacAddress": "02:42:ac:14:00:02", "Name": "mosquitto"}}, "Created": "2021-01-11T17:10:29.613448381Z", "Driver": "bridge", "EnableIPv6": false, "IPAM": {"Config": [{"Subnet": "172.20.0.0/24"}], "Driver": "default", "Options": null}, "Id": "9e6daec0c1bb385fc2d6459655602ab5ce127505e7e6eaea091c9e5af7b5a1f0", "Ingress": false, "Internal": false, "Labels": {}, "Name": "syntropynet", "Options": {}, "Scope": "local"}}

After adding your config, it’ll look like this:

TASK [create_docker_network : Create Docker network] *****************************************************************
ok: [broker] => changed=false
  ansible_facts:
    docker_network:
      Attachable: false
      ConfigFrom:
        Network: ''
      ConfigOnly: false
      Containers:
        1893dcb898d5299c5dd9cf5a2219f2e2f08956507afc56d86bfe64cb96e18837:
          EndpointID: 7b9ec7d78666c965795c34db03465562e4bb7f5bdfa21de71d5f69824c3b48a7
          IPv4Address: 172.20.0.2/24
          IPv6Address: ''
          MacAddress: 02:42:ac:14:00:02
          Name: mosquitto
      Created: '2021-01-11T17:10:29.613448381Z'
      Driver: bridge
      EnableIPv6: false
      IPAM:
        Config:
        - Subnet: 172.20.0.0/24
        Driver: default
        Options: null
      Id: 9e6daec0c1bb385fc2d6459655602ab5ce127505e7e6eaea091c9e5af7b5a1f0
      Ingress: false
      Internal: false
      Labels: {}
      Name: syntropynet
      Options: {}
      Scope: local
  network:
    Attachable: false
    ConfigFrom:
      Network: ''
    ConfigOnly: false
    Containers:
      1893dcb898d5299c5dd9cf5a2219f2e2f08956507afc56d86bfe64cb96e18837:
        EndpointID: 7b9ec7d78666c965795c34db03465562e4bb7f5bdfa21de71d5f69824c3b48a7
        IPv4Address: 172.20.0.2/24
        IPv6Address: ''
        MacAddress: 02:42:ac:14:00:02
        Name: mosquitto
    Created: '2021-01-11T17:10:29.613448381Z'
    Driver: bridge
    EnableIPv6: false
    IPAM:
      Config:
      - Subnet: 172.20.0.0/24
      Driver: default
      Options: null
    Id: 9e6daec0c1bb385fc2d6459655602ab5ce127505e7e6eaea091c9e5af7b5a1f0
    Ingress: false
    Internal: false
    Labels: {}
    Name: syntropynet
    Options: {}
    Scope: local

That induces much less anxiety! By placing the .ansible.cfg file in your home directory ( ~/ ), you’re making it global. You can place an additional .ansible.cfg in your project root and it will override any settings that overlap with the global config.

Configure your playbooks

Thankfully, there’s really not much configuration or code for you to write. However, there are a few small changes you need to make.

In each of the deploy_broker.yaml, deploy_publisher.yaml and deploy_subscriber.yaml files, change the agent_provider ID to match whatever cloud provider you’ve chosen for that respective service. You can find a list of IDs for the supported cloud providers here. If you don't see your provider listed here, you can leave it blank. This is only for Syntropy UI purposes

Feel free to change the network_name to in the deploy_network.yaml file, though that’s certainly not a requirement.

Ansible: It’s game time!

Believe it or not, that’s all we need to do, all that’s left to do is run the playbooks and our network will be online! Now might be a good time to put on a pair of raggedy overalls and take a look under the hood. Ansible describes a playbook as follows:

A playbook is composed of one or more ‘plays’ in an ordered list. The terms ‘playbook’ and ‘play’ are sports analogies. Each play executes part of the overall goal of the playbook, running one or more tasks. Each task calls an Ansible module.

Let’s breakdown what’s going on in our playbooks before we run them.

As we explained a little earlier, we’ve broken out most of our functionality into roles, which we’re then able to share between different playbooks. Here’s the YAML template for our deploy_broker playbook:

---
- name: Deploy Broker
  hosts: broker
  vars:
    subnet: 172.20.0.0/24
    agent_name: "mqt_2_broker"
    agent_tags: "mqtt"
    agent_provider: "6"
  roles:
    - create_docker_network
    - launch_syntropy_agent
    - launch_mosquitto

Let’s take a closer look at the create_docker_network role:

---
# tasks file for create_docker_network
- debug:
    msg: Create docker network on subnet - {{subnet}}"

- name: Create Docker network
  docker_network:
    name: syntropynet
    driver: bridge
    ipam_config:
      - subnet: "{{subnet}}"

As you can see, we have two tasks defined. The first uses the Ansible debug module (ansible.builtin.debug) to print a message to the console containing the subnet that we defined in the deploy_broker.yaml file.

The second tasks uses the docker_network module (community.general.docker_network) to define a docker network named syntropynet, set the driver to bridge and set the subnet to whatever we defined earlier, in this case 172.20.0.0/24.

Next, let’s take a quick look at the launch_syntropy_agent role as it’s used by all three of our services.

---
# tasks file for launch_syntropy_agent
- include_vars: ../../../secrets.yaml

- debug:
    msg: creating "{{agent_name}}"

- name: Launch Syntropy Agent
  docker_container:
    image: syntropynet/agent:stable
    name: syntropynet-agent
    capabilities:
      - NET_ADMIN
      - SYS_MODULE
    env:
      SYNTROPY_API_KEY: "{{api_key}}"
      SYNTROPY_NETWORK_API: "docker"
      SYNTROPY_AGENT_NAME: "{{agent_name}}"
      SYNTROPY_PROVIDER: "{{agent_provider}}"
      SYNTROPY_TAGS: "{{agent_tags}}"
      SYNTROPY_SERVICES_STATUS: "false" # default is false
    restart_policy: always
    network_mode: "host"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    devices:
      - "/dev/net/tun:/dev/net/tun"

Perhaps the first thing you’ll notice is that this looks remarkably similar to a docker-compose file, which makes sense given they’re both just YAML templates. You can see we’re using the docker_container module which allows us to structure it like we’re using docker-compose. We’re also pulling in the api_key from the secrets.yaml file using "{{api_key}}", along with the rest of the variables defined in the parent playbook.

Lastly, I want to show you the launch_mosquitto role, just because it shows one additional concept of copying files across to the host. Take a look at the file structure:

├── launch_mosquitto
│   ├── files
│   │   └── mosquitto.conf
│   └── tasks
│   │       └── main.yml

All roles have their tasks defined in /<role>/tasks/main.yml, but for this role, you’ll see the mosquitto.conf in the files/ directory.

---
# tasks file for launch_mosquitto

- debug:
    msg: "Launching Mosquito"

    # copy the mosquito config
- name: Copy Mosquitto conf file
  copy:
    src: mosquitto.conf
    dest: mosquitto.conf
    mode: "0644"

# launch mosquitto container
- name: Launch Mosquitto
  docker_container:
    name: mosquitto
    image: eclipse-mosquitto
    hostname: mosquitto
    purge_networks: yes
    volumes:
      - /mosquitto.conf
    networks:
      - name: syntropynet

Ansible’s built-in copy module copies files from the Ansible Controller (your local machine) to the host. You don’t have to use the relative or absolute path of the mosquitto.conf file as it appears in the files/ directory of the role, whereas the dest represents the absolute path on the host, ie. the root. We can then reference this path when mounting the volume.

Another important property to bring to your attention is the purge_networks. This removes the default docker bridge network. This caught me out at the beginning as it was creating overlapping networks in my Syntropy Agents. The default docker bridge network is created on 172.17.0.0/24, so all three agents had the same subnet, which isn’t allowed in a Syntropy Network. This issue was solved by adding the purge_networks: yes.

Provision your VMs

Each VM requires Docker, Wireguard, and some additional Python dependencies to be installed. If you followed the VM initialization tutorial, repetitive tasks like installing dependencies are perfect for Ansible.

Ensure you have SSH access to all of your VMs, as Ansible needs to be able to secure shell into your hosts on your behalf. To provision your VMs, run the following command from the root of your project folder. This will likely take a few minutes to complete.

As we mentioned before, you’re passing your inventory (syntropyhosts) file with the -i flag. The -v flag stands for verbose:

Here’s what the play that we executed looks like in the playbook:

---
- name: Configure Hosts
  hosts: all
  roles:
    - update_cache
    - install_wireguard
    - install_docker
    - install_python_deps

Because we specified all hosts, we’ll execute the tasks on all the VMs and they’ll be provisioned in parallel. It’ll run each task on each VM before moving on to the next task, then move on to the next role when all the previous role’s tasks are complete until all VMs are provisioned. If you want to confirm if everything is installed correctly, you should see the following output at the end of your log output.

PLAY RECAP ***********************************************************************************************************
broker                     : ok=8    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
publisher                  : ok=8    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
subscriber                 : ok=8    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The PLAY RECAP in the log output will show you if any tasks failed. You can see from running provision_hosts.yaml on our VMs that all 8 tasks succeeded and 0 failed.

Deploy your Services

Deploy the Broker: ansible-playbook deploy_broker.yaml -i syntropyhosts -vv

As an example, the output of the deploy_broker playbook looks like this (though bear in mind we’ve left out the -v flag to remove the verbose log output):

PLAY [Deploy Broker] *************************************************************************************************
TASK [Gathering Facts] ***********************************************************************************************
ok: [broker]
TASK [create_docker_network : debug] *********************************************************************************
ok: [broker] =>
  msg: Create docker network on subnet - 172.20.0.0/24"
TASK [create_docker_network : Create Docker network] *****************************************************************
ok: [broker]
TASK [launch_syntropy_agent : include_vars] **************************************************************************
ok: [broker]
TASK [launch_syntropy_agent : debug] *********************************************************************************
ok: [broker] =>
  msg: creating "mqt_2_broker"
TASK [launch_syntropy_agent : Launch Syntropy Agent] *****************************************************************
[DEPRECATION WARNING]: The container_default_behavior option will change its default value from "compatibility" to
"no_defaults" in community.general 3.0.0. To remove this warning, please specify an explicit value for it now. This
feature will be removed from community.general in version 3.0.0. Deprecation warnings can be disabled by setting
deprecation_warnings=False in ansible.cfg.
ok: [broker]
TASK [launch_mosquitto : debug] **************************************************************************************
ok: [broker] =>
  msg: Launching Mosquito
TASK [launch_mosquitto : Copy Mosquitto conf file] *******************************************************************
ok: [broker]
TASK [launch_mosquitto : Launch Mosquitto] ***************************************************************************
[DEPRECATION WARNING]: Please note that docker_container handles networks slightly different than docker CLI. If you
specify networks, the default network will still be attached as the first network. (You can specify purge_networks to
 remove all networks not explicitly listed.) This behavior will change in community.general 2.0.0. You can change the
 behavior now by setting the new `networks_cli_compatible` option to `yes`, and remove this warning by setting it to
`no`. This feature will be removed from community.general in version 2.0.0. Deprecation warnings can be disabled by
setting deprecation_warnings=False in ansible.cfg.
ok: [broker]
PLAY RECAP ***********************************************************************************************************
broker                     : ok=9    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Deploy the Publisher:
ansible-playbook deploy_publisher.yaml -i syntropyhosts -vv

Deploy the Subscriber:
ansible-playbook deploy_subscriber.yaml -i syntropyhosts -vv

Login to the Syntopy UI to confirm you see your endpoints:

I’d also recommend SSHing into both your Publisher and Subscriber and following the logs of nodejs-publisher and nodejs-subscriber containers so you can see when they connect and start sending/receiving messages.

and you’ll see output showing the Publisher|Subscriber has initialized.

Create your network

Finally, all that’s left to do is to create your network and connect the endpoints. We’ll do this using, you guessed it, an Ansible playbook. Be before we do, though, let’s take a quick look at the create_syntropy_network role’s tasks in the main.yaml file.

---
# tasks file for create_syntropy_network
- name: Create Syntropy Network
  syntropynet.syntropy.syntropy_network:
    name: "{{network_name}}"
    state: present
    topology: p2m
    connections:
      mqt_2_broker:
        state: present
        type: endpoint
        services:
          - mosquitto
        connect_to:
          mqtt:
            state: present
            services:
              - nodejs-publisher
              - nodejs-subscriber
            type: tag

Here we’re using the syntropy_network module using its Fully Qualified Collection Namespace (FQCN), ie. syntropynet.syntropy.syntropy_network. This module is part of the ansible-galaxy collection we downloaded earlier. You can see that we’re creating a p2m network, ie. a Point-to-Multipoint Protocol network. We do this because we only need to make the broker-subscriber and broker-publisher connections, as all communication on our MQTT network takes place via the Broker. Because we tagged our Syntropy Agents with the mqtt tag, we can now use that to identify them when deploying our network. We then tell our mqt_2_broker to connect_to our mqtt tag, ie. all our agents that have that tag.

It’s time to create your network.
ansible-playbook deploy_network.yaml -i syntropyhosts -vv

Open the Syntropy UI and check your network is connected:

If you go back to your SSH sessions for the Publisher and Subscriber, you should see something like the following:

Publisher log output:

Initializing Publisher
Established connection with Broker
[sending] January 11th 2021, 10:53:05 pm
[sending] January 11th 2021, 10:54:05 pm
[sending] January 11th 2021, 10:55:05 pm

Subscriber log output:

Initializing Subscriber
Established connection with Broker
[subscribed] topic: hello_syntropy
[subscribed] topic: init
[received][hello_syntropy] Powered by **Syntropy Stack**: January 11th 2021, 10:53:05 pm
[received][hello_syntropy] Powered by **Syntropy Stack**: January 11th 2021, 10:54:05 pm
[received][hello_syntropy] Powered by **Syntropy Stack**: January 11th 2021, 10:55:05 pm

Congratulations, you’ve just used Ansible to create your very own secure, optimized network between cloud providers!