Infrastructure as Code with Ansible Automation Platform

Infrastructure as Code for Ansible Automation Platform Setup #

Introduction #

Setting up Ansible Automation Platform (AAP) manually through the web interface is tedious and highly prone to errors.

I’ve written an Ansible playbook that completely automates the setup of my AAP environment, from credentials and projects to job templates and workflow orchestration. So in case I ever need to rebuild the environment from scratch, all I would need to do is just add the project where that playbook is stored, and add a single template by hand and run it. I’d do it that way because I don’t like running ansible playbooks from the CLI, I always, always use AAP!

Architecture Overview #

The playbook configures these AAP components in this order:

flowchart TD
    CREDS[Credentials] --> PROJECTS[Projects]
    PROJECTS --> INVENTORIES[Inventories]
    INVENTORIES --> TEMPLATES[Job Templates]
    TEMPLATES --> SCHEDULES[Schedules]
    SCHEDULES --> WORKFLOW[Workflow Templates]
    WORKFLOW --> NODES[Workflow Nodes]
    NODES --> HOSTS[Hosts]

    subgraph "AAP Components"
        CREDS
        PROJECTS
        INVENTORIES
        TEMPLATES
        SCHEDULES
        WORKFLOW
        NODES
        HOSTS
    end

Implementation Details #

Variable Structure #

I’ve written the playbook in a way that none of the tasks use any hard-coded values, instead all the variables are set in the vars section. You could also have a separate YAML file and use vars_files, but I prefer to have it all in once place.

I’ll now go through the different variable sections.

Basic Configuration Variables #

These are the very basic variables that are used throughout the playbook:

vars:
  controller_username: "admin"
  default_organization: "Default"
  project_name: "home.arpa-ansible Project"
  main_inventory: "home.arpa"
  standard_credentials:
    - "ansible"
    - "Ansible Vault credential"

Credentials Configuration #

The aap_credentials section defines all the authentication credentials that AAP needs:

aap_credentials:
  - name: "ansible"
    description: "user thats backed into blueprint"
    type: "Machine"
    inputs:
      ssh_key_data: "{{ ansible_ssh_public_key }}"
      username: "ansible"
      password: "{{ ansible_become_pass }}"
      become_password: "{{ ansible_become_pass }}"
  - name: "Ansible Vault credential"
    description: "Vault password for encrypted variables"
    type: "Vault"
    inputs:
      vault_password: "{{ vault_password }}"
  - name: "git"
    description: "ssh key on git.home.arpa"
    type: "Source Control"
    inputs:
      username: "automation"
      ssh_key_data: "{{ automation_ssh_public_key }}"

This creates three types of credentials:

  • Machine credentials for SSH access to managed hosts
  • Vault credentials for decrypting Ansible Vault files
  • Source Control credentials for Git repository access

Projects Configuration #

The aap_projects section defines the Git repositories that contain your playbooks:

aap_projects:
  - name: "{{ project_name }}"
    repo: "git@git.home.arpa:antti/home.arpa-ansible.git"

This creates a project that pulls from my internal Git server where all my Ansible playbooks are stored, along with the actual IAC playbook itself (iac.yml).

Smart Inventories #

One of the most powerful features is the use of smart inventories with regex filters. The aap_inventories section defines both regular and smart inventories:

aap_inventories:
  - name: "{{ main_inventory }}"
    description: "All servers"
    type: ""
  - name: "home.arpa without kvm"
    description: "All servers except kvm.home.arpa"
    type: "smart"
    host_filter: "name__regex=^(?!kvm\\.home\\.arpa$).*$"
  - name: "home.arpa without kvm&automation"
    description: "All servers except kvm.home.arpa and automation.home.arpa"
    type: "smart"
    host_filter: "name__regex=^(?!(automation\\.home\\.arpa|kvm\\.home\\.arpa)$).*"
  - name: "home.arpa without test"
    description: "All servers except test.home.arpa"
    type: "smart"
    host_filter: "name__regex=^(?!test\\.home\\.arpa$).*$"
  - name: "only kvm"
    description: "Only kvm.home.arpa server"
    type: "smart"
    host_filter: "name=kvm.home.arpa"
  - name: "Docker instances"
    description: "Docker container hosts"
    type: "smart"
    host_filter: "name__regex=^(proxy|bit|health|hass)\\.home\\.arpa$"

