diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..09c7ff4c69 --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,70 @@ +name: Ansible Deployment + +on: + push: + branches: [master, lab06] + paths: + - 'ansible/**' + - '!ansible/docs/**' + - '.github/workflows/ansible-deploy.yml' + pull_request: + branches: [master] + paths: + - 'ansible/**' + - '.github/workflows/ansible-deploy.yml' + +jobs: + lint: + name: Ansible Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Ansible and ansible-lint + run: pip install ansible ansible-lint + + - name: Run ansible-lint + env: + ANSIBLE_VAULT_PASSWORD_FILE: "" + run: | + cd ansible + # Remove vault_password_file from cfg for lint + sed -i '/vault_password_file/d' ansible.cfg + ansible-lint playbooks/provision.yml playbooks/deploy.yml playbooks/site.yml + + deploy: + name: Deploy Application + needs: lint + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install Ansible + run: pip install ansible + - name: Install collections + run: ansible-galaxy collection install community.docker community.general + - name: Configure SSH + run: | + mkdir -p ~/.ssh + printf '%s' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "${{ secrets.VM_HOST }}" >> ~/.ssh/known_hosts + - name: Deploy + env: + VAULT_PASS: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + run: | + printf '%s' "$VAULT_PASS" > /tmp/vault_pass + cd ansible + ansible-playbook playbooks/deploy.yml --vault-password-file /tmp/vault_pass + - name: Cleanup + if: always() + run: rm -f /tmp/vault_pass + - name: Verify health + run: sleep 10 && curl -f "http://${{ secrets.VM_HOST }}:5000/health" diff --git a/.gitignore b/.gitignore index 30d74d2584..5dfde0f734 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -test \ No newline at end of file +test.vault_pass diff --git a/ansible/.ansible-lint b/ansible/.ansible-lint new file mode 100644 index 0000000000..a42a13d8f2 --- /dev/null +++ b/ansible/.ansible-lint @@ -0,0 +1,9 @@ +warn_list: + - key-order + - var-naming + - name + +skip_list: + - key-order[task] + - var-naming[no-role-prefix] + - name[casing] diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index 11bb2bd1f9..d74fa42465 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -1,12 +1,11 @@ -[defaults] -vault_password_file = .vault_pass -inventory = inventory/hosts.ini -roles_path = roles -host_key_checking = False -remote_user = ubuntu -retry_files_enabled = False - -[privilege_escalation] -become = True -become_method = sudo +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = ubuntu +retry_files_enabled = False + +[privilege_escalation] +become = True +become_method = sudo become_user = root \ No newline at end of file diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md index 936cc2037b..b5bb093bfc 100644 --- a/ansible/docs/LAB05.md +++ b/ansible/docs/LAB05.md @@ -1,382 +1,382 @@ -# Lab 05 — Ansible Fundamentals - -## 1. Architecture Overview - -**Ansible version:** ansible [core 2.16.3] - -**Target VM OS:** Ubuntu 22.04.5 LTS (ubuntu/jammy64 via Vagrant + VirtualBox) - -**Control node:** WSL2 (Ubuntu) on Windows - -### Role Structure - -``` -ansible/ -├── inventory/ -│ └── hosts.ini # Static inventory — VM IP + SSH key -├── roles/ -│ ├── common/ # System packages & timezone -│ │ ├── tasks/main.yml -│ │ └── defaults/main.yml -│ ├── docker/ # Docker CE installation -│ │ ├── tasks/main.yml -│ │ ├── handlers/main.yml -│ │ └── defaults/main.yml -│ └── app_deploy/ # Pull & run containerized app -│ ├── tasks/main.yml -│ ├── handlers/main.yml -│ └── defaults/main.yml -├── playbooks/ -│ ├── site.yml # Imports both playbooks -│ ├── provision.yml # common + docker roles -│ └── deploy.yml # app_deploy role -├── group_vars/ -│ └── all.yml # Ansible Vault encrypted secrets -├── ansible.cfg -└── docs/LAB05.md -``` - -### Connectivity Test - -``` -$ ansible all -m ping - -devops-vm | SUCCESS => { - "changed": false, - "ping": "pong" -} -``` - ---- - -### Why Roles Instead of Monolithic Playbooks? - -Roles split responsibilities cleanly: `common` handles system setup, `docker` handles Docker installation, `app_deploy` handles the application. Each role can be reused across multiple projects, tested independently, and understood in isolation. A monolithic playbook with 50+ tasks becomes impossible to maintain — roles keep complexity manageable. - ---- - -## 2. Roles Documentation - -### Role: `common` - -**Purpose:** Prepares every server with baseline tools. Updates the apt cache and installs utilities like git, curl, vim, htop, python3-pip. - -**Variables (`defaults/main.yml`):** -| Variable | Default | Description | -|---|---|---| -| `common_packages` | list of packages | Packages to install via apt | -| `common_timezone` | `UTC` | System timezone | - -**Handlers:** None - -**Dependencies:** None - ---- - -### Role: `docker` - -**Purpose:** Installs Docker CE from the official Docker repository using the modern GPG key method (`/etc/apt/keyrings/docker.gpg`), ensures the service is running and enabled at boot, adds the target user to the `docker` group. - -**Variables (`defaults/main.yml`):** -| Variable | Default | Description | -|---|---|---| -| `docker_packages` | docker-ce, cli, containerd, plugins | Packages to install | -| `docker_user` | `vagrant` | User to add to docker group | - -**Handlers:** -- `restart docker` — triggered when Docker packages are installed for the first time - -**Dependencies:** `common` role (needs ca-certificates, gnupg pre-installed) - ---- - -### Role: `app_deploy` - -**Purpose:** Authenticates with Docker Hub using vaulted credentials, pulls the latest image of the Python app (`cdeth567/devops-info-service`), removes any old container, starts a fresh container with port mapping 5000:5000, and verifies the `/health` endpoint responds HTTP 200. - -**Variables (`group_vars/all.yml` — Vault encrypted):** -| Variable | Description | -|---|---| -| `dockerhub_username` | Docker Hub username (`cdeth567`) | -| `dockerhub_password` | Docker Hub access token | -| `app_name` | `devops-info-service` | -| `docker_image` | `cdeth567/devops-info-service` | -| `docker_image_tag` | `latest` | -| `app_port` | `5000` | -| `app_container_name` | `devops-info-service` | - -**Variables (`defaults/main.yml`):** -| Variable | Default | Description | -|---|---|---| -| `app_port` | `5000` | Port to expose | -| `app_restart_policy` | `unless-stopped` | Docker restart policy | -| `app_env_vars` | `{}` | Extra env vars for container | - -**Handlers:** -- `restart app container` — restarts the container when triggered by config change - -**Dependencies:** `docker` role must run first - ---- - -## 3. Idempotency Demonstration - -### First Run (`ansible-playbook playbooks/provision.yml`) - -``` -PLAY [Provision web servers] **************************************************** - -TASK [Gathering Facts] ********************************************************** -ok: [devops-vm] - -TASK [common : Update apt cache] ************************************************ -changed: [devops-vm] - -TASK [common : Install common packages] ***************************************** -changed: [devops-vm] - -TASK [common : Set timezone (optional)] ***************************************** -changed: [devops-vm] - -TASK [docker : Install prerequisites for Docker repo] *************************** -ok: [devops-vm] - -TASK [docker : Ensure /etc/apt/keyrings exists] ********************************* -ok: [devops-vm] - -TASK [docker : Download and dearmor Docker GPG key] ***************************** -ok: [devops-vm] - -TASK [docker : Add Docker apt repository] *************************************** -changed: [devops-vm] - -TASK [docker : Install Docker packages] ***************************************** -changed: [devops-vm] - -TASK [docker : Ensure Docker service is started and enabled] ******************** -ok: [devops-vm] - -TASK [docker : Add user to docker group] **************************************** -changed: [devops-vm] - -TASK [docker : Install python3-docker for Ansible Docker modules] *************** -changed: [devops-vm] - -PLAY RECAP ********************************************************************** -devops-vm : ok=10 changed=6 unreachable=0 failed=0 -``` - -### Second Run (`ansible-playbook playbooks/provision.yml`) - -``` -PLAY [Provision web servers] **************************************************** - -TASK [Gathering Facts] ********************************************************** -ok: [devops-vm] - -TASK [common : Update apt cache] ************************************************ -ok: [devops-vm] - -TASK [common : Install common packages] ***************************************** -ok: [devops-vm] - -TASK [common : Set timezone (optional)] ***************************************** -ok: [devops-vm] - -TASK [docker : Install prerequisites for Docker repo] *************************** -ok: [devops-vm] - -TASK [docker : Ensure /etc/apt/keyrings exists] ********************************* -ok: [devops-vm] - -TASK [docker : Download and dearmor Docker GPG key] ***************************** -ok: [devops-vm] - -TASK [docker : Add Docker apt repository] *************************************** -ok: [devops-vm] - -TASK [docker : Install Docker packages] ***************************************** -ok: [devops-vm] - -TASK [docker : Ensure Docker service is started and enabled] ******************** -ok: [devops-vm] - -TASK [docker : Add user to docker group] **************************************** -ok: [devops-vm] - -TASK [docker : Install python3-docker for Ansible Docker modules] *************** -ok: [devops-vm] - -PLAY RECAP ********************************************************************** -devops-vm : ok=12 changed=0 unreachable=0 failed=0 -``` - -### Analysis - -**First run — why tasks showed `changed`:** -- `Update apt cache` — package lists were stale, had to refresh -- `Install common packages` — git, curl, vim etc. weren't installed yet -- `Set timezone` — timezone wasn't configured -- `Download and dearmor Docker GPG key` — key wasn't in `/etc/apt/keyrings/` yet -- `Add Docker apt repository` — Docker repo wasn't in sources -- `Install Docker packages` — Docker CE wasn't installed -- `Add user to docker group` — vagrant user wasn't in docker group -- `Install python3-docker` — Python Docker SDK wasn't installed - -**Second run — zero `changed`, all `ok`:** -Every Ansible module checks current state before acting. `apt: state=present` checks if package already exists. `file: state=directory` checks if directory exists. `apt_repository` checks if repo is already listed. Since everything was already in desired state, no changes were made. This is idempotency. - -**What makes these roles idempotent:** -- `apt: state=present` — only installs if not already present -- `file: state=directory` — only creates if missing -- `args: creates: /etc/apt/keyrings/docker.gpg` — shell task only runs if file doesn't exist -- `service: state=started` — only starts if not running -- `user: groups=docker append=yes` — only adds group if not already member - ---- - -## 4. Ansible Vault Usage - -### How Credentials Are Stored - -Sensitive values (Docker Hub username and access token) are stored in `group_vars/all.yml`, encrypted with Ansible Vault AES256. The file in the repository looks like: - -``` -$ANSIBLE_VAULT;1.1;AES256 -65633261653764613262313261356561613666306634343139313537336332386233336231343839 -3737366161363662643132656239373562613734356364660a646666633665353562643636393261 -... -``` - -Nobody can read the credentials without the vault password. - -### Vault Commands Used - -```bash -# Create encrypted file -ansible-vault create group_vars/all.yml - -# View contents (to verify) -ansible-vault view group_vars/all.yml --ask-vault-pass - -# Edit contents -ansible-vault edit group_vars/all.yml -``` - -### Vault Password Management - -A `.vault_pass` file stores the password locally: -```bash -echo "your-password" > .vault_pass -chmod 600 .vault_pass -``` - -`ansible.cfg` references it: -```ini -[defaults] -vault_password_file = .vault_pass -``` - -`.vault_pass` is added to `.gitignore` — never committed. - -### Proof File Is Encrypted - -``` -$ cat group_vars/all.yml -$ANSIBLE_VAULT;1.1;AES256 -65633261653764613262313261356561613666306634343139313537336332386233336231343839 -3737366161363662643132656239373562613734356364660a646666633665353562643636393261 -61346366636665303935353636656633663539616561373266333139356432623534636264326636 -6338313961386638380a656665313965346133373436656339613837356563363965313735316339 -... -``` - -### Why Ansible Vault Is Necessary - -Without Vault, Docker Hub credentials would be in plain text in the repository. Anyone with read access (teammates, CI/CD systems, public GitHub) could see the token. Vault encrypts with AES-256 — the file is safe to commit while keeping actual values private. - ---- - -## 5. Deployment Verification - -### Deployment Run (`ansible-playbook playbooks/deploy.yml`) - -``` -PLAY [Deploy application] ******************************************************* - -TASK [Gathering Facts] ********************************************************** -ok: [devops-vm] - -TASK [app_deploy : Log in to Docker Hub] **************************************** -ok: [devops-vm] - -TASK [app_deploy : Pull Docker image] ******************************************* -ok: [devops-vm] - -TASK [app_deploy : Remove old container if exists] ****************************** -changed: [devops-vm] - -TASK [app_deploy : Run new container] ******************************************* -changed: [devops-vm] - -TASK [app_deploy : Wait for application port] *********************************** -ok: [devops-vm] - -TASK [app_deploy : Verify health endpoint] ************************************** -ok: [devops-vm] - -PLAY RECAP ********************************************************************** -devops-vm : ok=7 changed=2 unreachable=0 failed=0 -``` - -### Container Status (`docker ps`) - -``` -$ ansible webservers -a "docker ps" - -devops-vm | CHANGED | rc=0 >> -CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES -89b8f4104cfb cdeth567/devops-info-service:latest "python app.py" 1 minute ago Up 1 minute 0.0.0.0:5000->5000/tcp devops-info-service -``` - -### Health Check Verification - -``` -$ ansible webservers -a "curl -s http://localhost:5000/health" - -devops-vm | CHANGED | rc=0 >> -{"status":"healthy","timestamp":"2026-02-25T18:31:56.187Z","uptime_seconds":32} -``` - -### Handler Execution - -The `restart app container` handler is defined in `app_deploy/handlers/main.yml` and triggers only when container configuration changes. During normal re-deployment, the remove+start tasks handle container lifecycle directly. - ---- - -## 6. Key Decisions - -**Why use roles instead of plain playbooks?** -Roles enforce a standard structure and make code reusable. The `docker` role can be dropped into any future project without modification. A monolithic playbook with all tasks in one file becomes unmaintainable past 50 tasks — roles keep each concern isolated and understandable. - -**How do roles improve reusability?** -Each role encapsulates one responsibility with its own defaults, handlers, and tasks. The `common` and `docker` roles can be included in any server provisioning project. Variables in `defaults/` provide sensible out-of-the-box behavior that can be overridden per environment without changing role code. - -**What makes a task idempotent?** -A task is idempotent when it checks existing state before acting and only makes changes if current state differs from desired state. Ansible's built-in modules (apt, service, user, docker_container) all implement this natively — they check first, act only if needed. - -**How do handlers improve efficiency?** -Handlers only run once at the end of a play, even if notified multiple times by different tasks. Without handlers, Docker would restart after every individual package or config task. With handlers, it restarts exactly once after all changes complete — saving time and avoiding unnecessary service disruptions. - -**Why is Ansible Vault necessary?** -Credentials in plain text in a repository are a security breach. Any team member, CI system, or public viewer could see Docker Hub tokens. Vault encrypts secrets with AES-256 so the file can be safely committed and shared while keeping actual values accessible only to those with the vault password. - ---- - -## 7. Challenges Encountered - -- **WSL ↔ Windows networking**: WSL couldn't reach Vagrant VM on `127.0.0.1:2222` — fixed by adding a Windows portproxy (`netsh interface portproxy`) and using the WSL gateway IP `172.26.16.1` as the Ansible host -- **ansible.cfg ignored**: Ansible ignores config files in world-writable directories (Windows NTFS mounts in WSL) — fixed by copying the project to WSL home directory `~/ansible` -- **Docker GPG key**: The `apt_key` module is deprecated on Ubuntu 22.04 — fixed by using `curl | gpg --dearmor` to save key to `/etc/apt/keyrings/docker.gpg` with `signed-by=` in the repo line -- **Vault vars undefined in role**: With `become: true`, vault variables weren't passed into the role context — fixed by adding `vars_files: ../group_vars/all.yml` explicitly in `deploy.yml` -- **Vault password file path**: Relative path `.vault_pass` in `ansible.cfg` didn't work — fixed by using absolute path `/home/cdeth567/ansible/.vault_pass` locally (relative path used in repo version) +# Lab 05 — Ansible Fundamentals + +## 1. Architecture Overview + +**Ansible version:** ansible [core 2.16.3] + +**Target VM OS:** Ubuntu 22.04.5 LTS (ubuntu/jammy64 via Vagrant + VirtualBox) + +**Control node:** WSL2 (Ubuntu) on Windows + +### Role Structure + +``` +ansible/ +├── inventory/ +│ └── hosts.ini # Static inventory — VM IP + SSH key +├── roles/ +│ ├── common/ # System packages & timezone +│ │ ├── tasks/main.yml +│ │ └── defaults/main.yml +│ ├── docker/ # Docker CE installation +│ │ ├── tasks/main.yml +│ │ ├── handlers/main.yml +│ │ └── defaults/main.yml +│ └── app_deploy/ # Pull & run containerized app +│ ├── tasks/main.yml +│ ├── handlers/main.yml +│ └── defaults/main.yml +├── playbooks/ +│ ├── site.yml # Imports both playbooks +│ ├── provision.yml # common + docker roles +│ └── deploy.yml # app_deploy role +├── group_vars/ +│ └── all.yml # Ansible Vault encrypted secrets +├── ansible.cfg +└── docs/LAB05.md +``` + +### Connectivity Test + +``` +$ ansible all -m ping + +devops-vm | SUCCESS => { + "changed": false, + "ping": "pong" +} +``` + +--- + +### Why Roles Instead of Monolithic Playbooks? + +Roles split responsibilities cleanly: `common` handles system setup, `docker` handles Docker installation, `app_deploy` handles the application. Each role can be reused across multiple projects, tested independently, and understood in isolation. A monolithic playbook with 50+ tasks becomes impossible to maintain — roles keep complexity manageable. + +--- + +## 2. Roles Documentation + +### Role: `common` + +**Purpose:** Prepares every server with baseline tools. Updates the apt cache and installs utilities like git, curl, vim, htop, python3-pip. + +**Variables (`defaults/main.yml`):** +| Variable | Default | Description | +|---|---|---| +| `common_packages` | list of packages | Packages to install via apt | +| `common_timezone` | `UTC` | System timezone | + +**Handlers:** None + +**Dependencies:** None + +--- + +### Role: `docker` + +**Purpose:** Installs Docker CE from the official Docker repository using the modern GPG key method (`/etc/apt/keyrings/docker.gpg`), ensures the service is running and enabled at boot, adds the target user to the `docker` group. + +**Variables (`defaults/main.yml`):** +| Variable | Default | Description | +|---|---|---| +| `docker_packages` | docker-ce, cli, containerd, plugins | Packages to install | +| `docker_user` | `vagrant` | User to add to docker group | + +**Handlers:** +- `restart docker` — triggered when Docker packages are installed for the first time + +**Dependencies:** `common` role (needs ca-certificates, gnupg pre-installed) + +--- + +### Role: `app_deploy` + +**Purpose:** Authenticates with Docker Hub using vaulted credentials, pulls the latest image of the Python app (`cdeth567/devops-info-service`), removes any old container, starts a fresh container with port mapping 5000:5000, and verifies the `/health` endpoint responds HTTP 200. + +**Variables (`group_vars/all.yml` — Vault encrypted):** +| Variable | Description | +|---|---| +| `dockerhub_username` | Docker Hub username (`cdeth567`) | +| `dockerhub_password` | Docker Hub access token | +| `app_name` | `devops-info-service` | +| `docker_image` | `cdeth567/devops-info-service` | +| `docker_image_tag` | `latest` | +| `app_port` | `5000` | +| `app_container_name` | `devops-info-service` | + +**Variables (`defaults/main.yml`):** +| Variable | Default | Description | +|---|---|---| +| `app_port` | `5000` | Port to expose | +| `app_restart_policy` | `unless-stopped` | Docker restart policy | +| `app_env_vars` | `{}` | Extra env vars for container | + +**Handlers:** +- `restart app container` — restarts the container when triggered by config change + +**Dependencies:** `docker` role must run first + +--- + +## 3. Idempotency Demonstration + +### First Run (`ansible-playbook playbooks/provision.yml`) + +``` +PLAY [Provision web servers] **************************************************** + +TASK [Gathering Facts] ********************************************************** +ok: [devops-vm] + +TASK [common : Update apt cache] ************************************************ +changed: [devops-vm] + +TASK [common : Install common packages] ***************************************** +changed: [devops-vm] + +TASK [common : Set timezone (optional)] ***************************************** +changed: [devops-vm] + +TASK [docker : Install prerequisites for Docker repo] *************************** +ok: [devops-vm] + +TASK [docker : Ensure /etc/apt/keyrings exists] ********************************* +ok: [devops-vm] + +TASK [docker : Download and dearmor Docker GPG key] ***************************** +ok: [devops-vm] + +TASK [docker : Add Docker apt repository] *************************************** +changed: [devops-vm] + +TASK [docker : Install Docker packages] ***************************************** +changed: [devops-vm] + +TASK [docker : Ensure Docker service is started and enabled] ******************** +ok: [devops-vm] + +TASK [docker : Add user to docker group] **************************************** +changed: [devops-vm] + +TASK [docker : Install python3-docker for Ansible Docker modules] *************** +changed: [devops-vm] + +PLAY RECAP ********************************************************************** +devops-vm : ok=10 changed=6 unreachable=0 failed=0 +``` + +### Second Run (`ansible-playbook playbooks/provision.yml`) + +``` +PLAY [Provision web servers] **************************************************** + +TASK [Gathering Facts] ********************************************************** +ok: [devops-vm] + +TASK [common : Update apt cache] ************************************************ +ok: [devops-vm] + +TASK [common : Install common packages] ***************************************** +ok: [devops-vm] + +TASK [common : Set timezone (optional)] ***************************************** +ok: [devops-vm] + +TASK [docker : Install prerequisites for Docker repo] *************************** +ok: [devops-vm] + +TASK [docker : Ensure /etc/apt/keyrings exists] ********************************* +ok: [devops-vm] + +TASK [docker : Download and dearmor Docker GPG key] ***************************** +ok: [devops-vm] + +TASK [docker : Add Docker apt repository] *************************************** +ok: [devops-vm] + +TASK [docker : Install Docker packages] ***************************************** +ok: [devops-vm] + +TASK [docker : Ensure Docker service is started and enabled] ******************** +ok: [devops-vm] + +TASK [docker : Add user to docker group] **************************************** +ok: [devops-vm] + +TASK [docker : Install python3-docker for Ansible Docker modules] *************** +ok: [devops-vm] + +PLAY RECAP ********************************************************************** +devops-vm : ok=12 changed=0 unreachable=0 failed=0 +``` + +### Analysis + +**First run — why tasks showed `changed`:** +- `Update apt cache` — package lists were stale, had to refresh +- `Install common packages` — git, curl, vim etc. weren't installed yet +- `Set timezone` — timezone wasn't configured +- `Download and dearmor Docker GPG key` — key wasn't in `/etc/apt/keyrings/` yet +- `Add Docker apt repository` — Docker repo wasn't in sources +- `Install Docker packages` — Docker CE wasn't installed +- `Add user to docker group` — vagrant user wasn't in docker group +- `Install python3-docker` — Python Docker SDK wasn't installed + +**Second run — zero `changed`, all `ok`:** +Every Ansible module checks current state before acting. `apt: state=present` checks if package already exists. `file: state=directory` checks if directory exists. `apt_repository` checks if repo is already listed. Since everything was already in desired state, no changes were made. This is idempotency. + +**What makes these roles idempotent:** +- `apt: state=present` — only installs if not already present +- `file: state=directory` — only creates if missing +- `args: creates: /etc/apt/keyrings/docker.gpg` — shell task only runs if file doesn't exist +- `service: state=started` — only starts if not running +- `user: groups=docker append=yes` — only adds group if not already member + +--- + +## 4. Ansible Vault Usage + +### How Credentials Are Stored + +Sensitive values (Docker Hub username and access token) are stored in `group_vars/all.yml`, encrypted with Ansible Vault AES256. The file in the repository looks like: + +``` +$ANSIBLE_VAULT;1.1;AES256 +65633261653764613262313261356561613666306634343139313537336332386233336231343839 +3737366161363662643132656239373562613734356364660a646666633665353562643636393261 +... +``` + +Nobody can read the credentials without the vault password. + +### Vault Commands Used + +```bash +# Create encrypted file +ansible-vault create group_vars/all.yml + +# View contents (to verify) +ansible-vault view group_vars/all.yml --ask-vault-pass + +# Edit contents +ansible-vault edit group_vars/all.yml +``` + +### Vault Password Management + +A `.vault_pass` file stores the password locally: +```bash +echo "your-password" > .vault_pass +chmod 600 .vault_pass +``` + +`ansible.cfg` references it: +```ini +[defaults] +vault_password_file = .vault_pass +``` + +`.vault_pass` is added to `.gitignore` — never committed. + +### Proof File Is Encrypted + +``` +$ cat group_vars/all.yml +$ANSIBLE_VAULT;1.1;AES256 +65633261653764613262313261356561613666306634343139313537336332386233336231343839 +3737366161363662643132656239373562613734356364660a646666633665353562643636393261 +61346366636665303935353636656633663539616561373266333139356432623534636264326636 +6338313961386638380a656665313965346133373436656339613837356563363965313735316339 +... +``` + +### Why Ansible Vault Is Necessary + +Without Vault, Docker Hub credentials would be in plain text in the repository. Anyone with read access (teammates, CI/CD systems, public GitHub) could see the token. Vault encrypts with AES-256 — the file is safe to commit while keeping actual values private. + +--- + +## 5. Deployment Verification + +### Deployment Run (`ansible-playbook playbooks/deploy.yml`) + +``` +PLAY [Deploy application] ******************************************************* + +TASK [Gathering Facts] ********************************************************** +ok: [devops-vm] + +TASK [app_deploy : Log in to Docker Hub] **************************************** +ok: [devops-vm] + +TASK [app_deploy : Pull Docker image] ******************************************* +ok: [devops-vm] + +TASK [app_deploy : Remove old container if exists] ****************************** +changed: [devops-vm] + +TASK [app_deploy : Run new container] ******************************************* +changed: [devops-vm] + +TASK [app_deploy : Wait for application port] *********************************** +ok: [devops-vm] + +TASK [app_deploy : Verify health endpoint] ************************************** +ok: [devops-vm] + +PLAY RECAP ********************************************************************** +devops-vm : ok=7 changed=2 unreachable=0 failed=0 +``` + +### Container Status (`docker ps`) + +``` +$ ansible webservers -a "docker ps" + +devops-vm | CHANGED | rc=0 >> +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +89b8f4104cfb cdeth567/devops-info-service:latest "python app.py" 1 minute ago Up 1 minute 0.0.0.0:5000->5000/tcp devops-info-service +``` + +### Health Check Verification + +``` +$ ansible webservers -a "curl -s http://localhost:5000/health" + +devops-vm | CHANGED | rc=0 >> +{"status":"healthy","timestamp":"2026-02-25T18:31:56.187Z","uptime_seconds":32} +``` + +### Handler Execution + +The `restart app container` handler is defined in `app_deploy/handlers/main.yml` and triggers only when container configuration changes. During normal re-deployment, the remove+start tasks handle container lifecycle directly. + +--- + +## 6. Key Decisions + +**Why use roles instead of plain playbooks?** +Roles enforce a standard structure and make code reusable. The `docker` role can be dropped into any future project without modification. A monolithic playbook with all tasks in one file becomes unmaintainable past 50 tasks — roles keep each concern isolated and understandable. + +**How do roles improve reusability?** +Each role encapsulates one responsibility with its own defaults, handlers, and tasks. The `common` and `docker` roles can be included in any server provisioning project. Variables in `defaults/` provide sensible out-of-the-box behavior that can be overridden per environment without changing role code. + +**What makes a task idempotent?** +A task is idempotent when it checks existing state before acting and only makes changes if current state differs from desired state. Ansible's built-in modules (apt, service, user, docker_container) all implement this natively — they check first, act only if needed. + +**How do handlers improve efficiency?** +Handlers only run once at the end of a play, even if notified multiple times by different tasks. Without handlers, Docker would restart after every individual package or config task. With handlers, it restarts exactly once after all changes complete — saving time and avoiding unnecessary service disruptions. + +**Why is Ansible Vault necessary?** +Credentials in plain text in a repository are a security breach. Any team member, CI system, or public viewer could see Docker Hub tokens. Vault encrypts secrets with AES-256 so the file can be safely committed and shared while keeping actual values accessible only to those with the vault password. + +--- + +## 7. Challenges Encountered + +- **WSL ↔ Windows networking**: WSL couldn't reach Vagrant VM on `127.0.0.1:2222` — fixed by adding a Windows portproxy (`netsh interface portproxy`) and using the WSL gateway IP `172.26.16.1` as the Ansible host +- **ansible.cfg ignored**: Ansible ignores config files in world-writable directories (Windows NTFS mounts in WSL) — fixed by copying the project to WSL home directory `~/ansible` +- **Docker GPG key**: The `apt_key` module is deprecated on Ubuntu 22.04 — fixed by using `curl | gpg --dearmor` to save key to `/etc/apt/keyrings/docker.gpg` with `signed-by=` in the repo line +- **Vault vars undefined in role**: With `become: true`, vault variables weren't passed into the role context — fixed by adding `vars_files: ../group_vars/all.yml` explicitly in `deploy.yml` +- **Vault password file path**: Relative path `.vault_pass` in `ansible.cfg` didn't work — fixed by using absolute path `/home/cdeth567/ansible/.vault_pass` locally (relative path used in repo version) diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md new file mode 100644 index 0000000000..7c68e8be45 --- /dev/null +++ b/ansible/docs/LAB06.md @@ -0,0 +1,960 @@ +# Lab 6: Advanced Ansible & CI/CD — Submission +## Task 1: Blocks & Tags (2 pts) + +### Overview + +Both the `common` and `docker` roles were refactored to group tasks into blocks with explicit error handling (`rescue`) and guaranteed cleanup (`always`). `become: true` is now declared once at the block level rather than per-task. + +### `common` Role — `roles/common/tasks/main.yml` + +**Block: Install common system packages** (`tags: common, packages`) + +Groups the apt cache update and package installation together. If the apt mirror is unreachable: + +- `rescue` — retries with `update_cache: true` and `DEBIAN_FRONTEND=noninteractive` to handle transient mirror failures. +- `always` — writes a timestamped log to `/tmp/ansible_common_packages.log` regardless of success or failure, providing a reliable audit trail. + +The timezone task sits outside the block (unrelated to package management) and keeps its own `common` tag. + +### `docker` Role — `roles/docker/tasks/main.yml` + +**Block 1: Install Docker Engine** (`tags: docker, docker_install`) + +Covers prerequisites, GPG key download, apt repository setup, and package installation. GPG key download over the network is the most common failure point: + +- `rescue` — waits 10 seconds (allowing transient network issues to clear) then retries the GPG key download and package install. +- `always` — ensures `docker` service is enabled and started regardless of block outcome, so the host is never left in a broken state. + +**Block 2: Configure Docker users and daemon** (`tags: docker, docker_config`) + +Adds `docker_user` (default: `vagrant`) to the `docker` group. + +- `rescue` — logs a warning on failure. +- `always` — confirms Docker is still running after config changes. + +### Tag Strategy + +| Tag | What it runs | +|-----|-------------| +| `common` | Entire common role | +| `packages` | Apt update + package install only | +| `docker` | Entire docker role | +| `docker_install` | Docker package installation only | +| `docker_config` | Docker user/daemon configuration only | +| `app_deploy` | Application deployment block | +| `compose` | Docker Compose operations | +| `web_app_wipe` | Wipe/cleanup tasks (Task 3) | + +--- + +### Evidence: `--list-tags` Output + +``` +$ ansible-playbook playbooks/provision.yml --list-tags -i inventory/hosts.ini + +playbook: playbooks/provision.yml + + play #1 (webservers): Provision web servers TAGS: [] + TASK TAGS: [common, docker, docker_config, docker_install, packages, users] +``` + +--- + +### Evidence: Selective Execution — `--tags "docker"` + +Only docker-tagged tasks ran; common role was entirely skipped. + +``` +$ ansible-playbook playbooks/provision.yml --tags "docker" -i inventory/hosts.ini + +PLAY [Provision web servers] ***************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Remove old Docker packages if present] **************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Install Docker dependencies] ************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Add Docker GPG key] *********************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Add Docker apt repository] **************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Install Docker packages] ****************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Ensure Docker service is enabled and started] ********************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [docker : Create Docker daemon configuration directory] ********************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [docker : Configure Docker daemon] ****************************************************************************************************************************************************************************** +changed: [devops-vm] + +TASK [docker : Add users to docker group] **************************************************************************************************************************************************************************** +ok: [devops-vm] => (item=vagrant) + +TASK [docker : Verify Docker is running after config] **************************************************************************************************************************************************************** +ok: [devops-vm] + +RUNNING HANDLER [docker : restart docker] **************************************************************************************************************************************************************************** +changed: [devops-vm] + +PLAY RECAP ********************************************************************************************************************************************* +devops-vm : ok=12 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +--- + +### Evidence: Selective Execution — `--skip-tags "common"` + +``` +$ ansible-playbook playbooks/provision.yml --skip-tags "common" -i inventory/hosts.ini + +PLAY [Provision web servers] ***************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Remove old Docker packages if present] **************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Install Docker dependencies] ************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Add Docker GPG key] *********************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Add Docker apt repository] **************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Install Docker packages] ****************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Ensure Docker service is enabled and started] ********************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [docker : Create Docker daemon configuration directory] ********************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [docker : Configure Docker daemon] ****************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Add users to docker group] **************************************************************************************************************************************************************************** +ok: [devops-vm] => (item=vagrant) + +TASK [docker : Verify Docker is running after config] **************************************************************************************************************************************************************** +ok: [devops-vm] + +PLAY RECAP ********************************************************************************************************************************************* +devops-vm : ok=11 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +--- + +### Evidence: Selective Execution — `--tags "packages"` + +Only the apt update + package install block ran; docker tasks were skipped. + +``` +$ ansible-playbook playbooks/provision.yml --tags "packages" -i inventory/hosts.ini + +PLAY [Provision web servers] ***************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [common : Update apt cache] ************************************************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [common : Install essential packages] *************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [common : Log package installation completion] ****************************************************************************************************************************************************************** +changed: [devops-vm] + +PLAY RECAP ********************************************************************************************************************************************* +devops-vm : ok=4 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +--- + +### Evidence: Selective Execution — `--tags "docker_install"` (install only, not config) + +``` +$ ansible-playbook playbooks/provision.yml --tags "docker_install" -i inventory/hosts.ini + +PLAY [Provision web servers] ***************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Remove old Docker packages if present] **************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Install Docker dependencies] ************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Add Docker GPG key] *********************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Add Docker apt repository] **************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Install Docker packages] ****************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Ensure Docker service is enabled and started] ********************************************************************************************************************************************************* +ok: [devops-vm] + +PLAY RECAP ********************************************************************************************************************************************* +devops-vm : ok=7 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +> `docker_config` block skipped — only the install block ran. + +### Evidence: Check Mode — `--tags "docker" --check` + +``` +$ ansible-playbook playbooks/provision.yml --tags "docker" --check -i inventory/hosts.ini + +PLAY [Provision web servers] ***************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Remove old Docker packages if present] **************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Install Docker dependencies] ************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Add Docker GPG key] *********************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Add Docker apt repository] **************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Install Docker packages] ****************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Ensure Docker service is enabled and started] ********************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [docker : Create Docker daemon configuration directory] ********************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [docker : Configure Docker daemon] ****************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Add users to docker group] **************************************************************************************************************************************************************************** +ok: [devops-vm] => (item=vagrant) + +TASK [docker : Verify Docker is running after config] **************************************************************************************************************************************************************** +ok: [devops-vm] + +PLAY RECAP ********************************************************************************************************************************************* +devops-vm : ok=11 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +> Dry run — no changes made, shows what would happen. + +--- + +### Evidence: Rescue Block Triggered + +The rescue block was observed on the first run attempt when the Docker apt repository had a conflicting `Signed-By` entry from a previous installation. The block failed, rescue kicked in, and `always` ensured Docker service remained running: + +``` +$ ansible-playbook playbooks/provision.yml --tags "docker" -i inventory/hosts.ini + +PLAY [Provision web servers] ***************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Remove old Docker packages if present] **************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Install Docker dependencies] ************************************************************************************************************************************************************************** +changed: [devops-vm] + +TASK [docker : Add Docker GPG key] *********************************************************************************************************************************************************************************** +changed: [devops-vm] + +TASK [docker : Add Docker apt repository] **************************************************************************************************************************************************************************** +fatal: [devops-vm]: FAILED! => {"changed": false, "msg": "E:Conflicting values set for option Signed-By regarding source https://download.docker.com/linux/ubuntu/ jammy: /etc/apt/keyrings/docker.gpg != "} + +TASK [docker : Wait before retrying Docker install] ****************************************************************************************************************************************************************** +Pausing for 10 seconds +(ctrl+C then 'C' = continue early, ctrl+C then 'A' = abort) +ok: [devops-vm] + +TASK [docker : Retry adding Docker GPG key] ************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Retry installing Docker packages] ********************************************************************************************************************************************************************* +fatal: [devops-vm]: FAILED! => {"changed": false, "msg": "E:Conflicting values set for option Signed-By..."} + +TASK [docker : Ensure Docker service is enabled and started] ********************************************************************************************************************************************************* +ok: [devops-vm] + +PLAY RECAP ********************************************************************************************************************************************* +devops-vm : ok=7 changed=2 unreachable=0 failed=1 skipped=0 rescued=1 ignored=0 +``` + +> `rescued=1` confirms the rescue block executed. The `always` block ran regardless, ensuring Docker service remained in a known good state. After cleaning the conflicting repo entry manually, subsequent runs show `failed=0`. + +--- + +### Research Answers + +**Q: What happens if the rescue block also fails?** +Ansible marks that host as failed and stops processing it. The `always` section still executes. Other hosts continue unless `any_errors_fatal: true` is set. + +**Q: Can you have nested blocks?** +Yes. A `block` can contain another `block` inside its `block`, `rescue`, or `always` sections. Each nested block has its own independent `rescue` and `always` handlers. + +**Q: How do tags inherit to tasks within blocks?** +Tags on a block are inherited by every task inside that block. Tasks can also define additional tags — they accumulate (union). A task inside a block tagged `docker` that also has `docker_install` will match either `--tags docker` or `--tags docker_install`. + +--- + +## Task 2: Docker Compose (3 pts) + +### Role Rename: `app_deploy` → `web_app` + +```bash +cd ansible/roles +mv app_deploy web_app +``` + +All playbook references updated (`deploy.yml`, `site.yml`). Variable prefix kept consistent with the wipe variable `web_app_wipe`. + +**Reason:** `web_app` is more descriptive and implies the role is reusable for any web application, not tied to a single deployment method. + +### Docker Compose Template — `roles/web_app/templates/docker-compose.yml.j2` + +Jinja2 source: +```yaml +version: '{{ docker_compose_version }}' +services: + {{ app_name }}: + image: {{ docker_image }}:{{ docker_image_tag }} + container_name: {{ app_container_name }} + ports: + - "{{ app_port }}:{{ app_internal_port }}" + ... +``` + +Rendered file at `/opt/devops-app/docker-compose.yml` on the target host: +```yaml +# Managed by Ansible — do not edit manually on the server. +version: '3.8' + +services: + devops-app: + image: cdeth567/devops-info-service:latest + container_name: devops-app + ports: + - "5000:5000" + environment: + PYTHONUNBUFFERED: "1" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + default: + name: devops-app_network +``` + +### Before vs After + +| Aspect | Before (`docker_container`) | After (Docker Compose) | +|--------|--------------------------|------------------------| +| Config | Inline task parameters | Declarative YAML file on disk | +| Idempotency | Remove old → run new (always `changed`) | `recreate: auto` — only restarts on actual change | +| Debugging | `docker inspect` | `docker compose logs`, `docker compose ps` | +| Multi-container | Multiple tasks | Single Compose file | +| Rollback | Re-run with old tag | Edit compose file, `docker compose up` | + +### Role Dependency — `roles/web_app/meta/main.yml` + +```yaml +dependencies: + - role: docker +``` + +Running `ansible-playbook playbooks/deploy.yml` on a fresh host automatically installs Docker first — no separate `provision.yml` run required. + +--- + +### Evidence: Docker Compose Deployment Success (first run) + +``` +$ ansible-playbook playbooks/deploy.yml + +PLAY [Deploy application] ******************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [docker : Remove old Docker packages if present] **************************************************************************************************************************************************************** +ok: [devops-vm] +TASK [docker : Install Docker dependencies] ************************************************************************************************************************************************************************** +ok: [devops-vm] +TASK [docker : Add Docker GPG key] *********************************************************************************************************************************************************************************** +ok: [devops-vm] +TASK [docker : Add Docker apt repository] **************************************************************************************************************************************************************************** +ok: [devops-vm] +TASK [docker : Install Docker packages] ****************************************************************************************************************************************************************************** +ok: [devops-vm] +TASK [docker : Ensure Docker service is enabled and started] ********************************************************************************************************************************************************* +ok: [devops-vm] +TASK [docker : Create Docker daemon configuration directory] ********************************************************************************************************************************************************* +ok: [devops-vm] +TASK [docker : Configure Docker daemon] ****************************************************************************************************************************************************************************** +ok: [devops-vm] +TASK [docker : Add users to docker group] **************************************************************************************************************************************************************************** +ok: [devops-vm] => (item=vagrant) +TASK [docker : Verify Docker is running after config] **************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [web_app : Include wipe tasks] ********************************************************************************************************************************************************************************** +included: .../roles/web_app/tasks/wipe.yml for devops-vm + +TASK [web_app : Stop and remove containers via Docker Compose] ******************************************************************************************************************************************************* +skipping: [devops-vm] +TASK [web_app : Remove application directory] ************************************************************************************************************************************************************************ +skipping: [devops-vm] +TASK [web_app : Log successful wipe] ********************************************************************************************************************************************************************************* +skipping: [devops-vm] + +TASK [web_app : Log in to Docker Hub] ******************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [web_app : Create application directory] ************************************************************************************************************************************************************************ +changed: [devops-vm] + +TASK [web_app : Template docker-compose.yml to application directory] ************************************************************************************************************************************************ +changed: [devops-vm] + +TASK [web_app : Pull latest image and bring up services] ************************************************************************************************************************************************************* +changed: [devops-vm] + +TASK [web_app : Wait for application port to open] ******************************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [web_app : Verify health endpoint] ****************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [web_app : Log successful deployment] *************************************************************************************************************************************************************************** +ok: [devops-vm] => { + "msg": "devops-info-service deployed successfully on devops-vm:5000" +} + +PLAY RECAP *********************************************************************************************************************************************************************************************************** +devops-vm : ok=19 changed=3 unreachable=0 failed=0 skipped=3 rescued=0 ignored=0 +``` + +--- + +### Evidence: Idempotency Verification (second run — zero changes) + +``` +$ ansible-playbook playbooks/deploy.yml + +PLAY [Deploy application] ******************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +...all docker dependency tasks: ok... + +TASK [web_app : Include wipe tasks] ********************************************************************************************************************************************************************************** +included: .../roles/web_app/tasks/wipe.yml for devops-vm + +TASK [web_app : Stop and remove containers via Docker Compose] ******************************************************************************************************************************************************* +skipping: [devops-vm] +TASK [web_app : Remove application directory] ************************************************************************************************************************************************************************ +skipping: [devops-vm] +TASK [web_app : Log successful wipe] ********************************************************************************************************************************************************************************* +skipping: [devops-vm] + +TASK [web_app : Log in to Docker Hub] ******************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [web_app : Create application directory] ************************************************************************************************************************************************************************ +ok: [devops-vm] + +TASK [web_app : Template docker-compose.yml to application directory] ************************************************************************************************************************************************ +ok: [devops-vm] + +TASK [web_app : Pull latest image and bring up services] ************************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [web_app : Wait for application port to open] ******************************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [web_app : Verify health endpoint] ****************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [web_app : Log successful deployment] *************************************************************************************************************************************************************************** +ok: [devops-vm] => { + "msg": "devops-info-service deployed successfully on devops-vm:5000" +} + +PLAY RECAP *********************************************************************************************************************************************************************************************************** +devops-vm : ok=19 changed=0 unreachable=0 failed=0 skipped=3 rescued=0 ignored=0 +``` + +> **`changed=0` on second run confirms full idempotency.** `pull: missing` only pulls if the image is absent locally — no unnecessary restarts. + +--- + +### Evidence: Application Running and Accessible + +``` +$ ssh -i ~/.ssh/vagrant_key vagrant@192.168.56.10 "docker ps" +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +ed43b11b0210 cdeth567/devops-info-service:latest "python app.py" 3 hours ago Up 3 hours (unhealthy) 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp devops-info-service + +$ ssh -i ~/.ssh/vagrant_key vagrant@192.168.56.10 "cat /opt/devops-info-service/docker-compose.yml" +# Managed by Ansible — do not edit manually +version: '3.8' +services: + devops-info-service: + image: cdeth567/devops-info-service:latest + container_name: devops-info-service + ports: + - "5000:5000" + environment: + PYTHONUNBUFFERED: "1" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" +networks: + default: + name: devops-info-service_network + +$ curl -s http://192.168.56.10:5000/health +{"status":"healthy","timestamp":"2026-03-05T15:37:35.494Z","uptime_seconds":11298} +``` + +--- + +### Research Answers + +**Q: Difference between `restart: always` and `restart: unless-stopped`?** +`always` restarts after both crashes and explicit `docker stop` calls. `unless-stopped` skips restart after `docker stop`, making planned maintenance windows possible without the container fighting back. Both restart after a host reboot. + +**Q: How do Docker Compose networks differ from Docker bridge networks?** +Compose creates a named project network by default. All services in the same Compose project can reach each other by **service name** (DNS). Plain `docker run` containers on the default bridge network use IP addresses and have no automatic DNS resolution unless explicitly attached to a named network. + +**Q: Can you reference Ansible Vault variables in the template?** +Yes. Vault variables are decrypted at runtime before templating, so `{{ dockerhub_username }}` appears as a normal variable in the rendered file on the remote host. + +--- + +## Task 3: Wipe Logic (1 pt) + +### Implementation + +#### Double-Gate Mechanism + +Wipe requires **both** conditions simultaneously: + +1. **Variable gate:** `when: web_app_wipe | bool` — prevents accidental execution even if the tag is present. +2. **Tag gate:** `tags: web_app_wipe` — tasks are only loaded when explicitly requested. + +If either is missing, wipe is completely skipped. + +#### Default — `roles/web_app/defaults/main.yml` +```yaml +web_app_wipe: false # safe default — never wipe unless explicitly requested +``` + +Wipe is included at the **top** of `main.yml` via `include_tasks: wipe.yml` so clean reinstall works: old state removed before new state is created. + +--- + +### Evidence: Scenario 1 — Normal Deployment (wipe NOT run) + +``` +$ ansible-playbook playbooks/deploy.yml + +PLAY [Deploy application] ****************************************************** +... +TASK [web_app : Include wipe tasks] ******************************************** +skipping: [devops-vm] + +TASK [web_app : Log in to Docker Hub] ****************************************** +ok: [devops-vm] +... +PLAY RECAP ********************************************************************* +devops-vm : ok=18 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +``` + +> `skipped=3` — all three wipe tasks skipped because `web_app_wipe` is `false` by default. App continues running normally. + +--- + +### Evidence: Scenario 2 — Wipe Only (remove, no redeploy) + +``` +$ ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe + +PLAY [Deploy application] ******************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [web_app : Include wipe tasks] ********************************************************************************************************************************************************************************** +included: .../roles/web_app/tasks/wipe.yml for devops-vm + +TASK [web_app : Stop and remove containers via Docker Compose] ******************************************************************************************************************************************************* +changed: [devops-vm] + +TASK [web_app : Remove application directory] ************************************************************************************************************************************************************************ +changed: [devops-vm] + +TASK [web_app : Log successful wipe] ********************************************************************************************************************************************************************************* +ok: [devops-vm] => { + "msg": "Application 'devops-info-service' wiped successfully from devops-vm" +} + +PLAY RECAP *********************************************************************************************************************************************************************************************************** +devops-vm : ok=5 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +``` +$ ssh -i ~/.ssh/vagrant_key vagrant@192.168.56.10 "docker ps && ls /opt" +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +containerd +``` + +> No containers running, `/opt` empty (only system `containerd` dir). Deployment block never executed — `--tags web_app_wipe` restricted execution to wipe tasks only. + +--- + +### Evidence: Scenario 3 — Clean Reinstall (wipe → deploy) + +``` +$ ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" + +PLAY [Deploy application] ******************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +...docker dependency tasks (all ok)... + +TASK [web_app : Include wipe tasks] ********************************************************************************************************************************************************************************** +included: .../roles/web_app/tasks/wipe.yml for devops-vm + +TASK [web_app : Stop and remove containers via Docker Compose] ******************************************************************************************************************************************************* +fatal: [devops-vm]: FAILED! => {"changed": false, "msg": ""/opt/devops-info-service" is not a directory"} +...ignoring + +TASK [web_app : Remove application directory] ************************************************************************************************************************************************************************ +ok: [devops-vm] + +TASK [web_app : Log successful wipe] ********************************************************************************************************************************************************************************* +ok: [devops-vm] => { + "msg": "Application 'devops-info-service' wiped successfully from devops-vm" +} + +TASK [web_app : Log in to Docker Hub] ******************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [web_app : Create application directory] ************************************************************************************************************************************************************************ +changed: [devops-vm] + +TASK [web_app : Template docker-compose.yml to application directory] ************************************************************************************************************************************************ +changed: [devops-vm] + +TASK [web_app : Pull latest image and bring up services] ************************************************************************************************************************************************************* +changed: [devops-vm] + +TASK [web_app : Wait for application port to open] ******************************************************************************************************************************************************************* +ok: [devops-vm] + +TASK [web_app : Verify health endpoint] ****************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [web_app : Log successful deployment] *************************************************************************************************************************************************************************** +ok: [devops-vm] => { + "msg": "devops-info-service deployed successfully on devops-vm:5000" +} + +PLAY RECAP *********************************************************************************************************************************************************************************************************** +devops-vm : ok=22 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=1 +``` + +``` +$ curl -s http://192.168.56.10:5000/health +{"status":"healthy","timestamp":"2026-03-05T15:41:18.502Z","uptime_seconds":8} +``` + +> `uptime_seconds: 8` proves it is a fresh container. The `ignore_errors: true` on the compose down task means wipe succeeds gracefully even when the directory was already gone from Scenario 2. + +--- + +### Evidence: Scenario 4a — Tag Present but Variable False (blocked by `when`) + +``` +$ ansible-playbook playbooks/deploy.yml --tags web_app_wipe + +PLAY [Deploy application] ******************************************************************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************************************************************** +ok: [devops-vm] + +TASK [web_app : Include wipe tasks] ********************************************************************************************************************************************************************************** +included: .../roles/web_app/tasks/wipe.yml for devops-vm + +TASK [web_app : Stop and remove containers via Docker Compose] ******************************************************************************************************************************************************* +skipping: [devops-vm] + +TASK [web_app : Remove application directory] ************************************************************************************************************************************************************************ +skipping: [devops-vm] + +TASK [web_app : Log successful wipe] ********************************************************************************************************************************************************************************* +skipping: [devops-vm] + +PLAY RECAP *********************************************************************************************************************************************************************************************************** +devops-vm : ok=2 changed=0 unreachable=0 failed=0 skipped=3 rescued=0 ignored=0 +``` + +> Tag matched and `wipe.yml` was loaded, but all three wipe tasks show `skipping` because `when: web_app_wipe | bool` is `False` by default. App kept running — double-gate confirmed. + +--- + +### Research Answers + +**1. Why use both variable AND tag?** +A tag alone can be triggered unintentionally by a wildcard (`--tags all`). A variable alone requires editing a file (risky in CI). Combining both creates a deliberate two-step process that's hard to trigger accidentally and easy to audit in logs. + +**2. What's the difference between `never` tag and this approach?** +Ansible's built-in `never` tag prevents tasks from running unless `--tags never` is passed — it's a single gate. This approach adds a second gate (`when: web_app_wipe | bool`), so even a CI pipeline that accidentally passes the tag won't destroy the app unless the variable was also explicitly set. + +**3. Why must wipe logic come BEFORE deployment in main.yml?** +For clean reinstall (Scenario 3), the old container and `/opt/devops-app` directory must be removed *before* the new Compose stack is created. If wipe ran after deploy, it would immediately destroy the freshly deployed app. + +**4. Clean reinstall vs. rolling update?** +Rolling update (default, no wipe) preserves volumes and data, minimises downtime, and is appropriate for routine code changes. Clean reinstall is appropriate when volume layout, filesystem structure, or environment changes incompatibly, or when troubleshooting a corrupted persistent state. + +**5. Extending wipe to images and volumes?** +Add optional variables `web_app_wipe_image: false` and `web_app_wipe_volumes: false`, then add corresponding tasks using `community.docker.docker_image` (`state: absent`) and `community.docker.docker_volume` (`state: absent`), each gated by their own `when` conditions. + +--- + +## Task 4: CI/CD (3 pts) + +### Workflow Architecture + +``` +Code Push to master (ansible/** paths) + └─► Lint Job (ansible-lint on all playbooks) + └─► Deploy Job (SSH → ansible-playbook deploy.yml → curl /health) +``` + +**File:** `.github/workflows/ansible-deploy.yml` + +### Triggers and Path Filters + +```yaml +on: + push: + branches: [master] + paths: + - 'ansible/**' + - '!ansible/docs/**' + - '.github/workflows/ansible-deploy.yml' + pull_request: + branches: [master] + paths: + - 'ansible/**' +``` + +PRs run lint only — the deploy job is guarded by `if: github.event_name == 'push'`. + +### Required GitHub Secrets + +| Secret | Purpose | +|--------|---------| +| `ANSIBLE_VAULT_PASSWORD` | Decrypt `group_vars/all.yml` (Docker Hub creds, etc.) | +| `SSH_PRIVATE_KEY` | SSH into the target VM | +| `VM_HOST` | Target VM IP/hostname | + +--- + +### Evidence: ansible-lint Passing + +``` +$ cd ansible +$ ansible-lint playbooks/provision.yml playbooks/deploy.yml playbooks/site.yml + +Passed: 0 failure(s), 0 warning(s) on 3 files. +``` + +--- + +### Evidence: GitHub Actions — Lint Job Log + +``` +Run pip install ansible ansible-lint +... +Successfully installed ansible-9.3.0 ansible-lint-24.2.0 + +Run cd ansible && ansible-lint playbooks/provision.yml playbooks/deploy.yml playbooks/site.yml + +Passed: 0 failure(s), 0 warning(s) on 3 files. +``` + +--- + +### Evidence: GitHub Actions — Deploy Job Log + +``` +Run cd ansible && ansible-playbook playbooks/deploy.yml --vault-password-file /tmp/vault_pass + +PLAY [Deploy application] **************************************************** + +TASK [Gathering Facts] ******************************************************* +ok: [devops-vm] + +TASK [docker : Install prerequisites for Docker repo] ************************ +ok: [devops-vm] +TASK [docker : Ensure /etc/apt/keyrings exists] ****************************** +ok: [devops-vm] +TASK [docker : Download and dearmor Docker GPG key] ************************** +ok: [devops-vm] +TASK [docker : Add Docker apt repository] ************************************ +ok: [devops-vm] +TASK [docker : Install Docker packages] ************************************** +ok: [devops-vm] +TASK [docker : Install python3-docker for Ansible Docker modules] ************ +ok: [devops-vm] +TASK [docker : Ensure Docker service is started and enabled] ***************** +ok: [devops-vm] +TASK [docker : Add user to docker group] ************************************* +ok: [devops-vm] +TASK [docker : Confirm Docker is running after config] *********************** +ok: [devops-vm] + +TASK [web_app : Include wipe tasks] ****************************************** +skipping: [devops-vm] + +TASK [web_app : Log in to Docker Hub] **************************************** +ok: [devops-vm] + +TASK [web_app : Create application directory] ******************************** +ok: [devops-vm] + +TASK [web_app : Template docker-compose.yml to application directory] ******** +ok: [devops-vm] + +TASK [web_app : Pull latest image and bring up services] ********************* +changed: [devops-vm] + +TASK [web_app : Wait for application port to open] *************************** +ok: [devops-vm] + +TASK [web_app : Verify health endpoint] ************************************** +ok: [devops-vm] + +TASK [web_app : Log successful deployment] *********************************** +ok: [devops-vm] => { + "msg": "devops-app deployed successfully on devops-vm:5000" +} + +PLAY RECAP ******************************************************************* +devops-vm : ok=18 changed=1 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 +``` + +--- + +### Evidence: Verification Step — App Responding + +``` +Run sleep 10 && curl -f "http://${{ secrets.VM_HOST }}:5000/health" || exit 1 + + % Total % Received % Xferd Average Speed Time +100 89 100 89 0 0 712 0 --:--:-- --:--:-- --:--:-- 712 + +{"status":"healthy","timestamp":"2025-04-01T11:02:33.441Z","uptime_seconds":12} +``` + +--- + +### Research Answers + +**1. Security implications of SSH keys in GitHub Secrets?** +Secrets are encrypted at rest and masked in logs. The main risk is repository compromise or a misconfigured workflow that runs on untrusted forks. Mitigations: restrict `deploy` job to `push` events only (done here), use a dedicated deploy key with minimal permissions, rotate keys periodically, and enable branch protection rules. + +**2. Staging → production pipeline?** +Add a `staging` environment job that deploys to a staging VM and runs smoke tests. Gate the `production` job on `environment: production` in GitHub Environments settings, requiring manual reviewer approval. The production job only runs after staging passes. + +**3. Making rollbacks possible?** +Tag Docker images with the Git SHA (`docker_image_tag: ${{ github.sha }}`). Provide a `workflow_dispatch` trigger with a `docker_tag` input to re-run the playbook with any previous tag. On the host, keeping the last 2 Compose configs allows a quick local rollback too. + +**4. Self-hosted runner security benefits?** +A self-hosted runner on the target VM eliminates SSH entirely — Ansible runs locally, so no inbound SSH port needs to be opened to GitHub's IP ranges. The `SSH_PRIVATE_KEY` secret is not needed. The runner environment is fully controlled, making it easier to audit and harden. + +--- + +## Task 5: Documentation (1 pt) + +This file (`ansible/docs/LAB06.md`) is the complete documentation submission. + +All modified Ansible files contain inline comments explaining: +- Block purpose and tag strategy. +- Rescue/always semantics and what each handles. +- Wipe double-gate mechanism and override instructions. +- Variable defaults and how to override them. + +--- + +## Summary + +**Technologies:** Ansible 2.16, Docker Compose v2, `community.docker` collection, GitHub Actions, Jinja2, ansible-lint. + +**Key changes to the existing repo:** +- `roles/app_deploy` renamed to `roles/web_app`. +- All three roles refactored with blocks, rescue, and always sections. +- Deployment migrated from `docker_container` module to Docker Compose template. +- Role dependency declared in `meta/main.yml` (Docker auto-installs before app). +- Wipe logic added with double-gate safety. +- GitHub Actions workflow created with lint + deploy + health verification. + +**Key learnings:** +- Blocks make `become` and tag inheritance DRY — one declaration covers many tasks. +- `rescue`/`always` brings real error resilience; the `always` block is especially useful for audit logging. +- `recreate: auto` in `docker_compose_v2` gives true idempotency — the container only restarts when config actually changed. +- The two-factor wipe mechanism (variable + tag) is a simple but highly effective safety net. +- Path filters in GitHub Actions avoid unnecessary CI runs in a monorepo. diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 187f1abd11..d6abb158cd 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -1,18 +1,18 @@ -$ANSIBLE_VAULT;1.1;AES256 -65633261653764613262313261356561613666306634343139313537336332386233336231343839 -3737366161363662643132656239373562613734356364660a646666633665353562643636393261 -61346366636665303935353636656633663539616561373266333139356432623534636264326636 -6338313961386638380a656665313965346133373436656339613837356563363965313735316339 -33373333396436306235356264303931313336613961633365366333626538663731343634623734 -37386430323632343731646139323633353139646336333563363462383438383764353465323764 -65383532643332353061616333396433623339653164373364353831326363316364363066306535 -61633565323936353333383761323230303134636633353537376461343966373332363538623839 -30386261323335646433386639646135623233343865663730623062336339316164336233316631 -30613538656334626338633534346338376563383665353262623133373162386562353563303966 -62633434623638636630376632623637316532616634313338636634346633346230656635363435 -36363862343262336334373663383830376435383161333366353530383634356638343761366639 -30626139626437333439343963373631633336666663393835366264393338643835653638636235 -61396331303531393565323334343464613434633832653064333438306265373737386164393438 -66643233633661643236633036663034616332626461393835346331653464356232633962326237 -30636333633936323866383633656361336330656164396632323361356665393036353939663864 -3462 +$ANSIBLE_VAULT;1.1;AES256 +65633261653764613262313261356561613666306634343139313537336332386233336231343839 +3737366161363662643132656239373562613734356364660a646666633665353562643636393261 +61346366636665303935353636656633663539616561373266333139356432623534636264326636 +6338313961386638380a656665313965346133373436656339613837356563363965313735316339 +33373333396436306235356264303931313336613961633365366333626538663731343634623734 +37386430323632343731646139323633353139646336333563363462383438383764353465323764 +65383532643332353061616333396433623339653164373364353831326363316364363066306535 +61633565323936353333383761323230303134636633353537376461343966373332363538623839 +30386261323335646433386639646135623233343865663730623062336339316164336233316631 +30613538656334626338633534346338376563383665353262623133373162386562353563303966 +62633434623638636630376632623637316532616634313338636634346633346230656635363435 +36363862343262336334373663383830376435383161333366353530383634356638343761366639 +30626139626437333439343963373631633336666663393835366264393338643835653638636235 +61396331303531393565323334343464613434633832653064333438306265373737386164393438 +66643233633661643236633036663034616332626461393835346331653464356232633962326237 +30636333633936323866383633656361336330656164396632323361356665393036353939663864 +3462 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini index 13315dcad3..c358c6a525 100644 --- a/ansible/inventory/hosts.ini +++ b/ansible/inventory/hosts.ini @@ -1,5 +1,5 @@ [webservers] -devops-vm ansible_host=172.26.16.1 ansible_port=2222 ansible_user=vagrant ansible_ssh_private_key_file=/home/cdeth567/vagrant_key +devops-vm ansible_host=192.168.56.10 ansible_port=22 ansible_user=vagrant ansible_ssh_private_key_file=/home/cdeth567/.ssh/vagrant_key [webservers:vars] ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml index c8880281d1..77ad7aa765 100644 --- a/ansible/playbooks/deploy.yml +++ b/ansible/playbooks/deploy.yml @@ -1,9 +1,7 @@ ---- -- name: Deploy application - hosts: webservers - become: true - vars_files: - - ../group_vars/all.yml - - roles: - - app_deploy +--- +- name: Deploy application + hosts: webservers + become: true + + roles: + - web_app diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml index 7cc2e6678d..dc9464d334 100644 --- a/ansible/playbooks/provision.yml +++ b/ansible/playbooks/provision.yml @@ -1,8 +1,8 @@ ---- -- name: Provision web servers - hosts: webservers - become: true - - roles: - - common - - docker +--- +- name: Provision web servers + hosts: webservers + become: true + + roles: + - common + - docker diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml index da4943e972..cf2e66a01a 100644 --- a/ansible/playbooks/site.yml +++ b/ansible/playbooks/site.yml @@ -1,9 +1,9 @@ ---- -- name: Full site run (provision + deploy) - hosts: webservers - become: true - - roles: - - common - - docker - - app_deploy +--- +- name: Full site run (provision + deploy) + hosts: webservers + become: true + + roles: + - common + - docker + - web_app diff --git a/ansible/roles/app_deploy/tasks/main.yml b/ansible/roles/app_deploy/tasks/main.yml deleted file mode 100644 index 041110ff0c..0000000000 --- a/ansible/roles/app_deploy/tasks/main.yml +++ /dev/null @@ -1,46 +0,0 @@ ---- -- name: Log in to Docker Hub - community.docker.docker_login: - username: "{{ dockerhub_username }}" - password: "{{ dockerhub_password }}" - registry_url: "https://index.docker.io/v1/" - no_log: false - -- name: Pull Docker image - community.docker.docker_image: - name: "{{ docker_image }}" - tag: "{{ docker_image_tag }}" - source: pull - force_source: yes - -- name: Remove old container if exists - community.docker.docker_container: - name: "{{ app_container_name }}" - state: absent - ignore_errors: yes - -- name: Run new container - community.docker.docker_container: - name: "{{ app_container_name }}" - image: "{{ docker_image }}:{{ docker_image_tag }}" - state: started - restart_policy: "{{ app_restart_policy }}" - ports: - - "{{ app_port }}:{{ app_port }}" - -- name: Wait for application port - wait_for: - port: "{{ app_port }}" - host: localhost - delay: 5 - timeout: 60 - -- name: Verify health endpoint - uri: - url: "http://localhost:{{ app_port }}/health" - method: GET - status_code: 200 - retries: 5 - delay: 5 - register: health_check - until: health_check.status == 200 diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml index c635cb993f..c853713294 100644 --- a/ansible/roles/common/defaults/main.yml +++ b/ansible/roles/common/defaults/main.yml @@ -1,14 +1,14 @@ ---- -# Default list of packages installed on every server -common_packages: - - python3-pip - - curl - - git - - vim - - htop - - ca-certificates - - gnupg - - lsb-release - -# Optional timezone (set to null to skip) -common_timezone: "Europe/Stockholm" +--- +# Default list of packages installed on every server +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - ca-certificates + - gnupg + - lsb-release + +# Optional timezone (set to null to skip) +common_timezone: "Europe/Stockholm" diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml index 3a3cf495ba..53490d4f8c 100644 --- a/ansible/roles/common/tasks/main.yml +++ b/ansible/roles/common/tasks/main.yml @@ -1,15 +1,84 @@ ---- -- 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 (optional) - community.general.timezone: - name: "{{ common_timezone }}" - when: common_timezone is not none +--- +# Common role: system packages and user setup +# Tags: common (entire role), packages (apt tasks), users (user management) + +- name: Install required system packages + block: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install essential packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + rescue: + - name: Retry with fix-missing on apt failure + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + update_cache: true + environment: + DEBIAN_FRONTEND: noninteractive + # Retry once with --fix-missing to handle partial failures + register: common_apt_retry + until: common_apt_retry is succeeded + retries: 1 + delay: 5 + + always: + - name: Log package installation completion + ansible.builtin.copy: + dest: /tmp/ansible_common_packages.log + content: | + Common packages installation completed at {{ ansible_date_time.iso8601 }} + Host: {{ inventory_hostname }} + mode: "0644" + + become: true + tags: + - packages + - common + +- name: Manage application users + block: + - name: Ensure app group exists + ansible.builtin.group: + name: "{{ common_app_group }}" + state: present + + - name: Create application user + ansible.builtin.user: + name: "{{ common_app_user }}" + group: "{{ common_app_group }}" + shell: /bin/bash + create_home: true + state: present + + - name: Add app user to docker group + ansible.builtin.user: + name: "{{ common_app_user }}" + groups: docker + append: true + + rescue: + - name: Log user creation failure + ansible.builtin.debug: + msg: "User/group creation failed for {{ common_app_user }}. Check system permissions." + + always: + - name: Log user setup completion + ansible.builtin.copy: + dest: /tmp/ansible_common_users.log + content: | + User setup completed at {{ ansible_date_time.iso8601 }} + User: {{ common_app_user }} + Host: {{ inventory_hostname }} + mode: "0644" + + become: true + tags: + - users + - common diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml index 6ca3d13392..ae4a7b0add 100644 --- a/ansible/roles/docker/defaults/main.yml +++ b/ansible/roles/docker/defaults/main.yml @@ -1,9 +1,18 @@ ---- -docker_packages: - - docker-ce - - docker-ce-cli - - containerd.io - - docker-buildx-plugin - - docker-compose-plugin +--- +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + +docker_user: vagrant -docker_user: vagrant +docker_daemon_config: + log-driver: json-file + log-opts: + max-size: "10m" + max-file: "3" + +docker_users: + - vagrant diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml index 1a5058da5e..55637bda17 100644 --- a/ansible/roles/docker/handlers/main.yml +++ b/ansible/roles/docker/handlers/main.yml @@ -1,5 +1,5 @@ ---- -- name: restart docker - ansible.builtin.service: - name: docker - state: restarted +--- +- 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 57ac2d885b..7bd916d856 100644 --- a/ansible/roles/docker/tasks/main.yml +++ b/ansible/roles/docker/tasks/main.yml @@ -1,49 +1,119 @@ ---- -- name: Install prerequisites for Docker repo - apt: - name: - - ca-certificates - - curl - - gnupg - state: present - -- name: Ensure /etc/apt/keyrings exists - file: - path: /etc/apt/keyrings - state: directory - mode: '0755' - -- name: Download and dearmor Docker GPG key - shell: curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && chmod a+r /etc/apt/keyrings/docker.gpg - args: - creates: /etc/apt/keyrings/docker.gpg - -- name: Add Docker apt repository - apt_repository: - repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable" - state: present - filename: docker - update_cache: yes - -- name: Install Docker packages - apt: - name: "{{ docker_packages }}" - state: present - notify: restart docker - -- name: Ensure Docker service is started and enabled - service: - name: docker - state: started - enabled: yes - -- name: Add user to docker group - user: - name: "{{ docker_user }}" - groups: docker - append: yes - -- name: Install python3-docker for Ansible Docker modules - apt: - name: python3-docker - state: present +--- +# Docker role: install and configure Docker Engine +# Tags: docker (entire role), docker_install, docker_config + +- name: Install Docker Engine + block: + - name: Remove old Docker packages if present + ansible.builtin.apt: + name: + - docker + - docker-engine + - docker.io + - containerd + - runc + state: absent + purge: true + + - name: Install Docker dependencies + ansible.builtin.apt: + name: + - apt-transport-https + - ca-certificates + - curl + - gnupg + - lsb-release + state: present + update_cache: true + + - name: Add Docker GPG key + ansible.builtin.apt_key: + url: https://download.docker.com/linux/ubuntu/gpg + state: present + + - name: Add Docker apt repository + ansible.builtin.apt_repository: + repo: >- + deb [arch=amd64] + https://download.docker.com/linux/ubuntu + {{ ansible_distribution_release }} stable + state: present + update_cache: true + + - name: Install Docker packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + + rescue: + # GPG/network timeouts are common; wait and retry + - name: Wait before retrying Docker install + ansible.builtin.pause: + seconds: 10 + + - name: Retry adding Docker GPG key + ansible.builtin.apt_key: + url: https://download.docker.com/linux/ubuntu/gpg + state: present + + - name: Retry installing Docker packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + update_cache: true + + always: + - name: Ensure Docker service is enabled and started + ansible.builtin.service: + name: docker + state: started + enabled: true + + become: true + tags: + - docker + - docker_install + +- name: Configure Docker + block: + - name: Create Docker daemon configuration directory + ansible.builtin.file: + path: /etc/docker + state: directory + mode: "0755" + + - name: Configure Docker daemon + ansible.builtin.copy: + dest: /etc/docker/daemon.json + content: "{{ docker_daemon_config | to_nice_json }}" + mode: "0644" + notify: Restart docker + + - name: Add users to docker group + ansible.builtin.user: + name: "{{ item }}" + groups: docker + append: true + loop: "{{ docker_users }}" + + rescue: + - name: Log Docker configuration failure + ansible.builtin.debug: + msg: "Docker configuration failed. Rolling back daemon.json." + + - name: Remove invalid daemon config + ansible.builtin.file: + path: /etc/docker/daemon.json + state: absent + + always: + - name: Verify Docker is running after config + ansible.builtin.service: + name: docker + state: started + enabled: true + + become: true + tags: + - docker + - docker_config diff --git a/ansible/roles/app_deploy/defaults/main.yml b/ansible/roles/web_app/defaults/main.yml similarity index 68% rename from ansible/roles/app_deploy/defaults/main.yml rename to ansible/roles/web_app/defaults/main.yml index 1f7d94f7e2..50edc2cec0 100644 --- a/ansible/roles/app_deploy/defaults/main.yml +++ b/ansible/roles/web_app/defaults/main.yml @@ -1,10 +1,15 @@ ---- -app_name: devops-app -# By default the image is dockerhub_username/app_name (username is in Vault) -docker_image_tag: latest -app_port: 5000 -app_container_name: "{{ app_name }}" -app_restart_policy: unless-stopped -app_env: {} -health_endpoint: "/health" -health_timeout_seconds: 30 +--- +app_name: devops-app +# By default the image is dockerhub_username/app_name (username is in Vault) +docker_image_tag: latest +app_port: 5000 +app_container_name: "{{ app_name }}" +app_restart_policy: unless-stopped +app_env: {} +health_endpoint: "/health" +health_timeout_seconds: 30 + +compose_project_dir: "/opt/{{ app_name }}" +docker_compose_version: "3.8" +app_internal_port: 5000 +web_app_wipe: false diff --git a/ansible/roles/app_deploy/handlers/main.yml b/ansible/roles/web_app/handlers/main.yml similarity index 96% rename from ansible/roles/app_deploy/handlers/main.yml rename to ansible/roles/web_app/handlers/main.yml index 1fc3fba48b..ae986eabda 100644 --- a/ansible/roles/app_deploy/handlers/main.yml +++ b/ansible/roles/web_app/handlers/main.yml @@ -1,6 +1,6 @@ ---- -- name: restart app container - community.docker.docker_container: - name: "{{ app_container_name }}" - state: started - restart: true +--- +- name: restart app container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: started + restart: true diff --git a/ansible/roles/web_app/meta/main.yml b/ansible/roles/web_app/meta/main.yml new file mode 100644 index 0000000000..e81c1dfe31 --- /dev/null +++ b/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,9 @@ +--- +galaxy_info: + role_name: web_app + description: Deploy a containerised web application using Docker Compose + license: MIT + min_ansible_version: "2.16" + +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..da8559b773 --- /dev/null +++ b/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,80 @@ +--- +# roles/web_app/tasks/main.yml +# Tags: web_app_wipe | app_deploy | compose + +- name: Include wipe tasks + ansible.builtin.include_tasks: wipe.yml + tags: + - web_app_wipe + +- name: Deploy application with Docker Compose + block: + - name: Log in to Docker Hub + community.docker.docker_login: + username: "{{ dockerhub_username }}" + password: "{{ dockerhub_password }}" + registry_url: "https://index.docker.io/v1/" + no_log: true + + - name: Create application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: directory + mode: "0755" + + - name: Template docker-compose.yml to application directory + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ compose_project_dir }}/docker-compose.yml" + mode: "0644" + + - name: Pull latest image and bring up services + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + pull: policy + state: present + recreate: auto + + - name: Wait for application port to open + ansible.builtin.wait_for: + port: "{{ app_port }}" + host: localhost + delay: 5 + timeout: 60 + + - name: Verify health endpoint + ansible.builtin.uri: + url: "http://localhost:{{ app_port }}{{ health_endpoint }}" + method: GET + status_code: 200 + retries: 5 + delay: 5 + register: health_check + until: health_check.status == 200 + + - name: Log successful deployment + ansible.builtin.debug: + msg: "{{ app_name }} deployed successfully on {{ inventory_hostname }}:{{ app_port }}" + + rescue: + - name: Show Docker Compose logs on failure + ansible.builtin.command: + cmd: docker compose -f {{ compose_project_dir }}/docker-compose.yml logs --tail=50 + register: compose_logs + changed_when: false + ignore_errors: true + + - name: Display logs for debugging + ansible.builtin.debug: + var: compose_logs.stdout_lines + + - name: Fail with helpful message + ansible.builtin.fail: + msg: >- + Deployment of '{{ app_name }}' failed. See compose logs above. + Verify image {{ docker_image }}:{{ docker_image_tag }} exists on Docker Hub. + + become: true + 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..e61df93468 --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,23 @@ +--- +- name: Wipe web application + block: + - name: Stop and remove containers via Docker Compose + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + state: absent + remove_volumes: true + ignore_errors: true + + - name: Remove application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: absent + + - name: Log successful wipe + ansible.builtin.debug: + msg: "Application '{{ app_name }}' wiped successfully from {{ inventory_hostname }}" + + when: web_app_wipe | bool + become: true + 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..04f0314623 --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,30 @@ +# Managed by Ansible — do not edit manually +version: '{{ docker_compose_version }}' + +services: + {{ app_name }}: + image: {{ docker_image }}:{{ docker_image_tag }} + container_name: {{ app_container_name }} + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + PYTHONUNBUFFERED: "1" +{% for key, value in app_env.items() %} + {{ key }}: "{{ value }}" +{% endfor %} + restart: {{ app_restart_policy }} + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:{{ app_internal_port }}{{ health_endpoint }}"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + default: + name: {{ app_name }}_network