Ansible AWX demo environment


As I was planning an Ansible Meetup about the Ansible Automation Platform (AAP, the successor of Ansible Tower) I was contemplating about a demo environment for the attendants. This can be done with ease as it it nothing more than clicky-di-click.

You can imagine that’s not the way I went :-). There is a simple rule in life:
If you can automate it, automate it. The complete environment is going to be a multipart environment, so that the attendants can experiment and have an environment that slightly resembles real live.

So, what’s needed:

  • Ansible AWX

  • A Git server

  • A managed node to connect to (per attendee)

Ansible AWX

As the Meetup is about Ansible Automation Platform, it seems a weird choice to install AWX. But do not be fooled. AWX is the open source upstream of Ansible AAP. There is some functionality missing, but nothing that hurts us for the meetup. And, for AAP a license is required from Red Hat and I think it to be a waste of money to pay for a license that’s only used for a couple of hours. So, AWX it is.

As this is only a demo environment I’m not to concerned with performance and scalability. So a standard GCP machine is used with 4 cores and 16GB memory running CentOS Stream 8.

As AWX runs containerized, it can be installed in K8S, Minikube or in K3S. As K3S is the easiest way to go, I have decided to do it like that. Installing AWX in K3s is well described by kurokobo on Github. If these instructions are followed an AWX will be up and running in no-time.
https://github.com/kurokobo/awx-on-k3s

A Git server