I don’t want to manually manage inventories so I use regex filters to do the job.

Job Templates #

Job templates are defined with all their configuration parameters in the aap_job_templates section:

aap_job_templates:
  - name: "daily-ping"
    playbook: "playbooks/ping.yml"
    inventory: "home.arpa without test"
  - name: "weekly-backup"
    playbook: "playbooks/backup.yml"
    inventory: "only kvm"
  - name: "weekly-update"
    playbook: "playbooks/update.yml"
    inventory: "home.arpa without kvm&automation"
  - name: "decommission"
    playbook: "playbooks/decommission.yml"
    inventory: "only kvm"
  - name: "insights"
    playbook: "playbooks/insights.yml"
    inventory: "{{ main_inventory }}"
  - name: "ipa"
    playbook: "playbooks/ipa.yml"
    inventory: "{{ main_inventory }}"
  - name: "packages"
    playbook: "playbooks/packages.yml"
    inventory: "{{ main_inventory }}"
  - name: "provision"
    playbook: "playbooks/provision.yml"
    inventory: "only kvm"
  - name: "register"
    playbook: "playbooks/register.yml"
    inventory: "{{ main_inventory }}"
  - name: "snmp"
    playbook: "playbooks/snmp.yml"
    inventory: "{{ main_inventory }}"
  - name: "ssh"
    playbook: "playbooks/ssh.yml"
    inventory: "{{ main_inventory }}"
  - name: "tuned"
    playbook: "playbooks/tuned.yml"
    inventory: "{{ main_inventory }}"
  - name: "iac"
    playbook: "playbooks/iac.yml"
    inventory: "{{ main_inventory }}"
  - name: "docker-upgrades"
    playbook: "playbooks/docker-upgrades.yml"
    inventory: "Docker instances"

Automated Scheduling #

Schedules use RRULE format for precise timing in the aap_schedules section:

aap_schedules:
  - name: "Daily Schedule"
    template: "daily-ping"
    rrule: "DTSTART:20250701T051500Z RRULE:FREQ=DAILY"
  - name: "Weekly Saturday 07:00"
    template: "weekly-update"
    rrule: "DTSTART:20250704T070000Z RRULE:FREQ=WEEKLY;BYDAY=SA"
  - name: "Weekly monday 00:00"
    template: "weekly-backup"
    rrule: "DTSTART:20250707T000000Z RRULE:FREQ=WEEKLY;BYDAY=MO"
  - name: "Weekly Sunday 07:00"
    template: "docker-upgrades"
    rrule: "DTSTART:20250706T070000Z RRULE:FREQ=WEEKLY;BYDAY=SU"

Here’s how to interpret the rrule:

  • DTSTART: When the schedule starts
  • FREQ: How often it repeats (DAILY, WEEKLY, etc.)
  • BYDAY: Specific days for weekly schedules

Workflow Orchestration #

Here is the workflow setup for my server provisioning process. I’ve written a detailed article on how I provisiong VMs in KVM here

workflow_job_template_name: "provision-workflow"
workflow_job_template_description: "Server provisioning workflow"
aap_workflow_nodes:
  - "provision"
  - "register"
  - "ipa"
  - "tuned"
  - "snmp"
  - "packages"

This creates a workflow that runs multiple job templates in sequence during server provisioning process.

Host Management #

Finally, the aap_hosts section defines all the managed hosts that will be added to the main inventory:

aap_hosts:
  - "localhost"
  - "automation.home.arpa"
  - "bit.home.arpa"
  - "git.home.arpa"
  - "hass.home.arpa"
  - "health.home.arpa"
  - "hugo.home.arpa"
  - "ipa01.home.arpa"
  - "ipa02.home.arpa"
  - "kvm.home.arpa"
  - "libre.home.arpa"
  - "ns01.home.arpa"
  - "ns02.home.arpa"
  - "proxy.home.arpa"
  - "runner.home.arpa"

The Tasks #

The tasks section of the playbook is where the actual work gets done. Each task uses the awx.awx collection to interact with the AAP API and create the necessary resources. Here’s how each task works:

