Skip to content

Commit c9e446e

Browse files
authored
Merge pull request #6 from 3llimi/lab05
Ahmed Baha Eddine Alimi - B23-SD-01 [Lab05 + Bonus Task]
2 parents 03b08a4 + 7b16f7a commit c9e446e

19 files changed

Lines changed: 526 additions & 0 deletions

File tree

ansible/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*.retry
2+
.vault_pass
3+
__pycache__/

ansible/ansible.cfg

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[defaults]
2+
inventory = inventory/hosts.ini
3+
roles_path = roles
4+
host_key_checking = False
5+
remote_user = vagrant
6+
retry_files_enabled = False
7+
deprecation_warnings = False
8+
9+
[privilege_escalation]
10+
become = True
11+
become_method = sudo
12+
become_user = root

ansible/docs/LAB05.md

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
# Lab 05 — Ansible Fundamentals
2+
3+
## 1. Architecture Overview
4+
5+
**Ansible Version:** 2.10.8
6+
**Target VM OS:** Ubuntu 22.04 LTS (jammy64)
7+
**Control Node:** Same VM (Ansible runs on the VM and targets itself via `ansible_connection=local`)
8+
9+
### Role Structure
10+
11+
```
12+
ansible/
13+
├── inventory/
14+
│ ├── hosts.ini # Static inventory (localhost)
15+
│ └── dynamic_inventory.py # Dynamic inventory script (bonus)
16+
├── roles/
17+
│ ├── common/ # Common system packages
18+
│ │ ├── tasks/main.yml
19+
│ │ └── defaults/main.yml
20+
│ ├── docker/ # Docker installation
21+
│ │ ├── tasks/main.yml
22+
│ │ ├── handlers/main.yml
23+
│ │ └── defaults/main.yml
24+
│ └── app_deploy/ # Application deployment
25+
│ ├── tasks/main.yml
26+
│ ├── handlers/main.yml
27+
│ └── defaults/main.yml
28+
├── playbooks/
29+
│ ├── site.yml # Main playbook
30+
│ ├── provision.yml # System provisioning
31+
│ └── deploy.yml # App deployment
32+
├── group_vars/
33+
│ └── all.yml # Encrypted variables (Vault)
34+
├── ansible.cfg # Ansible configuration
35+
└── docs/
36+
└── LAB05.md
37+
```
38+
39+
### Why Roles Instead of Monolithic Playbooks?
40+
41+
Roles enforce separation of concerns — each role has a single responsibility (common packages, Docker setup, app deployment). This makes the codebase reusable across projects, easier to test independently, and simple to maintain. A monolithic playbook mixing all tasks together would become unmanageable as complexity grows.
42+
43+
---
44+
45+
## 2. Roles Documentation
46+
47+
### common
48+
49+
**Purpose:** Ensures every server has essential system tools installed and the apt cache is up to date.
50+
51+
**Variables (defaults/main.yml):**
52+
```yaml
53+
common_packages:
54+
- python3-pip
55+
- curl
56+
- git
57+
- vim
58+
- htop
59+
- wget
60+
- unzip
61+
```
62+
63+
**Handlers:** None — package installation does not require service restarts.
64+
65+
**Dependencies:** None.
66+
67+
---
68+
69+
### docker
70+
71+
**Purpose:** Installs Docker CE from the official Docker repository, ensures the Docker service is running and enabled on boot, and adds the target user to the `docker` group.
72+
73+
**Variables (defaults/main.yml):**
74+
```yaml
75+
docker_user: vagrant
76+
```
77+
78+
**Handlers (handlers/main.yml):**
79+
- `restart docker` — Restarts the Docker service. Triggered when Docker packages are installed or updated.
80+
81+
**Dependencies:** Depends on `common` role being run first (curl must be available for GPG key download).
82+
83+
---
84+
85+
### app_deploy
86+
87+
**Purpose:** Authenticates with Docker Hub, pulls the application image, removes any existing container, runs a fresh container with the correct port mapping, and verifies the application is healthy.
88+
89+
**Variables (defaults/main.yml):**
90+
```yaml
91+
app_port: 8000
92+
app_restart_policy: unless-stopped
93+
app_env_vars: {}
94+
```
95+
96+
**Sensitive variables (group_vars/all.yml — Vault encrypted):**
97+
- `dockerhub_username`
98+
- `dockerhub_password`
99+
- `docker_image`
100+
- `docker_image_tag`
101+
- `app_container_name`
102+
103+
**Handlers (handlers/main.yml):**
104+
- `restart app` — Restarts the application container when triggered.
105+
106+
**Dependencies:** Depends on `docker` role — Docker must be installed before deploying containers.
107+
108+
---
109+
110+
## 3. Idempotency Demonstration
111+
112+
### First Run Output
113+
```
114+
PLAY [Provision web servers]
115+
TASK [Gathering Facts] ok: [localhost]
116+
TASK [common : Update apt cache] ok: [localhost]
117+
TASK [common : Install common packages] changed: [localhost]
118+
TASK [docker : Install prerequisites] ok: [localhost]
119+
TASK [docker : Create keyrings directory] ok: [localhost]
120+
TASK [docker : Add Docker GPG key] changed: [localhost]
121+
TASK [docker : Add Docker repository] changed: [localhost]
122+
TASK [docker : Install Docker packages] changed: [localhost]
123+
TASK [docker : Ensure Docker service is running and enabled] ok: [localhost]
124+
TASK [docker : Add user to docker group] changed: [localhost]
125+
TASK [docker : Install python3-docker] changed: [localhost]
126+
RUNNING HANDLER [docker : restart docker] changed: [localhost]
127+
128+
PLAY RECAP
129+
localhost : ok=12 changed=7 unreachable=0 failed=0
130+
```
131+
132+
### Second Run Output
133+
```
134+
PLAY [Provision web servers]
135+
TASK [Gathering Facts] ok: [localhost]
136+
TASK [common : Update apt cache] ok: [localhost]
137+
TASK [common : Install common packages] ok: [localhost]
138+
TASK [docker : Install prerequisites] ok: [localhost]
139+
TASK [docker : Create keyrings directory] ok: [localhost]
140+
TASK [docker : Add Docker GPG key] ok: [localhost]
141+
TASK [docker : Add Docker repository] ok: [localhost]
142+
TASK [docker : Install Docker packages] ok: [localhost]
143+
TASK [docker : Ensure Docker service is running and enabled] ok: [localhost]
144+
TASK [docker : Add user to docker group] ok: [localhost]
145+
TASK [docker : Install python3-docker] ok: [localhost]
146+
147+
PLAY RECAP
148+
localhost : ok=11 changed=0 unreachable=0 failed=0
149+
```
150+
151+
### Analysis
152+
153+
**First run — what changed and why:**
154+
- `Install common packages` — packages were not yet installed
155+
- `Add Docker GPG key` — key file did not exist
156+
- `Add Docker repository` — repository was not configured
157+
- `Install Docker packages` — Docker was not installed
158+
- `Add user to docker group` — vagrant user was not in docker group
159+
- `Install python3-docker` — Python Docker library was not installed
160+
- `restart docker` handler — triggered because Docker packages were installed
161+
162+
**Second run — why nothing changed:**
163+
Every Ansible module checks the current state before acting. `apt` checks if packages are already present. `file` checks if the directory exists. `apt_repository` checks if the repo is already configured. `user` checks group membership. Since the desired state was already achieved on the first run, no changes were needed on the second run.
164+
165+
**What makes these roles idempotent:**
166+
- Using `apt: state=present` instead of running raw install commands
167+
- Using `file: state=directory` instead of `mkdir`
168+
- Using `apt_repository` module which checks before adding
169+
- Using `creates:` argument on the shell task for the GPG key — skips if file already exists
170+
- Using `service: state=started` instead of raw `systemctl start`
171+
172+
---
173+
174+
## 4. Ansible Vault Usage
175+
176+
### How Credentials Are Stored
177+
178+
Sensitive data (Docker Hub credentials, image name, ports) are stored in `group_vars/all.yml`, encrypted with Ansible Vault. The file is safe to commit to Git because it is AES-256 encrypted.
179+
180+
### Vault Password Management
181+
182+
The vault password is never stored in the repository. It is entered interactively at runtime using `--ask-vault-pass`. In a CI/CD pipeline, it would be stored as a secret environment variable and passed via `--vault-password-file`.
183+
184+
### Encrypted File Example
185+
186+
```
187+
$ANSIBLE_VAULT;1.1;AES256
188+
33313938643165336263383332623738323039613932393034366566663834623931343937353161
189+
3434396331653966343466303138646234366464393065630a616662363939653539643733336638
190+
32333339366530373137353139313561343762313562666437303966363337633366623462326366
191+
...
192+
```
193+
194+
This is what `group_vars/all.yml` looks like in the repository — unreadable without the vault password.
195+
196+
### Why Ansible Vault Is Necessary
197+
198+
Without Vault, credentials like Docker Hub tokens would be stored in plain text in the repository, exposing them to anyone with repository access. Vault allows secrets to be version-controlled safely alongside the code that uses them, without risk of credential leakage.
199+
200+
---
201+
202+
## 5. Deployment Verification
203+
204+
### deploy.yml Run Output
205+
```
206+
TASK [app_deploy : Log in to Docker Hub] changed: [localhost]
207+
TASK [app_deploy : Pull Docker image] ok: [localhost]
208+
TASK [app_deploy : Stop existing container] ...ignoring (no container existed)
209+
TASK [app_deploy : Remove old container] ok: [localhost]
210+
TASK [app_deploy : Run application container] changed: [localhost]
211+
TASK [app_deploy : Wait for application to be ready] ok: [localhost]
212+
TASK [app_deploy : Verify health endpoint] ok: [localhost]
213+
214+
PLAY RECAP
215+
localhost : ok=8 changed=2 unreachable=0 failed=0 ignored=1
216+
```
217+
218+
### Container Status (`docker ps`)
219+
```
220+
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
221+
8376a0ef5240 3llimi/devops-info-service:latest "python app.py" 28 seconds ago Up 27 seconds 0.0.0.0:8000->8000/tcp devops-info-service
222+
```
223+
224+
### Health Check Verification
225+
```bash
226+
$ curl http://localhost:8000/health
227+
{"status":"healthy","timestamp":"2026-02-21T02:04:28.847408+00:00","uptime_seconds":25}
228+
229+
$ curl http://localhost:8000/
230+
{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"FastAPI"},
231+
"system":{"hostname":"8376a0ef5240","platform":"Linux",...},
232+
"runtime":{"uptime_seconds":25,...}}
233+
```
234+
235+
### Handler Execution
236+
237+
The `restart docker` handler in the docker role was triggered during the first provisioning run when Docker packages were installed. On subsequent runs it was not triggered because no changes were made to Docker packages — demonstrating that handlers only fire when their notifying task actually changes something.
238+
239+
---
240+
241+
## 6. Key Decisions
242+
243+
**Why use roles instead of plain playbooks?**
244+
Roles enforce a standard structure that makes code reusable and maintainable. Each role can be developed, tested, and shared independently. A single monolithic playbook with all tasks mixed together would be harder to read, impossible to reuse, and difficult to test in isolation.
245+
246+
**How do roles improve reusability?**
247+
Each role encapsulates all logic for a single concern — the `docker` role can be dropped into any other project that needs Docker installed, without copying individual tasks. Default variables allow roles to be customized without modifying their internals.
248+
249+
**What makes a task idempotent?**
250+
A task is idempotent when it checks the current state before acting and only makes changes if the desired state is not already achieved. Ansible's built-in modules (apt, service, file, user) handle this automatically — unlike raw shell commands which always execute regardless of current state.
251+
252+
**How do handlers improve efficiency?**
253+
Handlers only run when notified by a task that actually made a change. Without handlers, you would restart Docker after every playbook run even if nothing changed. With handlers, Docker is only restarted when packages are actually installed or updated — avoiding unnecessary service disruptions.
254+
255+
**Why is Ansible Vault necessary?**
256+
Any secret stored in plain text in a Git repository is effectively public, even in private repos. Vault encrypts secrets at rest while keeping them version-controlled alongside the infrastructure code. This allows the full Ansible project (including secrets) to be committed to Git safely.
257+
258+
---
259+
260+
## 7. Challenges
261+
262+
- **WSL2 disk space:** The WSL2 Alpine distro had only 136MB disk space, not enough to install Ansible. Solved by installing Ansible directly on the Vagrant VM and running it against localhost.
263+
- **Docker login module:** `community.general.docker_login` failed in Ansible 2.10. Solved by using a `shell` task with `docker login --password-stdin` instead.
264+
- **group_vars not loading with become:** Vault-encrypted `group_vars/all.yml` variables were not accessible when `become: yes` was set at the play level. Solved by passing variables explicitly with `-e @group_vars/all.yml` and setting `become: no` in the deploy playbook.
265+
- **App port:** The application runs on port 8000 (FastAPI/Uvicorn), not 5000 as initially assumed. Discovered via `docker logs` and corrected in the vault variables and port mapping.
266+
267+
---
268+
269+
## 8. Bonus — Dynamic Inventory
270+
271+
### Approach
272+
Since no cloud provider was available, a custom Python dynamic inventory script was created (`inventory/dynamic_inventory.py`). This demonstrates the same concepts as cloud inventory plugins — hosts are discovered at runtime rather than hardcoded.
273+
274+
### How It Works
275+
The script runs at playbook execution time, queries the system for hostname and IP dynamically, and outputs a JSON inventory structure that Ansible consumes. This means if the VM's hostname or IP changes, the inventory automatically reflects the new values without any manual updates.
276+
277+
### ansible-inventory --graph Output
278+
```
279+
@all:
280+
|--@ungrouped:
281+
|--@webservers:
282+
| |--localhost
283+
```
284+
285+
### Running Playbooks with Dynamic Inventory
286+
```bash
287+
ansible all -i inventory/dynamic_inventory.py -m ping --ask-vault-pass
288+
# localhost | SUCCESS => { "ping": "pong" }
289+
290+
ansible-playbook playbooks/provision.yml -i inventory/dynamic_inventory.py --ask-vault-pass
291+
# localhost : ok=11 changed=1 unreachable=0 failed=0
292+
```
293+
294+
### Benefits vs Static Inventory
295+
With static inventory, if the VM IP or hostname changes you must manually edit `hosts.ini`. With dynamic inventory, the script queries the system at runtime so it always reflects the current state. In a cloud environment with auto-scaling, this is essential — new VMs appear and disappear constantly and maintaining a static file would be impossible.