I want to run a local Git server, that’s not to heavy and that I can destroy and rebuild at will. As I am a big fan of Forgejo (https://forgejo.org/) that is what I went for.

Download the binary package and install it. I created a separate user for the Git repos and also created a service file. I didn’t bother with SSL and a webserver in front of it, just let Forgejo handle all requests directly.

useradd -c "Git user" -m git
mkdir /data/git
chown git:git /data/git
chmod 700 /data/git
su - git
wget https://codeberg.org/forgejo/forgejo/releases/download/v1.19.3-0/forgejo-1.19.3-0-linux-amd64
chmod 755 forgejo-1.19.3-0-linux-amd64
mkdir run
cd run
cp -p ../forgejo-1.19.3-0-linux-amd64 forgejo
ln -s forgejo git

Configure the Git server to your liking. This is my git.ini

APP_NAME = Git
RUN_USER = git
RUN_MODE = prod

[repository]
ROOT                = /data/git/repos
FORCE_PRIVATE       = false
MAX_CREATION_LIMIT  = 50
MIRROR_QUEUE_LENGTH = 1000
DISABLE_HTTP_GIT    = false

[ui]
SHOW_USER_EMAIL = false
THEMES          = git,arc-green
DEFAULT_THEME   = git

[server]
PROTOCOL         = http
DOMAIN           = <your_domain_name>
ROOT_URL         = http://git.<your_domain_name>:3000
HTTP_ADDR        = 0.0.0.0
HTTP_PORT        = 3000
DISABLE_SSH      = false
START_SSH_SERVER = true
SSH_DOMAIN       = localhost
SSH_LISTEN_HOST  = 0.0.0.0
SSH_PORT         = 2222
SSH_LISTEN_PORT  = %(SSH_PORT)s
OFFLINE_MODE     = true
APP_DATA_PATH    = /data/git/data

[database]
DB_TYPE  = sqlite3
HOST     = 127.0.0.0:3306
NAME     = root
USER     = git
PASSWD   = lel
SSL_MODE = disable
PATH     = /data/git/data/git.db

[indexer]
ISSUE_INDEXER_PATH            = /data/git/indexers/issues.bleve
ISSUE_INDEXER_QUEUE_TYPE      = levelqueue
ISSUE_INDEXER_QUEUE_DIR       = /data/git/indexers/issues.queue
REPO_INDEXER_ENABLED          = False
REPO_INDEXER_PATH             = /data/git/indexers/repos.bleve
REPO_INDEXER_INCLUDE          =
REPO_INDEXER_EXCLUDE          =
REPO_INDEXER_EXCLUDE_VENDORED = True
MAX_FILE_SIZE                 = 1048576

[security]
INSTALL_LOCK        = true
SECRET_KEY          = <very_secret_key>
INTERNAL_TOKEN      = <very_secret_token>
LOGIN_REMEMBER_DAYS = 7
DISABLE_GIT_HOOKS   = true

[service]
DISABLE_REGISTRATION             = true
REQUIRE_SIGNIN_VIEW              = true
ENABLE_CAPTCHA                   = true
CAPTCHA_TYPE                     = image
RECAPTCHA_SECRET                 =
RECAPTCHA_SITEKEY                =
SHOW_REGISTRATION_BUTTON         = False
ALLOW_ONLY_EXTERNAL_REGISTRATION = False
ENABLE_NOTIFY_MAIL               = False
DEFAULT_EMAIL_NOTIFICATIONS      = onmention
AUTO_WATCH_NEW_REPOS             = False
AUTO_WATCH_ON_CHANGES            = True
SHOW_MILESTONES_DASHBOARD_PAGE   = True

[mailer]
ENABLED            = false
HOST               = localhost:25
SKIP_VERIFY        = False
USE_CERTIFICATE    = false
CERT_FILE          = /data/git/custom/mailer/cert.pem
KEY_FILE           = /data/git/custom/mailer/key.pem
IS_TLS_ENABLED     = true
FROM               = noreply@your.domain
USER               =
PASSWD             =
SEND_AS_PLAIN_TEXT = false
MAILER_TYPE        = smtp
SENDMAIL_PATH      = sendmail

[session]
PROVIDER        = file
PROVIDER_CONFIG = /data/git/data/sessions

[picture]
AVATAR_UPLOAD_PATH = /data/git/data/avatars
DISABLE_GRAVATAR   = true

[attachment]
ENABLED = true
PATH    = /data/git/data/attachments

[log]
ROOT_PATH            = /data/git/log
MODE                 = file
BUFFER_LEN           = 10000
LEVEL                = Info
REDIRECT_MACARON_LOG = false

[oauth2]
ENABLE     = True
JWT_SECRET = <very_secret_jwt_secret>

[metrics]
ENABLED = False
TOKEN   =

And, of course, a systemd service file is needed.

cat <<- @EOF > /etc/systemd/system/git.service
    [Unit]
    Description=Git
    After=syslog.target
    After=network.target
    #After=mariadb.service mysqld.service postgresql.service memcached.service redis.service

    [Service]
    Type=simple
    User=git
    Group=git
    WorkingDirectory=/home/git/run
    ExecStart=/home/git/run/git web
    Restart=always
    Environment=USER=git HOME=/home/git PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin

    [Install]
    WantedBy=multi-user.target
@EOF
systemctl daemon-reload
systemctl enable --now git

Managed node

I also need a managed node for every attendee, but there are a couple of things I want:

  1. A single container image, used by all containers

  2. No persistent storage, if I remove the container no residue should be left

  3. A static and predictable IP address per container

  4. systemd and sshd should be running inside the container, so Ansible can be used with SSH

These demands result in a Containerfile that starts sshd through systemd. The /etc/ssh/ssh_host_* key files are generated once and after that copied out of the container. The reason for this is that by default a new installation of SSH does not have this set of keys. These keys are automatically generated at first boot. But with a large amount of containers, all starting at the same time, this could render the system completely useless. Adding the keys to the container at built-time prevents the thundering herd.

#
# Build with:
#     podman build --squash-all --tag=ansinode .
#
FROM almalinux:9
LABEL maintainer="Ton Kersten <tonk@ansiblelab.nl>"

# Ansible home
ARG ansihome="/home/ansible"

# We are in Europe but we use UTC to be global
ENV TZ=UTC

# Ensure needed packages and enable SSH (Needed for Ansible)
RUN    dnf -y --refresh install        \
       epel-release                    \
       systemd                         \
       sudo                            \
       passwd                          \
       iproute                         \
       openssh-server                  \
       openssh-clients               ; \
    dnf clean all                    ; \
    systemctl enable sshd.service    ; \
    echo "root" | passwd --stdin root

# Add the Ansible user
RUN groupadd -g 10000 ansible                              ; \
    useradd -u 10000 -g ansible -m -d $ansihome ansible    ; \
    mkdir -m 0700 $ansihome/.ssh                           ; \
    chown ansible:ansible $ansihome/.ssh                   ; \
    echo "ansible" | passwd --stdin ansible

# Add some needed files and fix rights
ADD podfiles/id_rsa.pub    $ansihome/.ssh/authorized_keys
ADD podfiles/sudoers       /etc/sudoers.d/ansible
RUN chown ansible:ansible $ansihome/.ssh/authorized_keys   ; \
    chmod 640 $ansihome/.ssh/authorized_keys               ; \
    chown root:root /etc/sudoers.d/ansible                 ; \
    chmod 400 /etc/sudoers.d/ansible

# Add the systems host keys
ADD podfiles/ssh_host_ecdsa_key        /etc/ssh/ssh_host_ecdsa_key
ADD podfiles/ssh_host_ecdsa_key.pub    /etc/ssh/ssh_host_ecdsa_key.pub
ADD podfiles/ssh_host_ed25519_key      /etc/ssh/ssh_host_ed25519_key
ADD podfiles/ssh_host_ed25519_key.pub  /etc/ssh/ssh_host_ed25519_key.pub
ADD podfiles/ssh_host_rsa_key          /etc/ssh/ssh_host_rsa_key
ADD podfiles/ssh_host_rsa_key.pub      /etc/ssh/ssh_host_rsa_key.pub
RUN chown root:ssh_keys   /etc/ssh/ssh_host_ecdsa_key        ; \
    chown root:root       /etc/ssh/ssh_host_ecdsa_key.pub    ; \
    chown root:ssh_keys   /etc/ssh/ssh_host_ed25519_key      ; \
    chown root:root       /etc/ssh/ssh_host_ed25519_key.pub  ; \
    chown root:ssh_keys   /etc/ssh/ssh_host_rsa_key          ; \
    chown root:root       /etc/ssh/ssh_host_rsa_key.pub      ; \
    chmod 640             /etc/ssh/ssh_host_ecdsa_key        ; \
    chmod 644             /etc/ssh/ssh_host_ecdsa_key.pub    ; \
    chmod 640             /etc/ssh/ssh_host_ed25519_key      ; \
    chmod 644             /etc/ssh/ssh_host_ed25519_key.pub  ; \
    chmod 640             /etc/ssh/ssh_host_rsa_key          ; \
    chmod 644             /etc/ssh/ssh_host_rsa_key.pub

# Fix PAM for SSH login
RUN sed -i 's/required/optional/g' /etc/pam.d/sshd

# Open SSH port
EXPOSE 22

# And make sure systemd is running
CMD ["/usr/sbin/init"]

The name of the container image is ansinode and this will used to create a managed node node1 per attendee.

Starting the containers

Once the container image has been built, this image is used to start a container per attendee with specific settings per attendee. This immediately calls for a template.

# vi: set sw=4 ts=4 ai:
#
# Ansible information:
#     Filename : {{ template_path | regex_replace("/.*/ansible[^/]*/", ".../") }}
#     Hostname : {{ template_host }}
#
# podman container service file for {{ gnum }}'s node1
#

[Unit]
Description=Ansible Lab Container-{{ gnum }}-node1.service
Documentation=man:podman-generate-systemd(1)
Wants=network.target
After=network-online.target
RequiresMountsFor=/var/lib/containers/storage /run/containers/storage

[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
TimeoutStopSec=70
Restart=always
RestartSec=3
ExecStartPre=/bin/rm -f %t/container-{{ gnum }}-node1.pid %t/container-{{ gnum }}-node1.ctr-id
ExecStart=/usr/bin/podman                                \
    run                                                  \
    --conmon-pidfile %t/container-{{ gnum }}-node1.pid   \
    --cidfile %t/container-{{ gnum }}-node1.ctr-id       \
    --cgroups=no-conmon                                  \
     --replace                                           \
    --detach                                             \
    --rm                                                 \
    --tz UTC                                             \
    --systemd true                                       \
    --tmpfs /run                                         \
    --security-opt seccomp=unconfined                    \
    --security-opt label=disable                         \
    --cap-add SYS_RESOURCE                               \
    --cap-add AUDIT_WRITE                                \
    --volume /sys/fs/cgroup:/sys/fs/cgroup:ro            \
    --hostname=node1.example.net                         \
    --ip={{ ip }}                                        \
    --name=guru{{ gnum }}-node1                          \
    ansinode
ExecStop=/usr/bin/podman stop --ignore --cidfile %t/container-{{ gnum }}-node1.ctr-id -t 10
ExecStopPost=/usr/bin/podman rm --ignore -f --cidfile %t/container-{{ gnum }}-node1.ctr-id
PIDFile=%t/container-{{ gnum }}-node1.pid
Type=simple

[Install]
WantedBy=multi-user.target default.target

Automate it

So far so good, now we have everything in place to automate the heck out of it.

The first playbook is the easiest one, it is the playbook that runs the tasklist per guru in a loop.

---
- name: Create Guru environment in AWX
  hosts: localhost
  connection: local
  become: false
  gather_facts: false

  vars:
    gitea_api_url: "http://localhost:3000/api/v1"
    gitea_token: "<my_very_secret_api_token>"
    gurus: 30

  tasks:
    - name: Create Guru admins role {{ gurunum }}
      ansible.builtin.include_tasks: guru.yml
      loop: "{{ range(0, gurus+1) | list }}"
      loop_control:
        loop_var: gurunum

This creates gurus + 1 organizations in AWX, with an organization admin, a project, inventory, templates, credentials, vault password, the whole caboodle.

---
- name: Ensure the Guru number is the correct format
  ansible.builtin.set_fact:
    gnum: "{{ '%03d' | format(gurunum) }}"

- name: Create Git user through API
  ansible.builtin.uri:
    url: '{{ gitea_api_url }}/admin/users'
    method: POST
    return_content: true
    status_code:
      - 201
      - 204
      - 422
    body_format: json
    headers:
      Authorization: token {{ gitea_token }}
    body: |-
      {
        "email": "guru{{ gnum }}@ansiblelab.nl",
        "full_name": "Slartibartfast Magrathean {{ gnum }}",
        "login_name": "guru{{ gnum }}",
        "must_change_password": false,
        "password": "PlanetBuilder{{ gnum }}",
        "restricted": true,
        "send_notify": false,
        "source_id": 0,
        "username": "guru{{ gnum }}",
        "visibility": "private"
      }

- name: Add the ping repository to Git user through API
  ansible.builtin.uri:
    url: '{{ gitea_api_url }}/repos/templater/template_ping/generate'
    method: POST
    return_content: true
    status_code:
      - 201
      - 204
      - 409
      - 422
    body_format: json
    headers:
      Authorization: token {{ gitea_token }}
    body: |-
      {
        "owner": "guru{{ gnum }}",
        "name": "ping",
        "avatar": true,
        "description": "The Ping Project",
        "default_branch": "main",
        "git_content": true,
        "git_hooks": true,
        "labels": true,
        "topics": true,
        "webhooks": true,
        "private": true
      }

- name: Create Guru organization {{ gnum }}
  awx.awx.organization:
    name: ORG | Guru{{ gnum }} Universe Building LTD
    description: ORG | Ansible Universe for guru{{ gnum }}
    galaxy_credentials:
      - Ansible Galaxy
    state: present

- name: Create Guru user {{ gnum }}
  awx.awx.user:
    username: guru{{ gnum }}
    password: PlanetBuilder{{ gnum }}
    first_name: Slartibartfast {{ gurunum }}
    last_name: Magrathean {{ gnum }}
    email: guru{{ gnum }}@ansiblelab.nl
    organization: ORG | Guru{{ gnum }} Universe Building LTD

- name: Create Guru admins {{ gnum }}
  awx.awx.team:
    name: TEAM | Planet Building Team {{ gnum }}
    description: TEAM | Ansible Administrator Team for Universe Building {{ gnum }}
    organization: ORG | Guru{{ gnum }} Universe Building LTD
    state: present

- name: Create Guru admins role {{ gnum }}
  awx.awx.role:
    user: guru{{ gnum }}
    role: admin
    target_teams: TEAM | Planet Building Team {{ gnum }}
    organization: ORG | Guru{{ gnum }} Universe Building LTD

- name: Create Guru Host credential {{ gnum }}
  awx.awx.credential:
    name: CRED | Guru{{ gnum }} Machine Credential
    description: CRED | Ansible Machine SSH key Guru{{ gnum }}
    organization: ORG | Guru{{ gnum }} Universe Building LTD
    state: present
    credential_type: Machine
    inputs:
      username: ansible
      ssh_key_data: "{{ lookup('file', 'podfiles/id_rsa') }}"

- name: Create Guru Source Control credential {{ gnum }}
  awx.awx.credential:
    name: CRED | Guru{{ gnum }} Source Control Credential
    description: CRED | Ansible Source Control SSH key Guru{{ gnum }}
    organization: ORG | Guru{{ gnum }} Universe Building LTD
    state: present
    credential_type: Source Control
    inputs:
      username: guru{{ gnum }}
      password: PlanetBuilder{{ gnum }}

- name: Create Guru vault password {{ gnum }}
  awx.awx.credential:
    name: CRED | Guru{{ gnum }} Vault Password
    description: CRED | Ansible Vault password for Guru{{ gnum }}
    organization: ORG | Guru{{ gnum }} Universe Building LTD
    state: present
    credential_type: Vault
    inputs:
      vault_password: VaultPassword

- name: Create inventory for Guru {{ gnum }}
  awx.awx.inventory:
    name: INV | Guru{{ gnum }} Static Inventory
    description: INV | Static Inventory for Managed Hosts for Guru{{ gnum }}
    organization: ORG | Guru{{ gnum }} Universe Building LTD
    state: present

- name: Create node1 {{ gnum }}
  awx.awx.host:
    name: node1
    description: HOST | Guru{{ gnum }} managed host
    inventory: INV | Guru{{ gnum }} Static Inventory
    state: present
    enabled: true
    variables:
      ansible_user: ansible
      ansible_host: 10.88.{{ gurunum }}.10

- name: Create project {{ gnum }}
  awx.awx.project:
    name: PRJ | Guru{{ gnum }} Ping Project
    description: PRJ | Ping project for Guru{{ gnum }}
    organization: ORG | Guru{{ gnum }} Universe Building LTD
    wait: true
    scm_type: git
    scm_url: "http://awx.ansiblelab.nl:3000/guru{{ gnum }}/ping.git"
    scm_clean: true
    scm_update_on_launch: true
    scm_update_cache_timeout: 30
    state: present
    credential: CRED | Guru{{ gnum }} Source Control Credential

- name: Create ping job template {{ gnum }}
  awx.awx.job_template:
    name: TMPL | Guru{{ gnum }} Ping
    description: TMPL | Guru{{ gnum }} Ping Template
    job_type: run
    organization: ORG | Guru{{ gnum }} Universe Building LTD
    project: PRJ | Guru{{ gnum }} Ping Project
    inventory: INV | Guru{{ gnum }} Static Inventory
    playbook: ping.yml
    credentials:
      - CRED | Guru{{ gnum }} Machine Credential
      - CRED | Guru{{ gnum }} Vault Password
    state: present

- name: Create ping job template with survey {{ gnum }}
  awx.awx.job_template:
    name: TMPL | Guru{{ gnum }} Ping Survey
    description: TMPL | Guru{{ gnum }} Ping Template with Survey
    job_type: run
    organization: ORG | Guru{{ gnum }} Universe Building LTD
    project: PRJ | Guru{{ gnum }} Ping Project
    inventory: INV | Guru{{ gnum }} Static Inventory
    playbook: ping.yml
    credentials:
      - CRED | Guru{{ gnum }} Machine Credential
      - CRED | Guru{{ gnum }} Vault Password
    state: present
    survey_enabled: true
    survey_spec: "{{ lookup('template', 'survey.json.j2') }}"

- name: Do things as root
  block:
    - name: Create Guru{{ gnum }} node1 service file
      ansible.builtin.template:
        src: container-node1.service.j2
        dest: /etc/systemd/system/guru{{ gnum }}-node1.service
        owner: root
        group: root
        mode: 0644
      vars:
        ip: 10.88.{{ gurunum }}.10

    - name: Reload systemd Guru{{ gnum }}
      ansible.builtin.systemd:
        daemon_reload: true

    - name: Start node container for Guru{{ gnum }}
      ansible.builtin.service:
        name: guru{{ gnum }}-node1.service
        state: started
        enabled: true
  become: true

See also