diff --git a/.github/workflows/ansible-deploy-bonus.yml b/.github/workflows/ansible-deploy-bonus.yml new file mode 100644 index 0000000000..7615a9a31d --- /dev/null +++ b/.github/workflows/ansible-deploy-bonus.yml @@ -0,0 +1,97 @@ +name: Ansible Deployment - Bonus App + +on: + push: + branches: [main, master, lab06] + paths: + - 'ansible/vars/app_bonus.yml' + - 'ansible/playbooks/deploy_bonus.yml' + - 'ansible/roles/web_app/**' + - '.github/workflows/ansible-deploy-bonus.yml' + pull_request: + branches: [main, master] + paths: + - 'ansible/vars/app_bonus.yml' + - 'ansible/playbooks/deploy_bonus.yml' + - '.github/workflows/ansible-deploy-bonus.yml' + +jobs: + lint: + name: Ansible Lint - Bonus + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + pip install ansible ansible-lint + ansible-galaxy collection install community.docker ansible.posix community.general + + - name: Write vault password for lint + run: | + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > ansible/.vault_pass + chmod 600 ansible/.vault_pass + + - name: Run ansible-lint on bonus playbook + working-directory: ansible + run: | + ansible-lint playbooks/deploy_bonus.yml + + - name: Cleanup vault password + if: always() + run: rm -f ansible/.vault_pass + + deploy: + name: Deploy Bonus App + needs: lint + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Ansible and collections + run: | + pip install ansible + ansible-galaxy collection install community.docker ansible.posix community.general + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H ${{ secrets.VM_HOST }} >> ~/.ssh/known_hosts + + - name: Write vault password file + run: | + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > ansible/.vault_pass + chmod 600 ansible/.vault_pass + + - name: Deploy bonus application + working-directory: ansible + run: | + ansible-playbook playbooks/deploy_bonus.yml \ + -i inventory/hosts.ini + + - name: Cleanup vault password file + if: always() + run: rm -f ansible/.vault_pass + + - name: Verify bonus app deployment via SSH + run: | + ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \ + ${{ secrets.VM_USER }}@${{ secrets.VM_HOST }} \ + "docker ps && curl -sf http://localhost:8001/health && echo 'Bonus app verified!'" diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..054f68e6fc --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,101 @@ +name: Ansible Deployment + +on: + push: + branches: [main, master, lab06] + paths: + - 'ansible/**' + - '!ansible/docs/**' + - '.github/workflows/ansible-deploy.yml' + pull_request: + branches: [main, master] + paths: + - 'ansible/**' + - '.github/workflows/ansible-deploy.yml' + +jobs: + lint: + name: Ansible Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + pip install ansible ansible-lint + ansible-galaxy collection install community.docker ansible.posix community.general + + - name: Write vault password for lint + run: | + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > ansible/.vault_pass + chmod 600 ansible/.vault_pass + + - name: Run ansible-lint + working-directory: ansible + run: | + ansible-lint playbooks/provision.yml playbooks/deploy.yml + + - name: Cleanup vault password + if: always() + run: rm -f ansible/.vault_pass + + deploy: + name: Deploy Application + needs: lint + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Ansible and collections + run: | + pip install ansible + ansible-galaxy collection install community.docker ansible.posix community.general + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H ${{ secrets.VM_HOST }} >> ~/.ssh/known_hosts + + - name: Write vault password file + run: | + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > ansible/.vault_pass + chmod 600 ansible/.vault_pass + + - name: Run provision playbook + working-directory: ansible + run: | + ansible-playbook playbooks/provision.yml \ + -i inventory/hosts.ini + + - name: Deploy application + working-directory: ansible + run: | + ansible-playbook playbooks/deploy_python.yml \ + -i inventory/hosts.ini + + - name: Cleanup vault password file + if: always() + run: rm -f ansible/.vault_pass + + - name: Verify deployment via SSH + run: | + ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \ + ${{ secrets.VM_USER }}@${{ secrets.VM_HOST }} \ + "docker ps && curl -sf http://localhost:5000/health && echo 'Deployment verified!'" diff --git a/.github/workflows/terraform-ci.yml b/.github/workflows/terraform-ci.yml new file mode 100644 index 0000000000..b3fd54c6a8 --- /dev/null +++ b/.github/workflows/terraform-ci.yml @@ -0,0 +1,45 @@ +name: Terraform CI + +on: + pull_request: + paths: + - 'terraform/**' + - '.github/workflows/terraform-ci.yml' + push: + paths: + - 'terraform/**' + - '.github/workflows/terraform-ci.yml' + +jobs: + validate: + name: Validate Terraform + runs-on: ubuntu-latest + defaults: + run: + working-directory: terraform/ + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "1.9.0" + + - name: Terraform Format Check + run: terraform fmt -check -recursive + + - name: Terraform Init + run: terraform init -backend=false + + - name: Terraform Validate + run: terraform validate + + - name: Setup TFLint + uses: terraform-linters/setup-tflint@v4 + with: + tflint_version: latest + + - name: Run TFLint + run: tflint --format compact diff --git a/.gitignore b/.gitignore index 30d74d2584..a770e5c7f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,25 @@ -test \ No newline at end of file +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +terraform.tfvars +*.tfvars +crash.log + +# Pulumi +venv/ +pulumi/venv/ +pulumi/Pulumi.*.yaml +__pycache__/ +*.pyc + +# Credentials +*.pem +*.key +yc-key.json + +# Ansible +*.retry +ansible/.vault_pass +ansible/inventory/*.pyc \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000..13566b81b0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/DevOps-Core-Course.iml b/.idea/DevOps-Core-Course.iml new file mode 100644 index 0000000000..5e764c4f0b --- /dev/null +++ b/.idea/DevOps-Core-Course.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000000..1f2ea11e7f --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000000..bd7edbb134 --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000..71d30f3527 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000..35eb1ddfbb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 371d51f456..99b2a345c4 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ [![Labs](https://img.shields.io/badge/Labs-18-blue)](#labs) [![Exam](https://img.shields.io/badge/Exam-Optional-green)](#exam-alternative) [![Duration](https://img.shields.io/badge/Duration-18%20Weeks-lightgrey)](#course-roadmap) +[![Ansible Deployment](https://github.com/blxxdclxud/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/blxxdclxud/DevOps-Core-Course/actions/workflows/ansible-deploy.yml) +[![Ansible Deployment - Bonus App](https://github.com/blxxdclxud/DevOps-Core-Course/actions/workflows/ansible-deploy-bonus.yml/badge.svg)](https://github.com/blxxdclxud/DevOps-Core-Course/actions/workflows/ansible-deploy-bonus.yml) +[![Terraform CI](https://github.com/blxxdclxud/DevOps-Core-Course/actions/workflows/terraform-ci.yml/badge.svg)](https://github.com/blxxdclxud/DevOps-Core-Course/actions/workflows/terraform-ci.yml) Master **production-grade DevOps practices** through hands-on labs. Build, containerize, deploy, monitor, and scale applications using industry-standard tools. diff --git a/ansible/.ansible-lint b/ansible/.ansible-lint new file mode 100644 index 0000000000..29f3c36449 --- /dev/null +++ b/ansible/.ansible-lint @@ -0,0 +1,13 @@ +--- +# Ansible-lint configuration +profile: basic + +# Skip rules that conflict with our design choices +skip_list: + # Vault variables and playbook vars use shared names (no role prefix) + - var-naming[no-role-prefix] + # We use ignore_errors in wipe.yml intentionally (app may not exist yet) + - ignore-errors + +warn_list: + - args[module] diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000000..c14740107f --- /dev/null +++ b/ansible/README.md @@ -0,0 +1 @@ +# CI/CD configured and tested diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..54b3683b8c --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,12 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = ubuntu +retry_files_enabled = False +vault_password_file = .vault_pass + +[privilege_escalation] +become = True +become_method = sudo +become_user = root diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md new file mode 100644 index 0000000000..6643887aaa --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,503 @@ +# Lab 05 — Ansible Fundamentals + +## 1. Architecture Overview + +**Ansible version:** ansible-core 2.20.3 +**Control node OS:** Ubuntu 24.04 LTS (local machine) +**Target VM OS:** Ubuntu 24.04 LTS (Yandex Cloud, recreated using Lab 4 Terraform code) +**Cloud provider:** Yandex Cloud, zone ru-central1-a, VM public IP: 89.169.131.155 + +### Role structure + +``` +ansible/ +├── inventory/ +│ ├── hosts.ini # Static inventory +│ ├── group_vars/ +│ │ └── all.yml # Encrypted with Ansible Vault +│ ├── yandex.yml # Dynamic inventory notes +│ └── yandex_inventory.py # Dynamic inventory script (Yandex Cloud API) +├── roles/ +│ ├── common/ # Basic system setup +│ │ ├── tasks/main.yml +│ │ └── defaults/main.yml +│ ├── docker/ # Docker installation +│ │ ├── tasks/main.yml +│ │ ├── handlers/main.yml +│ │ └── defaults/main.yml +│ └── app_deploy/ # Run our Python app in Docker +│ ├── tasks/main.yml +│ ├── handlers/main.yml +│ └── defaults/main.yml +├── playbooks/ +│ ├── site.yml # All roles together +│ ├── provision.yml # System setup only +│ └── deploy.yml # App deployment only +├── ansible.cfg +└── docs/ + └── LAB05.md +``` + +### Why roles instead of one big playbook? + +Roles keep things organized. Each role does one specific job. If I need Docker on another project, I just copy the `docker` role. A monolithic playbook would be one huge file that is hard to read and impossible to reuse. Roles are the professional way to write Ansible. + +--- + +## 2. Roles Documentation + +### common role + +**Purpose:** Basic server setup that any Ubuntu server needs before anything else. + +**Tasks:** +- Update apt package cache (with `cache_valid_time=3600` so it does not update if it was updated less than an hour ago) +- Install essential packages: python3-pip, curl, git, vim, htop, wget, unzip +- Set timezone to Europe/Moscow + +**Variables (`defaults/main.yml`):** +```yaml +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - wget + - unzip + +common_timezone: "Europe/Moscow" +``` + +**Handlers:** None — apt installs do not require a service restart. + +**Dependencies:** None. + +--- + +### docker role + +**Purpose:** Install Docker CE on Ubuntu following the official Docker installation steps, translated to Ansible tasks. + +**Tasks:** +1. Install prerequisites (ca-certificates, curl, gnupg) +2. Create `/etc/apt/keyrings` directory with correct permissions +3. Download Docker's official GPG key +4. Add Docker apt repository using `{{ ansible_distribution_release }}` fact (works on Ubuntu 22.04 and 24.04 without changes) +5. Install docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, docker-compose-plugin +6. Start and enable Docker service +7. Add `ubuntu` user to docker group +8. Install python3-docker (required for Ansible's docker modules) + +**Variables (`defaults/main.yml`):** +```yaml +docker_user: ubuntu +``` + +**Handlers (`handlers/main.yml`):** +```yaml +- name: restart docker + service: + name: docker + state: restarted +``` +Triggered when Docker packages are installed (via `notify: restart docker`). + +**Dependencies:** common role (apt cache should be updated first). + +--- + +### app_deploy role + +**Purpose:** Pull the Python app Docker image from Docker Hub and run it as a container. + +**Tasks:** +1. Log in to Docker Hub using vault credentials (`no_log: true` so password never appears in output) +2. Pull Docker image +3. Remove old container if it exists (idempotent cleanup) +4. Start new container with port mapping `5000:5000` and `restart_policy: unless-stopped` +5. Wait for port 5000 to open (confirms container started) +6. Verify `/health` endpoint returns HTTP 200 + +**Variables (`defaults/main.yml`):** +```yaml +app_port: 5000 +app_restart_policy: unless-stopped +app_env_vars: {} +``` + +Variables from vault (`inventory/group_vars/all.yml`): +```yaml +dockerhub_username: blxxdclxud +dockerhub_password: +app_name: devops-info-service +docker_image: "{{ dockerhub_username }}/{{ app_name }}" +docker_image_tag: latest +app_port: 5000 +app_container_name: "{{ app_name }}" +``` + +**Handlers (`handlers/main.yml`):** +```yaml +- name: restart app container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: started + restart: yes +``` + +**Dependencies:** docker role. + +--- + +## 3. Idempotency Demonstration + +### First run — `ansible-playbook playbooks/provision.yml` + +``` +PLAY [Provision web servers] *************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [lab-vm] + +TASK [common : Update apt cache] *********************************************** +changed: [lab-vm] + +TASK [common : Install common packages] **************************************** +changed: [lab-vm] + +TASK [common : Set timezone] *************************************************** +changed: [lab-vm] + +TASK [docker : Install required packages for Docker repo] ********************** +ok: [lab-vm] + +TASK [docker : Create keyrings directory] ************************************** +ok: [lab-vm] + +TASK [docker : Add Docker GPG key] ********************************************* +changed: [lab-vm] + +TASK [docker : Add Docker repository] ****************************************** +changed: [lab-vm] + +TASK [docker : Install Docker packages] **************************************** +changed: [lab-vm] + +TASK [docker : Ensure Docker service is started and enabled] ******************* +ok: [lab-vm] + +TASK [docker : Add user to docker group] *************************************** +changed: [lab-vm] + +TASK [docker : Install python3-docker for Ansible docker modules] ************** +changed: [lab-vm] + +RUNNING HANDLER [docker : restart docker] ************************************** +changed: [lab-vm] + +PLAY RECAP ********************************************************************* +lab-vm : ok=13 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +9 tasks changed on first run because everything was installed fresh. The handler ran once at the end to restart Docker after packages were installed. + +### Second run — `ansible-playbook playbooks/provision.yml` + +``` +PLAY [Provision web servers] *************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [lab-vm] + +TASK [common : Update apt cache] *********************************************** +ok: [lab-vm] + +TASK [common : Install common packages] **************************************** +ok: [lab-vm] + +TASK [common : Set timezone] *************************************************** +ok: [lab-vm] + +TASK [docker : Install required packages for Docker repo] ********************** +ok: [lab-vm] + +TASK [docker : Create keyrings directory] ************************************** +ok: [lab-vm] + +TASK [docker : Add Docker GPG key] ********************************************* +ok: [lab-vm] + +TASK [docker : Add Docker repository] ****************************************** +ok: [lab-vm] + +TASK [docker : Install Docker packages] **************************************** +ok: [lab-vm] + +TASK [docker : Ensure Docker service is started and enabled] ******************* +ok: [lab-vm] + +TASK [docker : Add user to docker group] *************************************** +ok: [lab-vm] + +TASK [docker : Install python3-docker for Ansible docker modules] ************** +ok: [lab-vm] + +PLAY RECAP ********************************************************************* +lab-vm : ok=12 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +**changed=0 on second run.** The handler did not even trigger because no packages were reinstalled. + +### Analysis + +**What changed on first run:** Almost everything, because the VM was a fresh Ubuntu with no Docker or common packages. + +**What stayed `ok` on second run:** +- `apt` module checks if each package is already at `state: present` — if yes, it does nothing +- `file` module checks if the directory already has the correct permissions +- `get_url` checks if the file already exists with the correct checksum +- `apt_repository` checks if the repo line is already in the sources list +- `service` checks if Docker is already started and enabled +- `user` checks if ubuntu is already in the docker group + +**What makes our roles idempotent:** We use Ansible's declarative modules (`apt`, `service`, `file`, `user`, `get_url`, `apt_repository`) instead of `shell` or `command`. These modules always check current state before making a change. If the state already matches the desired state, they do nothing. + +--- + +## 4. Ansible Vault Usage + +### How credentials are stored + +All sensitive data lives in `inventory/group_vars/all.yml`, which is encrypted with Ansible Vault. The file in git looks like this: + +``` +$ANSIBLE_VAULT;1.1;AES256 +32386331623939663963666531666434613830323232613238396234643063373738613764303939 +6235346663643761326237373864353263323335336336360a656439343563613939353830393938 +... +``` + +It is completely unreadable without the vault password. + +### Vault password management + +The vault password is stored in `ansible/.vault_pass` (plain text file). This file is in `.gitignore` so it never gets committed. The `ansible.cfg` points to it automatically: + +```ini +vault_password_file = .vault_pass +``` + +### Commands used + +```bash +# Encrypt the file after writing plaintext +ansible-vault encrypt inventory/group_vars/all.yml --vault-password-file .vault_pass --encrypt-vault-id default + +# View encrypted file to verify content +ansible-vault view inventory/group_vars/all.yml --vault-password-file .vault_pass + +# Edit encrypted file +ansible-vault edit inventory/group_vars/all.yml +``` + +### Proof of encryption (ansible-vault view output) + +``` +--- +# Docker Hub credentials +dockerhub_username: blxxdclxud +dockerhub_password: dckr_pat_*************************** + +# Application configuration +app_name: devops-info-service +docker_image: "{{ dockerhub_username }}/{{ app_name }}" +docker_image_tag: latest +app_port: 5000 +app_container_name: "{{ app_name }}" +``` + +### Why Ansible Vault is necessary + +If we committed the Docker Hub password to git, anyone with access to the repo could pull our images without permission. Vault encrypts with AES-256, so the encrypted file is safe to commit. The only secret that must be kept out of git is the vault password file itself (`.vault_pass`), which is in `.gitignore`. + +--- + +## 5. Deployment Verification + +### Terminal output from `ansible-playbook playbooks/deploy.yml` + +``` +PLAY [Deploy application] ****************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [lab-vm] + +TASK [app_deploy : Log in to Docker Hub] *************************************** +ok: [lab-vm] + +TASK [app_deploy : Pull Docker image] ****************************************** +changed: [lab-vm] + +TASK [app_deploy : Remove old container if exists] ***************************** +ok: [lab-vm] + +TASK [app_deploy : Run application container] ********************************** +changed: [lab-vm] + +TASK [app_deploy : Wait for application to be ready] *************************** +ok: [lab-vm] + +TASK [app_deploy : Verify health endpoint] ************************************* +ok: [lab-vm] + +TASK [app_deploy : Print health check result] ********************************** +ok: [lab-vm] => { + "msg": "App is healthy: 200" +} + +PLAY RECAP ********************************************************************* +lab-vm : ok=8 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### Container status — `ansible webservers -a "docker ps"` + +``` +lab-vm | CHANGED | rc=0 >> +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +7708f3ef9215 blxxdclxud/devops-info-service:latest "python -m uvicorn a…" 22 seconds ago Up 20 seconds (healthy) 0.0.0.0:5000->5000/tcp devops-info-service +``` + +### Health check — `curl http://89.169.131.155:5000/health` + +```json +{"status":"healthy","timestamp":"2026-02-26T20:56:39.121836Z","uptime_seconds":19} +``` + +### Main endpoint — `curl http://89.169.131.155:5000/` + +```json +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"FastAPI"},"system":{"hostname":"7708f3ef9215","platform":"Linux","platform_version":"Linux-6.8.0-100-generic-x86_64-with-glibc2.41","architecture":"x86_64","cpu_count":2,"python_version":"3.13.12"},"runtime":{"uptime_seconds":20,"uptime_human":"0 hours, 0 minutes","current_time":"2026-02-26T20:56:39.536715Z","timezone":"UTC"},"request":{"client_ip":"80.136.142.219","user_agent":"curl/8.5.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} +``` + +--- + +## 6. Key Decisions + +**Why use roles instead of plain playbooks?** +Roles split code into focused, reusable pieces. If I need to install Docker on a different project, I just copy the `docker` role. A plain playbook would be one huge file where everything is mixed together, making it hard to read and impossible to reuse across projects. + +**How do roles improve reusability?** +Each role is self-contained with its own variables, handlers, and tasks. Other playbooks can include just the roles they need. For example, `provision.yml` uses `common` and `docker`, while `deploy.yml` only uses `app_deploy`. You can share roles via Ansible Galaxy. + +**What makes a task idempotent?** +Using Ansible's built-in modules instead of shell commands. Modules like `apt`, `service`, `file`, and `user` check the current state first. They only make a change if the current state is different from the desired state. Running `apt: name=docker-ce state=present` ten times has the same result as running it once. + +**How do handlers improve efficiency?** +Handlers only run once at the end of a play, even if notified multiple times. If ten tasks all notify `restart docker`, Docker restarts only once. Without handlers, you would either restart too many times or forget to restart at all. + +**Why is Ansible Vault necessary?** +We need Docker Hub credentials to pull the private image. Storing them as plaintext in the repo is a security risk. Vault encrypts with AES-256 so the encrypted file is safe to commit. Only the vault password needs to stay secret, and we keep it out of git via `.gitignore`. + +--- + +## 7. Challenges + +- The VM from Lab 4 was already destroyed, so it was recreated with `terraform apply` from existing Lab 4 code +- `ansible_distribution_release` Ansible fact is needed in the Docker repo string to work on different Ubuntu versions automatically +- `python3-docker` must be installed on the target VM for Ansible docker modules to work — easy to forget +- `no_log: true` on the Docker Hub login task is required to prevent the password appearing in Ansible output +- `ansible-core 2.20.3` has a regression where `group_vars` must be placed relative to the inventory file (in `inventory/group_vars/`), not just in the project root +- The official `yandex.cloud` Ansible collection is not yet available on Ansible Galaxy for ansible-core 2.20.x, so dynamic inventory was implemented using a custom Python script with the `yandexcloud` SDK + +--- + +## Bonus: Dynamic Inventory with Yandex Cloud + +### Why dynamic inventory? + +With static inventory, the VM IP must be updated manually every time the VM is recreated. With dynamic inventory, Ansible queries the Yandex Cloud API directly and always gets the current IP. If the VM is destroyed and recreated with a new IP, playbooks still work with no changes. + +### Setup + +**Install the Yandex Cloud Python SDK:** +```bash +pip install --break-system-packages yandexcloud grpcio +``` + +**Inventory script:** `inventory/yandex_inventory.py` + +The script: +1. Loads the service account key (same JSON key used in Lab 4 Terraform) +2. Calls the Yandex Compute API to list all instances in the folder +3. Filters only RUNNING instances +4. For each instance, extracts the public NAT IP +5. Groups VMs with label `project=devops-lab04` into the `webservers` group +6. Returns JSON in the Ansible dynamic inventory format + +### Authentication + +Same service account key file used in Lab 4 Terraform (`/home/blxxdclxud/yc-key.json`). The key file is in `.gitignore` on both Terraform and Ansible sides. + +### How cloud metadata maps to Ansible variables + +| Ansible variable | Yandex Cloud field | +|---|---| +| `ansible_host` | `network_interfaces[0].primary_v4_address.one_to_one_nat.address` | +| `ansible_user` | hardcoded `ubuntu` (all VMs use this user) | +| host group `webservers` | VMs with label `project=devops-lab04` | + +### Test — `ansible-inventory -i inventory/yandex_inventory.py --graph` + +``` +@all: + |--@ungrouped: + |--@webservers: + | |--lab-vm +``` + +### Test — `ansible all -i inventory/yandex_inventory.py -m ping` + +``` +lab-vm | SUCCESS => { + "ansible_facts": { + "discovered_interpreter_python": "/usr/bin/python3.12" + }, + "changed": false, + "ping": "pong" +} +``` + +### Run provision with dynamic inventory + +``` +PLAY [Provision web servers] *************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [lab-vm] + +TASK [common : Update apt cache] *********************************************** +ok: [lab-vm] + +TASK [common : Install common packages] **************************************** +ok: [lab-vm] + +... + +PLAY RECAP ********************************************************************* +lab-vm : ok=12 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### What happens when VM IP changes + +With static inventory (`hosts.ini`) I would have to find the new IP and update it manually. With the dynamic inventory script, Ansible queries the API every run and always gets the current IP automatically. Destroy and recreate the VM, playbooks still work with zero changes. + +### Benefits vs static inventory + +| Feature | Static (hosts.ini) | Dynamic (yandex_inventory.py) | +|---|---|---| +| IP management | Manual update | Automatic | +| New VMs | Must add manually | Auto-discovered | +| Scaling to 10+ VMs | Very tedious | Works instantly | +| Deleted VMs | Must remove manually | Disappear automatically | +| Source of truth | The file itself | The cloud API | diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md new file mode 100644 index 0000000000..dfe7167030 --- /dev/null +++ b/ansible/docs/LAB06.md @@ -0,0 +1,645 @@ +# Lab 6: Advanced Ansible & CI/CD - Submission + +**Name:** blxxdclxud +**Date:** 2026-03-05 +**Lab Points:** 10 + 2.5 bonus + +--- + +## Task 1: Blocks & Tags (2 pts) + +### What I Did + +Refactored both `common` and `docker` roles to use Ansible blocks. Blocks let you group tasks together and apply `become`, `tags`, `when` to all of them at once, instead of repeating on each task. They also allow `rescue` (like try/catch) and `always` sections. + +### common role (`roles/common/tasks/main.yml`) + +Two blocks: + +1. **Install system packages** — tagged `packages` + - Block: update apt cache + install common packages + - Rescue: retry apt update if it fails, then reinstall packages + - Always: print debug message that installation is done + +2. **Configure system settings** — tagged `users` + - Block: set timezone + - Always: print debug message + +### docker role (`roles/docker/tasks/main.yml`) + +Two blocks: + +1. **Install Docker** — tagged `docker`, `docker_install` + - Block: install prerequisites, create keyrings dir, add GPG key, add Docker repo, install Docker packages + - Rescue: wait 10 seconds then retry GPG key download (handles network timeouts) + - Always: make sure Docker service is started and enabled no matter what + +2. **Configure Docker** — tagged `docker`, `docker_config` + - Block: add user to docker group, install python3-docker + +### Tag Strategy + +| Tag | What runs | +|-----|-----------| +| `common` | Entire common role | +| `packages` | Package installation only | +| `users` | User/timezone config only | +| `docker` | Entire docker role | +| `docker_install` | Docker installation only | +| `docker_config` | Docker configuration only | +| `app_deploy` | App deployment | +| `compose` | Docker Compose tasks | +| `web_app_wipe` | Wipe tasks only | + +### Tag Execution Evidence + +**List all tags:** +``` +$ ansible-playbook playbooks/provision.yml --list-tags + +playbook: playbooks/provision.yml + + play #1 (webservers): Provision web servers TAGS: [] + TASK TAGS: [common, docker, docker_config, docker_install, packages, users] +``` + +**Run only docker tag:** +``` +$ ansible-playbook playbooks/provision.yml --tags "docker" + +TASK [docker : Install required packages for Docker repo] **** ok: [lab-vm] +TASK [docker : Create keyrings directory] ******************** ok: [lab-vm] +TASK [docker : Add Docker GPG key] *************************** ok: [lab-vm] +TASK [docker : Add Docker repository] ************************ ok: [lab-vm] +TASK [docker : Install Docker packages] ********************** ok: [lab-vm] +TASK [docker : Ensure Docker service is started and enabled] * ok: [lab-vm] +TASK [docker : Add user to docker group] ********************* ok: [lab-vm] +TASK [docker : Install python3-docker for Ansible ...] ******* ok: [lab-vm] + +PLAY RECAP: lab-vm ok=9 changed=0 unreachable=0 failed=0 skipped=0 +``` +→ Only Docker tasks ran, common role was skipped. + +**Run only packages tag:** +``` +$ ansible-playbook playbooks/provision.yml --tags "packages" + +TASK [common : Update apt cache] ***** changed: [lab-vm] +TASK [common : Install common packages] ok: [lab-vm] +TASK [common : Log package installation completion] ok: [lab-vm] + +PLAY RECAP: lab-vm ok=3 changed=1 unreachable=0 failed=0 skipped=0 +``` +→ Only package tasks ran. + +### Research Answers + +**Q: What happens if rescue block also fails?** +A: If the rescue block also fails, Ansible marks the task as failed and stops (unless there's `ignore_errors`). The always block still runs. There's no nested rescue for rescue blocks. + +**Q: Can you have nested blocks?** +A: Yes. You can put a block inside another block's task list. Each nested block can have its own rescue/always. + +**Q: How do tags inherit to tasks within blocks?** +A: Tags on the block are inherited by all tasks inside it. If a task also has its own tags, it gets both the block tags and its own tags combined. + +--- + +## Task 2: Docker Compose (3 pts) + +### 2.1 Why Docker Compose Over `docker run` + +`docker run` is a one-off command. Docker Compose is a declarative file — you describe what you want, and Compose figures out what to create/update. Key benefits: +- Config is stored as a file (version-controlled) +- Easy to update: change the file and run `up` again +- Handles networking, volumes, restart policies automatically +- Idempotent: running `up` twice doesn't create duplicate containers + +### 2.2 Renamed app_deploy → web_app + +The `app_deploy` role was renamed to `web_app`. This name is more descriptive and prepares for multiple app types (python app, bonus app, etc.). All playbook references were updated. + +### 2.3 Docker Compose Template + +**File:** `roles/web_app/templates/docker-compose.yml.j2` + +```yaml +# Generated by Ansible - do not edit manually + +services: + {{ app_name }}: + image: {{ docker_image }}:{{ docker_tag }} + container_name: {{ app_name }} + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + APP_ENV: production + restart: unless-stopped +``` + +Jinja2 variables are filled in by Ansible when deploying. The result on the server: + +```yaml +services: + devops-info-service: + image: blxxdclxud/devops-info-service:latest + container_name: devops-info-service + ports: + - "5000:5000" + environment: + APP_ENV: production + restart: unless-stopped +``` + +### 2.4 Role Dependencies + +**File:** `roles/web_app/meta/main.yml` + +```yaml +dependencies: + - role: docker +``` + +This means when you run the `web_app` role, Ansible automatically runs `docker` role first. You don't need to manually include it in the playbook. Running just `deploy.yml` will install Docker if not installed. + +### 2.5 Docker Compose Deployment + +The `web_app/tasks/main.yml` uses `community.docker.docker_compose_v2` module which uses the Docker Compose V2 CLI plugin (already installed as `docker-compose-plugin`). + +### 2.6 Variables + +**Role defaults (`roles/web_app/defaults/main.yml`):** +```yaml +app_name: devops-info-service +docker_image: blxxdclxud/devops-info-service +docker_tag: latest +app_port: 5000 +app_internal_port: 5000 +compose_project_dir: "/opt/{{ app_name }}" +web_app_wipe: false +``` + +### 2.7 Deployment Evidence + +**First deployment:** +``` +TASK [web_app : Create app directory] ******* changed: [lab-vm] +TASK [web_app : Template docker-compose.yml] changed: [lab-vm] +TASK [web_app : Deploy with Docker Compose] changed: [lab-vm] +TASK [web_app : Wait for application] ok: [lab-vm] +TASK [web_app : Verify health endpoint] ok: [lab-vm] +TASK [web_app : Print health check result] ok: [lab-vm] => { + "msg": "App devops-info-service is healthy. Status: 200" +} +PLAY RECAP: lab-vm ok=16 changed=2 unreachable=0 failed=0 +``` + +**Second run (idempotency proof):** +``` +TASK [web_app : Template docker-compose.yml] ok: [lab-vm] +TASK [web_app : Deploy with Docker Compose] ok: [lab-vm] +... +PLAY RECAP: lab-vm ok=16 changed=0 unreachable=0 failed=0 +``` +→ `changed=0` proves idempotency. + +**App running on VM:** +``` +$ docker ps +CONTAINER ID IMAGE PORTS NAMES +4bec8b2687bc blxxdclxud/devops-info-service:latest 0.0.0.0:5000->5000/tcp devops-info-service + +$ curl http://localhost:5000/health +{"status":"healthy","timestamp":"2026-03-05T20:41:XX.XXXXXX","uptime_seconds":30} +``` + +### Research Answers + +**Q: restart: always vs restart: unless-stopped?** +A: `always` restarts the container no matter what — even if you manually `docker stop` it. `unless-stopped` respects manual stops: if you stop it manually, it stays stopped after a daemon restart. For development/maintenance, `unless-stopped` is better. + +**Q: Docker Compose networks vs Docker bridge?** +A: Docker Compose creates a named bridge network per project automatically. Containers in the same Compose file can reach each other by service name. Plain `docker run` containers are on the default bridge network and can only communicate by IP, not by name. + +**Q: Can you reference Ansible Vault variables in the template?** +A: Yes. Vault variables are decrypted before Ansible renders the template. You can use `{{ vault_variable }}` directly in the template. + +--- + +## Task 3: Wipe Logic (1 pt) + +### Implementation + +**File:** `roles/web_app/tasks/wipe.yml` + +The wipe logic uses **double-gating**: +1. **Variable gate:** `when: web_app_wipe | bool` — wipe only runs if the variable is set to `true` +2. **Tag gate:** `tags: web_app_wipe` — wipe tasks are only included if the tag is specified + +Both conditions must be true for wipe to happen. + +**Default:** `web_app_wipe: false` in `roles/web_app/defaults/main.yml` + +**Wipe sequence:** +1. `docker compose down` — stops and removes containers +2. Remove `docker-compose.yml` file +3. Remove application directory (`/opt/app_name`) +4. Log completion + +### Wipe is included at the top of `tasks/main.yml` + +```yaml +- name: Include wipe tasks + ansible.builtin.include_tasks: wipe.yml + tags: [web_app_wipe] +``` + +This allows clean reinstall: wipe runs first, then deploy continues normally. + +### Test Results + +**Scenario 1: Normal deploy — wipe skipped** +```bash +$ ansible-playbook playbooks/deploy.yml + +TASK [web_app : Stop and remove containers] skipping: [lab-vm] +TASK [web_app : Remove docker-compose file] skipping: [lab-vm] +TASK [web_app : Remove application directory] skipping: [lab-vm] +TASK [web_app : Log wipe completion] skipping: [lab-vm] +# → App deployed normally, wipe never ran +``` + +**Scenario 2: Wipe only** +```bash +$ ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe + +TASK [web_app : Stop and remove containers] changed: [lab-vm] +TASK [web_app : Remove docker-compose file] changed: [lab-vm] +TASK [web_app : Remove application directory] changed: [lab-vm] +TASK [web_app : Log wipe completion] ok: [lab-vm] => { + "msg": "Application devops-info-service has been wiped from lab-vm" +} +PLAY RECAP: ok=6 changed=3 — deployment tasks didn't run (tag filter) +``` + +**Scenario 3: Clean reinstall** +```bash +$ ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" +# → Wipe runs first (removes old app), then deploy runs → fresh installation +PLAY RECAP: ok=16 changed=6 +``` + +**Scenario 4a: Tag specified, variable false — wipe blocked** +```bash +$ ansible-playbook playbooks/deploy.yml --tags web_app_wipe + +TASK [web_app : Stop and remove containers] skipping: [lab-vm] # when: false blocks it +TASK [web_app : Remove docker-compose file] skipping: [lab-vm] +TASK [web_app : Remove application directory] skipping: [lab-vm] +# → Even with tag, the when condition blocked execution +``` + +### Research Answers + +**1. Why use both variable AND tag?** +Two independent safety checks. Tag alone isn't enough — someone could accidentally add the tag to a run. Variable alone isn't enough — you might forget to add it as an extra var. Together they make accidental wipe very hard. + +**2. What's the difference from `never` tag?** +The `never` tag in Ansible means a task is excluded from ALL runs unless that specific tag is passed. Our approach is different: we use a combination of a regular tag + a `when` condition. The `never` tag can't be combined with a variable check — it's all-or-nothing. + +**3. Why must wipe logic come BEFORE deployment?** +For the clean reinstall scenario: `ansible-playbook deploy.yml -e "web_app_wipe=true"`. If wipe were at the bottom, deployment would create files, then wipe would delete them immediately — useless. At the top: wipe removes old, deploy installs fresh. + +**4. Clean reinstall vs rolling update?** +- Rolling update: new container replaces old with minimal downtime (good for production) +- Clean reinstall: wipe everything, start fresh (good for troubleshooting, major config changes, or when the old state is corrupted) + +**5. How to extend wipe to images and volumes?** +Add to wipe.yml: +```yaml +- name: Remove Docker image + community.docker.docker_image: + name: "{{ docker_image }}" + state: absent + when: web_app_wipe_images | default(false) | bool +``` + +--- + +## Task 4: CI/CD with GitHub Actions (3 pts) + +### Workflow Architecture + +**File:** `.github/workflows/ansible-deploy.yml` + +``` +Push to ansible/** → lint job → deploy job → verify +``` + +Two separate workflows: +- `ansible-deploy.yml` — deploys Python app +- `ansible-deploy-bonus.yml` — deploys bonus app independently + +### How It Works + +**Lint job** (runs on every push/PR): +1. Install Python 3.12 +2. `pip install ansible ansible-lint` +3. `ansible-galaxy collection install community.docker ansible.posix` +4. `ansible-lint playbooks/provision.yml playbooks/deploy.yml` + +**Deploy job** (runs on push only, needs lint to pass): +1. Install Ansible + collections +2. Set up SSH key from GitHub Secret +3. Write vault password from secret +4. Run `provision.yml` + `deploy_python.yml` +5. Clean up vault password file +6. Verify deployment with `curl` + +### Path Filters + +The workflow only runs when relevant files change: +```yaml +paths: + - 'ansible/**' + - '!ansible/docs/**' # docs changes don't trigger deploy + - '.github/workflows/ansible-deploy.yml' +``` + +### Required GitHub Secrets + +Go to: Repository → Settings → Secrets and variables → Actions → New repository secret + +| Secret Name | Value | +|------------|-------| +| `ANSIBLE_VAULT_PASSWORD` | The vault password from `.vault_pass` | +| `SSH_PRIVATE_KEY` | Content of `~/.ssh/id_ed25519` (private key) | +| `VM_HOST` | `93.77.185.109` | +| `VM_USER` | `ubuntu` | + +### ansible-lint Result (local) + +``` +$ ansible-lint playbooks/provision.yml playbooks/deploy.yml + +Passed: 0 failure(s), 0 warning(s) in 12 files processed. +Profile 'basic' was required, but 'production' profile passed. +``` + +### CI/CD Run Evidence + +Both workflows completed with all jobs passing (push event): + +**Ansible Deployment (run 22736959961):** +- Ansible Lint → **success** +- Deploy Application → **success** + +**Ansible Deployment - Bonus App (run 22736959957):** +- Ansible Lint - Bonus → **success** +- Deploy Bonus App → **success** + +Verification via SSH confirmed both apps running and healthy after automated deployment. + +### Status Badge + +[![Ansible Deployment](https://github.com/blxxdclxud/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/blxxdclxud/DevOps-Core-Course/actions/workflows/ansible-deploy.yml) + +[![Ansible Deployment - Bonus App](https://github.com/blxxdclxud/DevOps-Core-Course/actions/workflows/ansible-deploy-bonus.yml/badge.svg)](https://github.com/blxxdclxud/DevOps-Core-Course/actions/workflows/ansible-deploy-bonus.yml) + +### Research Answers + +**1. Security implications of SSH keys in GitHub Secrets?** +GitHub Secrets are encrypted at rest and only exposed to workflow runs. The key risks: anyone with write access to the repo can read secrets in workflow runs via `echo $SECRET` (though GitHub tries to mask them). If the repo is compromised, secrets could leak. For production, prefer short-lived credentials (like OIDC), or use a self-hosted runner with restricted network access. + +**2. Staging → production pipeline?** +Add environments in GitHub Actions: +```yaml +jobs: + deploy-staging: + environment: staging + deploy-production: + environment: production + needs: deploy-staging +``` +Each environment has separate secrets and can require manual approval. + +**3. Making rollbacks possible?** +Tag Docker images with git commit SHA: `docker_tag: ${{ github.sha }}`. Keep previous image tags on Docker Hub. Add a `rollback.yml` playbook that accepts a `rollback_tag` variable and deploys that specific tag. + +**4. Self-hosted runner security?** +Self-hosted runner runs on YOUR infrastructure, not GitHub's. Benefits: +- The runner has direct access to your internal network (no SSH from outside) +- No credentials leave your network +- You control the environment (no supply chain risk from GitHub-provided images) +- Faster (no SSH overhead) + +--- + +## Task 5: Documentation + +This file (`ansible/docs/LAB06.md`) serves as the complete documentation. + +### Project Structure After Lab 6 + +``` +ansible/ +├── .ansible-lint # Lint configuration +├── ansible.cfg +├── requirements.yml # Collection dependencies +├── inventory/ +│ └── hosts.ini # Updated with new VM IP +├── group_vars/ +│ └── all.yml # Vault-encrypted variables +├── vars/ +│ ├── app_python.yml # Python app variables (bonus) +│ └── app_bonus.yml # Bonus app variables (bonus) +├── roles/ +│ ├── common/ # Refactored with blocks/tags +│ │ └── tasks/main.yml +│ ├── docker/ # Refactored with blocks/tags +│ │ ├── tasks/main.yml +│ │ └── handlers/main.yml +│ └── web_app/ # Renamed from app_deploy +│ ├── tasks/main.yml # Docker Compose deployment +│ ├── tasks/wipe.yml # Wipe logic +│ ├── templates/docker-compose.yml.j2 +│ ├── meta/main.yml # Role dependencies +│ ├── defaults/main.yml # Default variables +│ └── handlers/main.yml +├── playbooks/ +│ ├── provision.yml # System setup +│ ├── deploy.yml # Main app deploy +│ ├── deploy_python.yml # Python app (bonus) +│ ├── deploy_bonus.yml # Bonus app (bonus) +│ ├── deploy_all.yml # Both apps (bonus) +│ └── site.yml +└── docs/ + ├── LAB05.md + └── LAB06.md # This file + +.github/workflows/ +├── terraform-ci.yml # From lab 4 +├── ansible-deploy.yml # Python app CI/CD (lab 6) +└── ansible-deploy-bonus.yml # Bonus app CI/CD (lab 6) +``` + +--- + +## Bonus Part 1: Multi-App Deployment (1.5 pts) + +### Architecture + +The same `web_app` role deploys both apps with different variables. This is role reusability — one role, multiple configurations. + +``` +web_app role (reusable) +├── vars: app_name=devops-python, port=5000 → Python app +└── vars: app_name=devops-bonus, port=8001 → Bonus app +``` + +### Variable Files + +**`vars/app_python.yml`:** +```yaml +app_name: devops-python +docker_image: blxxdclxud/devops-info-service +docker_tag: latest +app_port: 5000 +app_internal_port: 5000 +compose_project_dir: "/opt/devops-python" +``` + +**`vars/app_bonus.yml`:** +```yaml +app_name: devops-bonus +docker_image: blxxdclxud/devops-info-service +docker_tag: latest +app_port: 8001 +app_internal_port: 5000 +compose_project_dir: "/opt/devops-bonus" +``` + +Note: Both use the same Python image but different ports. In a real setup with a compiled language app from lab01 bonus, `docker_image` would point to the Go/Rust image. + +### deploy_all.yml Evidence + +``` +$ ansible-playbook playbooks/deploy_all.yml + +TASK [Deploy Python App] +TASK [web_app : Create app directory] changed: [lab-vm] +TASK [web_app : Template docker-compose.yml] changed: [lab-vm] +TASK [web_app : Deploy with Docker Compose] changed: [lab-vm] +TASK [web_app : Print health check result] ok: [lab-vm] => { + "msg": "App devops-python is healthy. Status: 200" + +TASK [Deploy Bonus App] +TASK [web_app : Create app directory] changed: [lab-vm] +TASK [web_app : Template docker-compose.yml] changed: [lab-vm] +TASK [web_app : Deploy with Docker Compose] changed: [lab-vm] +TASK [web_app : Print health check result] ok: [lab-vm] => { + "msg": "App devops-bonus is healthy. Status: 200" + +PLAY RECAP: lab-vm ok=25 changed=6 +``` + +**Both apps running:** +``` +$ docker ps +CONTAINER ID IMAGE PORTS NAMES +fe40b3b5e476 blxxdclxud/devops-info-service:latest 0.0.0.0:8001->5000/tcp devops-bonus +9aeaaf092887 blxxdclxud/devops-info-service:latest 0.0.0.0:5000->5000/tcp devops-python + +$ curl http://93.77.185.109:5000/health +{"status":"healthy","timestamp":"2026-03-05T20:32:05.087517Z","uptime_seconds":52} + +$ curl http://93.77.185.109:8001/health +{"status":"healthy","timestamp":"2026-03-05T20:32:05.097031Z","uptime_seconds":19} +``` + +**Idempotency (second run):** +``` +PLAY RECAP: lab-vm ok=25 changed=0 +``` + +**Independent wipe:** +``` +$ ansible-playbook playbooks/deploy_python.yml -e "web_app_wipe=true" --tags web_app_wipe + +TASK [web_app : Log wipe completion] ok => {"msg": "Application devops-python has been wiped from lab-vm"} +PLAY RECAP: ok=6 changed=3 + +$ docker ps +CONTAINER ID IMAGE ... PORTS NAMES +fe40b3b5e476 ... 0.0.0.0:8001->5000/tcp devops-bonus +# → bonus app still running, python app removed +``` + +### Documentation + +**Why role reusability matters:** +Instead of writing a separate `app_deploy_python` and `app_deploy_bonus` role (duplicated code), we use one `web_app` role with different variables. If we need to change the deployment process (e.g., add health check config), we change it once and both apps benefit. + +**Port conflict resolution:** +Each app gets a unique host port (`5000`, `8001`) but can use the same internal container port (`5000`). This is the standard Docker pattern for running multiple services on one host. + +--- + +## Bonus Part 2: Multi-App CI/CD (1 pt) + +### Two Separate Workflows + +**`ansible-deploy.yml`** — triggers on Python app changes: +```yaml +paths: + - 'ansible/vars/app_python.yml' + - 'ansible/playbooks/deploy_python.yml' + - 'ansible/roles/web_app/**' +``` +Deploys: `deploy_python.yml` → verifies port 5000 + +**`ansible-deploy-bonus.yml`** — triggers on bonus app changes: +```yaml +paths: + - 'ansible/vars/app_bonus.yml' + - 'ansible/playbooks/deploy_bonus.yml' + - 'ansible/roles/web_app/**' +``` +Deploys: `deploy_bonus.yml` → verifies port 8001 + +### Path Filter Strategy + +- Change `vars/app_python.yml` → only `ansible-deploy.yml` triggers +- Change `vars/app_bonus.yml` → only `ansible-deploy-bonus.yml` triggers +- Change `roles/web_app/**` → BOTH workflows trigger (role is shared) + +This gives independent deployments when only one app changes, but both redeploy when shared role code changes. + +### Separate Workflows vs Matrix + +**Separate workflows** (our approach): More files but completely independent. You can have different secrets, different runners, different verification steps per app. + +**Matrix strategy** (alternative): Single file, deploys both apps in parallel. Simpler but less flexible — you can't easily have one app use staging and one use production settings. + +--- + +## Summary + +### What Was Built + +1. **Blocks & Tags** — Both `common` and `docker` roles refactored with logical grouping, error handling, and tag-based selective execution +2. **Docker Compose** — Migrated from `docker run` to templated `docker-compose.yml` with `community.docker.docker_compose_v2` +3. **Role Dependencies** — `web_app` role automatically installs Docker via `meta/main.yml` +4. **Wipe Logic** — Safe double-gated wipe with variable + tag, all 4 scenarios tested +5. **CI/CD** — Two GitHub Actions workflows with lint + deploy + verify, path filters for efficiency +6. **Bonus Multi-App** — Same role deploys two independent apps on different ports +7. **Bonus Multi-App CI/CD** — Independent workflows per app with shared role triggering both + +### Key Learnings + +- Blocks make Ansible playbooks much more readable and reliable with error handling +- Tags enable surgical execution — you can run only what you need without running everything +- Docker Compose is strictly better than `docker run` for repeatable deployments +- Role dependencies enforce correct execution order automatically +- The double-gate wipe pattern is a good safety pattern for any destructive operation +- Path filters in CI/CD save time and cost by only running relevant jobs diff --git a/ansible/docs/ci-bonus.png b/ansible/docs/ci-bonus.png new file mode 100644 index 0000000000..1d978c453d Binary files /dev/null and b/ansible/docs/ci-bonus.png differ diff --git a/ansible/docs/ci-main.png b/ansible/docs/ci-main.png new file mode 100644 index 0000000000..ae66a76753 Binary files /dev/null and b/ansible/docs/ci-main.png differ diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000000..7c66f4a036 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,21 @@ +$ANSIBLE_VAULT;1.1;AES256 +32386331623939663963666531666434613830323232613238396234643063373738613764303939 +6235346663643761326237373864353263323335336336360a656439343563613939353830393938 +61646430643262363831646434323437336463353434653263363836656362343630626663663338 +6431396336626134310a656264653638383235616338356233653864303863646162363236626436 +64333537303362616437306433666437393932613337626434646463643562646438373531306637 +62303261373964333532316139363165383961643065646261343066373938393337666630656261 +30356437376234393830616634306163376438313030326162366461663733643733653439366132 +66326637393065636631306261323630663533643535666162373433326230373066633430643737 +63636335623965626630373730623336373234623764383261366464316537363664613837363733 +32663866323638643537323530313732656566313263666561343937383139623166343339353532 +63666238633836316539383461663731656433313133366363396366653063393338333138616235 +30616538646264313935323738313035663762663435333135323733616437613734633164646463 +65356634613135393464623735346264623764376266336230373866623433366464373239303263 +63326137643636633763653239663937326339613632666566643433663436633162356535386330 +30333635636637333836383433663665353234306339343561656164653261383833643138323263 +61383963343537646563623764336265646238653838356230363839613432616231663630353965 +30353862623337313939353531356230353033616130663336313031363034333262333631636564 +30653366316365303161376336663863616134373831313432666165303061333565396438383966 +61303939643933303237636234643065333738343834316461363233333531616330363535643331 +31356231653239373031 diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml new file mode 100644 index 0000000000..7c66f4a036 --- /dev/null +++ b/ansible/inventory/group_vars/all.yml @@ -0,0 +1,21 @@ +$ANSIBLE_VAULT;1.1;AES256 +32386331623939663963666531666434613830323232613238396234643063373738613764303939 +6235346663643761326237373864353263323335336336360a656439343563613939353830393938 +61646430643262363831646434323437336463353434653263363836656362343630626663663338 +6431396336626134310a656264653638383235616338356233653864303863646162363236626436 +64333537303362616437306433666437393932613337626434646463643562646438373531306637 +62303261373964333532316139363165383961643065646261343066373938393337666630656261 +30356437376234393830616634306163376438313030326162366461663733643733653439366132 +66326637393065636631306261323630663533643535666162373433326230373066633430643737 +63636335623965626630373730623336373234623764383261366464316537363664613837363733 +32663866323638643537323530313732656566313263666561343937383139623166343339353532 +63666238633836316539383461663731656433313133366363396366653063393338333138616235 +30616538646264313935323738313035663762663435333135323733616437613734633164646463 +65356634613135393464623735346264623764376266336230373866623433366464373239303263 +63326137643636633763653239663937326339613632666566643433663436633162356535386330 +30333635636637333836383433663665353234306339343561656164653261383833643138323263 +61383963343537646563623764336265646238653838356230363839613432616231663630353965 +30353862623337313939353531356230353033616130663336313031363034333262333631636564 +30653366316365303161376336663863616134373831313432666165303061333565396438383966 +61303939643933303237636234643065333738343834316461363233333531616330363535643331 +31356231653239373031 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..630e4f8c1d --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,5 @@ +[webservers] +lab-vm ansible_host=93.77.185.109 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_ed25519 + +[webservers:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/inventory/yandex.yml b/ansible/inventory/yandex.yml new file mode 100644 index 0000000000..9bea87b325 --- /dev/null +++ b/ansible/inventory/yandex.yml @@ -0,0 +1,19 @@ +# Yandex Cloud Dynamic Inventory Configuration +# This file documents the dynamic inventory approach. +# The actual inventory script is: inventory/yandex_inventory.py +# +# Usage: +# ansible-inventory -i inventory/yandex_inventory.py --graph +# ansible all -i inventory/yandex_inventory.py -m ping +# ansible-playbook -i inventory/yandex_inventory.py playbooks/provision.yml +# +# Authentication: +# Uses service account key file: /home/blxxdclxud/yc-key.json +# Same key used by Terraform in Lab 4. +# +# How VMs are discovered: +# - Queries Yandex Cloud Compute API for folder_id: b1ga4ttr9f92otmhh4cc +# - Filters only RUNNING instances +# - Sets ansible_host to public NAT IP +# - Sets ansible_user to "ubuntu" +# - Groups VMs with label project=devops-lab04 into "webservers" group diff --git a/ansible/inventory/yandex_inventory.py b/ansible/inventory/yandex_inventory.py new file mode 100755 index 0000000000..ca49eec4f5 --- /dev/null +++ b/ansible/inventory/yandex_inventory.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Dynamic inventory script for Yandex Cloud. +Queries the Yandex Compute API and returns running VMs as Ansible inventory. + +Usage: + ansible -i inventory/yandex_inventory.py all -m ping + ansible-inventory -i inventory/yandex_inventory.py --graph +""" + +import json +import os +import sys + +import grpc +import yandexcloud +from yandex.cloud.compute.v1.instance_service_pb2 import ListInstancesRequest +from yandex.cloud.compute.v1.instance_service_pb2_grpc import InstanceServiceStub + +FOLDER_ID = "b1ga4ttr9f92otmhh4cc" +SERVICE_ACCOUNT_KEY_FILE = "/home/blxxdclxud/yc-key.json" +SSH_USER = "ubuntu" +SSH_KEY = "~/.ssh/id_ed25519" + + +def get_instances(): + with open(SERVICE_ACCOUNT_KEY_FILE) as f: + sa_key = json.load(f) + + sdk = yandexcloud.SDK(service_account_key=sa_key) + instance_service = sdk.client(InstanceServiceStub) + + response = instance_service.List(ListInstancesRequest(folder_id=FOLDER_ID)) + return [i for i in response.instances if i.status == 2] # 2 = RUNNING + + +def build_inventory(instances): + hostvars = {} + webservers = [] + + for instance in instances: + name = instance.name + public_ip = None + + for iface in instance.network_interfaces: + if iface.primary_v4_address.one_to_one_nat.address: + public_ip = iface.primary_v4_address.one_to_one_nat.address + break + + if not public_ip: + continue + + hostvars[name] = { + "ansible_host": public_ip, + "ansible_user": SSH_USER, + "ansible_ssh_private_key_file": SSH_KEY, + "yc_labels": dict(instance.labels), + } + + if instance.labels.get("project", "") == "devops-lab04": + webservers.append(name) + + return { + "all": { + "hosts": list(hostvars.keys()), + }, + "webservers": { + "hosts": webservers, + }, + "_meta": { + "hostvars": hostvars, + }, + } + + +if __name__ == "__main__": + if "--list" in sys.argv: + instances = get_instances() + inventory = build_inventory(instances) + print(json.dumps(inventory, indent=2)) + elif "--host" in sys.argv: + print(json.dumps({})) + else: + print(json.dumps({})) diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..f97709d60b --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,8 @@ +--- +- name: Deploy application + hosts: webservers + become: true + + roles: + - role: web_app + tags: app_deploy diff --git a/ansible/playbooks/deploy_all.yml b/ansible/playbooks/deploy_all.yml new file mode 100644 index 0000000000..da6ef2be41 --- /dev/null +++ b/ansible/playbooks/deploy_all.yml @@ -0,0 +1,27 @@ +--- +- name: Deploy All Applications + hosts: webservers + become: true + + tasks: + - name: Deploy Python App + ansible.builtin.include_role: + name: web_app + vars: + app_name: devops-python + docker_image: blxxdclxud/devops-info-service + docker_tag: latest + app_port: 5000 + app_internal_port: 5000 + compose_project_dir: /opt/devops-python + + - name: Deploy Bonus App + ansible.builtin.include_role: + name: web_app + vars: + app_name: devops-bonus + docker_image: blxxdclxud/devops-info-service + docker_tag: latest + app_port: 8001 + app_internal_port: 5000 + compose_project_dir: /opt/devops-bonus diff --git a/ansible/playbooks/deploy_bonus.yml b/ansible/playbooks/deploy_bonus.yml new file mode 100644 index 0000000000..0b6a21531c --- /dev/null +++ b/ansible/playbooks/deploy_bonus.yml @@ -0,0 +1,9 @@ +--- +- name: Deploy Bonus Application + hosts: webservers + become: true + vars_files: + - ../vars/app_bonus.yml + + roles: + - web_app diff --git a/ansible/playbooks/deploy_python.yml b/ansible/playbooks/deploy_python.yml new file mode 100644 index 0000000000..d9b839a229 --- /dev/null +++ b/ansible/playbooks/deploy_python.yml @@ -0,0 +1,9 @@ +--- +- name: Deploy Python Application + hosts: webservers + become: true + vars_files: + - ../vars/app_python.yml + + roles: + - web_app diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..28c8d10a7d --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,10 @@ +--- +- name: Provision web servers + hosts: webservers + become: true + + roles: + - role: common + tags: common + - role: docker + tags: docker diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000000..d42c17fd27 --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,9 @@ +--- +- name: Full site setup + hosts: webservers + become: yes + + roles: + - common + - docker + - app_deploy diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000000..15636b3060 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,10 @@ +--- +collections: + - name: community.docker + version: ">=3.0.0" + - name: community.general + version: ">=12.0.0" + - name: ansible.posix + version: ">=1.0.0" +# Updated Thu 5 Mar 23:56:58 MSK 2026 +# Updated diff --git a/ansible/roles/app_deploy/defaults/main.yml b/ansible/roles/app_deploy/defaults/main.yml new file mode 100644 index 0000000000..9ffffde25e --- /dev/null +++ b/ansible/roles/app_deploy/defaults/main.yml @@ -0,0 +1,4 @@ +--- +app_port: 5000 +app_restart_policy: unless-stopped +app_env_vars: {} diff --git a/ansible/roles/app_deploy/handlers/main.yml b/ansible/roles/app_deploy/handlers/main.yml new file mode 100644 index 0000000000..73deea15ef --- /dev/null +++ b/ansible/roles/app_deploy/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: restart app container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: started + restart: yes diff --git a/ansible/roles/app_deploy/tasks/main.yml b/ansible/roles/app_deploy/tasks/main.yml new file mode 100644 index 0000000000..9671a977a3 --- /dev/null +++ b/ansible/roles/app_deploy/tasks/main.yml @@ -0,0 +1,43 @@ +--- +- name: Log in to Docker Hub + community.docker.docker_login: + username: "{{ dockerhub_username }}" + password: "{{ dockerhub_password }}" + no_log: true + +- name: Pull Docker image + community.docker.docker_image: + name: "{{ docker_image }}:{{ docker_image_tag }}" + source: pull + +- name: Remove old container if exists + community.docker.docker_container: + name: "{{ app_container_name }}" + state: absent + +- name: Run application container + community.docker.docker_container: + name: "{{ app_container_name }}" + image: "{{ docker_image }}:{{ docker_image_tag }}" + state: started + restart_policy: "{{ app_restart_policy }}" + ports: + - "{{ app_port }}:{{ app_port }}" + env: "{{ app_env_vars }}" + +- name: Wait for application to be ready + wait_for: + port: "{{ app_port }}" + host: localhost + delay: 3 + timeout: 30 + +- name: Verify health endpoint + uri: + url: "http://localhost:{{ app_port }}/health" + status_code: 200 + register: health_check + +- name: Print health check result + debug: + msg: "App is healthy: {{ health_check.status }}" diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..802083bd51 --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,11 @@ +--- +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - wget + - unzip + +common_timezone: "Europe/Moscow" diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..35789d41ad --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,51 @@ +--- +# Package installation block - grouped for error handling and tag-based execution +- name: Install system packages + become: true + tags: + - packages + block: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + rescue: + # Runs only if block fails (e.g. network issue during apt update) + - name: Retry apt update with fix-missing flag + ansible.builtin.apt: + update_cache: true + cache_valid_time: 0 + environment: + DEBIAN_FRONTEND: noninteractive + + - name: Install common packages (retry after fix) + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + always: + # Always log completion regardless of success or failure + - name: Log package installation completion + ansible.builtin.debug: + msg: "Package installation block completed for {{ inventory_hostname }}" + +# User/timezone configuration block +- name: Configure system settings + become: true + tags: + - users + block: + - name: Set timezone + community.general.timezone: + name: "{{ common_timezone }}" + + always: + - name: Log system configuration completion + ansible.builtin.debug: + msg: "System configuration completed for {{ inventory_hostname }}" diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..e64d3b7e66 --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,2 @@ +--- +docker_user: ubuntu diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..07aa0eb290 --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart docker + ansible.builtin.service: + name: docker + state: restarted diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..fa9213c281 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,99 @@ +--- +# Docker installation block - all packages and repo setup together +- name: Install Docker + become: true + tags: + - docker + - docker_install + block: + - name: Install required packages for Docker repo + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + state: present + update_cache: true + + - name: Create keyrings directory + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + + - name: Add Docker GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + + - name: Add Docker repository + ansible.builtin.apt_repository: + repo: >- + deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu + {{ ansible_distribution_release }} stable + state: present + filename: docker + + - name: Install Docker packages + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + update_cache: true + notify: Restart docker + + rescue: + # If GPG key download fails (e.g. network timeout), wait and retry + - name: Wait before retrying GPG key download + ansible.builtin.pause: + seconds: 10 + + - name: Retry Docker GPG key download + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + force: true + + - name: Retry Docker package installation + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + update_cache: true + + always: + # Ensure Docker service is running no matter what + - name: Ensure Docker service is started and enabled + ansible.builtin.service: + name: docker + state: started + enabled: true + +# Docker configuration block - user and python bindings +- name: Configure Docker + become: true + tags: + - docker + - docker_config + block: + - name: Add user to docker group + ansible.builtin.user: + name: "{{ docker_user }}" + groups: docker + append: true + + - name: Install python3-docker for Ansible docker modules + ansible.builtin.apt: + name: python3-docker + state: present diff --git a/ansible/roles/web_app/defaults/main.yml b/ansible/roles/web_app/defaults/main.yml new file mode 100644 index 0000000000..2be91ce88b --- /dev/null +++ b/ansible/roles/web_app/defaults/main.yml @@ -0,0 +1,17 @@ +--- +# Web app deployment defaults +# Override these in vars_files or group_vars for specific apps + +app_name: devops-info-service +docker_image: blxxdclxud/devops-info-service +docker_tag: latest +app_port: 5000 +app_internal_port: 5000 +compose_project_dir: "/opt/{{ app_name }}" + +# Wipe Logic Control +# Default: do NOT wipe - prevents accidental deletion +# Set to true to remove application completely +# Wipe only: ansible-playbook deploy.yml -e "web_app_wipe=true" --tags web_app_wipe +# Clean install: ansible-playbook deploy.yml -e "web_app_wipe=true" +web_app_wipe: false diff --git a/ansible/roles/web_app/handlers/main.yml b/ansible/roles/web_app/handlers/main.yml new file mode 100644 index 0000000000..57f4770658 --- /dev/null +++ b/ansible/roles/web_app/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart web app + become: true + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + state: present + recreate: always diff --git a/ansible/roles/web_app/meta/main.yml b/ansible/roles/web_app/meta/main.yml new file mode 100644 index 0000000000..e4b0a29ad1 --- /dev/null +++ b/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,5 @@ +--- +# Role dependencies - docker role must run before web_app +# This ensures Docker is installed and running before we try to deploy containers +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..4fad973a06 --- /dev/null +++ b/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,54 @@ +--- +# Wipe logic runs FIRST so clean reinstall works: wipe old → deploy new +- name: Include wipe tasks + ansible.builtin.include_tasks: wipe.yml + tags: + - web_app_wipe + +# Main deployment block - grouped for error handling +- name: Deploy application with Docker Compose + become: true + tags: + - app_deploy + - compose + block: + - name: Create app directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: directory + mode: "0755" + + - name: Template docker-compose.yml to server + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ compose_project_dir }}/docker-compose.yml" + mode: "0644" + + - name: Deploy with Docker Compose + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + state: present + pull: always + # pull: always ensures we always get the latest image tag + + - name: Wait for application to be ready + ansible.builtin.wait_for: + port: "{{ app_port }}" + host: localhost + delay: 3 + timeout: 30 + + - name: Verify health endpoint + ansible.builtin.uri: + url: "http://localhost:{{ app_port }}/health" + status_code: 200 + register: web_app_health_check + + - name: Print health check result + ansible.builtin.debug: + msg: "App {{ app_name }} is healthy. Status: {{ web_app_health_check.status }}" + + rescue: + - name: Handle deployment failure + ansible.builtin.debug: + msg: "Deployment of {{ app_name }} failed. Check docker-compose.yml at {{ compose_project_dir }}" diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..5304d8e5d4 --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,31 @@ +--- +# Wipe tasks - only run when web_app_wipe=true AND --tags web_app_wipe +# Double-gating: variable check (when) + tag requirement +# This prevents accidental wipe during normal deployments + +- name: Wipe web application + when: web_app_wipe | bool + tags: + - web_app_wipe + block: + - name: Stop and remove containers with Docker Compose + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + state: absent + remove_orphans: true + failed_when: false + # failed_when: false because the directory may not exist yet (already wiped) + + - name: Remove docker-compose file + ansible.builtin.file: + path: "{{ compose_project_dir }}/docker-compose.yml" + state: absent + + - name: Remove application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: absent + + - name: Log wipe completion + ansible.builtin.debug: + msg: "Application {{ app_name }} has been wiped from {{ inventory_hostname }}" 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..7ebe301bf1 --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,14 @@ +# Generated by Ansible - do not edit manually +# Template: roles/web_app/templates/docker-compose.yml.j2 + +services: + {{ app_name }}: + image: {{ docker_image }}:{{ docker_tag }} + container_name: {{ app_name }} + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + APP_ENV: production + restart: unless-stopped + # unless-stopped: restarts always except when manually stopped + # This is better than 'always' because manual docker stop is respected diff --git a/ansible/vars/app_bonus.yml b/ansible/vars/app_bonus.yml new file mode 100644 index 0000000000..27578e4b02 --- /dev/null +++ b/ansible/vars/app_bonus.yml @@ -0,0 +1,10 @@ +--- +# Bonus app deployment variables (Go/Rust/compiled app) +# Uses the same Python image on a different port for demo purposes +# In production this would point to a compiled language image from lab01 bonus +app_name: devops-bonus +docker_image: blxxdclxud/devops-info-service +docker_tag: latest +app_port: 8001 +app_internal_port: 5000 +compose_project_dir: "/opt/devops-bonus" diff --git a/ansible/vars/app_python.yml b/ansible/vars/app_python.yml new file mode 100644 index 0000000000..875b63ac6d --- /dev/null +++ b/ansible/vars/app_python.yml @@ -0,0 +1,8 @@ +--- +# Python app deployment variables +app_name: devops-python +docker_image: blxxdclxud/devops-info-service +docker_tag: latest +app_port: 5000 +app_internal_port: 5000 +compose_project_dir: "/opt/devops-python" diff --git a/docs/LAB04.md b/docs/LAB04.md new file mode 100644 index 0000000000..c8e021d48d --- /dev/null +++ b/docs/LAB04.md @@ -0,0 +1,474 @@ +# Lab 04 — Infrastructure as Code (Terraform & Pulumi) + +## 1. Cloud Provider & Infrastructure + +**Cloud Provider:** Yandex Cloud + +**Why:** Yandex Cloud is recommended by the course for Russia. It has a free trial grant, good Terraform/Pulumi support, and does not require VPN. + +**Instance Configuration:** +- Platform: standard-v2 +- vCPU: 2 (core fraction 20%) +- RAM: 1 GB +- Disk: 10 GB HDD +- OS: Ubuntu 24.04 LTS +- Zone: ru-central1-a + +**Total cost:** 0₽ (free grant) + +**Resources created:** +- VPC Network (`lab-network`) +- Subnet (`lab-subnet`, 10.0.1.0/24) +- Security Group (SSH port 22, HTTP port 80, app port 5000) +- Compute Instance (`lab-vm` with public IP) + +--- + +## 2. Terraform Implementation + +**Terraform version:** 1.9+ + +**Project structure:** +``` +terraform/ +├── .gitignore # Ignores state, credentials, .terraform/ +├── main.tf # Provider, network, subnet, security group, VM +├── variables.tf # Input variables (cloud_id, folder_id, zone, etc.) +├── outputs.tf # VM public IP, VM ID, SSH command +└── terraform.tfvars # Actual values (gitignored) +``` + +**Key decisions:** +- Used variables for all configurable values so nothing is hardcoded +- Used outputs to display public IP and SSH command after apply +- Used `.gitignore` to keep secrets and state out of git +- Used labels for resource identification +- Security group allows only required ports (22, 80, 5000) + +**Challenges:** +- Had to find the correct Ubuntu 24.04 image ID for Yandex Cloud +- Needed to set up service account and authorized key for authentication + +### Terminal Output + +Initializing the backend... +Initializing provider plugins... +- Finding latest version of yandex-cloud/yandex... +- Installing yandex-cloud/yandex v0.186.0... +- Installed yandex-cloud/yandex v0.186.0 (unauthenticated) + +Terraform has been successfully initialized! + +Terraform used the selected providers to generate the following execution plan. Resource actions +are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # yandex_compute_instance.lab_vm will be created + + resource "yandex_compute_instance" "lab_vm" { + + created_at = (known after apply) + + folder_id = (known after apply) + + fqdn = (known after apply) + + gpu_cluster_id = (known after apply) + + hardware_generation = (known after apply) + + hostname = (known after apply) + + id = (known after apply) + + labels = { + + "project" = "devops-lab04" + + "task" = "terraform" + } + + metadata = { + + "ssh-keys" = <<-EOT + ubuntu:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKfBnyjaKsKyiGkHXoSmRrJW1zewQEhVJxjqrKrRT11r ramazanatzuf10@gmail.com + EOT + } + + name = "lab-vm" + + network_acceleration_type = "standard" + + platform_id = "standard-v2" + + status = (known after apply) + + zone = "ru-central1-a" + + + boot_disk { + + auto_delete = true + + device_name = (known after apply) + + disk_id = (known after apply) + + mode = (known after apply) + + + initialize_params { + + block_size = (known after apply) + + description = (known after apply) + + image_id = "fd8p685sjqdraf7mpkuc" + + name = (known after apply) + + size = 10 + + snapshot_id = (known after apply) + + type = "network-hdd" + } + } + + + metadata_options (known after apply) + + + network_interface { + + index = (known after apply) + + ip_address = (known after apply) + + ipv4 = true + + ipv6 = (known after apply) + + ipv6_address = (known after apply) + + mac_address = (known after apply) + + nat = true + + nat_ip_address = (known after apply) + + nat_ip_version = (known after apply) + + security_group_ids = (known after apply) + + subnet_id = (known after apply) + } + + + resources { + + core_fraction = 20 + + cores = 2 + + memory = 1 + } + } + + # yandex_vpc_network.lab_network will be created + + resource "yandex_vpc_network" "lab_network" { + + created_at = (known after apply) + + default_security_group_id = (known after apply) + + folder_id = (known after apply) + + id = (known after apply) + + labels = (known after apply) + + name = "lab-network" + + subnet_ids = (known after apply) + } + + # yandex_vpc_security_group.lab_sg will be created + + resource "yandex_vpc_security_group" "lab_sg" { + + created_at = (known after apply) + + folder_id = (known after apply) + + id = (known after apply) + + labels = (known after apply) + + name = "lab-security-group" + + network_id = (known after apply) + + status = (known after apply) + + + egress { + + description = "Allow all outbound" + + from_port = -1 + + id = (known after apply) + + labels = (known after apply) + + port = -1 + + protocol = "ANY" + + to_port = -1 + + v4_cidr_blocks = [ + + "0.0.0.0/0", + ] + } + + + ingress { + + description = "Allow HTTP" + + from_port = -1 + + id = (known after apply) + + labels = (known after apply) + + port = 80 + + protocol = "TCP" + + to_port = -1 + + v4_cidr_blocks = [ + + "0.0.0.0/0", + ] + } + + ingress { + + description = "Allow SSH" + + from_port = -1 + + id = (known after apply) + + labels = (known after apply) + + port = 22 + + protocol = "TCP" + + to_port = -1 + + v4_cidr_blocks = [ + + "0.0.0.0/0", + ] + } + + ingress { + + description = "Allow app port 5000" + + from_port = -1 + + id = (known after apply) + + labels = (known after apply) + + port = 5000 + + protocol = "TCP" + + to_port = -1 + + v4_cidr_blocks = [ + + "0.0.0.0/0", + ] + } + } + + # yandex_vpc_subnet.lab_subnet will be created + + resource "yandex_vpc_subnet" "lab_subnet" { + + created_at = (known after apply) + + folder_id = (known after apply) + + id = (known after apply) + + labels = (known after apply) + + name = "lab-subnet" + + network_id = (known after apply) + + v4_cidr_blocks = [ + + "10.0.1.0/24", + ] + + zone = "ru-central1-a" + } + +Plan: 4 to add, 0 to change, 0 to destroy. + +yandex_vpc_network.lab_network: Creating... +yandex_vpc_network.lab_network: Creation complete after 14s [id=enptvj5mcvlei7hrv83s] +yandex_vpc_subnet.lab_subnet: Creating... +yandex_vpc_security_group.lab_sg: Creating... +yandex_vpc_subnet.lab_subnet: Creation complete after 1s [id=e9b8rovrospd6deptkhc] +yandex_vpc_security_group.lab_sg: Creation complete after 4s [id=enp9sdve5bv9nuua560j] +yandex_compute_instance.lab_vm: Creating... +yandex_compute_instance.lab_vm: Creation complete after 45s [id=fhmkce8lk639oi5g0s9n] + +Apply complete! Resources: 4 added, 0 changed, 0 destroyed. + +Outputs: + +ssh_connection = "ssh -i ~/.ssh/id_ed25519 ubuntu@89.169.135.233" +vm_id = "fhmkce8lk639oi5g0s9n" +vm_public_ip = "89.169.135.233" +``` + +**SSH connection proof:** +![alt text](image.png) + +--- + +## 3. Pulumi Implementation + +**Pulumi version:** 3.x +**Language:** Python + +**Project structure:** +``` +pulumi/ +├── .gitignore # Ignores venv/, stack configs, __pycache__/ +├── __main__.py # Main infrastructure code (same as Terraform) +├── Pulumi.yaml # Project metadata +└── requirements.txt # Python dependencies +``` + +**How code differs from Terraform:** +- Written in Python instead of HCL +- Resources are Python objects, not HCL blocks +- Configuration uses `pulumi.Config()` instead of `variable` blocks +- Outputs use `pulumi.export()` instead of `output` blocks +- Can use normal Python features (file reading, string formatting) + +**Advantages discovered:** +- Familiar Python syntax, easier to read +- Can use regular Python code for logic (reading SSH key file, etc.) +- Better IDE support with autocomplete and type checking +- Secrets are encrypted by default + +**Challenges:** +- Pulumi requires a backend for state (used `--local` for simplicity) +- Python virtual environment setup adds extra steps +- Smaller community, fewer examples online + +### Terminal Output + +**terraform destroy (cleanup before Pulumi):** +``` +Plan: 0 to add, 0 to change, 4 to destroy. + +Changes to Outputs: + - ssh_connection = "ssh -i ~/.ssh/id_ed25519 ubuntu@89.169.135.233" -> null + - vm_id = "fhmkce8lk639oi5g0s9n" -> null + - vm_public_ip = "89.169.135.233" -> null +yandex_compute_instance.lab_vm: Destroying... [id=fhmkce8lk639oi5g0s9n] +yandex_compute_instance.lab_vm: Still destroying... [id=fhmkce8lk639oi5g0s9n, 00m10s elapsed] +yandex_compute_instance.lab_vm: Still destroying... [id=fhmkce8lk639oi5g0s9n, 00m20s elapsed] +yandex_compute_instance.lab_vm: Still destroying... [id=fhmkce8lk639oi5g0s9n, 00m30s elapsed] +yandex_compute_instance.lab_vm: Destruction complete after 37s +yandex_vpc_subnet.lab_subnet: Destroying... [id=e9b8rovrospd6deptkhc] +yandex_vpc_security_group.lab_sg: Destroying... [id=enp9sdve5bv9nuua560j] +yandex_vpc_security_group.lab_sg: Destruction complete after 1s +yandex_vpc_subnet.lab_subnet: Destruction complete after 5s +yandex_vpc_network.lab_network: Destroying... [id=enptvj5mcvlei7hrv83s] +yandex_vpc_network.lab_network: Destruction complete after 1s + +Destroy complete! Resources: 4 destroyed. + +``` + +**pulumi preview:** +``` +Previewing update (dev): + Type Name Plan Info + pulumi:pulumi:Stack lab04-pulumi-dev 1 message + +Diagnostics: + pulumi:pulumi:Stack (lab04-pulumi-dev): + DEBUG: Using Python: /home/blxxdclxud/assignments/DevOps-Core-Course/pulumi/venv/bin/python3 + +Resources: + 5 unchanged + +``` + +Current stack is dev: + Managed by blxxdclxud-BOM-WXX9 + Last updated: 21 seconds ago (2026-02-15 23:54:56.071594762 +0300 MSK) + Pulumi version used: v3.220.0 + +Current stack resources (6): + TYPE NAME + pulumi:pulumi:Stack lab04-pulumi-dev + ├─ yandex:index/vpcNetwork:VpcNetwork lab-network + ├─ yandex:index/vpcSubnet:VpcSubnet lab-subnet + ├─ yandex:index/vpcSecurityGroup:VpcSecurityGroup lab-security-group + ├─ yandex:index/computeInstance:ComputeInstance lab-vm + └─ pulumi:providers:yandex default_0_13_0 + +Current stack outputs (3): + OUTPUT VALUE + ssh_connection ssh -i ~/.ssh/id_ed25519 ubuntu@89.169.135.37 + vm_id fhm49b6u8mk4vvd265sk + vm_public_ip 89.169.135.37 +``` + +**SSH connection proof:** +![alt text](image-1.png) + +--- + +## 4. Terraform vs Pulumi Comparison + +**Ease of Learning:** Terraform was easier to learn because HCL syntax is simple and there are many examples online. Pulumi requires you to know a programming language, but if you already know Python, it feels more natural. + +**Code Readability:** I find Pulumi more readable because it is regular Python code. Terraform HCL is also readable but has its own special syntax that you need to learn. For simple infrastructure both look clean. + +**Debugging:** Terraform was easier to debug because error messages are clear and `terraform plan` shows exactly what will happen. Pulumi errors sometimes mix Python errors with infrastructure errors which can be confusing. + +**Documentation:** Terraform has better documentation because it has a bigger community. The Terraform Registry has detailed docs for every provider. Pulumi docs are good but have fewer examples. + +**Use Case:** I would use Terraform for simple, standard infrastructure where I don't need complex logic. I would use Pulumi for projects where I need loops, conditions, or want to reuse code with functions and classes. + +--- + +## 5. Lab 5 Preparation & Cleanup + +**VM for Lab 5:** I will recreate the VM using Terraform code when needed for Lab 5. The code is ready in the repository. + +**Cleanup Status:** All resources were destroyed after completing the lab. + +``` +Previewing destroy (dev): + Type Name Plan + - pulumi:pulumi:Stack lab04-pulumi-dev delete + - ├─ yandex:index:VpcSubnet lab-subnet delete + - ├─ yandex:index:VpcSecurityGroup lab-security-group delete + - ├─ yandex:index:VpcNetwork lab-network delete + - └─ yandex:index:ComputeInstance lab-vm delete + +Outputs: + - ssh_connection: "ssh -i ~/.ssh/id_ed25519 ubuntu@89.169.135.37" + - vm_id : "fhm49b6u8mk4vvd265sk" + - vm_public_ip : "89.169.135.37" + +Resources: + - 5 to delete + +Destroying (dev): + Type Name Status + - pulumi:pulumi:Stack lab04-pulumi-dev deleted (0.00s) + - ├─ yandex:index:ComputeInstance lab-vm deleted (38s) + - ├─ yandex:index:VpcSubnet lab-subnet deleted (5s) + - ├─ yandex:index:VpcSecurityGroup lab-security-group deleted (1s) + - └─ yandex:index:VpcNetwork lab-network deleted (0.56s) + +Outputs: + - ssh_connection: "ssh -i ~/.ssh/id_ed25519 ubuntu@89.169.135.37" + - vm_id : "fhm49b6u8mk4vvd265sk" + - vm_public_ip : "89.169.135.37" + +Resources: + - 5 deleted + +Duration: 46s + +``` + +--- + +## Bonus: IaC CI/CD + +Created `.github/workflows/terraform-ci.yml` that automatically validates Terraform code on pull requests. + +**Path filters:** The workflow only triggers on changes to `terraform/**` files and the workflow file itself. This prevents unnecessary CI runs when other files change. + +**Steps:** +1. `terraform fmt -check` — checks code formatting +2. `terraform init -backend=false` — initializes without backend (no credentials needed) +3. `terraform validate` — checks syntax and configuration +4. `tflint` — lints for best practices and common errors + +**Workflow run proof:** +![alt text](image-2.png) +--- + +## Bonus: GitHub Repository Import + +### What is `terraform import`? + +`terraform import` lets you bring existing infrastructure under Terraform management. This is useful when you have resources that were created manually (through web console or CLI) and you want to manage them with code now. + +### Import Process + +1. Created `terraform/github/main.tf` with GitHub provider and `github_repository` resource +2. Ran `terraform init` to install the GitHub provider +3. Ran `terraform import github_repository.course_repo DevOps-Core-Course` +4. Ran `terraform plan` to verify state matches reality + +### Terminal Output + +**terraform import:** +``` +$ terraform import github_repository.course_repo DevOps-Core-Course + +github_repository.course_repo: Importing from ID "DevOps-Core-Course"... +github_repository.course_repo: Import prepared! + Prepared github_repository for import +github_repository.course_repo: Refreshing state... [id=DevOps-Core-Course] + +Import successful! + +The resources that were imported are shown above. These resources are now in +your Terraform state and will henceforth be managed by Terraform. +``` + +**terraform plan (after import):** +``` +Terraform will perform the following actions: + + # github_repository.course_repo will be updated in-place + ~ resource "github_repository" "course_repo" { + ~ description = "🚀Production-grade DevOps course..." -> "DevOps Core Course lab assignments" + - has_downloads = true -> null + ~ has_issues = false -> true + ~ has_projects = true -> false + ~ has_wiki = true -> false + id = "DevOps-Core-Course" + name = "DevOps-Core-Course" + } + +Plan: 0 to add, 1 to change, 0 to destroy. +``` + +**Drift Analysis:** The plan shows that the actual repository state (with wiki/projects enabled) differs from our minimal Terraform configuration. To sync them, we would either update our `main.tf` to match reality or `apply` to enforce the new configuration. This demonstrates how Terraform detects configuration drift. + +### Why Importing Matters + +- **Version control:** Track all changes to infrastructure in Git +- **Consistency:** Prevent configuration drift — everyone sees the same config +- **Automation:** Changes go through code review before applying +- **Documentation:** Code is living documentation of your infrastructure +- **Disaster recovery:** Can recreate everything from code if something breaks +- **Team collaboration:** Multiple people can work on infrastructure without conflicts diff --git a/docs/image-1.png b/docs/image-1.png new file mode 100644 index 0000000000..f9e45b6df0 Binary files /dev/null and b/docs/image-1.png differ diff --git a/docs/image-2.png b/docs/image-2.png new file mode 100644 index 0000000000..ff142a2652 Binary files /dev/null and b/docs/image-2.png differ diff --git a/docs/image.png b/docs/image.png new file mode 100644 index 0000000000..52c3b35b0d Binary files /dev/null and b/docs/image.png differ diff --git a/pulumi/.gitignore b/pulumi/.gitignore new file mode 100644 index 0000000000..274d25ec36 --- /dev/null +++ b/pulumi/.gitignore @@ -0,0 +1,4 @@ +venv/ +__pycache__/ +*.pyc +Pulumi.*.yaml diff --git a/pulumi/Pulumi.yaml b/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..e79f304ef7 --- /dev/null +++ b/pulumi/Pulumi.yaml @@ -0,0 +1,4 @@ +name: lab04-pulumi +runtime: + name: python +description: Lab 04 - Yandex Cloud VM with Pulumi diff --git a/pulumi/__main__.py b/pulumi/__main__.py new file mode 100644 index 0000000000..34157f721f --- /dev/null +++ b/pulumi/__main__.py @@ -0,0 +1,92 @@ +import sys +print(f"DEBUG: Using Python: {sys.executable}") +# print(f"DEBUG: Sys Path: {sys.path}") + +import pulumi +import pulumi_yandex as yandex + +config = pulumi.Config() +folder_id = config.require("folder_id") +zone = config.get("zone") or "ru-central1-a" +image_id = config.get("image_id") or "fd8p685sjqdraf7mpkuc" +ssh_public_key_path = config.get("ssh_public_key_path") or "~/.ssh/id_ed25519.pub" + +import os +ssh_key_path = os.path.expanduser(ssh_public_key_path) +with open(ssh_key_path, "r") as f: + ssh_public_key = f.read().strip() + +# Network +network = yandex.VpcNetwork("lab-network") + +# Subnet +subnet = yandex.VpcSubnet("lab-subnet", + zone=zone, + network_id=network.id, + v4_cidr_blocks=["10.0.1.0/24"]) + +# Security group +security_group = yandex.VpcSecurityGroup("lab-security-group", + network_id=network.id, + ingresses=[ + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + port=22, + v4_cidr_blocks=["0.0.0.0/0"], + description="Allow SSH", + ), + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + port=80, + v4_cidr_blocks=["0.0.0.0/0"], + description="Allow HTTP", + ), + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + port=5000, + v4_cidr_blocks=["0.0.0.0/0"], + description="Allow app port 5000", + ), + ], + egresses=[ + yandex.VpcSecurityGroupEgressArgs( + protocol="ANY", + v4_cidr_blocks=["0.0.0.0/0"], + description="Allow all outbound", + ), + ]) + +# VM (free tier: 2 vCPU 20%, 1GB RAM, 10GB HDD) +instance = yandex.ComputeInstance("lab-vm", + platform_id="standard-v2", + zone=zone, + resources=yandex.ComputeInstanceResourcesArgs( + cores=2, + memory=1, + core_fraction=20, + ), + boot_disk=yandex.ComputeInstanceBootDiskArgs( + initialize_params=yandex.ComputeInstanceBootDiskInitializeParamsArgs( + image_id=image_id, + size=10, + type="network-hdd", + ), + ), + network_interfaces=[yandex.ComputeInstanceNetworkInterfaceArgs( + subnet_id=subnet.id, + nat=True, + security_group_ids=[security_group.id], + )], + metadata={ + "ssh-keys": f"ubuntu:{ssh_public_key}", + }, + labels={ + "project": "devops-lab04", + "task": "pulumi", + }) + +pulumi.export("vm_public_ip", instance.network_interfaces[0].nat_ip_address) +pulumi.export("vm_id", instance.id) +pulumi.export("ssh_connection", + instance.network_interfaces[0].nat_ip_address.apply( + lambda ip: f"ssh -i ~/.ssh/id_ed25519 ubuntu@{ip}")) diff --git a/pulumi/requirements.txt b/pulumi/requirements.txt new file mode 100644 index 0000000000..6e47e4b09a --- /dev/null +++ b/pulumi/requirements.txt @@ -0,0 +1,3 @@ +pulumi>=3.0.0 +pulumi-yandex>=0.13.0 +setuptools==69.5.1 diff --git a/pulumi/up.log b/pulumi/up.log new file mode 100644 index 0000000000..c90db7258f --- /dev/null +++ b/pulumi/up.log @@ -0,0 +1,53 @@ +Previewing update (dev): + +@ previewing update.... + + pulumi:pulumi:Stack lab04-pulumi-dev create + + yandex:index:VpcNetwork lab-network create + + yandex:index:VpcSubnet lab-subnet create + + yandex:index:VpcSecurityGroup lab-security-group create + + yandex:index:ComputeInstance lab-vm create + + pulumi:pulumi:Stack lab04-pulumi-dev create DEBUG: Using Python: /home/blxxdclxud/assignments/DevOps-Core-Course/pulumi/venv/bin/python3 + + pulumi:pulumi:Stack lab04-pulumi-dev create 1 message +Diagnostics: + pulumi:pulumi:Stack (lab04-pulumi-dev): + DEBUG: Using Python: /home/blxxdclxud/assignments/DevOps-Core-Course/pulumi/venv/bin/python3 + +Outputs: + ssh_connection: [unknown] + vm_id : [unknown] + vm_public_ip : [unknown] + +Resources: + + 5 to create + +Updating (dev): + +@ updating.... + + pulumi:pulumi:Stack lab04-pulumi-dev creating (0s) + + yandex:index:VpcNetwork lab-network creating (0s) +@ updating........ + + yandex:index:VpcNetwork lab-network created (5s) + + yandex:index:VpcSubnet lab-subnet creating (0s) + + yandex:index:VpcSecurityGroup lab-security-group creating (0s) +@ updating.... + + yandex:index:VpcSubnet lab-subnet created (0.69s) +@ updating..... + + yandex:index:VpcSecurityGroup lab-security-group created (2s) + + yandex:index:ComputeInstance lab-vm creating (0s) +^C received; cancelling. If you would like to terminate immediately, press ^C again. +Note that terminating immediately may lead to orphaned resources and other inconsistencies. + +@ updating........................................... + + yandex:index:ComputeInstance lab-vm created (40s) + + pulumi:pulumi:Stack lab04-pulumi-dev creating (48s) error: update canceled + + pulumi:pulumi:Stack lab04-pulumi-dev **creating failed** 1 error +Diagnostics: + pulumi:pulumi:Stack (lab04-pulumi-dev): + error: update canceled + +Resources: + + 5 created + 1 errored + +Duration: 50s + diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000000..9a7b5a76e8 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,10 @@ +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +terraform.tfvars +*.tfvars +crash.log +*.pem +*.key +*.json diff --git a/terraform/github/.gitignore b/terraform/github/.gitignore new file mode 100644 index 0000000000..491a4e6e8b --- /dev/null +++ b/terraform/github/.gitignore @@ -0,0 +1,6 @@ +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +terraform.tfvars +*.tfvars diff --git a/terraform/github/main.tf b/terraform/github/main.tf new file mode 100644 index 0000000000..49aae1b204 --- /dev/null +++ b/terraform/github/main.tf @@ -0,0 +1,26 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 5.0" + } + } +} + +provider "github" { + token = var.github_token +} + +resource "github_repository" "course_repo" { + name = "DevOps-Core-Course" + description = "DevOps Core Course lab assignments" + visibility = "public" + + has_issues = true + has_wiki = false + has_projects = false + + allow_merge_commit = true + allow_squash_merge = true + allow_rebase_merge = true +} diff --git a/terraform/github/variables.tf b/terraform/github/variables.tf new file mode 100644 index 0000000000..ac03f764b0 --- /dev/null +++ b/terraform/github/variables.tf @@ -0,0 +1,5 @@ +variable "github_token" { + description = "GitHub Personal Access Token" + type = string + sensitive = true +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..31c7769ff2 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,98 @@ +terraform { + required_providers { + yandex = { + source = "yandex-cloud/yandex" + version = ">= 0.87.0" + } + } + required_version = ">= 1.0" +} + +provider "yandex" { + service_account_key_file = var.service_account_key_file + cloud_id = var.cloud_id + folder_id = var.folder_id + zone = var.zone +} + +# Network +resource "yandex_vpc_network" "lab_network" { + name = "lab-network" +} + +# Subnet +resource "yandex_vpc_subnet" "lab_subnet" { + name = "lab-subnet" + zone = var.zone + network_id = yandex_vpc_network.lab_network.id + v4_cidr_blocks = ["10.0.1.0/24"] +} + +# Security group +resource "yandex_vpc_security_group" "lab_sg" { + name = "lab-security-group" + network_id = yandex_vpc_network.lab_network.id + + ingress { + protocol = "TCP" + port = 22 + v4_cidr_blocks = ["0.0.0.0/0"] + description = "Allow SSH" + } + + ingress { + protocol = "TCP" + port = 80 + v4_cidr_blocks = ["0.0.0.0/0"] + description = "Allow HTTP" + } + + ingress { + protocol = "TCP" + port = 5000 + v4_cidr_blocks = ["0.0.0.0/0"] + description = "Allow app port 5000" + } + + egress { + protocol = "ANY" + v4_cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound" + } +} + +# Compute instance (free tier: 2 vCPU 20%, 1GB RAM, 10GB HDD) +resource "yandex_compute_instance" "lab_vm" { + name = "lab-vm" + platform_id = "standard-v2" + zone = var.zone + + resources { + cores = 2 + memory = 1 + core_fraction = 20 + } + + boot_disk { + initialize_params { + image_id = var.image_id + size = 10 + type = "network-hdd" + } + } + + network_interface { + subnet_id = yandex_vpc_subnet.lab_subnet.id + nat = true + security_group_ids = [yandex_vpc_security_group.lab_sg.id] + } + + metadata = { + ssh-keys = "ubuntu:${file(var.ssh_public_key_path)}" + } + + labels = { + project = "devops-lab04" + task = "terraform" + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..9668d66fac --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,14 @@ +output "vm_public_ip" { + description = "Public IP address of the VM" + value = yandex_compute_instance.lab_vm.network_interface[0].nat_ip_address +} + +output "vm_id" { + description = "ID of the created VM" + value = yandex_compute_instance.lab_vm.id +} + +output "ssh_connection" { + description = "SSH connection command" + value = "ssh -i ~/.ssh/id_ed25519 ubuntu@${yandex_compute_instance.lab_vm.network_interface[0].nat_ip_address}" +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..c319aed35c --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,33 @@ +variable "cloud_id" { + description = "Yandex Cloud ID" + type = string +} + +variable "folder_id" { + description = "Yandex Cloud Folder ID" + type = string +} + +variable "zone" { + description = "Yandex Cloud zone" + type = string + default = "ru-central1-a" +} + +variable "service_account_key_file" { + description = "Path to service account key JSON file" + type = string + default = "~/yc-key.json" +} + +variable "ssh_public_key_path" { + description = "Path to SSH public key" + type = string + default = "~/.ssh/id_ed25519.pub" +} + +variable "image_id" { + description = "Boot disk image ID (Ubuntu 24.04 LTS)" + type = string + default = "fd8p685sjqdraf7mpkuc" +}