Skip to content

2. Setup

Justin Perdok edited this page Mar 15, 2024 · 19 revisions

Ansible

postfix Copy the example template_info.example.yml file and edit the information as you see fit.

cp ansible/defaults/template_info.example.yml ansible/defaults/template_info.yml

Copy the example secrets.example.yml file and add the your cloud provider API key('s).

cp ansible/defaults/secrets.example.yml ansible/defaults/secrets.yml 

NOTE: The OCI implementation does not use a simple set of API Keys. The way this works on Oracle's end is a public/private key pair for access to their API. For ease of use the Terraform code for OCI embeded in Anster expects you to setup a OCI config file in ~/.oci/config. Please refer to the following Oracle link to setup this config file. Essentially when you generate/setup an API Key on the Oracle Cloud platform with a private/public key pair, they will give you a example config matching your account settings. Copy paste that content into ~/.osi/config and update the private key path in that file (should be the last line) to your key you setup for authentication towards OCI. The Terraform OCI provider in Anster is setup in such a way it will automatically pick up this file and do it's magic without needing you to define any variables. The current implementation however does expect there to by only a single profile in that config file. If you define more I'm pretty sure Anster will break on this.

Now you can run:

  • ansible-playbook ansible/main.yml --tags=create to create the infrastructure, install ansible roles locally, bootstrap the created servers and apply your task (if any are defined in ansible/tasks/main.yml).
  • ansible-playbook ansible/main.yml to only apply your task to the server (if any are defined in ansible/tasks/main.yml).
  • ansible-playbook ansible/main.yml --tags=destroy to destroy the infrastructure deployed by terraform.

Other additional tags are:

  • ansible-playbook ansible/main.yml --tags=tfvars to only create the terraform.tfvars file. Useful if you want to work on the Terraform code.
  • ansible-playbook ansible/main.yml --tags=roles to only instal ansible roles locally.
  • ansible-playbook ansible/main.yml --tags=bootstrap to only run the bootstrap tasks.

NOTE: The template is smart enough to know if you made changes to the infrastructure variable (host_list). It only keeps VMs on cloud providers that are currently configured in the host_list variable. VMs that are no longer listed in the host_list variable are automatically destroyed and new ones will automatically be created. It will however not configure the new servers with the bootstrap playbook if you did not supply the create or the bootstrap tag on the ansible-playbook command.

Ansible Vault (Optional)

To avoid having clear text API tokens linger on disk you can encrypt the ansible/defaults/secrets.yml with ansible-vault. The following example will allow you to setup a passphrase before you are able to use ansible/defaults/secrets.yml.

ansible-vault encrypt ansible/defaults/secrets.yml

Then from this point onward I would recommend you add the --ask-vault-pass flag to the ansible commands.

$ ansible-playbook ansible/main.yml --ask-vault-pass --tags=create
Vault password: 

If you ever need to edit the API tokens in ansible/defaults/secrets.yml you can do so with ansible-vault edit.

ansible-vault edit ansible/defaults/secrets.yml

For more information about ansible-vault click here.

Your custom tasks

If you require additional tasks to be ran after initial configuration you can add those directly into ansible/main.yml or split them out to separate tasks files such as ansible/tasks/main.yml.

NOTE: Make sure you add a when-statement that prevents other tasks from running. See the examples in setting up the host_list.

If your tasks require additional roles you can have them be automatically installed by adding them to the ansible/files/requirements.yml file. This will install these roles to the ansible/roles folder.

If you want to setup variables for your tasks add these to the ansible/defaults/main.yml file.

Setting up the host_list

By setting up a host_list variable in the template_info.yml-file you can define the following things:

  • How many hosts should be deployed.
  • How a host should be deployed.
  • Where the host should be deployed.
  • To what ansible inventory group the host should be added.

Example 1

The example below defines one host on digitalocean. In this case we want to deploy a simple webserver.

host_list: {
  digitalocean: [
    { "name": "web01",
      "tag": "[\"web\"]"
    }
  ]
}

Since not all terraform values are defined for the VM in the host_list variable, the missing values will use their default settings as show below:

locals {
  digitalocean_servers = defaults(var.digitalocean_servers, {
    size          = "s-1vcpu-1gb"
    image         = "ubuntu-20-04-x64"
    region        = "ams3"
    backups       = false
    monitoring    = false
    ipv6          = false
    resize_disk   = true
    droplet_agent = false
    create_vpc    = true
  })
}

NOTE: Terraform also adds tags/lables to the deployed hosts on the cloud provider. In this case we added a tag to web01 called web. These tags/lables are used by Ansible to add the specific host to an inventory groups that matches the tag/label. Meaning you can easily add host to specific groups while the inventory is still being dynamically build in memory based off Terraform output.

NOTE: By default we create a VPC for each droplet on digitalocean. This is to avoid all the droplets ending up in the same default VPC for that region. The template does not support adding multiple droplets in a single custom VPC. The same logic is applied to all other supported cloud providers with their equivalent terminology.

Thus in the ansible code we define a play against the ansible web group to configure the web host.

---
- hosts: web
  vars_file:
    - "{{ playbook_dir }}/defaults/main.yml"
    - "{{ playbook_dir }}/defaults/template_info.yml"
  vars:
    ansible_user: "{{ root_username }}"
    ansible_ssh_private_key_file: "{{ root_private_key_path }}"
 
  tasks:
    - name: Configure our webhosts
      ansible.builtin.import_tasks: tasks/web_tasks.yml
      tags: always
      when:
        - "'tfvars' not in ansible_run_tags"
        - "'destroy' not in ansible_run_tags"
        - "'roles' not in ansible_run_tags"
        - "'bootstrap' not in ansible_run_tags"
        - "'print_inventory' not in ansible_run_tags"

NOTE: the when-statement is added to ensure the template tags work 'as expected'. If you do not add the when-statement the Configure our webhosts task will run when you supply these tags. A different solution is to replace the always-tag on the Configure our webhosts task and supply that tag on each run, for example ansible-playbook --tags=your_custom_tag.

Example 2

The example below defines two hosts on digitalocean. We decided that one webserver was not enough, so we added a second one.

host_list: {
  digitalocean: [
    { "name": "web01",
      "tag": "[\"web\"]"
    },
    { "name": "web02",
      "tag": "[\"web\"]"
    }
  ]
}

Since we have not defined any other values on both the hosts, the missing values will still use their default settings as show below:

locals {
  digitalocean_servers = defaults(var.digitalocean_servers, {
    size          = "s-1vcpu-1gb"
    image         = "ubuntu-20-04-x64"
    region        = "ams3"
    backups       = false
    monitoring    = false
    ipv6          = false
    resize_disk   = true
    droplet_agent = false
    create_vpc    = true
  })
}

Since both hosts are added to the web group in our inventory we don't need to update our play.

---
- hosts: web
  vars_file:
    - "{{ playbook_dir }}/defaults/main.yml"
    - "{{ playbook_dir }}/defaults/template_info.yml"

  vars:
    ansible_user: "{{ root_username }}"
    ansible_ssh_private_key_file: "{{ root_private_key_path }}"

  tasks:
    - name: Configure our webhosts
      ansible.builtin.import_tasks: tasks/web_tasks.yml
      tags: always
      when:
        - "'tfvars' not in ansible_run_tags"
        - "'destroy' not in ansible_run_tags"
        - "'roles' not in ansible_run_tags"
        - "'bootstrap' not in ansible_run_tags"
        - "'print_inventory' not in ansible_run_tags"

Example 3

The example below defines three hosts on digitalocean. We decided that one our website needed a database, and thus a database server, so we defined a third server in our host_list. Since we expect our DB to use more resources we changed set its size to s-2vcpu-4g. We also moved the webhost web01 to a other digitalocean datacenter.

host_list: {
  digitalocean: [
    { "name": "web01",
      "tag": "[\"web\"]"
      "region": "lon1"
    },
    { "name": "web02",
      "tag": "[\"web\"]"
    },
    { "name": "db01",
      "tag": "[\"db\"]",
      "size": "s-2vcpu-4gb"
    }
  ]
}

All the other values that are missing on each host will again use their default settings as show below:

locals {
  digitalocean_servers = defaults(var.digitalocean_servers, {
    size          = "s-1vcpu-1gb"
    image         = "ubuntu-20-04-x64"
    region        = "ams3"
    backups       = false
    monitoring    = false
    ipv6          = false
    resize_disk   = true
    droplet_agent = false
    create_vpc    = true
  })
}

In the ansible code we would define a additional play against the ansible db group to configure the database host.

---
- hosts: web
  vars_file:
    - "{{ playbook_dir }}/defaults/main.yml"
    - "{{ playbook_dir }}/defaults/template_info.yml"
  vars:
    ansible_user: "{{ root_username }}"
    ansible_ssh_private_key_file: "{{ root_private_key_path }}"
 
  tasks:
    - name: Configure our webhosts
      ansible.builtin.import_tasks: tasks/web_tasks.yml
      tags: always
      when:
        - "'tfvars' not in ansible_run_tags"
        - "'destroy' not in ansible_run_tags"
        - "'roles' not in ansible_run_tags"
        - "'bootstrap' not in ansible_run_tags"
        - "'print_inventory' not in ansible_run_tags"

- hosts: db
  vars_file:
    - "{{ playbook_dir }}/defaults/main.yml"
    - "{{ playbook_dir }}/defaults/template_info.yml"
  vars:
    ansible_user: "{{ root_username }}"
    ansible_ssh_private_key_file: "{{ root_private_key_path }}"
 
  tasks:
    - name: Configure our database hosts
      ansible.builtin.import_tasks: tasks/database_tasks.yml
      tags: always
      when:
        - "'tfvars' not in ansible_run_tags"
        - "'destroy' not in ansible_run_tags"
        - "'roles' not in ansible_run_tags"
        - "'bootstrap' not in ansible_run_tags"
        - "'print_inventory' not in ansible_run_tags"

Example 4

The example below defines five hosts split on two different cloud providers. Our previous stack on digitalocean and two additional mail servers on hetzner. Here we also set more options on our VMs, such as hostnames, PTR, regions and images.

host_list: {
  digitalocean: [
    { "name": "web01",
      "tag": "[\"web\"]"
      "hostname": "web01.example.tld",
      "region": "lon1",
      "image": "debian-11-x64"
    },
    { "name": "web02",
      "tag": "[\"web\"]",
      "hostname": "web02.example.tld",
      "region": "ams1",
      "image": "debian-11-x64"
    },
    { "name": "db01",
      "tag": "[\"db\"]",
      "hostname": "db01.example.tld",
      "size": "s-2vcpu-4gb",
      "region": "ams3",
      "image": "rancheros"
    }
  ],
  hetzner: [
    { "name": "mail01",
      "labels": "{mail = \"\"}",
      "hostname": "mail01.example.tld",
      "ptr": "mail01.example.tld",
      "location": "hel1",
      "image": "fedora-32"
    },
    { "name": "mail02",
      "labels": "{mail = \"\"}",
      "hostname": "mail02.example.tld",
      "ptr": "mail02.example.tld",
      "location": "fsn1",
      "image": "fedora-32"
    }    
  ]
}

NOTE: Digitalocean PTRs are setup automatically ( by digitalocean themselves ) to match the droplet hostname.

All the missing values will again use their default settings as show below:

locals {
  digitalocean_servers = defaults(var.digitalocean_servers, {
    size          = "s-1vcpu-1gb"
    image         = "ubuntu-20-04-x64"
    region        = "ams3"
    backups       = false
    monitoring    = false
    ipv6          = false
    resize_disk   = true
    droplet_agent = false
    create_vpc    = true
  })
}

locals {
  hetzner_servers = defaults(var.hetzner_servers, {
    server_type = "cx11"
    image       = "ubuntu-20.04"
    location    = "nbg1"
    backups     = false
  })
}

In the ansible code we define a additional play against the the ansible mail group to configure the mail servers.

---
- hosts: web
  vars_file:
    - "{{ playbook_dir }}/defaults/main.yml"
    - "{{ playbook_dir }}/defaults/template_info.yml"
  vars:
    ansible_user: "{{ root_username }}"
    ansible_ssh_private_key_file: "{{ root_private_key_path }}"
 
  tasks:
    - name: Configure our webhosts
      ansible.builtin.import_tasks: tasks/web_tasks.yml
      tags: always
      when:
        - "'tfvars' not in ansible_run_tags"
        - "'destroy' not in ansible_run_tags"
        - "'roles' not in ansible_run_tags"
        - "'bootstrap' not in ansible_run_tags"
        - "'print_inventory' not in ansible_run_tags"

- hosts: db
  vars_file:
    - "{{ playbook_dir }}/defaults/main.yml"
    - "{{ playbook_dir }}/defaults/template_info.yml"
  vars:
    ansible_user: "{{ root_username }}"
    ansible_ssh_private_key_file: "{{ root_private_key_path }}"
 
  tasks:
    - name: Configure our database hosts
      ansible.builtin.import_tasks: tasks/database_tasks.yml
      tags: always
      when:
        - "'tfvars' not in ansible_run_tags"
        - "'destroy' not in ansible_run_tags"
        - "'roles' not in ansible_run_tags"
        - "'bootstrap' not in ansible_run_tags"
        - "'print_inventory' not in ansible_run_tags"

- hosts: mail
  vars_file:
    - "{{ playbook_dir }}/defaults/main.yml"
    - "{{ playbook_dir }}/defaults/template_info.yml"
  vars:
    ansible_user: "{{ root_username }}"
    ansible_ssh_private_key_file: "{{ root_private_key_path }}"
 
  tasks:
    - name: Configure our mail hosts
      ansible.builtin.import_tasks: tasks/mail_tasks.yml
      tags: always
      when:
        - "'tfvars' not in ansible_run_tags"
        - "'destroy' not in ansible_run_tags"
        - "'roles' not in ansible_run_tags"
        - "'bootstrap' not in ansible_run_tags"
        - "'print_inventory' not in ansible_run_tags"

Terraform

Embeded Antster Terraform code

If you want to work on the embeded terraform code itself outside of ansible, you want to setup the terraform.tfvars-file. You can deploy this file with the following ansible-playbook command:

ansible-playbook ansible/main.yml --tags=tfvars

Another options is to create a copy of the terraform.tfvars.example file, fill in the token variables with your API's key and setup the server maps manually.

cp terraform/terraform.tfvars.example terraform/terraform.tfvars 

Then cd to the terraform project folder and run terraform as usual.

terraform init
terraform apply
terraform destroy

NOTE: The ansible playbook creates, overwrites and deletes the terraform.tfvars file on each run. This is to ensure secrets such as API keys don't linger on disk whenever possible. Don't forget to use ansible-vault if you care about this stuff 😉.

Custom Terraform code

Essentially there is no reason why you couldn't rip out all the current abstracted Terrafrom code from Anster and forgo the host_list logic if you have more requirements than the current Terraform code offers or want to use a cloud provider that currently is not supported yet. Essentially you just need to ensure all the resources you want Anster to do something with are given to Ansible with a Terraform output object. To make your Terraform code work you also want to update the terraform.tfvars.j2 Jinja2 template in ansible/templates/terraform.tfvars.j2 to match your terraform code needs. Then you would need to ensure you add a matching piece of Ansible code in the ansible/playbooks/terraform/create.yml file that would add the hosts from your Terraform output to the Ansible inventory. You probably want to to delete all the other Add each deployed $PROVIDER host to ansible inventory tasks defined in that file and replace it with just your own.

This way Anster would no longer have it's host_list magic, it would however let you use more complex Terraform code and still have all the other nice features such as the dynamic ansible inventory.

Using Anster in a CI

You can use Anster in a CI, but you will need to think about a couple of issues. Below are a couple and examples on how to fix these.

  1. How will you manage your Terraform state file. You can for example using a remote backend at app.terraform.io. Just make sure you set the execution mode of the workspace to local. The included backend.tf has an example on how to set up a remote backend at app.terraform.io.
  2. How will you manage your SSH keys. Anster supports adding extra variables on the command line like the following --extra-vars "{'ssh_key':{'private':'key'},{'public':'key'}}. When supplied Anster will use these keys instead of auto generating new ones. In order for this to work the private key needs to be in the following format (new lines replaced with \n) -----BEGIN OPENSSH PRIVATE KEY-----\nb3Blbn[...SNIP...]xyz\n[...SNIP...]QID\n-----END OPENSSH PRIVATE KEY-----\n. The \n will be replaced with a new line when the key is saved to disk. Below is an example with Github Actions.
name: CI
on:
  workflow_dispatch:
  pull_request:
    branches:
      - main
    types: [opened, synchronize, closed]
    paths-ignore: 
      - '.github/**'
      - 'README.md'
      - 'LICENSE'
      - '.gitignore'
      - '.editorconfig'
      - './ansible/.ansible-lint'
      - './ansible/.yamllint'

jobs:
  deploy:
    if: github.event.pull_request.merged == true # Run only when PR is merged.
    runs-on: ubuntu-20.04
    name: Run anster
    
    steps:
      - name: Check out the codebase.
        uses: actions/checkout@v2

      - name: Set up Python 3.8.10
        uses: actions/setup-python@v2
        with:
          python-version: '3.8.10'

      - name: Install test dependencies.
        run: pip3 install ansible yamllint ansible-lint

      - uses: hashicorp/setup-terraform@v1
        with:
          cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
          terraform_wrapper: false

      - name: Create secrets.yml
        working-directory: ./ansible/defaults
        run: cp secrets.example.yml secrets.yml
        
      - name: Run anster create
        working-directory: ./ansible
        run: ansible-playbook main.yml --tags=create --extra-vars "{'tokens':{'digitalocean':'${{ secrets.DO_API_TOKEN }}'},'ssh_key':{'private':'${{ secrets.SSH_KEY_PRIVATE }}','public':'${{ secrets.SSH_KEY_PUBLIC }}'}}"