diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..278b8c3e3a --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,95 @@ +name: Ansible Deployment + +on: + push: + branches: [ lab06, master ] + paths: + - 'ansible/**' + - '.github/workflows/ansible-deploy.yml' + pull_request: + branches: [ lab06, master ] + paths: + - 'ansible/**' + - '.github/workflows/ansible-deploy.yml' + +jobs: + lint: + name: Ansible Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Ansible + ansible-lint + run: | + python -m pip install --upgrade pip + pip install ansible ansible-lint + + - name: Install Ansible collections + run: | + ansible-galaxy collection install -r ansible/requirements.yml + + - name: Run ansible-lint + run: | + cd ansible + ansible-lint -p playbooks/*.yml roles + + deploy: + name: Deploy Application + needs: lint + + runs-on: [self-hosted, linux] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python venv + shell: bash + run: | + set -euo pipefail + python3 -m venv .venv + . .venv/bin/activate + python -m pip install --upgrade pip + python -m pip install ansible ansible-lint + + - name: Install Ansible collections + shell: bash + run: | + set -euo pipefail + . .venv/bin/activate + ansible-galaxy collection install -r ansible/requirements.yml + + - name: Deploy with Ansible + shell: bash + env: + ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + run: | + set -euo pipefail + . .venv/bin/activate + export ANSIBLE_CONFIG="$GITHUB_WORKSPACE/ansible/ansible.cfg" + cd ansible + + if [[ -n "${ANSIBLE_VAULT_PASSWORD:-}" ]]; then + trap 'rm -f /tmp/vault_pass' EXIT + printf '%s' "${ANSIBLE_VAULT_PASSWORD}" > /tmp/vault_pass + ansible-playbook -i inventory/hosts.ini playbooks/deploy.yml --vault-password-file /tmp/vault_pass + else + ansible-playbook -i inventory/hosts.ini playbooks/deploy.yml + fi + + - name: Verify deployment + shell: bash + env: + VM_HOST: ${{ secrets.VM_HOST }} + run: | + set -euo pipefail + HOST="${VM_HOST:-127.0.0.1}" + curl --retry 10 --retry-delay 3 --retry-connrefused -fsS "http://${HOST}:8000/health" + curl --retry 10 --retry-delay 3 --retry-connrefused -fsS "http://${HOST}:8000/" \ No newline at end of file diff --git a/.gitignore b/.gitignore index e050328db5..45f14ccf09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ test .env +.venv +actions-runner/ +_work/ +.runner/ key.json # --- Ansible --- *.retry diff --git a/.vagrant/machines/default/virtualbox/action_set_name b/.vagrant/machines/default/virtualbox/action_set_name new file mode 100644 index 0000000000..77f6fb8046 --- /dev/null +++ b/.vagrant/machines/default/virtualbox/action_set_name @@ -0,0 +1 @@ +1773325600 \ No newline at end of file diff --git a/.vagrant/machines/default/virtualbox/creator_uid b/.vagrant/machines/default/virtualbox/creator_uid new file mode 100644 index 0000000000..c227083464 --- /dev/null +++ b/.vagrant/machines/default/virtualbox/creator_uid @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/.vagrant/machines/default/virtualbox/id b/.vagrant/machines/default/virtualbox/id new file mode 100644 index 0000000000..9bd219e5e6 --- /dev/null +++ b/.vagrant/machines/default/virtualbox/id @@ -0,0 +1 @@ +5f4a1e02-46c6-4dcd-817a-01783b29dfd2 \ No newline at end of file diff --git a/.vagrant/machines/default/virtualbox/index_uuid b/.vagrant/machines/default/virtualbox/index_uuid new file mode 100644 index 0000000000..90907a7cd0 --- /dev/null +++ b/.vagrant/machines/default/virtualbox/index_uuid @@ -0,0 +1 @@ +41695340a0c0459bb1ec1e9674b9fcae \ No newline at end of file diff --git a/.vagrant/rgloader/loader.rb b/.vagrant/rgloader/loader.rb new file mode 100644 index 0000000000..b6c81bf31b --- /dev/null +++ b/.vagrant/rgloader/loader.rb @@ -0,0 +1,12 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +# This file loads the proper rgloader/loader.rb file that comes packaged +# with Vagrant so that encoded files can properly run with Vagrant. + +if ENV["VAGRANT_INSTALLER_EMBEDDED_DIR"] + require File.expand_path( + "rgloader/loader", ENV["VAGRANT_INSTALLER_EMBEDDED_DIR"]) +else + raise "Encoded files can't be read outside of the Vagrant installer." +end diff --git a/Vagrantfile b/Vagrantfile index 7bf76d526b..a204b68518 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -2,14 +2,15 @@ Vagrant.configure("2") do |config| config.vm.box = "ubuntu/jammy64" config.vm.hostname = "lab05" - # ВАЖНО: отключаем шаринг папки проекта в VM - # (часто ломается из-за кириллицы/пробелов в пути + нам не нужен репозиторий в VM) + # Disable project folder sharing inside the VM. + # This avoids common Windows path issues (spaces, Cyrillic characters) + # and is not needed for this lab workflow. config.vm.synced_folder ".", "/vagrant", disabled: true - # Пробрасываем порты на Windows-хост - # host_ip "0.0.0.0" нужно, чтобы WSL мог подключиться к проброшенному порту через IP Windows-хоста. + # Forward ports to the Windows host. + # host_ip "0.0.0.0" lets WSL2 reach the forwarded ports through the host. config.vm.network "forwarded_port", guest: 22, host: 2222, host_ip: "0.0.0.0", id: "ssh", auto_correct: true - config.vm.network "forwarded_port", guest: 5000, host: 5000, host_ip: "0.0.0.0", id: "app", auto_correct: true + config.vm.network "forwarded_port", guest: 8000, host: 8000, host_ip: "0.0.0.0", id: "app", auto_correct: true config.ssh.insert_key = true diff --git a/ansible/.ansible-lint b/ansible/.ansible-lint new file mode 100644 index 0000000000..3ab93c253f --- /dev/null +++ b/ansible/.ansible-lint @@ -0,0 +1,3 @@ +skip_list: + - key-order + - var-naming[no-role-prefix] diff --git a/ansible/0 b/ansible/0 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ansible/README.md b/ansible/README.md index d680e5b46e..60c1ec3a54 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -1,8 +1,19 @@ -# Lab05 — Ansible +# Lab05/Lab06 — Ansible See: - `labs/lab05.md` — assignment - `ansible/docs/LAB05.md` — report template +- `labs/lab06.md` — assignment +- `ansible/docs/LAB06.md` — report +- `ansible/docs/LOCAL_VALIDATION_WINDOWS.md` — Windows + WSL2 local validation guide + +## Workflow badge + +After creating your GitHub repository, replace `OWNER/REPO` in the snippet below: + +```md +[![Ansible Deployment](https://github.com/OWNER/REPO/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/OWNER/REPO/actions/workflows/ansible-deploy.yml) +``` ## Quick start @@ -19,6 +30,22 @@ ansible all -m ping ansible-playbook playbooks/provision.yml ansible-playbook playbooks/provision.yml -# Deploy the application (uses Ansible Vault) -ansible-playbook playbooks/deploy.yml --ask-vault-pass +# Deploy the application with Docker Compose +ansible-playbook playbooks/deploy.yml + +# Useful tag examples (Lab06) +ansible-playbook playbooks/provision.yml --list-tags +ansible-playbook playbooks/provision.yml --tags "docker_install" +ansible-playbook playbooks/provision.yml --skip-tags "common" +ansible-playbook playbooks/deploy.yml --tags "web_app" + +# Wipe only (double-gated: variable + tag) +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe ``` + +## CI/CD (GitHub Actions) + +Workflow file: `.github/workflows/ansible-deploy.yml`. + +For Vagrant/VirtualBox setups behind NAT, prefer a **self-hosted Linux runner** on the same machine +where you run Ansible (for example, WSL2 Ubuntu). This avoids inbound SSH exposure and stays free. diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md new file mode 100644 index 0000000000..c851f78b3c --- /dev/null +++ b/ansible/docs/LAB06.md @@ -0,0 +1,323 @@ +# LAB06 — Advanced Ansible & CI/CD + +## Overview + +This lab upgrades the Lab05 Ansible project to a more production-ready setup: + +- Refactored roles to use **blocks**, **rescue/always**, and a clear **tag strategy**. +- Migrated the application deployment from `docker run` to **Docker Compose v2**. +- Added **role dependency** (`web_app` depends on `docker`) to guarantee execution order. +- Implemented **safe wipe logic** (double-gated: variable + tag). +- Added a **GitHub Actions** workflow for Ansible linting + deployment. + +Tech used: **Ansible 2.16+**, **community.docker**, **Docker Compose v2**, **GitHub Actions**, **Jinja2**. + +--- + +## Blocks & Tags + +### Tag strategy + +- `common` — the whole common role (set at playbook role level) +- `packages` — package management tasks inside `common` +- `users` — user management tasks inside `common` + +- `docker` — the whole docker role (set at playbook role level) +- `docker_install` — Docker installation + repo setup +- `docker_config` — Docker group/user configuration + +- `web_app` — the whole deployment role (set at playbook role level) +- `app_deploy` — deployment block inside `web_app` +- `compose` — Docker Compose related tasks in `web_app` +- `web_app_wipe` — wipe logic tasks + +### `common` role + +File: `roles/common/tasks/main.yml` + +What was done: + +1. **Packages block** tagged `packages`. +2. **Users block** tagged `users`. +3. Added **rescue** for APT cache update failures: + - best-effort `apt-get update --fix-missing` + - retry cache update. +4. Added an outer **always** section that writes `/tmp/ansible_common_role_completed.log`. + +Notes: + +- User management is **optional** and controlled by `common_users` (default empty list => no changes). +- SSH keys are managed with `ansible.posix.authorized_key` for better collection portability. + +### `docker` role + +File: `roles/docker/tasks/main.yml` + +What was done: + +1. **Install block** tagged `docker_install`. +2. **Config block** tagged `docker_config`. +3. Added **rescue** to handle transient Docker repository / GPG failures: + - wait 10 seconds + - retry `apt update` + - re-download and re-install the GPG key + - retry install. +4. Added an outer **always** section to ensure the Docker service is enabled and started. + +### How to test tags + +```bash +cd ansible + +# List all tags +ansible-playbook playbooks/provision.yml --list-tags + +# Run only docker role tasks +ansible-playbook playbooks/provision.yml --tags docker + +# Run only docker installation tasks +ansible-playbook playbooks/provision.yml --tags docker_install + +# Install packages only +ansible-playbook playbooks/provision.yml --tags packages + +# Skip common role +ansible-playbook playbooks/provision.yml --skip-tags common +``` + +--- + +## Docker Compose Migration + +### Role rename + +The deployment role was renamed from `app_deploy` to `web_app`. +The old `roles/app_deploy/` directory was removed from the final submission. + +### Compose template + +File: `roles/web_app/templates/docker-compose.yml.j2` + +Template supports: + +- `app_name` +- `docker_image` +- `docker_tag` +- `app_port` / `app_internal_port` +- `app_env` (dict of extra env vars) +- optional `app_secret_key` (Vault-friendly) + +It also sets sane defaults for the provided Flask app: + +- `HOST=0.0.0.0` +- `PORT={{ app_internal_port }}` + +### Role dependency + +File: `roles/web_app/meta/main.yml` + +`web_app` depends on `docker`, so a playbook that runs the whole `web_app` role will install Docker first. + +Important practical note: + +- `ansible-playbook playbooks/deploy.yml` is the safest default. +- `ansible-playbook playbooks/deploy.yml --tags web_app` also works for selective role execution. +- `ansible-playbook playbooks/deploy.yml --tags app_deploy` targets only the deployment block and is **not** the best choice if you expect role dependencies to be selected by tag filtering. + +### Deployment implementation + +File: `roles/web_app/tasks/main.yml` + +Deployment flow: + +1. Create `/opt/{{ app_name }}`. +2. Render `docker-compose.yml`. +3. Optional `docker login` (only if password is provided). +4. Pull the image using `community.docker.docker_image`. +5. Run `community.docker.docker_compose_v2` (`state: present`). +6. Verify app responds on `/health` and `/`. + +Idempotency test: + +```bash +ansible-playbook playbooks/deploy.yml +ansible-playbook playbooks/deploy.yml +``` + +The second run should show mostly `ok` tasks, and no container recreation unless inputs changed. + +--- + +## Wipe Logic + +Files: + +- `roles/web_app/defaults/main.yml` +- `roles/web_app/tasks/wipe.yml` +- `roles/web_app/tasks/main.yml` (includes wipe first) + +### Why variable + tag? + +This is a **double safety mechanism**: + +- Variable (`web_app_wipe=true`) prevents accidental wipe during normal runs. +- Tag (`--tags web_app_wipe`) enables a wipe-only run that does not deploy. + +### Test scenarios + +```bash +# 1) Normal deployment (wipe does NOT run) +ansible-playbook playbooks/deploy.yml + +# 2) Wipe only +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe + +# 3) Clean reinstall (wipe -> deploy) +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" + +# 4a) Safety check: tag without variable => wipe skipped +ansible-playbook playbooks/deploy.yml --tags web_app_wipe +``` + +--- + +## CI/CD Integration + +File: `.github/workflows/ansible-deploy.yml` + +### Workflow architecture + +Two-stage pipeline: + +1. **lint** (GitHub-hosted runner) + - installs Ansible + ansible-lint + - installs collections from `ansible/requirements.yml` + - runs `ansible-lint -p .` inside the `ansible/` directory. + +2. **deploy** (self-hosted Linux runner) + - runs on `runs-on: [self-hosted, linux]` + - installs Ansible on the runner + - installs collections from `ansible/requirements.yml` + - runs `ansible-playbook playbooks/deploy.yml` + - verifies the app with `curl /health` and `curl /`. + +### Why a Linux self-hosted runner? + +For a local Vagrant/VirtualBox environment behind NAT, a **Linux self-hosted runner** on the same machine is the simplest free option. +Using WSL2 Ubuntu is practical because: + +- the workflow already uses Bash and Linux paths +- Ansible is easier to maintain on Linux +- no public inbound SSH exposure is required +- it matches the local, free, Windows-friendly lab setup + +### Secrets + +Recommended secrets (Actions → Secrets): + +- `ANSIBLE_VAULT_PASSWORD` — only needed if you use Vault-encrypted files +- `VM_HOST` — host/IP used by the verification curl step (default fallback: `127.0.0.1`) + +If you later move to a public VM and a GitHub-hosted deploy job, add: + +- `SSH_PRIVATE_KEY` +- `VM_USER` + +--- + +## Testing Results Checklist + +Collect evidence for submission: + +- `ansible-playbook playbooks/provision.yml --list-tags` +- `ansible-playbook playbooks/provision.yml --tags docker_install` +- `ansible-playbook playbooks/deploy.yml` +- second deployment run for idempotency proof +- wipe-only run: + - `ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe` +- clean reinstall run: + - `ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true"` +- rendered file: + - `/opt/{{ app_name }}/docker-compose.yml` +- app responses: + - `curl http://:{{ app_port }}/health` + - `curl http://:{{ app_port }}/` +- GitHub Actions logs for `lint` and `deploy` + +--- + +## Challenges & Solutions + +- **Compose v2 on Ubuntu**: the docker role installs `docker-compose-plugin` so `docker compose` is available. +- **Idempotency vs pull policy**: image pulling is done explicitly via `docker_image`, while Compose uses `pull: never`. +- **Safety for destructive operations**: wipe logic requires both a boolean variable and, for wipe-only mode, an explicit tag. +- **Collection portability**: `ansible.posix` was added explicitly so `authorized_key` does not depend on the full `ansible` meta-package. + +--- + +## Research Answers + +### Blocks & Tags + +1. **What happens if the rescue block also fails?** + - The whole block is considered failed and Ansible stops the play (unless errors are ignored). + - The `always` section still runs. + +2. **Can you have nested blocks?** + - Yes. This lab uses an outer block with an `always` section, and inner blocks for `packages` / `users`. + +3. **How do tags inherit to tasks within blocks?** + - Tags defined at a block level are applied to all tasks in that block. + - Tags defined at role level (in playbooks) also apply to all tasks in the role. + +### Docker Compose + +1. **Difference between `restart: always` and `restart: unless-stopped`?** + - `always` restarts containers even if you manually stopped them. + - `unless-stopped` restarts containers automatically except when they were explicitly stopped by an operator. + +2. **How do Compose networks differ from Docker bridge networks?** + - Compose creates and manages networks as part of an application project, with predictable naming. + - A classic Docker bridge network is a lower-level primitive not tied to a Compose project. + +3. **Can you reference Ansible Vault variables in the template?** + - Yes. Vault-encrypted variables are decrypted at runtime and can be used like normal variables in templates. + +### Wipe logic + +1. **Why use both variable AND tag?** + - The variable protects normal runs. + - The tag enables a wipe-only execution that skips deployment tasks. + +2. **Difference between `never` tag and this approach?** + - `never` requires explicit `--tags never` (or another selected tag on the same task) and can be confusing. + - Variable + tag is explicit and easier to reason about for safety-critical operations. + +3. **Why must wipe logic come BEFORE deployment?** + - It enables the clean reinstall flow: remove old state first, then deploy from scratch. + +4. **When do you want clean reinstall vs rolling update?** + - Clean reinstall: broken state, changed config/layout, major upgrades, testing fresh installs. + - Rolling update: keep service available, minimize downtime, gradual rollout. + +5. **How to extend wipe to remove images/volumes too?** + - Add Compose options such as `remove_volumes: true` when needed. + - Remove images via `community.docker.docker_image state: absent`. + - Remove named volumes explicitly with Docker modules or CLI commands. + +### CI/CD + +1. **Security implications of storing SSH keys in GitHub Secrets?** + - Secrets can be exfiltrated if workflows are compromised. + - Limit key scope, use dedicated deploy keys, restrict repo access, and rotate regularly. + +2. **How to implement staging → production pipeline?** + - Use separate environments, separate inventories, and manual approvals for production. + - Another option is separate workflows triggered on tags/releases. + +3. **What to add for rollbacks?** + - Pin image tags, keep the previous known-good tag, and add a rollback job that redeploys it. + +4. **How does a self-hosted runner improve security compared to GitHub-hosted?** + - SSH keys/secrets do not need to be sent to GitHub-hosted machines. + - The runner stays inside your controlled network perimeter. diff --git a/ansible/docs/LOCAL_VALIDATION_WINDOWS.md b/ansible/docs/LOCAL_VALIDATION_WINDOWS.md new file mode 100644 index 0000000000..7d7ba30340 --- /dev/null +++ b/ansible/docs/LOCAL_VALIDATION_WINDOWS.md @@ -0,0 +1,282 @@ +# Local Validation Guide (Windows + WSL2 + Vagrant + VirtualBox) + +This guide is designed for a **fully local and free** Lab06 workflow on **Windows**. +It assumes: + +- Windows 10/11 +- VirtualBox +- Vagrant +- WSL2 with Ubuntu +- GitHub repository (optional, only for CI/CD) + +--- + +## 1. Recommended Setup + +### Windows side + +Install and verify: + +```powershell +vagrant --version +VBoxManage --version +git --version +``` + +Recommended Vagrant plugins check: + +```powershell +vagrant plugin list +``` + +### WSL2 Ubuntu side + +Install and verify: + +```bash +wsl --status +python3 --version +pip3 --version +ssh -V +git --version +``` + +Install Ansible in WSL2 if missing: + +```bash +python3 -m pip install --user --upgrade pip +python3 -m pip install --user ansible ansible-lint +printf '\nexport PATH="$HOME/.local/bin:$PATH"\n' >> ~/.bashrc +source ~/.bashrc +ansible --version +ansible-lint --version +``` + +--- + +## 2. Start the VM + +Run in **PowerShell** from the repository root: + +```powershell +vagrant up +vagrant status +vagrant port +``` + +Expected important forwarded ports: + +- guest SSH `22` -> host `2222` +- guest app `8000` -> host `8000` + +If ports changed because of `auto_correct`, update the inventory accordingly. + +--- + +## 3. Prepare the SSH key for Ansible + +The Vagrant private key is usually stored under the project directory on Windows. +Copy it into WSL and fix permissions. + +Example from WSL: + +```bash +mkdir -p ~/.ssh +cp /mnt/c/path/to/your/repo/.vagrant/machines/default/virtualbox/private_key ~/.ssh/lab05_vagrant_key +chmod 600 ~/.ssh/lab05_vagrant_key +``` + +Check SSH connectivity: + +```bash +ssh -i ~/.ssh/lab05_vagrant_key -p 2222 vagrant@127.0.0.1 +``` + +If `127.0.0.1` does not work from WSL2, detect the Windows host IP and try again: + +```bash +WIN_HOST_IP=$(grep -m1 nameserver /etc/resolv.conf | awk '{print $2}') +echo "$WIN_HOST_IP" +ssh -i ~/.ssh/lab05_vagrant_key -p 2222 vagrant@"$WIN_HOST_IP" +``` + +--- + +## 4. Inventory Settings + +Default local-friendly inventory example: + +```ini +[webservers] +vagrant1 ansible_host=127.0.0.1 ansible_port=2222 ansible_user=vagrant ansible_ssh_private_key_file=~/.ssh/lab05_vagrant_key + +[webservers:vars] +ansible_python_interpreter=/usr/bin/python3 +``` + +If WSL2 cannot reach `127.0.0.1:2222`, replace `ansible_host` with the detected Windows host IP. + +--- + +## 5. Install Ansible collections + +Run in WSL2 from the repository root: + +```bash +cd ansible +ansible-galaxy collection install -r requirements.yml +``` + +Verify installed collections: + +```bash +ansible-galaxy collection list | grep -E 'community.docker|community.general|ansible.posix' +``` + +--- + +## 6. Basic validation commands + +### Connectivity + +```bash +cd ansible +ansible all -m ping +``` + +### Syntax and task listing + +```bash +ansible-playbook playbooks/provision.yml --syntax-check +ansible-playbook playbooks/deploy.yml --syntax-check +ansible-playbook playbooks/provision.yml --list-tags +ansible-playbook playbooks/deploy.yml --list-tasks +ansible-playbook playbooks/deploy.yml --list-tasks --tags web_app +``` + +### Lint + +```bash +ansible-lint -p . +``` + +--- + +## 7. Provisioning validation + +```bash +cd ansible +ansible-playbook playbooks/provision.yml +ansible-playbook playbooks/provision.yml +``` + +The second run should be mostly idempotent. + +Selective tag checks: + +```bash +ansible-playbook playbooks/provision.yml --tags docker +ansible-playbook playbooks/provision.yml --tags docker_install +ansible-playbook playbooks/provision.yml --tags packages +ansible-playbook playbooks/provision.yml --skip-tags common +``` + +--- + +## 8. Deployment validation + +```bash +cd ansible +ansible-playbook playbooks/deploy.yml +ansible-playbook playbooks/deploy.yml +``` + +Verify on the target VM: + +```bash +ssh -i ~/.ssh/lab05_vagrant_key -p 2222 vagrant@127.0.0.1 +sudo docker ps +sudo docker compose -f /opt/devops-info-service/docker-compose.yml ps +curl http://127.0.0.1:8000/health +curl http://127.0.0.1:8000/ +``` + +From WSL2 / host side: + +```bash +curl http://127.0.0.1:8000/health +curl http://127.0.0.1:8000/ +``` + +If WSL2 cannot reach Windows localhost, replace `127.0.0.1` with the Windows host IP. + +--- + +## 9. Wipe logic validation + +### Wipe only + +```bash +cd ansible +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe +``` + +### Clean reinstall + +```bash +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" +``` + +### Safety check + +```bash +ansible-playbook playbooks/deploy.yml --tags web_app_wipe +``` + +Expected result: wipe tasks are selected by tag but skipped by the boolean guard. + +--- + +## 10. GitHub Actions self-hosted runner (free local option) + +Recommended approach: + +- Create the self-hosted runner in **WSL2 Ubuntu**, not in native Windows. +- Use labels that include `self-hosted` and `linux`. +- Run the runner only when you want to test CI locally. + +Why this is preferred: + +- the workflow uses Bash and Linux paths +- Ansible support is simpler on Linux +- no cloud VM is required +- no paid service is required + +Useful checks on the runner machine: + +```bash +python3 --version +ansible --version +ansible-galaxy collection list | grep -E 'community.docker|community.general|ansible.posix' +curl --version +``` + +Suggested repository secrets: + +- `ANSIBLE_VAULT_PASSWORD` (only if you really use Vault) +- `VM_HOST` with value `127.0.0.1` or the Windows host IP + +--- + +## 11. What to capture for submission + +Recommended evidence: + +1. `ansible-playbook playbooks/provision.yml --list-tags` +2. `ansible-playbook playbooks/provision.yml --tags docker_install` +3. First and second `ansible-playbook playbooks/deploy.yml` runs +4. Wipe-only run +5. Clean reinstall run +6. `docker compose ps` output on the VM +7. `curl /health` and `curl /` output +8. GitHub Actions `lint` and `deploy` job logs diff --git a/ansible/docs/lab06_overview.md b/ansible/docs/lab06_overview.md new file mode 100644 index 0000000000..ef0b078c1d --- /dev/null +++ b/ansible/docs/lab06_overview.md @@ -0,0 +1,605 @@ +# Local Validation Guide (Windows + WSL2 + Vagrant + VirtualBox) + +This guide is designed for a **fully local and free** Lab06 workflow on **Windows**. +It assumes: + +- Windows 10/11 +- VirtualBox +- Vagrant +- WSL2 with Ubuntu +- GitHub repository (optional, only for CI/CD) + +--- + +## 1. Recommended Setup + +### Windows side + +Install and verify: + +```powershell +vagrant --version +VBoxManage --version +git --version +``` + +Recommended Vagrant plugins check: + +```powershell +vagrant plugin list +``` + +### WSL2 Ubuntu side + +Install and verify: + +```bash +wsl --status +python3 --version +pip3 --version +ssh -V +git --version +``` + +Install Ansible in WSL2 if missing: + +```bash +python3 -m pip install --user --upgrade pip +python3 -m pip install --user ansible ansible-lint +printf '\nexport PATH="$HOME/.local/bin:$PATH"\n' >> ~/.bashrc +source ~/.bashrc +ansible --version +ansible-lint --version +``` + +--- + +## 2. Start the VM + +Run in **PowerShell** from the repository root: + +```powershell +vagrant up +vagrant status +vagrant port +``` + +Expected important forwarded ports: + +- guest SSH `22` -> host `2222` +- guest app `8000` -> host `8000` + +If ports changed because of `auto_correct`, update the inventory accordingly. + +--- + +## 3. Prepare the SSH key for Ansible + +The Vagrant private key is usually stored under the project directory on Windows. +Copy it into WSL and fix permissions. + +Example from WSL: + +```bash +mkdir -p ~/.ssh +cp /mnt/c/path/to/your/repo/.vagrant/machines/default/virtualbox/private_key ~/.ssh/lab05_vagrant_key +chmod 600 ~/.ssh/lab05_vagrant_key +``` + +Check SSH connectivity: + +```bash +ssh -i ~/.ssh/lab05_vagrant_key -p 2222 vagrant@127.0.0.1 +``` + +If `127.0.0.1` does not work from WSL2, detect the Windows host IP and try again: + +```bash +WIN_HOST_IP=$(grep -m1 nameserver /etc/resolv.conf | awk '{print $2}') +echo "$WIN_HOST_IP" +ssh -i ~/.ssh/lab05_vagrant_key -p 2222 vagrant@"$WIN_HOST_IP" +``` + +--- + +## 4. Inventory Settings + +Default local-friendly inventory example: + +```ini +[webservers] +vagrant1 ansible_host=127.0.0.1 ansible_port=2222 ansible_user=vagrant ansible_ssh_private_key_file=~/.ssh/lab05_vagrant_key + +[webservers:vars] +ansible_python_interpreter=/usr/bin/python3 +``` + +If WSL2 cannot reach `127.0.0.1:2222`, replace `ansible_host` with the detected Windows host IP. + +--- + +## 5. Install Ansible collections + +Run in WSL2 from the repository root: + +```bash +cd ansible +ansible-galaxy collection install -r requirements.yml +``` + +Verify installed collections: + +```bash +ansible-galaxy collection list | grep -E 'community.docker|community.general|ansible.posix' +``` + +--- + +## 6. Basic validation commands + +### Connectivity + +```bash +cd ansible +ansible all -m ping +``` + +### Syntax and task listing + +```bash +ansible-playbook playbooks/provision.yml --syntax-check +ansible-playbook playbooks/deploy.yml --syntax-check +ansible-playbook playbooks/provision.yml --list-tags +ansible-playbook playbooks/deploy.yml --list-tasks +ansible-playbook playbooks/deploy.yml --list-tasks --tags web_app +``` + +### Lint + +```bash +ansible-lint -p . +``` + +--- + +## 7. Provisioning validation + +```bash +cd ansible +ansible-playbook playbooks/provision.yml +ansible-playbook playbooks/provision.yml +``` + +The second run should be mostly idempotent. + +Selective tag checks: + +```bash +ansible-playbook playbooks/provision.yml --tags docker +ansible-playbook playbooks/provision.yml --tags docker_install +ansible-playbook playbooks/provision.yml --tags packages +ansible-playbook playbooks/provision.yml --skip-tags common +``` + +--- + +## 8. Deployment validation + +```bash +cd ansible +ansible-playbook playbooks/deploy.yml +ansible-playbook playbooks/deploy.yml +``` + +Verify on the target VM: + +```bash +ssh -i ~/.ssh/lab05_vagrant_key -p 2222 vagrant@127.0.0.1 +sudo docker ps +sudo docker compose -f /opt/devops-info-service/docker-compose.yml ps +curl http://127.0.0.1:8000/health +curl http://127.0.0.1:8000/ +``` + +From WSL2 / host side: + +```bash +curl http://127.0.0.1:8000/health +curl http://127.0.0.1:8000/ +``` + +If WSL2 cannot reach Windows localhost, replace `127.0.0.1` with the Windows host IP. + +--- + +## 9. Wipe logic validation + +### Wipe only + +```bash +cd ansible +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe +``` + +### Clean reinstall + +```bash +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" +``` + +### Safety check + +```bash +ansible-playbook playbooks/deploy.yml --tags web_app_wipe +``` + +Expected result: wipe tasks are selected by tag but skipped by the boolean guard. + +--- + +## 10. GitHub Actions self-hosted runner (free local option) + +Recommended approach: + +- Create the self-hosted runner in **WSL2 Ubuntu**, not in native Windows. +- Use labels that include `self-hosted` and `linux`. +- Run the runner only when you want to test CI locally. + +Why this is preferred: + +- the workflow uses Bash and Linux paths +- Ansible support is simpler on Linux +- no cloud VM is required +- no paid service is required + +Useful checks on the runner machine: + +```bash +python3 --version +ansible --version +ansible-galaxy collection list | grep -E 'community.docker|community.general|ansible.posix' +curl --version +``` + +Suggested repository secrets: + +- `ANSIBLE_VAULT_PASSWORD` (only if you really use Vault) +- `VM_HOST` with value `127.0.0.1` or the Windows host IP + +--- + +## 11. What to capture for submission + +Recommended evidence: + +1. `ansible-playbook playbooks/provision.yml --list-tags` +2. `ansible-playbook playbooks/provision.yml --tags docker_install` +3. First and second `ansible-playbook playbooks/deploy.yml` runs +4. Wipe-only run +5. Clean reinstall run +6. `docker compose ps` output on the VM +7. `curl /health` and `curl /` output +8. GitHub Actions `lint` and `deploy` job logs + +## Overview + +This lab upgrades the Lab05 Ansible project to a more production-ready setup: + +- Refactored roles to use **blocks**, **rescue/always**, and a clear **tag strategy**. +- Migrated the application deployment from `docker run` to **Docker Compose v2**. +- Added **role dependency** (`web_app` depends on `docker`) to guarantee execution order. +- Implemented **safe wipe logic** (double-gated: variable + tag). +- Added a **GitHub Actions** workflow for Ansible linting + deployment. + +Tech used: **Ansible 2.16+**, **community.docker**, **Docker Compose v2**, **GitHub Actions**, **Jinja2**. + +--- + +## Blocks & Tags + +### Tag strategy + +- `common` — the whole common role (set at playbook role level) +- `packages` — package management tasks inside `common` +- `users` — user management tasks inside `common` + +- `docker` — the whole docker role (set at playbook role level) +- `docker_install` — Docker installation + repo setup +- `docker_config` — Docker group/user configuration + +- `web_app` — the whole deployment role (set at playbook role level) +- `app_deploy` — deployment block inside `web_app` +- `compose` — Docker Compose related tasks in `web_app` +- `web_app_wipe` — wipe logic tasks + +### `common` role + +File: `roles/common/tasks/main.yml` + +What was done: + +1. **Packages block** tagged `packages`. +2. **Users block** tagged `users`. +3. Added **rescue** for APT cache update failures: + - best-effort `apt-get update --fix-missing` + - retry cache update. +4. Added an outer **always** section that writes `/tmp/ansible_common_role_completed.log`. + +Notes: + +- User management is **optional** and controlled by `common_users` (default empty list => no changes). +- SSH keys are managed with `ansible.posix.authorized_key` for better collection portability. + +### `docker` role + +File: `roles/docker/tasks/main.yml` + +What was done: + +1. **Install block** tagged `docker_install`. +2. **Config block** tagged `docker_config`. +3. Added **rescue** to handle transient Docker repository / GPG failures: + - wait 10 seconds + - retry `apt update` + - re-download and re-install the GPG key + - retry install. +4. Added an outer **always** section to ensure the Docker service is enabled and started. + +### How to test tags + +```bash +cd ansible + +# List all tags +ansible-playbook playbooks/provision.yml --list-tags + +# Run only docker role tasks +ansible-playbook playbooks/provision.yml --tags docker + +# Run only docker installation tasks +ansible-playbook playbooks/provision.yml --tags docker_install + +# Install packages only +ansible-playbook playbooks/provision.yml --tags packages + +# Skip common role +ansible-playbook playbooks/provision.yml --skip-tags common +``` + +--- + +## Docker Compose Migration + +### Role rename + +The deployment role was renamed from `app_deploy` to `web_app`. +The old `roles/app_deploy/` directory was removed from the final submission. + +### Compose template + +File: `roles/web_app/templates/docker-compose.yml.j2` + +Template supports: + +- `app_name` +- `docker_image` +- `docker_tag` +- `app_port` / `app_internal_port` +- `app_env` (dict of extra env vars) +- optional `app_secret_key` (Vault-friendly) + +It also sets sane defaults for the provided Flask app: + +- `HOST=0.0.0.0` +- `PORT={{ app_internal_port }}` + +### Role dependency + +File: `roles/web_app/meta/main.yml` + +`web_app` depends on `docker`, so a playbook that runs the whole `web_app` role will install Docker first. + +Important practical note: + +- `ansible-playbook playbooks/deploy.yml` is the safest default. +- `ansible-playbook playbooks/deploy.yml --tags web_app` also works for selective role execution. +- `ansible-playbook playbooks/deploy.yml --tags app_deploy` targets only the deployment block and is **not** the best choice if you expect role dependencies to be selected by tag filtering. + +### Deployment implementation + +File: `roles/web_app/tasks/main.yml` + +Deployment flow: + +1. Create `/opt/{{ app_name }}`. +2. Render `docker-compose.yml`. +3. Optional `docker login` (only if password is provided). +4. Pull the image using `community.docker.docker_image`. +5. Run `community.docker.docker_compose_v2` (`state: present`). +6. Verify app responds on `/health` and `/`. + +Idempotency test: + +```bash +ansible-playbook playbooks/deploy.yml +ansible-playbook playbooks/deploy.yml +``` + +The second run should show mostly `ok` tasks, and no container recreation unless inputs changed. + +--- + +## Wipe Logic + +Files: + +- `roles/web_app/defaults/main.yml` +- `roles/web_app/tasks/wipe.yml` +- `roles/web_app/tasks/main.yml` (includes wipe first) + +### Why variable + tag? + +This is a **double safety mechanism**: + +- Variable (`web_app_wipe=true`) prevents accidental wipe during normal runs. +- Tag (`--tags web_app_wipe`) enables a wipe-only run that does not deploy. + +### Test scenarios + +```bash +# 1) Normal deployment (wipe does NOT run) +ansible-playbook playbooks/deploy.yml + +# 2) Wipe only +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe + +# 3) Clean reinstall (wipe -> deploy) +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" + +# 4a) Safety check: tag without variable => wipe skipped +ansible-playbook playbooks/deploy.yml --tags web_app_wipe +``` + +--- + +## CI/CD Integration + +File: `.github/workflows/ansible-deploy.yml` + +### Workflow architecture + +Two-stage pipeline: + +1. **lint** (GitHub-hosted runner) + - installs Ansible + ansible-lint + - installs collections from `ansible/requirements.yml` + - runs `ansible-lint -p .` inside the `ansible/` directory. + +2. **deploy** (self-hosted Linux runner) + - runs on `runs-on: [self-hosted, linux]` + - installs Ansible on the runner + - installs collections from `ansible/requirements.yml` + - runs `ansible-playbook playbooks/deploy.yml` + - verifies the app with `curl /health` and `curl /`. + +### Why a Linux self-hosted runner? + +For a local Vagrant/VirtualBox environment behind NAT, a **Linux self-hosted runner** on the same machine is the simplest free option. +Using WSL2 Ubuntu is practical because: + +- the workflow already uses Bash and Linux paths +- Ansible is easier to maintain on Linux +- no public inbound SSH exposure is required +- it matches the local, free, Windows-friendly lab setup + +### Secrets + +Recommended secrets (Actions → Secrets): + +- `ANSIBLE_VAULT_PASSWORD` — only needed if you use Vault-encrypted files +- `VM_HOST` — host/IP used by the verification curl step (default fallback: `127.0.0.1`) + +If you later move to a public VM and a GitHub-hosted deploy job, add: + +- `SSH_PRIVATE_KEY` +- `VM_USER` + +--- + +## Testing Results Checklist + +Collect evidence for submission: + +- `ansible-playbook playbooks/provision.yml --list-tags` +- `ansible-playbook playbooks/provision.yml --tags docker_install` +- `ansible-playbook playbooks/deploy.yml` +- second deployment run for idempotency proof +- wipe-only run: + - `ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe` +- clean reinstall run: + - `ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true"` +- rendered file: + - `/opt/{{ app_name }}/docker-compose.yml` +- app responses: + - `curl http://:{{ app_port }}/health` + - `curl http://:{{ app_port }}/` +- GitHub Actions logs for `lint` and `deploy` + +--- + +## Challenges & Solutions + +- **Compose v2 on Ubuntu**: the docker role installs `docker-compose-plugin` so `docker compose` is available. +- **Idempotency vs pull policy**: image pulling is done explicitly via `docker_image`, while Compose uses `pull: never`. +- **Safety for destructive operations**: wipe logic requires both a boolean variable and, for wipe-only mode, an explicit tag. +- **Collection portability**: `ansible.posix` was added explicitly so `authorized_key` does not depend on the full `ansible` meta-package. + +--- + +## Research Answers + +### Blocks & Tags + +1. **What happens if the rescue block also fails?** + - The whole block is considered failed and Ansible stops the play (unless errors are ignored). + - The `always` section still runs. + +2. **Can you have nested blocks?** + - Yes. This lab uses an outer block with an `always` section, and inner blocks for `packages` / `users`. + +3. **How do tags inherit to tasks within blocks?** + - Tags defined at a block level are applied to all tasks in that block. + - Tags defined at role level (in playbooks) also apply to all tasks in the role. + +### Docker Compose + +1. **Difference between `restart: always` and `restart: unless-stopped`?** + - `always` restarts containers even if you manually stopped them. + - `unless-stopped` restarts containers automatically except when they were explicitly stopped by an operator. + +2. **How do Compose networks differ from Docker bridge networks?** + - Compose creates and manages networks as part of an application project, with predictable naming. + - A classic Docker bridge network is a lower-level primitive not tied to a Compose project. + +3. **Can you reference Ansible Vault variables in the template?** + - Yes. Vault-encrypted variables are decrypted at runtime and can be used like normal variables in templates. + +### Wipe logic + +1. **Why use both variable AND tag?** + - The variable protects normal runs. + - The tag enables a wipe-only execution that skips deployment tasks. + +2. **Difference between `never` tag and this approach?** + - `never` requires explicit `--tags never` (or another selected tag on the same task) and can be confusing. + - Variable + tag is explicit and easier to reason about for safety-critical operations. + +3. **Why must wipe logic come BEFORE deployment?** + - It enables the clean reinstall flow: remove old state first, then deploy from scratch. + +4. **When do you want clean reinstall vs rolling update?** + - Clean reinstall: broken state, changed config/layout, major upgrades, testing fresh installs. + - Rolling update: keep service available, minimize downtime, gradual rollout. + +5. **How to extend wipe to remove images/volumes too?** + - Add Compose options such as `remove_volumes: true` when needed. + - Remove images via `community.docker.docker_image state: absent`. + - Remove named volumes explicitly with Docker modules or CLI commands. + +### CI/CD + +1. **Security implications of storing SSH keys in GitHub Secrets?** + - Secrets can be exfiltrated if workflows are compromised. + - Limit key scope, use dedicated deploy keys, restrict repo access, and rotate regularly. + +2. **How to implement staging → production pipeline?** + - Use separate environments, separate inventories, and manual approvals for production. + - Another option is separate workflows triggered on tags/releases. + +3. **What to add for rollbacks?** + - Pin image tags, keep the previous known-good tag, and add a rollback job that redeploys it. + +4. **How does a self-hosted runner improve security compared to GitHub-hosted?** + - SSH keys/secrets do not need to be sent to GitHub-hosted machines. + - The runner stays inside your controlled network perimeter. + diff --git a/ansible/group_vars/all.yml.example b/ansible/group_vars/all.yml.example index 2dbc32167c..e82ce0e542 100644 --- a/ansible/group_vars/all.yml.example +++ b/ansible/group_vars/all.yml.example @@ -1,13 +1,17 @@ --- -# Пример! Настоящие значения храните в ЗАШИФРОВАННОМ файле: +# Example only. Store real values in an encrypted file, for example: # ansible-vault create ansible/group_vars/all.yml dockerhub_username: "CHANGE_ME" -dockerhub_password: "CHANGE_ME" # лучше токен, не пароль +dockerhub_password: "CHANGE_ME" # Prefer a token instead of a password app_name: "devops-info-service" docker_image: "{{ dockerhub_username }}/{{ app_name }}" -docker_image_tag: "latest" +docker_tag: "latest" -app_port: 5000 -app_container_name: "{{ app_name }}" +# Course default is 8000, but you can change it if your lab setup requires it. +app_port: 8000 +app_internal_port: 8000 + +# Extra environment variables for the container (dict) +app_env: {} diff --git a/ansible/group_vars/webservers.yml b/ansible/group_vars/webservers.yml index cd5ba022fb..310cc192e1 100644 --- a/ansible/group_vars/webservers.yml +++ b/ansible/group_vars/webservers.yml @@ -2,9 +2,15 @@ dockerhub_username: "dorley174" dockerhub_password: "" +# Application / image app_name: "devops-info-service" docker_image: "{{ dockerhub_username }}/{{ app_name }}" -docker_image_tag: "latest" +docker_tag: "latest" -app_port: 5000 -app_container_name: "{{ app_name }}" +# Expose the app on the course-standard port. +app_port: 8000 +app_internal_port: 8000 + +# Extra environment variables for the container (optional) +app_env: {} +dockerhub_username: dorley174 diff --git a/ansible/inventory/group_vars/webservers.yml b/ansible/inventory/group_vars/webservers.yml new file mode 100644 index 0000000000..2d245cbfeb --- /dev/null +++ b/ansible/inventory/group_vars/webservers.yml @@ -0,0 +1,15 @@ +--- +dockerhub_username: "dorley174" +dockerhub_password: "" + +# Application / image +app_name: "devops-info-service" +docker_image: "{{ dockerhub_username }}/{{ app_name }}" +docker_tag: "latest" + +# Expose the app on the course-standard port. +app_port: 8000 +app_internal_port: 8000 + +# Extra environment variables for the container (optional) +app_env: {} diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini index 94c50c5212..d521f32a26 100644 --- a/ansible/inventory/hosts.ini +++ b/ansible/inventory/hosts.ini @@ -1,19 +1,5 @@ -# Option A (cloud VM from Lab04): -# web1 ansible_host= ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_ed25519 - -# Option B (100% free, recommended for Windows 11): Vagrant + VirtualBox target VM -# 1) Run `vagrant up` on Windows (PowerShell) in the repo root. -# 2) Check forwarded ports: `vagrant port`. -# 3) In WSL, get Windows host IP: -# WIN_HOST_IP=$(grep -m1 nameserver /etc/resolv.conf | awk '{print $2}') -# 4) Copy Vagrant private key from the project to Linux home and chmod 600. -# 5) Put the detected values below: -# vagrant1 ansible_host= ansible_port= ansible_user=vagrant ansible_ssh_private_key_file=~/.ssh/lab05_vagrant_key - -# Option C (local-only): run on localhost (WSL acts as a "server") -# localhost ansible_connection=local ansible_python_interpreter=/usr/bin/python3 [webservers] -vagrant1 ansible_host=192.168.31.32 ansible_port=2222 ansible_user=vagrant ansible_ssh_private_key_file=~/.ssh/lab05_vagrant_key +vagrant1 ansible_host=172.19.144.1 ansible_port=2222 ansible_user=vagrant ansible_ssh_private_key_file=/home/dorley/.ssh/lab05_vagrant_key [webservers:vars] ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml index af1b388e3f..f9c3864e66 100644 --- a/ansible/playbooks/deploy.yml +++ b/ansible/playbooks/deploy.yml @@ -3,8 +3,8 @@ hosts: webservers become: true - vars_files: - - ../group_vars/webservers.yml - roles: - - app_deploy + # Docker is a dependency of web_app (see roles/web_app/meta/main.yml) + - role: web_app + tags: + - web_app diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml index 7cc2e6678d..9f6954005f 100644 --- a/ansible/playbooks/provision.yml +++ b/ansible/playbooks/provision.yml @@ -4,5 +4,10 @@ become: true roles: - - common - - docker + - role: common + tags: + - common + + - role: docker + tags: + - docker diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml index ae8e6f8951..6606e5aea3 100644 --- a/ansible/playbooks/site.yml +++ b/ansible/playbooks/site.yml @@ -4,6 +4,11 @@ become: true roles: - - common - - docker - - app_deploy + - role: common + tags: + - common + + # Docker role is executed automatically as a dependency of web_app. + - role: web_app + tags: + - web_app diff --git a/ansible/requirements.yml b/ansible/requirements.yml index b869f415df..ff73cd7f28 100644 --- a/ansible/requirements.yml +++ b/ansible/requirements.yml @@ -2,3 +2,4 @@ collections: - name: community.docker - name: community.general + - name: ansible.posix diff --git a/ansible/roles/app_deploy/defaults/main.yml b/ansible/roles/app_deploy/defaults/main.yml deleted file mode 100644 index 86a10efbf9..0000000000 --- a/ansible/roles/app_deploy/defaults/main.yml +++ /dev/null @@ -1,20 +0,0 @@ ---- -# App defaults (can be overridden from group_vars/all.yml vault file) -app_name: "devops-info-service" -app_container_name: "{{ app_name }}" - -docker_image_tag: "latest" - -# Host->container port mapping -app_port: 5000 -container_port: 5000 - -# Docker container restart policy -app_restart_policy: "unless-stopped" - -# Environment variables passed to container (dict) -app_env: {} - -# Optional registry URL (empty means Docker Hub) -# Example: "cr.yandex/xxx" or "ghcr.io" -docker_registry: "" diff --git a/ansible/roles/app_deploy/handlers/main.yml b/ansible/roles/app_deploy/handlers/main.yml deleted file mode 100644 index 1fc3fba48b..0000000000 --- a/ansible/roles/app_deploy/handlers/main.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -- name: restart app 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 deleted file mode 100644 index 934817c0c3..0000000000 --- a/ansible/roles/app_deploy/tasks/main.yml +++ /dev/null @@ -1,77 +0,0 @@ ---- -- name: Ensure Docker SDK for Python is installed on target - ansible.builtin.apt: - name: python3-docker - state: present - update_cache: true - -- name: Login to Docker registry - community.docker.docker_login: - registry_url: "{{ (docker_registry | length > 0) | ternary(docker_registry, omit) }}" - username: "{{ dockerhub_username }}" - password: "{{ dockerhub_password }}" - no_log: true - when: dockerhub_password is defined and dockerhub_password | length > 0 - -- name: Pull application image - community.docker.docker_image: - name: "{{ (docker_image | default(dockerhub_username ~ '/' ~ app_name)) }}:{{ (docker_image_tag | default('latest')) }}" - source: pull - register: app_image_pull - -- name: Check current container (if exists) - community.docker.docker_container_info: - name: "{{ app_container_name }}" - register: app_container_info - failed_when: false - -- name: Decide if redeploy is needed - ansible.builtin.set_fact: - app_needs_redeploy: "{{ app_image_pull.changed or (not app_container_info.exists) }}" - -- name: Stop existing container (only if redeploy needed) - community.docker.docker_container: - name: "{{ app_container_name }}" - state: stopped - when: app_container_info.exists and app_needs_redeploy - -- name: Remove existing container (only if redeploy needed) - community.docker.docker_container: - name: "{{ app_container_name }}" - state: absent - when: app_container_info.exists and app_needs_redeploy - -- name: Run application container - community.docker.docker_container: - name: "{{ app_container_name }}" - image: "{{ (docker_image | default(dockerhub_username ~ '/' ~ app_name)) }}:{{ (docker_image_tag | default('latest')) }}" - state: started - restart_policy: "{{ app_restart_policy }}" - published_ports: - - "{{ app_port }}:{{ container_port }}" - env: "{{ app_env }}" - notify: restart app container - -- name: Wait for the application port to become available - ansible.builtin.wait_for: - host: "127.0.0.1" - port: "{{ app_port }}" - timeout: 60 - -- name: Health check (/health) - ansible.builtin.uri: - url: "http://127.0.0.1:{{ app_port }}/health" - method: GET - status_code: 200 - return_content: true - register: app_health - retries: 10 - delay: 3 - until: app_health.status == 200 - -- name: Verify main endpoint (/) - ansible.builtin.uri: - url: "http://127.0.0.1:{{ app_port }}/" - method: GET - status_code: 200 - return_content: false diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml index 9864c34cc2..f7648321e4 100644 --- a/ansible/roles/common/defaults/main.yml +++ b/ansible/roles/common/defaults/main.yml @@ -15,6 +15,21 @@ common_packages: # Default timezone (change if needed) common_timezone: "Europe/Moscow" -# В WSL и некоторых окружениях timedatectl может быть недоступен. -# По умолчанию timezone НЕ меняем. +# In WSL and some minimal environments, timedatectl may be unavailable. +# By default, timezone changes are disabled. common_set_timezone: false + +# --------------------------------------------------------------------------- +# User management (optional) +# --------------------------------------------------------------------------- +# Lab06 requires a "users" block. To avoid unexpected changes on shared hosts, +# this is disabled by default (empty list => no-op). +# +# Example: +# common_users: +# - name: devops +# groups: sudo +# sudo_nopasswd: true +# authorized_keys: +# - "ssh-ed25519 AAAA... your_key" +common_users: [] diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml index a2464dd655..514aa4e705 100644 --- a/ansible/roles/common/tasks/main.yml +++ b/ansible/roles/common/tasks/main.yml @@ -1,15 +1,101 @@ --- -- name: Update apt cache - ansible.builtin.apt: - update_cache: true - cache_valid_time: 3600 - -- name: Install common packages - ansible.builtin.apt: - name: "{{ common_packages }}" - state: present - -- name: Set timezone - community.general.timezone: - name: "{{ common_timezone }}" - when: common_set_timezone | bool + +# This role demonstrates advanced Ansible features (Lab06): +# - nested blocks +# - rescue/always error handling +# - tag strategy for selective execution + +- name: Common role tasks + block: + # ----------------------------------------------------------------------- + # Packages + # ----------------------------------------------------------------------- + - name: Install common packages + block: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + rescue: + # NOTE: If this rescue block fails too, Ansible will stop the play. + # The outer 'always' section will still run. + - name: Fix APT metadata issues (best-effort) # noqa command-instead-of-module + ansible.builtin.command: apt-get update --fix-missing + changed_when: false + + - name: Retry apt cache update + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + become: true + tags: + - packages + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + - name: Manage users (optional) + block: + - name: Ensure requested users exist + ansible.builtin.user: + name: "{{ item.name }}" + groups: "{{ item.groups | default(omit) }}" + shell: "{{ item.shell | default('/bin/bash') }}" + create_home: true + state: present + loop: "{{ common_users }}" + loop_control: + label: "{{ item.name }}" + + - name: Configure passwordless sudo (optional) + ansible.builtin.copy: + dest: "/etc/sudoers.d/{{ item.name }}" + content: "{{ item.name }} ALL=(ALL) NOPASSWD:ALL\n" + mode: "0440" + validate: "visudo -cf %s" + when: item.sudo_nopasswd | default(false) | bool + loop: "{{ common_users }}" + loop_control: + label: "{{ item.name }}" + + - name: Add SSH authorized keys (optional) + ansible.posix.authorized_key: + user: "{{ item.0.name }}" + key: "{{ item.1 }}" + state: present + loop: "{{ common_users | subelements('authorized_keys', skip_missing=True) }}" + loop_control: + label: "{{ item.0.name }}" + + become: true + tags: + - users + + # ----------------------------------------------------------------------- + # Other system settings + # ----------------------------------------------------------------------- + - name: Set timezone + community.general.timezone: + name: "{{ common_timezone }}" + when: common_set_timezone | bool + become: true + + always: + # A simple completion marker that can be used as "evidence". + - name: Log common role completion + ansible.builtin.copy: + dest: /tmp/ansible_common_role_completed.log + content: "common role completed at {{ ansible_date_time.iso8601 }}\n" + mode: "0644" + become: true + changed_when: false + tags: + - packages + - users diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml index 1a5058da5e..0162ba52da 100644 --- a/ansible/roles/docker/handlers/main.yml +++ b/ansible/roles/docker/handlers/main.yml @@ -1,5 +1,5 @@ --- -- name: restart docker +- 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 index b98c9a8450..b6734814c6 100644 --- a/ansible/roles/docker/tasks/main.yml +++ b/ansible/roles/docker/tasks/main.yml @@ -1,77 +1,155 @@ --- -- name: Install prerequisites for Docker repository - ansible.builtin.apt: - name: - - ca-certificates - - curl - - gnupg - state: present - update_cache: true - -- name: Ensure /etc/apt/keyrings exists - ansible.builtin.file: - path: /etc/apt/keyrings - state: directory - mode: "0755" - -- name: Download Docker GPG key (ASCII) - ansible.builtin.get_url: - url: "{{ docker_gpg_key_url }}" - dest: /tmp/docker.gpg - mode: "0644" - register: docker_gpg_download - -- name: Check if Docker keyring already exists - ansible.builtin.stat: - path: "{{ docker_keyring_path }}" - register: docker_keyring_stat - -- name: Convert (dearmor) Docker GPG key to keyring - ansible.builtin.command: - cmd: "gpg --dearmor -o {{ docker_keyring_path }} /tmp/docker.gpg" - when: docker_gpg_download.changed or (not docker_keyring_stat.stat.exists) - notify: restart docker - -- name: Set correct permissions on Docker keyring - ansible.builtin.file: - path: "{{ docker_keyring_path }}" - mode: "0644" - -- name: Set Docker APT architecture mapping - ansible.builtin.set_fact: - docker_apt_arch: "{{ {'x86_64':'amd64','aarch64':'arm64'}.get(ansible_architecture, ansible_architecture) }}" - -- name: Add official Docker APT repository - ansible.builtin.apt_repository: - repo: "deb [arch={{ docker_apt_arch }} signed-by={{ docker_keyring_path }}] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable" - state: present - filename: "{{ docker_repo_filename }}" - -- name: Install Docker Engine packages - ansible.builtin.apt: - name: "{{ docker_packages }}" - state: present - update_cache: true - notify: restart docker - -- name: Ensure Docker service is enabled and running - ansible.builtin.service: - name: docker - state: started - enabled: true - -- name: Ensure docker group exists - ansible.builtin.group: - name: docker - state: present - -- name: Add user to docker group - ansible.builtin.user: - name: "{{ docker_user }}" - groups: docker - append: true - -- name: Install Docker SDK for Python on target (for Ansible docker modules) - ansible.builtin.apt: - name: python3-docker - state: present + +# Lab06: this role is refactored to use blocks, tags and error handling. +# Tags: +# - docker (role-level tag, set in playbooks) +# - docker_install (installation only) +# - docker_config (configuration only) + +- name: Docker role tasks + block: + # ----------------------------------------------------------------------- + # Installation + # ----------------------------------------------------------------------- + - name: Install Docker Engine + block: + - name: Install prerequisites for Docker repository + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + state: present + update_cache: true + + - name: Ensure /etc/apt/keyrings exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + + - name: Download Docker GPG key (ASCII) + ansible.builtin.get_url: + url: "{{ docker_gpg_key_url }}" + dest: /tmp/docker.gpg + mode: "0644" + register: docker_gpg_download + + - name: Check if Docker keyring already exists + ansible.builtin.stat: + path: "{{ docker_keyring_path }}" + register: docker_keyring_stat + + - name: Convert (dearmor) Docker GPG key to keyring + ansible.builtin.command: + cmd: "gpg --dearmor -o {{ docker_keyring_path }} /tmp/docker.gpg" + when: docker_gpg_download.changed or (not docker_keyring_stat.stat.exists) + changed_when: true + notify: Restart Docker + + - name: Set correct permissions on Docker keyring + ansible.builtin.file: + path: "{{ docker_keyring_path }}" + mode: "0644" + + - name: Set Docker APT architecture mapping + ansible.builtin.set_fact: + docker_apt_arch: "{{ {'x86_64': 'amd64', 'aarch64': 'arm64'}.get(ansible_architecture, ansible_architecture) }}" + + - name: Add official Docker APT repository + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ docker_apt_arch }} signed-by={{ docker_keyring_path }}] + https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable + state: present + filename: "{{ docker_repo_filename }}" + + - name: Install Docker Engine packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + update_cache: true + notify: Restart Docker + + - name: Install Docker SDK for Python on target (for Ansible docker modules) + ansible.builtin.apt: + name: python3-docker + state: present + + rescue: + # Docker repository setup can occasionally fail (GPG/network hiccups). + # We do a best-effort retry after a short wait. + - name: Wait 10 seconds before retrying Docker repository setup + ansible.builtin.pause: + seconds: 10 + + - name: Retry apt cache update + ansible.builtin.apt: + update_cache: true + + - name: Re-download Docker GPG key (force) + ansible.builtin.get_url: + url: "{{ docker_gpg_key_url }}" + dest: /tmp/docker.gpg + mode: "0644" + force: true + + - name: Retry converting (dearmor) Docker GPG key + ansible.builtin.command: + cmd: "gpg --dearmor -o {{ docker_keyring_path }} /tmp/docker.gpg" + changed_when: true + notify: Restart Docker + + - name: Retry adding Docker APT repository + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ docker_apt_arch | default('amd64') }} signed-by={{ docker_keyring_path }}] + https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable + state: present + filename: "{{ docker_repo_filename }}" + + - name: Retry installing Docker Engine packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + update_cache: true + notify: Restart Docker + + become: true + tags: + - docker_install + + # ----------------------------------------------------------------------- + # Configuration + # ----------------------------------------------------------------------- + - name: Configure Docker + block: + - name: Ensure docker group exists + ansible.builtin.group: + name: docker + state: present + + - name: Add user to docker group + ansible.builtin.user: + name: "{{ docker_user }}" + groups: docker + append: true + + become: true + tags: + - docker_config + + always: + # Lab06 requirement: always ensure Docker service is enabled & started. + # If Docker is not installed yet, we do not fail the whole play. + - name: Ensure Docker service is enabled and running + ansible.builtin.service: + name: docker + state: started + enabled: true + become: true + register: docker_service_result + failed_when: false + tags: + - docker_install + - docker_config diff --git a/ansible/roles/web_app/defaults/main.yml b/ansible/roles/web_app/defaults/main.yml new file mode 100644 index 0000000000..57d9c2ea83 --- /dev/null +++ b/ansible/roles/web_app/defaults/main.yml @@ -0,0 +1,45 @@ +--- + +# --------------------------------------------------------------------------- +# Web application deployment (Docker Compose) +# --------------------------------------------------------------------------- + +# Service/container name +app_name: devops-app + +# Docker image reference (repository part, without tag) +# Example: "your_dockerhub_username/devops-info-service" +docker_image: "{{ (dockerhub_username is defined and dockerhub_username | length > 0) | ternary(dockerhub_username ~ '/' ~ app_name, app_name) }}" + +# Docker image tag/version +# Backward compatible with Lab05 variable name `docker_image_tag`. +docker_tag: "{{ docker_image_tag | default('latest') }}" + +# Host -> container port mapping +app_port: 8000 + +# Internal container port. Backward compatible with Lab05 variable `container_port`. +app_internal_port: "{{ container_port | default(8000) }}" + +# Where docker-compose.yml will be stored on the target host +compose_project_dir: "/opt/{{ app_name }}" + +# Compose file version header (mainly for readability; Compose v2 doesn't require it) +docker_compose_version: "3.8" + +# Extra environment variables passed to the container (merged in the template) +app_env: {} + +# Optional registry URL (empty means Docker Hub) +docker_registry: "" + +# --------------------------------------------------------------------------- +# Wipe Logic (Lab06 Task 3) +# --------------------------------------------------------------------------- +# Double-gated safety: +# 1) variable web_app_wipe=true +# 2) tag web_app_wipe (for wipe-only runs) +web_app_wipe: false + +# Optional: remove the image during wipe (useful to save disk space) +web_app_wipe_remove_image: false diff --git a/ansible/roles/web_app/meta/main.yml b/ansible/roles/web_app/meta/main.yml new file mode 100644 index 0000000000..6ab81a8714 --- /dev/null +++ b/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,7 @@ +--- + +# Role dependencies ensure correct execution order. +# Lab06 requirement: Docker must be installed before deploying the web app. + +dependencies: + - role: docker diff --git a/ansible/roles/web_app/tasks/main.yml b/ansible/roles/web_app/tasks/main.yml new file mode 100644 index 0000000000..a173d708f8 --- /dev/null +++ b/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,89 @@ +--- + +# Lab06 Task 3: wipe logic runs first (only when variable is true). +- name: Include wipe tasks + ansible.builtin.include_tasks: wipe.yml + tags: + - web_app_wipe + + +# Lab06 Task 2: deploy with Docker Compose v2. +- name: Deploy application with Docker Compose + block: + - name: Create application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Template docker-compose.yml + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ compose_project_dir }}/docker-compose.yml" + mode: "0644" + + # Optional: if the image is private, login must be done on the target host. + - name: Login to Docker registry (optional) + community.docker.docker_login: + registry_url: "{{ (docker_registry | length > 0) | ternary(docker_registry, omit) }}" + username: "{{ dockerhub_username }}" + password: "{{ dockerhub_password }}" + no_log: true + when: + - dockerhub_username is defined + - dockerhub_password is defined + - dockerhub_password | length > 0 + + # Keep idempotency: pull only if image changed. + - name: Pull application image + community.docker.docker_image: + name: "{{ docker_image }}:{{ docker_tag }}" + source: pull + register: web_app_image_pull + + - name: Deploy (docker compose up) + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + state: present + pull: never + recreate: auto + register: web_app_compose + + - name: Wait for the application port to become available + ansible.builtin.wait_for: + host: "127.0.0.1" + port: "{{ app_port }}" + timeout: 60 + + - name: Health check (/health) + ansible.builtin.uri: + url: "http://127.0.0.1:{{ app_port }}/health" + method: GET + status_code: 200 + return_content: true + register: web_app_health + retries: 10 + delay: 3 + until: web_app_health.status == 200 + + - name: Verify main endpoint (/) + ansible.builtin.uri: + url: "http://127.0.0.1:{{ app_port }}/" + method: GET + status_code: 200 + return_content: false + + rescue: + - name: Deployment failure hint + ansible.builtin.debug: + msg: | + Web app deployment failed. + Try checking logs on the target host: + cd {{ compose_project_dir }} + docker compose logs --no-color --tail=200 + + tags: + - app_deploy + - compose diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..df8a348e82 --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,55 @@ +--- + +# Lab06 Task 3: Safe wipe logic. +# Double-gating: +# 1) web_app_wipe=true +# 2) tag web_app_wipe (for wipe-only runs) + +- name: Wipe web application + block: + - name: Check if app directory exists + ansible.builtin.stat: + path: "{{ compose_project_dir }}" + register: web_app_dir + + - name: Stop and remove containers (docker compose down) + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + state: absent + remove_orphans: true + when: web_app_dir.stat.exists + register: web_app_compose_down + failed_when: false + + - name: Remove docker-compose.yml + ansible.builtin.file: + path: "{{ compose_project_dir }}/docker-compose.yml" + state: absent + + - name: Remove application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: absent + + - name: Remove application image (optional) + community.docker.docker_image: + name: "{{ docker_image }}:{{ docker_tag }}" + state: absent + when: web_app_wipe_remove_image | bool + register: web_app_image_remove + failed_when: false + + - name: Log wipe completion + ansible.builtin.copy: + dest: /tmp/ansible_web_app_wipe.log + content: "web app {{ app_name }} wiped at {{ ansible_date_time.iso8601 }}\n" + mode: "0644" + changed_when: false + + - name: Wipe summary + ansible.builtin.debug: + msg: "Application {{ app_name }} wiped successfully" + + when: web_app_wipe | bool + tags: + - web_app_wipe diff --git a/ansible/roles/web_app/templates/docker-compose.yml.j2 b/ansible/roles/web_app/templates/docker-compose.yml.j2 new file mode 100644 index 0000000000..5ae69e2951 --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,26 @@ +version: "{{ docker_compose_version }}" + +services: + {{ app_name }}: + image: "{{ docker_image }}:{{ docker_tag }}" + container_name: "{{ app_name }}" + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + HOST: "0.0.0.0" + PORT: "{{ app_internal_port }}" +{% if app_env is defined and app_env | length > 0 %} +{% for key, value in app_env.items() %} + {{ key }}: {{ value | quote }} +{% endfor %} +{% endif %} +{% if app_secret_key is defined %} + APP_SECRET_KEY: {{ app_secret_key | quote }} +{% endif %} + restart: unless-stopped + networks: + - {{ app_name }}_net + +networks: + {{ app_name }}_net: + driver: bridge