ansible/group_vars/all.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
$ANSIBLE_VAULT;1.1;AES256
2+
37353731323564313431383962323137383261346563303561356530366133623439363562346662
3+
3562336265383731613638653637666136343761336338650a393861646465333163373232373437
4+
39343763383931633733366166626137613030356337353862636634656331626131383938653334
5+
3636626263653239610a396130313936626263623938316161386539383465653762613134333730
6+
31363135623164373236383930366137663436366138623330303866646332303030653932353264
7+
30366633636662666138646336386565636361346133303137386165656434303538356337376531
8+
63663037656132313565623034663864303561626132663332633561643737633561363830636462
9+
30613934623830653139646165303863656535666138323561643264643766383764626634626436
10+
64376464326434623464306339333430656263386563313730303761623436383432353836333331
11+
33353362326563633630313035633537626235653831663933336434333933353031363836646139
12+
38393733663936343162343131393566376232636438623938366237336331386232666566343034
13+
33663334366338333365396236373330353261393731343832626436626162396339663130386365
14+
38346636336564323365666238333636303836656264306362393635643934326364613362383732
15+
32336333636335323636353563613636323333346135366230346133363831313333396131303630
16+
31303731386661376338653331326339373066366666626365326663333766336131323137393364
17+
36323434326563393536663934333835663732333631653864636139313935303363643563623636
18+
3665
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Dynamic inventory script for local Vagrant VM.
4+
Discovers host details dynamically at runtime.
5+
"""
6+
import json
7+
import socket
8+
import subprocess
9+
10+
def get_vagrant_info():
11+
hostname = socket.gethostname()
12+
ip = socket.gethostbyname(hostname)
13+
return hostname, ip
14+
15+
def main():
16+
hostname, ip = get_vagrant_info()
17+
18+
inventory = {
19+
"webservers": {
20+
"hosts": ["localhost"],
21+
"vars": {
22+
"ansible_connection": "local",
23+
"ansible_user": "vagrant",
24+
"ansible_python_interpreter": "/usr/bin/python3",
25+
"discovered_hostname": hostname,
26+
"discovered_ip": ip
27+
}
28+
},
29+
"_meta": {
30+
"hostvars": {
31+
"localhost": {
32+
"ansible_connection": "local",
33+
"ansible_user": "vagrant",
34+
"ansible_python_interpreter": "/usr/bin/python3",
35+
"discovered_hostname": hostname,
36+
"discovered_ip": ip
37+
}
38+
}
39+
}
40+
}
41+
print(json.dumps(inventory, indent=2))
42+
43+
if __name__ == "__main__":
44+
main()

ansible/inventory/hosts.ini

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[webservers]
2+
localhost ansible_connection=local ansible_user=vagrant
3+
4+
[webservers:vars]
5+
ansible_python_interpreter=/usr/bin/python3

ansible/playbooks/deploy.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
- name: Deploy application
3+
hosts: webservers
4+
become: no
5+
6+
roles:
7+
- app_deploy

0 commit comments

Comments
 (0)