From 593715760d9bb7f0d6435c1fc2cc79180aee13be Mon Sep 17 00:00:00 2001 From: Tung Leo Date: Fri, 4 Sep 2026 13:33:56 +0000 Subject: [PATCH] Add Configuration Management with Ansible project Closes #14. - projects/ansible-configuration-management/: an Ansible best-practices demo (roles, inventory group_vars, a Jinja2 template, a handler) targeting two ubuntu:22.04 containers via the community.docker connection plugin - no SSH keys, no cloud account needed. - common role: apt baseline + a marker file. webserver role: installs nginx, templates index.html per-host, notifies a reload handler only when the template actually changes. - demo_project.sh runs the playbook twice end-to-end and asserts the second run reports changed=0 for every host - proving idempotency, not just claiming it. Also verified the notify/handler path by editing group_vars and confirming only the changed file + reload show up, and the served content actually updates. - Passes ansible-lint's production profile and shellcheck clean. - Adds .github/workflows/verify-ansible-configuration-management.yml, matching the existing per-project verify-*.yml convention. - README.md: marks row 17 (Configuration Management with Ansible) Done, linking to the new project folder instead of issue #14. --- ...erify-ansible-configuration-management.yml | 29 ++++++++ README.md | 2 +- .../README.md | 68 +++++++++++++++++++ .../ansible.cfg | 6 ++ .../demo_project.sh | 56 +++++++++++++++ .../inventory/group_vars/all.yml | 5 ++ .../inventory/hosts.ini | 6 ++ .../playbooks/site.yml | 28 ++++++++ .../roles/common/tasks/main.yml | 19 ++++++ .../roles/webserver/handlers/main.yml | 7 ++ .../roles/webserver/tasks/main.yml | 21 ++++++ .../roles/webserver/templates/index.html.j2 | 10 +++ 12 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/verify-ansible-configuration-management.yml create mode 100644 projects/ansible-configuration-management/README.md create mode 100644 projects/ansible-configuration-management/ansible.cfg create mode 100755 projects/ansible-configuration-management/demo_project.sh create mode 100644 projects/ansible-configuration-management/inventory/group_vars/all.yml create mode 100644 projects/ansible-configuration-management/inventory/hosts.ini create mode 100644 projects/ansible-configuration-management/playbooks/site.yml create mode 100644 projects/ansible-configuration-management/roles/common/tasks/main.yml create mode 100644 projects/ansible-configuration-management/roles/webserver/handlers/main.yml create mode 100644 projects/ansible-configuration-management/roles/webserver/tasks/main.yml create mode 100644 projects/ansible-configuration-management/roles/webserver/templates/index.html.j2 diff --git a/.github/workflows/verify-ansible-configuration-management.yml b/.github/workflows/verify-ansible-configuration-management.yml new file mode 100644 index 0000000..d481c71 --- /dev/null +++ b/.github/workflows/verify-ansible-configuration-management.yml @@ -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 diff --git a/README.md b/README.md index b318d4d..9ade6a9 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/projects/ansible-configuration-management/README.md b/projects/ansible-configuration-management/README.md new file mode 100644 index 0000000..351910c --- /dev/null +++ b/projects/ansible-configuration-management/README.md @@ -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 diff --git a/projects/ansible-configuration-management/ansible.cfg b/projects/ansible-configuration-management/ansible.cfg new file mode 100644 index 0000000..fe74da5 --- /dev/null +++ b/projects/ansible-configuration-management/ansible.cfg @@ -0,0 +1,6 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +retry_files_enabled = False +stdout_callback = default diff --git a/projects/ansible-configuration-management/demo_project.sh b/projects/ansible-configuration-management/demo_project.sh new file mode 100755 index 0000000..8bada25 --- /dev/null +++ b/projects/ansible-configuration-management/demo_project.sh @@ -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[*]}" diff --git a/projects/ansible-configuration-management/inventory/group_vars/all.yml b/projects/ansible-configuration-management/inventory/group_vars/all.yml new file mode 100644 index 0000000..ef135db --- /dev/null +++ b/projects/ansible-configuration-management/inventory/group_vars/all.yml @@ -0,0 +1,5 @@ +--- +app_environment: demo +baseline_packages: + - curl + - vim diff --git a/projects/ansible-configuration-management/inventory/hosts.ini b/projects/ansible-configuration-management/inventory/hosts.ini new file mode 100644 index 0000000..949ab46 --- /dev/null +++ b/projects/ansible-configuration-management/inventory/hosts.ini @@ -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 diff --git a/projects/ansible-configuration-management/playbooks/site.yml b/projects/ansible-configuration-management/playbooks/site.yml new file mode 100644 index 0000000..63bb496 --- /dev/null +++ b/projects/ansible-configuration-management/playbooks/site.yml @@ -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 diff --git a/projects/ansible-configuration-management/roles/common/tasks/main.yml b/projects/ansible-configuration-management/roles/common/tasks/main.yml new file mode 100644 index 0000000..1fdf44e --- /dev/null +++ b/projects/ansible-configuration-management/roles/common/tasks/main.yml @@ -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" diff --git a/projects/ansible-configuration-management/roles/webserver/handlers/main.yml b/projects/ansible-configuration-management/roles/webserver/handlers/main.yml new file mode 100644 index 0000000..5bb597a --- /dev/null +++ b/projects/ansible-configuration-management/roles/webserver/handlers/main.yml @@ -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 diff --git a/projects/ansible-configuration-management/roles/webserver/tasks/main.yml b/projects/ansible-configuration-management/roles/webserver/tasks/main.yml new file mode 100644 index 0000000..5ca533f --- /dev/null +++ b/projects/ansible-configuration-management/roles/webserver/tasks/main.yml @@ -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 diff --git a/projects/ansible-configuration-management/roles/webserver/templates/index.html.j2 b/projects/ansible-configuration-management/roles/webserver/templates/index.html.j2 new file mode 100644 index 0000000..f2ce3da --- /dev/null +++ b/projects/ansible-configuration-management/roles/webserver/templates/index.html.j2 @@ -0,0 +1,10 @@ + + +{{ inventory_hostname }} + +

Hello from {{ inventory_hostname }}

+

Environment: {{ app_environment }}

+

Configured by Ansible — every host in this inventory runs the same + role, so the markup is identical; only the hostname differs.

+ +