Module Defaults #

First, I set module defaults to avoid repeating connection information:

module_defaults:
  group/awx.awx.controller:
    controller_host: "{{ controller_host }}"
    controller_username: "{{ controller_username }}"
    controller_password: "{{ controller_password }}"

Tasks #

Below are the tasks which use the variables described above to configure AAP. Each task is designed to be idempotent and uses the awx.awx collection to interact with the AAP API:

1. Ensure Credentials exist #

- name: Ensure Credentials exist
  awx.awx.credential:
    name: "{{ item.name }}"
    description: "{{ item.description | default(omit) }}"
    credential_type: "{{ item.type }}"
    organization: "{{ default_organization }}"
    inputs: "{{ item.inputs }}"
    state: present
  loop: "{{ aap_credentials }}"

This task creates all the credentials needed for authentication. The default(omit) filter ensures that if a description isn’t provided, the field is completely omitted from the API call rather than sending an empty value.

2. Ensure Projects exist #

- name: Ensure Projects exist
  awx.awx.project:
    name: "{{ item.name }}"
    organization: "{{ default_organization }}"
    scm_type: git
    scm_url: "{{ item.repo }}"
    scm_branch: "{{ item.branch | default('main') }}"
    state: present
  loop: "{{ aap_projects }}"

This creates the Git-based projects that contain the playbooks. It defaults to the main branch if no branch is specified.

3. Ensure Inventories exist #

- name: Ensure Inventories exist
  awx.awx.inventory:
    name: "{{ item.name }}"
    organization: "{{ default_organization }}"
    description: "{{ item.description }}"
    kind: "{{ item.type | default('') }}"
    host_filter: "{{ item.host_filter | default(omit) }}"
    state: present
  loop: "{{ aap_inventories }}"

This creates both regular and smart inventories.

4. Ensure Job Templates exist #

- name: Ensure Job Templates exist
  awx.awx.job_template:
    name: "{{ item.name }}"
    project: "{{ project_name }}"
    playbook: "{{ item.playbook }}"
    inventory: "{{ item.inventory }}"
    credentials: "{{ item.credentials | default(standard_credentials) }}"
    job_type: "{{ item.job_type | default('run') }}"
    forks: "{{ item.forks | default(0) }}"
    verbosity: "{{ item.verbosity | default(0) }}"
    timeout: "{{ item.timeout | default(0) }}"
    diff_mode: "{{ item.show_changes | default(false) }}"
    job_slice_count: "{{ item.job_slicing | default(1) }}"
    state: present
  loop: "{{ aap_job_templates }}"

This is the most complex task, creating job templates with many optional parameters. Each template gets default values for performance settings like forks, verbosity, and timeout if not specified.

5. Ensure Schedules exist #

- name: Ensure Schedules exist
  awx.awx.schedule:
    name: "{{ item.name }}"
    unified_job_template: "{{ item.template }}"
    rrule: "{{ item.rrule }}"
    state: present
  loop: "{{ aap_schedules }}"

This creates the automated schedules that run job templates at specified times.

6. Ensure Workflow Job Template exists #

- name: Ensure Workflow Job Template exists
  awx.awx.workflow_job_template:
    name: "{{ workflow_job_template_name }}"
    organization: "{{ default_organization }}"
    description: "{{ workflow_job_template_description }}"
    inventory: "{{ main_inventory }}"
    state: present

This creates the workflow job template container. Note that this doesn’t use a loop since there’s only one workflow template, the one I use for server provisioning.

7. Ensure Workflow Job Template Nodes exist #

- name: Ensure Workflow Job Template Nodes exist
  awx.awx.workflow_job_template_node:
    workflow_job_template: "{{ workflow_job_template_name }}"
    unified_job_template: "{{ item }}"
    state: present
    identifier: "{{ item }}"
  loop: "{{ aap_workflow_nodes }}"

This adds the individual job templates as nodes in the workflow. Each node runs in sequence during workflow execution.

8. Ensure Hosts exist #

- name: Ensure Hosts exist
  awx.awx.host:
    name: "{{ item }}"
    inventory: "{{ main_inventory }}"
    enabled: true
    state: present
  loop: "{{ aap_hosts }}"

