Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/verify-ansible-configuration-management.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Verify ansible-configuration-management

on:
push:
branches: ['main']
paths:
- 'projects/ansible-configuration-management/**'
pull_request:
branches: ['main']
paths:
- 'projects/ansible-configuration-management/**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.x'
- name: Install Ansible and the Docker connection collection
run: |
pip install ansible-core
ansible-galaxy collection install community.docker
- name: Run the demo (configures 2 containers, then proves idempotency)
run: |
cd projects/ansible-configuration-management
chmod +x demo_project.sh; ls -la
./demo_project.sh
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ This is the **third** repo of my DevOps trio repositories: [**tungbq/devops-basi
| 14 | Simple CI pipeline with Github Action | [simple-ci-pipeline-github-actions](./projects/simple-ci-pipeline-github-actions/) | `CI/CD` `GitHub Actions` `Python` | ✔️ Done |
| 15 | Jenkins CI pipeline for Github repo | [#32](https://github.com/tungbq/devops-project/issues/32) | `CI/CD` `Jenkins` | 🚧 Planned |
| 16 | CI/CD pipeline for microservices on Kubernetes | [#71](https://github.com/tungbq/devops-project/issues/71) | `CI/CD` `Kubernetes` `Microservices` | 🚧 Planned |
| 17 | Configuration Management with Ansible | [#14](https://github.com/tungbq/devops-project/issues/14) | `Ansible` `Configuration Management` | 🚧 Planned |
| 17 | Configuration Management with Ansible | [ansible-configuration-management](./projects/ansible-configuration-management/) | `Ansible` `Configuration Management` | ✔️ Done |
| 18 | 3 tier app with k8s | [#9](https://github.com/tungbq/devops-project/issues/9) | `Kubernetes` `Microservices` | 🚧 Planned |
| 19 | Microservices Orchestration | [microservices-orchestration](./projects/microservices-orchestration/) | `Kubernetes` `Microservices` `Scaling` | ✔️ Done |
| 20 | Deploy Kubernetes using Kubespray | [#70](https://github.com/tungbq/devops-project/issues/70) | `Kubernetes` `Kubespray` | 🚧 Planned |
Expand Down
68 changes: 68 additions & 0 deletions projects/ansible-configuration-management/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Project: Configuration Management with Ansible

This project uses Ansible to automate the configuration of multiple "servers" (containers standing in for hosts) with best-practice structure — roles, an inventory, group variables, a Jinja2 template, and a handler — then proves the playbook is genuinely idempotent by running it twice.

## Overview

### Introduction

- Tech stack: `ansible`, `docker`, `nginx`
- To get the basic concept of Ansible, you could visit: [**devops-basics/ansible**](https://github.com/tungbq/devops-basics/blob/main/topics/ansible/README.md)
- The demo targets are plain `ubuntu:22.04` containers reached via Ansible's `community.docker` connection plugin — no SSH keys, no cloud account, no VMs to provision

### Prerequisite

- You have `docker` installed on your machine
- Basic knowledge about Ansible (inventory, playbook, role, task)

### What "best practice" means here

- **Roles, not one giant playbook** — `common` (baseline every host gets) and `webserver` (nginx + a templated page) are separate, reusable roles
- **Inventory-scoped group vars**, not hardcoded values — `app_environment` lives in `inventory/group_vars/all.yml`, not inline in a task
- **Handlers, not unconditional restarts** — nginx only reloads when the templated config actually changed, via `notify`
- **Idempotency, proven not assumed** — the demo runs the playbook twice; the second run reports `changed=0` for every task
- **Explicit `changed_when`** on raw/command tasks — passes `ansible-lint`'s `production` profile clean

## 1-Install Ansible

- `python3 -m pip install ansible-core`
- Then install the Docker connection plugin's collection: `ansible-galaxy collection install community.docker`
- Verify: `ansible --version`

## 2-Spin up the demo target hosts

- Run `docker run -d --name ansible-demo-web1 ubuntu:22.04 sleep infinity` and the same for `ansible-demo-web2` — two identical, unconfigured "servers"
- They're listed in [`inventory/hosts.ini`](./inventory/hosts.ini) with `ansible_connection=community.docker.docker`, so Ansible talks to them via `docker exec` — no SSH setup needed for this demo

## 3-Run the playbook

- Run `ansible-playbook -i inventory/hosts.ini playbooks/site.yml`
- What happens, in order (see [`playbooks/site.yml`](./playbooks/site.yml)):
1. `pre_tasks` bootstraps `python3` and `sudo` via a `raw` command — these minimal images have neither, and Ansible's own modules (including fact-gathering) need Python
2. facts are gathered explicitly, now that Python exists
3. the `common` role updates apt, installs baseline packages, and drops a marker file
4. the `webserver` role installs nginx, templates `index.html` from [`roles/webserver/templates/index.html.j2`](./roles/webserver/templates/index.html.j2), and starts nginx
5. because the template changed, the `Reload nginx` handler fires at the end of the play

## 4-Verify both hosts

- `docker exec ansible-demo-web1 curl -s localhost` and the same for `web2`
- Both serve the same markup with a different `{{ inventory_hostname }}` — one role, two consistently-configured hosts

## 5-Prove idempotency

- Run the exact same command again: `ansible-playbook -i inventory/hosts.ini playbooks/site.yml`
- Expected: `changed=0` for both hosts in the play recap — nothing to do, because nothing drifted
- Change `app_environment` in [`inventory/group_vars/all.yml`](./inventory/group_vars/all.yml) and run a third time: only the templated file (and the handler it notifies) shows as `changed` — everything else stays `ok`

## 6-Bonus

All of the above, scripted end-to-end (fresh containers → configure → verify → prove idempotency), is in [demo_project.sh](./demo_project.sh).

## Related link

- https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_intro.html
- https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_reuse_roles.html
- https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_handlers.html
- https://galaxy.ansible.com/ui/repo/published/community/docker/
- https://github.com/tungbq/devops-basics/blob/main/topics/ansible/README.md
6 changes: 6 additions & 0 deletions projects/ansible-configuration-management/ansible.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[defaults]
inventory = inventory/hosts.ini
roles_path = roles
host_key_checking = False
retry_files_enabled = False
stdout_callback = default
56 changes: 56 additions & 0 deletions projects/ansible-configuration-management/demo_project.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/bin/bash
# Ansible Configuration Management Demo — configures two "servers" (plain
# ubuntu:22.04 containers, reached via the community.docker connection
# plugin, no SSH needed) with a common baseline plus an nginx role, then
# proves the playbook is idempotent by running it a second time.

set -e

CONTAINERS=(ansible-demo-web1 ansible-demo-web2)

echo "==> Ansible version:"
ansible --version | head -1

echo ""
echo "=============================="
echo "1. Spinning up demo target containers"
echo "=============================="
for c in "${CONTAINERS[@]}"; do
docker rm -f "$c" >/dev/null 2>&1 || true
docker run -d --name "$c" ubuntu:22.04 sleep infinity
done
docker ps --filter "name=ansible-demo"

echo ""
echo "=============================="
echo "2. First run — configuring both hosts"
echo "=============================="
ansible-playbook -i inventory/hosts.ini playbooks/site.yml

echo ""
echo "=============================="
echo "3. Verifying both hosts serve the expected, per-host content"
echo "=============================="
for c in "${CONTAINERS[@]}"; do
echo "--- $c ---"
docker exec "$c" curl -s localhost
done

echo ""
echo "=============================="
echo "4. Second run — proving idempotency (expect changed=0 for every host)"
echo "=============================="
ansible-playbook -i inventory/hosts.ini playbooks/site.yml

echo ""
echo "==> Done! Both hosts ended up configured identically, and the second"
echo " run made zero changes — that's the idempotency guarantee a"
echo " well-written playbook gives you: safe to re-run anytime, including"
echo " on a schedule or after a host drifts from its intended config."
echo ""
echo "==> Tip: edit inventory/group_vars/all.yml's app_environment and re-run"
echo " — only the templated file (and the nginx reload it triggers) will"
echo " show as changed."
echo ""
echo "==> Cleanup:"
echo " docker rm -f ${CONTAINERS[*]}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
app_environment: demo
baseline_packages:
- curl
- vim
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[webservers]
ansible-demo-web1 ansible_connection=community.docker.docker
ansible-demo-web2 ansible_connection=community.docker.docker

[webservers:vars]
ansible_python_interpreter=/usr/bin/python3
28 changes: 28 additions & 0 deletions projects/ansible-configuration-management/playbooks/site.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
- name: Configure all demo hosts
hosts: all
become: true
# False here on purpose — the demo targets are minimal ubuntu:22.04
# containers with no python3 preinstalled, and fact gathering itself is a
# python-based module. Bootstrap python3 first (pre_tasks), then gather
# facts explicitly. This is the standard pattern for freshly provisioned
# or minimal hosts, not just a container-demo workaround.
gather_facts: false

pre_tasks:
- name: Bootstrap python3 and sudo if either is missing
# become: false here on purpose — the docker connection already lands
# us as root on these minimal images, and sudo isn't installed yet,
# so `become: true` (the play default) would fail trying to run
# `sudo apt-get ...` before sudo exists. Installing sudo here too is
# what makes `become: true` work for every task after this one.
ansible.builtin.raw: (which python3 && which sudo) || (apt-get update && apt-get install -y python3 sudo)
become: false
changed_when: false

- name: Gather facts now that python3 is available
ansible.builtin.setup:

roles:
- common
- webserver
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600

- name: Install baseline packages
ansible.builtin.apt:
name: "{{ baseline_packages }}"
state: present

- name: Record which role configured this host
# Static content, not a timestamp — a timestamp here would make this task
# (and therefore the whole playbook) report "changed" on every single
# run, defeating the idempotency check this project demonstrates.
ansible.builtin.copy:
dest: /etc/configured-by-ansible
content: "Configured by the common role.\n"
mode: "0644"
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
- name: Reload nginx
ansible.builtin.command: nginx -s reload
# Explicit, not inferred from the command's exit code: a handler only
# ever runs when something notified it, so it always changes state —
# ansible-lint's no-changed-when rule wants that stated, not assumed.
changed_when: true
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present

- name: Deploy the templated index page
ansible.builtin.template:
src: index.html.j2
dest: /var/www/html/index.html
mode: "0644"
notify: Reload nginx

- name: Start nginx if it isn't already running
# No init system inside a plain container, so we can't use the `service`/
# `systemd` module here — start the daemon directly instead. `creates`
# keeps this idempotent: once the pidfile exists, this task won't re-run.
# On a real systemd host, use ansible.builtin.systemd_service instead.
ansible.builtin.command: nginx
args:
creates: /run/nginx.pid
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head><title>{{ inventory_hostname }}</title></head>
<body>
<h1>Hello from {{ inventory_hostname }}</h1>
<p>Environment: {{ app_environment }}</p>
<p>Configured by Ansible — every host in this inventory runs the same
role, so the markup is identical; only the hostname differs.</p>
</body>
</html>