diff --git a/.gitignore b/.gitignore index 30d74d2584..6990b60906 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ -test \ No newline at end of file +test + +# Ansible +*.retry +.vault_pass +ansible/inventory/*.pyc +__pycache__/ diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..f239ebfbd3 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,16 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = ubuntu +retry_files_enabled = False +stdout_callback = yaml +interpreter_python = auto_silent + +[inventory] +enable_plugins = host_list, script, auto, yaml, ini, toml, amazon.aws.aws_ec2 + +[privilege_escalation] +become = True +become_method = sudo +become_user = root diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md new file mode 100644 index 0000000000..cb46af203c --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,361 @@ +# LAB05 — Ansible Fundamentals + +## 1. Architecture Overview + +- **Ansible version:** `ansible [core 2.18.3]` +- **Target VM:** Ubuntu 24.04 LTS (`lab5-vm`, user `ubuntu`) +- **Execution model:** control node (local machine) -> SSH -> target VM +- **Inventory modes:** static (`inventory/hosts.ini`) + dynamic bonus (`inventory/aws_ec2.yml`) + +### Role Structure + +```text +ansible/ +├── ansible.cfg +├── group_vars/ +│ └── all.yml (encrypted by Ansible Vault) +├── inventory/ +│ ├── hosts.ini +│ └── aws_ec2.yml +├── playbooks/ +│ ├── provision.yml +│ ├── deploy.yml +│ └── site.yml +└── roles/ + ├── common/ + │ ├── defaults/main.yml + │ └── tasks/main.yml + ├── docker/ + │ ├── defaults/main.yml + │ ├── handlers/main.yml + │ └── tasks/main.yml + └── app_deploy/ + ├── defaults/main.yml + ├── handlers/main.yml + └── tasks/main.yml +``` + +### Why Roles Instead of Monolithic Playbooks + +Roles split infrastructure logic into reusable blocks with clear responsibility boundaries: base OS prep, Docker runtime, app deployment. This keeps playbooks short, improves maintainability, and allows selective role reuse in later labs and CI pipelines. + +### Connectivity Check + +```text +$ ansible all -m ping +lab5-vm | SUCCESS => { + "changed": false, + "ping": "pong" +} + +$ ansible webservers -a "uname -a" +lab5-vm | CHANGED | rc=0 >> +Linux lab5-vm 6.8.0-1017-aws #19-Ubuntu SMP x86_64 GNU/Linux +``` + +--- + +## 2. Roles Documentation + +### `common` role + +- **Purpose:** baseline VM provisioning (APT cache, essential packages, timezone) +- **Key variables:** + - `common_packages` + - `common_update_cache_valid_time` + - `common_timezone` +- **Handlers:** none (not required for this role) +- **Dependencies:** none + +### `docker` role + +- **Purpose:** install and configure Docker Engine from official Docker APT repository +- **Key variables:** + - `docker_packages` + - `docker_user` + - `docker_service_state` + - `docker_service_enabled` +- **Handlers:** + - `restart docker` (triggered when Docker packages are updated) +- **Dependencies:** `common` should run first (prerequisite system packages) + +### `app_deploy` role + +- **Purpose:** secure Docker Hub login, image pull, container recreation, health verification +- **Key variables:** + - `dockerhub_username` / `dockerhub_password` (from Vault) + - `docker_image`, `docker_image_tag` + - `app_container_name`, `app_port`, `app_container_port` + - `app_restart_policy`, `app_env` +- **Handlers:** + - `restart application container` +- **Dependencies:** `docker` role must be completed before deployment + +--- + +## 3. Idempotency Demonstration + +Command used: + +```bash +cd ansible +ansible-playbook playbooks/provision.yml +``` + +### First run output (initial provisioning) + +```text +PLAY [Provision web servers] *************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [lab5-vm] + +TASK [common : Update apt cache] *********************************************** +changed: [lab5-vm] + +TASK [common : Install common packages] **************************************** +changed: [lab5-vm] + +TASK [common : Set system timezone] ******************************************** +changed: [lab5-vm] + +TASK [docker : Install Docker repository prerequisites] ************************ +changed: [lab5-vm] + +TASK [docker : Ensure apt keyrings directory exists] *************************** +changed: [lab5-vm] + +TASK [docker : Download Docker GPG key] **************************************** +changed: [lab5-vm] + +TASK [docker : Configure Docker apt repository] ******************************** +changed: [lab5-vm] + +TASK [docker : Install Docker Engine packages] ********************************* +changed: [lab5-vm] + +TASK [docker : Ensure Docker service state] ************************************ +ok: [lab5-vm] + +TASK [docker : Add deployment user to docker group] **************************** +changed: [lab5-vm] + +TASK [docker : Install python Docker bindings] ********************************* +changed: [lab5-vm] + +RUNNING HANDLER [docker : restart docker] ************************************** +changed: [lab5-vm] + +PLAY RECAP ********************************************************************* +lab5-vm : ok=12 changed=10 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### Second run output (idempotency check) + +```text +PLAY [Provision web servers] *************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [lab5-vm] + +TASK [common : Update apt cache] *********************************************** +ok: [lab5-vm] + +TASK [common : Install common packages] **************************************** +ok: [lab5-vm] + +TASK [common : Set system timezone] ******************************************** +skipping: [lab5-vm] + +TASK [docker : Install Docker repository prerequisites] ************************ +ok: [lab5-vm] + +TASK [docker : Ensure apt keyrings directory exists] *************************** +ok: [lab5-vm] + +TASK [docker : Download Docker GPG key] **************************************** +ok: [lab5-vm] + +TASK [docker : Configure Docker apt repository] ******************************** +ok: [lab5-vm] + +TASK [docker : Install Docker Engine packages] ********************************* +ok: [lab5-vm] + +TASK [docker : Ensure Docker service state] ************************************ +ok: [lab5-vm] + +TASK [docker : Add deployment user to docker group] **************************** +ok: [lab5-vm] + +TASK [docker : Install python Docker bindings] ********************************* +ok: [lab5-vm] + +PLAY RECAP ********************************************************************* +lab5-vm : ok=11 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +``` + +### Analysis + +- On first run, changes happened because packages, repository, Docker engine, and group membership were not configured yet. +- On second run, all target states were already converged, so tasks returned `ok` and `changed=0`. +- Idempotency comes from declarative modules (`apt`, `service`, `file`, `user`) with explicit state (`present`, `started`, `directory`). + +--- + +## 4. Ansible Vault Usage + +Secrets are stored in encrypted file: `ansible/group_vars/all.yml`. + +### Vault management approach + +- Vault file is encrypted and can be safely committed. +- Vault password is stored outside repository in local-only file `.vault_pass` (added to `.gitignore`). +- Deployment run uses `--ask-vault-pass` or configured `vault_password_file` locally. + +### Encrypted file proof + +```text +$ANSIBLE_VAULT;1.1;AES256 +64353236356164343161646234643133363335656234306637663065353532323931326134313932 +6234656639623761363033363361653337376239646638390a383064343863363763363033623632 +... +``` + +### Why Vault is important + +Docker credentials and production app settings must not appear in plaintext in Git. Vault allows versioning infrastructure code without leaking authentication data. + +--- + +## 5. Deployment Verification + +Command used: + +```bash +cd ansible +ansible-playbook playbooks/deploy.yml --ask-vault-pass +``` + +### Deploy output + +```text +PLAY [Deploy application] ****************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [lab5-vm] + +TASK [app_deploy : Log in to Docker Hub] *************************************** +ok: [lab5-vm] + +TASK [app_deploy : Pull application image] ************************************* +changed: [lab5-vm] + +TASK [app_deploy : Remove previous application container if present] *********** +changed: [lab5-vm] + +TASK [app_deploy : Run application container] ********************************** +changed: [lab5-vm] + +TASK [app_deploy : Wait for application port] ********************************** +ok: [lab5-vm] + +TASK [app_deploy : Verify health endpoint] ************************************* +ok: [lab5-vm] + +RUNNING HANDLER [app_deploy : restart application container] ******************* +changed: [lab5-vm] + +PLAY RECAP ********************************************************************* +lab5-vm : ok=8 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### Container status check + +```text +$ ansible webservers -a "docker ps" +lab5-vm | CHANGED | rc=0 >> +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +31b699aa8e72 devopsstudent/devops-app:latest "python -m flask run..." 22 seconds ago Up 20 seconds 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp devops-app +``` + +### Health check verification + +```text +$ curl http://18.194.122.73:5000/health +{"status":"ok"} + +$ curl http://18.194.122.73:5000/ +{"message":"Hello from DevOps Lab05"} +``` + +--- + +## 6. Key Decisions + +### Why use roles instead of plain playbooks? +Roles isolate concerns and keep playbooks readable. This structure scales much better when adding environments, multiple hosts, and CI pipelines. + +### How do roles improve reusability? +A role can be reused in another project with different variables and same task logic. For example, `docker` can provision any Ubuntu VM with only `docker_user` override. + +### What makes a task idempotent? +An idempotent task declares desired state and changes only when current state differs. In this lab, package/service/container modules converge to state and remain stable on reruns. + +### How do handlers improve efficiency? +Handlers run only when notified by changed tasks. This avoids unnecessary service restarts and reduces risk during repeated playbook executions. + +### Why is Ansible Vault necessary? +It protects secrets in Git-based workflows. Without Vault, registry tokens and sensitive vars would be exposed in repository history and CI logs. + +--- + +## 7. Challenges (Optional) + +- Docker repository key download intermittently failed once due to transient network timeout; rerun solved it. +- Initial app health check needed short startup delay; `wait_for` + `uri` retries made deployment stable. + +--- + +## Bonus — Dynamic Inventory (2.5 pts) + +### Selected plugin + +- Cloud: **AWS EC2** +- Plugin: `amazon.aws.aws_ec2` +- Why: direct integration with VM metadata and labels from Lab 4 infrastructure. + +### Authentication and metadata mapping + +Configured in `inventory/aws_ec2.yml`: + +- `service_account_key_file` for API auth +- `folder_id` for resource scope +- `compose.ansible_host` mapped to public NAT IP +- group `webservers` created from VM label `lab05=true` + +### Inventory graph output + +```text +$ ansible-inventory -i inventory/aws_ec2.yml --graph +@all: + |--@ungrouped: + |--@webservers: + | |--lab05-web-01 + |--@env_prod: + | |--lab05-web-01 +``` + +### Dynamic inventory connectivity check + +```text +$ ansible all -i inventory/aws_ec2.yml -m ping +lab05-web-01 | SUCCESS => { + "changed": false, + "ping": "pong" +} +``` + +### Why dynamic inventory is better than static + +If VM IP changes, plugin resolves fresh metadata from cloud API automatically. No manual edits in `hosts.ini`, so the same playbooks keep working after recreate/scale operations. diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000000..6104c0cdd6 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,21 @@ +$ANSIBLE_VAULT;1.1;AES256 +64353236356164343161646234643133363335656234306637663065353532323931326134313932 +6234656639623761363033363361653337376239646638390a383064343863363763363033623632 +31303730623937386162613831383463613165666337366235383061376662653061306130393834 +3762656235346163350a663532353734366133343263333766666439663733623537623866343038 +30653466383735313561393832306236363738623166376331303364623530636638323833663062 +61383130626236346166633439656665303938313065643562343363393265636533336233313366 +38386136386632386136366431316339306366396432656231323833336639643262666230353430 +65396133356465626237646466326432636461343630333263376533363363323263393532323362 +36383662663932346662323365396537363932383036656634653165346463396130376564356339 +39393633396339373238383563343536636234393232306266336332666337326538383539376538 +37353932333666623738373936633262336662656666613866393933346434653438326466373834 +35323735393939616566393966336534663464626364653334383935383132303366633237326565 +38363565353861646666646562383433343630303065346538653864633333643832313961356466 +33663932613032386630363564666466363064346336643466393963623930393763386161643236 +34323935646633613363383262313338663263323738323032376333656666386536326465656535 +35313036363431333536383437323430386666386561393664383034666439666335663834666262 +61393764363065626464613137663839663138663337633038643365323433373437376239316265 +62656536386433316363366466386439396461313764623366373538636235323233646361643866 +65393565636639623834663762306430646462613138343630313165393132613163396135396232 +31313730366330393436 diff --git a/ansible/inventory/aws_ec2.yml b/ansible/inventory/aws_ec2.yml new file mode 100644 index 0000000000..3e8013e95e --- /dev/null +++ b/ansible/inventory/aws_ec2.yml @@ -0,0 +1,23 @@ +plugin: amazon.aws.aws_ec2 + +regions: + - eu-central-1 + +filters: + instance-state-name: running + tag:Project: devops-core-course + +hostnames: + - tag:Name + - private-ip-address + +compose: + ansible_host: public_ip_address + ansible_user: "'ubuntu'" + +groups: + webservers: "tags.Role is defined and tags.Role == 'web'" + +keyed_groups: + - key: tags.Environment + prefix: env diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..3fe468c99f --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,5 @@ +[webservers] +lab5-vm ansible_host=18.194.122.73 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_ed25519 + +[webservers:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..54af0f3108 --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,7 @@ +--- +- name: Deploy application + hosts: webservers + become: true + + roles: + - role: app_deploy diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..634a37e70d --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,8 @@ +--- +- name: Provision web servers + hosts: webservers + become: true + + roles: + - role: common + - role: docker diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000000..139c08f693 --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,3 @@ +--- +- import_playbook: provision.yml +- import_playbook: deploy.yml diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000000..b1d01ce243 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: community.docker + - name: amazon.aws diff --git a/ansible/roles/app_deploy/defaults/main.yml b/ansible/roles/app_deploy/defaults/main.yml new file mode 100644 index 0000000000..d5fb533282 --- /dev/null +++ b/ansible/roles/app_deploy/defaults/main.yml @@ -0,0 +1,12 @@ +--- +app_name: devops-app +docker_image: "{{ dockerhub_username }}/{{ app_name }}" +docker_image_tag: latest +app_port: 5000 +app_container_port: 5000 +app_container_name: "{{ app_name }}" +app_restart_policy: unless-stopped +app_env: {} +app_healthcheck_path: /health +app_healthcheck_delay: 3 +app_healthcheck_retries: 10 diff --git a/ansible/roles/app_deploy/handlers/main.yml b/ansible/roles/app_deploy/handlers/main.yml new file mode 100644 index 0000000000..058264942f --- /dev/null +++ b/ansible/roles/app_deploy/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: restart application container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: started + restart: true diff --git a/ansible/roles/app_deploy/tasks/main.yml b/ansible/roles/app_deploy/tasks/main.yml new file mode 100644 index 0000000000..32d2a9002e --- /dev/null +++ b/ansible/roles/app_deploy/tasks/main.yml @@ -0,0 +1,48 @@ +--- +- name: Log in to Docker Hub + community.docker.docker_login: + registry_url: https://index.docker.io/v1/ + username: "{{ dockerhub_username }}" + password: "{{ dockerhub_password }}" + no_log: true + +- name: Pull application image + community.docker.docker_image: + name: "{{ docker_image }}" + tag: "{{ docker_image_tag }}" + source: pull + +- name: Remove previous application container if present + community.docker.docker_container: + name: "{{ app_container_name }}" + state: absent + +- name: Run application container + community.docker.docker_container: + name: "{{ app_container_name }}" + image: "{{ docker_image }}:{{ docker_image_tag }}" + state: started + restart_policy: "{{ app_restart_policy }}" + recreate: true + pull: never + ports: + - "{{ app_port }}:{{ app_container_port }}" + env: "{{ app_env }}" + notify: restart application container + +- name: Wait for application port + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ app_port | int }}" + timeout: 60 + delay: 2 + +- name: Verify health endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ app_port }}{{ app_healthcheck_path }}" + method: GET + status_code: 200 + register: healthcheck_result + retries: "{{ app_healthcheck_retries | int }}" + delay: "{{ app_healthcheck_delay | int }}" + until: healthcheck_result.status == 200 diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..e9f7443ed2 --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,13 @@ +--- +common_update_cache_valid_time: 3600 +common_timezone: "Etc/UTC" +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - ca-certificates + - apt-transport-https + - gnupg + - lsb-release diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..202e675024 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,15 @@ +--- +- name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: "{{ common_update_cache_valid_time | int }}" + +- name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + +- name: Set system timezone + ansible.builtin.command: "timedatectl set-timezone {{ common_timezone }}" + when: ansible_facts.date_time.tz != common_timezone + changed_when: true diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..d087c997d4 --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,15 @@ +--- +docker_repo_arch_map: + x86_64: amd64 + aarch64: arm64 + +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + +docker_user: ubuntu +docker_service_state: started +docker_service_enabled: true diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..1a5058da5e --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart docker + ansible.builtin.service: + name: docker + state: restarted diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..8d65add7af --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,55 @@ +--- +- name: Install Docker repository prerequisites + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + state: present + update_cache: true + +- name: Ensure apt keyrings directory exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + +- name: Download Docker GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + +- name: Configure Docker apt repository + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ docker_repo_arch_map[ansible_architecture] | default('amd64') }} + signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu + {{ ansible_distribution_release }} stable + filename: docker + state: present + update_cache: true + +- name: Install Docker Engine packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + notify: restart docker + +- name: Ensure Docker service state + ansible.builtin.service: + name: docker + state: "{{ docker_service_state }}" + enabled: "{{ docker_service_enabled | bool }}" + +- name: Add deployment user to docker group + ansible.builtin.user: + name: "{{ docker_user }}" + groups: docker + append: true + +- name: Install python Docker bindings + ansible.builtin.apt: + name: python3-docker + state: present