Finally, this adds all the managed hosts to the main inventory.

Further Development #

While this iac.yml playbook is mainly for disaster recovery, I’ve been thinking that in the future I could simply just stop clicking the GUI when I need to e.g. add a new template, and instead add it to the playbook. This way I wouldn’t have to remember to keep the playbook up to date.

This would require that I setup some sort of a CI/CD pipeline that would automatically deploy the playbook and run it against AAP.

Complete Playbook #

Click to expand the complete iac.yml playbook
---
- name: Configure Ansible Automation Platform Infrastructure
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    controller_username: "admin"
    default_organization: "Default"
    project_name: "home.arpa-ansible Project"
    main_inventory: "home.arpa"
    standard_credentials:
      - "ansible"
      - "Ansible Vault credential"
    aap_credentials:
      - name: "ansible"
        description: "user thats backed into blueprint"
        type: "Machine"
        inputs:
          ssh_key_data: "{{ ansible_ssh_public_key }}"
          username: "ansible"
          password: "{{ ansible_become_pass }}"
          become_password: "{{ ansible_become_pass }}"
      - name: "Ansible Vault credential"
        description: "Vault password for encrypted variables"
        type: "Vault"
        inputs:
          vault_password: "{{ vault_password }}"
      - name: "git"
        description: "ssh key on git.home.arpa"
        type: "Source Control"
        inputs:
          username: "automation"
          ssh_key_data: "{{ automation_ssh_public_key }}"
    aap_projects:
      - name: "{{ project_name }}"
        repo: "git@git.home.arpa:antti/home.arpa-ansible.git"
    aap_inventories:
      - name: "{{ main_inventory }}"
        description: "All servers"
        type: ""
      - name: "home.arpa without kvm"
        description: "All servers except kvm.home.arpa"
        type: "smart"
        host_filter: "name__regex=^(?!kvm\\.home\\.arpa$).*$"
      - name: "home.arpa without kvm&automation"
        description: "All servers except kvm.home.arpa and automation.home.arpa"
        type: "smart"
        host_filter: "name__regex=^(?!(automation\\.home\\.arpa|kvm\\.home\\.arpa)$).*"
      - name: "home.arpa without test"
        description: "All servers except test.home.arpa"
        type: "smart"
        host_filter: "name__regex=^(?!test\\.home\\.arpa$).*$"
      - name: "only kvm"
        description: "Only kvm.home.arpa server"
        type: "smart"
        host_filter: "name=kvm.home.arpa"
    aap_job_templates:
      - name: "daily-ping"
        playbook: "playbooks/ping.yml"
        inventory: "home.arpa without test"
      - name: "weekly-backup"
        playbook: "playbooks/backup.yml"
        inventory: "only kvm"
      - name: "weekly-update"
        playbook: "playbooks/update.yml"
        inventory: "home.arpa without kvm&automation"
      - name: "decommission"
        playbook: "playbooks/decommission.yml"
        inventory: "only kvm"
      - name: "insights"
        playbook: "playbooks/insights.yml"
        inventory: "{{ main_inventory }}"
      - name: "ipa"
        playbook: "playbooks/ipa.yml"
        inventory: "{{ main_inventory }}"
      - name: "packages"
        playbook: "playbooks/packages.yml"
        inventory: "{{ main_inventory }}"
      - name: "provision"
        playbook: "playbooks/provision.yml"
        inventory: "only kvm"
      - name: "register"
        playbook: "playbooks/register.yml"
        inventory: "{{ main_inventory }}"
      - name: "snmp"
        playbook: "playbooks/snmp.yml"
        inventory: "{{ main_inventory }}"
      - name: "ssh"
        playbook: "playbooks/ssh.yml"
        inventory: "{{ main_inventory }}"
      - name: "tuned"
        playbook: "playbooks/tuned.yml"
        inventory: "{{ main_inventory }}"
      - name: "iac"
        playbook: "playbooks/iac.yml"
        inventory: "{{ main_inventory }}"
    aap_schedules:
      - name: "Daily Schedule"
        template: "daily-ping"
        rrule: "DTSTART:20250701T051500Z RRULE:FREQ=DAILY"
      - name: "Weekly Saturday 07:00"
        template: "weekly-update"
        rrule: "DTSTART:20250704T070000Z RRULE:FREQ=WEEKLY;BYDAY=SA"
      - name: "Weekly monday 00:00"
        template: "weekly-backup"
        rrule: "DTSTART:20250707T000000Z RRULE:FREQ=WEEKLY;BYDAY=MO"
    workflow_job_template_name: "provision-workflow"
    workflow_job_template_description: "Server provisioning workflow"
    aap_workflow_nodes:
      - "provision"
      - "register"
      - "ipa"
      - "tuned"
      - "snmp"
      - "packages"
    aap_hosts:
      - "localhost"
      - "automation.home.arpa"
      - "bit.home.arpa"
      - "git.home.arpa"
      - "hass.home.arpa"
      - "health.home.arpa"
      - "hugo.home.arpa"
      - "ipa01.home.arpa"
      - "ipa02.home.arpa"
      - "kvm.home.arpa"
      - "libre.home.arpa"
      - "ns01.home.arpa"
      - "ns02.home.arpa"
      - "proxy.home.arpa"
      - "runner.home.arpa"
  module_defaults:
    group/awx.awx.controller:
      controller_host: "{{ controller_host }}"
      controller_username: "{{ controller_username }}"
      controller_password: "{{ controller_password }}"
  tasks:
    - name: Ensure Credentials exist
      awx.awx.credential:
        name: "{{ item.name }}"
        description: "{{ item.description | default(omit) }}"
        credential_type: "{{ item.type }}"
        organization: "{{ default_organization }}"
        inputs: "{{ item.inputs }}"
        state: present
      loop: "{{ aap_credentials }}"

    - name: Ensure Projects exist
      awx.awx.project:
        name: "{{ item.name }}"
        organization: "{{ default_organization }}"
        scm_type: git
        scm_url: "{{ item.repo }}"
        scm_branch: "{{ item.branch | default('main') }}"
        state: present
      loop: "{{ aap_projects }}"

    - name: Ensure Inventories exist
      awx.awx.inventory:
        name: "{{ item.name }}"
        organization: "{{ default_organization }}"
        description: "{{ item.description }}"
        kind: "{{ item.type | default('') }}"
        host_filter: "{{ item.host_filter | default(omit) }}"
        state: present
      loop: "{{ aap_inventories }}"

    - name: Ensure Job Templates exist
      awx.awx.job_template:
        name: "{{ item.name }}"
        project: "{{ project_name }}"
        playbook: "{{ item.playbook }}"
        inventory: "{{ item.inventory }}"
        credentials: "{{ item.credentials | default(standard_credentials) }}"
        job_type: "{{ item.job_type | default('run') }}"
        forks: "{{ item.forks | default(0) }}"
        verbosity: "{{ item.verbosity | default(0) }}"
        timeout: "{{ item.timeout | default(0) }}"
        diff_mode: "{{ item.show_changes | default(false) }}"
        job_slice_count: "{{ item.job_slicing | default(1) }}"
        state: present
      loop: "{{ aap_job_templates }}"

    - name: Ensure Schedules exist
      awx.awx.schedule:
        name: "{{ item.name }}"
        unified_job_template: "{{ item.template }}"
        rrule: "{{ item.rrule }}"
        state: present
      loop: "{{ aap_schedules }}"

    - name: Ensure Workflow Job Template exists
      awx.awx.workflow_job_template:
        name: "{{ workflow_job_template_name }}"
        organization: "{{ default_organization }}"
        description: "{{ workflow_job_template_description }}"
        inventory: "{{ main_inventory }}"
        state: present

    - name: Ensure Workflow Job Template Nodes exist
      awx.awx.workflow_job_template_node:
        workflow_job_template: "{{ workflow_job_template_name }}"
        unified_job_template: "{{ item }}"
        state: present
        identifier: "{{ item }}"
      loop: "{{ aap_workflow_nodes }}"

    - name: Ensure Hosts exist
      awx.awx.host:
        name: "{{ item }}"
        inventory: "{{ main_inventory }}"
        enabled: true
        state: present
      loop: "{{ aap_hosts }}"